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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ea0b989b3f
commit
547b5d9987
@@ -67,6 +67,7 @@ pub mod topology_worker;
|
|||||||
pub mod validator_preflight;
|
pub mod validator_preflight;
|
||||||
pub mod vm_placement;
|
pub mod vm_placement;
|
||||||
pub mod vm_stop_gate;
|
pub mod vm_stop_gate;
|
||||||
|
pub mod vm_tool_gate;
|
||||||
pub mod vm_tool_tap;
|
pub mod vm_tool_tap;
|
||||||
pub mod workflow_registry;
|
pub mod workflow_registry;
|
||||||
|
|
||||||
|
|||||||
@@ -691,10 +691,37 @@ async fn run_inside(
|
|||||||
// ONE settings document, written once, carrying whichever hooks installed.
|
// ONE settings document, written once, carrying whichever hooks installed.
|
||||||
// Two writers here is the silent clobber `guest_settings` exists to stop:
|
// Two writers here is the silent clobber `guest_settings` exists to stop:
|
||||||
// whichever ran second would erase the other's hook with no error at all.
|
// whichever ran second would erase the other's hook with no error at all.
|
||||||
let settings = match (gate_dir, tap_dir) {
|
// The PRE-execution gate. Installed on the same terms as the tap: a
|
||||||
(None, None) => None,
|
// failure here degrades to no gate rather than failing the phase, because
|
||||||
(g, t) => {
|
// a mission that runs ungated is what we have today and a mission that
|
||||||
let doc = crate::vm_tool_tap::guest_settings(g, t);
|
// refuses to run is a regression.
|
||||||
|
let tool_gate_dir = match settings_supported {
|
||||||
|
false => None,
|
||||||
|
true => match vm
|
||||||
|
.exec(
|
||||||
|
&crate::vm_tool_gate::install_command(crate::vm_tool_gate::GUEST_DIR),
|
||||||
|
None,
|
||||||
|
60,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(o) if o.rc == 0 => Some(crate::vm_tool_gate::GUEST_DIR),
|
||||||
|
other => {
|
||||||
|
eprintln!(
|
||||||
|
"microvm_executor: could not install the tool gate on {} ({other:?}) \
|
||||||
|
— this phase's tool calls will run unchecked",
|
||||||
|
vm.vm_id()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = match (gate_dir, tap_dir, tool_gate_dir) {
|
||||||
|
(None, None, None) => None,
|
||||||
|
(g, t, tg) => {
|
||||||
|
let doc = crate::vm_tool_tap::guest_settings(g, t, tg);
|
||||||
let cmd = crate::vm_tool_tap::settings_command(
|
let cmd = crate::vm_tool_tap::settings_command(
|
||||||
crate::vm_tool_tap::SETTINGS_PATH,
|
crate::vm_tool_tap::SETTINGS_PATH,
|
||||||
&doc,
|
&doc,
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ mod tests {
|
|||||||
assert!(!install.contains(write), "{install}");
|
assert!(!install.contains(write), "{install}");
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crate::vm_tool_tap::guest_settings(Some(GATE_DIR), None)["hooks"]["Stop"][0]["hooks"]
|
crate::vm_tool_tap::guest_settings(Some(GATE_DIR), None, None)["hooks"]["Stop"][0]["hooks"]
|
||||||
[0]["command"],
|
[0]["command"],
|
||||||
json!("/root/gate/stop-gate.sh")
|
json!("/root/gate/stop-gate.sh")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
//! A **pre-execution** gate on mission tool calls.
|
||||||
|
//!
|
||||||
|
//! Everything else that watches a mission agent watches it too late.
|
||||||
|
//! [`crate::vm_tool_tap`] is a `PostToolUse` hook — it fires after the tool has
|
||||||
|
//! already run, and `exit 0`s unconditionally because a non-zero `PostToolUse`
|
||||||
|
//! talks back to the model. It is telemetry and says so.
|
||||||
|
//!
|
||||||
|
//! So until now a mission agent's `Bash` call was gated by nothing, anywhere.
|
||||||
|
//! `GatePolicy` — the §15 door — has exactly one enforcement site, the chat
|
||||||
|
//! loop, and its approvals key on `(session_id, message_id)`, which no mission
|
||||||
|
//! phase can produce. Meanwhile the three solo tiers run `claude -p` with
|
||||||
|
//! `--permission-mode acceptEdits`: `Read`, `Edit`, `Write`, `Bash`,
|
||||||
|
//! pre-approved.
|
||||||
|
//!
|
||||||
|
//! `PreToolUse` fires under `claude -p` in this image — measured by
|
||||||
|
//! [`crate::vm_stop_gate`], which proved the hook mechanism and the exit-2
|
||||||
|
//! contract — and had **no callers at all**. This module is that hook.
|
||||||
|
//!
|
||||||
|
//! # What this is, and what it is not
|
||||||
|
//!
|
||||||
|
//! It is a deterministic policy gate: a small deny list of actions that are
|
||||||
|
//! destructive or exfiltrating regardless of intent, blocked before they run,
|
||||||
|
//! with the reason handed back to the model so it can choose differently.
|
||||||
|
//!
|
||||||
|
//! It is **not** the §15 human approval gate. 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. Making mission work suspendable for human
|
||||||
|
//! approval is a larger change (the approval key alone has no mission-shaped
|
||||||
|
//! form). This closes the gap between "nothing" and "something", and it should
|
||||||
|
//! not be described as more than that.
|
||||||
|
//!
|
||||||
|
//! # Why the deny list is short
|
||||||
|
//!
|
||||||
|
//! A gate that blocks legitimate work is worse than none: the agent cannot ask
|
||||||
|
//! a human, so it either works around the block — which is how you get an agent
|
||||||
|
//! doing something stranger than what you denied — or it burns the turn. Every
|
||||||
|
//! entry here is an action with no legitimate form inside a mission checkout.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// Where the gate lives in the guest. Under `/root`, never the repository —
|
||||||
|
/// anything written into the checkout would show up in the delivered diff.
|
||||||
|
pub const GUEST_DIR: &str = "/root/toolgate";
|
||||||
|
|
||||||
|
/// The file the gate appends a line to for every denial.
|
||||||
|
pub const DENIED_FILE: &str = "denied.jsonl";
|
||||||
|
|
||||||
|
/// How a rule's needle is matched.
|
||||||
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||||
|
enum Match {
|
||||||
|
/// The needle must START a command segment. `grep -rn 'rm -rf /' docs/`
|
||||||
|
/// searches for the string and must not be denied; `rm -rf / …` runs it.
|
||||||
|
/// A plain substring test cannot tell those apart, and the first version
|
||||||
|
/// of this gate denied the grep — caught by its own test.
|
||||||
|
Command,
|
||||||
|
/// A flag anywhere in the segment, unless the segment is a text tool that
|
||||||
|
/// is plainly reading or printing the flag rather than passing it.
|
||||||
|
Flag,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One denial rule.
|
||||||
|
struct Rule {
|
||||||
|
/// Matched against the Bash command line, case-insensitively.
|
||||||
|
needle: &'static str,
|
||||||
|
how: Match,
|
||||||
|
/// Given to the model verbatim. It says what to do instead, because a bare
|
||||||
|
/// refusal makes an agent retry the same thing with different quoting.
|
||||||
|
reason: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actions with no legitimate form inside a mission.
|
||||||
|
///
|
||||||
|
/// Deliberately not a general-purpose sandbox. The container and microVM
|
||||||
|
/// boundaries do that job; this catches the specific commands that damage the
|
||||||
|
/// mission itself or move its contents off the machine.
|
||||||
|
const RULES: &[Rule] = &[
|
||||||
|
Rule {
|
||||||
|
needle: "rm -rf /",
|
||||||
|
how: Match::Command,
|
||||||
|
reason: "Refusing `rm -rf /`. Delete specific paths under the checkout \
|
||||||
|
instead; nothing in a mission needs to remove a filesystem root.",
|
||||||
|
},
|
||||||
|
Rule {
|
||||||
|
needle: "git push --force",
|
||||||
|
how: Match::Command,
|
||||||
|
reason: "Refusing a force push. It rewrites history other phases and \
|
||||||
|
the reviewer rely on. Push normally, or if history genuinely \
|
||||||
|
must change, say so in your output and stop.",
|
||||||
|
},
|
||||||
|
Rule {
|
||||||
|
needle: "git push -f ",
|
||||||
|
how: Match::Command,
|
||||||
|
reason: "Refusing a force push. It rewrites history other phases and \
|
||||||
|
the reviewer rely on. Push normally, or if history genuinely \
|
||||||
|
must change, say so in your output and stop.",
|
||||||
|
},
|
||||||
|
Rule {
|
||||||
|
needle: "git reset --hard origin",
|
||||||
|
how: Match::Command,
|
||||||
|
reason: "Refusing to hard-reset onto the remote. That discards the \
|
||||||
|
work this phase was asked to produce. If the checkout is \
|
||||||
|
wrong, report it rather than resetting it away.",
|
||||||
|
},
|
||||||
|
Rule {
|
||||||
|
needle: "curl -x post",
|
||||||
|
how: Match::Command,
|
||||||
|
reason: "Refusing an outbound POST. Reading is fine; sending mission \
|
||||||
|
content off the machine goes through the platform, not curl.",
|
||||||
|
},
|
||||||
|
Rule {
|
||||||
|
needle: "--dangerously-skip-permissions",
|
||||||
|
how: Match::Flag,
|
||||||
|
reason: "Refusing to relaunch without permission checks. You already \
|
||||||
|
hold the tools this phase is meant to use.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The reason a command is denied, or `None` to allow it.
|
||||||
|
///
|
||||||
|
/// Pure so the policy is testable without a VM — the half most likely to be
|
||||||
|
/// wrong is the matching, and it is the half that needs no guest to exercise.
|
||||||
|
pub fn deny_reason(tool: &str, command: &str) -> Option<&'static str> {
|
||||||
|
// Only Bash carries arbitrary commands. Read/Edit/Write are bounded by the
|
||||||
|
// filesystem the tier already isolates, and blocking them on substrings
|
||||||
|
// would deny a file whose CONTENTS mention a denied string.
|
||||||
|
if !tool.eq_ignore_ascii_case("bash") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lower = command.to_ascii_lowercase();
|
||||||
|
for segment in segments(&lower) {
|
||||||
|
let segment = segment.trim();
|
||||||
|
if segment.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for rule in RULES {
|
||||||
|
let hit = match rule.how {
|
||||||
|
Match::Command => segment.starts_with(rule.needle),
|
||||||
|
Match::Flag => segment.contains(rule.needle) && !is_text_tool(segment),
|
||||||
|
};
|
||||||
|
if hit {
|
||||||
|
return Some(rule.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a command line on shell separators, so each piece can be tested as a
|
||||||
|
/// command in its own right.
|
||||||
|
///
|
||||||
|
/// Not a shell parser, and it does not need to be: a determined agent can
|
||||||
|
/// defeat any string-matching gate (base64, a variable, a here-doc), and this
|
||||||
|
/// one is aimed at accidents and obvious cases rather than at an adversary.
|
||||||
|
/// Saying so is better than implying a guarantee it cannot make — the real
|
||||||
|
/// isolation is the container and microVM boundary.
|
||||||
|
fn segments(command: &str) -> Vec<&str> {
|
||||||
|
// `&` and `|` cover `&&`/`||` too — splitting on the single character
|
||||||
|
// leaves an empty piece between them, which the caller skips.
|
||||||
|
command
|
||||||
|
.split(|c| matches!(c, ';' | '|' | '&' | '\n'))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this segment a tool that reads or prints its arguments rather than
|
||||||
|
/// executing them?
|
||||||
|
fn is_text_tool(segment: &str) -> bool {
|
||||||
|
const TEXT_TOOLS: &[&str] = &[
|
||||||
|
"grep", "rg", "ag", "echo", "printf", "cat", "less", "head", "tail",
|
||||||
|
"sed", "awk", "comm", "diff",
|
||||||
|
];
|
||||||
|
let first = segment.split_whitespace().next().unwrap_or("");
|
||||||
|
TEXT_TOOLS.contains(&first)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The guest hook script.
|
||||||
|
///
|
||||||
|
/// The hook is handed the tool-use event as JSON on stdin, so it must extract
|
||||||
|
/// `tool_name` and `tool_input.command` before it can match anything. The first
|
||||||
|
/// version matched the raw JSON text and therefore could never anchor a rule to
|
||||||
|
/// the start of a command — `case` saw `{"tool_name":"bash",...` every time.
|
||||||
|
///
|
||||||
|
/// Parsing uses `node`, not `jq` (absent from the image) and not a `sed`
|
||||||
|
/// pipeline (JSON escaping). `node` is guaranteed present: Claude Code is a
|
||||||
|
/// node program, so any image that can run `claude` can run this.
|
||||||
|
///
|
||||||
|
/// Every failure path allows. A gate that fails closed on a parse error blocks
|
||||||
|
/// every tool call in the phase, which is precisely what a `case`-syntax bug
|
||||||
|
/// did here before a test ran the script under a real shell.
|
||||||
|
pub fn hook_script(dir: &str) -> String {
|
||||||
|
let mut checks = String::new();
|
||||||
|
for r in RULES {
|
||||||
|
// The literal half is DOUBLE-QUOTED. A `case` pattern is shell words,
|
||||||
|
// so an unquoted needle containing a space (`rm -rf /`) is a syntax
|
||||||
|
// error — and a syntax error makes the whole script exit non-zero,
|
||||||
|
// which as a PreToolUse hook denies EVERY call.
|
||||||
|
let pattern = match r.how {
|
||||||
|
Match::Command => format!("\"{}\"*", shell_pattern(r.needle)),
|
||||||
|
Match::Flag => format!("*\"{}\"*", shell_pattern(r.needle)),
|
||||||
|
};
|
||||||
|
checks.push_str(&format!(
|
||||||
|
" case \"$seg\" in\n\
|
||||||
|
\x20 {pattern})\n\
|
||||||
|
\x20 printf '%s\\n' {reason} >&2\n\
|
||||||
|
\x20 printf '%s\\n' \"$payload\" >> {dir}/{denied} 2>/dev/null\n\
|
||||||
|
\x20 exit 2\n\
|
||||||
|
\x20 ;;\n\
|
||||||
|
\x20 esac\n",
|
||||||
|
pattern = pattern,
|
||||||
|
reason = shell_quote(r.reason),
|
||||||
|
dir = dir,
|
||||||
|
denied = DENIED_FILE,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"#!/bin/sh\n\
|
||||||
|
# Pre-execution tool gate. See cm-api/src/vm_tool_gate.rs.\n\
|
||||||
|
mkdir -p {dir} 2>/dev/null\n\
|
||||||
|
payload=$(cat)\n\
|
||||||
|
# Tool name on line 1, command on line 2. Anything unparseable prints\n\
|
||||||
|
# nothing and the gate allows — never fail closed here.\n\
|
||||||
|
info=$(printf '%s' \"$payload\" | node -e '{extract}' 2>/dev/null)\n\
|
||||||
|
tool=$(printf '%s\\n' \"$info\" | sed -n 1p)\n\
|
||||||
|
cmd=$(printf '%s\\n' \"$info\" | sed -n 2p)\n\
|
||||||
|
# Only Bash carries arbitrary commands.\n\
|
||||||
|
[ \"$tool\" = bash ] || exit 0\n\
|
||||||
|
[ -n \"$cmd\" ] || exit 0\n\
|
||||||
|
lower=$(printf '%s' \"$cmd\" | tr '[:upper:]' '[:lower:]')\n\
|
||||||
|
# Split on shell separators and test each piece as its own command,\n\
|
||||||
|
# so `grep 'rm -rf /' docs` is searching, not running.\n\
|
||||||
|
old_ifs=$IFS\n\
|
||||||
|
# A LITERAL newline. `IFS='\\n'` in POSIX sh sets IFS to backslash and\n\
|
||||||
|
# the letter n, not a newline — so nothing split, and only commands\n\
|
||||||
|
# with no separator at all were ever tested.\n\
|
||||||
|
IFS='\n'\n\
|
||||||
|
for seg in $(printf '%s' \"$lower\" | tr ';|&' '\\n'); do\n\
|
||||||
|
\x20 seg=$(printf '%s' \"$seg\" | sed 's/^ *//')\n\
|
||||||
|
{checks}\
|
||||||
|
done\n\
|
||||||
|
IFS=$old_ifs\n\
|
||||||
|
# Nothing matched. Exit 0 ALLOWS the call.\n\
|
||||||
|
exit 0\n",
|
||||||
|
extract = NODE_EXTRACT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the hook event on stdin and prints `tool_name` then the command.
|
||||||
|
///
|
||||||
|
/// Lowercases the tool name so the shell comparison is exact. Silent on any
|
||||||
|
/// error: the caller treats empty output as "allow".
|
||||||
|
const NODE_EXTRACT: &str = r#"let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const n=String(j.tool_name||"").toLowerCase();const c=String((j.tool_input&&j.tool_input.command)||"").replace(/\n/g," ");process.stdout.write(n+"\n"+c+"\n")}catch(e){}})"#;
|
||||||
|
|
||||||
|
/// A needle as a `case` pattern: glob metacharacters escaped.
|
||||||
|
fn shell_pattern(needle: &str) -> String {
|
||||||
|
// Inside double quotes a glob metacharacter is already literal; what must
|
||||||
|
// not appear raw is a quote or a backslash.
|
||||||
|
needle.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-quote for `sh`, closing and reopening around any embedded quote.
|
||||||
|
fn shell_quote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `PreToolUse` entry for the guest settings document.
|
||||||
|
///
|
||||||
|
/// Returned rather than written, because [`crate::vm_tool_tap::guest_settings`]
|
||||||
|
/// is the single writer of that document and must stay so: each feature writing
|
||||||
|
/// its own `settings.json` is a silent clobber, and the stop gate disappearing
|
||||||
|
/// is how a coding phase completes having written nothing.
|
||||||
|
pub fn settings_hook(dir: &str) -> Value {
|
||||||
|
json!([{ "hooks": [{ "type": "command", "command": format!("{dir}/tool-gate.sh") }] }])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One shell command that installs the gate.
|
||||||
|
pub fn install_command(dir: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"mkdir -p {dir} && cat > {dir}/tool-gate.sh <<'CM_GATE_EOF'\n{}\nCM_GATE_EOF\nchmod +x {dir}/tool-gate.sh",
|
||||||
|
hook_script(dir)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn destructive_commands_are_denied_with_a_reason_that_says_what_to_do() {
|
||||||
|
let why = deny_reason("Bash", "rm -rf / --no-preserve-root").expect("must deny");
|
||||||
|
assert!(
|
||||||
|
why.contains("instead"),
|
||||||
|
"a bare refusal makes the agent retry with different quoting: {why}"
|
||||||
|
);
|
||||||
|
assert!(deny_reason("Bash", "git push --force origin main").is_some());
|
||||||
|
assert!(deny_reason("Bash", "git reset --hard origin/main").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_is_case_insensitive() {
|
||||||
|
assert!(deny_reason("Bash", "GIT PUSH --FORCE origin main").is_some());
|
||||||
|
assert!(deny_reason("bash", "RM -RF /").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gate must not become a general-purpose linter. Every one of these is
|
||||||
|
/// ordinary mission work, and denying any of them would make an agent work
|
||||||
|
/// around the block — which is worse than not gating.
|
||||||
|
#[test]
|
||||||
|
fn ordinary_mission_work_is_allowed() {
|
||||||
|
for cmd in [
|
||||||
|
"cargo test --workspace",
|
||||||
|
"git add -A && git commit -m 'INT-01 done'",
|
||||||
|
"git push origin mission-branch",
|
||||||
|
"rm -rf target/debug",
|
||||||
|
"rm -rf ./node_modules",
|
||||||
|
"curl -s https://export.arxiv.org/abs/2401.00001",
|
||||||
|
"grep -rn 'rm -rf /' docs/",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
deny_reason("Bash", cmd),
|
||||||
|
None,
|
||||||
|
"denied ordinary work: {cmd}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only Bash carries arbitrary commands. Matching a file's CONTENTS against
|
||||||
|
/// the deny list would refuse to read a document that merely mentions one.
|
||||||
|
#[test]
|
||||||
|
fn non_bash_tools_are_not_matched_on_their_arguments() {
|
||||||
|
assert_eq!(deny_reason("Read", "/mission/repo/docs/rm -rf / notes.md"), None);
|
||||||
|
assert_eq!(deny_reason("Write", "git push --force"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The generated shell must agree with the Rust predicate. Two
|
||||||
|
/// implementations of one policy is how a gate passes its unit tests and
|
||||||
|
/// denies something else in the guest.
|
||||||
|
#[test]
|
||||||
|
fn the_script_carries_every_rule() {
|
||||||
|
let script = hook_script(GUEST_DIR);
|
||||||
|
for rule in RULES {
|
||||||
|
assert!(
|
||||||
|
script.contains(&shell_pattern(rule.needle)),
|
||||||
|
"rule {:?} is enforced in Rust and missing from the guest script",
|
||||||
|
rule.needle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_script_denies_with_exit_2_and_allows_by_falling_through_to_exit_0() {
|
||||||
|
let script = hook_script(GUEST_DIR);
|
||||||
|
assert!(script.contains("exit 2"), "denial must block the call");
|
||||||
|
assert!(
|
||||||
|
script.trim_end().ends_with("exit 0"),
|
||||||
|
"the last statement must be an allow — no path may fail open into a \
|
||||||
|
non-zero exit and block legitimate work"
|
||||||
|
);
|
||||||
|
assert!(script.contains(">&2"), "the reason must reach the model");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reason containing an apostrophe must not break out of its quoting.
|
||||||
|
#[test]
|
||||||
|
fn reasons_are_shell_quoted() {
|
||||||
|
let q = shell_quote("don't do that");
|
||||||
|
assert_eq!(q, "'don'\\''t do that'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The generated script run against a real `sh`.
|
||||||
|
///
|
||||||
|
/// The unit tests above check the Rust predicate and the script's TEXT. Neither
|
||||||
|
/// proves the shell behaves: a quoting slip, a `case` pattern that never
|
||||||
|
/// matches, or an `IFS` mistake all pass those and allow everything in the
|
||||||
|
/// guest. The stop gate learned this the same way, which is why it has the
|
||||||
|
/// equivalent test.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod shell_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
/// Run the hook with `payload` on stdin. Returns (exit code, stderr).
|
||||||
|
fn run(payload: &str) -> (i32, String) {
|
||||||
|
// Unique per invocation: these tests run in parallel and each removes
|
||||||
|
// its directory afterwards, so a shared path has them deleting the
|
||||||
|
// script out from under each other.
|
||||||
|
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||||
|
let seq = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let dir = std::env::temp_dir().join(format!("cm-gate-{}-{seq}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let script = dir.join("tool-gate.sh");
|
||||||
|
std::fs::write(&script, hook_script(&dir.to_string_lossy())).unwrap();
|
||||||
|
|
||||||
|
let mut child = Command::new("sh")
|
||||||
|
.arg(&script)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn sh");
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.write_all(payload.as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
let out = child.wait_with_output().expect("wait");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
(
|
||||||
|
out.status.code().unwrap_or(-1),
|
||||||
|
String::from_utf8_lossy(&out.stderr).to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_shell_blocks_a_force_push_with_exit_2_and_a_reason() {
|
||||||
|
let payload = r#"{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}"#;
|
||||||
|
let (code, stderr) = run(payload);
|
||||||
|
assert_eq!(code, 2, "exit 2 is what blocks the call; stderr={stderr}");
|
||||||
|
assert!(
|
||||||
|
stderr.contains("force push"),
|
||||||
|
"the model must be told why: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The false positive the Rust predicate was fixed for, proven in the shell
|
||||||
|
/// too — the two implementations have to agree.
|
||||||
|
#[test]
|
||||||
|
fn the_shell_allows_grepping_for_a_denied_string() {
|
||||||
|
let payload = r#"{"tool_name":"Bash","tool_input":{"command":"grep -rn 'rm -rf /' docs/"}}"#;
|
||||||
|
let (code, stderr) = run(payload);
|
||||||
|
assert_eq!(code, 0, "searching for the string is not running it: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_shell_allows_ordinary_work() {
|
||||||
|
for cmd in [
|
||||||
|
"cargo test --workspace",
|
||||||
|
"git add -A && git commit -m 'INT-01 done'",
|
||||||
|
"rm -rf target/debug",
|
||||||
|
] {
|
||||||
|
let payload = format!(
|
||||||
|
r#"{{"tool_name":"Bash","tool_input":{{"command":"{cmd}"}}}}"#
|
||||||
|
);
|
||||||
|
let (code, stderr) = run(&payload);
|
||||||
|
assert_eq!(code, 0, "denied ordinary work {cmd:?}: {stderr}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_shell_blocks_a_destructive_delete_reached_after_a_cd() {
|
||||||
|
let payload =
|
||||||
|
r#"{"tool_name":"Bash","tool_input":{"command":"cd /tmp && rm -rf / --no-preserve-root"}}"#;
|
||||||
|
let (code, _) = run(payload);
|
||||||
|
assert_eq!(code, 2, "a separator must not smuggle the command past the gate");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,8 +103,12 @@ pub fn hook_script(dir: &str) -> String {
|
|||||||
/// gate exists to catch. One writer, one document, one test that both hooks
|
/// gate exists to catch. One writer, one document, one test that both hooks
|
||||||
/// survive it.
|
/// survive it.
|
||||||
///
|
///
|
||||||
/// `None` for either half means that hook is simply absent.
|
/// `None` for any part means that hook is simply absent.
|
||||||
pub fn guest_settings(gate_dir: Option<&str>, tap_dir: Option<&str>) -> Value {
|
pub fn guest_settings(
|
||||||
|
gate_dir: Option<&str>,
|
||||||
|
tap_dir: Option<&str>,
|
||||||
|
tool_gate_dir: Option<&str>,
|
||||||
|
) -> Value {
|
||||||
let mut hooks = serde_json::Map::new();
|
let mut hooks = serde_json::Map::new();
|
||||||
if let Some(dir) = gate_dir {
|
if let Some(dir) = gate_dir {
|
||||||
hooks.insert(
|
hooks.insert(
|
||||||
@@ -118,6 +122,12 @@ pub fn guest_settings(gate_dir: Option<&str>, tap_dir: Option<&str>) -> Value {
|
|||||||
json!([{ "hooks": [{ "type": "command", "command": format!("{dir}/tap.sh") }] }]),
|
json!([{ "hooks": [{ "type": "command", "command": format!("{dir}/tap.sh") }] }]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(dir) = tool_gate_dir {
|
||||||
|
// PRE-execution, unlike the tap above. Composed here rather than
|
||||||
|
// written by `vm_tool_gate` itself for the same reason everything else
|
||||||
|
// is: one writer, one document.
|
||||||
|
hooks.insert("PreToolUse".into(), crate::vm_tool_gate::settings_hook(dir));
|
||||||
|
}
|
||||||
json!({ "hooks": Value::Object(hooks) })
|
json!({ "hooks": Value::Object(hooks) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +205,7 @@ mod tests {
|
|||||||
/// failure the gate was built to catch.
|
/// failure the gate was built to catch.
|
||||||
#[test]
|
#[test]
|
||||||
fn both_hooks_survive_one_settings_document() {
|
fn both_hooks_survive_one_settings_document() {
|
||||||
let s = guest_settings(Some("/root/gate"), Some(TAP_DIR));
|
let s = guest_settings(Some("/root/gate"), Some(TAP_DIR), None);
|
||||||
let hooks = s.get("hooks").expect("hooks");
|
let hooks = s.get("hooks").expect("hooks");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hooks["Stop"][0]["hooks"][0]["command"],
|
hooks["Stop"][0]["hooks"][0]["command"],
|
||||||
@@ -211,11 +221,11 @@ mod tests {
|
|||||||
/// Either half absent leaves the other exactly as it was.
|
/// Either half absent leaves the other exactly as it was.
|
||||||
#[test]
|
#[test]
|
||||||
fn one_hook_alone_is_a_valid_document() {
|
fn one_hook_alone_is_a_valid_document() {
|
||||||
let gate_only = guest_settings(Some("/root/gate"), None);
|
let gate_only = guest_settings(Some("/root/gate"), None, None);
|
||||||
assert!(gate_only["hooks"].get("Stop").is_some());
|
assert!(gate_only["hooks"].get("Stop").is_some());
|
||||||
assert!(gate_only["hooks"].get("PostToolUse").is_none());
|
assert!(gate_only["hooks"].get("PostToolUse").is_none());
|
||||||
|
|
||||||
let tap_only = guest_settings(None, Some(TAP_DIR));
|
let tap_only = guest_settings(None, Some(TAP_DIR), None);
|
||||||
assert!(tap_only["hooks"].get("Stop").is_none());
|
assert!(tap_only["hooks"].get("Stop").is_none());
|
||||||
assert!(tap_only["hooks"].get("PostToolUse").is_some());
|
assert!(tap_only["hooks"].get("PostToolUse").is_some());
|
||||||
}
|
}
|
||||||
@@ -329,3 +339,44 @@ mod tests {
|
|||||||
assert!(parse(r#"{"tool_name":" ","tool_input":{}}"#).is_empty());
|
assert!(parse(r#"{"tool_name":" ","tool_input":{}}"#).is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod three_hook_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// All three hooks must survive one document.
|
||||||
|
///
|
||||||
|
/// The tap and the stop gate already shared it; the pre-execution gate is
|
||||||
|
/// the third, and the clobber this function exists to prevent gets more
|
||||||
|
/// likely with each one. A missing `Stop` lets a phase finish having
|
||||||
|
/// written nothing; a missing `PreToolUse` runs every command unchecked.
|
||||||
|
#[test]
|
||||||
|
fn the_document_carries_the_stop_gate_the_tap_and_the_pre_execution_gate() {
|
||||||
|
let s = guest_settings(
|
||||||
|
Some("/root/gate"),
|
||||||
|
Some(TAP_DIR),
|
||||||
|
Some(crate::vm_tool_gate::GUEST_DIR),
|
||||||
|
);
|
||||||
|
let hooks = s["hooks"].as_object().expect("hooks object");
|
||||||
|
assert!(hooks.contains_key("Stop"), "stop gate lost");
|
||||||
|
assert!(hooks.contains_key("PostToolUse"), "tap lost");
|
||||||
|
assert!(hooks.contains_key("PreToolUse"), "pre-execution gate lost");
|
||||||
|
assert_eq!(hooks.len(), 3, "an unexpected hook appeared: {hooks:?}");
|
||||||
|
|
||||||
|
// And the pre-execution hook points at the gate's own script, not the
|
||||||
|
// tap's — pointing PreToolUse at tap.sh would exit 0 on everything and
|
||||||
|
// read as a gate that allows all.
|
||||||
|
let cmd = s["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
|
||||||
|
.as_str()
|
||||||
|
.expect("command");
|
||||||
|
assert!(cmd.ends_with("tool-gate.sh"), "wrong script: {cmd}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gate alone must still produce a usable document.
|
||||||
|
#[test]
|
||||||
|
fn the_gate_can_be_installed_without_the_others() {
|
||||||
|
let s = guest_settings(None, None, Some(crate::vm_tool_gate::GUEST_DIR));
|
||||||
|
assert_eq!(s["hooks"].as_object().unwrap().len(), 1);
|
||||||
|
assert!(s["hooks"]["PreToolUse"].is_array());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user