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