fix(cli): refuse a zero-byte audit database, and split verified-nothing out of exit 0 (BACKLOG #1669) - #1156
Conversation
…ing" out of exit 0 (BACKLOG #1669) The guard on audit-verify, audit-anchor and rekey-audit only asked whether the --db path existed. A zero-byte file exists and is a valid, empty SQLite database, so it walked straight past, open_store migrated a schema into the file that was meant to be the evidence, and the command reported a clean chain of nothing with exit 0. All three now probe the path over a read-only SQLite handle before the store opens -- mode=ro can neither create nor migrate -- and exit 2 when there is no audit_log table. audit-verify additionally returns exit 3 for a clean walk over an empty log, with a new --allow-empty turning that back into 0. Exit 1 stays a broken chain. audit-anchor keeps exit 0 on a real store whose log is empty.
|
LANDER review. Read against head 44492ad, and I ran the probe rather than reasoning about it. Merge. THE DEFECT IS THE PUREST INSTANCE OF THIS REPOSITORY'S RECURRING FAILURE I HAVE SEEN ALL WEEK. The second shape is worse than the typo. A zero-byte file -- what a I MEASURED EVERY LOAD-BEARING CLAIM, WITH CONTROLS THAT DISCRIMINATE. Running the diff's own probe form: The last line is the safety property the whole design rests on, and it holds: a THE So the obvious f-string would have made this guard refuse a real audit log because of a character in its directory name -- on Windows, where this product runs, a realistic path. A guard that reds on correct evidence gets switched off by whoever hits it. That one line is doing more work than it looks like. SPLITTING "VERIFIED NOTHING" ONTO ITS OWN EXIT CODE IS THE RIGHT CALL AND THE REASONING IS EXACT. This command already spends 1 on a BROKEN CHAIN and 2 on "could not start". Folding an empty log into either would tell a compliance job that finding nothing was detected tamper, or was its own misconfiguration. 3 says "ran, and found nothing to verify" -- a third answer for a third state, rather than an existing code borrowed to carry it. Two details raise this above a competent fix:
THE ASYMMETRY BETWEEN THE TWO CALLERS IS DELIBERATE AND CORRECTLY ARGUED. The Limiting the probe to SQLite is right and the bound is stated rather than assumed: a server backend's connection string is not a file and cannot be created by connecting to it. ONE THING FOR WHOEVER WRITES THE RELEASE NOTE, and the CHANGELOG entry already covers it: exit 3 is NEW, so any existing job running against a legitimately empty log flips from 0 to 3 on upgrade. That is the intended behaviour -- an empty log SHOULD stop being silently indistinguishable from a pass -- but it is a change someone's cron will feel, and Verdict: merge. |
|
LANDER: withdrawn from the merge queue on an ORDERING CONSTRAINT, not on the review. My verdict above stands unchanged and this PR needs no rework yet. MEASURED, NOT PREDICTED. Two controls, because three PRs touch PR 1152 is at queue position 1 and this was at position 9. When 1152 lands, this goes DIRTY. Left in place, the entry would have been evicted at that moment -- and an eviction recreates the queue branches of every entry building beside it, discarding runs that had already gone green. I measured that earlier tonight on #1167's eviction: #1152 and #1159 each lost a completed WHAT IS NEEDED BEFORE THIS GOES BACK IN. After 1152 merges, update this branch and re-derive the overlapping region against the merged tree. Do NOT re-apply this side's hunk and do not take either side wholesale: both edits are real. 1152 rewrites store-open failure handling in I will handle the update and re-enqueue once 1152 lands, so there is nothing for the author to do unless the re-derivation turns out to need a judgement about the code rather than about placement -- in which case I will stop and ask rather than resolve it myself. ONE PIECE OF CONTEXT SO THE DELAY IS NOT MISREAD AS A PROBLEM WITH THIS WORK: the repository's runner pool is at zero right now -- two consecutive readings of |
|
LANDER: the forecast conflict has come due. #1152 landed as IT IS ONE HUNK, SEVEN LINES, and I confirmed the rest of the file auto-merges. Both sides are real edits to the same three lines. #1152 wrapped the call in a Nothing else in the hunk changes. The ONE QUESTION I AM DELIBERATELY NOT ANSWERING, and it is the reason I am handing this back rather than pushing it myself. This PR's own So on the SQLite path, the probe now fires first and #1152's Worth checking the same three call sites while you are in there: MY MERGE VERDICT ABOVE STANDS UNCHANGED. The review was of the diff's substance -- the read-only probe that cannot write to the evidence it checks, the exit-3 split, the integer row count instead of scraping "verified 0 " out of a message -- and none of that is affected by a seven-line merge. This is purely mechanical work standing between a reviewed PR and the queue. WHAT I WILL DO once the branch is updated: verify the merge by RECONSTRUCTION rather than by reading the diff, re-run the pre-enqueue audit, and enqueue it. No re-review needed. TWO THINGS TO KNOW BEFORE YOU PUSH: A flaky test is now on The runner pool is coming out of an infrastructure incident (in_progress=3 against queued=33, up from 1 against 45), so expect checks to sit queued a while. |
…uards The only conflicting file was messagefoundry/__main__.py, where #1670 (PR 1152) landed _emit_store_open_error and routed the same three audit subcommands through it. The two guards close different doors and both are kept. #1669 refuses a path that IS a SQLite database but carries no audit_log table, which used to pass and report a clean chain of nothing. #1670 refuses a path that is not a database at all, which used to hang and print a traceback. _audit_verify now runs its three-tuple unpack inside #1670's try/except; _audit_anchor and _rekey_audit auto-merged with both. Where the two overlap, on a path SQLite cannot read, the #1669 probe reaches it first and hands the exception to _emit_store_open_error, so one condition still produces one line and #1670's own child-interpreter test keeps passing. That also gives audit-anchor --json back the JSON error body #1670 built for it, via a new as_json passthrough on the probe. sqlite3 is now imported at module scope by #1670, so the probe's local import of it and of Path is dropped rather than shadowing the same objects. The SECURITY.md exit-code paragraph auto-merged; it is amended here to name the not-a-database case among the exit-2 causes and to say that 1 is never spent on any of them.
|
Merged origin/main (21 commits) and resolved the conflict. Pushed as d5655a9; What conflicted. One file, Both guards are kept, because they close different doors.
Exit-code contract re-verified by execution against the real CLI on the merged tree, one child process per cell:
One cell differs from the repair brief's table, and it differs on Checks run locally: One doc amendment. The SECURITY.md exit-code paragraph auto-merged but listed only three exit-2 causes. It now names the not-a-database case among them as an "at least" list, and says that 1 is never spent on any of them. |
BACKLOG #1669 --
audit-verifyandaudit-anchoraccept a zero-byte database, write a schema into it, and exit 0 on verified-nothing.The defect, reproduced on this branch's base
The existing M-31 guard only asks whether the path EXISTS. A zero-byte file exists and is a valid, empty SQLite database -- what a
touchin an install script, a failed copy or a log-rotation mistake leaves behind -- so it walked past the guard,open_storemigrated 372,736 bytes of schema into the file that was meant to be the evidence, and the command reported a clean chain of nothing. A scheduled compliance job reads the exit code and nothing else.What changed
One helper,
_refuse_a_store_that_is_not_an_audit_log, used by_audit_verify,_audit_anchorand_rekey_audit. It absorbs the existing missing-file guard (same messages, same exit 2) and adds the table probe: a stdlibsqlite3.connect("file:...?mode=ro", uri=True)handle queried againstsqlite_masterforaudit_log.mode=rocan neither create the file nor migrate it, which is the "must not write to the evidence" half of the row -- measured, the zero-byte file stays at 0 bytes and no WAL or SHM sibling is left behind. Only SQLite is probed; a server backend returns immediately, so the Postgres and SQL Server CLI tests stay inert as their docstrings claim.The narrow form was taken deliberately over the row's step 1 (
readonly=Truethreaded throughopen_store): that is a store-protocol change across four files and three backends, andmode=rodelivers the same guarantee for this path.--allow-emptyis new on theaudit-verifysubparser, and the empty-log count now comes fromstore.audit_anchor()rather than from matching"verified 0 "in a human-readable message.Exit codes, per subcommand
audit_logtableaudit-verify--allow-emptyaudit-anchorrekey-auditWhy exit 3, and not 1 or 2.
audit-verifyalready spends 1 on a BROKEN CHAIN, so a compliance job keying on the exit code would read an empty log as a detected tamper -- a page for an event that never happened. Exit 2 is "could not open the store" and is taken by open PR 1152. 3 is the first free code and says "ran, and found nothing to verify", which is neither a pass nor an alarm.One decision on top of that, and it is mine to flag. An explicit
--expected-anchor 0:also yields exit 0, alongside--allow-empty. On a chain that verified clean with zero rows,0:is the only anchor that could have matched, so passing it IS an assertion that the log holds nothing -- and it is a checked one, unlike the flag. Without this,test_expected_anchor_accepts_the_empty_log_anchorwould have gone red, and the #328 round-trip it pins ("a fresh instance is the one state that cannot be anchored") would have become a regression. I preferred keeping that behaviour to editing the test's expectation.Why
audit-anchoris ruled differently from its verify twin. Anchoring a fresh store as0:is a documented workflow that_ANCHOR_FORMand_parse_anchorsupport. It keeps exit 0 on a real store whose log is legitimately empty, and refuses only the non-audit-database paths.Side effect worth naming, and NOT a claim to have closed #1670
Because the probe runs before the store opens, a
--dbthat is not a database at all is now refused at exit 2 on these three subcommands instead of hanging. That is PR 1152's finding, reached from a different direction; #1670 owns the general case across the other store-opening subcommands and theMessageStore.openleak itself. The code comment says so.Expected conflict with PR 1152
Open PR 1152 (BACKLOG #1670,
claude/builder-1670-store-open-leak) edits_audit_verify,_audit_anchorand_rekey_auditroughly ten lines from this work, adding_emit_store_open_errorand asqlite3.DatabaseErrorclause around eachasyncio.run(run()). Its diff was read before this one was written. The two close different doors and neither subsumes the other. Whichever lands second rebases; the merge is positional, not semantic.Checks
Run in this worktree, on its own venv, all green:
ruff check .andruff format --check .mypy messagefoundry(strict) -- 274 source filespytest tests/test_audit_integrity.py tests/test_cli.py tests/test_cp1252_console_safety.py tests/test_off_loopback_runbook.py-- 188 passed, 1 skippedpytestover the doc guard lane (test_doc_guards_lane,test_doc_ref_handle,test_docs_cite_no_refused_config_keys,test_docs_runbooks,test_docs_security_pathways,test_operator_docs_no_warning_sign,test_link_resolution) -- 384 passedSkipped: the full suite, which does not finish under fleet contention.
CI legs that must be read, because a local pytest SILENTLY SKIPS them and a fully skipped suite reports green:
postgres storeandsql server (store + connector). The exit-3 change is backend-agnostic. Both legs'audit-verifytests seed rows before every call, so none reaches the empty-log path -- read against the code, not run here.Docs
docs/SECURITY.md, "Tamper-evidence" -- the four exit codes and what a scheduled job should do with each. This is the source of record for the exit-code contract.docs/EARLY-ADOPTER-GUIDE.md-- a checklist item telling the operator to branch on the code rather than on nonzero, linking to SECURITY.md rather than restating it.CHANGELOG.md-- Unreleased / Fixed.docs/CONFIGURATION.mdis left alone: it already carries the anchor semantics and SECURITY.md already links to it.Ledger banner for the Lander
Open question for the next brief
None blocking. One judgement call is flagged above -- accepting
--expected-anchor 0:as an empty-log assertion alongside--allow-empty. If the owner wants--allow-emptyto be the sole escape,test_expected_anchor_accepts_the_empty_log_anchorneeds its expectation moved from 0 to 3 and the #328 round-trip re-argued. I judged preserving the shipped behaviour the safer default.