diff --git a/agent-builder/constrained_autonomy_design_decisions.md b/agent-builder/constrained_autonomy_design_decisions.md index 6be5b3f..7128e15 100644 --- a/agent-builder/constrained_autonomy_design_decisions.md +++ b/agent-builder/constrained_autonomy_design_decisions.md @@ -30,7 +30,18 @@ Claude Code's Bash tool runs as `administrator` (docker group + sudo). Today, co - 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) +## 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 :8083 `GET /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 key `agent-sudo-sudomd` created + **SUDO.md SIGNED** (sha256 +> `38eca778…`, 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.md` gets the IDENTICAL mechanism as `sudo_sign.py`: Vault transit ed25519 key, sign file sha256, `verify_gate` on load via scoped AppRole, `..._VERIFY_ENFORCE` flag. 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.md` inherits the posture: **built to verify, ships `enforce=false`, flips to `true` when #145 lands** — one activation signs SUDO.md + proxy.md + CLAUDE_CMDS.md together. (proxy.md signature status UNVERIFIED — do not assert it is signed.) @@ -68,6 +79,81 @@ User's stated order was: (1) finish Agent-Sudo P3 subsystems [command testing + **#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: +1. **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/null` is + anchored so `>/dev/nullx` and `>/dev/null/../../etc/passwd` cannot ride the prefix (tested). +2. **`cd`** enumerated — no filesystem effect, and every other segment is still checked + independently (`cd /etc && rm -rf x` still prompts on the `rm`). +3. **`ssh ''`** — 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 single `ls`. + +**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).