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