Skip to main content

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