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::{Component, Path, PathBuf};
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/// Output redirects and `tee` are intentionally absent. The quote-aware target
50/// scanner resolves relative paths from the tool invocation cwd instead of
51/// relying on command-text containment.
52///
53/// Compiled once. The patterns are known-valid, so a compile failure here is a
54/// programmer error and panics.
55static BASH_MUTATION_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
56    let config_dirs = crate::adapters::all_config_dir_names()
57        .iter()
58        .map(|d| regex::escape(d))
59        .collect::<Vec<_>>()
60        .join("|");
61    [
62        (
63            r"\b(npm|pnpm|yarn|bun)\s+(install|add|ci|i)\b".to_string(),
64            "package install/add",
65        ),
66        (r"\bpip3?\s+install\b".to_string(), "pip install"),
67        (r"\bsed\s+-i\b".to_string(), "in-place file edit (sed -i)"),
68        // A create/copy/move/link verb whose operand is a path under any
69        // harness config dir (`adapters::all_config_dir_names`) — catches
70        // stray writes to a config dir that aren't a `>` redirect (caught
71        // below). Read-only verbs (`cat`, `ls`) aren't listed, so inspecting
72        // the dirs stays allowed.
73        (
74            format!(r"\b(cp|mv|mkdir|touch|ln|rsync|install)\b[^|;&\n]*({config_dirs})(/|\b)"),
75            "path under a harness config dir",
76        ),
77        // The same create verbs whose operand is a top-level `skills/` directory —
78        // catches a bare `skills/` left in the cwd. `skills-data` and other
79        // `skills`-prefixed names are excluded by the trailing `/`, whitespace, or
80        // end-of-string boundary.
81        (
82            r#"\b(cp|mv|mkdir|touch|ln|rsync)\b[^|;&\n]*[\s'"=/]\.{0,2}/?skills(/|\s|$)"#
83                .to_string(),
84            "creates a bare skills/ dir",
85        ),
86    ]
87    .into_iter()
88    .map(|(re, reason)| {
89        (
90            Regex::new(&re)
91                .unwrap_or_else(|e| panic!("bundled bash pattern {re:?} is invalid: {e}")),
92            reason,
93        )
94    })
95    .collect()
96});
97
98/// Pull the target path from a write tool's arguments (`file_path` →
99/// `notebook_path` → `path` → `filePath`, the last being OpenCode's camelCase
100/// spelling). Returns `None` when the input is not an object or carries no
101/// string path.
102pub fn path_arg(args: &Value) -> Option<&str> {
103    let obj = args.as_object()?;
104    ["file_path", "notebook_path", "path", "filePath"]
105        .iter()
106        .find_map(|k| obj.get(*k).and_then(Value::as_str))
107}
108
109/// Extract file paths from an `apply_patch`-style tool payload. Codex exposes
110/// patch targets as a structured `files` list or as freeform patch text
111/// (`command`/`patch`/`input`/`content`), OpenCode as `patchText`; collect all
112/// of them so the guard can deny unknown or out-of-bounds patches before they
113/// run.
114pub fn apply_patch_paths(args: &Value) -> Vec<String> {
115    let mut out = Vec::new();
116    let Some(obj) = args.as_object() else {
117        return out;
118    };
119
120    if let Some(files) = obj.get("files") {
121        collect_file_values(files, &mut out);
122    }
123
124    for key in ["command", "patch", "input", "content", "patchText"] {
125        if let Some(text) = obj.get(key).and_then(Value::as_str) {
126            collect_patch_header_paths(text, &mut out);
127        }
128    }
129
130    out.sort();
131    out.dedup();
132    out
133}
134
135fn collect_file_values(value: &Value, out: &mut Vec<String>) {
136    match value {
137        Value::String(path) => out.push(path.to_string()),
138        Value::Array(items) => {
139            for item in items {
140                collect_file_values(item, out);
141            }
142        }
143        Value::Object(obj) => {
144            for key in ["file_path", "path", "absolute_file_path", "move_path"] {
145                if let Some(path) = obj.get(key).and_then(Value::as_str) {
146                    out.push(path.to_string());
147                }
148            }
149        }
150        _ => {}
151    }
152}
153
154fn collect_patch_header_paths(text: &str, out: &mut Vec<String>) {
155    for line in text.lines() {
156        for prefix in [
157            "*** Add File: ",
158            "*** Update File: ",
159            "*** Delete File: ",
160            "*** Move to: ",
161        ] {
162            if let Some(path) = line.strip_prefix(prefix) {
163                let path = path.trim();
164                if !path.is_empty() {
165                    out.push(path.to_string());
166                }
167            }
168        }
169    }
170}
171
172/// Lexically absolutize a path: join onto `repo_root` if relative, then normalize.
173/// Mirrors node's `resolve()` — no symlink resolution or existence requirement.
174pub(crate) fn resolve_path(target: &str, repo_root: &Path) -> PathBuf {
175    let joined = if Path::new(target).is_absolute() {
176        PathBuf::from(target)
177    } else {
178        repo_root.join(target)
179    };
180    let absolute = std::path::absolute(&joined).unwrap_or(joined);
181    let mut normalized = PathBuf::new();
182    for component in absolute.components() {
183        match component {
184            Component::CurDir => {}
185            Component::ParentDir => {
186                normalized.pop();
187            }
188            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
189            Component::RootDir => normalized.push(component.as_os_str()),
190            Component::Normal(part) => normalized.push(part),
191        }
192    }
193    normalized
194}
195
196/// True when `target` resolves to `dir` or a descendant of it. Relative `target`s
197/// resolve against `repo_root`. `Path::starts_with` matches whole path
198/// components, so `.eval-magic2` is correctly not under `.eval-magic`.
199pub fn is_under(target: &str, dir: &str, repo_root: &Path) -> bool {
200    let base = resolve_path(dir, repo_root);
201    let abs = resolve_path(target, repo_root);
202    abs.starts_with(&base)
203}
204
205/// True when `target` is under any of `dirs`.
206pub fn is_under_any(target: &str, dirs: &[String], repo_root: &Path) -> bool {
207    dirs.iter().any(|d| is_under(target, d, repo_root))
208}
209
210pub(super) const OUTPUT_REDIRECTION_REASON: &str = "output redirection to a file";
211
212/// A cwd-aware Bash denial plus the literal targets the scanner resolved.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub(crate) struct BashClassification {
215    pub reason: &'static str,
216    pub resolved_targets: Vec<String>,
217}
218
219/// Cwd-aware/evidence-producing implementation behind [`classify_bash`].
220pub(crate) fn classify_bash_with_cwd(
221    command: &str,
222    allowed_roots: &[String],
223    invocation_cwd: &Path,
224) -> Option<BashClassification> {
225    if command.is_empty() {
226        return None;
227    }
228    if let Some(denial) =
229        super::shell_targets::classify_output_targets(command, allowed_roots, invocation_cwd)
230    {
231        return Some(denial);
232    }
233    if let Some(denial) =
234        super::git_command::classify_git_commands(command, allowed_roots, invocation_cwd)
235    {
236        return Some(denial);
237    }
238    if allowed_roots.iter().any(|r| command.contains(r)) {
239        return None;
240    }
241    BASH_MUTATION_PATTERNS
242        .iter()
243        .find(|(re, _)| re.is_match(command))
244        .map(|(_, reason)| BashClassification {
245            reason,
246            resolved_targets: Vec::new(),
247        })
248}
249
250/// If a Bash command matches a mutation pattern and is not scoped to one of
251/// `allowed_roots`, return the human reason; otherwise `None`. A command is
252/// treated as scoped when it textually references an allowed root. Output-file
253/// targets are the exception: they are resolved lexically from the process cwd
254/// and every target must fall under an allowed root.
255pub fn classify_bash(command: &str, allowed_roots: &[String]) -> Option<&'static str> {
256    let cwd = std::env::current_dir().unwrap_or_default();
257    classify_bash_with_cwd(command, allowed_roots, &cwd).map(|result| result.reason)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use serde_json::json;
264
265    const ROOTS: [&str; 2] = ["/work/.eval-magic", "/work/.claude/skills"];
266
267    fn roots() -> Vec<String> {
268        ROOTS.iter().map(|s| s.to_string()).collect()
269    }
270
271    #[test]
272    fn is_write_tool_matches_every_harness_write_tool() {
273        for t in ["Write", "Edit", "MultiEdit", "NotebookEdit", "file_change"] {
274            assert!(is_write_tool(t), "{t} should be a write tool");
275        }
276        for t in ["Read", "Bash", "Grep", "apply_patch", ""] {
277            assert!(!is_write_tool(t), "{t} should not be a write tool");
278        }
279    }
280
281    #[test]
282    fn is_patch_tool_matches_apply_patch_style_tools_only() {
283        assert!(is_patch_tool("apply_patch"));
284        for t in ["Write", "Bash", "file_change", ""] {
285            assert!(!is_patch_tool(t), "{t} should not be a patch tool");
286        }
287    }
288
289    #[test]
290    fn is_shell_tool_matches_every_harness_shell_tool() {
291        for t in ["Bash", "command_execution"] {
292            assert!(is_shell_tool(t), "{t} should be a shell tool");
293        }
294        for t in ["Write", "apply_patch", ""] {
295            assert!(!is_shell_tool(t), "{t} should not be a shell tool");
296        }
297    }
298
299    #[test]
300    fn path_arg_prefers_file_path_then_notebook_then_path() {
301        assert_eq!(path_arg(&json!({ "file_path": "/a" })), Some("/a"));
302        assert_eq!(path_arg(&json!({ "notebook_path": "/b" })), Some("/b"));
303        assert_eq!(path_arg(&json!({ "path": "/c" })), Some("/c"));
304        assert_eq!(
305            path_arg(&json!({ "file_path": "/a", "path": "/c" })),
306            Some("/a")
307        );
308        assert_eq!(path_arg(&json!({ "command": "ls" })), None);
309        assert_eq!(path_arg(&json!("not an object")), None);
310    }
311
312    #[test]
313    fn path_arg_recognizes_opencode_camel_case_file_path() {
314        // OpenCode's edit/write tools take `filePath` (camelCase).
315        assert_eq!(path_arg(&json!({ "filePath": "/op" })), Some("/op"));
316        // snake_case still wins when both are present (claude/codex payloads).
317        assert_eq!(
318            path_arg(&json!({ "file_path": "/a", "filePath": "/op" })),
319            Some("/a")
320        );
321    }
322
323    #[test]
324    fn apply_patch_paths_reads_opencode_patch_text() {
325        // OpenCode's apply_patch tool takes the patch body as `patchText`.
326        let paths = apply_patch_paths(&json!({
327            "patchText": "*** Begin Patch\n*** Add File: docs/new.md\n*** End Patch\n"
328        }));
329        assert_eq!(paths, vec!["docs/new.md".to_string()]);
330    }
331
332    #[test]
333    fn apply_patch_paths_reads_codex_command_and_every_patch_target() {
334        let paths = apply_patch_paths(&json!({
335            "command": "\
336        *** Begin Patch\n\
337        *** Add File: fixtures/new.txt\n\
338        *** Update File: fixtures/source.txt\n\
339        *** Move to: fixtures/moved.txt\n\
340        *** Delete File: fixtures/old.txt\n\
341        *** End Patch\n"
342        }));
343        assert_eq!(
344            paths,
345            vec![
346                "fixtures/moved.txt".to_string(),
347                "fixtures/new.txt".to_string(),
348                "fixtures/old.txt".to_string(),
349                "fixtures/source.txt".to_string(),
350            ]
351        );
352    }
353
354    #[test]
355    fn apply_patch_paths_collects_structured_and_freeform_targets() {
356        let paths = apply_patch_paths(&json!({
357            "files": [
358                "/tmp/out.md",
359                { "path": "src/lib.rs" },
360                { "move_path": "src/new.rs" }
361            ],
362            "patch": "*** Begin Patch\n*** Update File: docs/a.md\n*** Move to: docs/b.md\n*** End Patch\n"
363        }));
364        assert_eq!(
365            paths,
366            vec![
367                "/tmp/out.md".to_string(),
368                "docs/a.md".to_string(),
369                "docs/b.md".to_string(),
370                "src/lib.rs".to_string(),
371                "src/new.rs".to_string(),
372            ]
373        );
374    }
375
376    #[test]
377    fn is_under_matches_dir_and_descendants() {
378        let repo = Path::new("/work");
379        assert!(is_under("/work/.eval-magic", "/work/.eval-magic", repo));
380        assert!(is_under(
381            "/work/.eval-magic/x/out.md",
382            "/work/.eval-magic",
383            repo
384        ));
385        assert!(!is_under("/work/runner/run.ts", "/work/.eval-magic", repo));
386        // `.eval-magic2` is not under `.eval-magic` (separator boundary).
387        assert!(!is_under("/work/.eval-magic2/x", "/work/.eval-magic", repo));
388    }
389
390    #[test]
391    fn is_under_resolves_relative_targets_against_repo_root() {
392        let repo = Path::new("/work");
393        assert!(is_under(".eval-magic/x", "/work/.eval-magic", repo));
394    }
395
396    #[test]
397    fn is_under_any_checks_every_root() {
398        let repo = Path::new("/work");
399        assert!(is_under_any("/work/.claude/skills/s", &roots(), repo));
400        assert!(!is_under_any("/etc/passwd", &roots(), repo));
401    }
402
403    #[test]
404    fn classify_bash_flags_installs_and_git_worktree_escape() {
405        assert_eq!(
406            classify_bash("npm install left-pad", &roots()),
407            Some("package install/add")
408        );
409        assert_eq!(
410            classify_bash("git worktree add ../wt -b scratch", &roots()),
411            Some("git worktree add (working tree outside the sandbox)")
412        );
413        assert_eq!(
414            classify_bash("echo hi > out.log", &roots()),
415            Some("output redirection to a file")
416        );
417    }
418
419    #[test]
420    fn classify_bash_allows_local_git_workflows_inside_the_task_repository() {
421        let cwd = Path::new("/work/.eval-magic/task");
422        for command in [
423            "git status --short",
424            "git add . && git commit -m baseline",
425            "git switch -c scratch",
426            "git checkout -- src/lib.rs",
427            "git reset HEAD~1",
428            "git branch topic",
429            "git rev-parse --git-dir",
430            "git -C /work/.eval-magic/task status",
431            "GIT_WORK_TREE=/work/.eval-magic/task git status",
432        ] {
433            assert_eq!(
434                classify_bash_with_cwd(command, &roots(), cwd),
435                None,
436                "{command}"
437            );
438        }
439    }
440
441    #[test]
442    fn classify_bash_denies_git_repository_routing_escapes() {
443        let cwd = Path::new("/work/.eval-magic/task");
444        for command in [
445            "git -C /outside status",
446            "git --git-dir=/outside/repo.git status",
447            "git --work-tree /outside status",
448            "GIT_DIR=/outside/repo.git git status",
449            "env GIT_WORK_TREE=../../outside git status",
450            "git -C \"$TARGET\" status",
451            "git --git-dir='unterminated status",
452        ] {
453            let denial = classify_bash_with_cwd(command, &roots(), cwd)
454                .unwrap_or_else(|| panic!("expected Git routing denial for {command}"));
455            assert_eq!(denial.reason, "git repository routing escape", "{command}");
456        }
457    }
458
459    #[test]
460    fn classify_bash_denies_remote_git_operations_before_allowed_root_shortcut() {
461        let cwd = Path::new("/work/.eval-magic/task");
462        for command in [
463            "git clone https://example.com/repo.git",
464            "git fetch origin",
465            "git --no-pager push origin main",
466            "git -c color.ui=false push origin main",
467            "git pull",
468            "git push /work/.eval-magic/task",
469            "git ls-remote origin",
470            "git submodule update --init",
471            "git send-email patch",
472            "git svn fetch",
473            "git p4 sync",
474            "git archive --remote=origin HEAD",
475            "git remote add origin https://example.com/repo.git",
476            "git remote remove origin",
477            "git config remote.origin.url https://example.com/repo.git",
478            "git config --add url.ssh://git@example.com/.insteadOf gh:",
479            "git config set remote.origin.url https://example.com/repo.git",
480            "git config unset remote.origin.url",
481            "git config rename-section remote.origin remote.backup",
482            "git config remove-section remote.origin",
483        ] {
484            let denial = classify_bash_with_cwd(command, &roots(), cwd)
485                .unwrap_or_else(|| panic!("expected remote Git denial for {command}"));
486            assert_eq!(denial.reason, "git remote operation", "{command}");
487        }
488    }
489
490    #[test]
491    fn classify_bash_allows_remote_inspection_without_mutation() {
492        let cwd = Path::new("/work/.eval-magic/task");
493        for command in [
494            "git remote",
495            "git remote -v",
496            "git remote get-url origin",
497            "git config remote.origin.url",
498            "git config --get url.ssh://git@example.com/.insteadOf",
499        ] {
500            assert_eq!(
501                classify_bash_with_cwd(command, &roots(), cwd),
502                None,
503                "{command}"
504            );
505        }
506    }
507
508    #[test]
509    fn classify_bash_flags_creates_under_every_harness_config_dir_but_allows_reads() {
510        for dir in crate::adapters::all_config_dir_names() {
511            assert_eq!(
512                classify_bash(&format!("mkdir -p {dir}/x"), &[]),
513                Some("path under a harness config dir"),
514                "mkdir under {dir} should be flagged"
515            );
516            assert_eq!(
517                classify_bash(&format!("cp evil.json {dir}/hooks.json"), &[]),
518                Some("path under a harness config dir"),
519                "cp into {dir} should be flagged"
520            );
521            assert_eq!(
522                classify_bash(&format!("cat {dir}/settings.json"), &[]),
523                None,
524                "read of {dir} should stay allowed"
525            );
526            assert_eq!(classify_bash(&format!("ls {dir}"), &[]), None);
527        }
528    }
529
530    #[test]
531    fn classify_bash_allows_scoped_and_readonly_commands() {
532        // Textually references an allowed root → scoped → allowed.
533        assert_eq!(
534            classify_bash("echo hi > /work/.eval-magic/x/log", &roots()),
535            None
536        );
537        assert_eq!(classify_bash("ls -la /", &roots()), None);
538        assert_eq!(classify_bash("", &roots()), None);
539    }
540}