- constrained_autonomy_design_decisions.md: D1-D8 locked (hybrid hook classifier + Agent-Sudo daemon executor, structural classification with unknown->sandbox, fail-closed, Hermes recovery ladder) + EXECUTION ORDER with CA-P1a inserted as zero-dependency item 0 - agent_prompts.md shared block: replace "TIER-2 IS BROKEN" with the corrected root cause (old primary bridge's NTFY endpoint was unreachable; tier-2 works when the bridge reaches ntfy + user is attended) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.9 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 (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.
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.