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