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