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