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