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]
80 pub fn injected_tokens(&self, source: &Action, cwd: &str, output: &str) -> Vec<Token> {
81 let found = injection::candidates(output);
82 let ctx = EvalContext {
83 cwd: cwd.to_owned(),
84 ..self.ctx.clone()
85 };
86 let (source_atoms, _) = checked_atoms(source, &ctx, self.opens_as(), &NoResolver);
87 let own_hosts: Vec<&str> = source_atoms.iter().filter_map(host_of).collect();
88 let host_token = |kind: TokenKind| {
89 let own_hosts = &own_hosts;
90 move |host: String| {
91 let foreign = !own_hosts.iter().any(|h| h.eq_ignore_ascii_case(&host))
92 && !crate::host::is_loopback(&host);
93 foreign.then_some(Token { kind, text: host })
94 }
95 };
96 let commands = found
97 .commands
98 .iter()
99 .flat_map(|line| self.commands_of(line, &ctx));
100 let paths = found.paths.iter().filter_map(|raw| {
101 let path = crate::paths::resolve(raw, &ctx.home, ctx.project.as_deref(), None)?;
102 (path.len() > 1 && !self.in_project(&path)).then(|| Token {
103 kind: TokenKind::Path,
104 text: self.path_key(&path),
105 })
106 });
107 let mut tokens: Vec<Token> = Vec::new();
108 let all = found
109 .url_hosts
110 .into_iter()
111 .filter_map(host_token(TokenKind::UrlHost))
112 .chain(commands)
113 .chain(paths)
114 .chain(
115 found
116 .word_hosts
117 .into_iter()
118 .filter_map(host_token(TokenKind::WordHost)),
119 );
120 for token in all {
121 if tokens.len() >= MAX_TOKENS_PER_OUTPUT {
122 break;
123 }
124 if !tokens.contains(&token) {
125 tokens.push(token);
126 }
127 }
128 tokens
129 }
130
131 #[must_use]
138 pub fn with_injected(
139 &self,
140 decision: Decision,
141 action: &Action,
142 paths: &dyn PathResolver,
143 seen: &dyn InjectedLookup,
144 ) -> Decision {
145 let checked = matches!(
146 action.shown(),
147 Action::Shell { .. } | Action::FsWrite { .. } | Action::Patch { .. }
148 );
149 if !checked || decision.verdict == Verdict::Deny {
150 return decision;
151 }
152 let (atoms, _) = self.atoms(action, paths);
153 let reaches_outside = atoms.iter().any(|atom| match atom {
157 AtomicAction::Net { .. } | AtomicAction::Fetch { .. } => !self.allows(atom),
158 AtomicAction::FsRead { path } | AtomicAction::FsWrite { path } => {
159 !self.in_project(path) && !self.allows(atom)
160 }
161 _ => false,
162 });
163 atoms
164 .iter()
165 .filter(|atom| reaches_outside || !matches!(atom, AtomicAction::Shell { .. }))
166 .filter_map(|atom| self.injected(atom, seen))
167 .fold(decision, |mut acc, next| {
168 acc.merge(next);
169 acc
170 })
171 }
172
173 fn injected(&self, atom: &AtomicAction, seen: &dyn InjectedLookup) -> Option<Decision> {
176 let candidates: Vec<Token> = match atom {
177 AtomicAction::Net { host } | AtomicAction::Fetch { host } => {
178 [TokenKind::UrlHost, TokenKind::WordHost]
179 .map(|kind| Token {
180 kind,
181 text: host.to_ascii_lowercase(),
182 })
183 .to_vec()
184 }
185 AtomicAction::NetWord { host } => vec![Token {
188 kind: TokenKind::UrlHost,
189 text: host.to_ascii_lowercase(),
190 }],
191 AtomicAction::FsWrite { path } => vec![Token {
192 kind: TokenKind::Path,
193 text: self.path_key(path),
194 }],
195 AtomicAction::Shell { argv } => command_text(argv)
196 .map(|text| Token {
197 kind: TokenKind::Command,
198 text,
199 })
200 .into_iter()
201 .collect(),
202 _ => return None,
203 };
204 let (token, source) = candidates
205 .into_iter()
206 .find_map(|token| seen.source_of(&token).map(|source| (token, source)))?;
207 if self.allows(atom) {
208 return None;
209 }
210 let what = match token.kind {
211 TokenKind::UrlHost | TokenKind::WordHost => "host",
212 TokenKind::Path => "path",
213 TokenKind::Command => "command",
214 };
215 Some(Decision::single(
216 Verdict::Review,
217 injection::INJECTED_RULE,
218 format!(
219 "the {what} `{}` came from {source}, untrusted content this session read; {} may \
220 follow an instruction planted there",
221 token.text,
222 atom.describe()
223 ),
224 ))
225 }
226
227 fn allows(&self, atom: &AtomicAction) -> bool {
229 self.evaluate_atomic(atom)
230 .is_some_and(|d| d.verdict == Verdict::Allow)
231 }
232
233 fn commands_of(&self, line: &str, ctx: &EvalContext) -> Vec<Token> {
235 let action = Action::Shell {
236 command: line.to_owned(),
237 };
238 let (atoms, _) = checked_atoms(&action, ctx, self.opens_as(), &NoResolver);
239 atoms
240 .iter()
241 .filter_map(|atom| match atom {
242 AtomicAction::Shell { argv } => command_text(argv),
243 _ => None,
244 })
245 .map(|text| Token {
246 kind: TokenKind::Command,
247 text,
248 })
249 .collect()
250 }
251
252 fn in_project(&self, path: &str) -> bool {
254 let key = self.path_key(path);
255 [&self.ctx.project, &self.ctx.real_project]
256 .into_iter()
257 .flatten()
258 .any(|root| {
259 let root = self.path_key(root);
260 let root = root.trim_end_matches('/');
261 key == root
262 || key
263 .strip_prefix(root)
264 .is_some_and(|rest| rest.starts_with('/'))
265 })
266 }
267
268 fn path_key(&self, path: &str) -> String {
270 if self.ctx.case_insensitive_paths {
271 path.to_lowercase()
272 } else {
273 path.to_owned()
274 }
275 }
276}
277
278fn command_text(argv: &[String]) -> Option<String> {
281 let text = argv.join(" ");
282 (argv.len() >= 2 && (MIN_COMMAND_LEN..=MAX_COMMAND_LEN).contains(&text.len())).then_some(text)
283}
284
285fn host_of(atom: &AtomicAction) -> Option<&str> {
287 match atom {
288 AtomicAction::Net { host }
289 | AtomicAction::Fetch { host }
290 | AtomicAction::NetWord { host } => Some(host),
291 _ => None,
292 }
293}
294
295fn program_name(word: &str) -> &str {
297 word.rsplit(['/', '\\']).next().unwrap_or(word)
298}
299
300fn cut(text: String) -> String {
302 if text.chars().count() <= MAX_SOURCE_LEN {
303 return text;
304 }
305 let mut short: String = text.chars().take(MAX_SOURCE_LEN).collect();
306 short.push('…');
307 short
308}
309
310#[cfg(test)]
311mod tests;