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 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(vec![AtomicAction::FsRead {
222 path: paths::normalise(path, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
223 }]),
224 Action::FsWrite { path } => ParseOutcome::Parsed(vec![AtomicAction::FsWrite {
225 path: paths::normalise(path, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
226 }]),
227 Action::Patch { writes } if writes.is_empty() => ParseOutcome::Unparseable {
228 reason: "patch names no files".to_owned(),
229 atoms: Vec::new(),
230 },
231 Action::Patch { writes } => ParseOutcome::Parsed(
232 writes
233 .iter()
234 .map(|p| AtomicAction::FsWrite {
235 path: paths::normalise(p, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
236 })
237 .collect(),
238 ),
239 Action::ReadFiles { paths } if paths.is_empty() => ParseOutcome::Unparseable {
240 reason: "read names no files".to_owned(),
241 atoms: Vec::new(),
242 },
243 Action::ReadFiles { paths } => ParseOutcome::Parsed(
244 paths
245 .iter()
246 .map(|p| AtomicAction::FsRead {
247 path: paths::normalise(p, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
248 })
249 .collect(),
250 ),
251 Action::Net { url } => match host::of_url(url) {
252 Some(host) => ParseOutcome::Parsed(vec![AtomicAction::Net { host }]),
253 None => ParseOutcome::Unparseable {
254 reason: format!("no host in url `{url}`"),
255 atoms: Vec::new(),
256 },
257 },
258 Action::Fetch { url } => match host::of_url(url) {
259 Some(host) => ParseOutcome::Parsed(vec![AtomicAction::Fetch { host }]),
260 None => ParseOutcome::Unparseable {
261 reason: format!("no host in url `{url}`"),
262 atoms: Vec::new(),
263 },
264 },
265 Action::WebSearch { query } => ParseOutcome::Parsed(vec![AtomicAction::WebSearch {
266 query: query.clone(),
267 }]),
268 Action::UnknownTool { host, tool } => {
269 ParseOutcome::Parsed(vec![AtomicAction::UnknownTool {
270 host: host.clone(),
271 tool: tool.clone(),
272 }])
273 }
274 Action::McpTool {
275 name,
276 reads,
277 writes,
278 hosts,
279 ..
280 } => {
281 let norm =
282 |p: &String| paths::normalise(p, &ctx.home, ctx.project.as_deref(), &ctx.cwd);
283 let mut atoms = vec![AtomicAction::McpTool { name: name.clone() }];
284 atoms.extend(reads.iter().map(|p| AtomicAction::FsRead { path: norm(p) }));
285 atoms.extend(
286 writes
287 .iter()
288 .map(|p| AtomicAction::FsWrite { path: norm(p) }),
289 );
290 let mut bad_host = None;
291 for host in hosts {
292 match host::of_url(host) {
293 Some(host) => atoms.push(AtomicAction::Net { host }),
294 None => bad_host = bad_host.or(Some(host)),
295 }
296 }
297 match bad_host {
298 Some(host) => ParseOutcome::Unparseable {
299 reason: format!("no host in mcp argument `{host}`"),
300 atoms,
301 },
302 None => ParseOutcome::Parsed(atoms),
303 }
304 }
305 };
306 (outcome, Vec::new())
307}
308
309fn checked_atoms(
313 action: &Action,
314 ctx: &EvalContext,
315 paths: &dyn PathResolver,
316) -> (Vec<AtomicAction>, Option<String>) {
317 let (outcome, globs) = classify_action(action, ctx);
318 let (mut atoms, unparsed) = outcome.into_parts();
319 let mut expansion = Expansion::new(paths);
320 for glob in &globs {
321 if let Some(pattern) = glob.subject() {
322 let named = expansion.paths(pattern);
323 atoms.extend(named.into_iter().filter_map(|path| glob.with_path(path)));
324 }
325 }
326 let unexpanded = expansion.overflow();
327 let atoms = with_environ_reads(with_resolved_paths(atoms, paths));
328 (atoms, unparsed.or(unexpanded))
329}
330
331fn with_environ_reads(atoms: Vec<AtomicAction>) -> Vec<AtomicAction> {
336 let environ: Vec<AtomicAction> = atoms
337 .iter()
338 .filter_map(|atom| match atom {
339 AtomicAction::FsRead { path } if is_proc_environ(path) => Some(AtomicAction::Shell {
340 argv: vec!["@proc-environ".to_owned(), path.clone()],
341 }),
342 _ => None,
343 })
344 .collect();
345 atoms.into_iter().chain(environ).collect()
346}
347
348fn is_proc_environ(path: &str) -> bool {
350 let parts: Vec<&str> = path.split('/').collect();
351 let id = |s: &str| !s.is_empty();
352 match parts.as_slice() {
353 ["", "proc", pid, "environ"] => id(pid),
354 ["", "proc", pid, "task", tid, "environ"] => id(pid) && id(tid),
355 _ => false,
356 }
357}
358
359impl CompiledPolicy<'_> {
360 #[must_use]
365 pub fn decide(&self, action: &Action) -> Decision {
366 self.decide_with(action, &NoResolver, &NoResolver)
367 }
368
369 #[must_use]
375 pub fn atoms(
376 &self,
377 action: &Action,
378 paths: &dyn PathResolver,
379 ) -> (Vec<AtomicAction>, Option<String>) {
380 checked_atoms(action, &self.ctx, paths)
381 }
382
383 #[must_use]
387 pub fn decide_with(
388 &self,
389 action: &Action,
390 resolver: &dyn ProgramResolver,
391 paths: &dyn PathResolver,
392 ) -> Decision {
393 let (atoms, unparsed) = self.atoms(action, paths);
394 let pins = atoms.iter().filter_map(|atom| match atom {
395 AtomicAction::Shell { argv } => {
396 programs::check(&argv[0], &self.policy.executables, resolver)
397 }
398 _ => None,
399 });
400 let verdict = self.policy.defaults.unclassified();
404 let unparsed = unparsed
405 .map(|reason| unparseable(verdict, format!("could not parse action safely: {reason}")));
406 let unchecked = match action {
407 Action::McpTool {
408 unchecked: Some(limit),
409 ..
410 } => Some(unparseable(
411 verdict,
412 format!("mcp arguments not fully checked: {limit}"),
413 )),
414 _ => None,
415 };
416 atoms
417 .iter()
418 .filter_map(|atom| self.evaluate_atomic(atom))
419 .chain(pins)
420 .chain(unparsed)
421 .chain(unchecked)
422 .reduce(|mut acc, next| {
423 acc.merge(next);
424 acc
425 })
426 .unwrap_or_else(|| unparseable(verdict, "action has no evaluable parts".to_owned()))
427 }
428}
429
430fn with_resolved_paths(atoms: Vec<AtomicAction>, paths: &dyn PathResolver) -> Vec<AtomicAction> {
433 let resolved: Vec<AtomicAction> = atoms
434 .iter()
435 .filter_map(|atom| {
436 let (AtomicAction::FsRead { path } | AtomicAction::FsWrite { path }) = atom else {
437 return None;
438 };
439 atom.with_path(paths.resolve(path).filter(|real| real != path)?)
440 })
441 .collect();
442 atoms.into_iter().chain(resolved).collect()
443}
444
445fn unparseable(verdict: Verdict, reason: String) -> Decision {
447 Decision::single(verdict, "unparseable", reason)
448}
449
450pub fn evaluate(
455 policy: &Policy,
456 ctx: &EvalContext,
457 action: &Action,
458) -> Result<Decision, PolicyError> {
459 Ok(CompiledPolicy::compile(policy, ctx)?.decide(action))
460}
461
462#[cfg(test)]
463mod tests;