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