Files
clawmates/crates/cm-api/src/vm_stop_gate.rs
T
Omar SobhandClaude Opus 5 547b5d9987 feat(missions): a pre-execution gate on mission tool calls
The second half of the tool-call research. Until now a mission agent's
Bash call was gated by nothing, anywhere.

WHY THERE WAS NO GATE

vm_tool_tap is a PostToolUse hook: it fires after the tool has already run
and exit-0s unconditionally, because a non-zero PostToolUse talks back to
the model. It is telemetry and says so. GatePolicy — the §15 door — has one
enforcement site, the chat loop, and its approvals key on
(session_id, message_id), which no mission phase can produce. Meanwhile the
solo tiers run `claude -p --permission-mode acceptEdits` with Read, Edit,
Write and Bash pre-approved.

PreToolUse fires under `claude -p` in this image — measured by vm_stop_gate,
which also proved the exit-2-plus-stderr contract — and had zero callers.
This is that hook.

WHAT IT IS, AND IS NOT

A deterministic policy gate: a short deny list of actions with no legitimate
form inside a mission, blocked before they run, with the reason handed back
so the model can choose differently.

It is NOT the §15 human approval gate, and the module says so. A hook blocks
the agent's process while it runs and a human decision takes minutes to
hours; waiting inside the hook would wedge the turn. This closes the gap
between nothing and something.

The deny list is short on purpose. A gate that blocks legitimate work is
worse than none: the agent cannot ask a human, so it either works around the
block — doing something stranger than what was denied — or burns the turn.

FOUR BUGS THE TESTS FOUND, IN ORDER

1. Substring matching denied `grep -rn 'rm -rf /' docs/`. Searching for a
   string is not running it. Now rules anchor to the start of a shell
   segment, with a separate flag-style match that exempts text tools.
2. The `case` patterns were unquoted, so a needle containing a space made
   the whole script a SYNTAX ERROR — which as a PreToolUse hook exits
   non-zero and denies EVERY call. Every text assertion passed while the
   script was in that state; only running it under a real `sh` found it.
3. The hook receives JSON, not a command, so "starts with" could never
   match — `case` saw `{"tool_name":"bash",...` every time. Now extracts
   tool_name and tool_input.command with `node` (no jq in the image; node is
   guaranteed because Claude Code is a node program).
4. `IFS='\n'` in POSIX sh sets IFS to backslash and the letter n, not a
   newline. Nothing split, so only commands with no separator were ever
   tested and `cd /tmp && rm -rf /` sailed through. Now a literal newline.

Every failure path allows. A gate that fails closed on a parse error blocks
the whole phase, which is exactly what bug 2 did.

Wired through vm_tool_tap::guest_settings, still the single writer of the
guest settings document — a third hook makes the clobber it prevents more
likely, not less, and a test asserts all three survive one document and that
PreToolUse points at the gate's own script rather than the tap's.

Honest limit, stated in the module: a determined agent defeats any
string-matching gate. This is aimed at accidents and obvious cases; the real
isolation is the container and microVM boundary.

Full workspace suite green: 106 binaries, zero build errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 14:50:01 -07:00

517 lines
22 KiB
Rust

