1use 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
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum UntrustedKind {
56 Web,
58 Mcp,
60 UnknownTool,
62 File,
64}
65
66impl UntrustedKind {
67 #[must_use]
69 pub fn of(atom: &AtomicAction) -> Option<Self> {
70 match atom {
71 AtomicAction::Fetch { .. } | AtomicAction::WebSearch { .. } => Some(Self::Web),
72 AtomicAction::McpTool { .. } => Some(Self::Mcp),
73 AtomicAction::UnknownTool { .. } => Some(Self::UnknownTool),
74 _ => None,
75 }
76 }
77
78 #[must_use]
80 pub fn as_str(self) -> &'static str {
81 match self {
82 Self::Web => "web",
83 Self::Mcp => "mcp",
84 Self::UnknownTool => "unknown-tool",
85 Self::File => "file",
86 }
87 }
88
89 #[must_use]
91 pub fn describe(self) -> &'static str {
92 match self {
93 Self::Web => "web content",
94 Self::Mcp => "an MCP tool result",
95 Self::UnknownTool => "an unknown tool's result",
96 Self::File => "a file an exposed session wrote",
97 }
98 }
99}
100
101impl Taint {
102 pub fn absorb(&mut self, other: Taint) {
104 for secret in other.secrets {
105 if !self.secrets.contains(&secret) {
106 self.secrets.push(secret);
107 }
108 }
109 if self.untrusted.is_none() {
110 self.untrusted = other.untrusted;
111 }
112 }
113}
114
115pub(super) fn compile_protected(
117 policy: &Policy,
118 ctx: &EvalContext,
119) -> Result<Vec<GlobPattern>, PolicyError> {
120 crate::taint::protected_writes(policy.taint.as_ref())
121 .flat_map(|p| ctx.spellings(p))
122 .map(|p| GlobPattern::compile(&p, ctx.case_insensitive_paths))
123 .collect()
124}
125
126impl CompiledPolicy<'_> {
127 #[must_use]
132 pub fn exposure(&self, action: &Action, cwd: &str, paths: &dyn PathResolver) -> Taint {
133 let ctx = EvalContext {
134 cwd: cwd.to_owned(),
135 ..self.ctx.clone()
136 };
137 let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
138 let mut taint = Taint::default();
139 for atom in atoms {
140 match atom {
141 AtomicAction::FsRead { .. } if self.names_secret(&atom) => {
142 taint.absorb(Taint {
143 secrets: vec![Secret {
144 source: atom.describe(),
145 host: None,
146 }],
147 untrusted: None,
148 });
149 }
150 _ if UntrustedKind::of(&atom).is_some() => {
151 taint.untrusted.get_or_insert_with(|| atom.describe());
152 }
153 _ => {}
154 }
155 }
156 taint
157 }
158
159 #[must_use]
163 pub fn untrusted_read(
164 &self,
165 action: &Action,
166 cwd: &str,
167 paths: &dyn PathResolver,
168 ) -> Option<(UntrustedKind, String)> {
169 let ctx = EvalContext {
170 cwd: cwd.to_owned(),
171 ..self.ctx.clone()
172 };
173 let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
174 atoms
175 .iter()
176 .find_map(|atom| UntrustedKind::of(atom).map(|kind| (kind, atom.describe())))
177 }
178
179 #[must_use]
185 pub fn with_taint(
186 &self,
187 decision: Decision,
188 action: &Action,
189 paths: &dyn PathResolver,
190 taint: &Taint,
191 ) -> Decision {
192 if *taint == Taint::default() {
195 return decision;
196 }
197 let encoded = if taint.secrets.is_empty() {
201 Vec::new()
202 } else {
203 super::egress::encoded_atoms(action.shown(), |host| {
204 taint.secrets.iter().all(|s| {
205 s.host
206 .as_deref()
207 .is_some_and(|own| own.eq_ignore_ascii_case(host))
208 })
209 })
210 };
211 let encoded = encoded
212 .iter()
213 .filter_map(|atom| self.matched_rules(atom))
214 .filter(|hit| matches!(hit.verdict, Verdict::Deny | Verdict::Review));
215 self.atoms(action, paths)
216 .0
217 .iter()
218 .filter_map(|atom| self.tainted(atom, taint))
219 .chain(encoded)
220 .fold(decision, |mut acc, next| {
221 acc.merge(next);
222 acc
223 })
224 }
225
226 fn tainted(&self, atom: &AtomicAction, taint: &Taint) -> Option<Decision> {
227 let reason = match atom {
228 AtomicAction::Net { host }
229 | AtomicAction::NetWord { host }
230 | AtomicAction::Fetch { host } => {
231 let secret = taint.secrets.iter().find(|s| {
232 !s.host
233 .as_deref()
234 .is_some_and(|own| own.eq_ignore_ascii_case(host))
235 })?;
236 secret_reason(secret, atom)
237 }
238 AtomicAction::McpTool { .. }
239 | AtomicAction::WebSearch { .. }
240 | AtomicAction::UnknownTool { .. } => secret_reason(taint.secrets.first()?, atom),
241 AtomicAction::FsWrite { path } if any_match(&self.protected, path.as_str()) => {
242 format!(
243 "the session read untrusted content ({}); {} changes what runs or what the agent is told later",
244 taint.untrusted.as_ref()?,
245 atom.describe()
246 )
247 }
248 _ => return None,
249 };
250 Some(Decision::single(Verdict::Review, TAINT_RULE, reason))
251 }
252
253 fn names_secret(&self, atom: &AtomicAction) -> bool {
254 [&self.deny, &self.repo_review, &self.allow, &self.review]
258 .into_iter()
259 .flatten()
260 .any(|g| {
261 let id = g.id.as_str();
262 (id == SECRET_RULE
263 || id
264 .strip_prefix(REPO_RULE_PREFIX)
265 .is_some_and(|rest| rest == SECRET_RULE))
266 && g.matches(atom)
267 })
268 }
269}
270
271fn secret_reason(secret: &Secret, atom: &AtomicAction) -> String {
272 format!(
273 "the session read secret material ({}); {} could carry it out",
274 secret.source,
275 atom.describe()
276 )
277}