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}