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> {
83 let compile_list = |groups: &'p [RuleGroup]| {
84 groups
85 .iter()
86 .map(|g| CompiledGroup::compile(g, ctx))
87 .collect::<Result<Vec<_>, _>>()
88 };
89 Ok(Self {
90 policy,
91 ctx: ctx.clone(),
92 deny: compile_list(&policy.deny)?,
93 repo_ask: compile_list(&policy.repo_ask)?,
94 allow: compile_list(&policy.allow)?,
95 ask: compile_list(&policy.ask)?,
96 protected: taint::compile_protected(policy, ctx)?,
97 })
98 }
99
100 #[must_use]
102 pub fn policy(&self) -> &'p Policy {
103 self.policy
104 }
105
106 #[must_use]
108 pub fn context(&self) -> &EvalContext {
109 &self.ctx
110 }
111
112 #[must_use]
119 pub fn evaluate_atomic(&self, action: &AtomicAction) -> Option<Decision> {
120 for (groups, verdict) in [
121 (&self.deny, Verdict::Deny),
122 (&self.repo_ask, Verdict::Ask),
123 (&self.allow, Verdict::Allow),
124 (&self.ask, Verdict::Ask),
125 ] {
126 let mut hit = Decision::new(verdict);
127 for g in groups {
128 if g.matches(action) {
129 let why = g.group.reason.as_ref().map_or_else(
130 || action.describe(),
131 |r| format!("{r}: {}", action.describe()),
132 );
133 hit.push(&g.group.id, why);
134 }
135 }
136 if !hit.rules.is_empty() {
137 return Some(hit);
138 }
139 }
140 if matches!(action, AtomicAction::Pipeline { .. }) {
141 return None;
142 }
143 let (verdict, rule_id) = self.policy.defaults.for_kind(action.kind());
144 Some(Decision::single(
145 verdict,
146 &rule_id,
147 format!("no rule matched {}", action.describe()),
148 ))
149 }
150}
151
152impl<'p> CompiledGroup<'p> {
153 fn compile(group: &'p RuleGroup, ctx: &EvalContext) -> Result<Self, PolicyError> {
154 let shell = group
155 .shell
156 .iter()
157 .map(|p| ShellPattern::compile(p))
158 .collect::<Result<_, _>>()?;
159 let mut globs: [Vec<GlobPattern>; Kind::ALL.len()] = Default::default();
160 for kind in Kind::ALL.into_iter().filter(|k| *k != Kind::Shell) {
161 let case_insensitive = match kind {
162 Kind::FsRead | Kind::FsWrite => ctx.case_insensitive_paths,
163 Kind::Net | Kind::Fetch => true,
164 _ => false,
165 };
166 globs[kind as usize] = group
167 .patterns(kind)
168 .iter()
169 .flat_map(|p| ctx.spellings(p))
170 .map(|p| GlobPattern::compile(&p, case_insensitive))
171 .collect::<Result<_, _>>()?;
172 }
173 Ok(Self {
174 group,
175 shell,
176 globs,
177 })
178 }
179
180 fn matches(&self, action: &AtomicAction) -> bool {
181 match (action, action.subject()) {
182 (AtomicAction::Shell { argv } | AtomicAction::Pipeline { argv }, _) => {
183 any_match(&self.shell, argv.as_slice())
184 }
185 (_, Some(subject)) => action
186 .kind()
187 .rule_kinds()
188 .iter()
189 .any(|k| any_match(&self.globs[*k as usize], subject)),
190 (_, None) => false,
191 }
192 }
193}
194
195impl EvalContext {
196 pub(crate) fn spellings(&self, raw: &str) -> Vec<String> {
201 let (negated, body) = crate::pattern::split_negation(raw);
202 let bang = if negated { "!" } else { "" };
203 let moved = self.moved_dirs.iter().filter_map(|(default, dir)| {
204 let rest = body.strip_prefix(default.as_str())?;
205 (rest.is_empty() || rest.starts_with('/')).then(|| format!("{bang}{dir}{rest}"))
206 });
207 let raws: Vec<String> = std::iter::once(raw.to_owned()).chain(moved).collect();
208 let projects: Vec<Option<&str>> = match &self.project {
209 Some(p) => [Some(p), self.real_project.as_ref()]
210 .into_iter()
211 .flatten()
212 .map(|p| Some(p.as_str()))
213 .collect(),
214 None => vec![None],
215 };
216 let mut out = Vec::new();
217 for home in [Some(&self.home), self.real_home.as_ref()]
218 .into_iter()
219 .flatten()
220 {
221 for project in &projects {
222 out.extend(
223 raws.iter()
224 .filter_map(|raw| paths::expand_pattern(raw, home, *project)),
225 );
226 }
227 }
228 out.sort();
229 out.dedup();
230 out
231 }
232}
233
234#[must_use]
237pub fn classify_action(action: &Action, ctx: &EvalContext) -> (ParseOutcome, Vec<AtomicAction>) {
238 let outcome = match action {
239 Action::Shell { command } => {
240 return classify_globs(
241 command,
242 &ShellContext {
243 home: &ctx.home,
244 project: ctx.project.as_deref(),
245 cwd: &[Some(ctx.cwd.clone())],
246 },
247 );
248 }
249 Action::ForeignShell { shell, .. } => ParseOutcome::Unparseable {
250 reason: format!("{shell} commands are not parsed; only POSIX shell is classified"),
251 atoms: Vec::new(),
252 },
253 Action::FsRead { path } => ParseOutcome::Parsed(vec![AtomicAction::FsRead {
254 path: paths::normalise(path, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
255 }]),
256 Action::FsWrite { path } => ParseOutcome::Parsed(vec![AtomicAction::FsWrite {
257 path: paths::normalise(path, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
258 }]),
259 Action::Patch { writes } if writes.is_empty() => ParseOutcome::Unparseable {
260 reason: "patch names no files".to_owned(),
261 atoms: Vec::new(),
262 },
263 Action::Patch { writes } => ParseOutcome::Parsed(
264 writes
265 .iter()
266 .map(|p| AtomicAction::FsWrite {
267 path: paths::normalise(p, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
268 })
269 .collect(),
270 ),
271 Action::ReadFiles { paths } if paths.is_empty() => ParseOutcome::Unparseable {
272 reason: "read names no files".to_owned(),
273 atoms: Vec::new(),
274 },
275 Action::ReadFiles { paths } => ParseOutcome::Parsed(
276 paths
277 .iter()
278 .map(|p| AtomicAction::FsRead {
279 path: paths::normalise(p, &ctx.home, ctx.project.as_deref(), &ctx.cwd),
280 })
281 .collect(),
282 ),
283 Action::Net { url } => match host::of_url(url) {
284 Some(host) => ParseOutcome::Parsed(vec![AtomicAction::Net { host }]),
285 None => ParseOutcome::Unparseable {
286 reason: format!("no host in url `{url}`"),
287 atoms: Vec::new(),
288 },
289 },
290 Action::Fetch { url } => match host::of_url(url) {
291 Some(host) => ParseOutcome::Parsed(vec![AtomicAction::Fetch { host }]),
292 None => ParseOutcome::Unparseable {
293 reason: format!("no host in url `{url}`"),
294 atoms: Vec::new(),
295 },
296 },
297 Action::McpTool {
298 name,
299 reads,
300 writes,
301 hosts,
302 ..
303 } => {
304 let norm =
305 |p: &String| paths::normalise(p, &ctx.home, ctx.project.as_deref(), &ctx.cwd);
306 let mut atoms = vec![AtomicAction::McpTool { name: name.clone() }];
307 atoms.extend(reads.iter().map(|p| AtomicAction::FsRead { path: norm(p) }));
308 atoms.extend(
309 writes
310 .iter()
311 .map(|p| AtomicAction::FsWrite { path: norm(p) }),
312 );
313 let mut bad_host = None;
314 for host in hosts {
315 match host::of_url(host) {
316 Some(host) => atoms.push(AtomicAction::Net { host }),
317 None => bad_host = bad_host.or(Some(host)),
318 }
319 }
320 match bad_host {
321 Some(host) => ParseOutcome::Unparseable {
322 reason: format!("no host in mcp argument `{host}`"),
323 atoms,
324 },
325 None => ParseOutcome::Parsed(atoms),
326 }
327 }
328 };
329 (outcome, Vec::new())
330}
331
332fn checked_atoms(
336 action: &Action,
337 ctx: &EvalContext,
338 paths: &dyn PathResolver,
339) -> (Vec<AtomicAction>, Option<String>) {
340 let (outcome, globs) = classify_action(action, ctx);
341 let (mut atoms, unparsed) = outcome.into_parts();
342 let mut expansion = Expansion::new(paths);
343 for glob in &globs {
344 if let Some(pattern) = glob.subject() {
345 let named = expansion.paths(pattern);
346 atoms.extend(named.into_iter().filter_map(|path| glob.with_path(path)));
347 }
348 }
349 let unexpanded = expansion.overflow();
350 (with_resolved_paths(atoms, paths), unparsed.or(unexpanded))
351}
352
353impl CompiledPolicy<'_> {
354 #[must_use]
358 pub fn decide(&self, action: &Action) -> Decision {
359 self.decide_with(action, &NoResolver, &NoResolver)
360 }
361
362 #[must_use]
368 pub fn atoms(
369 &self,
370 action: &Action,
371 paths: &dyn PathResolver,
372 ) -> (Vec<AtomicAction>, Option<String>) {
373 checked_atoms(action, &self.ctx, paths)
374 }
375
376 #[must_use]
380 pub fn decide_with(
381 &self,
382 action: &Action,
383 resolver: &dyn ProgramResolver,
384 paths: &dyn PathResolver,
385 ) -> Decision {
386 let (atoms, unparsed) = self.atoms(action, paths);
387 let pins = atoms.iter().filter_map(|atom| match atom {
388 AtomicAction::Shell { argv } => {
389 programs::check(&argv[0], &self.policy.executables, resolver)
390 }
391 _ => None,
392 });
393 let unparsed =
396 unparsed.map(|reason| unparseable(format!("could not parse action safely: {reason}")));
397 let unchecked = match action {
398 Action::McpTool {
399 unchecked: Some(limit),
400 ..
401 } => Some(unparseable(format!(
402 "mcp arguments not fully checked: {limit}"
403 ))),
404 _ => None,
405 };
406 atoms
407 .iter()
408 .filter_map(|atom| self.evaluate_atomic(atom))
409 .chain(pins)
410 .chain(unparsed)
411 .chain(unchecked)
412 .reduce(|mut acc, next| {
413 acc.merge(next);
414 acc
415 })
416 .unwrap_or_else(|| unparseable("action has no evaluable parts".to_owned()))
417 }
418}
419
420fn with_resolved_paths(atoms: Vec<AtomicAction>, paths: &dyn PathResolver) -> Vec<AtomicAction> {
423 let resolved: Vec<AtomicAction> = atoms
424 .iter()
425 .filter_map(|atom| {
426 let (AtomicAction::FsRead { path } | AtomicAction::FsWrite { path }) = atom else {
427 return None;
428 };
429 atom.with_path(paths.resolve(path).filter(|real| real != path)?)
430 })
431 .collect();
432 atoms.into_iter().chain(resolved).collect()
433}
434
435fn unparseable(reason: String) -> Decision {
436 Decision::single(Verdict::Ask, "unparseable", reason)
437}
438
439pub fn evaluate(
444 policy: &Policy,
445 ctx: &EvalContext,
446 action: &Action,
447) -> Result<Decision, PolicyError> {
448 Ok(CompiledPolicy::compile(policy, ctx)?.decide(action))
449}
450
451#[cfg(test)]
452mod tests;