ops-warden/docs/adr/ADR-0004-agent-read-boundary-on-high-risk-lanes.md
tegwick 35aff380a3
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Lift ops-warden's binding rules into owned ADRs
Five rules that governed this repo lived in wiki prose, a workplan, and a
comment at the top of catalog.yaml. All were followed; none was addressable.
A reader outside ops-warden could not cite one, could not tell whether it was
current, and — the point of this change — could not tell whether it was ours
to change or someone else's that we merely obey.

  ADR-0001  The routing catalog is a pointer layer, never a second copy
  ADR-0002  ops-warden is a transparent conduit, never a secret broker
  ADR-0003  Cover gaps, but never silently own them
  ADR-0004  High-risk lanes refuse raw value streaming to agent sessions
  ADR-0005  Implement one lane narrowly, route everything else

Each carries owner: ops-warden, which is the load-bearing field. It says we
follow the rule AND we are responsible for changing it — by superseding ADR,
never an in-place edit. The failure this prevents runs both ways: a rule we own
mistaken for inherited canon never gets fixed, because we wait for an owner who
does not exist; inherited canon mistaken for ours gets quietly bent, and the
drift is invisible until it breaks a repo that trusted the canonical version.

Rules we follow but do not own — NetKingdom canon, the IAM profile, the
credential-management standard, the-custodian's ADR-001 workplan convention —
are cited, never copied into docs/adr/. Copying them would recreate exactly the
second-source-of-truth failure ADR-0001 exists to prevent. architecture.md also
now flags the three-digit/four-digit ADR-001 vs ADR-0001 collision, which is
itself an ours-versus-inherited confusion waiting to happen.

Publication verified rather than assumed: all five render through policy-nexus
tools/render.py, and owner reaches the reader in three places — the page eyebrow
(render.py:346), the index Owner column (build_site.py:123,137), and the
publication manifest. build_site.py:179 makes title/status/owner required, so
ownership cannot be dropped on the way out. policy-nexus publishes and never
writes back; docs/adr/ stays the source of truth.

Documentation adapted: SCOPE.md gains a Governing rules section and an orientation
entry; .claude/rules/architecture.md replaced its stub with the ADR index, the
owned-versus-inherited rule, and ADR-over-wiki precedence; finding-routing.md's
ADR-gap section closed; catalog.yaml's no-double-source header now cites ADR-0001
rather than the originating workplan.

uv run pytest -q → 338 passed, 4 deselected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:35:13 +02:00

85 lines
3.6 KiB
Markdown

---
id: ops-warden-adr-0004
type: adr
title: "ADR-0004 — High-risk lanes refuse raw value streaming to agent sessions"
domain: infotech
repo: ops-warden
status: accepted
version: "1.0"
revision: "1"
owner: ops-warden
binds: "ops-warden; any agent runtime calling warden access"
created: "2026-07-20"
updated: "2026-08-18"
last_reviewed: "2026-08-18"
review_interval: 6m
enforced_by: "src/warden/access.py (exit 7); OpenBao policy agent-high-risk-boundary"
supersedes: ""
successor: ""
---
# ADR-0004 — High-risk lanes refuse raw value streaming to agent sessions
## Status
Accepted. Decided during WARDEN-WP-0026 (credential disclosure hygiene), in
response to a real disclosure on 2026-07-16.
## Context
On 2026-07-16 a secret value reached a captured stdout. The mechanism was ordinary:
`bao kv get -field=X` in an agent session. Nothing was misconfigured and nobody
misused a tool. The value was read correctly, by an authorized caller, using the
documented command — and an agent session records its stdout, so the value landed in
a transcript that outlives the shell.
This is a structural mismatch, not a mistake to train away. Agent sessions are
logged by design; that is what makes them reviewable. A human at a terminal sees a
value and it scrolls away. An agent "seeing" a value writes it into a durable
context that may be stored, replayed, or sent to an inference provider.
Guidance alone will not fix it. The command is correct, it is in every runbook, and
the next agent that needs the value will reach for it.
## Decision
**When `WARDEN_AGENT_ID` is set and the catalog lane is `risk: high`, ops-warden
refuses to stream the raw value and exits 7.** The agent is not blocked from doing
its work — `--out`, `--exec`, `--wrap` and `--fingerprint` all remain available.
It is blocked from doing its work *in a way that writes the secret into a transcript*.
**The boundary is enforced at the credential store as well as at the CLI.** The
OpenBao policy `agent-high-risk-boundary` denies data-read on those paths for agent
tokens, allowing metadata and capabilities only. A control that lives solely in our
own CLI is a control that ends the moment someone calls `bao` directly.
**Verification must not require a read.** To check a lane, use
`bao token capabilities` — allow/deny — never a read of the value. This is the
specific habit the disclosure taught us to break.
**Exposure is reportable without reading.** `warden taint <catalog-id>` reports KV v2
`custom_metadata` (`exposed_at`, `exposed_version`) and touches no secret data.
## Consequences
**Agents can still do everything they could before, by a different route.** `--exec`
covers nearly every real case: the child process gets the value in its environment,
the agent never sees it. The friction is deliberate and small.
**Exit 7 is a contract other runtimes depend on.** It is a distinguishable code, not
a generic failure, so a caller can tell "refused by boundary" from "lane broken" and
retry correctly. Changing it is a breaking change to every agent runtime.
**`risk: high` becomes a load-bearing catalog field** rather than documentation.
Classifying a lane now changes runtime behaviour, so it must be set deliberately.
**We accept that `--unsafe-stdout` still exists for humans.** The boundary keys on
`WARDEN_AGENT_ID`, so an agent that does not set it is not caught. That is a known
limit: this ADR raises the floor for cooperating runtimes and hardens the store
behind them; it does not claim to stop a determined caller.
## Related
- `wiki/playbooks/agent-read-boundary.md`
- `wiki/playbooks/exposed-taint.md`
- `ADR-0002` — the conduit rule this narrows for agent callers