1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
use std::io::Read;
use crate::cli::last_trigger;
use tirith_core::engine::{self, AnalysisContext};
use tirith_core::extract::ScanContext;
use tirith_core::output;
use tirith_core::tokenize::ShellType;
pub fn run(
shell: &str,
json: bool,
non_interactive: bool,
interactive_flag: bool,
html_path: Option<&str>,
with_source: bool,
) -> i32 {
const MAX_PASTE: u64 = 1024 * 1024;
let mut raw_bytes = Vec::new();
if let Err(e) = std::io::stdin()
.take(MAX_PASTE + 1)
.read_to_end(&mut raw_bytes)
{
eprintln!("tirith: failed to read stdin: {e}");
return 1;
}
if raw_bytes.len() as u64 > MAX_PASTE {
eprintln!("tirith: paste input exceeds 1 MiB limit");
return 1;
}
if raw_bytes.is_empty() {
return 0;
}
let shell_type = match shell.parse::<ShellType>() {
Ok(s) => s,
Err(_) => {
eprintln!("tirith: warning: unknown shell '{shell}', falling back to posix");
ShellType::Posix
}
};
// Lossy is fine — raw bytes are preserved separately for byte-scan rules.
let input = String::from_utf8_lossy(&raw_bytes).into_owned();
let interactive = if interactive_flag {
true
} else if non_interactive {
false
} else if let Ok(val) = std::env::var("TIRITH_INTERACTIVE") {
val == "1"
} else {
is_terminal::is_terminal(std::io::stderr())
};
let clipboard_html = html_path.and_then(|path| match std::fs::read_to_string(path) {
Ok(html) => Some(html),
Err(e) => {
eprintln!("tirith: warning: failed to read clipboard HTML from '{path}': {e}");
None
}
});
// M12 ch1 G1 TOCTOU fix: only `--with-source` reads `clipboard_source.json`, and
// exactly ONCE — the same record feeds both the engine (paste_source_mismatch) and
// the display below. The tri-state lets the engine distinguish "CLI looked and found
// nothing" (`AbsentOrInvalid`, must NOT re-read disk) from "CLI never looked"
// (`Unread`, engine reads once itself — the plain `tirith paste` path).
let display_record = if with_source {
tirith_core::clipboard::read_source_record()
} else {
None
};
let clipboard_source_state = if with_source {
match display_record.clone() {
Some(rec) => tirith_core::clipboard::ClipboardSourceState::Loaded(rec),
None => tirith_core::clipboard::ClipboardSourceState::AbsentOrInvalid,
}
} else {
tirith_core::clipboard::ClipboardSourceState::Unread
};
let ctx = AnalysisContext {
input,
shell: shell_type,
scan_context: ScanContext::Paste,
raw_bytes: Some(raw_bytes),
interactive,
cwd: std::env::current_dir()
.ok()
.map(|p| p.display().to_string()),
file_path: None,
repo_root: None,
is_config_override: false,
clipboard_html,
card_ref: None,
clipboard_source: clipboard_source_state,
};
// PR #121 item 18: one policy snapshot for analysis + enforcement + audit, to
// close the TOCTOU window where a `.tirith/policy.yaml` change between two
// `Policy::discover` reads routed detection and enforcement against different policies.
let (mut verdict, policy) = engine::analyze_returning_policy(&ctx);
crate::cli::warn_repo_policy_neutralized(&policy);
crate::cli::warn_bad_injection_seeds(&policy);
// M4 item 8: origin attribution — the CLI is the only layer that knows whether the
// caller was a human, an agent (TIRITH_INTEGRATION), or CI. The audit below picks it up.
verdict.agent_origin = Some(tirith_core::agent_origin::resolve_cli_origin(interactive));
// M4 item 8 ch3: enforce `agent_rules.deny` here — the paste path does NOT route
// through `post_process_verdict`, so without this a deny matcher would fire on
// `tirith check` but silently fail on `tirith paste`. M4 PR #120 fix-6 (Greptile P1):
// skip under bypass (TIRITH=0), mirroring check/gateway — the raw verdict already
// wins and apply_agent_rules must not re-Block. Pinned by
// `paste_agent_rules_deny_skipped_under_tirith_bypass_today`.
if !verdict.bypass_honored {
tirith_core::escalation::apply_agent_rules(&mut verdict, &policy);
}
// Audit must capture full detection BEFORE paranoia filtering (ADR-13: paranoia is
// an output-layer filter). M4 item 8 ch3: bypass-honored verdicts are logged here too
// (the engine no longer audits its bypass path, so the CLI can stamp agent_origin first).
let event_id = uuid::Uuid::new_v4().to_string();
// Best-effort audit on the `paste` hot path — a write failure must not
// change behavior, so the Result is intentionally dropped.
let _ = tirith_core::audit::log_verdict(
&verdict,
&ctx.input,
None,
Some(event_id),
&policy.dlp_custom_patterns,
);
engine::filter_findings_by_paranoia(&mut verdict, policy.paranoia);
if verdict.action != tirith_core::verdict::Action::Allow {
last_trigger::write_last_trigger(&verdict, &ctx.input, &policy.dlp_custom_patterns);
}
if json {
// M12 ch1 `--with-source`: add the attributed clipboard source as extra top-level
// JSON keys (not a Finding). `clipboard_source: null` when no extension / stale
// record / hash mismatch, so a caller distinguishes "no source" from a missing flag.
let source_attribution = if with_source {
// Hash the ORIGINAL bytes, in lockstep with the engine's paste_source_mismatch.
let raw = ctx.raw_bytes.as_deref().unwrap_or(ctx.input.as_bytes());
Some(resolve_source_attribution(raw, display_record.as_ref()))
} else {
None
};
if write_paste_json(&verdict, &policy.dlp_custom_patterns, source_attribution).is_err() {
eprintln!("tirith: failed to write JSON output");
}
} else {
if output::write_human_auto(&verdict, false).is_err() {
eprintln!("tirith: failed to write output");
}
// M12 ch1 `--with-source` human mode: a one-line stderr attribution note
// (structured keys live in `--json`). Graceful when no source was recorded.
if with_source {
let raw = ctx.raw_bytes.as_deref().unwrap_or(ctx.input.as_bytes());
match resolve_source_attribution(raw, display_record.as_ref()) {
serde_json::Value::Null => {
eprintln!("tirith paste: no clipboard source recorded for this paste");
}
v => {
let url = v
.get("source_url")
.and_then(|u| u.as_str())
.unwrap_or("(unknown)");
eprintln!("tirith paste: clipboard source: {url}");
}
}
}
}
verdict.action.exit_code()
}
/// Resolve the attributed clipboard source for this paste, if the companion extension
/// recorded one whose `content_sha256` matches the pasted bytes. Returns
/// `{source_url, source_title}` on a match, else `null` (no record / hash mismatch /
/// no extension) so `--with-source` always emits a `clipboard_source` key.
///
/// `raw` is the ORIGINAL bytes (not lossy `ctx.input`), hashed in LOCKSTEP with the
/// `paste_source_mismatch` rule so display and finding never disagree on a non-UTF-8
/// paste. `record` is the SAME one read once at the top of `run` (G1 TOCTOU fix) — we
/// do not re-read `clipboard_source.json` here.
fn resolve_source_attribution(
raw: &[u8],
record: Option<&tirith_core::clipboard::ClipboardSourceRecord>,
) -> serde_json::Value {
let Some(record) = record else {
return serde_json::Value::Null;
};
// Same `matches_bytes` the engine's rule uses (Greptile R1 #6), on the original bytes.
if !record.matches_bytes(raw) {
// Recorded source exists but does not describe this paste (stale / replaced).
return serde_json::Value::Null;
}
// Provenance comes from an arbitrary web page via the untrusted extension, so it is
// sanitized before surfacing: drop URL query/fragment/userinfo (token-bearing), strip
// terminal control sequences, length-cap. Both output paths read these sanitized values.
serde_json::json!({
"source_url": sanitize_source_url(&record.source_url),
"source_title": sanitize_provenance_text(&record.source_title),
})
}
/// Max characters of provenance text surfaced into output. Bounds how much of a
/// (potentially sensitive) page title or path can leak into logs / JSON.
const PROVENANCE_MAX_CHARS: usize = 256;
/// Neutralize one untrusted provenance string before display/logging. Runs through the
/// shared `output_filter` (strips ANSI/OSC/APC/DCS, bare CR, C0 controls, DEL, zero-width),
/// then flattens tabs/newlines to spaces and length-caps.
fn sanitize_provenance_text(s: &str) -> String {
let mut out = Vec::with_capacity(s.len());
tirith_core::mcp::output_filter::sanitize_text_into(s.as_bytes(), &mut out);
let cleaned = String::from_utf8(out).unwrap_or_default();
let flattened: String = cleaned
.chars()
.map(|c| if c.is_whitespace() { ' ' } else { c })
.collect();
cap_chars(flattened.trim(), PROVENANCE_MAX_CHARS)
}
/// Redact the high-risk parts of a source URL (query, fragment, `user:pass@` userinfo —
/// all token-bearing) while keeping `scheme://host/path`, then sanitize + cap. A value
/// that does not parse as a URL is still sanitized verbatim, just not structurally redacted.
fn sanitize_source_url(url: &str) -> String {
match url::Url::parse(url) {
Ok(mut parsed) => {
parsed.set_query(None);
parsed.set_fragment(None);
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
sanitize_provenance_text(parsed.as_str())
}
Err(_) => sanitize_provenance_text(url),
}
}
/// Truncate to at most `max` characters (not bytes), appending `…` when cut.
fn cap_chars(s: &str, max: usize) -> String {
if s.chars().count() > max {
let mut t: String = s.chars().take(max).collect();
t.push('…');
t
} else {
s.to_string()
}
}
/// Write the paste verdict as JSON, optionally splicing a top-level `clipboard_source`
/// key (`--with-source`). Renders the shared `output::write_json` envelope, then parses
/// it back to add the extra key. Without `--with-source` it is byte-identical to `write_json`.
fn write_paste_json(
verdict: &tirith_core::verdict::Verdict,
custom_patterns: &[String],
source_attribution: Option<serde_json::Value>,
) -> std::io::Result<()> {
use std::io::Write as _;
let Some(source) = source_attribution else {
return output::write_json(verdict, custom_patterns, std::io::stdout().lock());
};
// Render to a buffer, then add the extra key. A parse failure is unreachable for our
// own serializer; fall back to the plain envelope rather than dropping output.
let mut buf = Vec::new();
output::write_json(verdict, custom_patterns, &mut buf)?;
let mut value: serde_json::Value = match serde_json::from_slice(&buf) {
Ok(v) => v,
Err(_) => {
// Still emit newline-terminated output for line-oriented consumers.
let mut stdout = std::io::stdout().lock();
stdout.write_all(&buf)?;
return writeln!(stdout);
}
};
if let Some(obj) = value.as_object_mut() {
obj.insert("clipboard_source".to_string(), source);
}
let mut stdout = std::io::stdout().lock();
serde_json::to_writer(&mut stdout, &value)?;
writeln!(stdout)
}
#[cfg(test)]
mod tests {
use super::*;
use tirith_core::clipboard::{content_sha256_hex, ClipboardSourceRecord};
fn record_for(payload: &[u8], source_url: &str, source_title: &str) -> ClipboardSourceRecord {
ClipboardSourceRecord {
updated_at: "2026-05-30T00:00:00Z".to_string(),
// matching hash so attribution proceeds (matches_bytes == true)
content_sha256: content_sha256_hex(payload),
source_url: source_url.to_string(),
source_title: source_title.to_string(),
hidden_text_detected: false,
}
}
// A hash mismatch yields no attribution (display stays in lockstep with the rule).
#[test]
fn no_attribution_when_hash_mismatches() {
let rec = record_for(b"the-real-bytes", "https://docs.example.com/x", "X");
let v = resolve_source_attribution(b"DIFFERENT-bytes", Some(&rec));
assert_eq!(v, serde_json::Value::Null);
}
// CodeRabbit Major: untrusted provenance — URL token-bearing parts stripped and
// terminal control sequences in the title neutralized before emission.
#[test]
fn provenance_is_sanitized_before_emission() {
let payload = b"install-me";
let rec = record_for(
payload,
"https://user:pw@docs.example.com/install?token=SECRET123&sig=ABC#section",
// ANSI color escape + BEL + an embedded newline, injected via the page title
"Install\u{1b}[31mGuide\u{07}\nline2",
);
let v = resolve_source_attribution(payload, Some(&rec));
let url = v.get("source_url").and_then(|u| u.as_str()).unwrap();
let title = v.get("source_title").and_then(|t| t.as_str()).unwrap();
// URL: query, fragment, and userinfo dropped; meaningful path kept.
assert_eq!(url, "https://docs.example.com/install");
assert!(
!url.contains("SECRET123"),
"signed token must not leak: {url:?}"
);
assert!(!url.contains("token=") && !url.contains("sig="));
assert!(!url.contains('#') && !url.contains("user:pw"));
// Title: ANSI/BEL control sequences stripped, newline flattened, text kept.
assert!(
!title.contains('\u{1b}'),
"ANSI escape must be stripped: {title:?}"
);
assert!(!title.contains('\u{07}'), "BEL must be stripped: {title:?}");
assert!(
!title.contains('\n'),
"newline must be flattened: {title:?}"
);
assert!(title.contains("Install") && title.contains("Guide"));
}
// Long titles are length-capped so a sensitive page title can't dump
// unbounded text into logs/JSON.
#[test]
fn provenance_title_is_length_capped() {
let payload = b"x";
let rec = record_for(
payload,
"https://example.com/",
&"A".repeat(PROVENANCE_MAX_CHARS + 50),
);
let v = resolve_source_attribution(payload, Some(&rec));
let title = v.get("source_title").and_then(|t| t.as_str()).unwrap();
// capped to PROVENANCE_MAX_CHARS plus the single ellipsis marker
assert!(title.chars().count() <= PROVENANCE_MAX_CHARS + 1);
assert!(
title.ends_with('…'),
"truncation marker expected: {title:?}"
);
}
// A non-URL provenance value is still sanitized (never emitted raw) even
// though it can't be structurally redacted.
#[test]
fn non_url_source_is_still_sanitized() {
let got = sanitize_source_url("not a url\u{1b}[2J\u{07}");
assert!(!got.contains('\u{1b}') && !got.contains('\u{07}'));
}
}