Repository navigation
Halt and keel serve hardening: drain every POST, HALT on a failed Pulse, serve keeps --external-host (#955, #958, #971) - #977
Merged
Conversation
…nfig field (#955, #971) #955: do_POST drains the request body in a finally (bounded by _drain_request_body, which closes instead of reading an oversized or unreadable length), so a halt sent with a body no longer leaves bytes that poison the next request on a kept-alive connection. Server tests pin that the halt reads and validates no body. #971: serve() rebuilt ServeConfig by hand after binding and dropped external_hosts and started_at. It now uses dataclasses.replace(cfg, port=...). Tests drive the real serve(): a declared external Host is admitted, the remote session lifetime is enforced, and a release on a declared-remote deployment is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) A failed /api/pulse (500, or a refusal from its read) now carries halt: {halt_token, resume_command} on the error envelope. The halt token is security.halt_token (never the gates token), and the kill switch is read on its own through pulse.halt_resume_command, apart from gather_pulse. A switch that cannot be read shows the button, never the HALTED line. Every other failure, and every pre-admission refusal, carries halt: null, so the key set stays constant. Client: readingFrom forwards halt off a failure document. When Pulse fails and carries it, paint hands it to rememberHaltToken and renders pulseFailedView, which is the same haltControl plus the error. No extra confirm and no phrase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#976) setup.inspect opened the database mode=ro. A WAL file whose -wal/-shm are gone (a checkpointing last close, any copied backup) cannot be opened mode=ro, so a running deployment read as "keel isn't running here" and the stopped view hid HALT. New keel.data.db.connect_read_only: mode=rw (creates no database file) plus PRAGMA query_only = ON, so SQLite refuses every write on the connection. Every console read goes through it: api.open_repo (previously a read-WRITE connect), setup.inspect, setup._sleeve_sell_kind, venue_readiness._read_only_trade_scope. The halt and the gated releases keep a writable opener, api.open_repo_to_write. Tests on a scratch WAL copy with its sidecars removed: inspect, Pulse (+ HALT 200), the promote lookup and venue scope all read it; a row, schema and header write through open_repo are refused; every connection opened by a sweep of all JSON read routes is query_only; the halt's own connection is not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Linux CI's SQLite opens a sidecar-less WAL database under mode=ro, so the fixture's built-in reproduction failed every test there. The reproduction is now its own test, skipped (and saying why) on a build that does not have the bug; the read and read-only tests run on every build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/#971 tests One parser (_declared_body_length) for the drain and _read_json_object: Transfer-Encoding, a non-digit/duplicate Content-Length close the connection. Halt still engages first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er; read-path pin connect_read_only disables checkpoint-on-close, builds its URI with _read_only_uri (UNC, drive, reserved characters), and the docstring states what read-only rests on. AST pin over the console read path forbids query_only/attach/wal_checkpoint/vacuum. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
…den the read-only SQL pin to the Repository Bound the Content-Length digit string before int() (a 5000-digit value raised out of the handler), treat stdlib header parse defects as untrustworthy framing (a space before the colon hid the length and desynchronised the connection), and walk repository.py/audit.py/db.py in the banned-SQL AST pin with a function-scoped allow-list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r-shaped POSTs keep the connection #977 round 3: the AST pin now scans every non-docstring string constant (variable- and constant-held SQL included) through one shared _forbidden_sql used by the pin and its self-test; add real-browser header halt and setup POST keep-alive cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… round 4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r arrives (review round 5) A halt that drained or read its body first passed every test; this one declares 100 bytes, sends none, and needs the 200 and the engaged switch while the server is still waiting. Kills both mutants (drain-first, read-first). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
|
Gatekeeper: READY at This PR now also fixes #976. Console reads open the database with Review: 5 rounds, each by a fresh reviewer. Findings per round: 4, 4, 3, 2, 1. All are fixed and test-first, with each mutant applied and then restored from a saved copy.
Checks on 64c3a68:
Browser check (own
Filed:
|
eaitbrahim
added a commit
that referenced
this pull request
Oct 12, 2026
#978's prose test counted do_POST's refusing guards; #977 made do_POST a drain wrapper around _post, so the walk never reached _gated_action and main went red. The count is unchanged (four + two + the phrase = seven), so only the test moves. Mutation-checked: dropping the Sec-Fetch-Site guard from _post fails it. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #955
Closes #958
Closes #971
Closes #976
Hardens the halt and
keel serve. There are three server-side fixes, plus a client view for a Pulse report that fails.#955: every POST ends at a request boundary
do_POSTnow runs its routing in_post()and calls_drain_request_body()in afinally. The halt answers without reading a body, so before this a body sent anyway stayed on a kept-alive socket and the next request was parsed as{}GET …(501).Content-Lengthcloses the connection instead of reading. Why drain: the console's next read rides the same connection, and the test the issue asks for (a GET on the same socket answers 200) requires it. Cost if wrong: a client that declares a body and never sends it stalls only its own connection._drain_request_bodynow setsbody_consumed, so the closing drain never reads twice after_refusealready drained.{}, a 2-byte body, non-JSON, or nested JSON each answers 200 with the switch engaged, and then a GET on the same socket answers 200. An oversized body still halts. Together these pin server-side that the halt reads and validates no body: the console: HALT gets its own token; the release token is served nowhere (#968, #969) #970 review's mutant (halt calls_read_json_objectand answers 400) is now killed.#958: HALT survives a Pulse that cannot be built
/api/pulse's error envelope carrieshalt: {halt_token, resume_command}. The token issecurity.halt_token, never the gates token. The kill switch is read on its own path (api.pulse_halt→pulse.halt_resume_command), not throughgather_pulse. Every other failure, and every pre-admission refusal, carrieshalt: null, so the key set stays constant.readingFromforwardshaltoff a failure document. Inpaint, a Pulse failure that carrieshaltgoes torememberHaltToken(the one holder) and then to the newpulseFailedView, which is the samehaltControlfollowed by the error. There is no extra confirm and no phrase.gather_pulseforced to raise, the response carries the halt token, the resume command follows the switch, and the served token halts (200). An unreadable switch shows the button. With every route's read made to raise, only Pulse carrieshalt. A refused Pulse carrieshalt: null. Structural client pins cover: Reading literals pairhaltwithattestations, only the failure arm readsdocument_.halt, the order of the paint branches, and thatpulseFailedViewishaltControl({ halt: reading.halt })plus the error, with no report section.#971:
serve()keeps every config fieldrunning = replace(cfg, port=int(bound_port))replaces the hand-listed constructor, which droppedexternal_hostsandstarted_at.serve(), run in a thread withbuild_serverwrapped only to capture the server:Review hardening (rounds 1-5)
_declared_body_length, serves the drain,_read_json_objectand_refuse. It treats the following as untrustworthy: anyTransfer-Encoding, more than oneContent-Length, a value that is not plain digits (-2,+2,1_0), more than 18 digits, and a header block the stdlib could not parse (for exampleContent-Length : 2). In each case it closes the connection instead of draining. The halt still engages and answers 200 first. Other POSTs get the existing 400 refusal. Honest shapes stay on keep-alive: no length,0, lowercase names, leading zeros, and the full header sets a browser sends with the halt POST and a JSON setup POST.connect_read_onlysetsSQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, so a console read that closes last leaves the trading database file byte-identical. The WAL then persists until the agent's own next write checkpoints it.keel update's backup API reads the WAL, so backups are unaffected.query_onlyis per-connection and reversible, and it does not blockATTACHor checkpoint pragmas. A structural (AST) pin,_forbidden_sql, therefore scans every non-docstring string constant, f-string parts included, in every console reader module pluskeel/data/repository.py,audit.pyanddb.py. It bansquery_only,wal_checkpoint,vacuumand a whole-wordattach. Its floors are at least 50 SQL calls and 1500 strings, so it can't pass vacuously. Its only allow-listed statement isconnect_read_only's ownPRAGMA query_only = ON. The pin's self-test runs that same function on planted sources. Amultipart/*ormessage/*POST also closes after it is answered, by design, because the console sends only JSON or no body._read_only_uri. It handles POSIX paths, Windows drive letters and UNC paths, and percent-encodes#,?,%and spaces.--external-hostconsole refuses everything after 12 h, HALT included, until it is restarted.Verification (final head)
ruff check keel tests packagesandruff format --check .are clean. Baremypyis clean (509 files).node --checkpasses on everystatic/js/*.jsand onsw.js.pytest: 8369 passed, 3 skipped, run with
logs/keel.logremoved first.Mutation:
Each mutant was applied with its sha256 checked, then restored from a saved copy.
Browser check: Playwright against my own
keel serveon :8911, with a scratch database and the service worker unregistered first./api/gates/haltanswers 200, and the HALTED line appears with no button./api/pulseanswers 500 withhalt: {halt_token, resume_command: ""}. The page shows the "Pulse" heading, the HALT button and the error. HALT asks for one confirm, the switch engages, and the repaint shows the HALTED line.-wal/-shmremoved, so amode=roopen fails ("unable to open database file"). The full Pulse with its HALT button still shows, not the stopped view./api/pulse.CI is green on the final head.
#976: console reads open a sidecar-less WAL database, and still cannot write
-wal/-shmare gone (a checkpointing last close, any copied backup) cannot be openedmode=ro.setup.inspectopened it that way, so a running deployment read as "keel isn't running here", and the stopped view hides HALT.keel.data.db.connect_read_only:mode=rw(it never creates a database file) plusPRAGMA query_only = ONset before any read, so SQLite refuses every write on the connection. Every console read goes through it:api.open_repo(which was a read-WRITEconnectuntil now),setup.inspect,setup._sleeve_sell_kindandvenue_readiness._read_only_trade_scope. The halt and the gated releases use their own writable opener,api.open_repo_to_write.query_only, not amode=roretry fallback. A fallback would need a read-write open anyway, andquery_onlyputs the read-only guarantee in SQLite on every read rather than only on the retry. Cost if wrong: a console read may re-create the-wal/-shmsidecars, which hold no data.mode=rofails there):inspect, Pulse, the promote lookup and the venue scope all read it, and Pulse's HALT answers 200.open_repoare each refused, and the switch is unchanged.query_only. The halt's own connection is not.🤖 Generated with Claude Code