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}