1use 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#[derive(Debug, Clone)]
25pub struct EvalContext {
26 pub home: String,
28 pub project: Option<String>,
33 pub real_home: Option<String>,
37 pub real_project: Option<String>,
43 pub moved_dirs: Vec<(String, String)>,
49 pub cwd: String,
51 pub case_insensitive_paths: bool,
54}
55
56#[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 protected: Vec<GlobPattern>,
70}
71
72#[derive(Debug)]
73struct CompiledGroup<'p> {
74 group: &'p RuleGroup,
75 shell: Vec<ShellPattern>,
76 globs: [Vec<GlobPattern>; Kind::ALL.len()],
78}
79
80impl<'p> CompiledPolicy<'p> {
81 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 #[must_use]
105 pub fn policy(&self) -> &'p Policy {
106 self.policy
107 }
108
109 #[must_use]
111 pub fn context(&self) -> &EvalContext {
112 &self.ctx
113 }
114
115 #[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 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 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#[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
366fn 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 #[must_use]
392 pub fn decide(&self, action: &Action) -> Decision {
393 self.decide_with(action, &NoResolver, &NoResolver)
394 }
395
396 #[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 #[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 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
454fn 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
473pub 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;