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
+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';\"" \
| head -1 | tr -d '\r')
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
''|0)
# 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" ;;
*)
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
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 ;;
esac
}