feat(gate): role-scoped policy — the verifier may not write, enforced by us
deploy / test (push) Successful in 5m3s
deploy / build (push) Successful in 5m36s

The gate's rules were global: what no mission may do. This adds the
task-scoped half ActGov (arXiv 2609.24446) argues for — per-action
validation against the authorization boundary of the role making the
call — starting with the one role whose limit is structural: a verifier
that edits the thing it is verifying turns a failed check into a passing
one and reports success.

The enabling fact was measured before anything was built on it: Claude
Code 2.1.278 puts agent_type on a SUBAGENT's PreToolUse payload and
leaves it absent on the lead's (local probe: agent_type: prober,
agent_id: aacf093a). A policy keyed on a field that is not there is a
policy that never fires and looks installed — the failure this codebase
keeps paying for.

ROLE_POLICIES renders into the same guest script as the floor, so the
shell and the Rust predicate cannot disagree (the property
the_script_carries_every_rule already pins for the floor, now pinned for
roles too). Shell tests run the real generated script: the verifier's
Write is refused with rule=role-verifier-readonly and agent_type on the
record, the lead's identical Write is allowed, explorer is untouched, and
the verifier still reads and runs cargo test.

This is deliberately a second enforcer, not a replacement: the CLI's own
--agents tool list is the harness policing itself, and it silently did
nothing until 2.1.243 rejected the string form we were sending (cbc9c2d).
The harness now distinguishes 'never reached for a write' from 'the gate
refused one', which the tap alone could not say.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-22 09:00:07 -05:00
co-authored by Claude Opus 5
parent 716044833b
commit 1a244b7d32
2 changed files with 173 additions and 19 deletions
+161 -17
View File
@@ -235,6 +235,39 @@ const PROTECTED_REASON: &str = "Refusing to touch the tool hooks, their records,
settings that install them. They are the mission's audit trail and are not part \ settings that install them. They are the mission's audit trail and are not part \
of the work; nothing in a task needs them changed."; of the work; nothing in a task needs them changed.";
/// A role's own limits, on top of the floor every role is held to.
///
/// `role` matches the PreToolUse payload's `agent_type`, which Claude Code
/// sets on a SUBAGENT's calls and leaves absent on the lead's own —
/// measured locally on 2.1.278 (`agent_type: prober`, `agent_id: aacf093a`)
/// before this existed, because a policy keyed on a field that is not there
/// is a policy that never fires and looks installed.
///
/// This is the task-scoped half of the gate. The rules above say what no
/// mission may do; these say what a particular ROLE may not do, and they
/// are enforced here rather than by the CLI's own `--agents` tool list:
/// that list is the model harness policing itself, and it silently did
/// nothing at all until 2.1.243 rejected the string form we were sending
/// (cbc9c2d). Two independent enforcers, one of them ours.
struct RolePolicy {
role: &'static str,
/// Tool names, lowercased as the extractor prints them.
deny_tools: &'static [&'static str],
reason: &'static str,
/// Recorded on the denial, like any other rule.
id: &'static str,
}
const ROLE_POLICIES: &[RolePolicy] = &[RolePolicy {
role: "verifier",
deny_tools: WRITE_TOOLS,
id: "role-verifier-readonly",
reason: "Refusing a write from the verifier. Its job is to check work it must \
not change: a verifier that edits the thing it is verifying turns a \
failed check into a passing one and reports success. Report what you \
found and let the lead fix it.",
}];
/// The write tools, lowercased as the extractor prints them. `deny_reason` /// The write tools, lowercased as the extractor prints them. `deny_reason`
/// checks their `file_path` against [`PROTECTED_PATHS`]; every other tool's /// checks their `file_path` against [`PROTECTED_PATHS`]; every other tool's
/// arguments are left alone, because blocking `Read` on a substring would /// arguments are left alone, because blocking `Read` on a substring would
@@ -271,17 +304,33 @@ pub fn denial_detail(line: &str) -> serde_json::Value {
/// The reason a command is denied, or `None` to allow it. /// The reason a command is denied, or `None` to allow it.
pub fn deny_reason(tool: &str, command: &str) -> Option<&'static str> { pub fn deny_reason(tool: &str, command: &str) -> Option<&'static str> {
decide(tool, command, None).map(|d| d.reason) decide(tool, command, None, None).map(|d| d.reason)
} }
/// The decision for one tool call: `None` allows. /// The decision for one tool call: `None` allows.
/// ///
/// `argument` is the Bash command line; `file_path` is what a write tool /// `argument` is the Bash command line; `file_path` is what a write tool
/// was given. Pure so the policy is testable without a VM — the half most /// was given; `role` is the calling subagent's `agent_type`, `None` for the
/// likely to be wrong is the matching, and it is the half that needs no /// lead's own calls. 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. /// guest to exercise.
pub fn decide(tool: &str, argument: &str, file_path: Option<&str>) -> Option<Denial> { pub fn decide(
tool: &str,
argument: &str,
file_path: Option<&str>,
role: Option<&str>,
) -> Option<Denial> {
let tool = tool.to_ascii_lowercase(); let tool = tool.to_ascii_lowercase();
// A role's own limits come first: they are narrower than the floor by
// construction, and the reason names the role rather than the action.
if let Some(role) = role.map(str::trim).filter(|r| !r.is_empty()) {
if let Some(p) = ROLE_POLICIES
.iter()
.find(|p| p.role.eq_ignore_ascii_case(role) && p.deny_tools.contains(&tool.as_str()))
{
return Some(Denial { rule: p.id, reason: p.reason });
}
}
// A write tool is judged on WHERE it writes and nothing else. // A write tool is judged on WHERE it writes and nothing else.
if WRITE_TOOLS.contains(&tool.as_str()) { if WRITE_TOOLS.contains(&tool.as_str()) {
let path = file_path.unwrap_or(""); let path = file_path.unwrap_or("");
@@ -487,6 +536,25 @@ pub fn hook_script(dir: &str) -> String {
} }
} }
// One `case` per role policy, rendered from the same table the Rust
// predicate reads, so the guest and the host cannot disagree about who
// may do what.
let mut role_checks = String::new();
for p in ROLE_POLICIES {
role_checks.push_str(&format!(
"case \"$role\" in\n\
\x20 {role})\n\
\x20 case \"$tool\" in\n\
\x20 {tools})\n{body}\
\x20 esac\n\
\x20 ;;\n\
esac\n",
role = shell_pattern(p.role),
tools = p.deny_tools.join("|"),
body = deny(p.id, p.reason, " "),
));
}
let text_tools = TEXT_TOOLS.join("|"); let text_tools = TEXT_TOOLS.join("|");
format!( format!(
"#!/bin/sh\n\ "#!/bin/sh\n\
@@ -505,6 +573,10 @@ pub fn hook_script(dir: &str) -> String {
tool=$(printf '%s\\n' \"$info\" | sed -n 1p)\n\ tool=$(printf '%s\\n' \"$info\" | sed -n 1p)\n\
cmd=$(printf '%s\\n' \"$info\" | sed -n 2p)\n\ cmd=$(printf '%s\\n' \"$info\" | sed -n 2p)\n\
path=$(printf '%s\\n' \"$info\" | sed -n 3p)\n\ path=$(printf '%s\\n' \"$info\" | sed -n 3p)\n\
role=$(printf '%s\\n' \"$info\" | sed -n 4p)\n\
# A role's own limits. Empty role = the lead's own call, which no\n\
# role policy binds.\n\
{role_checks}\
# A write tool is judged on where it writes: the hooks, their records\n\ # A write tool is judged on where it writes: the hooks, their records\n\
# and the settings that install them are off limits.\n\ # and the settings that install them are off limits.\n\
case \"$tool\" in\n\ case \"$tool\" in\n\
@@ -543,6 +615,7 @@ pub fn hook_script(dir: &str) -> String {
exit 0\n", exit 0\n",
extract = NODE_EXTRACT, extract = NODE_EXTRACT,
inert = INERT_FILE, inert = INERT_FILE,
role_checks = role_checks,
write_tools = WRITE_TOOLS.join("|"), write_tools = WRITE_TOOLS.join("|"),
protected = PROTECTED_PATHS protected = PROTECTED_PATHS
.iter() .iter()
@@ -553,12 +626,13 @@ pub fn hook_script(dir: &str) -> String {
) )
} }
/// Reads the hook event on stdin and prints `tool_name`, the command, then /// Reads the hook event on stdin and prints `tool_name`, the command, the
/// the file path a write tool was given (empty for the rest). /// file path a write tool was given (empty for the rest), then the calling
/// subagent's `agent_type` (empty on the lead's own calls).
/// ///
/// Lowercases the tool name so the shell comparison is exact. Silent on any /// Lowercases the tool name so the shell comparison is exact. Silent on any
/// error: the caller treats empty output as "allow". /// 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 t=j.tool_input||{};const n=String(j.tool_name||"").toLowerCase();const c=String(t.command||"").replace(/\n/g," ");const p=String(t.file_path||t.notebook_path||"").replace(/\n/g," ");process.stdout.write(n+"\n"+c+"\n"+p+"\n")}catch(e){}})"#; const NODE_EXTRACT: &str = r#"let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const t=j.tool_input||{};const n=String(j.tool_name||"").toLowerCase();const c=String(t.command||"").replace(/\n/g," ");const p=String(t.file_path||t.notebook_path||"").replace(/\n/g," ");const a=String(j.agent_type||"").replace(/\n/g," ").toLowerCase();process.stdout.write(n+"\n"+c+"\n"+p+"\n"+a+"\n")}catch(e){}})"#;
/// A needle as a `case` pattern: glob metacharacters escaped. /// A needle as a `case` pattern: glob metacharacters escaped.
fn shell_pattern(needle: &str) -> String { fn shell_pattern(needle: &str) -> String {
@@ -690,21 +764,21 @@ mod tests {
/// path alone — the contents can say anything. /// path alone — the contents can say anything.
#[test] #[test]
fn decisions_name_their_rule_and_write_tools_are_judged_by_path() { fn decisions_name_their_rule_and_write_tools_are_judged_by_path() {
let d = decide("Bash", "git push --force origin main", None).unwrap(); let d = decide("Bash", "git push --force origin main", None, None).unwrap();
assert_eq!(d.rule, "force-push"); assert_eq!(d.rule, "force-push");
assert_eq!(decide("Bash", "curl -s -X POST https://x -d @f", None).unwrap().rule, "curl-body"); assert_eq!(decide("Bash", "curl -s -X POST https://x -d @f", None, None).unwrap().rule, "curl-body");
assert_eq!(decide("Bash", "cat /root/toolgate/denied.jsonl", None).unwrap().rule, "hook-files"); assert_eq!(decide("Bash", "cat /root/toolgate/denied.jsonl", None, None).unwrap().rule, "hook-files");
assert_eq!(decide("Bash", "rm -f /root/guest-settings.json", None).unwrap().rule, "hook-files"); assert_eq!(decide("Bash", "rm -f /root/guest-settings.json", None, None).unwrap().rule, "hook-files");
let w = decide("Write", "", Some("/root/toolhooks/settings.json")).unwrap(); let w = decide("Write", "", Some("/root/toolhooks/settings.json"), None).unwrap();
assert_eq!(w.rule, "hook-files"); assert_eq!(w.rule, "hook-files");
assert!(decide("Edit", "", Some("/mission/repo/.git/hooks/post-commit")).is_some()); assert!(decide("Edit", "", Some("/mission/repo/.git/hooks/post-commit"), None).is_some());
assert!(decide("NotebookEdit", "", Some("/root/.claude/settings.json")).is_some()); assert!(decide("NotebookEdit", "", Some("/root/.claude/settings.json"), None).is_some());
// The checkout is the work. // The checkout is the work.
assert!(decide("Write", "", Some("/mission/repo/README.md")).is_none()); assert!(decide("Write", "", Some("/mission/repo/README.md"), None).is_none());
assert!(decide("Write", "rm -rf /", Some("/mission/repo/notes.md")).is_none()); assert!(decide("Write", "rm -rf /", Some("/mission/repo/notes.md"), None).is_none());
// A protected path in a Read is a read. // A protected path in a Read is a read.
assert!(decide("Read", "", Some("/root/toolgate/denied.jsonl")).is_none()); assert!(decide("Read", "", Some("/root/toolgate/denied.jsonl"), None).is_none());
// The rule ids are unique — a duplicate would make the count lie. // The rule ids are unique — a duplicate would make the count lie.
let mut ids: Vec<&str> = RULES.iter().map(|r| r.id).collect(); let mut ids: Vec<&str> = RULES.iter().map(|r| r.id).collect();
ids.sort_unstable(); ids.sort_unstable();
@@ -712,6 +786,42 @@ mod tests {
assert_eq!(ids.len(), RULES.len()); assert_eq!(ids.len(), RULES.len());
} }
/// A role policy binds that role's subagent and nobody else.
#[test]
fn the_verifier_may_not_write_and_the_lead_is_untouched() {
let d = decide("Write", "", Some("/mission/repo/src/lib.rs"), Some("verifier")).unwrap();
assert_eq!(d.rule, "role-verifier-readonly");
assert!(d.reason.contains("passing one"), "{}", d.reason);
assert!(decide("Edit", "", Some("/mission/repo/x.rs"), Some("VERIFIER")).is_some());
// The same write from the lead, or from another role, is ordinary work.
assert!(decide("Write", "", Some("/mission/repo/src/lib.rs"), None).is_none());
assert!(decide("Write", "", Some("/mission/repo/src/lib.rs"), Some("")).is_none());
assert!(decide("Write", "", Some("/mission/repo/src/lib.rs"), Some("explorer")).is_none());
// Reading is the verifier's whole job.
assert!(decide("Read", "", Some("/mission/repo/src/lib.rs"), Some("verifier")).is_none());
assert!(decide("Bash", "cargo test", None, Some("verifier")).is_none());
// The floor still applies to a role the policy does not name.
assert_eq!(
decide("Bash", "git push --force origin main", None, Some("explorer")).unwrap().rule,
"force-push"
);
}
/// Two implementations of one policy: every role rule must reach the
/// guest script, the same property `the_script_carries_every_rule` pins
/// for the floor.
#[test]
fn the_script_carries_every_role_policy() {
let script = hook_script(GUEST_DIR);
for p in ROLE_POLICIES {
assert!(script.contains(p.role), "role {:?} missing from the guest script", p.role);
assert!(script.contains(p.id), "rule id {:?} missing from the guest script", p.id);
for t in p.deny_tools {
assert!(script.contains(t), "{t} missing from {}'s guest check", p.role);
}
}
}
#[test] #[test]
fn a_denial_line_reads_back_as_the_event_with_its_rule() { fn a_denial_line_reads_back_as_the_event_with_its_rule() {
let d = denial_detail(r#"{"rule":"curl-body","payload":{"tool_name":"Bash","tool_input":{"command":"curl -d x"}}}"#); let d = denial_detail(r#"{"rule":"curl-body","payload":{"tool_name":"Bash","tool_input":{"command":"curl -d x"}}}"#);
@@ -838,6 +948,40 @@ mod shell_tests {
assert_eq!(v["payload"]["tool_input"]["command"], "git push --force origin main"); assert_eq!(v["payload"]["tool_input"]["command"], "git push --force origin main");
} }
/// The role policy, in the shell that actually runs in the guest: the
/// verifier's write is refused with its rule recorded, the lead's
/// identical write is allowed, and the verifier's reads and tests are
/// untouched. This is the enforcement the CLI's own `--agents` list is
/// supposed to provide and silently did not until 2.1.243.
#[test]
fn the_shell_refuses_a_write_from_the_verifier_and_allows_the_leads() {
let v = r#"{"tool_name":"Write","agent_type":"verifier","agent_id":"a1","tool_input":{"file_path":"/mission/repo/src/lib.rs","content":"fn x(){}"}}"#;
let (code, stderr) = run(v);
assert_eq!(code, 2, "the verifier must not write: {stderr}");
assert!(stderr.contains("verifier"), "{stderr}");
let rec: serde_json::Value = serde_json::from_str(last_denied().trim()).unwrap();
assert_eq!(rec["rule"], "role-verifier-readonly");
assert_eq!(rec["payload"]["agent_type"], "verifier");
let lead = r#"{"tool_name":"Write","tool_input":{"file_path":"/mission/repo/src/lib.rs","content":"fn x(){}"}}"#;
assert_eq!(run(lead).0, 0, "the lead writes as it always did");
let other = r#"{"tool_name":"Edit","agent_type":"explorer","tool_input":{"file_path":"/mission/repo/a.rs","old_string":"a","new_string":"b"}}"#;
assert_eq!(run(other).0, 0, "no policy binds explorer");
for ok in [
r#"{"tool_name":"Read","agent_type":"verifier","tool_input":{"file_path":"/mission/repo/src/lib.rs"}}"#,
r#"{"tool_name":"Bash","agent_type":"verifier","tool_input":{"command":"cargo test --all"}}"#,
] {
let (code, stderr) = run(ok);
assert_eq!(code, 0, "the verifier must still read and test: {stderr}");
}
// The floor is unchanged for a role the policy does not name.
let forced = r#"{"tool_name":"Bash","agent_type":"explorer","tool_input":{"command":"git push --force origin main"}}"#;
assert_eq!(run(forced).0, 2);
}
/// A write tool aimed at the gate's own records is refused, in the shell, /// A write tool aimed at the gate's own records is refused, in the shell,
/// with the rule recorded; the same tool aimed at the checkout is not. /// with the rule recorded; the same tool aimed at the checkout is not.
#[test] #[test]
+12 -2
View File
@@ -892,6 +892,13 @@ assert_verifier_read_only() { # <mission> <label>
where mission_id='$1' and kind='tool.call' and detail->>'subagent'='verifier';\"" \ where mission_id='$1' and kind='tool.call' and detail->>'subagent'='verifier';\"" \
| head -1 | tr -d '\r') | head -1 | tr -d '\r')
total=${counts%% *}; writes=${counts##* } total=${counts%% *}; writes=${counts##* }
# Whether the GATE refused a verifier write, as opposed to the verifier
# never attempting one. Both are fine outcomes; they are different facts,
# and the tap records only the second.
local denied
denied=$(ssh "$HOST" "docker exec clawmates_postgres_1 psql -U postgres -d clawmates -tAc \
\"select count(*) from mission_events where mission_id='$1' and kind='gate.denied' \
and detail->>'rule'='role-verifier-readonly';\"" | head -1 | tr -d '[:space:]')
case "$total" in case "$total" in
''|0) ''|0)
# Say which subagent types WERE seen, so a spelling mismatch (a custom # Say which subagent types WERE seen, so a spelling mismatch (a custom
@@ -902,9 +909,12 @@ assert_verifier_read_only() { # <mission> <label>
fail "$2-verifier: no tool call attributed to agent_type=verifier (seen: ${seen:-none}) — it never ran, or reports under another name; read-only UNPROVEN" ;; fail "$2-verifier: no tool call attributed to agent_type=verifier (seen: ${seen:-none}) — it never ran, or reports under another name; read-only UNPROVEN" ;;
*) *)
if [ "$writes" = "0" ]; then if [ "$writes" = "0" ]; then
pass "$2-verifier: $total call(s) by the verifier, none a write — the tools allowlist is applied" case "${denied:-0}" in
0) pass "$2-verifier: $total call(s) by the verifier, none a write and none refused — it never reached for one" ;;
*) pass "$2-verifier: $total call(s) by the verifier, none a write; the gate refused $denied attempt(s) (role-verifier-readonly)" ;;
esac
else else
fail "$2-verifier: the verifier WROTE $writes time(s) out of $totalits tools allowlist is not applied" fail "$2-verifier: the verifier WROTE $writes time(s) out of $totalneither the tools allowlist nor the role policy stopped it"
fi ;; fi ;;
esac esac
} }