The first admission of TTY-gated actions into the browser. Policy decided; this is Tier 1 only.
Supersedes the open question in #436 ("D3 — A GUI human gate"), which was closed as completed with nothing built. keel serve's own docstring already promised this: "the browser gets its own gate in a later change, and it will be a gate rather than a bypass."
Scope: three actions, and only these three
| action |
CLI |
rail |
what it releases |
resume |
keel resume |
kill-switch |
trading resumes after the kill-switch halted it |
resume-entries |
keel resume-entries |
16 |
new entries resume after the consecutive-loss halt |
reset-hwm |
keel reset-hwm |
11 |
the drawdown breaker is re-seeded against current equity |
Explicitly not in Tier 1: autonomy on, record-flow, the three attestations, update.
Two corrections to the action list as briefed
1. Engaging the kill switch is not a gated action and must not become one. keel kill calls engage_kill_switch with no gate — its docstring says "Always allowed (safe action)" — and it is absent from the nine in keel/capabilities.py. Only the disengage (keel resume) is gated. Putting a confirmation ceremony in front of stopping the engine would make the stop slower than the start, which is the wrong direction for the one control an operator reaches for in a hurry. Halt should be a plain button, ungated, and should ship in Tier 1 alongside these three.
2. All three Tier 1 actions increase capability; none reduces risk. They release a brake that a rail applied. The phasing decision stands — these are the right three to do first — but the rationale should be stated as what it is, because the issue text will be read later as justification:
Tier 1 restores normal operation after a halt the operator has reviewed. The capability it grants is bounded (the rails that fired can fire again) and instantly reversible (keel kill). Tier 2 grants standing capability — autonomy converts every later prompt into an automatic yes — or rebases the drawdown baseline itself, which capabilities.py calls out as "the one direction a circuit breaker must not fail in".
reset-hwm is the closest of the three to Tier 2 on that reading, since it touches the same baseline record-flow does. It stays in Tier 1, but it should be the last of the three built and should carry the strongest confirmation phrase.
The security invariant: loopback only
Every state-changing POST /api/gates/* refuses unless the request originated on loopback:
if not is_loopback_request(request):
return 403, {"error": "GATED_ACTION_LOOPBACK_ONLY",
"message": "Gated actions are restricted to local loopback sessions."}
The refusal is on the connection's peer address, never on a header — X-Forwarded-For and Host are attacker-controlled through a tunnel, which is the whole point. A deployment started with --host 0.0.0.0 or any --external-host therefore serves the read surface and refuses every gated action, and says so on the page rather than only in the response.
This holds until #648 (remote-exposure security pass) and #656 (device pairing) land. It is not a placeholder to be relaxed quietly: relaxing it is those issues' job and should be their PR.
The confirmation ceremony
- A modal requiring the operator to type an exact phrase —
RESUME TRADING, CLEAR STREAK HALT, RESET HIGH WATER MARK. Typed, not clicked: it is the browser's equivalent of the terminal ceremony, and the console already types back the asset code for attestations.
POST, a valid session token, and X-Keel-Client: 1 — a header a plain HTML form cannot set, which is what _api_client_header_ok already relies on for the setup write surface.
- The write token minted into the document that carries the action, as
/api/setup already does; never into a GET that could be cached, logged or pasted.
What must not change
_is_interactive stays exactly as it is, and the CLI path keeps requiring a real TTY. This is a second gate with the same fail-closed property satisfied by different evidence — never an override, never an env var, never a --yes. Any of those would be settable from cron and would dissolve every rail built on the first gate.
Acceptance
- A request from
curl with no session, from a scheduled job, or from any non-loopback peer is refused for all three actions, with the same message shape as the TTY refusal.
- A request with a valid session but the wrong typed phrase is refused.
keel/capabilities.py gains a second Gate and the three rows move to it, so keel capabilities and the Gates page show which gate covers what. The audit surface is the point: an auditor must be able to see, in one place, that six actions remain TTY-only.
- A test asserts no new code path reaches any of the nine actions without passing one of the two gates.
- A test asserts the six Tier 2 actions are not reachable from the web package at all — the existing disjointness test over
keel.commands.setup.ACTIONS extended, not replaced.
The first admission of TTY-gated actions into the browser. Policy decided; this is Tier 1 only.
Supersedes the open question in #436 ("D3 — A GUI human gate"), which was closed as completed with nothing built.
keel serve's own docstring already promised this: "the browser gets its own gate in a later change, and it will be a gate rather than a bypass."Scope: three actions, and only these three
resumekeel resumeresume-entrieskeel resume-entriesreset-hwmkeel reset-hwmExplicitly not in Tier 1:
autonomy on,record-flow, the three attestations,update.Two corrections to the action list as briefed
1. Engaging the kill switch is not a gated action and must not become one.
keel killcallsengage_kill_switchwith no gate — its docstring says "Always allowed (safe action)" — and it is absent from the nine inkeel/capabilities.py. Only the disengage (keel resume) is gated. Putting a confirmation ceremony in front of stopping the engine would make the stop slower than the start, which is the wrong direction for the one control an operator reaches for in a hurry. Halt should be a plain button, ungated, and should ship in Tier 1 alongside these three.2. All three Tier 1 actions increase capability; none reduces risk. They release a brake that a rail applied. The phasing decision stands — these are the right three to do first — but the rationale should be stated as what it is, because the issue text will be read later as justification:
reset-hwmis the closest of the three to Tier 2 on that reading, since it touches the same baselinerecord-flowdoes. It stays in Tier 1, but it should be the last of the three built and should carry the strongest confirmation phrase.The security invariant: loopback only
Every state-changing
POST /api/gates/*refuses unless the request originated on loopback:The refusal is on the connection's peer address, never on a header —
X-Forwarded-ForandHostare attacker-controlled through a tunnel, which is the whole point. A deployment started with--host 0.0.0.0or any--external-hosttherefore serves the read surface and refuses every gated action, and says so on the page rather than only in the response.This holds until #648 (remote-exposure security pass) and #656 (device pairing) land. It is not a placeholder to be relaxed quietly: relaxing it is those issues' job and should be their PR.
The confirmation ceremony
RESUME TRADING,CLEAR STREAK HALT,RESET HIGH WATER MARK. Typed, not clicked: it is the browser's equivalent of the terminal ceremony, and the console already types back the asset code for attestations.POST, a valid session token, andX-Keel-Client: 1— a header a plain HTML form cannot set, which is what_api_client_header_okalready relies on for the setup write surface./api/setupalready does; never into a GET that could be cached, logged or pasted.What must not change
_is_interactivestays exactly as it is, and the CLI path keeps requiring a real TTY. This is a second gate with the same fail-closed property satisfied by different evidence — never an override, never an env var, never a--yes. Any of those would be settable from cron and would dissolve every rail built on the first gate.Acceptance
curlwith no session, from a scheduled job, or from any non-loopback peer is refused for all three actions, with the same message shape as the TTY refusal.keel/capabilities.pygains a secondGateand the three rows move to it, sokeel capabilitiesand the Gates page show which gate covers what. The audit surface is the point: an auditor must be able to see, in one place, that six actions remain TTY-only.keel.commands.setup.ACTIONSextended, not replaced.