Skip to main content

eval_magic/sandbox/
policy.rs

1//! Write-boundary primitives.
2//!
3//! Stateless classifiers shared by the armed guard ([`super::decide`]) and
4//! `pipeline::detect-stray-writes`: which tools write, which Bash commands
5//! mutate state outside a sandbox, and whether a path falls under an allowed
6//! root.
7
8use std::path::Path;
9use std::sync::LazyLock;
10
11use regex::Regex;
12use serde_json::Value;
13
14/// Tools that mutate the filesystem and carry a target path argument.
15pub const WRITE_TOOLS: [&str; 4] = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
16
17/// True for a tool name that writes the filesystem with a path argument.
18pub fn is_write_tool(tool_name: &str) -> bool {
19    WRITE_TOOLS.contains(&tool_name)
20}
21
22/// Bash command patterns that mutate state outside an eval's sandbox. Heuristics
23/// — Bash is too flexible to parse exactly. `detect-stray-writes` surfaces these
24/// as warnings; the opt-in guard denies them. Each is meaningful only when the
25/// command does not reference an allowed root (see [`classify_bash`]).
26///
27/// Compiled once. The patterns are known-valid, so a compile failure here is a
28/// programmer error and panics.
29static BASH_MUTATION_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
30    [
31        (
32            r"\b(npm|pnpm|yarn|bun)\s+(install|add|ci|i)\b",
33            "package install/add",
34        ),
35        (r"\bpip3?\s+install\b", "pip install"),
36        (r"\bsed\s+-i\b", "in-place file edit (sed -i)"),
37        (
38            r"\bgit\s+(commit|add|push|checkout|reset|restore|merge|rebase)\b",
39            "git mutation",
40        ),
41        (
42            r"\bgit\s+worktree\s+add\b",
43            "git worktree add (working tree outside the sandbox)",
44        ),
45        // A create/copy/move/link verb whose operand is a path under `.claude` —
46        // catches stray writes to the harness config dir that aren't a `>`
47        // redirect (caught below). Read-only verbs (`cat`, `ls`) aren't listed,
48        // so inspecting `.claude` stays allowed.
49        (
50            r"\b(cp|mv|mkdir|touch|ln|rsync|install)\b[^|;&\n]*\.claude(/|\b)",
51            "path under .claude",
52        ),
53        // The same create verbs whose operand is a top-level `skills/` directory —
54        // catches a bare `skills/` left in the cwd. `skills-data` and other
55        // `skills`-prefixed names are excluded by the trailing `/`, whitespace, or
56        // end-of-string boundary.
57        (
58            r#"\b(cp|mv|mkdir|touch|ln|rsync)\b[^|;&\n]*[\s'"=/]\.{0,2}/?skills(/|\s|$)"#,
59            "creates a bare skills/ dir",
60        ),
61        (r"(^|\s)(>>?|tee)\s", "output redirection to a file"),
62    ]
63    .into_iter()
64    .map(|(re, reason)| {
65        (
66            Regex::new(re)
67                .unwrap_or_else(|e| panic!("bundled bash pattern {re:?} is invalid: {e}")),
68            reason,
69        )
70    })
71    .collect()
72});
73
74/// Pull the target path from a write tool's arguments (`file_path` →
75/// `notebook_path` → `path`). Returns `None` when the input is not an object or
76/// carries no string path.
77pub fn path_arg(args: &Value) -> Option<&str> {
78    let obj = args.as_object()?;
79    ["file_path", "notebook_path", "path"]
80        .iter()
81        .find_map(|k| obj.get(*k).and_then(Value::as_str))
82}
83
84/// Extract file paths from a Codex `apply_patch` hook payload. Codex can expose
85/// patch targets as a structured `files` list or as freeform patch text; collect
86/// both so the guard can deny unknown or out-of-bounds patches before they run.
87pub fn apply_patch_paths(args: &Value) -> Vec<String> {
88    let mut out = Vec::new();
89    let Some(obj) = args.as_object() else {
90        return out;
91    };
92
93    if let Some(files) = obj.get("files") {
94        collect_file_values(files, &mut out);
95    }
96
97    for key in ["patch", "input", "content"] {
98        if let Some(text) = obj.get(key).and_then(Value::as_str) {
99            collect_patch_header_paths(text, &mut out);
100        }
101    }
102
103    out.sort();
104    out.dedup();
105    out
106}
107
108fn collect_file_values(value: &Value, out: &mut Vec<String>) {
109    match value {
110        Value::String(path) => out.push(path.to_string()),
111        Value::Array(items) => {
112            for item in items {
113                collect_file_values(item, out);
114            }
115        }
116        Value::Object(obj) => {
117            for key in ["file_path", "path", "absolute_file_path", "move_path"] {
118                if let Some(path) = obj.get(key).and_then(Value::as_str) {
119                    out.push(path.to_string());
120                }
121            }
122        }
123        _ => {}
124    }
125}
126
127fn collect_patch_header_paths(text: &str, out: &mut Vec<String>) {
128    for line in text.lines() {
129        for prefix in [
130            "*** Add File: ",
131            "*** Update File: ",
132            "*** Delete File: ",
133            "*** Move to: ",
134        ] {
135            if let Some(path) = line.strip_prefix(prefix) {
136                let path = path.trim();
137                if !path.is_empty() {
138                    out.push(path.to_string());
139                }
140            }
141        }
142    }
143}
144
145/// Lexically absolutize a path: join onto `repo_root` if relative, then normalize.
146/// Mirrors node's `resolve()` — no symlink resolution or existence requirement.
147fn absolutize(target: &str, repo_root: &Path) -> std::path::PathBuf {
148    let joined = if Path::new(target).is_absolute() {
149        std::path::PathBuf::from(target)
150    } else {
151        repo_root.join(target)
152    };
153    // `std::path::absolute` normalizes `.`/`..` lexically without touching disk.
154    std::path::absolute(&joined).unwrap_or(joined)
155}
156
157/// True when `target` resolves to `dir` or a descendant of it. Relative `target`s
158/// resolve against `repo_root`. `Path::starts_with` matches whole path
159/// components, so `.eval-magic2` is correctly not under `.eval-magic`.
160pub fn is_under(target: &str, dir: &str, repo_root: &Path) -> bool {
161    let base = absolutize(dir, repo_root);
162    let abs = absolutize(target, repo_root);
163    abs.starts_with(&base)
164}
165
166/// True when `target` is under any of `dirs`.
167pub fn is_under_any(target: &str, dirs: &[String], repo_root: &Path) -> bool {
168    dirs.iter().any(|d| is_under(target, d, repo_root))
169}
170
171/// If a Bash command matches a mutation pattern and is not scoped to one of
172/// `allowed_roots`, return the human reason; otherwise `None`. A command is
173/// treated as scoped when it textually references an allowed root.
174pub fn classify_bash(command: &str, allowed_roots: &[String]) -> Option<&'static str> {
175    if command.is_empty() {
176        return None;
177    }
178    if allowed_roots.iter().any(|r| command.contains(r)) {
179        return None;
180    }
181    BASH_MUTATION_PATTERNS
182        .iter()
183        .find(|(re, _)| re.is_match(command))
184        .map(|(_, reason)| *reason)
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use serde_json::json;
191
192    const ROOTS: [&str; 2] = ["/work/.eval-magic", "/work/.claude/skills"];
193
194    fn roots() -> Vec<String> {
195        ROOTS.iter().map(|s| s.to_string()).collect()
196    }
197
198    #[test]
199    fn is_write_tool_matches_the_four_write_tools() {
200        for t in ["Write", "Edit", "MultiEdit", "NotebookEdit"] {
201            assert!(is_write_tool(t), "{t} should be a write tool");
202        }
203        for t in ["Read", "Bash", "Grep", ""] {
204            assert!(!is_write_tool(t), "{t} should not be a write tool");
205        }
206    }
207
208    #[test]
209    fn path_arg_prefers_file_path_then_notebook_then_path() {
210        assert_eq!(path_arg(&json!({ "file_path": "/a" })), Some("/a"));
211        assert_eq!(path_arg(&json!({ "notebook_path": "/b" })), Some("/b"));
212        assert_eq!(path_arg(&json!({ "path": "/c" })), Some("/c"));
213        assert_eq!(
214            path_arg(&json!({ "file_path": "/a", "path": "/c" })),
215            Some("/a")
216        );
217        assert_eq!(path_arg(&json!({ "command": "ls" })), None);
218        assert_eq!(path_arg(&json!("not an object")), None);
219    }
220
221    #[test]
222    fn apply_patch_paths_collects_structured_and_freeform_targets() {
223        let paths = apply_patch_paths(&json!({
224            "files": [
225                "/tmp/out.md",
226                { "path": "src/lib.rs" },
227                { "move_path": "src/new.rs" }
228            ],
229            "patch": "*** Begin Patch\n*** Update File: docs/a.md\n*** Move to: docs/b.md\n*** End Patch\n"
230        }));
231        assert_eq!(
232            paths,
233            vec![
234                "/tmp/out.md".to_string(),
235                "docs/a.md".to_string(),
236                "docs/b.md".to_string(),
237                "src/lib.rs".to_string(),
238                "src/new.rs".to_string(),
239            ]
240        );
241    }
242
243    #[test]
244    fn is_under_matches_dir_and_descendants() {
245        let repo = Path::new("/work");
246        assert!(is_under("/work/.eval-magic", "/work/.eval-magic", repo));
247        assert!(is_under(
248            "/work/.eval-magic/x/out.md",
249            "/work/.eval-magic",
250            repo
251        ));
252        assert!(!is_under("/work/runner/run.ts", "/work/.eval-magic", repo));
253        // `.eval-magic2` is not under `.eval-magic` (separator boundary).
254        assert!(!is_under("/work/.eval-magic2/x", "/work/.eval-magic", repo));
255    }
256
257    #[test]
258    fn is_under_resolves_relative_targets_against_repo_root() {
259        let repo = Path::new("/work");
260        assert!(is_under(".eval-magic/x", "/work/.eval-magic", repo));
261    }
262
263    #[test]
264    fn is_under_any_checks_every_root() {
265        let repo = Path::new("/work");
266        assert!(is_under_any("/work/.claude/skills/s", &roots(), repo));
267        assert!(!is_under_any("/etc/passwd", &roots(), repo));
268    }
269
270    #[test]
271    fn classify_bash_flags_install_and_git_mutations() {
272        assert_eq!(
273            classify_bash("npm install left-pad", &roots()),
274            Some("package install/add")
275        );
276        assert_eq!(
277            classify_bash("git worktree add ../wt -b scratch", &roots()),
278            Some("git worktree add (working tree outside the sandbox)")
279        );
280        assert_eq!(
281            classify_bash("echo hi > out.log", &roots()),
282            Some("output redirection to a file")
283        );
284    }
285
286    #[test]
287    fn classify_bash_allows_scoped_and_readonly_commands() {
288        // Textually references an allowed root → scoped → allowed.
289        assert_eq!(
290            classify_bash("echo hi > /work/.eval-magic/x/log", &roots()),
291            None
292        );
293        assert_eq!(classify_bash("ls -la /", &roots()), None);
294        assert_eq!(classify_bash("", &roots()), None);
295    }
296}