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 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
82    /// not compile, and on a `moved_dirs` entry whose `default` could alias every
83    /// pattern (empty, bare `/`, or not an absolute or `~/`-rooted directory).
84    pub fn compile(policy: &'p Policy, ctx: &EvalContext) -> Result<Self, PolicyError> {
85        ctx.validate_moved_dirs()?;
86        let compile_list = |groups: &'p [RuleGroup]| {
87            groups
88                .iter()
89                .map(|g| CompiledGroup::compile(g, ctx))
90                .collect::<Result<Vec<_>, _>>()
91        };
92        Ok(Self {
93            policy,
94            ctx: ctx.clone(),
95            deny: compile_list(&policy.deny)?,
96            repo_ask: compile_list(&policy.repo_ask)?,
97            allow: compile_list(&policy.allow)?,
98            ask: compile_list(&policy.ask)?,
99            protected: taint::compile_protected(policy, ctx)?,
100        })
101    }
102
103    /// The policy this was compiled from.
104    #[must_use]
105    pub fn policy(&self) -> &'p Policy {
106        self.policy
107    }
108
109    /// The context this was compiled for.
110    #[must_use]
111    pub fn context(&self) -> &EvalContext {
112        &self.ctx
113    }
114
115    /// Evaluate one atomic action: deny → allow → ask → default, with a
116    /// repository policy's ask rules ([`Policy::repo_ask`]) tried before allow.
117    ///
118    /// Returns `None` for a whole-pipeline atom that no rule mentions: pipelines
119    /// are only there so rules like `curl * | sh` can see across `|`; the
120    /// per-sub-command atoms carry the default verdict.
121    #[must_use]
122    pub fn evaluate_atomic(&self, action: &AtomicAction) -> Option<Decision> {
123        for (groups, verdict) in [
124            (&self.deny, Verdict::Deny),
125            (&self.repo_ask, Verdict::Ask),
126            (&self.allow, Verdict::Allow),
127            (&self.ask, Verdict::Ask),
128        ] {
129            let mut hit = Decision::new(verdict);
130            for g in groups {
131                if g.matches(action) {
132                    let why = g.group.reason.as_ref().map_or_else(
133                        || action.describe(),
134                        |r| format!("{r}: {}", action.describe()),
135                    );
136                    hit.push(&g.group.id, why);
137                }
138            }
139            if !hit.rules.is_empty() {
140                return Some(hit);
141            }
142        }
143        if matches!(action, AtomicAction::Pipeline { .. }) {
144            return None;
145        }
146        let (verdict, rule_id) = self.policy.defaults.for_kind(action.kind());
147        Some(Decision::single(
148            verdict,
149            &rule_id,
150            format!("no rule matched {}", action.describe()),
151        ))
152    }
153}
154
155impl<'p> CompiledGroup<'p> {
156    fn compile(group: &'p RuleGroup, ctx: &EvalContext) -> Result<Self, PolicyError> {
157        let shell = group
158            .shell
159            .iter()
160            .map(|p| ShellPattern::compile(p))
161            .collect::<Result<_, _>>()?;
162        let mut globs: [Vec<GlobPattern>; Kind::ALL.len()] = Default::default();
163        for kind in Kind::ALL.into_iter().filter(|k| *k != Kind::Shell) {
164            let case_insensitive = match kind {
165                Kind::FsRead | Kind::FsWrite => ctx.case_insensitive_paths,
166                Kind::Net | Kind::Fetch => true,
167                _ => false,
168            };
169            globs[kind as usize] = group
170                .patterns(kind)
171                .iter()
172                .flat_map(|p| ctx.spellings(p))
173                .map(|p| GlobPattern::compile(&p, case_insensitive))
174                .collect::<Result<_, _>>()?;
175        }
176        Ok(Self {
177            group,
178            shell,
179            globs,
180        })
181    }
182
183    fn matches(&self, action: &AtomicAction) -> bool {
184        match (action, action.subject()) {
185            (AtomicAction::Shell { argv } | AtomicAction::Pipeline { argv }, _) => {
186                any_match(&self.shell, argv.as_slice())
187            }
188            (_, Some(subject)) => action
189                .kind()
190                .rule_kinds()
191                .iter()
192                .any(|k| any_match(&self.globs[*k as usize], subject)),
193            (_, None) => false,
194        }
195    }
196}
197
198impl EvalContext {
199    /// Reject [`Self::moved_dirs`] entries whose `default` could alias every
200    /// pattern body (empty-after-trim or a bare `/`) or does not look like a
201    /// directory (must start with `/` or `~/`). The engine fails closed on such
202    /// a context: without validation an empty `default` makes [`Self::spellings`]
203    /// emit a spelling under `moved` for every pattern (#399), so a `moved_dirs`
204    /// mistake in the caller would silently widen every rule. The CLI legitimately
205    /// lists the same `default` twice when the moved directory has a resolved
206    /// spelling or comes from both the environment and the recorded host dir,
207    /// so a duplicate `default` is kept (its spellings deduped in [`Self::spellings`]).
208    pub(crate) fn validate_moved_dirs(&self) -> Result<(), PolicyError> {
209        for (default, _) in &self.moved_dirs {
210            let trimmed = default.trim();
211            let valid = !trimmed.is_empty()
212                && trimmed != "/"
213                && (trimmed == "~" || trimmed.starts_with("~/") || paths::is_absolute(trimmed));
214            if !valid {
215                return Err(PolicyError::Rule {
216                    rule: "context.moved_dirs".to_owned(),
217                    problem: format!(
218                        "default directory `{default}` must be absolute (`/…`) or home-rooted (`~/…`), \
219                         not empty, whitespace or a bare `/`"
220                    ),
221                });
222            }
223        }
224        Ok(())
225    }
226
227    /// `raw` expanded once per spelling of the home directory and project root
228    /// it names ([`paths::expand_pattern`]), and once more per directory in
229    /// [`Self::moved_dirs`] it falls under; empty when it names no location.
230    /// The policy compiler (`ir`) lowers patterns through the same expansion.
231    pub(crate) fn spellings(&self, raw: &str) -> Vec<String> {
232        let (negated, body) = crate::pattern::split_negation(raw);
233        let bang = if negated { "!" } else { "" };
234        let moved = self.moved_dirs.iter().filter_map(|(default, dir)| {
235            let rest = body.strip_prefix(default.as_str())?;
236            (rest.is_empty() || rest.starts_with('/')).then(|| format!("{bang}{dir}{rest}"))
237        });
238        let raws: Vec<String> = std::iter::once(raw.to_owned()).chain(moved).collect();
239        let projects: Vec<Option<&str>> = match &self.project {
240            Some(p) => [Some(p), self.real_project.as_ref()]
241                .into_iter()
242                .flatten()
243                .map(|p| Some(p.as_str()))
244                .collect(),
245            None => vec![None],
246        };
247        let mut out = Vec::new();
248        for home in [Some(&self.home), self.real_home.as_ref()]
249            .into_iter()
250            .flatten()
251        {
252            for project in &projects {
253                out.extend(
254                    raws.iter()
255                        .filter_map(|raw| paths::expand_pattern(raw, home, *project)),
256                );
257            }
258        }
259        out.sort();
260        out.dedup();
261        out
262    }
263}
264
265/// Classify an [`Action`] into atomic actions for the given context, and list
266/// the reads and writes among them whose path is a glob the shell expands.
267#[must_use]
268pub fn classify_action(action: &Action, ctx: &EvalContext) -> (ParseOutcome, Vec<AtomicAction>) {
269    let outcome = match action {
270        Action::Shell { command } => {
271            return classify_globs(
272                command,
273                &ShellContext {
274                    home: &ctx.home,
275                    project: ctx.project.as_deref(),
276                    cwd: &[Some(ctx.cwd.clone())],
277                },
278            );
279        }
280        Action::ForeignShell { shell, .. } => ParseOutcome::Unparseable {
281            reason: format!("{shell} commands are not parsed; only POSIX shell is classified"),
282            atoms: Vec::new(),
283        },
284        Action::FsRead { path } => ParseOutcome::Parsed(vec![AtomicAction::FsRead {
285            path: paths::normalise(path, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
286        }]),
287        Action::FsWrite { path } => ParseOutcome::Parsed(vec![AtomicAction::FsWrite {
288            path: paths::normalise(path, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
289        }]),
290        Action::Patch { writes } if writes.is_empty() => ParseOutcome::Unparseable {
291            reason: "patch names no files".to_owned(),
292            atoms: Vec::new(),
293        },
294        Action::Patch { writes } => ParseOutcome::Parsed(
295            writes
296                .iter()
297                .map(|p| AtomicAction::FsWrite {
298                    path: paths::normalise(p, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
299                })
300                .collect(),
301        ),
302        Action::ReadFiles { paths } if paths.is_empty() => ParseOutcome::Unparseable {
303            reason: "read names no files".to_owned(),
304            atoms: Vec::new(),
305        },
306        Action::ReadFiles { paths } => ParseOutcome::Parsed(
307            paths
308                .iter()
309                .map(|p| AtomicAction::FsRead {
310                    path: paths::normalise(p, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
311                })
312                .collect(),
313        ),
314        Action::Net { url } => match host::of_url(url) {
315            Some(host) => ParseOutcome::Parsed(vec![AtomicAction::Net { host }]),
316            None => ParseOutcome::Unparseable {
317                reason: format!("no host in url `{url}`"),
318                atoms: Vec::new(),
319            },
320        },
321        Action::Fetch { url } => match host::of_url(url) {
322            Some(host) => ParseOutcome::Parsed(vec![AtomicAction::Fetch { host }]),
323            None => ParseOutcome::Unparseable {
324                reason: format!("no host in url `{url}`"),
325                atoms: Vec::new(),
326            },
327        },
328        Action::WebSearch { query } => ParseOutcome::Parsed(vec![AtomicAction::WebSearch {
329            query: query.clone(),
330        }]),
331        Action::McpTool {
332            name,
333            reads,
334            writes,
335            hosts,
336            ..
337        } => {
338            let norm =
339                |p: &String| paths::normalise(p, &ctx.home, ctx.project.as_deref(), &ctx.cwd);
340            let mut atoms = vec![AtomicAction::McpTool { name: name.clone() }];
341            atoms.extend(reads.iter().map(|p| AtomicAction::FsRead { path: norm(p) }));
342            atoms.extend(
343                writes
344                    .iter()
345                    .map(|p| AtomicAction::FsWrite { path: norm(p) }),
346            );
347            let mut bad_host = None;
348            for host in hosts {
349                match host::of_url(host) {
350                    Some(host) => atoms.push(AtomicAction::Net { host }),
351                    None => bad_host = bad_host.or(Some(host)),
352                }
353            }
354            match bad_host {
355                Some(host) => ParseOutcome::Unparseable {
356                    reason: format!("no host in mcp argument `{host}`"),
357                    atoms,
358                },
359                None => ParseOutcome::Parsed(atoms),
360            }
361        }
362    };
363    (outcome, Vec::new())
364}
365
366/// The atoms of `action` in `ctx` with an extra atom for every path a glob
367/// operand names (`crate::expand`) and for every read or write `paths`
368/// resolves through a symlink, and why part of the action cannot be checked.
369fn checked_atoms(
370    action: &Action,
371    ctx: &EvalContext,
372    paths: &dyn PathResolver,
373) -> (Vec<AtomicAction>, Option<String>) {
374    let (outcome, globs) = classify_action(action, ctx);
375    let (mut atoms, unparsed) = outcome.into_parts();
376    let mut expansion = Expansion::new(paths);
377    for glob in &globs {
378        if let Some(pattern) = glob.subject() {
379            let named = expansion.paths(pattern);
380            atoms.extend(named.into_iter().filter_map(|path| glob.with_path(path)));
381        }
382    }
383    let unexpanded = expansion.overflow();
384    (with_resolved_paths(atoms, paths), unparsed.or(unexpanded))
385}
386
387impl CompiledPolicy<'_> {
388    /// Decide one host action without executable resolution.
389    ///
390    /// Unparseable input yields `ask` with the parser's reason, never `allow`.
391    #[must_use]
392    pub fn decide(&self, action: &Action) -> Decision {
393        self.decide_with(action, &NoResolver, &NoResolver)
394    }
395
396    /// The atomic actions [`Self::decide_with`] evaluates for `action`, with an
397    /// extra atom for every path an unquoted glob operand names in the
398    /// directories `paths` lists, and for every read or write `paths` resolves
399    /// through a symlink; and the reason when part of the action cannot be
400    /// classified or a glob not expanded in full (`ask`).
401    #[must_use]
402    pub fn atoms(
403        &self,
404        action: &Action,
405        paths: &dyn PathResolver,
406    ) -> (Vec<AtomicAction>, Option<String>) {
407        checked_atoms(action, &self.ctx, paths)
408    }
409
410    /// Decide one host action, resolving shell programs through `resolver` so
411    /// that `executables` pins and installation pins are enforced, and paths
412    /// through `paths` so a symlink cannot move a read or write past a rule.
413    #[must_use]
414    pub fn decide_with(
415        &self,
416        action: &Action,
417        resolver: &dyn ProgramResolver,
418        paths: &dyn PathResolver,
419    ) -> Decision {
420        let (atoms, unparsed) = self.atoms(action, paths);
421        let pins = atoms.iter().filter_map(|atom| match atom {
422            AtomicAction::Shell { argv } => {
423                programs::check(&argv[0], &self.policy.executables, resolver)
424            }
425            _ => None,
426        });
427        // A part that could not be parsed, or arguments the adapter did not
428        // search, may name anything: ask, and let a deny on the rest still win.
429        let unparsed =
430            unparsed.map(|reason| unparseable(format!("could not parse action safely: {reason}")));
431        let unchecked = match action {
432            Action::McpTool {
433                unchecked: Some(limit),
434                ..
435            } => Some(unparseable(format!(
436                "mcp arguments not fully checked: {limit}"
437            ))),
438            _ => None,
439        };
440        atoms
441            .iter()
442            .filter_map(|atom| self.evaluate_atomic(atom))
443            .chain(pins)
444            .chain(unparsed)
445            .chain(unchecked)
446            .reduce(|mut acc, next| {
447                acc.merge(next);
448                acc
449            })
450            .unwrap_or_else(|| unparseable("action has no evaluable parts".to_owned()))
451    }
452}
453
454/// Add an atom for the resolved path of every read and write that goes
455/// through a symlink. The literal atom stays: a rule may name either location.
456fn with_resolved_paths(atoms: Vec<AtomicAction>, paths: &dyn PathResolver) -> Vec<AtomicAction> {
457    let resolved: Vec<AtomicAction> = atoms
458        .iter()
459        .filter_map(|atom| {
460            let (AtomicAction::FsRead { path } | AtomicAction::FsWrite { path }) = atom else {
461                return None;
462            };
463            atom.with_path(paths.resolve(path).filter(|real| real != path)?)
464        })
465        .collect();
466    atoms.into_iter().chain(resolved).collect()
467}
468
469fn unparseable(reason: String) -> Decision {
470    Decision::single(Verdict::Ask, "unparseable", reason)
471}
472
473/// Compile `policy` for `ctx` and decide a single action.
474///
475/// Convenience for one-shot callers; long-lived callers should keep a
476/// [`CompiledPolicy`] instead.
477pub fn evaluate(
478    policy: &Policy,
479    ctx: &EvalContext,
480    action: &Action,
481) -> Result<Decision, PolicyError> {
482    Ok(CompiledPolicy::compile(policy, ctx)?.decide(action))
483}
484
485#[cfg(test)]
486mod tests;