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 super::{CompiledPolicy, EvalContext, checked_atoms};
10use crate::action::{Action, AtomicAction};
11use crate::pattern::{GlobPattern, any_match};
12use crate::policy::{Policy, PolicyError};
13use crate::realpath::PathResolver;
14use crate::repo::REPO_RULE_PREFIX;
15use crate::verdict::{Decision, Verdict};
16
17/// The rule group whose `fs.read` patterns name secret material, wherever the
18/// policy puts it. In the default policy it denies, so only a policy that asks
19/// for these reads lets one run and taint the session. A repository policy's
20/// group with the same base id (prefixed to `repo:secrets-paths` by
21/// `RepoPolicy::merge`) is also taint-significant.
22const SECRET_RULE: &str = "secrets-paths";
23
24/// The rule id a tightened decision carries.
25const TAINT_RULE: &str = "session-taint";
26
27/// Where secret material in a session came from.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Secret {
30 /// What brought it in, for reasons (`read /Users/me/.ssh/id_rsa`).
31 pub source: String,
32 /// The one host the secret belongs to and may still go to. `None` for a
33 /// file read: it belongs to no host, so every host asks. The secrets
34 /// broker (ADR-020) raises a `Secret` with its own host when a brokered
35 /// value is used, so later requests to other hosts ask.
36 pub host: Option<String>,
37}
38
39/// What earlier calls of one session exposed it to.
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
41pub struct Taint {
42 /// Secret material the session read, de-duplicated by source.
43 pub secrets: Vec<Secret>,
44 /// The first untrusted content the session read (`fetch docs.rs`), if any.
45 pub untrusted: Option<String>,
46}
47
48impl Taint {
49 /// Add what another call exposed the session to.
50 pub fn absorb(&mut self, other: Taint) {
51 for secret in other.secrets {
52 if !self.secrets.contains(&secret) {
53 self.secrets.push(secret);
54 }
55 }
56 if self.untrusted.is_none() {
57 self.untrusted = other.untrusted;
58 }
59 }
60}
61
62/// The built-in protected paths and the ones `policy` adds, compiled for `ctx`.
63pub(super) fn compile_protected(
64 policy: &Policy,
65 ctx: &EvalContext,
66) -> Result<Vec<GlobPattern>, PolicyError> {
67 crate::taint::protected_writes(policy.taint.as_ref())
68 .flat_map(|p| ctx.spellings(p))
69 .map(|p| GlobPattern::compile(&p, ctx.case_insensitive_paths))
70 .collect()
71}
72
73impl CompiledPolicy<'_> {
74 /// What running `action` in `cwd` exposed its session to: secret material
75 /// from a read the `secrets-paths` group names, untrusted content from a
76 /// fetch, a web search, an unknown tool or an MCP result. The caller passes only calls that ran; the parts
77 /// of an action that cannot be classified expose nothing they can name.
78 #[must_use]
79 pub fn exposure(&self, action: &Action, cwd: &str, paths: &dyn PathResolver) -> Taint {
80 let ctx = EvalContext {
81 cwd: cwd.to_owned(),
82 ..self.ctx.clone()
83 };
84 let (atoms, _) = checked_atoms(action, &ctx, paths);
85 let mut taint = Taint::default();
86 for atom in atoms {
87 match atom {
88 AtomicAction::FsRead { .. } if self.names_secret(&atom) => {
89 taint.absorb(Taint {
90 secrets: vec![Secret {
91 source: atom.describe(),
92 host: None,
93 }],
94 untrusted: None,
95 });
96 }
97 AtomicAction::Fetch { .. }
98 | AtomicAction::WebSearch { .. }
99 | AtomicAction::UnknownTool { .. }
100 | AtomicAction::McpTool { .. } => {
101 taint.untrusted.get_or_insert_with(|| atom.describe());
102 }
103 _ => {}
104 }
105 }
106 taint
107 }
108
109 /// `decision` for `action`, tightened by `taint`: after secret material,
110 /// network to any host but the secret's own, and every MCP call, web
111 /// search and unknown tool (the hosts they reach are unknown), asks; after untrusted content, a write to
112 /// a protected path asks. Merging keeps the strictest verdict, so this
113 /// never loosens a decision.
114 #[must_use]
115 pub fn with_taint(
116 &self,
117 decision: Decision,
118 action: &Action,
119 paths: &dyn PathResolver,
120 taint: &Taint,
121 ) -> Decision {
122 // A clean session tightens nothing; skip classifying (and listing the
123 // directories of glob operands) a second time.
124 if *taint == Taint::default() {
125 return decision;
126 }
127 self.atoms(action, paths)
128 .0
129 .iter()
130 .filter_map(|atom| self.tainted(atom, taint))
131 .fold(decision, |mut acc, next| {
132 acc.merge(next);
133 acc
134 })
135 }
136
137 fn tainted(&self, atom: &AtomicAction, taint: &Taint) -> Option<Decision> {
138 let reason = match atom {
139 AtomicAction::Net { host }
140 | AtomicAction::NetWord { host }
141 | AtomicAction::Fetch { host } => {
142 let secret = taint.secrets.iter().find(|s| {
143 !s.host
144 .as_deref()
145 .is_some_and(|own| own.eq_ignore_ascii_case(host))
146 })?;
147 secret_reason(secret, atom)
148 }
149 AtomicAction::McpTool { .. }
150 | AtomicAction::WebSearch { .. }
151 | AtomicAction::UnknownTool { .. } => secret_reason(taint.secrets.first()?, atom),
152 AtomicAction::FsWrite { path } if any_match(&self.protected, path.as_str()) => {
153 format!(
154 "the session read untrusted content ({}); {} changes what runs or what the agent is told later",
155 taint.untrusted.as_ref()?,
156 atom.describe()
157 )
158 }
159 _ => return None,
160 };
161 Some(Decision::single(Verdict::Ask, TAINT_RULE, reason))
162 }
163
164 fn names_secret(&self, atom: &AtomicAction) -> bool {
165 // A repository policy's rules are renamed to `repo:<id>` by `RepoPolicy::merge`,
166 // so match the base id and the prefixed one; `repo_ask` carries the ask rules
167 // merged in (ADR-022), so iterate it too.
168 [&self.deny, &self.repo_ask, &self.allow, &self.ask]
169 .into_iter()
170 .flatten()
171 .any(|g| {
172 let id = g.group.id.as_str();
173 (id == SECRET_RULE
174 || id
175 .strip_prefix(REPO_RULE_PREFIX)
176 .is_some_and(|rest| rest == SECRET_RULE))
177 && g.matches(atom)
178 })
179 }
180}
181
182fn secret_reason(secret: &Secret, atom: &AtomicAction) -> String {
183 format!(
184 "the session read secret material ({}); {} could carry it out",
185 secret.source,
186 atom.describe()
187 )
188}