Skip to main content

openmoat_core/
engine.rs

1//! The decision engine (docs/POLICY.md §4, docs/ARCHITECTURE.md §4).
2//!
3//! Per atomic action: `deny → allow → review → defaults`. Across the atomic
4//! actions of one tool call: strictest verdict wins. Deny rules are absolute;
5//! an allow rule can never override a deny, so "deny by default with holes"
6//! is expressed through per-kind `defaults`, never through a deny rule.
7
8use std::borrow::Cow;
9
10use crate::action::{Action, AtomicAction};
11use crate::kind::Kind;
12use crate::pattern::{GlobPattern, ShellPattern, any_match};
13use crate::policy::{Policy, PolicyError, RuleGroup};
14use crate::programs::{self, NoResolver, ProgramResolver};
15use crate::realpath::PathResolver;
16use crate::verdict::{Decision, Verdict};
17
18mod aliases;
19mod atoms;
20mod contents;
21mod context;
22mod egress;
23mod injection;
24mod staging;
25mod taint;
26
27use atoms::{checked_atoms, value_read_atoms};
28pub use contents::HOLDS_PROTECTED_RULE;
29pub use staging::{STAGING_ATOM, Staging};
30pub use taint::{Secret, TAINT_RULE, Taint, UntrustedKind};
31
32/// Everything the engine needs from the environment. Supplied by the caller.
33#[derive(Debug, Clone)]
34pub struct EvalContext {
35    /// The user's home directory, for `~` and `$HOME`.
36    pub home: String,
37    /// The trusted project root, for `${project}`. `None` when the session has
38    /// no project the caller is willing to trust (it would be the home directory,
39    /// one of its ancestors or a filesystem root): every pattern naming
40    /// `${project}` then matches nothing, so project-scoped allows do not apply.
41    pub project: Option<String>,
42    /// `home` with its symlinks resolved, when that differs (a home on a linked
43    /// volume, a Windows 8.3 short name). Patterns naming `~` match under both
44    /// spellings, since the caller's [`PathResolver`] reports paths in this one.
45    pub real_home: Option<String>,
46    /// `project` with its symlinks resolved, when that differs (macOS `/tmp` →
47    /// `/private/tmp`, `~/code` → `/Volumes/dev/code`). Patterns naming
48    /// `${project}` match under both spellings, exclusions included, so a file
49    /// in the project is the project's however its path is written. A link
50    /// *inside* the project changes nothing here: its target is checked as is.
51    pub real_project: Option<String>,
52    /// Directories an environment variable moved out of the home directory
53    /// (`MOAT_HOME`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `CURSOR_CONFIG_DIR`), as
54    /// `(default, moved)` pairs such as `("~/.claude", "/srv/claude")`. A
55    /// pattern naming `default` or a path below it also matches the same path
56    /// under `moved`, so rules about a host's directory follow it when it moves.
57    pub moved_dirs: Vec<(String, String)>,
58    /// The directory relative paths are taken from.
59    pub cwd: String,
60    /// Whether file paths compare case-insensitively, as on the default macOS
61    /// and Windows file systems. Host names always do; names never do.
62    pub case_insensitive_paths: bool,
63}
64
65/// A policy with all patterns compiled for one evaluation context.
66///
67/// Compile once per process (or per policy reload) and reuse for every tool
68/// call; compilation dominates the cost of a single decision. It borrows the
69/// policy ([`Self::compile`]) or owns it ([`Self::compile_owned`], for a caller
70/// such as `openmoat-sdk` that keeps one engine for as long as it likes).
71#[derive(Debug)]
72pub struct CompiledPolicy<'p> {
73    policy: Cow<'p, Policy>,
74    ctx: EvalContext,
75    deny: Vec<CompiledGroup>,
76    repo_review: Vec<CompiledGroup>,
77    allow: Vec<CompiledGroup>,
78    review: Vec<CompiledGroup>,
79    defer: Vec<CompiledGroup>,
80    /// Paths whose writes need review once the session read untrusted content (`taint`).
81    protected: Vec<GlobPattern>,
82    /// The folders deny rules' paths lie in, which reading or writing needs review for.
83    folders: contents::ProtectedFolders,
84    /// Where a read is not collection (ADR-061, proposed): the project, read
85    /// roots, caches, temporary folders and `staging.ignore`.
86    inside: Vec<GlobPattern>,
87}
88
89#[derive(Debug)]
90struct CompiledGroup {
91    /// The group's id, as decisions name it.
92    id: String,
93    /// The group's `reason`, which prefixes each of its reasons.
94    reason: Option<String>,
95    shell: Vec<ShellPattern>,
96    /// Glob patterns per kind, indexed by `Kind as usize`; the shell slot is empty.
97    globs: [Vec<GlobPattern>; Kind::ALL.len()],
98    /// For an allow group, its `net` and `fetch` patterns without the
99    /// `host:port` ones ([`crate::host::port_pattern`]), which a host without a
100    /// port is matched against: `*:8443` allows port 8443 of every host to the
101    /// proxy, and must not also match an IPv6 address whose text ends in
102    /// `:8443`. Deny and review rules keep them: there they only refuse more.
103    bare_hosts: Option<[Vec<GlobPattern>; Kind::ALL.len()]>,
104}
105
106/// Whether `pattern` is a `net` or `fetch` pattern written `host:port`, not an
107/// exclusion.
108fn names_port(pattern: &str) -> bool {
109    !pattern.starts_with('!') && crate::host::port_pattern(pattern).is_some()
110}
111
112impl<'p> CompiledPolicy<'p> {
113    /// Compile every pattern of `policy` for `ctx`. Fails on a pattern that does
114    /// not compile, and on a `moved_dirs` entry whose `default` could alias every
115    /// pattern (empty, bare `/`, or not an absolute or `~/`-rooted directory).
116    pub fn compile(policy: &'p Policy, ctx: &EvalContext) -> Result<Self, PolicyError> {
117        Self::build(Cow::Borrowed(policy), ctx)
118    }
119
120    /// [`Self::compile`] for a policy the result keeps, so it borrows nothing.
121    pub fn compile_owned(
122        policy: Policy,
123        ctx: &EvalContext,
124    ) -> Result<CompiledPolicy<'static>, PolicyError> {
125        CompiledPolicy::build(Cow::Owned(policy), ctx)
126    }
127
128    fn build(policy: Cow<'p, Policy>, ctx: &EvalContext) -> Result<Self, PolicyError> {
129        ctx.validate_moved_dirs()?;
130        let compile_list = |groups: &[RuleGroup], allow: bool| {
131            groups
132                .iter()
133                .map(|g| CompiledGroup::compile(g, ctx, allow))
134                .collect::<Result<Vec<_>, _>>()
135        };
136        Ok(Self {
137            ctx: ctx.clone(),
138            deny: compile_list(&policy.deny, false)?,
139            repo_review: compile_list(&policy.repo_review, false)?,
140            allow: compile_list(&policy.allow, true)?,
141            review: compile_list(&policy.review, false)?,
142            defer: compile_list(&policy.defer, false)?,
143            protected: taint::compile_protected(&policy, ctx)?,
144            folders: contents::ProtectedFolders::compile(&policy.deny, ctx),
145            inside: staging::compile_inside(&policy, ctx)?,
146            policy,
147        })
148    }
149
150    /// The policy this was compiled from.
151    #[must_use]
152    pub fn policy(&self) -> &Policy {
153        &self.policy
154    }
155
156    /// The context this was compiled for.
157    #[must_use]
158    pub fn context(&self) -> &EvalContext {
159        &self.ctx
160    }
161
162    /// Evaluate one atomic action: deny → allow → review → defer → default, with a
163    /// repository policy's review rules ([`Policy::repo_review`]) tried before allow.
164    ///
165    /// A repository review never softens what the user's policy alone decides
166    /// for the atom: per atom the result is the stricter of the two (ADR-022),
167    /// so a repository's `review: [{net: ['*']}]` leaves a host the `net: deny`
168    /// default refuses denied.
169    ///
170    /// Returns `None` for a whole-pipeline atom that no rule mentions: pipelines
171    /// are only there so rules like `curl * | sh` can see across `|`; the
172    /// per-sub-command atoms carry the default verdict.
173    #[must_use]
174    pub fn evaluate_atomic(&self, action: &AtomicAction) -> Option<Decision> {
175        if let Some(deny) = Self::hit(&self.deny, Verdict::Deny, action) {
176            return Some(deny);
177        }
178        let own = self.own_rules(action).or_else(|| {
179            (!matches!(action, AtomicAction::Pipeline { .. })).then(|| {
180                let (verdict, rule_id) = self.policy.defaults.for_atom(action);
181                Decision::single(
182                    verdict,
183                    &rule_id,
184                    format!("no rule matched {}", action.describe()),
185                )
186            })
187        });
188        match Self::hit(&self.repo_review, Verdict::Review, action) {
189            Some(repo) => Some(match own {
190                Some(own) if own.verdict > repo.verdict => own,
191                _ => repo,
192            }),
193            None => own,
194        }
195    }
196
197    /// The first of deny, repository review, allow, review and defer whose rules
198    /// match `action`, with every matching rule; `None` when no rule does.
199    fn matched_rules(&self, action: &AtomicAction) -> Option<Decision> {
200        Self::hit(&self.deny, Verdict::Deny, action)
201            .or_else(|| Self::hit(&self.repo_review, Verdict::Review, action))
202            .or_else(|| self.own_rules(action))
203    }
204
205    /// The first of the user's allow, review and defer lists whose rules match.
206    fn own_rules(&self, action: &AtomicAction) -> Option<Decision> {
207        Self::hit(&self.allow, Verdict::Allow, action)
208            .or_else(|| Self::hit(&self.review, Verdict::Review, action))
209            .or_else(|| Self::hit(&self.defer, Verdict::Defer, action))
210    }
211
212    /// `verdict` with every group of `groups` that matches `action`; `None`
213    /// when none does.
214    fn hit(groups: &[CompiledGroup], verdict: Verdict, action: &AtomicAction) -> Option<Decision> {
215        let mut hit = Decision::new(verdict);
216        for g in groups.iter().filter(|g| g.matches(action)) {
217            let why = g.reason.as_ref().map_or_else(
218                || action.describe(),
219                |r| format!("{r}: {}", action.describe()),
220            );
221            hit.push(&g.id, why);
222        }
223        (!hit.rules.is_empty()).then_some(hit)
224    }
225}
226
227impl CompiledGroup {
228    /// `allow` says whether the group is an allow rule ([`Self::bare_hosts`]).
229    fn compile(group: &RuleGroup, ctx: &EvalContext, allow: bool) -> Result<Self, PolicyError> {
230        let shell = group
231            .shell
232            .iter()
233            .map(|p| ShellPattern::compile(p))
234            .collect::<Result<_, _>>()?;
235        let mut globs: [Vec<GlobPattern>; Kind::ALL.len()] = Default::default();
236        let mut bare: [Vec<GlobPattern>; Kind::ALL.len()] = Default::default();
237        for kind in Kind::ALL.into_iter().filter(|k| *k != Kind::Shell) {
238            let case_insensitive = match kind {
239                Kind::FsRead | Kind::FsWrite => ctx.case_insensitive_paths,
240                Kind::Net | Kind::Fetch => true,
241                _ => false,
242            };
243            let compile = |patterns: &mut dyn Iterator<Item = &String>| {
244                patterns
245                    .flat_map(|p| ctx.spellings(p))
246                    .map(|p| GlobPattern::compile(&p, case_insensitive))
247                    .collect::<Result<Vec<_>, _>>()
248            };
249            let patterns = group.patterns(kind);
250            globs[kind as usize] = compile(&mut patterns.iter())?;
251            if matches!(kind, Kind::Net | Kind::Fetch) {
252                bare[kind as usize] = compile(&mut patterns.iter().filter(|p| !names_port(p)))?;
253            }
254        }
255        Ok(Self {
256            id: group.id.clone(),
257            reason: group.reason.clone(),
258            shell,
259            globs,
260            bare_hosts: allow.then_some(bare),
261        })
262    }
263
264    fn matches(&self, action: &AtomicAction) -> bool {
265        match (action, action.subject()) {
266            (AtomicAction::Shell { argv } | AtomicAction::Pipeline { argv }, _) => {
267                any_match(&self.shell, argv.as_slice())
268            }
269            (_, Some(subject)) => {
270                let globs = match &self.bare_hosts {
271                    Some(bare)
272                        if matches!(
273                            action,
274                            AtomicAction::Net { .. } | AtomicAction::Fetch { .. }
275                        ) && crate::host::port_pattern(subject).is_none() =>
276                    {
277                        bare
278                    }
279                    _ => &self.globs,
280                };
281                action
282                    .kind()
283                    .rule_kinds()
284                    .iter()
285                    .any(|k| any_match(&globs[*k as usize], subject))
286            }
287            (_, None) => false,
288        }
289    }
290}
291
292impl CompiledPolicy<'_> {
293    /// Decide one host action without executable resolution.
294    ///
295    /// Unparseable input yields the policy's catch-all verdict (`review` or
296    /// `defer`) with the parser's reason, never `allow`.
297    #[must_use]
298    pub fn decide(&self, action: &Action) -> Decision {
299        self.decide_with(action, &NoResolver, &NoResolver)
300    }
301
302    /// The atomic actions [`Self::decide_with`] evaluates for `action`, with an
303    /// extra atom for every path an unquoted glob operand names in the
304    /// directories `paths` lists, and for every read or write `paths` resolves
305    /// through a symlink; and the reason when part of the action cannot be
306    /// classified or a glob not expanded in full (`review`).
307    #[must_use]
308    pub fn atoms(
309        &self,
310        action: &Action,
311        paths: &dyn PathResolver,
312    ) -> (Vec<AtomicAction>, Option<String>) {
313        let (atoms, unparsed) = self.all_atoms(action, paths);
314        (atoms, unparsed.map(|u| u.reason))
315    }
316
317    /// [`checked_atoms`] and the atoms of any token or placeholder they send
318    /// where it does not belong ([`egress`]), and of personal data they send
319    /// that a rule of this policy names (ADR-052).
320    fn all_atoms(
321        &self,
322        action: &Action,
323        paths: &dyn PathResolver,
324    ) -> (Vec<AtomicAction>, Option<crate::shell::Unparsed>) {
325        let (mut atoms, unparsed) = checked_atoms(action, &self.ctx, self.opens_as(), paths);
326        let egress = egress::atoms(action, &atoms, &self.policy.secrets)
327            .into_iter()
328            .filter(|atom| !egress::is_personal(atom) || self.matched_rules(atom).is_some());
329        atoms.extend(egress);
330        (atoms, unparsed)
331    }
332
333    /// How a read-only page open is decided under this policy (ADR-047).
334    fn opens_as(&self) -> atoms::Opens {
335        atoms::Opens {
336            as_fetch: self.policy.browser_open_is_fetch(),
337        }
338    }
339
340    /// The commands of `action` that publish local commits (`git push`, `gh pr
341    /// create`), each with the directories it may run in. Only a shell command
342    /// has any.
343    #[must_use]
344    pub fn pushes(&self, action: &Action) -> Vec<crate::shell::push::Push> {
345        let Action::Shell { command } = action.shown() else {
346            return Vec::new();
347        };
348        crate::shell::push::pushes(
349            command,
350            &crate::shell::ShellContext {
351                home: &self.ctx.home,
352                cwd: &[Some(self.ctx.cwd.clone())],
353            },
354        )
355    }
356
357    /// The files `action` runs as code (ADR-039): a script an interpreter or a
358    /// shell runs (`python3 x.py`, `bash x.sh`, `source x`), a file it loads
359    /// first (`node -r ./x.js`) and a program named by a path (`./x`), through
360    /// every wrapper the classifier unwraps (`env`, `time`, `bash -c '…'`), as
361    /// canonical paths, each also as `paths` resolves it through a symlink.
362    /// Only a shell command runs any; a test runner, build tool or package
363    /// script (`pytest`, `npm test`, `go run .`) names none.
364    #[must_use]
365    pub fn executed(&self, action: &Action, paths: &dyn PathResolver) -> Vec<String> {
366        let Action::Shell { command } = action.shown() else {
367            return Vec::new();
368        };
369        atoms::executed_files(command, &self.ctx, paths)
370    }
371
372    /// Decide one host action, resolving shell programs through `resolver` so
373    /// that `executables` pins and installation pins are enforced, and paths
374    /// through `paths` so a symlink cannot move a read or write past a rule.
375    #[must_use]
376    pub fn decide_with(
377        &self,
378        action: &Action,
379        resolver: &dyn ProgramResolver,
380        paths: &dyn PathResolver,
381    ) -> Decision {
382        let (atoms, unparsed) = self.all_atoms(action, paths);
383        let pins = atoms.iter().filter_map(|atom| match atom {
384            AtomicAction::Shell { argv } => {
385                programs::check(&argv[0], &self.policy.executables, resolver)
386            }
387            _ => None,
388        });
389        // Another spelling of a command (OpenMoat's binary under another name
390        // or case) adds the deny or review its rules give, and nothing else.
391        let aliased = aliases::aliases(&atoms, &self.ctx, resolver, paths)
392            .into_iter()
393            .filter_map(|atom| self.matched_rules(&atom))
394            .filter(|hit| matches!(hit.verdict, Verdict::Deny | Verdict::Review));
395        // A path only an MCP argument's value looks like, under a key the
396        // adapter does not know, adds the deny its read would get and nothing
397        // else: no new review or defer for a value that is not a path.
398        let value_reads = value_read_atoms(action, &self.ctx, paths)
399            .into_iter()
400            .filter_map(|atom| Self::hit(&self.deny, Verdict::Deny, &atom));
401        // A part that could not be parsed, or arguments the adapter did not
402        // search, may name anything: never allow (defer or review, ADR-025), and let
403        // a deny on the rest still win. A line not read in full, or one past a
404        // classifier limit, needs review: a deny in what was not read must not defer.
405        let verdict = self.policy.defaults.unclassified();
406        let unparsed = unparsed.map(|u| {
407            let verdict = if u.needs_review {
408                Verdict::Review
409            } else {
410                verdict
411            };
412            unparseable(
413                verdict,
414                format!("could not parse action safely: {}", u.reason),
415            )
416        });
417        let unchecked = match action.shown() {
418            Action::McpTool {
419                unchecked: Some(limit),
420                ..
421            } => Some(unparseable(
422                verdict,
423                format!("mcp arguments not fully checked: {limit}"),
424            )),
425            _ => None,
426        };
427        // What the payload does not show may be anything: at least `review`.
428        let partial = match action {
429            Action::Unchecked { reason, .. } => Some(Decision::single(
430                Verdict::Review,
431                crate::action::UNCHECKED_RULE,
432                reason.clone(),
433            )),
434            _ => None,
435        };
436        // A folder holding paths a deny rule names needs review, whatever allows it (ADR-038).
437        let folders = atoms.iter().filter_map(|atom| self.folders.decide(atom));
438        atoms
439            .iter()
440            .filter_map(|atom| self.evaluate_atomic(atom))
441            .chain(folders)
442            .chain(aliased)
443            .chain(value_reads)
444            .chain(pins)
445            .chain(unparsed)
446            .chain(unchecked)
447            .chain(partial)
448            .reduce(|mut acc, next| {
449                acc.merge(next);
450                acc
451            })
452            .unwrap_or_else(|| unparseable(verdict, "action has no evaluable parts".to_owned()))
453    }
454}
455
456/// Input that cannot be classified: the policy's catch-all, never `allow` (ADR-025).
457fn unparseable(verdict: Verdict, reason: String) -> Decision {
458    Decision::single(verdict, "unparseable", reason)
459}
460
461/// Compile `policy` for `ctx` and decide a single action.
462///
463/// Convenience for one-shot callers; long-lived callers should keep a
464/// [`CompiledPolicy`] instead.
465pub fn evaluate(
466    policy: &Policy,
467    ctx: &EvalContext,
468    action: &Action,
469) -> Result<Decision, PolicyError> {
470    Ok(CompiledPolicy::compile(policy, ctx)?.decide(action))
471}
472
473#[cfg(test)]
474mod tests;