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