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. Tool names come from the adapters' cross-harness vocabulary union
7//! ([`all_tool_vocabulary`]), so no harness's tool naming is hardcoded here.
8
9use std::path::Path;
10use std::sync::LazyLock;
11
12use regex::Regex;
13use serde_json::Value;
14
15use crate::adapters::all_tool_vocabulary;
16
17/// True for a tool name that writes the filesystem with a single target path
18/// argument, in any harness's vocabulary.
19pub fn is_write_tool(tool_name: &str) -> bool {
20    all_tool_vocabulary()
21        .write_tools
22        .iter()
23        .any(|t| t == tool_name)
24}
25
26/// True for an apply_patch-style tool whose payload carries patch targets
27/// (extracted with [`apply_patch_paths`]), in any harness's vocabulary.
28pub fn is_patch_tool(tool_name: &str) -> bool {
29    all_tool_vocabulary()
30        .patch_tools
31        .iter()
32        .any(|t| t == tool_name)
33}
34
35/// True for a shell-execution tool carrying a `command` argument, in any
36/// harness's vocabulary.
37pub fn is_shell_tool(tool_name: &str) -> bool {
38    all_tool_vocabulary()
39        .shell_tools
40        .iter()
41        .any(|t| t == tool_name)
42}
43
44/// Bash command patterns that mutate state outside an eval's sandbox. Heuristics
45/// — Bash is too flexible to parse exactly. `detect-stray-writes` surfaces these
46/// as warnings; the opt-in guard denies them. Each is meaningful only when the
47/// command does not reference an allowed root (see [`classify_bash`]).
48///
49/// Compiled once. The patterns are known-valid, so a compile failure here is a
50/// programmer error and panics.
51static BASH_MUTATION_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
52    let config_dirs = crate::adapters::all_config_dir_names()
53        .iter()
54        .map(|d| regex::escape(d))
55        .collect::<Vec<_>>()
56        .join("|");
57    [
58        (
59            r"\b(npm|pnpm|yarn|bun)\s+(install|add|ci|i)\b".to_string(),
60            "package install/add",
61        ),
62        (r"\bpip3?\s+install\b".to_string(), "pip install"),
63        (r"\bsed\s+-i\b".to_string(), "in-place file edit (sed -i)"),
64        (
65            r"\bgit\s+(commit|add|push|checkout|reset|restore|merge|rebase)\b".to_string(),
66            "git mutation",
67        ),
68        (
69            r"\bgit\s+worktree\s+add\b".to_string(),
70            "git worktree add (working tree outside the sandbox)",
71        ),
72        // A create/copy/move/link verb whose operand is a path under any
73        // harness config dir (`adapters::all_config_dir_names`) — catches
74        // stray writes to a config dir that aren't a `>` redirect (caught
75        // below). Read-only verbs (`cat`, `ls`) aren't listed, so inspecting
76        // the dirs stays allowed.
77        (
78            format!(r"\b(cp|mv|mkdir|touch|ln|rsync|install)\b[^|;&\n]*({config_dirs})(/|\b)"),
79            "path under a harness config dir",
80        ),
81        // The same create verbs whose operand is a top-level `skills/` directory —
82        // catches a bare `skills/` left in the cwd. `skills-data` and other
83        // `skills`-prefixed names are excluded by the trailing `/`, whitespace, or
84        // end-of-string boundary.
85        (
86            r#"\b(cp|mv|mkdir|touch|ln|rsync)\b[^|;&\n]*[\s'"=/]\.{0,2}/?skills(/|\s|$)"#
87                .to_string(),
88            "creates a bare skills/ dir",
89        ),
90        (
91            r"(^|\s)(>>?|tee)\s".to_string(),
92            "output redirection to a file",
93        ),
94    ]
95    .into_iter()
96    .map(|(re, reason)| {
97        (
98            Regex::new(&re)
99                .unwrap_or_else(|e| panic!("bundled bash pattern {re:?} is invalid: {e}")),
100            reason,
101        )
102    })
103    .collect()
104});
105
106/// Pull the target path from a write tool's arguments (`file_path` →
107/// `notebook_path` → `path` → `filePath`, the last being OpenCode's camelCase
108/// spelling). Returns `None` when the input is not an object or carries no
109/// string path.
110pub fn path_arg(args: &Value) -> Option<&str> {
111    let obj = args.as_object()?;
112    ["file_path", "notebook_path", "path", "filePath"]
113        .iter()
114        .find_map(|k| obj.get(*k).and_then(Value::as_str))
115}
116
117/// Extract file paths from an `apply_patch`-style tool payload. Codex exposes
118/// patch targets as a structured `files` list or as freeform patch text
119/// (`patch`/`input`/`content`), OpenCode as `patchText`; collect all of them
120/// so the guard can deny unknown or out-of-bounds patches before they run.
121pub fn apply_patch_paths(args: &Value) -> Vec<String> {
122    let mut out = Vec::new();
123    let Some(obj) = args.as_object() else {
124        return out;
125    };
126
127    if let Some(files) = obj.get("files") {
128        collect_file_values(files, &mut out);
129    }
130
131    for key in ["patch", "input", "content", "patchText"] {
132        if let Some(text) = obj.get(key).and_then(Value::as_str) {
133            collect_patch_header_paths(text, &mut out);
134        }
135    }
136
137    out.sort();
138    out.dedup();
139    out
140}
141
142fn collect_file_values(value: &Value, out: &mut Vec<String>) {
143    match value {
144        Value::String(path) => out.push(path.to_string()),
145        Value::Array(items) => {
146            for item in items {
147                collect_file_values(item, out);
148            }
149        }
150        Value::Object(obj) => {
151            for key in ["file_path", "path", "absolute_file_path", "move_path"] {
152                if let Some(path) = obj.get(key).and_then(Value::as_str) {
153                    out.push(path.to_string());
154                }
155            }
156        }
157        _ => {}
158    }
159}
160
161fn collect_patch_header_paths(text: &str, out: &mut Vec<String>) {
162    for line in text.lines() {
163        for prefix in [
164            "*** Add File: ",
165            "*** Update File: ",
166            "*** Delete File: ",
167            "*** Move to: ",
168        ] {
169            if let Some(path) = line.strip_prefix(prefix) {
170                let path = path.trim();
171                if !path.is_empty() {
172                    out.push(path.to_string());
173                }
174            }
175        }
176    }
177}
178
179/// Lexically absolutize a path: join onto `repo_root` if relative, then normalize.
180/// Mirrors node's `resolve()` — no symlink resolution or existence requirement.
181fn absolutize(target: &str, repo_root: &Path) -> std::path::PathBuf {
182    let joined = if Path::new(target).is_absolute() {
183        std::path::PathBuf::from(target)
184    } else {
185        repo_root.join(target)
186    };
187    // `std::path::absolute` normalizes `.`/`..` lexically without touching disk.
188    std::path::absolute(&joined).unwrap_or(joined)
189}
190
191/// True when `target` resolves to `dir` or a descendant of it. Relative `target`s
192/// resolve against `repo_root`. `Path::starts_with` matches whole path
193/// components, so `.eval-magic2` is correctly not under `.eval-magic`.
194pub fn is_under(target: &str, dir: &str, repo_root: &Path) -> bool {
195    let base = absolutize(dir, repo_root);
196    let abs = absolutize(target, repo_root);
197    abs.starts_with(&base)
198}
199
200/// True when `target` is under any of `dirs`.
201pub fn is_under_any(target: &str, dirs: &[String], repo_root: &Path) -> bool {
202    dirs.iter().any(|d| is_under(target, d, repo_root))
203}
204
205/// If a Bash command matches a mutation pattern and is not scoped to one of
206/// `allowed_roots`, return the human reason; otherwise `None`. A command is
207/// treated as scoped when it textually references an allowed root.
208pub fn classify_bash(command: &str, allowed_roots: &[String]) -> Option<&'static str> {
209    if command.is_empty() {
210        return None;
211    }
212    if allowed_roots.iter().any(|r| command.contains(r)) {
213        return None;
214    }
215    BASH_MUTATION_PATTERNS
216        .iter()
217        .find(|(re, _)| re.is_match(command))
218        .map(|(_, reason)| *reason)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use serde_json::json;
225
226    const ROOTS: [&str; 2] = ["/work/.eval-magic", "/work/.claude/skills"];
227
228    fn roots() -> Vec<String> {
229        ROOTS.iter().map(|s| s.to_string()).collect()
230    }
231
232    #[test]
233    fn is_write_tool_matches_every_harness_write_tool() {
234        for t in ["Write", "Edit", "MultiEdit", "NotebookEdit", "file_change"] {
235            assert!(is_write_tool(t), "{t} should be a write tool");
236        }
237        for t in ["Read", "Bash", "Grep", "apply_patch", ""] {
238            assert!(!is_write_tool(t), "{t} should not be a write tool");
239        }
240    }
241
242    #[test]
243    fn is_patch_tool_matches_apply_patch_style_tools_only() {
244        assert!(is_patch_tool("apply_patch"));
245        for t in ["Write", "Bash", "file_change", ""] {
246            assert!(!is_patch_tool(t), "{t} should not be a patch tool");
247        }
248    }
249
250    #[test]
251    fn is_shell_tool_matches_every_harness_shell_tool() {
252        for t in ["Bash", "command_execution"] {
253            assert!(is_shell_tool(t), "{t} should be a shell tool");
254        }
255        for t in ["Write", "apply_patch", ""] {
256            assert!(!is_shell_tool(t), "{t} should not be a shell tool");
257        }
258    }
259
260    #[test]
261    fn path_arg_prefers_file_path_then_notebook_then_path() {
262        assert_eq!(path_arg(&json!({ "file_path": "/a" })), Some("/a"));
263        assert_eq!(path_arg(&json!({ "notebook_path": "/b" })), Some("/b"));
264        assert_eq!(path_arg(&json!({ "path": "/c" })), Some("/c"));
265        assert_eq!(
266            path_arg(&json!({ "file_path": "/a", "path": "/c" })),
267            Some("/a")
268        );
269        assert_eq!(path_arg(&json!({ "command": "ls" })), None);
270        assert_eq!(path_arg(&json!("not an object")), None);
271    }
272
273    #[test]
274    fn path_arg_recognizes_opencode_camel_case_file_path() {
275        // OpenCode's edit/write tools take `filePath` (camelCase).
276        assert_eq!(path_arg(&json!({ "filePath": "/op" })), Some("/op"));
277        // snake_case still wins when both are present (claude/codex payloads).
278        assert_eq!(
279            path_arg(&json!({ "file_path": "/a", "filePath": "/op" })),
280            Some("/a")
281        );
282    }
283
284    #[test]
285    fn apply_patch_paths_reads_opencode_patch_text() {
286        // OpenCode's apply_patch tool takes the patch body as `patchText`.
287        let paths = apply_patch_paths(&json!({
288            "patchText": "*** Begin Patch\n*** Add File: docs/new.md\n*** End Patch\n"
289        }));
290        assert_eq!(paths, vec!["docs/new.md".to_string()]);
291    }
292
293    #[test]
294    fn apply_patch_paths_collects_structured_and_freeform_targets() {
295        let paths = apply_patch_paths(&json!({
296            "files": [
297                "/tmp/out.md",
298                { "path": "src/lib.rs" },
299                { "move_path": "src/new.rs" }
300            ],
301            "patch": "*** Begin Patch\n*** Update File: docs/a.md\n*** Move to: docs/b.md\n*** End Patch\n"
302        }));
303        assert_eq!(
304            paths,
305            vec![
306                "/tmp/out.md".to_string(),
307                "docs/a.md".to_string(),
308                "docs/b.md".to_string(),
309                "src/lib.rs".to_string(),
310                "src/new.rs".to_string(),
311            ]
312        );
313    }
314
315    #[test]
316    fn is_under_matches_dir_and_descendants() {
317        let repo = Path::new("/work");
318        assert!(is_under("/work/.eval-magic", "/work/.eval-magic", repo));
319        assert!(is_under(
320            "/work/.eval-magic/x/out.md",
321            "/work/.eval-magic",
322            repo
323        ));
324        assert!(!is_under("/work/runner/run.ts", "/work/.eval-magic", repo));
325        // `.eval-magic2` is not under `.eval-magic` (separator boundary).
326        assert!(!is_under("/work/.eval-magic2/x", "/work/.eval-magic", repo));
327    }
328
329    #[test]
330    fn is_under_resolves_relative_targets_against_repo_root() {
331        let repo = Path::new("/work");
332        assert!(is_under(".eval-magic/x", "/work/.eval-magic", repo));
333    }
334
335    #[test]
336    fn is_under_any_checks_every_root() {
337        let repo = Path::new("/work");
338        assert!(is_under_any("/work/.claude/skills/s", &roots(), repo));
339        assert!(!is_under_any("/etc/passwd", &roots(), repo));
340    }
341
342    #[test]
343    fn classify_bash_flags_install_and_git_mutations() {
344        assert_eq!(
345            classify_bash("npm install left-pad", &roots()),
346            Some("package install/add")
347        );
348        assert_eq!(
349            classify_bash("git worktree add ../wt -b scratch", &roots()),
350            Some("git worktree add (working tree outside the sandbox)")
351        );
352        assert_eq!(
353            classify_bash("echo hi > out.log", &roots()),
354            Some("output redirection to a file")
355        );
356    }
357
358    #[test]
359    fn classify_bash_flags_creates_under_every_harness_config_dir_but_allows_reads() {
360        for dir in crate::adapters::all_config_dir_names() {
361            assert_eq!(
362                classify_bash(&format!("mkdir -p {dir}/x"), &[]),
363                Some("path under a harness config dir"),
364                "mkdir under {dir} should be flagged"
365            );
366            assert_eq!(
367                classify_bash(&format!("cp evil.json {dir}/hooks.json"), &[]),
368                Some("path under a harness config dir"),
369                "cp into {dir} should be flagged"
370            );
371            assert_eq!(
372                classify_bash(&format!("cat {dir}/settings.json"), &[]),
373                None,
374                "read of {dir} should stay allowed"
375            );
376            assert_eq!(classify_bash(&format!("ls {dir}"), &[]), None);
377        }
378    }
379
380    #[test]
381    fn classify_bash_allows_scoped_and_readonly_commands() {
382        // Textually references an allowed root → scoped → allowed.
383        assert_eq!(
384            classify_bash("echo hi > /work/.eval-magic/x/log", &roots()),
385            None
386        );
387        assert_eq!(classify_bash("ls -la /", &roots()), None);
388        assert_eq!(classify_bash("", &roots()), None);
389    }
390}