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