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
26const 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, 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 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 [&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}