ADR 0009: Opt-in two-step confirmation broker for Tier-3 operations¶
- Status: Accepted
- Date: 2026-07-15
Context¶
ADR 0002 makes the service account, not an internal sandbox, the trust
boundary, and ADR 0003 classifies every call into a tier. Tiered policy is a
single-pass admission decision (policy.Policy.check; modes in
ADR 0003): the deny list runs first in every mode,
then the mode narrows the envelope.
That leaves a gap for the highest-consequence class. A Tier 3
(IRREVERSIBLE) operation (rm -rf, mkfs, dd of=/dev/…, a destructive >
redirect) in the default open posture executes on first request with no
friction. guarded can refuse it wholesale, but that is all-or-nothing: an
operator who needs occasional irreversible admin work must either run open
(no gate) or maintain a POLICY_ALLOW regex. There is no "permit, but require
a deliberate second step for this specific irreversible call" middle ground.
This pass compared relay-shell against two sibling MCP control planes. Their
pattern is a plan → authorize → execute operation broker: a privileged or
destructive action is not run when first requested; it returns a single-use,
TTL-bounded authorization token, and only a distinct second call presenting the
token executes. That step forces a model persuaded within a single turn to take
a separate, auditable action before an irreversible command runs. This ADR
adapts the pattern into relay-shell's idiom.
Decision¶
Add an opt-in, additive two-step confirmation broker for Tier-3 operations,
enforced in the one central runner (Relay.run) so it covers every tool
uniformly.
-
Opt-in, default off. A new
RELAY_SHELL_CONFIRM_TIER3setting (defaultfalse) turns it on;RELAY_SHELL_CONFIRM_TTL(default 120s, range 5–3600) bounds token lifetime. When off,Relay.brokerisNone, the gate block is skipped entirely, and the audit record is byte-identical to today, the same compatibility promise ADR 0006/0007 make. -
A safeguard on top of policy, never a bypass. The deny list and mode check in
Relay.runrun first, unchanged. The broker gate is consulted only after a call is already admitted, and only whendecision.tier == Tier.IRREVERSIBLE. Confirmation never widens what policy allows; a denied call is still denied, and areadonly/guarded-refused Tier-3 call never reaches the broker. -
Bound to the exact operation, not a per-tool parameter. A token is bound to
sha256(tool \0 op_key), whereop_keyis built inRelay.runfrom the command/content probe (policy_text) and a canonical serialization of the full audited argument set (audit_args).policy_textfixes what runs (command + stdin + env + script body, via the_policy_text_*builders);audit_argsfixes the operation's target: thehost(SSH tools),hosts(ssh_fanout),cwd(shell tools),session_id(session_send),local/remote(transfer tools), whichpolicy_textdeliberately omits. Binding to both means a token armed for one target cannot be consumed against another (no confused-deputy replay), and the gate covers every current and future Tier-3-capable tool with no per-wrapper token threading and no risk of a new tool silently escaping it. It lives in one place, as the deny list and classifier already gate centrally. -
The flow (three audited steps on the normal stream).
- plan: a Tier-3 call with no armed confirmation returns
[CONFIRM REQUIRED tier 3 …: call operation_confirm(token="…") then re-issue this exact call within Ns.]and is audited withaction=confirm_plan.work()does not run, so there is no side effect. - arm: a new
operation_confirm(token)MCP tool marks that token armed. It is Tier 0 (it mutates only ephemeral broker state and authorizes nothing on its own); its audit record carries only a non-replayable token fingerprint, never the raw token. -
execute: re-issuing the exact same call finds the armed token, burns it (single-use), and proceeds to
work(); the executed record is taggedaction=confirm_execute. -
Additive record shape. The audit record gains one optional
actionfield, written only when non-empty (exactly likerequest_id/client_id). Off-host parsers keep working; the default-off configuration produces a byte-identical record.server_infogains aconfirmblock (tier3,ttl, livependingcount). -
Ephemeral, process-local state. Tokens live in an in-memory, lock-guarded, size-bounded, TTL-swept store. A restart drops all pending tokens (fail-safe: a dropped token simply re-plans), so nothing persists and there is no cross-restart replay. The lock guards against a second writer (e.g. the ADR 0006 supervisor thread), as in the audit logger; today every caller runs on the event loop.
-
A tool, not a CLI verb (contrast ADR 0007). Audit verification is an operator/forensic action kept off the tool surface; the confirm step is part of the model's own workflow, since the model that issued the Tier-3 call must take the deliberate second step. So
operation_confirmis a registered MCP tool, moving the contract from 21 to 22 tools (tests/test_server.py::_EXPECTED,docs/tools.md, README, and_INSTRUCTIONSupdated in lockstep).
What the confirmation gate provides (and what it does not)¶
It provides deliberate, audited friction for irreversible operations: an
irreversible command cannot run as an unbroken continuation of a single
request; a separate operation_confirm call, bound to that exact operation and
valid only briefly, must intervene, and both the intent (confirm_plan) and
the execution (confirm_execute) are on the audit trail.
It is not a capability reduction and not a sandbox. A client fully in
control can call operation_confirm itself and proceed; the value is that doing
so is a distinct, logged, deliberate act, raising the bar against single-turn
persuasion and leaving clearer forensics. It does not defend against a client
that has decided to destroy data and will take both steps; that residual risk is
the ADR 0002 posture, unchanged. Tier classification is heuristic (ADR 0003):
the gate is only as precise as the tier, so it is an additional guardrail
layered on the deny list, never a replacement for it.
Consequences¶
- The audit-record schema grows one optional
actionfield underRELAY_SHELL_CONFIRM_TIER3=true; documented indocs/architecture.md§"Request lifecycle" anddocs/runbook.md§2.3. Default-off is byte-identical. Relay.rungains one gate block, skipped when the broker isNone. The §3.3 security-sensitive checklist (runbook) gains the broker invariants (opt-in, default-off byte-identical, deny/mode-first, single-use/TTL, raw-token-never-logged).- One new tool (
operation_confirm) and one new module (broker.py, paired withtests/test_broker.py).server_inforeports the liveconfirmblock. - The
inc_tool_callmetric gains a boundedconfirm_requiredoutcome value (a fixed constant, never user-controlled; the label-cardinality invariant holds).
Rejected alternatives¶
- Thread a
confirmtoken through every Tier-3-capable tool wrapper. Gives a two-call flow (no separateoperation_confirm), but requires editing ~11 wrappers and creates a standing footgun: any future tool that can classify Tier 3 but forgets the parameter would be permanently un-confirmable (fail-closed but broken) when the broker is on. The central gate is correct by construction for every tool, which for a security control outweighs saving one round-trip. - Make it a new policy mode (e.g.
confirm). Modes are a total order over tiers; confirmation is orthogonal (it can apply withinopenorguarded). Folding it into the mode enum would conflate "how much is permitted" with "what needs a second step" and complicate the four other places that switch on mode. A separate opt-in flag composes cleanly with any mode. - Rollback / verify commands (
rollback_command,verify_command,auto_rollback) now. The sibling broker also pairs an operation with a rollback and a post-verify command. That is a materially larger surface (executing more commands on the audited path, with their own policy/audit implications), deferred to the backlog (runbook §7.1) as a v2 built on this gate; see ADR 0010. - Persist tokens across restarts. A persisted token is a replay surface, and losing pending tokens on restart is already fail-safe (re-plan).
Validation outcome (2026-07-15)¶
Implemented and validated in the same PR (evidence in
audit/2026-07-15-engagement.md):
ruff check,ruff format --check,mypy --strictclean.pytest -q: 391 passed / 13 deselected (up from 374; +17 broker/wiring tests).pytest -m fuzz: 13 invariants pass.coverage: 94% with subprocess collection (floor 90%);broker.py100%,server.py96%.- 22 MCP tools (was 21):
operation_confirmadded;_EXPECTED, the stdio e2e count,docs/tools.md, README, and_INSTRUCTIONSupdated in lockstep. - Behavior validation: default-off Tier-3 runs unchanged and the record carries
no
actionfield (byte-identical); enabled, a Tier-3 call is challenged (confirm_plan, no side effect; a target file survives the plan step), arming then re-issuing the exact call executes it (confirm_execute, the target is removed), a mismatched/expired/bad token re-challenges, a non-Tier-3 call is unaffected, and the raw token never appears in the audit log.
PR-review hardening¶
A security review of the diff (bundled /security-review) caught one HIGH:
the initial draft bound the token to sha256(tool \0 policy_text) only. Because
policy_text is the command-content probe and deliberately omits the target
(host, cwd, session_id, hosts), a token armed for one target could be
consumed against another: confirm cwd=/tmp then execute cwd=/, or confirm
one host then fan ssh_fanout out to the whole inventory, defeating the gate's
own "bound to the exact operation" promise. The fix widened the binding to
op_key = policy_text \0 canonical(audit_args) (_confirm_op_key in
server.py), since audit_args carries the target for every tool; the central
gate keeps the no-per-wrapper-param property. A regression test
(test_on_token_bound_to_target_not_just_command) pins that a token armed for
one cwd neither authorizes nor mutates a different cwd. Connection-only
modifiers that are not in the audit record (user/port/key_path for the SSH
tools) are not part of the binding: they change how the relay connects, not
what audited operation runs against which target, so a change to them alone
(same host + same command) is out of scope for the confirmation identity.
2026-07-15 follow-up: SSH identity folded into the binding (BRK-3)¶
The adversarial pass (audit/2026-07-15-adversarial-engagement.md) reversed the
"out of scope" call above. A concrete escalation makes it matter: confirm
ssh_exec(host, "DROP DATABASE prod", user=readonly), arm, then re-issue with
user=root; same host+command, so the op-key matched and the token was
consumed against the escalated credential, invisible in the confirm_plan
record. The fix adds user/port/key_path to the ssh_exec/ssh_spawn
audit_args, which (since the op-key hashes audit_args) folds them into the
binding and surfaces the credential in the audit trail, a strictly better
outcome than the original scoping. Pinned by
test_confirm_op_key_binds_ssh_identity.
No change to policy admission, tier semantics, the no-sandbox posture, or any existing tool's response shape. This pass added a compensating control on ADR 0003's classification; it did not move the trust boundary.
2026-10-14 follow-up: tokens bound to the planning client (A14-C10b)¶
The binding covered the operation but not who asked. Under multi-client OAuth, client B
issuing the identical call inside the TTL consumed the token that client A had planned and
armed, so B executed an irreversible operation on A's confirmation. Reproduced against the
previous code with two verified identities (tests/test_broker_client_binding.py).
The broker now records the verified client_id (the identity the audit record carries,
never request metadata; empty when there is none) when it plans a token. arm and
consume accept only the same client_id: a foreign arm is indistinguishable from an
unknown token, and a foreign consume neither executes nor burns the owner's token, so B
is challenged afresh and gets its own token. Unauthenticated deployments (stdio, auth off)
share the empty identity, so their behaviour is unchanged.
STPA trace (authority handover; an approval click alone is not the argument):
- L-1 An irreversible operation runs without the second step its issuer was shown. H-1 A confirmation granted for one requester is spent by another. SC-1 A token is usable only by the client it was issued to, for the operation it was issued for.
- UCA-1 (provided unsafely)
consumereleases a token to a client other than the planner. UCA-2 (provided unsafely)armaccepts a token from a client that did not plan it. UCA-3 (not provided) a foreignconsumeburns the owner's armed token (denial of the owner's confirmation); avoided because a mismatch leaves the entry untouched. - CS-1 (UCA-1) A and B issue the same
rm; B retries inside A's armed window. Test:test_second_client_cannot_ride_the_first_clients_confirmation. CS-2 (UCA-2) B obtains or guesses A's token (128-bit, so only by leak) and arms it. Test:test_other_client_cannot_arm_a_token_it_did_not_plan. CS-3 Feedback is stale across a restart: tokens are in-memory and die with the process (unchanged). - Limits: this separates authenticated clients from each other; it does not stop one client from confirming its own operation, and an unauthenticated deployment has a single identity by construction. The broker remains a friction safeguard, not a sandbox (ADR 0002).