Repository navigation
Credential boot receipt: value-free record of the key store at every user-manager start, oneshot unit template, restart-check runbook - #495
Merged
Conversation
… installed guard against its pin --client-guards gains one boolean: the sha256 of the installed user-scope guard (<claude dir>/hooks/secret_path_guard.py) compared with the guard's line in the checkout's adoption/hooks/claude/SHA256SUMS, whose paths are relative to that file as tools/adoption/install_claude_profile.py reads them. The guard is the only file read: it is opened with O_NOFOLLOW and hashed only when fstat says it is a regular file, so a link planted in its place never makes the check open another file, a store file included. A missing pin line, a missing guard, a link or a directory is false. No digest is returned or printed. The boot receipt of the next commit reuses guard_matches_pin() and claude_config_dir(). Failing first: test_client_guards_reports_booleans_only now expects the key (false for its stand-in bytes) and failed on the old dict; the new test_guard_pin_check_hashes_only_the_installed_guard failed with KeyError. It covers not installed, the pinned copy, one byte more, a link to the pinned bytes, a directory, a link to a store file (os.open, io.open and builtins.open watched: no store path opened), a synthetic checkout whose pin line decides, a pin line for another file, no pin file, and the CLI (--json and text) printing the boolean and never the digest. Checked: python3 -m unittest tests.test_credential_status, 38 tests OK. A mutation that drops O_NOFOLLOW fails the new test (True is not False on the link to the pinned bytes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… user-manager start, and compare `record` writes <XDG_STATE_HOME>/native-agent-stack/credential-boot/ <UTC stamp>-<boot id prefix>.json and prints one line of counts. The receipt holds the boot id, uptime, the systemd version, linger (loginctl show-user --property=Linger), the checkout revision, credential_status.py's rows reduced to ids, statuses, store kinds, path templates, states, findings and warnings, each file row's lstat mode, size and mtime_ns, the checker's coverage names, the names of this uid's live native-agent-stack:* keys (/proc/keys; payloads and lengths dropped) and claude_user_guard_matches_pin. No store file is opened, followed or hashed. The file is written as a 0600 mkstemp dot-file and linked into place, so no receipt is ever replaced and a killed writer leaves only a dot-file; the directory is created 0700, refused unless it is a real directory of this user (O_NOFOLLOW) and tightened through that handle. `compare` diffs the latest two receipts: states, and the names of changed fingerprint fields, never their values (a one-variable file's size is its value's length). Exit 1 when a required or optional file row that was ok is no longer ok or has no row; 2 without a readable receipt; 0 otherwise, and a single receipt is reported as the baseline. Started without -I, the tool re-executes itself with -I, as tools/credentials/set_credential.py does. Failing first: the new test module failed to import before the tool existed; with the tool written but no -I re-execution, test_a_plain_start_reruns_isolated failed (the planted tempfile module on PYTHONPATH was imported). The tests, all on synthetic stores (HOME, XDG_CONFIG_HOME and XDG_STATE_HOME inside the test's temporary directory, a /proc/keys fixture): an explicit allowlist of every receipt key with fingerprints exactly mode, size and mtime_ns; canaries in nine store files absent from the receipt, the printed line, stderr and compare's output as raw, base64, hex, percent-encoded and md5/sha1/sha256/sha512/ blake2b forms, and no host path; the killed writer's dot-file named in the undeclared-file warning; no store file or link target opened (os.open, io.open and builtins.open watched); 0600 and 0700 under a zero umask, no replacement, a symlinked receipt directory refused; ok to missing, ok to unsafe and a vanished row flagged, a paid key's row not gated; a restart compared by name and state; the baseline and unreadable receipts. Checked: python3 -m unittest tests.test_credential_boot_receipt, 8 tests OK under python3 3.13 and /usr/bin/python3 3.12 (the unit's interpreter). Twelve mutations are each caught by the intended test: a content hash in the fingerprint, compare printing values, replacing a receipt, a vanished row not flagged, the expanded path for the template, a loose directory left loose, a group-readable receipt, a paid key gated, stat for lstat, no re-execution, the full receipt path in the line, a symlinked directory followed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ager start, rendered to the live clone The template runs `/usr/bin/python3 -I @repository@/scripts/ credential_boot_receipt.py record` as a Type=oneshot wanted by default.target: with linger on, logind starts the user manager at boot, so it runs at every user-manager start (every WSL distro start) with no login and no unlock. It has no Environment=, EnvironmentFile= or LoadCredential= line. UMask=0077 and NoNewPrivileges=true follow this directory's other oneshots; TimeoutStartSec=120 bounds a stuck git, systemctl or loginctl call, since a oneshot has no start timeout by default and default.target is ordered after the units it wants. Render path: the repository's existing @repository@ convention (token-report-refresh.service, codex-broker-reaper.service), substituted by hand before installing; no render tool exists and none was added. The header gives the one command, `sed 's#@repository@#%h/code/ native-agent-stack-live#g' ... > ~/.config/systemd/user/ credential-boot-receipt.service`, the value the workstation's installed units use; never the working checkout or a build worktree. Failing first: the four new BootReceiptUnitTemplateTests failed with FileNotFoundError before the template existed. They pin the exact directive list; check ExecStart (/usr/bin/python3 -I, the script under @repository@, record) and the absence of every variable or credential directive with helpers that planted violations fail (Environment=, EnvironmentFile=, LoadCredential=, LoadCredentialEncrypted=, SetCredential=, ImportCredential=, PassEnvironment=; ExecStart without -I, on the working checkout, running compare, or a bare python3); and run the header's pinned sed command without a shell, whose output must equal the pinned rendering with the live clone in ExecStart. Checked: python3 -m unittest tests.test_credential_boot_receipt, 12 tests OK under python3 3.13 and /usr/bin/python3 3.12; `systemd-analyze --user verify` on a rendered copy in a scratch directory exits 0 with no output (nothing installed, loaded or started). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent form, the boot receipt section
docs/secret-storage.md gains one section, "Restart check (2026-09-29)":
what a receipt holds and where, that compare's output (never a raw
receipt, whose size field gives a one-variable file's value length) is
what gets published, and the design's sequence steps 3 to 9 as commands
with expected results. Before the restart the coordinator fast-forwards
the live clone (fetch, merge --ff-only), renders the unit with the
template header's sed command, verifies, daemon-reloads, enables,
smoke-starts it (R0) and runs compare (baseline, exit 0); the restart is
the user's own `wsl --shutdown` from Windows, never an agent's; after it,
compare is expected to exit 0 with boot_id changed, every file row
ok -> ok with the same fingerprint, tavily from its file, the keyring
names gone and the guard pin true -> true. The paper units keep
--env-file and are untouched. The canary steps name no file of a later
change.
adoption/platforms/macos-arm64.md gains one paragraph in "launchd
services": the same tool as a RunAtLoad LaunchAgent after the FileVault
login, documented and not run, with null boot facts there. It carries
"added after `v2026.09.26.2`", which the docs-consistency check requires:
removing it in memory makes that check name the launchd services unit.
docs/decisions/2026-09-29-key-management.md gains one appended section,
"Boot receipt (2026-09-29)": the decision, the leak-path review's
constraints, alternatives, evidence class and overturn conditions. Nothing
else in the record changed.
Failing first: the two new RestartCheckRunbookTests failed ("no Restart
check section") before the section existed. They require every fenced
command in the section to pass scripts/hooks/secret_path_guard.py's
check(), the runbook to install exactly the unit header's pinned sed
render and run the documented systemctl steps and compare, the restart to
appear only as the user's `wsl --shutdown` and never as a command, and
the paper units' --env-file to be stated as kept.
Checked: tests.test_credential_boot_receipt 14 OK;
tests.test_adoption_docs_consistency 37 OK (1 skipped, the unrelated
profile-cell control; the unreleased-path marker check ran);
tests.test_adoption_launchd 70 OK; tests.test_adoption_bootstrap_macos
161 OK; tests.test_adoption_bootstrap 77 OK; tests.test_secret_path_guard
16 OK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alidate.py's session-identifier scan scripts/validate.py flags any UUID written out in a tracked file as a "possible local session identifier". The two synthetic boot ids in tests/test_credential_boot_receipt.py were UUID literals, so the scan reported the test file; they are now joined from their five groups at run time, with the same values, and the file text holds no UUID. Failing first: python3 scripts/validate.py reported "tests/test_credential_boot_receipt.py: contains possible local session identifier" on the previous commit, beside the manifest hash findings. Checked: that finding is gone and the remaining findings are only manifest hash mismatches (the base's eight unregistered files, and adoption/platforms/macos-arm64.md, which this branch changes); a UUID grep of the three new files counts 0; tests.test_credential_boot_receipt, 14 tests OK under python3 3.13 and /usr/bin/python3 3.12. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the key allowlist Amendments 4 and 9 of the PR-3 contract describe one test that checks the key allowlist, fingerprints of exactly mode, size and mtime_ns, and the canary scan of the receipt, the printed line and stderr. The allowlist was checked on an in-process receipt and the scan on the receipt the CLI writes; test_receipt_is_value_free now also asserts the allowlist and the three fingerprint fields on that CLI receipt (seven or more file rows with fingerprints), so one test covers all three on the command the unit runs. The in-process allowlist test, with its exact values, stays. Checked: tests.test_credential_boot_receipt, 14 tests OK under python3 3.13 and /usr/bin/python3 3.12; the twelve-mutation run is unchanged (baseline OK, all caught, file restored), and the content-hash mutation fails test_receipt_is_value_free, the allowlist test and the no-open test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a file that changed is never ok
Review finding 1 (medium): the checker's status scan and the fingerprint
lstat were separate observations, so a file removed between them was
recorded as state ok with no fingerprint, and compare printed
`ok -> ok, fingerprint gone` and exited 0.
Each file row is now observed with lstat just before and just after
cs.inspect. When the two observations agree on device, inode, mode,
size, mtime_ns and ctime_ns, the checker saw the same file whose
fingerprint is recorded; when they differ, the row's state is
changed_during_record, which compare treats as not ok (a gated row that
was ok becomes a regression, exit 1), and the receipt's result becomes
changed_during_record unless the checker already said unsafe. Inode and
ctime are compared only, never stored: the receipt still keeps exactly
mode, size and mtime_ns. The summary line gains a changed_during_record
count. The decision record's boot receipt section says so.
Failing first: test_a_file_changed_during_record_is_never_ok failed with
('ok', None) != ('changed_during_record', None), the reviewer's case,
with the file removed right after the checker's own observation (inspect
patched to run the change inside the call). The test also removes the
file just before the checker looks, replaces it with a twin of the same
bytes, mode and mtime (only the inode and ctime differ, so the three
recorded fields cannot tell), and runs a no-change control that stays ok.
Three existing tests failed on the new line format until the count was
added.
Checked: tests.test_credential_boot_receipt, 15 tests OK under python3
3.13 and /usr/bin/python3 3.12. Mutations, each caught by the new test:
identity without inode and ctime, both observations after the scan, the
changed row keeping the checker's state, the result staying ok. Eleven
earlier mutations, re-targeted at the changed code, are still caught.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ctory lock, never by the clock Review finding 2 (medium): compare ordered receipts by their wall-clock names, and a WSL clock can step back after a Windows sleep or before its first time sync, so a restart stamped 60 s earlier reversed the order and read a deleted file as `missing -> ok`, exit 0. Receipts are now named <sequence>-<UTC stamp>-<boot id prefix>.json. The 8-digit sequence is one more than the highest present; it is chosen and the mkstemp dot-file is linked to the name while the writer holds an exclusive flock of the receipt directory (a directory descriptor opened with O_NOFOLLOW; the lock ends when the descriptor is closed). A name taken anyway, EEXIST from a writer outside the lock, means choosing again, at most LINK_ATTEMPTS (16) times, then FileExistsError (exit 2); a taken name is never replaced. compare sorts by the sequence only, and names of the earlier unsequenced form sort before every sequenced receipt, in name order among themselves, without breaking compare. The stamp stays in the name for people. Why a lock as well as the EEXIST retry the review suggested: the stamp makes two writers' names differ even when they choose the same sequence, so an EEXIST can only catch a clash of identical names; the flock is what makes two concurrent writers choose distinct numbers. Failing first: all four new tests failed (no receipt_label or next_sequence, wall-clock names), and five existing tests failed on the new names. The new tests: a receipt stamped 60 s before its predecessor still compares as newer, and a file lost in between is a regression (exit 1); unsequenced receipts compare by name and sort before sequenced ones even with a far-future stamp, and do not count toward the sequence; two writers forced to race (a barrier after the checker's scan and a 0.3 s hold after choosing a number) get 1 and 2, and two concurrent CLI processes get distinct numbers too; a taken name moves the writer to the next number without touching the file, and a number that stays taken ends after exactly LINK_ATTEMPTS choices with nothing left behind. The receipt-mode test now checks that the same stamp and boot again take the next number and leave the earlier receipt unchanged. The decision record's boot receipt section and the runbook's Restart check section give the new name and order. Checked: tests.test_credential_boot_receipt, 19 tests OK under python3 3.13 and /usr/bin/python3 3.12; tests.test_secret_path_guard OK. Mutations, each caught: ordering by the stamp in the name (the reviewer's case), ordering by plain name, no directory lock, no new choice after a taken name, replacing a taken name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t exits 2 naming the file only Review finding 3 (low): a single schema-only receipt made compare raise KeyError: 'rows' in the baseline branch, exit 1 with a traceback holding absolute source paths, while the two-receipt branch returned 2. load_receipt now checks, for every receipt compare reads (the baseline and both of a pair), each field compare uses: rows as a list of objects with string id, status, store_kind and state, string lists of findings and warnings, and a fingerprint that is null or exactly mode, size and mtime_ns, with no id twice; boot and checkout objects; both coverage lists (string lists or null); keyring_names (a string list or null, present); the guard pin as a boolean; result as a string. The first problem is refused with one line, `receipt <file name> is malformed: <field path> is missing or has the wrong type`, exit 2, no traceback and no path. The baseline summary and the pair comparison keep a backstop that refuses the same way should a field slip past the check. The docstring, the decision record and the runbook say what exit 2 now covers. Failing first: test_a_malformed_receipt_stops_compare_with_its_name_only failed with exit 1 and `KeyError: 'rows'` in a traceback, the reviewer's case. It then covers, as the newer of two receipts, a row without a state, a fingerprint with a fourth field, keyring_names as a string, no guard boolean, a coverage list missing, and a JSON list in place of the receipt, each by its exact one-line message with no absolute path, and that removing the bad receipt makes compare pass again. Checked: tests.test_credential_boot_receipt, 20 tests OK under python3 3.13 and /usr/bin/python3 3.12. Mutations, each caught: no field check (the backstop still refuses, but not with the field's line), a fingerprint allowed more fields, the receipt's path in the message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he documented pattern on the real line Review finding 4 (low): `journalctl --user -u credential-boot-receipt.service -n 1 -o cat` can return systemd's own completion message rather than the receipt line. journalctl(1) of systemd 255 on this host says -u adds "additional matches for messages from systemd" about the unit, and a finished oneshot logs `Finished <unit> - <description>.` after the tool's line. The runbook's smoke step now reads `journalctl --user -u credential-boot-receipt.service -n 20 -o cat --grep='^credential boot receipt:'` and says why: --grep is PCRE2, matched case-insensitively for an all-lowercase pattern, and with -n it implies --reverse, so the first line is the newest receipt line (all per journalctl(1) on this host; this build links libpcre2, and a --grep probe with a pattern matching nothing returned no "compiled without" error). The decision record's boot receipt section names the selector and the finding. The old selector appears nowhere else. Failing first: test_the_documented_journal_selector_finds_the_printed_line_and_no_systemd_message failed on the runbook's `-n 1 -o cat` line. The test takes the one journalctl command from the runbook's fenced blocks, requires the --grep form, keeps the pattern to characters PCRE2 and Python's re read alike, compiles it with journalctl's case rule, and requires it to match the line the tool really prints (a CLI record on a synthetic store) and none of systemd's messages for this unit (Starting, Finished, Deactivated, Consumed, Main process exited, Failed with result, Failed to start, with the unit's own Description=) nor the tool's own refusal line. The runbook's two section helpers moved to module level so this CLI-level test reads the same commands as the guard test. Checked: tests.test_credential_boot_receipt, 21 tests OK under python3 3.13 and /usr/bin/python3 3.12; the new selector passes scripts/hooks/secret_path_guard.py's check(). Mutations, each caught: the tool's line prefix drifting, the runbook back to -n 1, a pattern that also matches systemd's messages, a pattern naming the unit instead of the line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e commit, last) Hashes of the four changed pinned files on top of main's manifest at 556fd81 (docs/lanes.md protocol: main's copy, register each file, component_matrix and new_host_grand_list --write; validate.py passes: 69 components, 8026 hashed files, 168 receipts). No receipt entry: this PR records no native run; the unit install and the restart comparison follow the merge. Co-Authored-By: Claude Sonnet 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.
Scope
scripts/credential_boot_receipt.pyrecords, at every start of the user's systemd manager (every WSL distro start), what the private credential store looked like (boot id, uptime, systemd version, linger, the checkout revision, the checker's rows as states and warnings only, per-file fingerprints of mode, size and mtime_ns fromlstat, live keyring key names, and a boolean saying whether the installed user-scope command guard file matches its pin), andcompareshows whether every key file survived a restart unchanged. AType=oneshotuser unit template runsrecordfrom the live clone;credential_status.py --client-guardsgains the guard-pin boolean; a "Restart check" runbook, a Mac paragraph and a decision-record section document it.556fd816(main after Tavily key persistence: create-only --from-env writer (python3 -I -S), memory-only status rows, coverage warnings, decision record started #490 merged)lane:foundationscripts/credential_boot_receipt.py(new),adoption/templates/systemd/credential-boot-receipt.service(new),tests/test_credential_boot_receipt.py(new),scripts/credential_status.pyandtests/test_credential_status.py(the--client-guardsboolean only),docs/secret-storage.md(the "Restart check" section),adoption/platforms/macos-arm64.md(one paragraph),docs/decisions/2026-09-29-key-management.md(one appended section), and the last commit'smanifests/evidence.json. Not touched: the guard, any credential file, the keyring, any installed unit.SOTA sources
systemd.service(5)(Type=oneshot,TimeoutStartSec=),systemd.exec(5)(UMask=,NoNewPrivileges=),systemd.unit(5)(WantedBy=default.targetwith lingering users),journalctl(1)(-uincludes systemd's own messages,--grep,-o cat); the unit is verified withsystemd-analyze --user verifyon a rendered scratch copy. Successful oneshots log a completion message (systemd v255src/core/service.c), which is why the runbook selects the receipt line with--grepinstead of-n 1.proc(5)for/proc/sys/kernel/random/boot_idand/proc/keys; the repository's own checker (scripts/credential_status.py, lstat only) and itstoken-report-refresh.serviceoneshot pattern (@REPOSITORY@rendered to the live clone).Evidence-class table
recordwrites only allowlisted fields (no content, no content hash); synthetic canary strings never appear in the receipt, the printed line or stderr in raw, base64, hex or percent-encoded form; receipts are created atomically (dot-file plusos.link) with a sequence number so ordering does not depend on the wall clock; a file changed between the checker's scan and the fingerprint is recorded aschanged_during_recordlocal_integration(failing-first tests, 27 deliberate breakages each caught)python3 -m unittest tests.test_credential_boot_receiptcompareexits 1 when a required or optional file row goes from ok to non-ok or disappears, 2 on a malformed receipt (file name only in the message), 0 otherwiselocal_integrationType=oneshot,python3 -Iand@REPOSITORY@inExecStart,WantedBy=default.target, noEnvironment=,EnvironmentFile=orLoadCredential=) is valid for systemd 255local_integration+ native validator on a rendered scratch copysystemd-analyze --user verifyexit 0; the pinned unit-text testswsl --shutdown; R0 versus R1 comparison passesLocal commands run
(The builder's wider run over the unit, launchd and macOS modules: 590 tests OK, 38 skipped; the receipt tests also pass under
/usr/bin/python33.12, the unit's interpreter.)Decision record
docs/decisions/2026-09-29-key-management.md, the boot-receipt section: what is recorded and why nothing else, why the unit runs from the live clone and never from the working checkout, the restart-comparison procedure (R0 before, R1 after) and what a pass does and does not prove (the files survived and no unlock was needed; it does not prove the keys are valid at the provider).Cross-family review: one read-only GPT-6 round (
gpt-6-astra, effort max) on the diff returned four findings, all fixed in one repair round with failing-first tests: (1) the checker's status scan and the fingerprint scan were separate observations, so a file removed between them readok: each file now gets onelstatbefore and one after the scan and a disagreement becomeschanged_during_record(not ok); (2) receipts were ordered by their wall-clock name, and a clock that jumps backwards after a Windows sleep reversed the order and hid a lost file: receipts now carry a sequence number under a directory lock andcompareorders by it; (3) a malformed single receipt crashedcomparewith a traceback and absolute paths: every receipt read is validated and reports one sanitized line; (4)journalctl -n 1can select systemd's completion message instead of the receipt line: the runbook uses-n 20 -o cat --grep='^credential boot receipt:'and a test runs that pattern against the real line.Host evidence
Not applicable to this PR: no file under
evidence/hosts/changes. After merge, announced to the live sessions: fast-forward the live clone, render the unit to~/.config/systemd/user,systemd-analyze --user verify,daemon-reload,enable, start it once, runpython3 -I scripts/credential_boot_receipt.py comparefor the baseline (R0); after the user'swsl --shutdown(agreed with the trading lane),compareagain (R1). The restart is the user's action and is never run by an agent.Checklist
permissions: contents: read: no workflow changes.🤖 Generated with Claude Code