openmoat_core/engine/
taint.rs1use 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
19const SECRET_RULE: &str = "secrets-paths";
25
26pub const TAINT_RULE: &str = "session-taint";
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Secret {
32 pub source: String,
34 pub host: Option<String>,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Taint {
44 pub secrets: Vec<Secret>,
46 pub untrusted: Option<String>,
48}
49
50impl Taint {
51 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
64pub(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 #[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, self.opens_as(), 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 #[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 if *taint == Taint::default() {
127 return decision;
128 }
129 let encoded = if taint.secrets.is_empty() {
133 Vec::new()
134 } else {
135 super::egress::encoded_atoms(action.shown(), |host| {
136 taint.secrets.iter().all(|s| {
137 s.host
138 .as_deref()
139 .is_some_and(|own| own.eq_ignore_ascii_case(host))
140 })
141 })
142 };
143 let encoded = encoded
144 .iter()
145 .filter_map(|atom| self.matched_rules(atom))
146 .filter(|hit| matches!(hit.verdict, Verdict::Deny | Verdict::Review));
147 self.atoms(action, paths)
148 .0
149 .iter()
150 .filter_map(|atom| self.tainted(atom, taint))
151 .chain(encoded)
152 .fold(decision, |mut acc, next| {
153 acc.merge(next);
154 acc
155 })
156 }
157
158 fn tainted(&self, atom: &AtomicAction, taint: &Taint) -> Option<Decision> {
159 let reason = match atom {
160 AtomicAction::Net { host }
161 | AtomicAction::NetWord { host }
162 | AtomicAction::Fetch { host } => {
163 let secret = taint.secrets.iter().find(|s| {
164 !s.host
165 .as_deref()
166 .is_some_and(|own| own.eq_ignore_ascii_case(host))
167 })?;
168 secret_reason(secret, atom)
169 }
170 AtomicAction::McpTool { .. }
171 | AtomicAction::WebSearch { .. }
172 | AtomicAction::UnknownTool { .. } => secret_reason(taint.secrets.first()?, atom),
173 AtomicAction::FsWrite { path } if any_match(&self.protected, path.as_str()) => {
174 format!(
175 "the session read untrusted content ({}); {} changes what runs or what the agent is told later",
176 taint.untrusted.as_ref()?,
177 atom.describe()
178 )
179 }
180 _ => return None,
181 };
182 Some(Decision::single(Verdict::Review, TAINT_RULE, reason))
183 }
184
185 fn names_secret(&self, atom: &AtomicAction) -> bool {
186 [&self.deny, &self.repo_review, &self.allow, &self.review]
190 .into_iter()
191 .flatten()
192 .any(|g| {
193 let id = g.id.as_str();
194 (id == SECRET_RULE
195 || id
196 .strip_prefix(REPO_RULE_PREFIX)
197 .is_some_and(|rest| rest == SECRET_RULE))
198 && g.matches(atom)
199 })
200 }
201}
202
203fn secret_reason(secret: &Secret, atom: &AtomicAction) -> String {
204 format!(
205 "the session read secret material ({}); {} could carry it out",
206 secret.source,
207 atom.describe()
208 )
209}