Skip to content

fix(serve): Stop and steering work on a daemon with no user-action key (SD-11) - #240

Merged
Broccolito merged 9 commits into
mainfrom
claude/nostalgic-fermi-6569af
Sep 12, 2026
Merged

fix(serve): Stop and steering work on a daemon with no user-action key (SD-11)#240
Broccolito merged 9 commits into
mainfrom
claude/nostalgic-fermi-6569af

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

⚠ Updated after security review — POST /interrupt is excluded

The first pass admitted all four turn-control routes on a keyless daemon, on one argument:
the caller already stops that turn through /agent/stop, and already puts text in front of that
chat's model through /reply. The review found that the second half is false for
/interrupt
. /reply takes the BR-33 single-turn lock and answers 409 whenever a
different turn is already running in that chat; /interrupt answers 409 when none is. The
two preconditions are disjoint, so in the exact state where a steer lands, the route said to
dominate it is refused. Admitting the steer would therefore have added something genuinely new —
attacker-chosen text injected into a turn already in flight, without cancelling it, which the
person watching sees as their own turn changing direction. Cancel-then-reply, the nearest thing a
caller holding only the daemon secret already has, kills the turn first and is visible. Not a
tier crossing (reach and the subagent rule are untouched) — a capability asymmetry.

So /interrupt keeps the user-action proof on both kinds of daemon, exactly as main has it,
and only /agent/cancel + the two continuation routes take the new gate. Its keyless refusal
carries a sentence (STEER_NO_KEY) rather than an empty body, because biorouter session attach
reads an empty turn-control 403 as "this daemon holds a key" and prompts for one — see
#261 compatibility below. Read every claim below with that correction
applied; the sections it changes say so.

A second, lower-severity item is fixed in the same pass: a daemon that comes up keyless by
accident
now says so loudly. See Loud about a keyless startup.

Summary

On every biorouter serve daemon the browser's Stop button, mid-turn steering and Stop and send answered 403 with an empty body — on a host configured with a public model as well as a private one (measured 2026-09-11 from the page). POST /agent/cancel, POST /interrupt, POST /agent/continuation/abandon and POST /agent/continuation/recover each began with an unconditional is_user_action check, and a daemon started with a closed stdin (SD-7) holds no user-action key, so nothing can ever pass it.

