openmoat_core/engine/
taint.rs1use 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
16const SECRET_RULE: &str = "secrets-paths";
20
21const TAINT_RULE: &str = "session-taint";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Secret {
27 pub source: String,
29 pub host: Option<String>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct Taint {
39 pub secrets: Vec<Secret>,
41 pub untrusted: Option<String>,
43}
44
45impl Taint {
46 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
59pub(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 #[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 #[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 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}