Skip to main content

openmoat_core/engine/
injection.rs

1//! Injected input (ADR-057): which call outputs are untrusted, what of them is
2//! kept, and how a later call that acts on it is tightened.
3//!
4//! Only tightens: a later shell command or write whose network target, path
5//! or command came from untrusted content needs review, unless the policy
6//! allows that part of it outright (a host on the allow list, a project
7//! file, `cargo build`). A fetch, a search, an MCP call or an unknown tool
8//! is not checked: following a link or a search result is how reading works,
9//! and the host's own prompt decides those (`fetch: defer`).
10
11use 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
20/// Programs whose output is text from a forge's issues, pull requests and
21/// comments, which anyone may write: their hosts are on the allow list
22/// (`registries`), so they are named here.
23const REMOTE_TEXT_PROGRAMS: [&str; 3] = ["gh", "glab", "tea"];
24
25/// The longest source description kept with a token.
26const MAX_SOURCE_LEN: usize = 200;
27
28impl CompiledPolicy<'_> {
29    /// What the output of `action`, run in `cwd`, is, when it is content an
30    /// attacker may control (`fetch https://docs.example/page`): what session
31    /// taint calls untrusted (a fetch, a web search, an MCP result, an unknown
32    /// tool; a metadata tool too, which does not taint the session, since a
33    /// page sets the title and URL a tab list shows), and a shell command that
34    /// reads from a network host the policy does not allow or from a forge's
35    /// command-line client (`gh issue view`). `None` for everything else: a
36    /// read of a file, a local command.
37    #[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    /// The tokens of `output`, the output of `source` (an action
76    /// [`Self::output_source`] names), worth keeping: hosts other than the
77    /// ones `source` itself reached and than loopback, absolute paths outside
78    /// the project, and commands of its code spans as the classifier reads
79    /// them. Hosts are kept in their canonical spelling
80    /// (`host::canonical`, ADR-063 §4), as a later call's hosts are
81    /// compared. De-duplicated, at most [`MAX_TOKENS_PER_OUTPUT`]: URL hosts
82    /// first, then commands, paths, and words that look like hosts.
83    #[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        // Never kept, so never a parent a later call links to (ADR-063 §4):
92        // `docs.github.com` named by a page on `github.com` links nothing new.
93        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    /// `decision` for `action`, tightened by the tokens of untrusted content
142    /// `seen` knows: a shell command or a write needs review when a host it
143    /// reaches, a file it writes or a command it runs came from that content,
144    /// and the policy does not allow that atom. A command counts only when the
145    /// call also reaches a host or a file outside the project that the policy
146    /// does not allow. Never loosens; a deny stays.
147    #[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        // A command copied from the content counts only when it reaches past
164        // what the policy allows outside the project: a host or a file. Docs
165        // tell people to run `git push origin main`; that is daily work.
166        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    /// The part of `atom` that came from untrusted content, unless the policy
185    /// allows the atom; `None` when no part did.
186    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            // a word that only looks like a host (`app.run`) is compared with
194            // hosts of URLs alone, which prose does not invent, and not with
195            // their parents: it is not a request to any of them
196            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            // a command is compared as the classifier reads it, which also
205            // yields the command a wrapper runs (`sudo`, `env`, `nohup`,
206            // `sh -c`) and leaves `VAR=value` out, at keep and at check alike
207            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    /// The kept URL host a parent domain of `host` matches, and its source:
231    /// a kept URL host links a request to any of its subdomains (ADR-063 §4).
232    /// One probe per parent, nearest first; a parent the policy allows on its
233    /// own (`github.com`) links nothing, so a page naming an allowed site
234    /// does not tighten every call to its subdomains. Word hosts are compared
235    /// exactly: a name in prose is weaker evidence than a URL (ADR-057 §4).
236    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    /// Whether the policy decides `atom` `allow` on its own.
254    fn allows(&self, atom: &AtomicAction) -> bool {
255        self.evaluate_atomic(atom)
256            .is_some_and(|d| d.verdict == Verdict::Allow)
257    }
258
259    /// The commands `line` runs, as [`Self::with_injected`] compares them.
260    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    /// Whether `path` is the project or below it, under either spelling.
279    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    /// `path` as tokens compare it: lowercase where file names ignore case.
295    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
304/// The text a command is compared by: its words joined by spaces, when it has
305/// arguments and is long enough to be distinctive.
306fn 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
311/// One part of a call that came from untrusted content, worded as the reason
312/// names it (the host `x`), and where it came from.
313struct Found {
314    part: String,
315    source: String,
316}
317
318/// One review per source of `found`, naming each part once: "the command `x`,
319/// the host `h` and the path `p` came from …". A planted command makes several
320/// atoms (the command, the host it reaches, the file it writes), and the
321/// person reads one sentence for them, not one per atom.
322fn 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
347/// `a`, `a and b`, `a, b and c`.
348fn 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
356/// The first of `tokens` the session kept, with its source.
357fn 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
366/// The tokens a call's `host` is compared with, one per kind of `kinds`: in
367/// its canonical spelling ([`crate::host::canonical`]), which tokens are kept
368/// in, then as written (lowercase, no trailing dot) when that differs, which
369/// rows kept before ADR-063 hold for an address spelled another way.
370fn 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
389/// `atom`, a `Net` or `Fetch` atom, reaching `host` instead.
390fn 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
399/// The host a network atom reaches.
400fn 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
409/// The file name of a program word (`/usr/bin/gh` → `gh`).
410fn program_name(word: &str) -> &str {
411    word.rsplit(['/', '\\']).next().unwrap_or(word)
412}
413
414/// `text`, cut to [`MAX_SOURCE_LEN`] characters.
415fn 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;