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        let mut taint = Taint::default();
141        for atom in atoms {
142            match atom {
143                AtomicAction::FsRead { .. } if self.names_secret(&atom) => {
144                    taint.absorb(Taint {
145                        secrets: vec![Secret {
146                            source: atom.describe(),
147                            host: None,
148                        }],
149                        untrusted: None,
150                    });
151                }
152                _ if self.taints(&atom).is_some() => {
153                    taint.untrusted.get_or_insert_with(|| atom.describe());
154                }
155                _ => {}
156            }
157        }
158        taint
159    }
160
161    /// The first untrusted content running `action` in `cwd` read, with what
162    /// kind it is: the same atoms [`CompiledPolicy::exposure`] counts, for
163    /// reports that say where the content came from (ADR-056).
164    #[must_use]
165    pub fn untrusted_read(
166        &self,
167        action: &Action,
168        cwd: &str,
169        paths: &dyn PathResolver,
170    ) -> Option<(UntrustedKind, String)> {
171        let ctx = EvalContext {
172            cwd: cwd.to_owned(),
173            ..self.ctx.clone()
174        };
175        let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
176        atoms
177            .iter()
178            .find_map(|atom| self.taints(atom).map(|kind| (kind, atom.describe())))
179    }
180
181    /// The kind of untrusted content `atom` brings into its session, which
182    /// then taints it: [`UntrustedKind::of`], except the result of an MCP tool
183    /// that returns metadata, not content (a browser's tab list,
184    /// `taint.metadata_tools`). Injected input still reads that result
185    /// ([`Self::output_source`]): a page sets its own title and URL.
186    fn taints(&self, atom: &AtomicAction) -> Option<UntrustedKind> {
187        let kind = UntrustedKind::of(atom)?;
188        match atom {
189            AtomicAction::McpTool { name }
190                if crate::taint::is_metadata_tool(self.policy.taint.as_ref(), name) =>
191            {
192                None
193            }
194            _ => Some(kind),
195        }
196    }
197
198    /// `decision` for `action`, tightened by `taint`: after secret material,
199    /// network to any host but the secret's own, and every MCP call, web
200    /// search and unknown tool (the hosts they reach are unknown), needs review; after untrusted content, a write to
201    /// a protected path needs review. Merging keeps the strictest verdict, so this
202    /// never loosens a decision.
203    #[must_use]
204    pub fn with_taint(
205        &self,
206        decision: Decision,
207        action: &Action,
208        paths: &dyn PathResolver,
209        taint: &Taint,
210    ) -> Decision {
211        // A clean session tightens nothing; skip classifying (and listing the
212        // directories of glob operands) a second time.
213        if *taint == Taint::default() {
214            return decision;
215        }
216        // After a secret read, a long encoded value in a URL is what a secret
217        // looks like on its way out: `@encoded-egress` atoms, which the policy's
218        // rules decide (`encoded-egress` denies them in the default policy).
219        let encoded = if taint.secrets.is_empty() {
220            Vec::new()
221        } else {
222            super::egress::encoded_atoms(action.shown(), |host| {
223                taint.secrets.iter().all(|s| {
224                    s.host
225                        .as_deref()
226                        .is_some_and(|own| own.eq_ignore_ascii_case(host))
227                })
228            })
229        };
230        let encoded = encoded
231            .iter()
232            .filter_map(|atom| self.matched_rules(atom))
233            .filter(|hit| matches!(hit.verdict, Verdict::Deny | Verdict::Review));
234        self.atoms(action, paths)
235            .0
236            .iter()
237            .filter_map(|atom| self.tainted(atom, taint))
238            .chain(encoded)
239            .fold(decision, |mut acc, next| {
240                acc.merge(next);
241                acc
242            })
243    }
244
245    fn tainted(&self, atom: &AtomicAction, taint: &Taint) -> Option<Decision> {
246        let reason = match atom {
247            AtomicAction::Net { host }
248            | AtomicAction::NetWord { host }
249            | AtomicAction::Fetch { host } => {
250                let secret = taint.secrets.iter().find(|s| {
251                    !s.host
252                        .as_deref()
253                        .is_some_and(|own| own.eq_ignore_ascii_case(host))
254                })?;
255                secret_reason(secret, atom)
256            }
257            AtomicAction::McpTool { .. }
258            | AtomicAction::WebSearch { .. }
259            | AtomicAction::UnknownTool { .. } => secret_reason(taint.secrets.first()?, atom),
260            AtomicAction::FsWrite { path } if any_match(&self.protected, path.as_str()) => {
261                format!(
262                    "the session read untrusted content ({}); {} changes what runs or what the agent is told later",
263                    taint.untrusted.as_ref()?,
264                    atom.describe()
265                )
266            }
267            _ => return None,
268        };
269        Some(Decision::single(Verdict::Review, TAINT_RULE, reason))
270    }
271
272    fn names_secret(&self, atom: &AtomicAction) -> bool {
273        // A repository policy's rules are renamed to `repo:<id>` by `RepoPolicy::merge`,
274        // so match the base id and the prefixed one; `repo_review` carries the review rules
275        // merged in (ADR-022), so iterate it too.
276        [&self.deny, &self.repo_review, &self.allow, &self.review]
277            .into_iter()
278            .flatten()
279            .any(|g| {
280                let id = g.id.as_str();
281                (id == SECRET_RULE
282                    || id
283                        .strip_prefix(REPO_RULE_PREFIX)
284                        .is_some_and(|rest| rest == SECRET_RULE))
285                    && g.matches(atom)
286            })
287    }
288}
289
290fn secret_reason(secret: &Secret, atom: &AtomicAction) -> String {
291    format!(
292        "the session read secret material ({}); {} could carry it out",
293        secret.source,
294        atom.describe()
295    )
296}