kovra_wrapper/allowlist.rs
1//! The executor allowlist (spec §5.1, invariant I15).
2//!
3//! Injecting a `high`/`prod` secret into a child process is only a containment
4//! boundary if the executable is **outside the agent's control** — a process the
5//! agent authored can read its own environment and print it (last-mile, §16).
6//! So `high`/`prod` injection is restricted to a configured allowlist of
7//! reviewed executables (e.g. a versioned `./deploy.sh`, a Makefile target);
8//! ad-hoc commands the agent improvises are not eligible.
9//!
10//! Matching is on the **resolved program path**, canonicalized (symlinks and
11//! relative components resolved) so `./deploy.sh`, `deploy.sh`, and the absolute
12//! path all compare equal when they name the same reviewed file.
13
14use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16
17/// A set of reviewed executable paths eligible to receive `high`/`prod`
18/// injection. An empty allowlist refuses **every** `high`/`prod` command (fails
19/// safe); `low`/`medium` non-prod injection never consults it (§5.1).
20#[derive(Debug, Clone, Default)]
21pub struct Allowlist {
22 /// Canonicalized (where possible) absolute paths of reviewed executables.
23 entries: BTreeSet<PathBuf>,
24}
25
26impl Allowlist {
27 /// An empty allowlist — refuses all `high`/`prod` commands.
28 pub fn empty() -> Self {
29 Self::default()
30 }
31
32 /// Build an allowlist from a set of reviewed executable paths.
33 pub fn from_paths<I, P>(paths: I) -> Self
34 where
35 I: IntoIterator<Item = P>,
36 P: Into<PathBuf>,
37 {
38 Self {
39 entries: paths
40 .into_iter()
41 .map(|p| canonical_or_owned(&p.into()))
42 .collect(),
43 }
44 }
45
46 /// Add one reviewed executable to the allowlist.
47 pub fn allow(&mut self, path: impl Into<PathBuf>) {
48 self.entries.insert(canonical_or_owned(&path.into()));
49 }
50
51 /// Whether `program` is a reviewed, allowlisted executable.
52 pub fn allows(&self, program: &Path) -> bool {
53 self.entries.contains(&canonical_or_owned(program))
54 }
55
56 /// Whether the allowlist is empty (refuses every `high`/`prod` command).
57 pub fn is_empty(&self) -> bool {
58 self.entries.is_empty()
59 }
60}
61
62/// Canonicalize a path, falling back to the path as-given when it does not exist
63/// on disk (a non-existent command can never match a real reviewed file, so the
64/// fallback is safe — it simply will not be on the list).
65fn canonical_or_owned(p: &Path) -> PathBuf {
66 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
67}
68
69/// Resolve `program` to the exact canonical path the allowlist matches against,
70/// so a caller can **execute the same file it vetted** (I15). The allowlist
71/// check canonicalizes (resolving symlinks and `..`), but if the spawn used the
72/// raw, un-canonicalized path the OS would re-resolve it at `exec` time — letting
73/// an allowlisted symlink be repointed during the confirmation window (TOCTOU).
74/// Spawning this resolved path instead binds the decision to the execution.
75/// Falls back to the path as-given when it cannot be resolved (then the gate has
76/// already refused it, since it cannot match a real reviewed file).
77pub fn resolve_program(program: &Path) -> PathBuf {
78 canonical_or_owned(program)
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn empty_allowlist_refuses_everything() {
87 let a = Allowlist::empty();
88 assert!(a.is_empty());
89 assert!(!a.allows(Path::new("/usr/bin/env")));
90 }
91
92 #[test]
93 fn allowlisted_program_matches_through_canonicalization() {
94 // A real file so canonicalize succeeds on both sides.
95 let dir = tempfile::tempdir().unwrap();
96 let exe = dir.path().join("deploy.sh");
97 std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
98
99 let a = Allowlist::from_paths([&exe]);
100 assert!(a.allows(&exe));
101
102 // A different file is not on the list.
103 let other = dir.path().join("evil.sh");
104 std::fs::write(&other, b"#!/bin/sh\n").unwrap();
105 assert!(!a.allows(&other));
106 }
107
108 #[test]
109 fn allow_adds_an_entry() {
110 let dir = tempfile::tempdir().unwrap();
111 let exe = dir.path().join("run.sh");
112 std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
113 let mut a = Allowlist::empty();
114 assert!(!a.allows(&exe));
115 a.allow(&exe);
116 assert!(a.allows(&exe));
117 }
118}