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, 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), and a shell command that reads from a network host the policy
33    /// does not allow or from a forge's command-line client (`gh issue view`).
34    /// `None` for everything else: a read of a file, a local command.
35    #[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    /// The tokens of `output`, the output of `source` (an action
74    /// [`Self::output_source`] names), worth keeping: hosts other than the
75    /// ones `source` itself reached and than loopback, absolute paths outside
76    /// the project, and commands of its code spans as the classifier reads
77    /// them. Hosts are kept in their canonical spelling
78    /// (`host::canonical`, ADR-063 §4), as a later call's hosts are
79    /// compared. De-duplicated, at most [`MAX_TOKENS_PER_OUTPUT`]: URL hosts
80    /// first, then commands, paths, and words that look like hosts.
81    #[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        // Never kept, so never a parent a later call links to (ADR-063 §4):
90        // `docs.github.com` named by a page on `github.com` links nothing new.
91        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    /// `decision` for `action`, tightened by the tokens of untrusted content
140    /// `seen` knows: a shell command or a write needs review when a host it
141    /// reaches, a file it writes or a command it runs came from that content,
142    /// and the policy does not allow that atom. A command counts only when the
143    /// call also reaches a host or a file outside the project that the policy
144    /// does not allow. Never loosens; a deny stays.
145    #[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        // A command copied from the content counts only when it reaches past
162        // what the policy allows outside the project: a host or a file. Docs
163        // tell people to run `git push origin main`; that is daily work.
164        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    /// The part of `atom` that came from untrusted content, unless the policy
183    /// allows the atom; `None` when no part did.
184    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            // a word that only looks like a host (`app.run`) is compared with
192            // hosts of URLs alone, which prose does not invent, and not with
193            // their parents: it is not a request to any of them
194            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            // a command is compared as the classifier reads it, which also
203            // yields the command a wrapper runs (`sudo`, `env`, `nohup`,
204            // `sh -c`) and leaves `VAR=value` out, at keep and at check alike
205            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    /// The kept URL host a parent domain of `host` matches, and its source:
229    /// a kept URL host links a request to any of its subdomains (ADR-063 §4).
230    /// One probe per parent, nearest first; a parent the policy allows on its
231    /// own (`github.com`) links nothing, so a page naming an allowed site
232    /// does not tighten every call to its subdomains. Word hosts are compared
233    /// exactly: a name in prose is weaker evidence than a URL (ADR-057 §4).
234    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    /// Whether the policy decides `atom` `allow` on its own.
252    fn allows(&self, atom: &AtomicAction) -> bool {
253        self.evaluate_atomic(atom)
254            .is_some_and(|d| d.verdict == Verdict::Allow)
255    }
256
257    /// The commands `line` runs, as [`Self::with_injected`] compares them.
258    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    /// Whether `path` is the project or below it, under either spelling.
277    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    /// `path` as tokens compare it: lowercase where file names ignore case.
293    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
302/// The text a command is compared by: its words joined by spaces, when it has
303/// arguments and is long enough to be distinctive.
304fn 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
309/// One part of a call that came from untrusted content, worded as the reason
310/// names it (the host `x`), and where it came from.
311struct Found {
312    part: String,
313    source: String,
314}
315
316/// One review per source of `found`, naming each part once: "the command `x`,
317/// the host `h` and the path `p` came from …". A planted command makes several
318/// atoms (the command, the host it reaches, the file it writes), and the
319/// person reads one sentence for them, not one per atom.
320fn 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
345/// `a`, `a and b`, `a, b and c`.
346fn 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
354/// The first of `tokens` the session kept, with its source.
355fn 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
364/// The tokens a call's `host` is compared with, one per kind of `kinds`: in
365/// its canonical spelling ([`crate::host::canonical`]), which tokens are kept
366/// in, then as written (lowercase, no trailing dot) when that differs, which
367/// rows kept before ADR-063 hold for an address spelled another way.
368fn 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
387/// `atom`, a `Net` or `Fetch` atom, reaching `host` instead.
388fn 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
397/// The host a network atom reaches.
398fn 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
407/// The file name of a program word (`/usr/bin/gh` → `gh`).
408fn program_name(word: &str) -> &str {
409    word.rsplit(['/', '\\']).next().unwrap_or(word)
410}
411
412/// `text`, cut to [`MAX_SOURCE_LEN`] characters.
413fn 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;