docs(agent-sudo): lock D-CB5-D-CB9, add the P3-WIRING agent prompt, record P3 state

- agent_sudo_design_decisions.md: D-CB5 (3 tier-3 fails / 15min sliding / per-host),
  D-CB6 (append-only breaker log, state always replayed — tamper-EVIDENT beats
  tamper-PROOF when the daemon is root), D-CB7 (Set B host_override in code),
  D-CB8 (which tier-4 sources latch), D-CB9 (trip escalation). Each with rationale,
  rejected alternatives, and accepted costs.
- agent_prompts.md: add the P3-WIRING prompt. Flags a real NAME COLLISION — the
  existing "P3" section is the OLD P3 (Vault AppRole + Timeshift + evaluator, done
  as #142); P3 was redefined after D-CB1-D-CB9 were locked. Running the old one
  would redo finished work. Today's inline agent prompts were never persisted, which
  is why this one is.
- context.md: P3 code complete but UNDEPLOYED — the server-01 tier-0 breaker.log hole
  is open in production until DEPLOY_RUNBOOK.md runs. Two verified deploy blockers
  (Dockerfile never COPYs circuit_breaker.py/security/; compose has no
  /var/lib/agent-sudo mount => breaker resettable by docker restart, the exact D-CB6
  bypass). Both were invisible to an 85/85-green suite.
- context.md: MEMORY_DIR answered — do NOT repoint embed_memory_dir.py; its
  claude_memories table has no reader. Recall corpus now includes design docs;
  context.md stays excluded until #192 prunes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Backtalk6858
2026-07-15 19:04:38 -05:00
parent dcc4cc4ef2
commit c0293fa96f
3 changed files with 275 additions and 6 deletions
@@ -132,3 +132,149 @@ Write the **phased** background-agent prompt SET (NOT one monolith — see reaso
- P5: decommission old sudo-bridge.
Opus+high prompt-craft upgrades over the Sonnet-era prompts (agent_prompts.md): bake in the 4-level test gate + agent_test_results logging (Sonnet's omitted it); split locked-contract from reference-implementation instead of embedding brittle literal scripts; right-size per-phase turn budgets; explicit "design is locked — do not redesign" block; per-phase rollback/abort criteria + idempotent pre-flight checks.
---
## CIRCUIT-BREAKER — REMAINING DECISIONS LOCKED (2026-07-15, grill-me Q3/Q4/Q5)
These close the last three open questions on task #149. D-CB1..D-CB4 (2026-07-14) fixed the
breaker's *concept*; these fix its *numbers, storage, and self-protection*. **#149 is now
design-complete — an implementing agent must NOT redesign any of this.**
### D-CB5 — LOCKED: benign-trip threshold = 3 tier-3 failures / 15-min SLIDING window / PER-HOST
- **Sliding, not fixed.** A fixed window lets failures straddle the boundary (2 at the end of
window 1 + 2 at the start of window 2 = 4 failures, no trip). Sliding closes that.
- **Per-host, not global.** The hosts have different roles (primary = production, server-01 =
sandbox); a sandbox failure burst must not disarm production tier-3. Decisive reason: a global
counter needs cross-host state, which recreates the **#173 failure class** (server-01's safety
depending on primary ⇒ drop primary to disarm server-01). Per-host adds no new cross-host edge.
- **Known, accepted cost:** an attacker probing BOTH hosts gets 2× budget before a benign trip.
Accepted because probing is a **tier-4 signal**, which latches hard via D-CB1 regardless of this
counter. The benign counter only ever governs broken scripts — and broken scripts are per-host.
- Threshold governs the BENIGN path only. Blast radius + re-arm still per D-CB1/D-CB2:
benign ⇒ stop **tier-3 only**, auto re-arm after cooldown w/ exponential backoff on repeat;
attack (any tier-4 attempt, or repeated trips inside one window) ⇒ **full read-only, human-only
reset, NTFY urgent.**
### D-CB6 — LOCKED: breaker state = LOCAL APPEND-ONLY EVENT LOG, state DERIVED by replay
`/var/lib/agent-sudo/breaker.log` (0600 root, append-only), one JSON event per line:
`{ts, host, event: tier3_fail|TRIP|REARM|RESET, cause: benign|attack, scope: tier3|readonly, cmd_id}`
- **State is never stored — always `replay(log)`.** Consequence that matters: a daemon restart
replays the log and the breaker is STILL TRIPPED. In-memory state would make
**restart = free reset = bypass** (the trap Q4 existed to catch).
- **Local only. No Vault, no Postgres.** Both live on primary ⇒ server-01 would depend on a remote
host for its own local safety state, and Vault-sealed/pg-down would mean *no breaker at all*.
Same #173 class as D-CB5's global-counter rejection. State must survive restart AND not add a
cross-host edge; local storage is the only option meeting both.
- **Append-only over a mutable `breaker.json` — because the daemon is root.** Root can rewrite any
local file, so "tamper-proof" is theater ([[feedback-autonomous-security-constrain-not-gate]]).
A mutable state file can be silently set back to `armed` leaving no trace. An append-only log
cannot be *edited* into a lie, only **truncated** — and truncation is **evident** (log shrinks,
audit trail has a hole). This converts *impossible-to-tamper* (false) into *tampering-is-visible*
(true and enforceable). Constrain, don't gate.
- Free wins: trip/reset events are labelled **training data**; and the replay boundary is the **D10
seam** letting Hermes assume trip-classification/reset later without a rewrite.
### D-CB7 — LOCKED: self-cannibalization ⇒ host_override TIER-4 in CODE, BOTH hosts, whole Set B surface
Found live during #145: `systemctl restart agent-sudo-daemon` routed THROUGH Agent-Sudo has the root
daemon kill its own executor mid-request — post-exec verify/audit can never complete. **The command
destroys its own witness.** Structural; `SNAPSHOT_ENABLED` does not touch it.
Refuse in code (a `host_override`, NOT merely a SUDO.md rule) on **both** hosts:
- `systemctl restart|stop|disable|mask agent-sudo-daemon`
- edits to `/etc/systemd/system/agent-sudo-daemon.service`
- writes to `agent-sudo/bridge/SUDO.md`
- writes to `/var/lib/agent-sudo/breaker.log`**new Set B member created by D-CB6**
- **Why the whole surface, not just `restart`:** the reason restart is forbidden is D-CB4 **Set B**
the unit env is where `SUDO_MD_VERIFY_ENFORCE` lives, so self-restart *is* the gate-disarm
primitive. But `stop`/`disable`/`mask`/unit-file-edit all reach the same place, and D-CB6's log
makes "reset the breaker" a one-line write. Blocking only `restart` blocks one door in an open frame.
- **Why code-level, not a SUDO.md rule:** *a rule that lives in the file it protects is not a
constraint, it is a suggestion.* (SUDO.md is signed, but host_override must not depend on the
gate it exists to protect.)
- **Why both hosts (rejecting tier-4-primary/tier-3-server-01):** server-01 is where Agent-Sudo
changes get PROVEN. A gate behaving differently in test than in prod means the sandbox stops
predicting production — the one host that proves the gate would be the one host not running it.
- **Known, accepted cost:** nobody can restart the daemon through Agent-Sudo, including for
legitimate deploys — a human needs a shell. That is **Set B working as designed**: human-only
*even though fully recoverable*, because automating it removes the ceiling. Per D6 (minimize, not
eliminate) this is a *near-zero-frequency* touchpoint, not a stall.
**XREF:** #149 (impl), #146 (primary cutover), #173 (same cross-host class — still OPEN, and
D-CB5/D-CB6 both deliberately avoid adding to it), D-CB1/D-CB2 (benign-vs-attack drives both blast
radius and re-arm), D-CB4 Set B, D9, D10, CA-D7.
### D-CB8 — LOCKED (2026-07-15): WHICH tier-4 sources are attack signals (closes D-CB1's open sub-decision)
D-CB1 says "any tier-4 attempt => attack latch" and explicitly left open: *"each trip signal must be
classified benign-noise vs attack at design time."* THIS IS THAT DECISION. It is load-bearing:
`app.py` reaches tier-4 from THREE different sources and **one of them is routine operation.**
| tier-4 source (`d.source`) | breaker signal | why |
|---|---|---|
| `danger_veto` | **ATTACK LATCH** (read-only) | matched a DANGER_PATTERN — attack-shaped |
| host_override (D-CB4 **Set B**) | **ATTACK LATCH** (read-only) | an attempt to disarm the gate itself |
| `miss_primary_refuse` | **NONE** (audit only) | **just an unlisted command** |
**Why `miss_primary_refuse` must NOT latch:** on primary it is the *default outcome for anything not
on the allowlist* — it is the system working, not an attack. Taken literally, D-CB1 would make every
typo and every new tool take the host to FULL READ-ONLY pending a human reset. **Verified against
reality:** during the 2026-07-15 session Agent-Sudo correctly refused every privileged command with
`source=miss_primary_refuse`; under the literal rule the breaker would have attack-latched on the
FIRST one and stayed latched all day. That is a false-positive machine and directly contradicts D9
("if the user is unavailable, automation must not stall").
**Rejected — "repeated misses trip BENIGN" (3-in-15min via the D-CB5 counter):** attractive because
rapid-fire misses resemble enumeration, but normal exploratory work produces exactly that shape, so
it re-imports the false-positive problem one layer down. A miss is not evidence of intent.
**Rejected — "only host_override latches, danger_veto trips benign":** a real destructive attempt
would then keep write access. Danger patterns catching fat-fingering as often as malice is fine —
the response (stop writing) is correct for BOTH.
**IMPLEMENTATION NOTE — the seam holds, no breaker change needed.** `circuit_breaker.record_tier4_
attempt()` stays correct as a primitive that ALWAYS attack-latches. D-CB8 governs only its CALLERS
in `app.py`: call it for `danger_veto` and host_override; for `miss_primary_refuse` emit the
existing `tier4_refuse` audit row and NO breaker event. Classification lives behind
`classify_trip()` (the D10 seam) so Hermes can refine this later without a rewrite.
**[[feedback-instrument-the-gate]] applies:** log every breaker decision WITH its reason and read the
log on a schedule. If `miss_primary_refuse` bursts turn out to correlate with real probing, D-CB8 can
be revisited **on logged evidence** — not on imagination. Widen/tighten on evidence.
### D-CB9 — LOCKED (2026-07-15): escalation = 4+ benign trips / 6h sliding (fixes an incoherent constant pair)
**The bug this fixes was in the SPEC, not the code**, and it was caught by the implementing agent
rather than by me. As first specified: `BASE_COOLDOWN_SECONDS`=10min but
`TRIP_ESCALATION_WINDOW_SECONDS`=1h with an implicit 2-trip threshold. Since re-arm happens at 10min,
**any repeat benign trip is NECESSARILY inside the 1h window** ⇒ every repeat escalates to an attack
latch ⇒ **the exponential backoff (20/40/80min) is unreachable dead code.** Two explicitly-required
behaviours (backoff; escalate-on-repeat) were mutually unsatisfiable at those constants.
**LOCKED VALUES:**
- `BASE_COOLDOWN_SECONDS = 10 * 60`
- `MAX_COOLDOWN_SECONDS = 4 * 60 * 60`
- `TRIP_ESCALATION_WINDOW_SECONDS = 6 * 60 * 60` (was 1h)
- `TRIP_ESCALATION_THRESHOLD = 4` (was implicitly 2)
Benign trips 1→2→3 stay benign with doubling cooldown (10 → 20 → 40 min); the **4th within a 6h
sliding window** attack-latches. Two counters, deliberately different: **escalation** counts benign
trips inside the 6h window; **backoff** counts benign trips since the last `RESET`.
**WHY 4/6h rather than latching on trip #2 — the key insight:** *exponential backoff IS the
anti-"attacker waits out the cooldown" mechanism.* 10→20→40→80min rate-limits a patient attacker
automatically, with no latch required. Meanwhile a broken cron job emits an IDENTICAL signal to a
patient attacker, and the breaker cannot tell them apart (same ambiguity as D-CB2). So latching on
the 2nd trip mostly punishes broken scripts and re-imports the false-positive problem D-CB8 just
removed. **A latch should mean "this is not resolving", not "you failed twice."**
- Rejected — *raise BASE_COOLDOWN above the 1h window*: makes backoff reachable, but one broken
script then kills tier-3 for 90+ min. Slow recovery fights D9 (automation must not stall).
- Rejected — *drop escalation, backoff only*: simplest and most autonomous, but a permanently broken
system would quietly self-throttle to the 4h cap forever and never ask for a human. A latch is how
the system says "I cannot fix this myself."
**PROCESS NOTE — worth keeping:** the constants were set casually mid-session to avoid stalling on a
question, and were not checked against each other. The implementing agent flagged the contradiction
instead of silently picking a reading that made its tests pass. That is the behaviour we want from
build agents, and it is why the "if you think a decision is wrong, implement it anyway but say so"
instruction is load-bearing — a compliant-but-silent agent would have shipped unreachable backoff and
nobody would have known until an incident. XREF [[feedback-instrument-the-gate]].