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/// taints a session, which [`CompiledPolicy::exposure`] and the provenance
52/// report (ADR-056) both use.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum UntrustedKind {
56    /// A web page or search result (`fetch`, `web search`).
57    Web,
58    /// The result of an MCP tool.
59    Mcp,
60    /// The result of a host tool OpenMoat does not know.
61    UnknownTool,
62    /// A file a session that had read untrusted content wrote (ADR-029).
63    File,
64}
65
66impl UntrustedKind {
67    /// The kind of untrusted content `atom` reads, if it reads any.
68    #[must_use]
69    pub fn of(atom: &AtomicAction) -> Option<Self> {
70        match atom {
71            AtomicAction::Fetch { .. } | AtomicAction::WebSearch { .. } => Some(Self::Web),
72            AtomicAction::McpTool { .. } => Some(Self::Mcp),
73            AtomicAction::UnknownTool { .. } => Some(Self::UnknownTool),
74            _ => None,
75        }
76    }
77
78    /// The spelling notes and reports use.
79    #[must_use]
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Self::Web => "web",
83            Self::Mcp => "mcp",
84            Self::UnknownTool => "unknown-tool",
85            Self::File => "file",
86        }
87    }
88
89    /// How a report names it: "after reading web content".
90    #[must_use]
91    pub fn describe(self) -> &'static str {
92        match self {
93            Self::Web => "web content",
94            Self::Mcp => "an MCP tool result",
95            Self::UnknownTool => "an unknown tool's result",
96            Self::File => "a file an exposed session wrote",
97        }
98    }
99}
100
101impl Taint {
102    /// Add what another call exposed the session to.
103    pub fn absorb(&mut self, other: Taint) {
104        for secret in other.secrets {
105            if !self.secrets.contains(&secret) {
106                self.secrets.push(secret);
107            }
108        }
109        if self.untrusted.is_none() {
110            self.untrusted = other.untrusted;
111        }
112    }
113}
114
115/// The built-in protected paths and the ones `policy` adds, compiled for `ctx`.
116pub(super) fn compile_protected(
117    policy: &Policy,
118    ctx: &EvalContext,
119) -> Result<Vec<GlobPattern>, PolicyError> {
120    crate::taint::protected_writes(policy.taint.as_ref())
121        .flat_map(|p| ctx.spellings(p))
122        .map(|p| GlobPattern::compile(&p, ctx.case_insensitive_paths))
123        .collect()
124}
125
126impl CompiledPolicy<'_> {
127    /// What running `action` in `cwd` exposed its session to: secret material
128    /// from a read the `secrets-paths` group names, untrusted content from a
129    /// fetch, a web search, an unknown tool or an MCP result. The caller passes only calls that ran; the parts
130    /// of an action that cannot be classified expose nothing they can name.
131    #[must_use]
132    pub fn exposure(&self, action: &Action, cwd: &str, paths: &dyn PathResolver) -> Taint {
133        let ctx = EvalContext {
134            cwd: cwd.to_owned(),
135            ..self.ctx.clone()
136        };
137        let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
138        let mut taint = Taint::default();
139        for atom in atoms {
140            match atom {
141                AtomicAction::FsRead { .. } if self.names_secret(&atom) => {
142                    taint.absorb(Taint {
143                        secrets: vec![Secret {
144                            source: atom.describe(),
145                            host: None,
146                        }],
147                        untrusted: None,
148                    });
149                }
150                _ if UntrustedKind::of(&atom).is_some() => {
151                    taint.untrusted.get_or_insert_with(|| atom.describe());
152                }
153                _ => {}
154            }
155        }
156        taint
157    }
158
159    /// The first untrusted content running `action` in `cwd` read, with what
160    /// kind it is: the same atoms [`CompiledPolicy::exposure`] counts, for
161    /// reports that say where the content came from (ADR-056).
162    #[must_use]
163    pub fn untrusted_read(
164        &self,
165        action: &Action,
166        cwd: &str,
167        paths: &dyn PathResolver,
168    ) -> Option<(UntrustedKind, String)> {
169        let ctx = EvalContext {
170            cwd: cwd.to_owned(),
171            ..self.ctx.clone()
172        };
173        let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
174        atoms
175            .iter()
176            .find_map(|atom| UntrustedKind::of(atom).map(|kind| (kind, atom.describe())))
177    }
178
179    /// `decision` for `action`, tightened by `taint`: after secret material,
180    /// network to any host but the secret's own, and every MCP call, web
181    /// search and unknown tool (the hosts they reach are unknown), needs review; after untrusted content, a write to
182    /// a protected path needs review. Merging keeps the strictest verdict, so this
183    /// never loosens a decision.
184    #[must_use]
185    pub fn with_taint(
186        &self,
187        decision: Decision,
188        action: &Action,
189        paths: &dyn PathResolver,
190        taint: &Taint,
191    ) -> Decision {
192        // A clean session tightens nothing; skip classifying (and listing the
193        // directories of glob operands) a second time.
194        if *taint == Taint::default() {
195            return decision;
196        }
197        // After a secret read, a long encoded value in a URL is what a secret
198        // looks like on its way out: `@encoded-egress` atoms, which the policy's
199        // rules decide (`encoded-egress` denies them in the default policy).
200        let encoded = if taint.secrets.is_empty() {
201            Vec::new()
202        } else {
203            super::egress::encoded_atoms(action.shown(), |host| {
204                taint.secrets.iter().all(|s| {
205                    s.host
206                        .as_deref()
207                        .is_some_and(|own| own.eq_ignore_ascii_case(host))
208                })
209            })
210        };
211        let encoded = encoded
212            .iter()
213            .filter_map(|atom| self.matched_rules(atom))
214            .filter(|hit| matches!(hit.verdict, Verdict::Deny | Verdict::Review));
215        self.atoms(action, paths)
216            .0
217            .iter()
218            .filter_map(|atom| self.tainted(atom, taint))
219            .chain(encoded)
220            .fold(decision, |mut acc, next| {
221                acc.merge(next);
222                acc
223            })
224    }
225
226    fn tainted(&self, atom: &AtomicAction, taint: &Taint) -> Option<Decision> {
227        let reason = match atom {
228            AtomicAction::Net { host }
229            | AtomicAction::NetWord { host }
230            | AtomicAction::Fetch { host } => {
231                let secret = taint.secrets.iter().find(|s| {
232                    !s.host
233                        .as_deref()
234                        .is_some_and(|own| own.eq_ignore_ascii_case(host))
235                })?;
236                secret_reason(secret, atom)
237            }
238            AtomicAction::McpTool { .. }
239            | AtomicAction::WebSearch { .. }
240            | AtomicAction::UnknownTool { .. } => secret_reason(taint.secrets.first()?, atom),
241            AtomicAction::FsWrite { path } if any_match(&self.protected, path.as_str()) => {
242                format!(
243                    "the session read untrusted content ({}); {} changes what runs or what the agent is told later",
244                    taint.untrusted.as_ref()?,
245                    atom.describe()
246                )
247            }
248            _ => return None,
249        };
250        Some(Decision::single(Verdict::Review, TAINT_RULE, reason))
251    }
252
253    fn names_secret(&self, atom: &AtomicAction) -> bool {
254        // A repository policy's rules are renamed to `repo:<id>` by `RepoPolicy::merge`,
255        // so match the base id and the prefixed one; `repo_review` carries the review rules
256        // merged in (ADR-022), so iterate it too.
257        [&self.deny, &self.repo_review, &self.allow, &self.review]
258            .into_iter()
259            .flatten()
260            .any(|g| {
261                let id = g.id.as_str();
262                (id == SECRET_RULE
263                    || id
264                        .strip_prefix(REPO_RULE_PREFIX)
265                        .is_some_and(|rest| rest == SECRET_RULE))
266                    && g.matches(atom)
267            })
268    }
269}
270
271fn secret_reason(secret: &Secret, atom: &AtomicAction) -> String {
272    format!(
273        "the session read secret material ({}); {} could carry it out",
274        secret.source,
275        atom.describe()
276    )
277}