Skip to main content

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}