//! The completion gate, moved into the agent's own loop.
//!
//! Every check this platform has on a phase runs **after** the agent has
//! finished: the evaluator judges `done_when`, capture notices that a coding
//! phase delivered nothing, and either verdict costs a whole new VM — a fresh
//! boot, a fresh inject, and an agent starting again with none of the context
//! that got it that far. Meanwhile the documented failure of a long-running
//! agent is that it *stops too early*.
//!
//! Claude Code's `Stop` hook is the seam. **Exit code 2 blocks the stop and
//! feeds stderr back to the model as the reason.** Measured, not read off docs —
//! an agent told "say hello and do nothing else", whose `Stop` hook exited 2
//! saying `evidence.txt` was missing, created `evidence.txt` and then stopped.
//!
//! # What it may and may not check
//!
//! Deliberately mechanical: whether the repository changed, and whether a
//! command the phase author wrote exits 0. NOT the `done_when` verdict — that is
//! an LLM judgement made host-side by a *different provider* on purpose
//! ([[evaluator-verification]]), and re-implementing it inside the VM would put
//! the agent's own environment in charge of grading the agent, which is the
//! correlated failure the independent judge exists to break.
//!
//! # The cap is load-bearing
//!
//! A gate with no ceiling turns a stuck agent into a wedged one: it would be
//! blocked, retry, be blocked again, and burn the hour-long turn budget instead
//! of failing in a way the operator can see. After [`MAX_BLOCKS`] the gate lets
//! the agent stop, records that it did, and leaves the verdict to the existing
//! post-hoc path — which still runs, unchanged.
//!
//! # Which hooks exist here
//!
//! `TaskCompleted` / `TeammateIdle` were the plan's chosen seam. Measured under
//! `claude -p`: they never fire, because no team forms in print mode at all.
//! `Stop`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit` and
//! `SessionStart` do.
/// How many times the gate may refuse a stop before it gives up and lets the
/// agent finish. Three is enough for "you wrote nothing" → "you wrote something"
/// → "your check passes" without ever approaching the turn budget.
pub const MAX_BLOCKS: u32 = 3;
/// The file the gate writes when it gives up and lets the agent stop with its
/// condition still failing.
///
/// A separate file rather than a line in the log, because the log is not
/// parseable for this: a block reason embeds the check's own output, and an
/// output line beginning `cap:` would read as a cap release that never happened.
///
/// It exists because the block COUNT cannot answer the question. Three blocks
/// followed by a stop that finally passed, and three blocks followed by a
/// release at the cap, both report `blocks: 3` — and they are opposite outcomes.
/// Without this, the second one completed the phase green.
pub const CAPPED_FILE: &str = "capped";
/// Where the gate lives in the guest.
///
/// Under `/root`, never under the repository. Anything written into
/// `/mission/repo` is collected and diffed, so a gate script placed there would
/// arrive in the user's delivered patch as if an agent had authored it.
pub const GATE_DIR: &str = "/root/gate";
/// What must hold before this phase's agent is allowed to stop.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StopGate {
/// The phase must leave the repository changed. Set for coding phases that
/// have not declared `allow_empty` — the same rule
/// `empty_delivery_is_a_failure` applies post-hoc, applied while the agent
/// can still do something about it.
pub require_changes: bool,
/// `config.done_when_check`: a shell command, run in the repo, that must
/// exit 0. The deterministic half of a completion condition — a command,
/// not a judgement.
pub check: Option<String>,
}
impl StopGate {
/// The gate for a phase, or `None` when there is nothing to enforce.
///
/// `None` matters: installing a hook that can never block would still cost a
/// process per stop and would put a `--settings` flag on the command line
/// for no reason.
pub fn for_phase(kind: &str, config: &serde_json::Value) -> Option<StopGate> {
let allow_empty = config.get("allow_empty").and_then(|v| v.as_bool()) == Some(true);
let check = config
.get("done_when_check")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let require_changes = kind == "coding" && !allow_empty;
if !require_changes && check.is_none() {
return None;
}
Some(StopGate {
require_changes,
check,
})
}
/// The same gate, for ONE NODE of a composed run.
///
/// `require_changes` is a property of the phase, not of every node in it: a
/// graph whose second node reviews or verifies is *supposed* to leave the
/// tree alone, and a per-node gate would refuse its stop three times for
/// doing exactly its job. Dropping it loses nothing, because
/// `empty_delivery_is_a_failure` applies the same rule post-hoc to what the
/// phase as a whole delivered.
///
/// That "post-hoc" claim used to be written as covering the `check` too. It
/// did not: nothing outside this hook has ever re-run `done_when_check`, so
/// a release at [`MAX_BLOCKS`] completed the phase green with the check
/// still failing. [`CAPPED_FILE`] is what closes that.
///
/// A declared `check` DOES apply per node: it is a command the phase author
/// wrote, and every stage of the work should satisfy it.
pub fn per_node(self) -> Option<StopGate> {
self.check.map(|check| StopGate {
require_changes: false,
check: Some(check),
})
}
/// The hook script, as POSIX `sh`.
///
/// `repo` and `dir` are parameters rather than the constants above so a test
/// can run this script — the real one, not a paraphrase — against a real git
/// repository in a temp directory.
pub fn script(&self, repo: &str, dir: &str) -> String {
let mut s = String::from("#!/bin/sh\n# ClawMates stop gate. Exit 2 refuses the stop.\n");
s.push_str(&format!("REPO={}\nGATE={}\nMAX={MAX_BLOCKS}\n", q(repo), q(dir)));
s.push_str("N=$(cat \"$GATE/blocks\" 2>/dev/null || echo 0)\nreason=''\n");
if self.require_changes {
// Two questions, because either alone is answerable "no" by a
// perfectly good phase: an agent that committed its work leaves a
// clean tree, and an agent that did not commit leaves HEAD where it
// was. Only both together mean nothing happened.
s.push_str(
"BASE=$(cat \"$REPO/.git/clawmates-base\" 2>/dev/null || echo '')\n\
DIRTY=$(git -C \"$REPO\" status --porcelain 2>/dev/null | head -c 400)\n\
HEAD=$(git -C \"$REPO\" rev-parse HEAD 2>/dev/null || echo '')\n\
if [ -z \"$DIRTY\" ] && [ -n \"$BASE\" ] && [ \"$HEAD\" = \"$BASE\" ]; then\n\
\x20 reason='This phase has changed nothing: the working tree is clean and \
HEAD is still the commit you started from. Do the work the task describes \
and leave it in the tree. If the task genuinely requires no code change, \
say so explicitly in your final message.'\n\
fi\n",
);
}
if let Some(check) = &self.check {
s.push_str(&format!(
"if [ -z \"$reason\" ]; then\n\
\x20 out=$(cd \"$REPO\" && sh -c {} 2>&1); rc=$?\n\
\x20 if [ \"$rc\" -ne 0 ]; then\n\
\x20 reason=\"This phase's completion check exited $rc, so the work is not \
done yet. The check is: {}\n\nIts output:\n$(printf '%s' \"$out\" | tail -c 1500)\"\n\
\x20 fi\n\
fi\n",
q(check),
// Inside a double-quoted assignment, so the command text itself
// must not carry a `\"` or a `$` that the shell would expand.
check.replace('\\', "\\\\").replace('"', "'").replace('$', "\\$"),
));
}
s.push_str(
"if [ -z \"$reason\" ]; then echo pass >> \"$GATE/log\"; exit 0; fi\n\
if [ \"$N\" -ge \"$MAX\" ]; then\n\
\x20 echo \"cap: $reason\" >> \"$GATE/log\"\n\
\x20 echo 1 > \"$GATE/capped\"\n\
\x20 exit 0\n\
fi\n\
N=$((N+1)); echo \"$N\" > \"$GATE/blocks\"\n\
echo \"block $N: $reason\" >> \"$GATE/log\"\n\
printf '%s\\n' \"$reason\" >&2\n\
exit 2\n",
);
s
}
/// One shell command that writes the gate SCRIPT into the guest.
///
/// It deliberately does NOT write `settings.json`. It used to, and it wrote
/// the whole document — so the moment a second feature needed a hook, the
/// later writer would silently erase this one. The composed document is
/// built in exactly one place: [`crate::vm_tool_tap::guest_settings`].
///
/// Written by `printf` through an exec rather than injected as part of the
/// tar: the tar lands in `/mission/repo`, which is exactly where this must
/// not be.
pub fn install_command(&self, repo: &str, dir: &str) -> String {
format!(
"mkdir -p {d} && rm -f {d}/blocks {d}/log {d}/capped \
&& printf '%s' {script} > {d}/stop-gate.sh \
&& chmod +x {d}/stop-gate.sh",
d = dir,
script = q(&self.script(repo, dir)),
)
}
}
/// Single-quote for `sh`. Same rule as `microvm_executor::shell_quote`, kept
/// local so this module has no dependency on the executor it is used by.
fn q(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::Path;
use std::process::Command;
fn sh(script: &str, dir: &Path) -> std::process::Output {
let path = dir.join("stop-gate.sh");
std::fs::write(&path, script).unwrap();
Command::new("sh").arg(&path).output().expect("run the gate")
}
/// A git repo with one commit and the clone-point marker the real checkout
/// carries (`mission_workspace::record_base_commit` writes it).
fn repo_with_base(root: &Path) -> std::path::PathBuf {
let repo = root.join("repo");
std::fs::create_dir_all(&repo).unwrap();
let git = |args: &[&str]| {
let o = Command::new("git")
.arg("-C")
.arg(&repo)
.args(args)
.output()
.unwrap();
assert!(o.status.success(), "git {args:?}: {:?}", o);
};
git(&["init", "--quiet"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "T"]);
std::fs::write(repo.join("README.md"), "base\n").unwrap();
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "base"]);
let head = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
std::fs::write(
repo.join(".git/clawmates-base"),
String::from_utf8_lossy(&head.stdout).trim(),
)
.unwrap();
repo
}
/// The failure this exists for: an agent that stops having written nothing.
/// Post-hoc that costs a whole new VM; here it costs one sentence.
#[test]
fn an_agent_that_changed_nothing_is_not_allowed_to_stop() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo_with_base(tmp.path());
let gate = StopGate {
require_changes: true,
check: None,
};
let script = gate.script(&repo.display().to_string(), &tmp.path().display().to_string());
let out = sh(&script, tmp.path());
assert_eq!(out.status.code(), Some(2), "the stop must be refused");
let why = String::from_utf8_lossy(&out.stderr);
assert!(why.contains("changed nothing"), "{why}");
// Uncommitted work counts — the usual case, since the agent is told to
// leave its work in the tree rather than commit it.
std::fs::write(repo.join("new.rs"), "fn done() {}\n").unwrap();
let out = sh(&script, tmp.path());
assert_eq!(out.status.code(), Some(0), "{:?}", out);
}
/// The gate gives up after [`MAX_BLOCKS`] and lets the agent stop — and it
/// must LEAVE A MARK when it does. Nothing outside this hook ever runs a
/// `done_when_check`, so a silent release completed the phase green with its
/// condition still failing.
///
/// The two files say different things and both are needed: `blocks` reaches
/// 3 in this test AND in a run where the agent got it right on the fourth
/// try, so the count alone cannot tell success from surrender.
#[test]
fn a_gate_that_gives_up_records_that_it_gave_up() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().display().to_string();
let repo = repo_with_base(tmp.path());
let gate = StopGate {
require_changes: false,
check: Some("exit 1".into()),
};
let script = gate.script(&repo.display().to_string(), &dir);
for n in 1..=MAX_BLOCKS {
let out = sh(&script, tmp.path());
assert_eq!(out.status.code(), Some(2), "block {n} must refuse the stop");
assert!(
!tmp.path().join(CAPPED_FILE).exists(),
"the cap mark must not appear while the gate is still blocking"
);
}
// One more stop: the gate is out of blocks and must let the agent go.
let out = sh(&script, tmp.path());
assert_eq!(out.status.code(), Some(0), "at the cap the stop is allowed");
assert_eq!(
std::fs::read_to_string(tmp.path().join(CAPPED_FILE))
.unwrap()
.trim(),
"1",
"the release must be recorded, or nothing downstream can see it"
);
}
/// The negative control for the mark: a gate whose check PASSES releases the
/// agent too, and that release must not be recorded as a surrender. Without
/// this, "always write the file" would pass the test above and fail every
/// healthy phase in production.
#[test]
fn a_gate_that_is_satisfied_leaves_no_cap_mark() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo_with_base(tmp.path());
let gate = StopGate {
require_changes: false,
check: Some("true".into()),
};
let script = gate.script(&repo.display().to_string(), &tmp.path().display().to_string());
let out = sh(&script, tmp.path());
assert_eq!(out.status.code(), Some(0));
assert!(
!tmp.path().join(CAPPED_FILE).exists(),
"a satisfied gate must not look like one that gave up"
);
}
/// And committed work counts too. An agent that committed leaves a CLEAN
/// tree, so a gate that only looked at `git status` would refuse the stop of
/// a phase that had done everything asked of it.
#[test]
fn work_the_agent_committed_satisfies_the_gate() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo_with_base(tmp.path());
std::fs::write(repo.join("new.rs"), "fn done() {}\n").unwrap();
for args in [vec!["add", "."], vec!["commit", "--quiet", "-m", "work"]] {
Command::new("git")
.arg("-C")
.arg(&repo)
.args(&args)
.output()
.unwrap();
}
let gate = StopGate {
require_changes: true,
check: None,
};
let out = sh(
&gate.script(&repo.display().to_string(), &tmp.path().display().to_string()),
tmp.path(),
);
assert_eq!(out.status.code(), Some(0), "{:?}", out);
}
/// The cap. Without it a stuck agent is blocked, retries, is blocked again,
/// and spends the whole hour-long turn budget instead of failing where an
/// operator can see it.
#[test]
fn the_gate_gives_up_after_the_cap_and_says_so() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo_with_base(tmp.path());
let gate = StopGate {
require_changes: true,
check: None,
};
let script = gate.script(&repo.display().to_string(), &tmp.path().display().to_string());
for i in 1..=MAX_BLOCKS {
assert_eq!(
sh(&script, tmp.path()).status.code(),
Some(2),
"block {i} of {MAX_BLOCKS}"
);
}
assert_eq!(
sh(&script, tmp.path()).status.code(),
Some(0),
"past the cap the agent must be allowed to stop"
);
let log = std::fs::read_to_string(tmp.path().join("log")).unwrap();
assert!(log.contains("cap:"), "giving up is recorded: {log}");
assert_eq!(
std::fs::read_to_string(tmp.path().join("blocks"))
.unwrap()
.trim(),
MAX_BLOCKS.to_string(),
"and the count is exact, so the host can report it"
);
}
/// A phase-declared check runs in the repo, and its OUTPUT comes back — a
/// gate that said only "the check failed" would send the agent guessing.
#[test]
fn a_declared_check_must_pass_and_its_output_is_the_feedback() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo_with_base(tmp.path());
let gate = StopGate {
require_changes: false,
check: Some("test -f wanted.txt || { echo 'wanted.txt is missing'; exit 3; }".into()),
};
let script = gate.script(&repo.display().to_string(), &tmp.path().display().to_string());
let out = sh(&script, tmp.path());
assert_eq!(out.status.code(), Some(2));
let why = String::from_utf8_lossy(&out.stderr);
assert!(why.contains("exited 3"), "{why}");
assert!(why.contains("wanted.txt is missing"), "{why}");
std::fs::write(repo.join("wanted.txt"), "here\n").unwrap();
assert_eq!(sh(&script, tmp.path()).status.code(), Some(0));
}
/// A check with quotes, `$` and apostrophes is ordinary. It travels through
/// `sh -c` inside a script that itself travels through `sh -c` to reach the
/// guest, and a quoting bug at either layer would run something else.
#[test]
fn a_check_with_shell_metacharacters_survives_both_layers() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo_with_base(tmp.path());
std::fs::write(repo.join("it's here.txt"), "x\n").unwrap();
let gate = StopGate {
require_changes: false,
check: Some("test -f \"it's here.txt\" && echo $HOME > /dev/null".into()),
};
let out = sh(
&gate.script(&repo.display().to_string(), &tmp.path().display().to_string()),
tmp.path(),
);
assert_eq!(out.status.code(), Some(0), "{:?}", out);
// And the install command it is embedded in is still one shell argument.
let install = gate.install_command("/mission/repo", GATE_DIR);
assert!(install.contains("stop-gate.sh"), "{install}");
}
/// Nothing the gate writes may land under the repository: `/mission/repo` is
/// collected and diffed, so a file there arrives in the user's patch as if
/// an agent had written it.
#[test]
fn the_gate_never_writes_into_the_delivered_tree() {
let gate = StopGate {
require_changes: true,
check: Some("cargo test".into()),
};
assert!(GATE_DIR.starts_with("/root/"), "{GATE_DIR}");
let install = gate.install_command("/mission/repo", GATE_DIR);
for write in ["> /mission/repo", "/mission/repo/stop", "/mission/repo/.claude"] {
assert!(!install.contains(write), "{install}");
}
assert_eq!(
crate::vm_tool_tap::guest_settings(Some(GATE_DIR), None, None)["hooks"]["Stop"][0]["hooks"]
[0]["command"],
json!("/root/gate/stop-gate.sh")
);
}
/// A composed run's nodes must not each be held to "this phase changed
/// something". The graph's verifier node changes nothing BY DESIGN, and a
/// per-node gate would refuse its stop until the cap — three wasted agent
/// turns for doing its job correctly.
#[test]
fn a_composed_node_is_not_held_to_the_whole_phases_delivery() {
let phase = StopGate::for_phase("coding", &json!({})).unwrap();
assert!(phase.require_changes);
assert!(
phase.per_node().is_none(),
"with nothing but the delivery rule, a node has no gate at all"
);
let with_check =
StopGate::for_phase("coding", &json!({ "done_when_check": "cargo test" })).unwrap();
let node = with_check.per_node().expect("the declared check still applies");
assert!(!node.require_changes);
assert_eq!(node.check.as_deref(), Some("cargo test"));
}
/// A gate with nothing to enforce must not be installed at all — a hook that
/// can never block still costs a process per stop and a flag on the command
/// line.
#[test]
fn a_phase_with_nothing_to_enforce_gets_no_gate() {
let none = json!({});
assert!(StopGate::for_phase("research", &none).is_none());
assert!(StopGate::for_phase("coding", &json!({ "allow_empty": true })).is_none());
let coding = StopGate::for_phase("coding", &none).expect("a coding phase must deliver");
assert!(coding.require_changes);
assert!(coding.check.is_none());
// A declared check applies to any kind, including one that is allowed to
// change nothing — a verification phase's whole job is that check.
let verify = StopGate::for_phase(
"research",
&json!({ "allow_empty": true, "done_when_check": " ./verify.sh " }),
)
.expect("a declared check is a gate on its own");
assert!(!verify.require_changes);
assert_eq!(verify.check.as_deref(), Some("./verify.sh"));
}
}