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, physical};
15use crate::shell::{ParseOutcome, ShellContext, classify_globs};
16use crate::verdict::{Decision, Verdict};
17use crate::{host, paths};
18
19mod context;
20mod taint;
21
22pub use taint::{Secret, Taint};
23
24#[derive(Debug, Clone)]
26pub struct EvalContext {
27 pub home: String,
29 pub project: Option<String>,
34 pub real_home: Option<String>,
38 pub real_project: Option<String>,
44 pub moved_dirs: Vec<(String, String)>,
50 pub cwd: String,
52 pub case_insensitive_paths: bool,
55}
56
57#[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 protected: Vec<GlobPattern>,
72}
73
74#[derive(Debug)]
75struct CompiledGroup<'p> {
76 group: &'p RuleGroup,
77 shell: Vec<ShellPattern>,
78 globs: [Vec<GlobPattern>; Kind::ALL.len()],
80}
81
82impl<'p> CompiledPolicy<'p> {
83 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 #[must_use]
108 pub fn policy(&self) -> &'p Policy {
109 self.policy
110 }
111
112 #[must_use]
114 pub fn context(&self) -> &EvalContext {
115 &self.ctx
116 }
117
118 #[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
202#[must_use]
205pub fn classify_action(action: &Action, ctx: &EvalContext) -> (ParseOutcome, Vec<AtomicAction>) {
206 let outcome = match action {
207 Action::Shell { command } => {
208 return classify_globs(
209 command,
210 &ShellContext {
211 home: &ctx.home,
212 project: ctx.project.as_deref(),
213 cwd: &[Some(ctx.cwd.clone())],
214 },
215 );
216 }
217 Action::ForeignShell { shell, .. } => ParseOutcome::Unparseable {
218 reason: format!("{shell} commands are not parsed; only POSIX shell is classified"),
219 atoms: Vec::new(),
220 },
221 Action::FsRead { path } => ParseOutcome::Parsed(file_atoms(path, ctx, read).collect()),
222 Action::FsWrite { path } => ParseOutcome::Parsed(file_atoms(path, ctx, write).collect()),
223 Action::Patch { writes } if writes.is_empty() => ParseOutcome::Unparseable {
224 reason: "patch names no files".to_owned(),
225 atoms: Vec::new(),
226 },
227 Action::Patch { writes } => ParseOutcome::Parsed(
228 writes
229 .iter()
230 .flat_map(|p| file_atoms(p, ctx, write))
231 .collect(),
232 ),
233 Action::ReadFiles { paths } if paths.is_empty() => ParseOutcome::Unparseable {
234 reason: "read names no files".to_owned(),
235 atoms: Vec::new(),
236 },
237 Action::ReadFiles { paths } => ParseOutcome::Parsed(
238 paths
239 .iter()
240 .flat_map(|p| file_atoms(p, ctx, read))
241 .collect(),
242 ),
243 Action::Net { url } => match host::of_url(url) {
244 Some(host) => ParseOutcome::Parsed(vec![AtomicAction::Net { host }]),
245 None => ParseOutcome::Unparseable {
246 reason: format!("no host in url `{url}`"),
247 atoms: Vec::new(),
248 },
249 },
250 Action::Fetch { url } => match host::of_url(url) {
251 Some(host) => ParseOutcome::Parsed(vec![AtomicAction::Fetch { host }]),
252 None => ParseOutcome::Unparseable {
253 reason: format!("no host in url `{url}`"),
254 atoms: Vec::new(),
255 },
256 },
257 Action::WebSearch { query } => ParseOutcome::Parsed(vec![AtomicAction::WebSearch {
258 query: query.clone(),
259 }]),
260 Action::UnknownTool { host, tool } => {
261 ParseOutcome::Parsed(vec![AtomicAction::UnknownTool {
262 host: host.clone(),
263 tool: tool.clone(),
264 }])
265 }
266 Action::McpTool {
267 name,
268 reads,
269 writes,
270 hosts,
271 ..
272 } => {
273 let mut atoms = vec![AtomicAction::McpTool { name: name.clone() }];
274 atoms.extend(reads.iter().flat_map(|p| file_atoms(p, ctx, read)));
275 atoms.extend(writes.iter().flat_map(|p| file_atoms(p, ctx, write)));
276 let mut bad_host = None;
277 for host in hosts {
278 match host::of_url(host) {
279 Some(host) => atoms.push(AtomicAction::Net { host }),
280 None => bad_host = bad_host.or(Some(host)),
281 }
282 }
283 match bad_host {
284 Some(host) => ParseOutcome::Unparseable {
285 reason: format!("no host in mcp argument `{host}`"),
286 atoms,
287 },
288 None => ParseOutcome::Parsed(atoms),
289 }
290 }
291 };
292 (outcome, Vec::new())
293}
294
295fn checked_atoms(
299 action: &Action,
300 ctx: &EvalContext,
301 paths: &dyn PathResolver,
302) -> (Vec<AtomicAction>, Option<String>) {
303 let (outcome, globs) = classify_action(action, ctx);
304 let (mut atoms, unparsed) = outcome.into_parts();
305 let mut expansion = Expansion::new(paths);
306 for glob in &globs {
307 if let Some(pattern) = glob.subject() {
308 let pattern = if paths::has_dotdot(pattern) {
310 physical(pattern, paths)
311 } else {
312 pattern.to_owned()
313 };
314 let named = expansion.paths(&pattern);
315 atoms.extend(named.into_iter().filter_map(|path| glob.with_path(path)));
316 }
317 }
318 let unexpanded = expansion.overflow();
319 let atoms = with_environ_reads(with_resolved_paths(atoms, paths));
320 (atoms, unparsed.or(unexpanded))
321}
322
323fn with_environ_reads(atoms: Vec<AtomicAction>) -> Vec<AtomicAction> {
328 let environ: Vec<AtomicAction> = atoms
329 .iter()
330 .filter_map(|atom| match atom {
331 AtomicAction::FsRead { path } if is_proc_environ(path) => Some(AtomicAction::Shell {
332 argv: vec!["@proc-environ".to_owned(), path.clone()],
333 }),
334 _ => None,
335 })
336 .collect();
337 atoms.into_iter().chain(environ).collect()
338}
339
340fn is_proc_environ(path: &str) -> bool {
342 let parts: Vec<&str> = path.split('/').collect();
343 let id = |s: &str| !s.is_empty();
344 match parts.as_slice() {
345 ["", "proc", pid, "environ"] => id(pid),
346 ["", "proc", pid, "task", tid, "environ"] => id(pid) && id(tid),
347 _ => false,
348 }
349}
350
351impl CompiledPolicy<'_> {
352 #[must_use]
357 pub fn decide(&self, action: &Action) -> Decision {
358 self.decide_with(action, &NoResolver, &NoResolver)
359 }
360
361 #[must_use]
367 pub fn atoms(
368 &self,
369 action: &Action,
370 paths: &dyn PathResolver,
371 ) -> (Vec<AtomicAction>, Option<String>) {
372 checked_atoms(action, &self.ctx, paths)
373 }
374
375 #[must_use]
379 pub fn decide_with(
380 &self,
381 action: &Action,
382 resolver: &dyn ProgramResolver,
383 paths: &dyn PathResolver,
384 ) -> Decision {
385 let (atoms, unparsed) = self.atoms(action, paths);
386 let pins = atoms.iter().filter_map(|atom| match atom {
387 AtomicAction::Shell { argv } => {
388 programs::check(&argv[0], &self.policy.executables, resolver)
389 }
390 _ => None,
391 });
392 let verdict = self.policy.defaults.unclassified();
396 let unparsed = unparsed
397 .map(|reason| unparseable(verdict, format!("could not parse action safely: {reason}")));
398 let unchecked = match action {
399 Action::McpTool {
400 unchecked: Some(limit),
401 ..
402 } => Some(unparseable(
403 verdict,
404 format!("mcp arguments not fully checked: {limit}"),
405 )),
406 _ => None,
407 };
408 atoms
409 .iter()
410 .filter_map(|atom| self.evaluate_atomic(atom))
411 .chain(pins)
412 .chain(unparsed)
413 .chain(unchecked)
414 .reduce(|mut acc, next| {
415 acc.merge(next);
416 acc
417 })
418 .unwrap_or_else(|| unparseable(verdict, "action has no evaluable parts".to_owned()))
419 }
420}
421
422fn file_atoms(
425 raw: &str,
426 ctx: &EvalContext,
427 atom: fn(String) -> AtomicAction,
428) -> impl Iterator<Item = AtomicAction> {
429 let project = ctx.project.as_deref();
430 let collapsed = paths::normalise(raw, &ctx.home, project, &ctx.cwd);
431 let traversal = paths::traversal(raw, &ctx.home, project, Some(&ctx.cwd));
432 std::iter::once(atom(collapsed)).chain(traversal.map(atom))
433}
434
435fn read(path: String) -> AtomicAction {
436 AtomicAction::FsRead { path }
437}
438
439fn write(path: String) -> AtomicAction {
440 AtomicAction::FsWrite { path }
441}
442
443fn file_path(atom: &AtomicAction) -> Option<&str> {
445 match atom {
446 AtomicAction::FsRead { path } | AtomicAction::FsWrite { path } => Some(path),
447 _ => None,
448 }
449}
450
451fn with_resolved_paths(atoms: Vec<AtomicAction>, paths: &dyn PathResolver) -> Vec<AtomicAction> {
457 let atoms: Vec<AtomicAction> = atoms
458 .into_iter()
459 .filter_map(|atom| match file_path(&atom) {
460 Some(path) if paths::has_dotdot(path) => atom.with_path(physical(path, paths)),
461 _ => Some(atom),
462 })
463 .collect();
464 let resolved: Vec<AtomicAction> = atoms
465 .iter()
466 .filter_map(|atom| {
467 let (AtomicAction::FsRead { path } | AtomicAction::FsWrite { path }) = atom else {
468 return None;
469 };
470 atom.with_path(paths.resolve(path).filter(|real| real != path)?)
471 })
472 .collect();
473 atoms.into_iter().chain(resolved).collect()
474}
475
476fn unparseable(verdict: Verdict, reason: String) -> Decision {
478 Decision::single(verdict, "unparseable", reason)
479}
480
481pub fn evaluate(
486 policy: &Policy,
487 ctx: &EvalContext,
488 action: &Action,
489) -> Result<Decision, PolicyError> {
490 Ok(CompiledPolicy::compile(policy, ctx)?.decide(action))
491}
492
493#[cfg(test)]
494mod tests;