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