This PR records the ruling as SD-11 and implements it:

  • On a daemon that holds no key, /agent/cancel and the two /agent/continuation/* routes gate through authorize_agent_control — the same call /agent/stop already makes there: the reach rule (every public chat; a private one only for a caller whose stated X-Caller-Provider covers it), then no subagent's chat. One shared call rather than a copy, so the two routes can't drift on who may stop a turn.
  • POST /interrupt is not one of them (the review's correction). It keeps the proof on both kinds of daemon via its own authorize_steer, and a keyless one refuses with a 403 carrying STEER_NO_KEY — a sentence, never an empty body. The browser's steer control falls back to the ordinary send queue on any refusal (chatStreamStore.steer()), so a browser user's text is delayed, not lost.
  • A daemon that holds a key (the desktop app's) is unchanged: proof only. The keyed-daemon refusals (cancel_without_user_action_proof_cannot_stop_another_turn, interrupt_without_user_action_proof_cannot_forge_human_steering) pass untouched.
  • A subagent's chat stays refused on a keyless daemon (as /reply and /agent/stop already refuse it), and refuse_subagent_unless_user now names the daemon, not the caller, as what cannot prove a person acted (SD-8).

Why admitting these is safe

The proof guards against a model holding the daemon secret (AR-11: recoverable) stopping another chat's turn or putting words in a person's mouth. On a keyless daemon that caller already has every one of those capabilities next door:

What the refusal withheld Where the same caller already does it on a keyless daemon Guarded by
Cancel a reachable chat's running turn POST /agent/stop (trips the same turn, and evicts the agent) authorize_agent_control — the gate this PR reuses
The same, as a model with no secret workspace_close { scope: "turn" } refuse_unless_writable (tier only)
Put text in front of the chat's model as the user, before or after a turn POST /reply (non-subagent chats accept it unstamped) session_reach
Abandon a pending Stop-and-Send workspace_close { scope: "turn" } refuse_unless_writable

The third row is the whole argument for the steer, and it does not hold/reply is
refused 409 in the exact state where a steer lands (see the banner). That row is why
/interrupt was dropped from the ruling.

So nothing that holds the secret gains a capability; the person gains Stop. The record answers privacy-tiers §3.1's Q1/Q2 in full, including one finding (not a new one): POST /active_work/{id}/cancel cancels a subagent by registry id with no gate on any daemon — the residual session_reach.rs already records.

The remaining three routes move together because Stop-and-Send mints a continuation lease that blocks every other turn in the chat until it is used or given up; admitting the cancel but not abandon/recover would wedge the chat on the first removed queued message or page reload. The tests measure that.

Tests — each fails before, and each piece of the fix is load-bearing

New keyless binary crates/biorouter-server/tests/turn_control_no_user_key.rs (its own binary: the digest is a process-global OnceLock, like approval_no_user_key.rs). Against unmodified main, all 7 tests fail — the measured symptom exactly (/agent/cancel 403, /interrupt 403) — and the premise test shows the asymmetry directly:

/agent/cancel on a Public chat with caller None answered 403 Forbidden; expected admitted=true.
All verdicts: [("/agent/stop", 200, true), ("/agent/cancel", 403, false)]

Mutation checks (each re-breaks one piece of the fix; the named test is the one that catches it):

Re-broken piece Caught by
/interrupt back on the keyless arm a_keyless_daemon_refuses_the_steer_it_admits_the_stop_for (measured 202, with the text on the running turn's queue)
/interrupt's keyless refusal made an empty 403 the same test — it asserts the body carries STEER_NO_KEY, which is what keeps biorouter session attach from prompting for a key that does not exist
The steer gate reading the chat (an id oracle) a_keyless_steer_refusal_says_the_same_thing_about_every_chat (before: public 409, private 403)
Keyless arm asks reach only (no subagent rule) a_subagents_turn_is_still_refused_… (child's turn cancelled, 200) and a_keyless_cancel_admits_exactly_the_callers_agent_stop_already_admits (stop 403 vs cancel 200)
Abandon back to proof-only stop_and_send_settles_… — "the chat is wedged"
Recover back to proof-only a_keyless_daemon_hands_a_pending_continuation_back_to_its_window (take_over 403)
Old model-addressed subagent refusal text on a keyless daemon a_subagents_turn_is_still_refused_and_the_refusal_says_why

Every suite below ran locally with BIOROUTER_DISABLE_KEYRING=true. (These were run before #239 landed, when CI still ran only --lib --bins; CI now runs the integration binaries too, so turn_control_no_user_key is CI-enforced.)

Suite Result
cargo test -p biorouter-server --tests lib 586 · biorouterd bin 575 · all 19 integration binaries, incl. turn_control_no_user_key 8, approval_no_user_key 3, knowledge_tier_no_user_key 2, privacy_ar15_is_retired 7, privacy_toggle_config 12, every_test_binary_is_sandboxed 2 — 0 failed
cargo test -p biorouter --lib privacy:: 232 passed
cargo test -p biorouter --test privacy_toggle --test privacy_capability --test privacy_disclosure_toggle --test privacy_guard_wiring 4 · 4 · 1 · 3 passed (wiring-census counts unchanged)
cargo test -p biorouter-mcp --lib knowledge::tier · --test privacy_toggle_export 31 · 1 passed
./scripts/clippy-lint.sh strict pass clean; too_many_lines baseline ok
cargo fmt --check · scripts/check-openapi-schema.sh clean · up to date

After merging main (60 commits, 2026-09-11): one conflict, in serve-decisions.md (main's new SD-9 beside this SD-11 — both kept, in order). main changed none of reply.rs, agent.rs or session_reach.rs; its auth.rs change (#231's throttle) leaves user_action_proof untouched. Re-run on the merge: cargo test -p biorouter-server --tests (lib 600 · bin 588 · all 19 integration binaries, turn_control_no_user_key 8/8), privacy_guard_wiring 3/3, cargo clippy -p biorouter-server --all-targets -D warnings clean, cargo fmt --check clean, and the regenerated contract is byte-identical to the merged one.

CI on the merged head (refreshed 2026-09-11 after #258/#259 landed): main merged again, no conflicts, SD-11 untouched. Re-verified on this head: cargo test -p biorouter-server --tests green across lib, bin and all integration binaries (turn_control_no_user_key 8/8), privacy_capability 4/4 (it was red on main until #258 and #259 fixed the reach-sites census — neither is part of this PR), privacy_guard_wiring 3/3, clippy -p biorouter-server --all-targets -D warnings and cargo fmt --check clean, and the regenerated contract byte-identical.

Two CI failures seen on earlier heads, neither from this PR and neither recurring here: test (windows-latest)'s session::builder::tests::close_ephemeral_store_removes_the_directory (a biorouter-cli test this PR does not touch, passed on the following run), and test (ubuntu-latest)'s agent_drafter::bundle::tests::a_timed_out_esbuild_reaps_its_whole_process_group — a process-reaping race in biorouter-mcp that passes on main. If that race fires again it kills the job before the integration step runs, so a red ubuntu check there is not evidence about this PR.

The repo-grep guards touched here all pass: session_reach's ordering census (with its new rows and controls), auth::all_five_raise_channels_call_the_guard, the_add_extension_route_still_refuses_a_public_session_outright, and the wiring census.

End to end, in a browser against a real biorouter serve

Built from this branch: biorouter serve spawning biorouterd with a closed stdin (its log: "no user-action key on stdin"), host configured with the public codex provider (gpt-5.5), driven by clicking the page's own controls:

Control in the page Request Answer
Queue row → "Add this message to the current turn" (steer) POST /interrupt 202, and the model read the steer and followed it
"Stop response" POST /agent/cancel 200 {"cancelled":true,"turn_id":"turn-2","settled":true}
Queue row → "Stop the current turn and send this message as a new turn" POST /agent/cancel (continuation) → POST /reply 200 {"cancelled":true,…,"settled":true,"continuation_lease":"…"}200, and the replacement turn answered

The persisted steer row carries no provenance ({"userVisible":true,"agentVisible":true,"pinned":false}), identical to an ordinary /reply row. The private-chat path was not driven from a page — on main a serve host cannot start a private chat until #229 — and is covered by the integration test instead.

#261 compatibility — checked before implementing, and it is the reason the refusal has a body

#261 (stacked on this branch) makes the CLI
send turn-control requests without the proof first and prompt only when the daemon asks for it.
Its key_verdict(code, body) reads a 403 with an empty body as "this daemon holds a key and
wants the proof" → prompt, and a 403 with a sentence as "no key would help" → print the
daemon's words. It reads /interrupt in three places, not one:

  1. settle_steering_key — attach's join-time probe, an empty-text /interrupt used purely as a
    gate question;
  2. steer_refusal — the per-steer refusal reader during attach;
  3. send_to/reply's key oracle: when /reply 403s, send asks /interrupt whether a
    key would change that.

A naive revert of /interrupt to bare is_user_action would have broken all three: on a
keyless daemon the refusal would be an empty 403, so a biorouter serve user would be prompted
for a user-action key that does not exist and then told it was the wrong one. That is why the
keyless arm answers STEER_NO_KEY instead — a sentenced 403 lands in KeyVerdict::Refused, the
branch #261 already implements for keyless daemons, and the person is shown the daemon's words.

Net: no conflict, and #261 needs no change. The empty 403 still means, and only means, "a
daemon that holds a key wants the proof". One knock-on worth naming: on a keyless daemon send_to's
oracle now always answers Refused(STEER_NO_KEY) when /reply 403s, so send's message names
steering where the actual refusal was the subagent rule. It is non-prompting and non-fatal, and
#261 may want to sharpen that sentence; it is not a regression of its contract.

#260 is frontend-only (a subagent's tab loads read-only in a browser) and does not read
/interrupt's status.

Loud about a keyless startup

read_user_action_digest has a 2-second bounded read of stdin. A desktop launcher slow enough to
miss it left the desktop daemon keyless — which was survivable while a keyless daemon simply
refused every control needing the proof: Stop answered 403 and the user complained. With Stop now
working there, the same misconfiguration degrades quietly to a weaker gate.

So the read reports why it holds no key instead of one undifferentiated None:
HandStarted (stdin is a terminal), NoneOffered (stdin closed empty — serve's Stdio::null(),
SD-7), TimedOut (⚠ a writer held the pipe open and wrote nothing), Malformed (⚠ not a 32-byte
hex digest). The last two are launcher faults; nothing Biorouter ships does either on purpose, so
their warning says so and says to restart. Every arm names both consequences — what this daemon
refuses, and what it now admits instead (SD-11) — and that mid-turn steering stays refused.
Classification is split into the pure classify_digest_line so the four arms are actually tested
(keyless_report_tests).

The 2 s bound is unchanged: nothing measured says the desktop launcher misses it, and what was
missing was the report, not the time.

⚠ On evidence, plainly: this item's fail-before is a measurement of the old code, not a red test.
The defect is an absence of information — one None for four causes and a warning naming only the
privacy refusal — and a red test for it would have to reference the enum it introduces, so it could
only fail to compile. The new tests are drift guards and are not presented as fail-before evidence.

Also in this PR

  • session_reach.rs: the gated-list table and residual bullet now describe the keyless arm; the ordering census gains rows for /agent/cancel, /agent/continuation/abandon and authorize_turn_control on recover. /interrupt is not on the list — it reaches neither gate on either daemon — so it stays one of reply.rs's over-read controls, alongside two non-handlers on either side of the file's rows. Counts in the wiring census (privacy_guard_wiring.rs) are unchanged — the gate is reached through authorize_agent_control — and only its two what: description strings moved.
  • programmatic-session-access.md: the three routes honour the capability header on a keyless daemon; the troubleshooting entry no longer claims a capable caller never sees a "no user-action key" refusal (a subagent's session produces one).
  • browser-access.md: Stop and Stop-and-send work in an ordinary browser chat; steering does not, and the row says what happens to the text instead (queued, sent when the turn ends); a subagent's tab is read-only.
  • CLAUDE.md: an SD-11 bullet in "Browser access".
  • OpenAPI: the four 403 descriptions, regenerated (openapi.json + types.gen.ts); check-openapi-schema.sh passes.

Not in this PR

  • A subagent's tab in a browser still shows a live composer, steer and Stop that all refuse — a pre-existing SD-8 gap (/reply refuses there too). Flagged as a separate task.
  • The CLI's session cancel / attach steering still demand a user-action key client-side, so against a keyless daemon cancel refuses locally a request the daemon now admits. fix(cli): the terminal asks the daemon before it asks you for the user-action key (SD-11 follow-up) #261 is the follow-up.
  • An ordinary browser chat's steer control is still offered on a keyless daemon and still refuses; its text falls back to the send queue, so nothing is lost, but SD-8's rule would have it say so before the click. Recorded in SD-11's Not decided here.

Notes for review

🤖 Generated with Claude Code

…ey (SD-11)

`POST /agent/cancel`, `POST /interrupt`, `POST /agent/continuation/abandon`
and `POST /agent/continuation/recover` began with an unconditional
`is_user_action` check. A daemon started with no user-action key — every
`biorouter serve` daemon (SD-7), and a hand-run `biorouterd` — can never pass
it, so the browser's Stop button, mid-turn steering and Stop-and-Send answered
403 on every chat, public ones included (measured 2026-09-11 from the page).

On such a daemon the four routes now gate through `authorize_agent_control`,
the same call `/agent/stop` makes there: the reach rule, then no subagent's
chat. The refusal protected nothing — `/agent/stop` already cancels the same
turn for the same caller, `workspace_close { scope: "turn" }` does it for a
model, and `/reply` already puts the caller's text in front of the chat.

- A keyless steer is recorded unstamped, never `UserDirect`: nothing on that
  daemon can establish that a person typed it, and `/reply` records the same
  caller's message the same way.
- A daemon that holds a key (the desktop app's) is unchanged: proof only.
- A subagent's chat stays refused on a keyless daemon, and
  `refuse_subagent_unless_user` now says the daemon, not the caller, is what
  cannot prove a person acted (SD-8).

`tests/turn_control_no_user_key.rs` is its own binary because the digest is
a process-global OnceLock. All seven of its tests fail on the previous code;
one of them pins the premise (keyless `/agent/cancel` admits exactly what
keyless `/agent/stop` admits). The session_reach ordering census gains rows
for the three routes and new over-read controls in reply.rs, since
`interrupt` is no longer ungated.
Records the ruling the previous commit implements: on a daemon that holds
no user-action key, /agent/cancel, /interrupt and the two continuation
routes admit exactly the callers /agent/stop already admits there, a
subagent's chat excepted; a keyless steer is unstamped; a daemon that
holds a key is unchanged.

The record answers privacy-tiers §3.1's two questions in writing, and its
Q2 table records one finding rather than a guard: POST
/active_work/{id}/cancel cancels a subagent by registry id with no gate on
any daemon (the residual session_reach.rs already records).

- programmatic-session-access.md: the three routes honour the capability
  header on a keyless daemon; the "different instrument" row now says the
  proof applies on a daemon that holds a key; the troubleshooting entry no
  longer claims a capable caller never sees a "no user-action key" refusal
  (a subagent's session produces one).
- browser-access.md: Stop, steering and Stop-and-Send work in an ordinary
  browser chat; a subagent's tab is read-only and does not yet say so.
- CLAUDE.md: an SD-11 bullet in "Browser access".

Numbered SD-11 because open PRs #226 and #229 both claim SD-9.
One conflict, in docs/deployment/serve-decisions.md: main added SD-9 (the
launch token works until the daemon stops, #226) where this branch added
SD-11. Both kept, in numeric order. CLAUDE.md's "SD-1..SD-9" range now
names SD-11 too.

main changed none of reply.rs, agent.rs or session_reach.rs since this
branch's base; its auth.rs change (#231, the throttle) leaves
user_action_proof and its three verdicts untouched.
Broccolito added a commit that referenced this pull request Sep 11, 2026
serve-decisions.md conflicted: main's #226 added SD-9 (the launch token
works until the daemon stops) and made the same SD-1 route fix this branch
made. SD-1 takes main's wording; main's SD-9 stays SD-9; this branch's
record becomes SD-12, after it. SD-10 and SD-11 are claimed by open #237
and #240, so SD-12 duplicates no number on main or in any open PR.

Every reference to this branch's record moves with it (code comments, the
two OpenAPI 409 descriptions, the daemon's keyless warning, the docs and
their anchors, CLAUDE.md). References to main's SD-9 are untouched.
CLAUDE.md's '(SD-1..SD-9)' range is replaced by no number at all, since
every record that lands would make it stale.
…aemon

SD-11 admitted four turn-control routes to the reach gate on a daemon that
holds no user-action key, on one argument: the same caller already stops that
turn through `/agent/stop`, and already puts text in front of that chat's model
through `/reply`. For `/agent/cancel` and the two continuation routes that
holds — their keyless arm calls literally the function `/agent/stop` calls, and
`/agent/stop` is strictly more destructive, since it cancels the turn *and*
evicts the agent.

For `POST /interrupt` it is false. `/reply` takes the BR-33 single-turn lock and
answers 409 whenever a *different* turn is already running in that chat;
`/interrupt` answers 409 when none is. The preconditions are disjoint, so in the
exact state where a steer lands, the route said to dominate it is refused. What
was admitted is therefore new: attacker-chosen text injected into a turn already
in flight, without cancelling it, which the person watching sees as their own
turn changing direction. Cancel-then-reply, the nearest thing a caller holding
the daemon secret already has, kills the turn first and is visible.

So `/interrupt` moves off `authorize_turn_control` onto its own `authorize_steer`
and keeps the proof on both kinds of daemon, which is what `main` had. Its body
returns to `main`'s with it: a steer is only ever `Proven`, so it is stamped
`UserDirect` unconditionally again and `TurnControlAuthority` is gone.

The keyless refusal is a 403 carrying the new `STEER_NO_KEY` and is deliberately
NOT empty. `biorouter session attach` tells a daemon that wants the proof apart
from one that cannot check it by whether a turn-control 403 has a body
(`session_watch::key_verdict`, the CLI follow-up stacked on this branch): empty
means "this daemon holds a key", and the terminal prompts for one. On a keyless
daemon that prompt would ask a `biorouter serve` user for a credential that does
not exist and then report it as the wrong key. The sentence keeps the empty 403
meaning exactly what that reading needs.

The gate asks nothing about the chat, so the refusal is byte-for-byte identical
for a public chat, a private one, a subagent's and an id that does not exist —
which keeps a route no proof can satisfy from becoming a per-id oracle. It was
not: before this change a public chat answered 409 and a private one 403.

Fail-before, `--test turn_control_no_user_key`: 7 passed, 2 failed —
`a_keyless_daemon_refuses_the_steer_it_admits_the_stop_for` measured **202** with
the text on the running turn's queue, and
`a_keyless_steer_refusal_says_the_same_thing_about_every_chat` measured the
per-chat divergence. 9/9 after.

The census follows: `/interrupt` comes off `session_reach`'s gated list, because
it never reaches that gate on either daemon, and goes back to being one of the
file's negative controls. `privacy_guard_wiring`'s two `what:` strings are
description-only; no count moves.
`read_user_action_digest` has a 2-second bounded read of stdin, and a desktop
launcher slow enough to miss it leaves the *desktop* daemon keyless. That was
survivable while a keyless daemon simply refused every control that needed the
proof — Stop answered 403 and the user complained. With SD-11 admitting Stop,
Stop-and-Send and the continuation routes there, the same misconfiguration
degrades quietly to a weaker gate instead of visibly breaking, so it has to be
reported.

The read now returns `Result<[u8; 32], NoUserActionKey>` with four arms rather
than one undifferentiated `None`: `HandStarted` (stdin is a terminal),
`NoneOffered` (stdin closed empty — `biorouter serve`'s `Stdio::null()`, SD-7),
`TimedOut` (a writer held the pipe open and wrote nothing inside the bound) and
`Malformed` (not a 32-byte hex digest). The last two are launcher faults —
nothing this repo ships does either on purpose — so their warning says so and
says to restart, while the two expected ones deliberately do not, or the line
would cry wolf on every `serve` start. Every arm names both consequences: what
this daemon refuses, and what it now admits instead.

Classification is split out of the I/O into `classify_digest_line`, because four
arms that are never told apart are the defect this commit is fixing;
`keyless_report_tests` covers the mapping and asserts each warning names both
consequences.

⚠ The 2 s bound is UNCHANGED. Nothing measured says the desktop launcher misses
it, and what was missing was the report rather than the time.

On evidence: this one's fail-before is a measurement of the old code, not a red
test. The whole of what a keyless daemon said before this commit was

    "no user-action key on stdin: this daemon will refuse every request that
     raises a session's privacy capability, including one made by the person at
     the keyboard"

which names neither turn control nor which of the four causes happened. A red
test would have to reference the enum this commit introduces, so it could only
fail to compile; the new tests are drift guards and are not offered as
fail-before evidence.
… Err

Two clippy failures from the two commits above, appended rather than squashed
because those two are pushed and PRs are stacked on this branch.

`authorize_steer` returned `Result<(), Response>` and tripped
`clippy::result_large_err`: a `Response` is 128 bytes, and there is no success
value to carry either. It is now `steer_refusal(&HeaderMap) -> Option<Response>`
— the refusal, if there is one — which reads better at the one call site and
matches the `if let … { return refusal }` shape the other three routes already
use. `authorize_turn_control` keeps its `Result` only because it is `async`, so
the lint sees a future rather than the `Result`; the doc comment says so, or the
next person will "tidy" the two into one shape and reintroduce this.

And a test of the keyless-startup classifier sliced a `String` by byte range
(`clippy::string_slice`); it builds the wrong-length hex with `repeat` instead,
which also makes the too-long case explicit rather than a concatenation.

`cargo clippy -p biorouter-server --all-targets -- -D warnings` clean,
`--test turn_control_no_user_key` 9/9, `--bins` 590/590, `cargo fmt --check`
clean.
Broccolito added a commit that referenced this pull request Sep 12, 2026
…11, not the draft

`the_terminals_empty_steer_is_answered_by_the_gate_and_touches_nothing` failed on
the merged tree — `test (ubuntu-latest)` on #275, `turn_control_no_user_key.rs:625`,
`9 passed; 1 failed`. Neither side is broken; the test encodes a ruling that
changed under it, and only an integration binary could see it (CI runs
`tests/*.rs` on ubuntu only, so #261 and #240 were both green apart).

#261 was written when SD-11 read "Stop **and steering** work on a daemon with no
key": `/interrupt` would pass the reach gate, so an empty steer reached the text
check and answered **400** for a chat the gate admitted. SD-11 then settled
narrower — `/interrupt` asks for the proof on BOTH kinds of daemon, because
`/reply` is refused 409 by the BR-33 single-turn lock in exactly the state where
a steer lands, so admitting it would add silent mid-turn injection that nothing
else there can do. `reply::steer_refusal` therefore answers from the HEADERS,
before the body is parsed and before any chat is resolved: every row is a 403
carrying `STEER_NO_KEY`.

What the terminal needs is unchanged, and is what the test still asserts: never
the EMPTY 403 that means "this daemon holds a key" (which would send a `serve`
user to find one that does not exist), always a sentence it can print instead
(`key_verdict` → `Refused`), and the question touches neither the turn nor the
agent's queue — now trivially, since nothing is reached. The five rows are kept
rather than collapsed, so a change that admits the steer for SOME chat fails
here instead of passing quietly.

Also sharpened `steer_gate_question`'s doc in the CLI: it listed all three
answer shapes correctly but let a reader expect the 400 from a keyless daemon,
where it is now unreachable.
@Broccolito
Broccolito merged commit 360a4e3 into main Sep 12, 2026
22 of 24 checks passed
@Broccolito
Broccolito deleted the claude/nostalgic-fermi-6569af branch September 12, 2026 08:33
Broccolito added a commit that referenced this pull request Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant