Skip to main content

openmoat_core/engine/
taint.rs

1//! Session taint (ADR-020): what earlier calls of a session brought into it,
2//! and how that tightens later decisions.
3//!
4//! The caller keeps the session's history (the CLI reads it from the audit
5//! log) and folds [`CompiledPolicy::exposure`] over the calls that ran. The
6//! resulting [`Taint`] then goes into [`CompiledPolicy::with_taint`], which can
7//! only turn an outcome stricter.
8
9use serde::{Deserialize, Serialize};
10
11use super::{CompiledPolicy, EvalContext, checked_atoms};
12use crate::action::{Action, AtomicAction};
13use crate::pattern::{GlobPattern, any_match};
14use crate::policy::{Policy, PolicyError};
15use crate::realpath::PathResolver;
16use crate::repo::REPO_RULE_PREFIX;
17use crate::verdict::{Decision, Verdict};
18
19/// The rule group whose `fs.read` patterns name secret material, wherever the
20/// policy puts it. In the default policy it denies, so only a policy that reviews
21/// for these reads lets one run and taint the session. A repository policy's
22/// group with the same base id (prefixed to `repo:secrets-paths` by
23/// `RepoPolicy::merge`) is also taint-significant.
24const SECRET_RULE: &str = "secrets-paths";
25
26/// The rule id a decision tightened by session taint carries.
27pub const TAINT_RULE: &str = "session-taint";
28
29/// Where secret material in a session came from.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Secret {
32    /// What brought it in, for reasons (`read /Users/me/.ssh/id_rsa`).
33    pub source: String,
34    /// The one host the secret belongs to and may still go to. `None` for a
35    /// file read: it belongs to no host, so every host needs review. The secrets
36    /// broker (ADR-020) raises a `Secret` with its own host when a brokered
37    /// value is used, so later requests to other hosts need review.
38    pub host: Option<String>,
39}
40
41/// What earlier calls of one session exposed it to.
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Taint {
44    /// Secret material the session read, de-duplicated by source.
45    pub secrets: Vec<Secret>,
46    /// The first untrusted content the session read (`fetch docs.rs`), if any.
47    pub untrusted: Option<String>,
48}
49
50/// Where untrusted content a session read came from: the one list of what
51/// injected input reads (ADR-057) and, less the metadata tools
52/// (`taint.metadata_tools`), of what taints a session, which
53/// [`CompiledPolicy::exposure`] and the provenance report (ADR-056) both use.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum UntrustedKind {
57    /// A web page or search result (`fetch`, `web search`).
58    Web,
59    /// The result of an MCP tool.
60    Mcp,
61    /// The result of a host tool OpenMoat does not know.
62    UnknownTool,
63    /// A file a session that had read untrusted content wrote (ADR-029).
64    File,
65}
66
67impl UntrustedKind {
68    /// The kind of untrusted content `atom` reads, if it reads any.
69    #[must_use]
70    pub fn of(atom: &AtomicAction) -> Option<Self> {
71        match atom {
72            AtomicAction::Fetch { .. } | AtomicAction::WebSearch { .. } => Some(Self::Web),
73            AtomicAction::McpTool { .. } => Some(Self::Mcp),
74            AtomicAction::UnknownTool { .. } => Some(Self::UnknownTool),
75            _ => None,
76        }
77    }
78
79    /// The spelling notes and reports use.
80    #[must_use]
81    pub fn as_str(self) -> &'static str {
82        match self {
83            Self::Web => "web",
84            Self::Mcp => "mcp",
85            Self::UnknownTool => "unknown-tool",
86            Self::File => "file",
87        }
88    }
89
90    /// How a report names it: "after reading web content".
91    #[must_use]
92    pub fn describe(self) -> &'static str {
93        match self {
94            Self::Web => "web content",
95            Self::Mcp => "an MCP tool result",
96            Self::UnknownTool => "an unknown tool's result",
97            Self::File => "a file an exposed session wrote",
98        }
99    }
100}
101
102impl Taint {
103    /// Add what another call exposed the session to.
104    pub fn absorb(&mut self, other: Taint) {
105        for secret in other.secrets {
106            if !self.secrets.contains(&secret) {
107                self.secrets.push(secret);
108            }
109        }
110        if self.untrusted.is_none() {
111            self.untrusted = other.untrusted;
112        }
113    }
114}
115
116/// The built-in protected paths and the ones `policy` adds, compiled for `ctx`.
117pub(super) fn compile_protected(
118    policy: &Policy,
119    ctx: &EvalContext,
120) -> Result<Vec<GlobPattern>, PolicyError> {
121    crate::taint::protected_writes(policy.taint.as_ref())
122        .flat_map(|p| ctx.spellings(p))
123        .map(|p| GlobPattern::compile(&p, ctx.case_insensitive_paths))
124        .collect()
125}
126
127impl CompiledPolicy<'_> {
128    /// What running `action` in `cwd` exposed its session to: secret material
129    /// from a read the `secrets-paths` group names, untrusted content from a
130    /// fetch, a web search, an unknown tool or an MCP result, except a
131    /// metadata tool's (`taint.metadata_tools`). The caller passes only calls that ran; the parts
132    /// of an action that cannot be classified expose nothing they can name.
133    #[must_use]
134    pub fn exposure(&self, action: &Action, cwd: &str, paths: &dyn PathResolver) -> Taint {
135        let ctx = EvalContext {
136            cwd: cwd.to_owned(),
137            ..self.ctx.clone()
138        };
139        let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
140        self.exposure_of(&atoms)
141    }
142
143    /// [`Self::exposure`] and [`Self::collection`] of one call that ran in
144    /// `cwd`, with its atoms worked out once: what a session's fold takes from
145    /// each of its calls, for half the classifying and path resolving of the
146    /// two apart.
147    #[must_use]
148    pub fn exposure_and_collection(
149        &self,
150        action: &Action,
151        cwd: &str,
152        paths: &dyn PathResolver,
153        is_dir: &dyn Fn(&str) -> bool,
154    ) -> (Taint, super::staging::Staging) {
155        let ctx = EvalContext {
156            cwd: cwd.to_owned(),
157            ..self.ctx.clone()
158        };
159        let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
160        (
161            self.exposure_of(&atoms),
162            self.collected(&atoms, &ctx, paths, is_dir),
163        )
164    }
165
166    /// What the checked atoms of one call exposed its session to.
167    fn exposure_of(&self, atoms: &[AtomicAction]) -> Taint {
168        let mut taint = Taint::default();
169        for atom in atoms {
170            match atom {
171                AtomicAction::FsRead { .. } if self.names_secret(atom) => {
172                    taint.absorb(Taint {
173                        secrets: vec![Secret {
174                            source: atom.describe(),
175                            host: None,
176                        }],
177                        untrusted: None,
178                    });
179                }
180                _ if self.taints(atom).is_some() => {
181                    taint.untrusted.get_or_insert_with(|| atom.describe());
182                }
183                _ => {}
184            }
185        }
186        taint
187    }
188
189    /// The first untrusted content running `action` in `cwd` read, with what
190    /// kind it is: the same atoms [`CompiledPolicy::exposure`] counts, for
191    /// reports that say where the content came from (ADR-056).
192    #[must_use]
193    pub fn untrusted_read(
194        &self,
195        action: &Action,
196        cwd: &str,
197        paths: &dyn PathResolver,
198    ) -> Option<(UntrustedKind, String)> {
199        let ctx = EvalContext {
200            cwd: cwd.to_owned(),
201            ..self.ctx.clone()
202        };
203        let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
204        atoms
205            .iter()
206            .find_map(|atom| self.taints(atom).map(|kind| (kind, atom.describe())))
207    }
208
209    /// The kind of untrusted content `atom` brings into its session, which
210    /// then taints it: [`UntrustedKind::of`], except the result of an MCP tool
211    /// that returns metadata, not content (a browser's tab list,
212    /// `taint.metadata_tools`). Injected input still reads that result
213    /// ([`Self::output_source`]): a page sets its own title and URL.
214    fn taints(&self, atom: &AtomicAction) -> Option<UntrustedKind> {
215        let kind = UntrustedKind::of(atom)?;
216        match atom {
217            AtomicAction::McpTool { name }
218                if crate::taint::is_metadata_tool(self.policy.taint.as_ref(), name) =>
219            {
220                None
221            }
222            _ => Some(kind),
223        }
224    }
225
226    /// `decision` for `action`, tightened by `taint`: after secret material,
227    /// network to any host but the secret's own, and every MCP call, web
228    /// search and unknown tool (the hosts they reach are unknown), needs review; after untrusted content, a write to
229    /// a protected path needs review. Merging keeps the strictest verdict, so this
230    /// never loosens a decision.
231    #[must_use]
232    pub fn with_taint(
233        &self,
234        decision: Decision,
235        action: &Action,
236        paths: &dyn PathResolver,
237        taint: &Taint,
238    ) -> Decision {
239        // A clean session tightens nothing; skip classifying (and listing the
240        // directories of glob operands) a second time.
241        if *taint == Taint::default() {
242            return decision;
243        }
244        // After a secret read, a long encoded value in a URL is what a secret
245        // looks like on its way out: `@encoded-egress` atoms, which the policy's
246        // rules decide (`encoded-egress` denies them in the default policy).
247        let encoded = if taint.secrets.is_empty() {
248            Vec::new()
249        } else {
250            super::egress::encoded_atoms(action.shown(), |host| {
251                taint.secrets.iter().all(|s| {
252                    s.host
253                        .as_deref()
254                        .is_some_and(|own| own.eq_ignore_ascii_case(host))
255                })
256            })
257        };
258        let encoded = encoded
259            .iter()
260            .filter_map(|atom| self.matched_rules(atom))
261            .filter(|hit| matches!(hit.verdict, Verdict::Deny | Verdict::Review));
262        self.atoms(action, paths)
263            .0
264            .iter()
265            .filter_map(|atom| self.tainted(atom, taint))
266            .chain(encoded)
267            .fold(decision, |mut acc, next| {
268                acc.merge(next);
269                acc
270            })
271    }
272
273    fn tainted(&self, atom: &AtomicAction, taint: &Taint) -> Option<Decision> {
274        let reason = match atom {
275            AtomicAction::Net { host }
276            | AtomicAction::NetWord { host }
277            | AtomicAction::Fetch { host } => {
278                let secret = taint.secrets.iter().find(|s| {
279                    !s.host
280                        .as_deref()
281                        .is_some_and(|own| own.eq_ignore_ascii_case(host))
282                })?;
283                secret_reason(secret, atom)
284            }
285            AtomicAction::McpTool { .. }
286            | AtomicAction::WebSearch { .. }
287            | AtomicAction::UnknownTool { .. } => secret_reason(taint.secrets.first()?, atom),
288            AtomicAction::FsWrite { path } if any_match(&self.protected, path.as_str()) => {
289                format!(
290                    "the session read untrusted content ({}); {} changes what runs or what the agent is told later",
291                    taint.untrusted.as_ref()?,
292                    atom.describe()
293                )
294            }
295            _ => return None,
296        };
297        Some(Decision::single(Verdict::Review, TAINT_RULE, reason))
298    }
299
300    fn names_secret(&self, atom: &AtomicAction) -> bool {
301        // A repository policy's rules are renamed to `repo:<id>` by `RepoPolicy::merge`,
302        // so match the base id and the prefixed one; `repo_review` carries the review rules
303        // merged in (ADR-022), so iterate it too.
304        [&self.deny, &self.repo_review, &self.allow, &self.review]
305            .into_iter()
306            .flatten()
307            .any(|g| {
308                let id = g.id.as_str();
309                (id == SECRET_RULE
310                    || id
311                        .strip_prefix(REPO_RULE_PREFIX)
312                        .is_some_and(|rest| rest == SECRET_RULE))
313                    && g.matches(atom)
314            })
315    }
316}
317
318fn secret_reason(secret: &Secret, atom: &AtomicAction) -> String {
319    format!(
320        "the session read secret material ({}); {} could carry it out",
321        secret.source,
322        atom.describe()
323    )
324}