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. De-duplicated, at most [`MAX_TOKENS_PER_OUTPUT`]: URL hosts
78    /// first, then commands, paths, and words that look like hosts.
79    #[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    /// `decision` for `action`, tightened by the tokens of untrusted content
132    /// `seen` knows: a shell command or a write needs review when a host it
133    /// reaches, a file it writes or a command it runs came from that content,
134    /// and the policy does not allow that atom. A command counts only when the
135    /// call also reaches a host or a file outside the project that the policy
136    /// does not allow. Never loosens; a deny stays.
137    #[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        // A command copied from the content counts only when it reaches past
154        // what the policy allows outside the project: a host or a file. Docs
155        // tell people to run `git push origin main`; that is daily work.
156        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    /// The review `atom` needs when one of its parts came from untrusted
174    /// content; `None` when none did or the policy allows the atom.
175    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            // a word that only looks like a host (`app.run`) is compared with
186            // hosts of URLs alone, which prose does not invent
187            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    /// Whether the policy decides `atom` `allow` on its own.
228    fn allows(&self, atom: &AtomicAction) -> bool {
229        self.evaluate_atomic(atom)
230            .is_some_and(|d| d.verdict == Verdict::Allow)
231    }
232
233    /// The commands `line` runs, as [`Self::with_injected`] compares them.
234    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    /// Whether `path` is the project or below it, under either spelling.
253    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    /// `path` as tokens compare it: lowercase where file names ignore case.
269    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
278/// The text a command is compared by: its words joined by spaces, when it has
279/// arguments and is long enough to be distinctive.
280fn 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
285/// The host a network atom reaches.
286fn 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
295/// The file name of a program word (`/usr/bin/gh` → `gh`).
296fn program_name(word: &str) -> &str {
297    word.rsplit(['/', '\\']).next().unwrap_or(word)
298}
299
300/// `text`, cut to [`MAX_SOURCE_LEN`] characters.
301fn 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;