harn_vm/security/file_provenance.rs
1//! Untrusted-origin file provenance (taint-on-write, distrust-on-read).
2//!
3//! The lethal-trifecta gate ([`super::classify_result_trust`] +
4//! [`crate::llm::agent_host_primitives`]) contains untrusted content that
5//! arrives over a *live* channel — an MCP server result, an internet fetch, a
6//! cross-agent hand-off. But content can also arrive on disk and be read back
7//! *later*: a cloned dependency's `README`, a downloaded dataset, a file the
8//! model itself wrote while a poisoned page was in context. A plain
9//! `read_file` is first-party by design ([`super::classify_result_trust`]
10//! returns `None` for it), so that deferred payload slips past the gate — the
11//! measured first-party residual of the containment battery
12//! ([`super::battery::run_containment_battery`]).
13//!
14//! Closing it does **not** mean distrusting every file read — a file you
15//! authored is not an injection vector, and gating first-party source reads
16//! would wreck usability. It means distrusting a file by its **origin**. This
17//! module is the persistent-storage analog of the in-context taint ledger
18//! ([`super::TaintRecord`]): a session-scoped map from a workspace path to the
19//! untrusted origin its content came from.
20//!
21//! Two seams, both reusing signals the runtime already computes:
22//!
23//! * **taint-on-write** — when a tool [`super::mutates_workspace`] *and*
24//! untrusted content is already in the session's context (or the writing
25//! tool's own result is untrusted, e.g. a fetch-to-disk / clone / MCP
26//! write), the written path inherits that untrusted origin. This is
27//! textbook taint propagation, extended from context to the file system.
28//! It needs no per-tool name allowlist: the "is this a download?" signal is
29//! the tool's existing [`crate::tool_annotations::ToolKind::Fetch`] /
30//! `Network` side-effect annotation, surfaced through
31//! [`super::classify_result_trust`].
32//! * **distrust-on-read** — a `Read`-kind tool whose target path is in the
33//! ledger classifies [`super::TrustLevel::Untrusted`], so its content flows
34//! into the same taint / trifecta gate as any other untrusted ingress.
35//!
36//! Gated behind the default-OFF `taint_file_provenance` policy flag, so
37//! behaviour is byte-identical when disabled (net-new enforcement, like
38//! `authenticate_directives`).
39//!
40//! Scope, stated honestly: the ledger keys on the **lexical** path a read/write
41//! names, so a command-based read (`cat vendor/dep/README`) whose payload path
42//! never appears as a structured `path` argument is out of scope — that is the
43//! `tool_result` residual the battery still reports. Per-value dataflow taint is
44//! not recoverable once content passes through the model, so (like
45//! [`super::TaintRecord`]) provenance is tracked at path granularity, not byte
46//! granularity.
47
48use std::collections::BTreeMap;
49
50use serde::{Deserialize, Serialize};
51
52use super::TrustLevel;
53
54/// Origin-id prefix recorded on the [`super::TaintRecord`] when a tainted file
55/// is read, surfaced in the trifecta gate's confirmation reason. The stored
56/// origin chains the source, e.g. `file:fetch:web_fetch` (this file's content
57/// came from a web fetch) or `file:tainted-context` (written while untrusted
58/// content was in context).
59pub const FILE_ORIGIN_PREFIX: &str = "file";
60
61/// Origin recorded when a write propagates ambient context taint (as opposed to
62/// inheriting a specific untrusted result's origin).
63pub const TAINTED_CONTEXT_ORIGIN: &str = "tainted-context";
64
65/// Structured-argument keys that name a single file a tool reads or writes.
66/// Matched across the common editor/host tool vocabulary; unknown shapes simply
67/// yield no path (fail-open to "no provenance", never a false quarantine).
68const PATH_KEYS: &[&str] = &["path", "file_path", "file", "filename", "target_file"];
69
70/// Lexical path normalization so a write and a later read that name the same
71/// file by a slightly different spelling still match. Deliberately lexical (no
72/// filesystem `canonicalize`): the security layer must not touch disk, and the
73/// battery's modelled paths do not exist. Strips a leading `./` and trailing
74/// slashes and trims surrounding whitespace.
75fn normalize_path(path: &str) -> String {
76 let trimmed = path.trim();
77 let trimmed = trimmed.strip_prefix("./").unwrap_or(trimmed);
78 trimmed.trim_end_matches('/').to_string()
79}
80
81/// Session-scoped map from a workspace path to the untrusted origin its content
82/// was last known to carry. Owned by the agent host session so it drops with the
83/// session (no cross-session leak), mirroring the [`super::TaintRecord`] ledger.
84#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
85pub struct FileProvenanceLedger {
86 tainted: BTreeMap<String, String>,
87}
88
89impl FileProvenanceLedger {
90 /// Record that `path` now holds content of untrusted `origin`. The first
91 /// origin sticks (a path does not become "more trusted" by a later write);
92 /// empty paths are ignored.
93 pub fn record(&mut self, path: &str, origin: &str) {
94 let key = normalize_path(path);
95 if key.is_empty() {
96 return;
97 }
98 self.tainted
99 .entry(key)
100 .or_insert_with(|| origin.to_string());
101 }
102
103 /// Trust classification for a read of `path`. `Some((Untrusted, origin))`
104 /// when the path is a known untrusted-origin file; `None` otherwise. Shaped
105 /// like [`super::classify_result_trust`] so the read path can `.or_else(...)`
106 /// the two.
107 pub fn classify(&self, path: &str) -> Option<(TrustLevel, String)> {
108 let key = normalize_path(path);
109 self.tainted.get(&key).map(|origin| {
110 (
111 TrustLevel::Untrusted,
112 format!("{FILE_ORIGIN_PREFIX}:{origin}"),
113 )
114 })
115 }
116
117 /// Trust classification for an `Execute`-kind tool whose command string names
118 /// a tainted-origin path — the laundering read (`cat vendor/dep/README`) that
119 /// evades structured [`Self::classify`] because the path never appears as a
120 /// `path` argument. Splits the command into path-shaped tokens (maximal runs
121 /// of path characters, so shell quoting / pipes / redirects are natural
122 /// delimiters), normalizes each, and returns the first that is a known
123 /// untrusted-origin file. Matching is exact on the normalized key, so a short
124 /// tainted name never substring-matches an unrelated path. `None` when the
125 /// command names no tainted path (fail-open, never a false quarantine).
126 pub fn references_tainted_path(&self, command: &str) -> Option<(TrustLevel, String)> {
127 if self.tainted.is_empty() {
128 return None;
129 }
130 command
131 .split(|c: char| !is_path_char(c))
132 .filter(|token| !token.is_empty())
133 .find_map(|token| self.classify(token))
134 }
135
136 pub fn is_empty(&self) -> bool {
137 self.tainted.is_empty()
138 }
139
140 pub fn len(&self) -> usize {
141 self.tainted.len()
142 }
143}
144
145/// Characters that can appear in a workspace path. A command string is split on
146/// everything else, so shell metacharacters (pipes, redirects, quotes, `;`, `&`)
147/// delimit tokens without needing to enumerate them.
148fn is_path_char(c: char) -> bool {
149 c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '-' | '_' | '~')
150}
151
152/// Argument keys an `Execute`-kind tool names its shell command under. Mirrors
153/// [`PATH_KEYS`] for the command surface.
154const COMMAND_KEYS: &[&str] = &["command", "cmd", "script"];
155
156/// Extract the shell command string a tool call names, joining a `command` /
157/// `cmd` / `script` string plus any `args` / `argv` array elements, so a command
158/// split across an argv list is still scanned as one string. `None` when no
159/// command is present (unknown shape fails open to "no provenance").
160pub fn command_string(arguments: &serde_json::Value) -> Option<String> {
161 let obj = arguments.as_object()?;
162 let mut parts: Vec<String> = Vec::new();
163 for key in COMMAND_KEYS {
164 if let Some(serde_json::Value::String(value)) = obj.get(*key) {
165 if !value.trim().is_empty() {
166 parts.push(value.clone());
167 }
168 }
169 }
170 for key in ["args", "argv"] {
171 if let Some(serde_json::Value::Array(items)) = obj.get(key) {
172 for item in items {
173 if let serde_json::Value::String(value) = item {
174 if !value.trim().is_empty() {
175 parts.push(value.clone());
176 }
177 }
178 }
179 }
180 }
181 if parts.is_empty() {
182 None
183 } else {
184 Some(parts.join(" "))
185 }
186}
187
188/// Extract the file path(s) a tool call names in its structured arguments. Reads
189/// the canonical single-path keys plus a plural `paths` array (multi-file
190/// write/patch). Used on the write path to record targets and on the read path
191/// to look them up, so both sides agree on what "the file" is.
192pub fn path_arguments(arguments: &serde_json::Value) -> Vec<String> {
193 let mut paths = Vec::new();
194 let Some(obj) = arguments.as_object() else {
195 return paths;
196 };
197 for key in PATH_KEYS {
198 if let Some(serde_json::Value::String(value)) = obj.get(*key) {
199 if !value.trim().is_empty() {
200 paths.push(value.clone());
201 }
202 }
203 }
204 if let Some(serde_json::Value::Array(items)) = obj.get("paths") {
205 for item in items {
206 if let serde_json::Value::String(value) = item {
207 if !value.trim().is_empty() {
208 paths.push(value.clone());
209 }
210 }
211 }
212 }
213 paths
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use serde_json::json;
220
221 #[test]
222 fn a_read_of_an_untainted_path_is_trusted() {
223 let ledger = FileProvenanceLedger::default();
224 assert!(ledger.is_empty());
225 assert!(ledger.classify("src/main.rs").is_none());
226 }
227
228 #[test]
229 fn a_read_of_a_recorded_path_is_untrusted_with_a_chained_origin() {
230 let mut ledger = FileProvenanceLedger::default();
231 ledger.record("vendor/dep/README.md", "fetch:web_fetch");
232 assert_eq!(ledger.len(), 1);
233 assert_eq!(
234 ledger.classify("vendor/dep/README.md"),
235 Some((TrustLevel::Untrusted, "file:fetch:web_fetch".to_string()))
236 );
237 }
238
239 #[test]
240 fn normalization_matches_write_and_read_spellings() {
241 let mut ledger = FileProvenanceLedger::default();
242 ledger.record("./notes/summary.md", TAINTED_CONTEXT_ORIGIN);
243 // Read names the same file without the leading `./`.
244 assert_eq!(
245 ledger.classify("notes/summary.md"),
246 Some((
247 TrustLevel::Untrusted,
248 format!("{FILE_ORIGIN_PREFIX}:{TAINTED_CONTEXT_ORIGIN}")
249 ))
250 );
251 }
252
253 #[test]
254 fn the_first_origin_sticks() {
255 let mut ledger = FileProvenanceLedger::default();
256 ledger.record("a.txt", "fetch:web_fetch");
257 ledger.record("a.txt", TAINTED_CONTEXT_ORIGIN);
258 assert_eq!(
259 ledger.classify("a.txt"),
260 Some((TrustLevel::Untrusted, "file:fetch:web_fetch".to_string()))
261 );
262 }
263
264 #[test]
265 fn empty_paths_are_ignored() {
266 let mut ledger = FileProvenanceLedger::default();
267 ledger.record(" ", "fetch:web_fetch");
268 ledger.record("", "fetch:web_fetch");
269 assert!(ledger.is_empty());
270 }
271
272 #[test]
273 fn path_arguments_reads_the_common_vocabulary() {
274 assert_eq!(
275 path_arguments(&json!({"path": "src/a.rs"})),
276 vec!["src/a.rs".to_string()]
277 );
278 assert_eq!(
279 path_arguments(&json!({"file_path": "src/b.rs", "content": "..."})),
280 vec!["src/b.rs".to_string()]
281 );
282 assert_eq!(
283 path_arguments(&json!({"paths": ["x.md", "y.md"], "note": "z"})),
284 vec!["x.md".to_string(), "y.md".to_string()]
285 );
286 }
287
288 #[test]
289 fn path_arguments_ignores_non_path_and_blank_values() {
290 assert!(path_arguments(&json!({"query": "ripgrep this"})).is_empty());
291 assert!(path_arguments(&json!({"path": " "})).is_empty());
292 assert!(path_arguments(&json!("not an object")).is_empty());
293 }
294
295 #[test]
296 fn references_tainted_path_catches_a_laundered_command_read() {
297 let mut ledger = FileProvenanceLedger::default();
298 ledger.record("vendor/dep/README.md", "fetch:clone");
299 // A `cat` that re-reads the tainted file, with a pipe and redirect around it.
300 assert_eq!(
301 ledger.references_tainted_path("cat ./vendor/dep/README.md | head -n 40"),
302 Some((TrustLevel::Untrusted, "file:fetch:clone".to_string()))
303 );
304 // Quoted spelling still matches (the quote is a non-path delimiter).
305 assert_eq!(
306 ledger.references_tainted_path("grep -R foo \"vendor/dep/README.md\""),
307 Some((TrustLevel::Untrusted, "file:fetch:clone".to_string()))
308 );
309 }
310
311 #[test]
312 fn references_tainted_path_is_precise_and_fail_open() {
313 let mut ledger = FileProvenanceLedger::default();
314 ledger.record("a.md", "fetch:clone");
315 // A first-party path that merely SHARES a suffix must not match: lookup is
316 // exact on the normalized key, never a substring.
317 assert!(ledger
318 .references_tainted_path("cat data.md && echo done")
319 .is_none());
320 // A command naming no tainted path at all is trusted.
321 assert!(ledger.references_tainted_path("ls -la src/").is_none());
322 // An empty ledger short-circuits to trusted.
323 assert!(FileProvenanceLedger::default()
324 .references_tainted_path("cat anything")
325 .is_none());
326 }
327
328 #[test]
329 fn command_string_joins_command_and_argv() {
330 assert_eq!(
331 command_string(&json!({"command": "cat vendor/dep/README.md"})),
332 Some("cat vendor/dep/README.md".to_string())
333 );
334 assert_eq!(
335 command_string(&json!({"cmd": "grep", "args": ["-R", "foo", "vendor/dep/README.md"]})),
336 Some("grep -R foo vendor/dep/README.md".to_string())
337 );
338 assert!(command_string(&json!({"path": "src/a.rs"})).is_none());
339 assert!(command_string(&json!({"command": " "})).is_none());
340 }
341}