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 atoms;
17mod context;
18mod egress;
19mod taint;
20
21use atoms::checked_atoms;
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
202impl CompiledPolicy<'_> {
203 /// Decide one host action without executable resolution.
204 ///
205 /// Unparseable input yields the policy's catch-all verdict (`ask` or
206 /// `defer`) with the parser's reason, never `allow`.
207 #[must_use]
208 pub fn decide(&self, action: &Action) -> Decision {
209 self.decide_with(action, &NoResolver, &NoResolver)
210 }
211
212 /// The atomic actions [`Self::decide_with`] evaluates for `action`, with an
213 /// extra atom for every path an unquoted glob operand names in the
214 /// directories `paths` lists, and for every read or write `paths` resolves
215 /// through a symlink; and the reason when part of the action cannot be
216 /// classified or a glob not expanded in full (`ask`).
217 #[must_use]
218 pub fn atoms(
219 &self,
220 action: &Action,
221 paths: &dyn PathResolver,
222 ) -> (Vec<AtomicAction>, Option<String>) {
223 let (atoms, unparsed) = self.all_atoms(action, paths);
224 (atoms, unparsed.map(|u| u.reason))
225 }
226
227 /// [`checked_atoms`] and the atoms of any token or placeholder they send
228 /// where it does not belong ([`egress`]).
229 fn all_atoms(
230 &self,
231 action: &Action,
232 paths: &dyn PathResolver,
233 ) -> (Vec<AtomicAction>, Option<crate::shell::Unparsed>) {
234 let (mut atoms, unparsed) = checked_atoms(action, &self.ctx, paths);
235 let egress = egress::atoms(action, &atoms, &self.policy.secrets);
236 atoms.extend(egress);
237 (atoms, unparsed)
238 }
239
240 /// The commands of `action` that publish local commits (`git push`, `gh pr
241 /// create`), each with the directories it may run in. Only a shell command
242 /// has any.
243 #[must_use]
244 pub fn pushes(&self, action: &Action) -> Vec<crate::shell::push::Push> {
245 let Action::Shell { command } = action else {
246 return Vec::new();
247 };
248 crate::shell::push::pushes(
249 command,
250 &crate::shell::ShellContext {
251 home: &self.ctx.home,
252 cwd: &[Some(self.ctx.cwd.clone())],
253 },
254 )
255 }
256
257 /// Decide one host action, resolving shell programs through `resolver` so
258 /// that `executables` pins and installation pins are enforced, and paths
259 /// through `paths` so a symlink cannot move a read or write past a rule.
260 #[must_use]
261 pub fn decide_with(
262 &self,
263 action: &Action,
264 resolver: &dyn ProgramResolver,
265 paths: &dyn PathResolver,
266 ) -> Decision {
267 let (atoms, unparsed) = self.all_atoms(action, paths);
268 let pins = atoms.iter().filter_map(|atom| match atom {
269 AtomicAction::Shell { argv } => {
270 programs::check(&argv[0], &self.policy.executables, resolver)
271 }
272 _ => None,
273 });
274 // A part that could not be parsed, or arguments the adapter did not
275 // search, may name anything: never allow (defer or ask, ADR-025), and let
276 // a deny on the rest still win. A line not read in full, or one past a
277 // classifier limit, asks: a deny in what was not read must not defer.
278 let verdict = self.policy.defaults.unclassified();
279 let unparsed = unparsed.map(|u| {
280 let verdict = if u.asks { Verdict::Ask } else { verdict };
281 unparseable(
282 verdict,
283 format!("could not parse action safely: {}", u.reason),
284 )
285 });
286 let unchecked = match action {
287 Action::McpTool {
288 unchecked: Some(limit),
289 ..
290 } => Some(unparseable(
291 verdict,
292 format!("mcp arguments not fully checked: {limit}"),
293 )),
294 _ => None,
295 };
296 atoms
297 .iter()
298 .filter_map(|atom| self.evaluate_atomic(atom))
299 .chain(pins)
300 .chain(unparsed)
301 .chain(unchecked)
302 .reduce(|mut acc, next| {
303 acc.merge(next);
304 acc
305 })
306 .unwrap_or_else(|| unparseable(verdict, "action has no evaluable parts".to_owned()))
307 }
308}
309
310/// Input that cannot be classified: the policy's catch-all, never `allow` (ADR-025).
311fn unparseable(verdict: Verdict, reason: String) -> Decision {
312 Decision::single(verdict, "unparseable", reason)
313}
314
315/// Compile `policy` for `ctx` and decide a single action.
316///
317/// Convenience for one-shot callers; long-lived callers should keep a
318/// [`CompiledPolicy`] instead.
319pub fn evaluate(
320 policy: &Policy,
321 ctx: &EvalContext,
322 action: &Action,
323) -> Result<Decision, PolicyError> {
324 Ok(CompiledPolicy::compile(policy, ctx)?.decide(action))
325}
326
327#[cfg(test)]
328mod tests;