Records why the v2.0 classifier taxed the user 22 prompts and what changed. Root cause: v2.0 conflated "contains a redirect character" with "writes something". `2>&1` is file-descriptor plumbing and `>/dev/null` is a discard — neither writes. That single mistake caused 16 of the 22 prompts. It was found by reading ca_decisions.jsonl, not by inspection: the bug was the classifier's reasoning, so re-reading the code only reproduced it. v2.1 changes (all evidence-driven, none speculative): - _SAFE_REDIRECT_RE strips fd plumbing + /dev/null discards before the redirect check; a redirect to any REAL path still disqualifies, anchored so `>/dev/nullx` cannot ride the prefix. - `cd` added to READONLY_ALLOW (no filesystem effect; every other segment must independently qualify anyway). - `ssh <host> '<cmd>'` classifies the inner command under identical rules, depth-limited to one hop. Verified: 42/42 self-test, e2e ALL PASS, classify() median 0.011ms. Tier-1 writes deliberately NOT shipped — widen on evidence, not guesses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
Constrained Autonomy for Claude Code — Design Decisions
Status: DESIGN LOCKED (grill-me 2026-07-14). Not yet built. Build = phased background agents (CA-P0…P5), mirroring the Agent-Sudo build model. Do NOT implement ad hoc — follow this record.
Driving insight (user): a permission prompt the human rubber-stamps — especially a truncated command they can't read or don't understand — adds no safety. It is the same theater as the NTFY approval gate we removed from Agent-Sudo. Kill the theater; replace it with an ENFORCED code constraint layer. Governing memory: feedback_autonomous_security_constrain_not_gate (constrain capability, don't gate access), feedback_evolution_by_default, feedback_always_include_training_loop, feedback_secrets_via_proxy_only.
The core problem
Claude Code's Bash tool runs as administrator (docker group + sudo). Today, consequential commands stop for a human permission prompt. --dangerously-skip-permissions removes that gate but adds NO constraint → strictly worse. We want: remove the theater prompt, but move the real gate into deterministic enforced code that reuses the Agent-Sudo constraint+recovery stack.
D1 — Enforcement architecture: HYBRID (hook classifies+routes; daemon executes-with-constraint)
- The hook =
security-enforcement.py(existing PreToolUse Bash hook, pure-regex, <50ms, no-I/O) extended into the decision point. It runs regardless of permission mode and is code, not a rubber stamp. It emits apermissionDecision:- tier-0/1 (read/reversible) →
allowLOCALLY (no prompt, no daemon round-trip → kills the theater, stays <50ms). - tier-2/3 (mutating/destructive) + UNKNOWN →
denythe raw command, message routes it to the Agent-Sudo daemon/exec(which already does sandbox-test-first + rollback + audit). This generalizes what the hook ALREADY does forsudotoday (block → route to bridge) into "anything consequential → Agent-Sudo". - tier-4 (catastrophic) →
deny, no override.
- tier-0/1 (read/reversible) →
- The daemon = Agent-Sudo
/exec(LIVE: server-01 8082, primary 8084). All heavy lifting (Incus sandbox mirror test, Timeshift/rollback, circuit-breaker,command_auditlogging) stays where it's built and tested. We reuse it; we do not reimplement it in a 50ms regex hook. - Rejected: pure-hook (can't sandbox/rollback in 50ms), route-EVERYTHING-through-daemon (latency + hard dependency + wrong root context for harmless reads).
D2 — Classification: STRUCTURAL effect-classifier, unknown→sandbox, learned allowlist on top
- SUDO.md only covers the
sudosurface; ~90% of Claude Code commands are non-sudo (git/docker/python/curl/file ops). So the classifier judges a command by its effect class, not exact-match:- reads/inspection → tier-0/1
allow - workspace-local / reversible writes (git add/commit, edits under project dirs, scratch scripts) → tier-1
allow - blast-radius mutations (docker rm/restart/compose down, writes outside workspace, DB writes, net-config, mv/rm on real paths) → tier-2/3 → daemon sandbox
- catastrophic verbs (rm -rf / patterns, mkfs, dd of=/dev/…, fork bombs, disk/partition ops) → tier-4
deny sudo-prefixed → daemon's SUDO.md governs (unchanged)- genuinely unrecognizable structure → default to daemon sandbox (treat as tier-2) — unknown = "prove it in the mirror first," NEVER "ask the tired human." (User-confirmed.)
- reads/inspection → tier-0/1
- Learned allowlist layered ON TOP: commands that ran clean N times (from
command_audit+human_verdictgold labels) get promoted to the local fast-path → system gets faster with use (feedback_evolution_by_default). Starts from structural analysis so day-one has zero rubber-stamp gaps. - Policy source of truth = a signed
CLAUDE_CMDS.mdtier-map file (the effect-class rules), analogous to SUDO.md.
D3 — Secrets: via secrets-proxy env_secrets (dormant until #128)
- Any command that touches a secret runs via secrets-proxy
/shell+env_secrets(vault://path#field/bitwarden://item) so values are sourced at runtime, never in stdout/history/argv. The hook's existing Category-2/3 blocks (secret exposure, hardcoded secrets) stay as the backstop that FORCES commands onto the proxy path. - Dependency: secrets-proxy = task #128 (pending/down). Secret-handling path is built but dormant until #128; interim fallback =
feedback_secret_to_script_via_file. Decoupled from the classifier build.
D4 — Tamper-evidence: Vault transit signature — UNBLOCKED 2026-07-14 (#145 resolved)
Transit is LIVE. Vault admin WAS recovered (user was right; my first probe was wrong — it checked only the least-privilege AppRole, which is denied transit by design, and never followed the Bitwarden path). Chain: vault approle →
secret/data/bitwarden-bridge#BRIDGE_API_KEY→ bridge :8083GET /secret?item=Hashicorp Vault&field=notes→ root token. GOTCHA: the token LABELED "Root token:" is REVOKED; a second UNLABELED token at the bottom of the note is the live one. Search the whole note — do not trust the label. transit enabled + ed25519 keyagent-sudo-sudomdcreated + SUDO.md SIGNED (sha25638eca778…, transit/verify PASSED) + tamper test PASSED (fake widened rule → gate REFUSED).CLAUDE_CMDS.md(CA-P4) and proxy.md (#150) can now be signed the same way.
D4 (original) — Vault transit signature (was dormant until #145)
CLAUDE_CMDS.mdgets the IDENTICAL mechanism assudo_sign.py: Vault transit ed25519 key, sign file sha256,verify_gateon load via scoped AppRole,..._VERIFY_ENFORCEflag. Tampered policy → refuse to honor.- Dependency: live signing blocked on #145 (OpenBao admin lockout; transit engine not enableable yet). SUDO.md itself runs unsigned (enforce=false) for this reason.
CLAUDE_CMDS.mdinherits the posture: built to verify, shipsenforce=false, flips totruewhen #145 lands — one activation signs SUDO.md + proxy.md + CLAUDE_CMDS.md together. (proxy.md signature status UNVERIFIED — do not assert it is signed.)
D5 — Fail mode: FAIL-CLOSED
- If the hook crashes or exceeds its 5s timeout →
denyeverything until healthy. A broken security control must not silently become no control. (User-confirmed.) - Mitigations: (a) hook stays dead-simple pure-regex (minimal crash surface); (b) an
ESCAPE_HATCHenv flag the USER sets to drop to prompt-mode if the hook ever bricks all Bash; (c) SessionStart self-test so a broken deploy is caught before it blocks work.
D6 — Recovery ladder (two phases)
- Phase 1 (now, background-agent automation): fail-closed → user reachable to grant permission / co-diagnose → fix → resume autonomy.
- Phase 2 (full autonomy): hook fail → ALL WORK PAUSES → Hermes (already monitoring) attempts diagnose+fix → success → resume; impossible → ALL WORK STOPS + high-priority NTFY ("catastrophic failure, all work halted, interactive session needed"). In that session Claude/Hermes still do the heavy lifting; user only grants permission.
- Depends on Hermes (#138) for Phase 2.
D7 — Circuit-breaker + self-disarm (inherited from Agent-Sudo)
- Same anomaly trip: N tier-3 failures in a window OR any tier-4 attempt → daemon self-disarms to read-only + NTFY. No new mechanism.
D8 — Training capture (inherited; zero new schema)
- Every classify+outcome →
command_audit(projects DB):assigned_tier,decision_type,matched_rule,evidence,exit_code,rollback_taken,verify_passed,human_verdict(gold label),training_signal. This is the local-model training export.
Phased build (background agents, like Agent-Sudo P0–P5)
- CA-P0 — write
CLAUDE_CMDS.mdtier-map + the structural classifier module (pure-regex effect-classes). No activation. - CA-P1 — wire the hook to emit
permissionDecisionallow/deny/ask; tier-0/1 local allow; tier-4 deny; tier-2/3+unknown → route to daemon/exec. Fail-closed +ESCAPE_HATCH+ SessionStart self-test. Test in the mirror first. - CA-P2 — daemon side: accept routed Claude-Code commands, sandbox-test unknowns in Incus mirror, capture rollback, log
command_audit. (Mostly exists — extend/verify.) - CA-P3 — secrets-proxy env_secrets path for secret-touching commands. GATED on #128.
- CA-P4 — sign
CLAUDE_CMDS.md(transit) + learned-allowlist promotion fromcommand_audit. GATED on #145. - CA-P5 — Hermes recovery-ladder (Phase 2) integration. GATED on #138.
EXECUTION ORDER (user-approved 2026-07-14, revised)
User's stated order was: (1) finish Agent-Sudo P3 subsystems [command testing + recovery] + sign SUDO.md; (2) finish/test/deploy secrets-proxy + sign proxy.md; (3) design/test/deploy constrained autonomy + sign CLAUDE_CMDS.md. User's concern: "I'll have to be here to give permission for everything."
REVISED — insert item 0 first. The ordering paradox: item 3 is what frees the user, but sat last, behind the two most babysitting-heavy items. Item 3's fast-path has NO dependency on items 1/2 (only CA-P2 needs item 1's sandbox; only CA-P3 needs item 2's proxy).
- CA-P1a — CONSERVATIVE FAST-PATH (do FIRST; zero dependencies). Hook classifies: a TIGHT, EXPLICITLY-ENUMERATED read-only set (git status/diff/log, ls, grep, find, docker ps/inspect, cat non-secret, scratch scripts) →
allowlocally, no prompt. Catastrophic verbs →deny(protection that does NOT exist today). Everything else falls through to the normal prompt exactly as today. Purely additive: net SAFER (adds tier-4 deny) AND less annoying. Removes ~80–90% of build-time prompts since building is overwhelmingly reads. Do NOT start with the full structural classifier — that's where a mis-classification could auto-allow something real. Start tight, widen withcommand_auditevidence. - Then 1 → 2 → 3 in the user's order, each one widening the fast-path: item 1's sandbox unlocks CA-P2 routing (destructive minority stops prompting); item 2 unlocks CA-P3 secrets path; item 3 completes + signs.
- Net: user is present only for the SHRINKING MINORITY of commands, immediately — instead of all of them until the very end.
#145 (Vault admin) — user reports RECOVERED but task not marked complete. Per feedback_verify_before_persist: VERIFY transit is actually enableable BEFORE marking done or signing. Next-session first action: verify → sign SUDO.md → close #145 → CA-P4's signing gate also falls.
CA-P1a — BUILT + LIVE (2026-07-14, task #148)
Shipped in /opt/appdata/docker/.claude/hooks/security-enforcement.py v2.0 as "Category 4",
layered under the existing Category 1–3 blocks (which run FIRST and still win — a fast-path
candidate that trips any security rule is BLOCKED, never allowed).
- allow — every segment matches a tight enumerated read-only set (git read-verbs, ls/cat/grep/ find, docker ps/inspect/logs, systemctl status, journalctl, ip show, sysinfo). No prompt.
- deny — catastrophic verbs (
rm -rf /+ system dirs, mkfs, dd→/dev, wipefs, fork bomb, destructive partition ops). New protection that did not exist before. No override. - prompt — everything else falls through exactly as before. Purely additive.
Safety rests on two independent conditions, both required: (1) transparent structure —
ANY $, backtick, redirection, or backgrounding disqualifies; (2) every pipeline segment
individually enumerated. One unknown segment disqualifies the whole command.
A real hole was caught by the tests, not by review: echo $VAULT_TOKEN initially classified
allow — echo is enumerated and $VAR is not $(, and Categories 1–3 don't catch it (not
docker exec env, not a cat of a known secret path). It would have printed a live secret with no
prompt. Fix: any $ disqualifies the fast-path — the hook cannot know what a variable holds,
so it cannot certify the command as a read. Locked in as a regression case. Lesson: enumerating
safe verbs is not enough; the structure must also be transparent, and only an adversarial
test suite finds the gap.
Verified: 23/23 self-test cases; e2e over the real stdin/stdout protocol ALL PASS;
classify() median 0.009ms (budget 50ms); malformed stdin → exit 0; non-Bash ignored.
D5 fail mode: in P1a the hook is NOT the only gate — the prompt still backstops everything
not fast-pathed, so "closed" = fall back to the prompt, never auto-allow. Any exception →
no decision → prompt (strictly no worse than pre-v2.0). Deny-everything fail-closed arrives with
CA-P1, when the hook becomes the sole gate. Escape hatch: CLAUDE_CA_ESCAPE_HATCH=1.
D5 self-test wired into session-start.sh (step 9) — reports at every SessionStart, non-fatal.
D8 training loop: every decision (including prompt) appends to
/opt/appdata/docker/.claude/hooks/ca_decisions.jsonl — best-effort, never breaks the hook.
The prompt rows are the D2 promotion candidates (approved-every-time ⇒ widen the fast-path).
CA-P1b — WIDENED ON EVIDENCE (2026-07-14, hook v2.1, task #147)
The first production data made the case, not intuition. After CA-P1a shipped, the user
observed he was still approving nearly everything. ca_decisions.jsonl answered why: 29
decisions, 22 prompts, and 16 were "opaque structure" — not mutations, not danger.
Root cause: 2>&1 contains a >. v2.0's rule was "any redirection is a WRITE ⇒ disqualify."
But 2>&1 is file-descriptor plumbing and >/dev/null is a discard — neither can write anything.
The rule was rejecting the exact idiom ordinary diagnostic reads are written in
(ls -la 2>&1 | head). The classifier wasn't being cautious; it was being wrong.
Three changes, all still tier-0. None widens WHAT may run — they let the hook recognise reads it was already supposed to allow:
- Safe redirects (
_SAFE_REDIRECT_RE) stripped before the redirect check:2>&1,>&2,2>/dev/null,&>/dev/null. A redirect to any REAL path still disqualifies;/dev/nullis anchored so>/dev/nullxand>/dev/null/../../etc/passwdcannot ride the prefix (tested). cdenumerated — no filesystem effect, and every other segment is still checked independently (cd /etc && rm -rf xstill prompts on therm).ssh <host> '<cmd>'— classify the INNER command under the identical rules, depth-limited to one hop. A read is a read regardless of which host runs it. Strict shape only: no options (ssh -o ProxyCommand=…prompts), no unquoted form. This is what stopped server-01 work from taxing the user on every singlels.
Security argument for the ssh hop, and its regression test: Categories 1–3 scan the FULL raw
text (including the inner) before the fast-path is consulted, so
ssh server-01 'cat …/agent-sudo/.env' is blocked — cat is structurally a read verb, so the
secret-path check is the ONLY thing standing between the ssh fast-path and an exfil channel. That
case is a locked regression test; if it ever goes green-to-allow, the hop must be withdrawn.
Verified: 42/42 self-test (up from 23); e2e ALL PASS, no regression; classify() median
0.011ms. Live-proved: ssh server-01 'systemctl is-active …' → allow, no prompt.
Known edge (accepted): 2>&1 immediately followed by a quote isn't stripped (lookahead wants
whitespace/;/|/EOL), so quoted compounds stay opaque → prompt. Safe direction, low value to fix.
Tier-1 (reversible writes) NOT shipped — deliberately. D2 authorises it, but the log says
Bash-tier-1 is a small slice of real traffic: file edits go through the Write/Edit tools (not
this hook), scratchpad writes are already pre-authorised, and git add/commit collides with
playbook_git_criteria_universal (commits are checklist-triggered — auto-allowing removes the
last friction on a rule enforced only by judgement). Correct next move per
feedback_evolution_by_default: run v2.1, let ca_decisions.jsonl name the next real tax, and
widen on evidence. The log found this one; it can find the next one.
Verified live state (2026-07-14)
- Hook:
/opt/appdata/docker/.claude/hooks/security-enforcement.py, registered PreToolUse matcher=Bash timeout=5 in~/.claude/settings.json. Currently block-only (exit 0/2), no classifier, no permissions allowlist set. - Agent-Sudo daemon
/health+/exec+/allowlist: server-01 8082 (LIVE, server_id=server-01), primary 8084 (server_id=primary; primary cutover swap still pending #146). command_audit(projects DB): 15 cols as above, present.