1use super::{CompiledPolicy, EvalContext, UntrustedKind, checked_atoms};
12use crate::action::{Action, AtomicAction};
13use crate::injection::{
14 self, InjectedLookup, MAX_COMMAND_LEN, MAX_TOKENS_PER_OUTPUT, MIN_COMMAND_LEN, Token, TokenKind,
15};
16use crate::programs::NoResolver;
17use crate::realpath::PathResolver;
18use crate::verdict::{Decision, Verdict};
19
20const REMOTE_TEXT_PROGRAMS: [&str; 3] = ["gh", "glab", "tea"];
24
25const MAX_SOURCE_LEN: usize = 200;
27
28impl CompiledPolicy<'_> {
29 #[must_use]
38 pub fn output_source(
39 &self,
40 action: &Action,
41 cwd: &str,
42 paths: &dyn PathResolver,
43 ) -> Option<String> {
44 let ctx = EvalContext {
45 cwd: cwd.to_owned(),
46 ..self.ctx.clone()
47 };
48 let (atoms, _) = checked_atoms(action, &ctx, self.opens_as(), paths);
49 if let Some(exposed) = atoms.iter().find(|atom| UntrustedKind::of(atom).is_some()) {
50 let source = match action.shown() {
51 Action::Fetch { url } => format!("the page fetched from {url}"),
52 Action::WebSearch { query } => format!("the web search results for \"{query}\""),
53 Action::McpTool { name, .. } => format!("the result of MCP tool {name}"),
54 Action::UnknownTool { tool, .. } => format!("the output of tool {tool}"),
55 Action::Shell { command } => format!("the output of `{command}`"),
56 _ => exposed.describe(),
57 };
58 return Some(cut(source));
59 }
60 let Action::Shell { command } = action.shown() else {
61 return None;
62 };
63 let reads_remote = atoms.iter().any(|atom| match atom {
64 AtomicAction::Net { host } | AtomicAction::Fetch { host } => {
65 !crate::host::is_loopback(host) && !self.allows(atom)
66 }
67 AtomicAction::Shell { argv } => argv
68 .first()
69 .is_some_and(|p| REMOTE_TEXT_PROGRAMS.contains(&program_name(p))),
70 _ => false,
71 });
72 reads_remote.then(|| cut(format!("the output of `{command}`")))
73 }
74
75 #[must_use]
84 pub fn injected_tokens(&self, source: &Action, cwd: &str, output: &str) -> Vec<Token> {
85 let found = injection::candidates(output);
86 let ctx = EvalContext {
87 cwd: cwd.to_owned(),
88 ..self.ctx.clone()
89 };
90 let (source_atoms, _) = checked_atoms(source, &ctx, self.opens_as(), &NoResolver);
91 let own_hosts: Vec<String> = source_atoms
94 .iter()
95 .filter_map(host_of)
96 .map(crate::host::canonical)
97 .collect();
98 let host_token = |kind: TokenKind| {
99 let own_hosts = &own_hosts;
100 move |host: String| {
101 let text = crate::host::canonical(&host);
102 let foreign = !own_hosts.contains(&text) && !crate::host::is_loopback(&host);
103 foreign.then_some(Token { kind, text })
104 }
105 };
106 let commands = found
107 .commands
108 .iter()
109 .flat_map(|line| self.commands_of(line, &ctx));
110 let paths = found.paths.iter().filter_map(|raw| {
111 let path = crate::paths::resolve(raw, &ctx.home, ctx.project.as_deref(), None)?;
112 (path.len() > 1 && !self.in_project(&path)).then(|| Token {
113 kind: TokenKind::Path,
114 text: self.path_key(&path),
115 })
116 });
117 let mut tokens: Vec<Token> = Vec::new();
118 let all = found
119 .url_hosts
120 .into_iter()
121 .filter_map(host_token(TokenKind::UrlHost))
122 .chain(commands)
123 .chain(paths)
124 .chain(
125 found
126 .word_hosts
127 .into_iter()
128 .filter_map(host_token(TokenKind::WordHost)),
129 );
130 for token in all {
131 if tokens.len() >= MAX_TOKENS_PER_OUTPUT {
132 break;
133 }
134 if !tokens.contains(&token) {
135 tokens.push(token);
136 }
137 }
138 tokens
139 }
140
141 #[must_use]
148 pub fn with_injected(
149 &self,
150 decision: Decision,
151 action: &Action,
152 paths: &dyn PathResolver,
153 seen: &dyn InjectedLookup,
154 ) -> Decision {
155 let checked = matches!(
156 action.shown(),
157 Action::Shell { .. } | Action::FsWrite { .. } | Action::Patch { .. }
158 );
159 if !checked || decision.verdict == Verdict::Deny {
160 return decision;
161 }
162 let (atoms, _) = self.atoms(action, paths);
163 let reaches_outside = atoms.iter().any(|atom| match atom {
167 AtomicAction::Net { .. } | AtomicAction::Fetch { .. } => !self.allows(atom),
168 AtomicAction::FsRead { path } | AtomicAction::FsWrite { path } => {
169 !self.in_project(path) && !self.allows(atom)
170 }
171 _ => false,
172 });
173 let found = atoms
174 .iter()
175 .filter(|atom| reaches_outside || !matches!(atom, AtomicAction::Shell { .. }))
176 .filter_map(|atom| self.injected(atom, seen))
177 .collect();
178 planted(found).into_iter().fold(decision, |mut acc, next| {
179 acc.merge(next);
180 acc
181 })
182 }
183
184 fn injected(&self, atom: &AtomicAction, seen: &dyn InjectedLookup) -> Option<Found> {
187 let (token, source) = match atom {
188 AtomicAction::Net { host } | AtomicAction::Fetch { host } => {
189 let kinds = [TokenKind::UrlHost, TokenKind::WordHost];
190 probe(seen, host_tokens(host, &kinds))
191 .or_else(|| self.parent_source(atom, host, seen))
192 }
193 AtomicAction::NetWord { host } => probe(seen, host_tokens(host, &[TokenKind::UrlHost])),
197 AtomicAction::FsWrite { path } => probe(
198 seen,
199 [Token {
200 kind: TokenKind::Path,
201 text: self.path_key(path),
202 }],
203 ),
204 AtomicAction::Shell { argv } => probe(
208 seen,
209 command_text(argv).map(|text| Token {
210 kind: TokenKind::Command,
211 text,
212 }),
213 ),
214 _ => None,
215 }?;
216 if self.allows(atom) {
217 return None;
218 }
219 let what = match token.kind {
220 TokenKind::UrlHost | TokenKind::WordHost => "host",
221 TokenKind::Path => "path",
222 TokenKind::Command => "command",
223 };
224 Some(Found {
225 part: format!("the {what} `{}`", token.text),
226 source,
227 })
228 }
229
230 fn parent_source(
237 &self,
238 atom: &AtomicAction,
239 host: &str,
240 seen: &dyn InjectedLookup,
241 ) -> Option<(Token, String)> {
242 let host = crate::host::canonical(host);
243 crate::host::parents(&host).find_map(|parent| {
244 let token = Token {
245 kind: TokenKind::UrlHost,
246 text: parent.to_owned(),
247 };
248 let source = seen.source_of(&token)?;
249 (!self.allows(&with_host(atom, parent))).then_some((token, source))
250 })
251 }
252
253 fn allows(&self, atom: &AtomicAction) -> bool {
255 self.evaluate_atomic(atom)
256 .is_some_and(|d| d.verdict == Verdict::Allow)
257 }
258
259 fn commands_of(&self, line: &str, ctx: &EvalContext) -> Vec<Token> {
261 let action = Action::Shell {
262 command: line.to_owned(),
263 };
264 let (atoms, _) = checked_atoms(&action, ctx, self.opens_as(), &NoResolver);
265 atoms
266 .iter()
267 .filter_map(|atom| match atom {
268 AtomicAction::Shell { argv } => command_text(argv),
269 _ => None,
270 })
271 .map(|text| Token {
272 kind: TokenKind::Command,
273 text,
274 })
275 .collect()
276 }
277
278 fn in_project(&self, path: &str) -> bool {
280 let key = self.path_key(path);
281 [&self.ctx.project, &self.ctx.real_project]
282 .into_iter()
283 .flatten()
284 .any(|root| {
285 let root = self.path_key(root);
286 let root = root.trim_end_matches('/');
287 key == root
288 || key
289 .strip_prefix(root)
290 .is_some_and(|rest| rest.starts_with('/'))
291 })
292 }
293
294 fn path_key(&self, path: &str) -> String {
296 if self.ctx.case_insensitive_paths {
297 path.to_lowercase()
298 } else {
299 path.to_owned()
300 }
301 }
302}
303
304fn command_text(argv: &[String]) -> Option<String> {
307 let text = argv.join(" ");
308 (argv.len() >= 2 && (MIN_COMMAND_LEN..=MAX_COMMAND_LEN).contains(&text.len())).then_some(text)
309}
310
311struct Found {
314 part: String,
315 source: String,
316}
317
318fn planted(found: Vec<Found>) -> Vec<Decision> {
323 let mut sources: Vec<(String, Vec<String>)> = Vec::new();
324 for Found { part, source } in found {
325 match sources.iter_mut().find(|(known, _)| *known == source) {
326 Some((_, parts)) if !parts.contains(&part) => parts.push(part),
327 Some(_) => {}
328 None => sources.push((source, vec![part])),
329 }
330 }
331 sources
332 .into_iter()
333 .map(|(source, parts)| {
334 Decision::single(
335 Verdict::Review,
336 injection::INJECTED_RULE,
337 format!(
338 "{} came from {source}, untrusted content this session read; the call may \
339 follow an instruction planted there",
340 listed(&parts)
341 ),
342 )
343 })
344 .collect()
345}
346
347fn listed(parts: &[String]) -> String {
349 match parts {
350 [] => String::new(),
351 [one] => one.clone(),
352 [init @ .., last] => format!("{} and {last}", init.join(", ")),
353 }
354}
355
356fn probe(
358 seen: &dyn InjectedLookup,
359 tokens: impl IntoIterator<Item = Token>,
360) -> Option<(Token, String)> {
361 tokens
362 .into_iter()
363 .find_map(|token| seen.source_of(&token).map(|source| (token, source)))
364}
365
366fn host_tokens(host: &str, kinds: &[TokenKind]) -> Vec<Token> {
371 let canonical = crate::host::canonical(host);
372 let written = host.trim_end_matches('.').to_ascii_lowercase();
373 let spellings = if written == canonical {
374 vec![canonical]
375 } else {
376 vec![canonical, written]
377 };
378 spellings
379 .iter()
380 .flat_map(|text| {
381 kinds.iter().map(|kind| Token {
382 kind: *kind,
383 text: text.clone(),
384 })
385 })
386 .collect()
387}
388
389fn with_host(atom: &AtomicAction, host: &str) -> AtomicAction {
391 let host = host.to_owned();
392 if matches!(atom, AtomicAction::Fetch { .. }) {
393 AtomicAction::Fetch { host }
394 } else {
395 AtomicAction::Net { host }
396 }
397}
398
399fn host_of(atom: &AtomicAction) -> Option<&str> {
401 match atom {
402 AtomicAction::Net { host }
403 | AtomicAction::Fetch { host }
404 | AtomicAction::NetWord { host } => Some(host),
405 _ => None,
406 }
407}
408
409fn program_name(word: &str) -> &str {
411 word.rsplit(['/', '\\']).next().unwrap_or(word)
412}
413
414fn cut(text: String) -> String {
416 if text.chars().count() <= MAX_SOURCE_LEN {
417 return text;
418 }
419 let mut short: String = text.chars().take(MAX_SOURCE_LEN).collect();
420 short.push('…');
421 short
422}
423
424#[cfg(test)]
425mod tests;