Skip to content

Tavily key persistence: create-only --from-env writer (python3 -I -S), memory-only status rows, coverage warnings, decision record started - #490

Merged
seathatflowsinourveins merged 7 commits into
mainfrom
claude/key-tavily-persist-20260929
Sep 29, 2026
Merged

seathatflowsinourveins merged 7 commits into
mainfrom
claude/key-tavily-persist-20260929

Conversation

@seathatflowsinourveins

Copy link
Copy Markdown
Owner

Scope

  • What this PR changes, in one or two sentences: the one provider key that lives only in the Linux kernel keyring (Tavily; lost on any WSL restart) gets a create-only path into the private 0600 store file that already holds the other keys (set_credential.py --from-env, which refuses unless started as python3 -I -S, never re-executes, accepts an entry with exactly one declared variable, and prints only <id>: stored), and credential_status.py now reports memory-only keyring rows and two names-only coverage warnings (an undeclared store file, an undeclared native-agent-stack:* keyring key). The tavily inventory entry becomes a private_env_file entry; the decision record docs/decisions/2026-09-29-key-management.md is started.
  • Base commit: b1be50c8
  • Lane: lane:foundation
  • Owned paths touched: tools/credentials/set_credential.py, scripts/credential_status.py, adoption/credential-inventory.json, adoption/tools/README.md, docs/secret-storage.md, recipes/tavily.md, docs/decisions/2026-09-29-key-management.md (new), tests/test_credential_tools.py, tests/test_credential_status.py, tests/test_secret_path_guard.py (one ALLOWED row for the exact chain string; no guard change), and the last commit's manifests/evidence.json. Not touched: the guard, the host store, the keyring, any unit.

SOTA sources

  • Linux kernel keyrings, for the /proc/keys line format and the user-key description: security/keys/proc.c (proc_keys_show) and security/keys/user_defined.c (user_describe) at torvalds/linux v6.18, and the kernel's keys documentation; the parser is checked against fixtures built from that format, so the first real-kernel observation is the post-merge checker run.
  • CPython command-line semantics for the isolation gate: -I (isolated mode) does not skip site, -S does; measured by this PR's controls (a sitecustomize module on PYTHONPATH and a .pth import line in a throwaway virtual environment): a plain start runs both, -I alone still runs the .pth code with the value in the environment, -I -S runs neither.
  • Alternatives measured or read for the record: dotenvx v2.31.1 run (measured on this store format, kernel of the injector decision), systemd LoadCredential= (mitigating only: a same-uid-readable tmpfs copy and the store path on the user bus), and the keyring alone (memory only). Sources are cited with pins in the decision record.
  • The design this PR builds from: the D1 design chosen by the design panel's security judge (repository-internal), with the leak-path review's amendments; no external tool was adopted for this component (none writes our store format from a keyring value).

Evidence-class table

Claim Evidence class Command / receipt
--from-env refuses before any write unless the interpreter is isolated with no site, never re-executes, refuses pairs and non-single entries, is create-only (os.link, EEXIST), and prints only <id>: stored local_integration (failing-first tests, eight mutations each caught, real-interpreter controls) python3 -m unittest tests.test_credential_tools
The checker reports memory-only rows, treats a required keyring row as an inventory error, and lists undeclared store files and native-agent-stack:* keyring keys by full name with an exact comparison local_integration (fixture-based parser) python3 -m unittest tests.test_credential_status
The exact chain string python3 scripts/kernel_keyring.py exec tavily_api_key TAVILY_API_KEY -- python3 -I -S tools/credentials/set_credential.py tavily --from-env passes the command guard local_integration ALLOWED row in tests/test_secret_path_guard.py
The chain has been run on a host not yet: the one store write follows the merge, announced to the live sessions, from the live clone none in this PR

Local commands run

$ TMPDIR=/var/tmp PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_credential_tools tests.test_credential_status tests.test_secret_path_guard tests.test_install_claude_profile tests.test_adoption_docs_consistency
Ran 189 tests in 13.729s
OK (skipped=4)
$ python3 scripts/validate.py
{"components": 69, "hashed_files": 7994, "profiles": 4, "receipts": 168, "status": "passed"}

Decision record

docs/decisions/2026-09-29-key-management.md (new, started here; later PRs append sections): the selection (0600 store files stay the store of record; the keyring is a transport only), the dated reading of the user's instructions for Tavily (2026-09-28 "don't store in files" for keys pasted in chat, then 2026-09-29 "the key can be store with env so no key is loss" and "handle the key with seamless sota env practice always": the later instruction governs; reversible by deleting the store file), the alternatives considered so far, and the comparison that would overturn the choice.

Cross-family review: one read-only GPT-6 round (gpt-6-astra, effort max) on the four-commit diff returned two medium findings and nothing else, both fixed in one repair round with failing-first tests: (1) the /proc/keys parser dropped or truncated descriptions containing / or : and let a declared key name hide a longer one (tavily_api_key:backup): it now compares the whole description exactly and reports every other native-agent-stack:* key by full name; (2) after this PR the file is the store of record but tvly-keyring still injects from the keyring copy until the id-based runner lands, so rotating only the file would break Tavily commands once the old key is revoked: a dated interim step (refresh the keyring copy after rotating the file) is stated once in each of the inventory notes, the secret-storage section and the recipe, with a test that the runner PR deletes with the step. An earlier independent leak-path review of the design changed the isolation gate from -I to -I -S and moved the check before the tool's re-exec block.

Host evidence

Not applicable: no file under evidence/hosts/ changes. After merge, from the live clone: python3 scripts/kernel_keyring.py exec tavily_api_key TAVILY_API_KEY -- python3 -I -S tools/credentials/set_credential.py tavily --from-env (expect tavily: stored), then python3 scripts/credential_status.py (expect tavily ok).

Checklist

  • New/changed GitHub Actions are pinned to a full commit SHA with a version comment (no floating tags): no workflow changes.
  • New/changed workflows declare top-level permissions: contents: read: no workflow changes.
  • No secrets are printed, logged or committed; no new required secret was added without a documented owner. All tests use synthetic values in temporary stores.
  • No new paid hosting, subscription or billing surface was introduced.
  • Peer-owned untracked files and worktrees were preserved (not deleted, moved or overwritten).

🤖 Generated with Claude Code

Scout and others added 7 commits September 29, 2026 00:24
…ws and undeclared keys

The inventory's tavily row changes from kernel_keyring (key tavily_api_key) to
private_env_file <store>/tavily.env, status still optional, rotation through
open_credential_terminal.sh tavily, and notes that carry the dated reading of
the 2026-09-26, 09-28 and 09-29 instructions (D1 design, C21).

scripts/credential_status.py (C06 part 1):
- a kernel_keyring row keeps state unchecked and gains persistence
  memory_only and the warning memory_only_lost_on_restart;
- a required kernel_keyring row is an inventory error, so validate.py and
  set_credential.py refuse such an inventory too;
- two coverage lists, names only, each a warning with exit 0:
  undeclared_store_file (one scandir of the store root, directories
  skipped, a symlinked root never listed through) and undeclared_keyring_key
  (this uid's live native-agent-stack:<name> keys in /proc/keys that no
  kernel_keyring row claims; revoked, dead, negative, invalidated and expired
  keys skipped). The line format follows linux v6.18 security/keys/proc.c
  proc_keys_show() and user_describe() in security/keys/user_defined.c.
- --proc-keys (default /proc/keys) lets the tests pass a fixture, as --root
  and --inventory already do; inspect() scans only when given a path.

Tests: the tavily row test and the keyring-row test now plant a memory-only
row (the real inventory has none), and every CLI run in the file passes a
fixture key list, so no test reads this host's /proc/keys or store.
set_credential's entry test now expects tavily to load as a file entry and
keeps the keyring refusal with a planted row.

Failing first (run by name before the implementation, 7 tests):
test_tavily_row_is_a_stored_file_entry FAIL (store kernel_keyring !=
private_env_file); test_required_keyring_row_is_an_inventory_error FAIL
(0 != 1 errors); test_keyring_row_reports_memory_only_lost_on_restart,
test_undeclared_store_file_is_reported_by_name_only,
test_undeclared_keyring_key_is_reported_from_a_proc_keys_fixture and
test_kernel_keyring_row_is_validated_and_never_inspected ERROR (inspect() had
no proc_keys); test_only_operator_supplied_stored_entries ERROR (Refused:
tavily not an operator-supplied stored entry).

After: TMPDIR=/var/tmp PYTHONDONTWRITEBYTECODE=1 python3 -m unittest
tests.test_credential_tools tests.test_credential_status
tests.test_secret_path_guard tests.test_install_claude_profile
tests.test_adoption_docs_consistency: 174 tests, the one failure is the
tolerated test_host_profile_copy_is_verbatim (host copy of the guard);
tests.test_codex_worker_lane tests.test_host_requests: 107 OK (9 skipped).
validate.py reports only the manifest hash mismatches of the four changed
pinned files, which the coordinator registers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter for a keyring key

The keyring-to-file chain of the D1 design (C05; amendments 2 and 3):

  python3 scripts/kernel_keyring.py exec tavily_api_key TAVILY_API_KEY -- python3 -I tools/credentials/set_credential.py tavily --from-env

- It runs only in an interpreter started with -I (sys.flags.isolated). A
  start without -I is refused at module load and never re-executed: that
  interpreter has already honoured PYTHONPATH and the user site directory
  beside the value, which a re-run cannot undo.
- It stores only an entry that declares exactly one variable, required or
  optional, so the Alpaca pairs and sec-contact are refused.
- It pops the variable from its environment first, then refuses an absent,
  empty or out-of-grammar value with the existing encode() rules.
- It is create-only: an existing name (a symlink too) is refused before the
  temporary file is written, and the finished, fsynced temporary file gets
  its final name with os.link, which fails with EEXIST instead of replacing;
  the temporary name is then unlinked. There is no replace option.
- The only output is "<id>: stored"; refusals name the variable, never the
  value, and an unexpected error prints its type only, without a traceback.
- The parser takes no abbreviations: argparse would otherwise read --from as
  --from-env, which the start-up check does not look for.
- The store-directory checks are the existing open_store().
Also KEY_PREFIX_HINT gains alpaca-paper-2: PK, from the review of #481.
(And one long line of commit 1's render_text is reflowed.)

Failing first (tests.test_credential_tools.StoreFromEnvTests and
test_second_paper_account_gets_the_same_prefix_hint, run by name before the
implementation): 9 of 10 failed. Seven errored with "module 'set_credential'
has no attribute 'run_from_env'" (or 'create_exclusively'); the two CLI tests
failed because argparse rejected --from-env (exit 2, no "python3 -I" hint);
the prefix test failed (no "does not start with PK" warning for an AK id).
test_cli_takes_no_abbreviation_of_from_env passed vacuously before the option
existed, so it is shown by mutation below.

Mutation check, each in a scratch copy of the tree, StoreFromEnvTests only:
dropping allow_abbrev=False fails the abbreviation test (both subtests);
os.replace instead of os.link fails the create-only link test and the store
test; re-executing instead of refusing fails the non-isolated CLI test;
env.get instead of env.pop fails the store test; skipping the isolation
check fails the isolation test. The non-isolated CLI test carries its own
control: a sitecustomize module on PYTHONPATH records that it ran and saw
TAVILY_API_KEY in the start without -I, and does not run under -I.

After: the five acceptance modules ran 184 tests, the one failure being the
tolerated test_host_profile_copy_is_verbatim. All values are synthetic, in a
temporary XDG_CONFIG_HOME; no test touches the kernel keyring or the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-executes

From the coordinator's independent review of the first version:
-I skips PYTHONPATH, PYTHON* variables and the user site directory but still
imports site, which runs the .pth lines of the interpreter's own
site-packages beside the value (on this host a uv-managed Python under the
home directory). The chain is now

  python3 scripts/kernel_keyring.py exec tavily_api_key TAVILY_API_KEY -- python3 -I -S tools/credentials/set_credential.py tavily --from-env

- Before the module-level re-exec block: with --from-env in argv the tool
  never re-executes, and refuses (exit 2, naming the required invocation,
  never a value) unless sys.flags.isolated and sys.flags.no_site are both
  set. Every other mode keeps today's re-exec with -I. run_from_env checks
  the same pair again (its isolated_start argument defaults to both flags).
- Every refusal comes before the first write: the existing final name, a
  symlink included, is refused before open_store() can create the store or
  tighten its mode; the check is repeated through the directory handle and
  os.link still settles a later race.
- The temporary file is a dot-file named after the final file
  (.tavily.env.<16 hex>.tmp). A kill before the link leaves it; the checker
  lists it as undeclared_store_file and never counts it as the stored key,
  and the writer never takes it for the final file.
- The inventory note carries the -I -S chain.

Failing first (StoreFromEnvTests, 12 tests, before the change): with -I alone
the old writer stored the file, so test_cli_refuses_minus_I_without_minus_S
and the venv control test failed with "0 != 2"; the plain-start refusal
lacked "python3 -I -S"; the other tests errored on the new isolated_start
argument. After the -I -S rule and before the pre-write check,
test_never_replaces_an_existing_file_and_writes_nothing_first failed with
"448 != 488": a refusal came after the store mode 0750 had been tightened to
0700. The leftover-temporary-file test passed at once (the name was already
a dot-file); it is pinned by mutation below.

Controls: a venv's site-packages .pth line records each interpreter start as
<isolated><saw the variable>. A plain start records only "01" (the value was
seen and nothing was re-executed), -I alone records "11" (site code ran
beside the value under -I), and -I -S records nothing and stores the file.

Mutation check, each in a scratch copy of the tree, StoreFromEnvTests only;
each is caught by the intended test: allow abbreviations; os.replace for
os.link; re-execute instead of refusing (the venv record shows an isolated
second start); env.get for env.pop; no isolation check; a temporary name
without the leading dot; no pre-write existence check; -I without -S
accepted.

After: TMPDIR=/var/tmp PYTHONDONTWRITEBYTECODE=1 python3 -m unittest
tests.test_credential_tools tests.test_credential_status
tests.test_secret_path_guard tests.test_install_claude_profile
tests.test_adoption_docs_consistency: 187 tests OK (4 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt, the checker's new lists

- docs/secret-storage.md: the store table lists tavily as <store>/tavily.env
  whose first write comes from the keyring copy. "Memory-only option: Linux
  kernel keyring (2026-09-26)" becomes "Kernel keyring: transport and
  per-boot spare (2026-09-29)", with an <a id> alias for the old anchor. It
  documents the create-only chain

    python3 scripts/kernel_keyring.py exec tavily_api_key TAVILY_API_KEY -- python3 -I -S tools/credentials/set_credential.py tavily --from-env

  and every refusal of --from-env, including why -I alone is refused and the
  leftover dot-file of a killed write. The Checker section describes
  memory_only rows, the required-row error and the two coverage lists, and
  rotation of the Tavily key moves to open_credential_terminal.sh tavily.
- recipes/tavily.md: "Memory-only key on Linux and WSL2" becomes "Stored key
  (2026-09-29)", also with an alias for the old anchor. The keyring commands
  stay valid until the next kernel restart; after it Tavily has no key until
  a file loader lands, an accepted gap because Tavily is not the default web
  lane. The later injector's default use is not described here.
- adoption/tools/README.md: tvly-keyring's link points at the renamed
  heading (tests/test_adoption_docs_consistency resolves anchors from
  headings only), and one sentence says the wrapper uses the keyring copy.
- docs/decisions/2026-09-29-key-management.md (new): the selection, the
  dated reading of the 2026-09-26, 09-28 and 09-29 instructions, the
  alternatives so far (dotenvx run measured, the keyring alone,
  LoadCredential= mitigating only, checked against systemd.exec(5)), the
  evidence classes, known limits and the overturn comparisons, in sections
  that later changes append to.
- tests/test_secret_path_guard.py: one ALLOWED row pins the exact chain
  string; the guard itself is unchanged.

Checked: the guard's check() returns None for the chain (probe at this
tree, guard file unchanged since the base); the 21 documented keyring lines
of the three pages all pass it (test_documented_keyring_commands_pass needs
at least 10). A scan of every added line and every commit message on the
branch for the host user name, home or scratch paths and host names found
none; the pattern's positive control matched.

After: TMPDIR=/var/tmp PYTHONDONTWRITEBYTECODE=1 python3 -m unittest
tests.test_credential_tools tests.test_credential_status
tests.test_secret_path_guard tests.test_install_claude_profile
tests.test_adoption_docs_consistency: 187 tests OK (4 skipped).
python3 scripts/validate.py reports only the SHA-256 and byte-count
mismatches of the pinned files this branch changed, for the coordinator's
manifest registration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hem in full

Finding 1 of the read-only cross-family review (GPT-6, effort max) of the
branch: the /proc/keys parser cut a description at the first character
outside kernel_keyring.py's name grammar and matched a declared key_name as
a prefix, so native-agent-stack:paper/backup was dropped and
native-agent-stack:tavily_api_key:backup was hidden by a declared
tavily_api_key row. user_describe() (linux v6.18,
security/keys/user_defined.c) prints the whole description, which may hold
"/", ":" or spaces, then ": <payload length>" for a positive key.

Now each line is parsed to its type column (proc_keys_show() prints the
type as "%-9.9s ") and its describe output. For a live, current-uid "user"
key only the final ": <payload length>" is removed. A kernel_keyring row
declares a key only when its key_name equals the whole rest after
"native-agent-stack:"; every other such key is listed under its full name.
Other key types are not listed (kernel_keyring.py stores only user keys).
Undecodable bytes are kept as backslash escapes rather than replaced. The
runbook's Checker section and the module docstring say the same.

Failing first:
tests.test_credential_status.CredentialStatusTests.test_proc_keys_descriptions_are_compared_whole_and_reported_in_full
failed on the old parser with ['logon-key', 'note', 'tavily_api_key2'] !=
['note: 12', 'paper/backup', 'tavily_api_key2', 'tavily_api_key:backup',
'with space']: "/" and space descriptions were dropped, "note: 12" was cut
to "note", tavily_api_key:backup was hidden by the declared tavily_api_key,
and a logon key was listed. The test also checks that a declared key that
is present is not listed, and that tavily_api_key is listed under the real
inventory, which has no kernel_keyring row.

Mutation check in a scratch copy: splitting the name on ":" before the
comparison, and dropping the "user" type filter, each fail the new test.
A lazy suffix pattern is an equivalent mutant under fullmatch (it strips
the same final ": <length>"). In those copies
test_repository_has_no_tracked_sensitive_names also failed, only because a
copy is not a git checkout.

After: TMPDIR=/var/tmp PYTHONDONTWRITEBYTECODE=1 python3 -m unittest
tests.test_credential_tools tests.test_credential_status
tests.test_secret_path_guard tests.test_install_claude_profile
tests.test_adoption_docs_consistency: 188 tests OK (4 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finding 2 of the read-only cross-family review (GPT-6, effort max): after
this branch the file is the store of record, but adoption/tools/tvly-keyring
and kernel_keyring.py exec still read the keyring copy until the id-based
runner lands. Rotating only the file and then revoking the old key at
Tavily would break Tavily commands before the next restart.

One dated step, starting "Interim step (2026-09-29, until the id-based runner
lands)", is added once in each place that tells how to rotate: the tavily
notes in adoption/credential-inventory.json (as their last sentence), the
keyring section of docs/secret-storage.md, and recipes/tavily.md. After
open_credential_terminal.sh tavily, the operator also refreshes the copy in
their own terminal with
`python3 scripts/kernel_keyring.py store --replace tavily_api_key`, or
accepts that the copy keeps the old key until the next kernel restart drops
it. Each step is its own sentence or paragraph, so the runner's change can
delete it together with its test. Only the wording changes: no code, and
the rotation field and existing assertions are untouched.

Failing first:
tests.test_credential_status.CredentialStatusTests.test_interim_tavily_rotation_step_is_stated_once_in_each_place
failed in all three places with "0 != 1" (the marker was absent). It checks
that the marker appears exactly once in each place, and that the step's own
paragraph names the rotation command, the refresh command and the next
kernel restart.

After: TMPDIR=/var/tmp PYTHONDONTWRITEBYTECODE=1 python3 -m unittest
tests.test_credential_tools tests.test_credential_status
tests.test_secret_path_guard tests.test_install_claude_profile
tests.test_adoption_docs_consistency: 189 tests OK (4 skipped); the
inventory still parses as JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot-file commit, last)

Hashes of the eight changed pinned files on top of main's manifest at b1be50c (docs/lanes.md protocol: main's copy,
register each file, component_matrix and new_host_grand_list --write; validate.py passes: 69 components, 7994 hashed
files, 168 receipts). No receipt entry: this PR records no native run; the one host write follows the merge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@seathatflowsinourveins seathatflowsinourveins added the lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers label Sep 29, 2026
@seathatflowsinourveins
seathatflowsinourveins merged commit 556fd81 into main Sep 29, 2026
25 checks passed
@seathatflowsinourveins
seathatflowsinourveins deleted the claude/key-tavily-persist-20260929 branch September 29, 2026 05:16
seathatflowsinourveins pushed a commit that referenced this pull request Sep 29, 2026
…mmand guard models it

Review finding 1 (2026-09-29, read-only GPT-6 review at effort max of
e437a361). AGENTS.md and the docs called the runner the default path, but the
command guard does not model it yet. A check of this checkout's guard
returned native_token_print for "tvly auth" and None for "python3
tools/credentials/credential_run.py tavily -- tvly auth", and a plain
"tvly auth" prints a key's first eight and last four characters, which
masking cannot catch. Making the runner the default first would reopen shapes
the guard refuses today. The guard model is phase 2 of the same pull request
series, by another builder, after the guard tightening lands.

AGENTS.md is restored to the base sentence. recipes/tavily.md is main's text
again (the interim rotation step of #490 and the keyring exec commands are the
default) with the runner added as an option: its commands, --check and
--json, and a dated sentence that the guard does not read its command yet
(phase 2) and that a "tvly auth" behind it would print part of the key.
docs/secret-storage.md "Using a key" (now titled "available") says the runner
is available and becomes the default after the guard model; the Alpaca
consumer and keyring exec passages it had replaced say subshell and exec
again, with the runner as an alternative. The section also lists the new
core_pattern refusal, the encoder forms, the bounded hold-back, the
non-blocking output with its 256 KiB queue and 2 s drain, the forms that
stay unmasked (nested encodings, wrapped base64, other escapers, fragments)
and the residual that a same-uid debugger, ptrace or /proc read is out of
scope. The decision record's Part 4 gets a "Not yet" line for the guard model
and the default-path flip, the repair round with its pins (torvalds/linux
v6.16 fs/coredump.c, systemd v257 src/coredump/coredump.c), new evidence
counts and an overturn condition; the macOS page's runner paragraph says
"may" and notes that the core-pattern check is skipped there. The module
docstring of credential_run.py carries the same status.

Checked with tests.test_credential_run, tests.test_credential_status,
tests.test_credential_tools, tests.test_install_claude_profile,
tests.test_adoption_docs_consistency and tests.test_secret_path_guard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
seathatflowsinourveins pushed a commit that referenced this pull request Sep 29, 2026
…mmand guard models it

Review finding 1 (2026-09-29, read-only GPT-6 review at effort max of
e437a361). AGENTS.md and the docs called the runner the default path, but the
command guard does not model it yet. A check of this checkout's guard
returned native_token_print for "tvly auth" and None for "python3
tools/credentials/credential_run.py tavily -- tvly auth", and a plain
"tvly auth" prints a key's first eight and last four characters, which
masking cannot catch. Making the runner the default first would reopen shapes
the guard refuses today. The guard model is phase 2 of the same pull request
series, by another builder, after the guard tightening lands.

AGENTS.md is restored to the base sentence. recipes/tavily.md is main's text
again (the interim rotation step of #490 and the keyring exec commands are the
default) with the runner added as an option: its commands, --check and
--json, and a dated sentence that the guard does not read its command yet
(phase 2) and that a "tvly auth" behind it would print part of the key.
docs/secret-storage.md "Using a key" (now titled "available") says the runner
is available and becomes the default after the guard model; the Alpaca
consumer and keyring exec passages it had replaced say subshell and exec
again, with the runner as an alternative. The section also lists the new
core_pattern refusal, the encoder forms, the bounded hold-back, the
non-blocking output with its 256 KiB queue and 2 s drain, the forms that
stay unmasked (nested encodings, wrapped base64, other escapers, fragments)
and the residual that a same-uid debugger, ptrace or /proc read is out of
scope. The decision record's Part 4 gets a "Not yet" line for the guard model
and the default-path flip, the repair round with its pins (torvalds/linux
v6.16 fs/coredump.c, systemd v257 src/coredump/coredump.c), new evidence
counts and an overturn condition; the macOS page's runner paragraph says
"may" and notes that the core-pattern check is skipped there. The module
docstring of credential_run.py carries the same status.

Checked with tests.test_credential_run, tests.test_credential_status,
tests.test_credential_tools, tests.test_install_claude_profile,
tests.test_adoption_docs_consistency and tests.test_secret_path_guard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
seathatflowsinourveins added a commit that referenced this pull request Sep 29, 2026
…ial_run.py), public_variables inventory field, docs as available (#497)

* credential inventory: public_variables classifies each optional stored variable

The key runner of the next commit (tools/credentials/credential_run.py)
masks every variable it injects except the names an entry lists in the new
optional field public_variables (D1 PR-4 contract, amendment 4).
scripts/credential_status.py now accepts the field, requires it to name
distinct optional_variables of its own entry (a required variable is never
public), and requires an env-file entry that declares optional variables to
classify them explicitly ([] masks them all). Rows without optional variables
carry no field.

Classification: APCA_API_BASE_URL is public on alpaca-paper and
alpaca-paper-2 (a base URL, not a secret). EDGAR_IDENTITY (sec-contact,
private contact data) and GF_SECURITY_ADMIN_USER (grafana-admin, engine-held
and never injected) stay masked.

Failed first: test_public_variables_classify_every_optional_stored_variable
errored with KeyError 'public_variables' before the inventory change, and the
schema ignored unknown keys, so none of its five planted mutations (a string,
a required name, another entry's name, a duplicate, a missing
classification) produced an inventory error. Checked with
tests.test_credential_status and tests.test_credential_tools: all pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* credential_run.py: id-based key runner that masks the keys it injects (D1 PR-4, phase 1)

  python3 tools/credentials/credential_run.py <inventory-id> [--only NAME]... -- <command> [args...]
  python3 tools/credentials/credential_run.py <inventory-id> --check

Stdlib only, Python 3.9 grammar. It re-executes under -I -S before
anything is read. It resolves the entry through credential_status
(inventory_errors must be empty; STORE_ROOT plus expand_template), refuses
engine-held, native, interactive and CI ids with a hint naming the holder,
and reads the 0600 file through a no-follow handle on the 0700 store
directory outside every Git worktree (no symlink, FIFO or hard link, at most
64 KiB). The parser is measure.py's load_env_file grammar held to
set_credential.py's value grammar (BARE_VALUE unquoted, encode() quoted), so
a hand-edited $, backtick or backslash is refused, never expanded. Masked
values must be at least 6 bytes (Buildkite LengthMin).

The command gets the caller's environment minus every inventory and
must_not_be_set name, plus this id's declared variables (narrowed by --only).
It runs in a new session with RLIMIT_CORE 0 and inherited stdin, and SIGINT,
SIGTERM and SIGHUP are forwarded to its process group. Its exit code passes
through (128+N for signal N). Both pipes are relayed as bytes through one
masker each. Needles are the raw value, base64 and base64url stable
interiors at alignments 0, 1 and 2, percent forms (quote and quote_plus,
upper- and lower-case hex), the JSON escape and hex; they are
deduplicated and matched longest first, with overlaps merged. A suffix that
may begin a needle is held and never cut through a complete match. A held
tail of 3 bytes or less is written after 100 ms idle or at the end; 4 bytes
or more is never written and becomes [REDACTED-PARTIAL:<NAME>] at EOF, on
kill or after the 2 s drain that stops a descendant holding a pipe from
hanging the runner. Messages carry ids, names, line numbers and reason
codes only; a catch-all prints the exception type and internal_error.

Sources are in the module docstring, each re-read at a pinned commit on
2026-09-29: dotenvx@278101db redactOutput.js, actions/runner@15231bed
ValueEncoders.cs and SecretMasker.cs, buildkite/agent@3345ee60 redact.go and
replacer.go, dmno-dev/varlock@1b880652 redact-stream.ts, and
Generalized-Labs/ironrun@b611c7ce encodings.go (v0.4.0).

Failed first: tests/test_credential_run.py was written before the tool and
failed to import it (exit 1). The first run of the tool then failed 7 of 30
tests. Four waited for the previous commit's public_variables. Two caught a
too-broad test check. One, test_flushes_an_unterminated_tail_at_eof, showed
that printing no held tail at EOF turns the last "d" of any output into a
marker, because base64 of every Tavily key ("tvly-") starts with "d". Hence
the 3-byte rule at EOF, which is a deviation from amendment 4's literal text:
a held tail is replaced at EOF only from 4 bytes, the idle timer's own
threshold. SHORT_TAIL_MAX flips it back, and the mutation below pins today's
choice. A later straddling-match case showed that cutting through a complete
match prints its last bytes raw, so the tool now holds such a match with the
tail.

Mutation check (scratch script, one intended test per mutant, tool restored
by sha256): 32 of 32 mutants caught by assertion failures, including base64
or hex needles removed, no held tail, idle flush of any tail, held tail
printed at EOF, no continuation after an idle flush, no overlap merge, no
safe boundary, UTF-8 decoding of output, inventory names kept, --only
ignored, public names masked, no minimum length, no writer grammar,
undeclared names accepted, mode, hard link and worktree checks removed,
engine ids injected, the unknown id echoed, no -S, no re-exec, stdin not
inherited, no signal forwarding, raw signal codes, same session, core dumps
allowed, an unbounded drain, the exception message printed and --check
printing values.

Checked: tests.test_credential_run (31 tests, including with ResourceWarning
as an error), tests.test_credential_status and tests.test_credential_tools
all pass. These are local integration checks with synthetic values in
temporary stores, not upstream acceptance. The guard model, the oracle's
injector phase and the docs follow; the runner does not ship without the
guard model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: using a key through credential_run.py (runbook, AGENTS.md, Tavily, macOS, decisions)

- docs/secret-storage.md: new section "Using a key (default path,
  2026-09-29)", with an explicit using-a-key anchor for AGENTS.md. It covers
  the runner form and --check, what is refused, the one user step to add a
  key (a new provider also gets its inventory entry and SECRET_NAMES line in
  the agent's worktree), the honest limits (only injected whole values and
  the listed encodings; not fragments or transformed values, not files,
  logs or the network; stdout and stderr masked separately; the 4-byte held
  tail rule; environ visibility; the guard covers Claude's Bash tool only),
  and units, launchd, Codex and MCP stdio servers. The five
  environment-only consumers now use the runner, and the set -a subshell is
  marked deprecated but still documented. The keyring "Use it" paragraph
  now names the runner as the default and exec as the per-boot spare.
- AGENTS.md: the design's credentials sentence (docs_and_default_path). The
  rest of the bullet stays.
- recipes/tavily.md: every tvly command goes through
  credential_run.py tavily -- tvly <args>. The keyring exec and tvly-keyring
  lines stay as a per-boot spare that works only until the next restart,
  and macOS uses the stored file instead of secret run.
- adoption/platforms/macos-arm64.md: "Keys under launchd". ProgramArguments
  go through the runner and EnvironmentVariables hold PATH only; the store
  is ~/.config, Tavily is a file, and the boot receipt is a later change.
  The ps capital-E guard fix is required before Mac acceptance. Not run on
  a Mac.
- docs/decisions/2026-09-29-key-management.md: one appended section, "Part
  4: the id-based key runner". It holds the selection, the references at
  their pins, and the alternatives with pins and evidence class (mise and
  agentself measured and rejected with the deciding measurements, dotenvx,
  ironrun, varlock and the design's list). It also records the one
  deviation (the held tail at EOF), the evidence class and the overturn
  comparisons.
- docs/decisions/2026-09-22-broker-credential-handling.md: the macOS note
  said ps eww. It is now ps -E, per Apple's ps(1)
  (apple-oss-distributions/adv_cmds@6bed8737 ps/ps.1 L123-128: -E shows the
  environment and -e means -A; L602-609: the environment -e is legacy mode
  only).

Failed first: tests.test_adoption_docs_consistency failed on the first
draft (test_every_relative_link_resolves: the macOS page linked
secret-storage.md#using-a-key, which is an <a id> alias, not a heading). All
markdown links now use heading slugs computed with the test's own
github_slug, and link_errors() reports none for the six changed pages.
Checked: the brief's acceptance command (tests.test_credential_run,
test_credential_status, test_credential_tools, test_install_claude_profile,
test_adoption_docs_consistency and test_secret_path_guard) ran 219 tests, OK
with 4 skipped. The guard's documented-keyring-commands test still finds
every keyring line. scripts/validate.py reports only SHA-256 and byte-count
mismatches of pinned files, which the coordinator re-pins in the branch's
last commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* credential inventory: reject overlapping and repeated variable names; masked names come from the schema module

Review finding 6 (2026-09-29, read-only GPT-6 review at effort max of
e437a361). A required variable listed again in optional_variables and in
public_variables produced no schema error and dropped out of the runner's
masked names, so the runner would have injected it unmasked.

scripts/credential_status.py now rejects an entry whose variables and
optional_variables share a name, and a name repeated within variables,
optional_variables or pointer_variables (a public_variables name outside
optional_variables and a repeat in public_variables were already
rejected). It also owns public_names() and masked_names(): a public name is
only an optional variable that is not also required. The runner's
masked_names() and public_names() delegate to them.

Failed first: test_variable_lists_reject_overlap_and_repeats_and_keep_
required_masked reported 0 errors for each of six planted entries (overlap,
the review's overlap plus public, and repeats in three lists) and found no
cs.masked_names; the runner test found a required name absent from
masked_names(). Checked with tests.test_credential_status,
tests.test_credential_run and tests.test_credential_tools: all pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py: refuse a host that pipes crash dumps to a collector; a closed stdin reads end of file

Review finding 2 (2026-09-29, read-only GPT-6 review at effort max of
e437a361). RLIMIT_CORE 0 stops a core file, not a collector. The kernel sets
the limit aside for a core_pattern that begins with a pipe (a program such as
systemd-coredump or apport) and, since 6.16, for one that begins with "@" (a
core socket): torvalds/linux@v6.16 fs/coredump.c L795-820, L242-243 and
L919. systemd-coredump then declines to store the core but still journals
the crashing process's environment, COREDUMP_ENVIRON (systemd/systemd@v257
src/coredump/coredump.c L472-479 and L1458-1459), which holds the injected
key.

The runner now reads /proc/sys/kernel/core_pattern before it reads the store
or starts anything, and refuses with core_pattern_pipe or core_pattern_socket
(exit 1, the message names that file and never what it holds). A file that
cannot be read is refused as core_pattern_unreadable; a file that does not
exist (macOS, no /proc) skips the check. There is no override flag. A
setrlimit(RLIMIT_CORE, 0) that fails is refused as core_limit_not_set
instead of ignored. The "@" refusal goes beyond the review's wording, on the
kernel source above. Out of scope: a debugger, ptrace or /proc reads by the
same uid.

Review finding 7. A stdin that was closed at start is reopened on /dev/null
by os.open, whose descriptor is close-on-exec, so the command's exec closed
it again and its read failed with EBADF instead of reaching end of file. Each
reopened standard descriptor is now marked inheritable, including when the
new number equals the target.

Failed first: the core-pattern and limit tests had nothing to call
(check_core_pattern and CORE_PATTERN_FILE did not exist), and the
closed-stdin test showed the command printing errno 9 instead of eof. Tests
run on a host whose real core_pattern pipes (common on CI runners) through a
test-only launcher that points CORE_PATTERN_FILE at a temporary file; the
two tests of the real re-execution skip there, and a test checks the real
tool against this host's own pattern. Checked with tests.test_credential_run:
40 tests pass, also with CREDENTIAL_RUN_TEST_LAUNCHER=1 (2 skipped).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py: mask the outputs of the common percent and JSON encoders

Review finding 3 (2026-09-29, read-only GPT-6 review at effort max of
e437a361). The masker registered only the percent form written with no safe
characters, and Python's json.dumps form, which for a value in the writer's
grammar equals the value. The real encoders write more:
urllib.parse.quote() keeps "/" by default (safe="/") while escaping "+", "="
":" and "@"; quote_plus() writes a space as "+"; PHP's json_encode writes "/"
as "\/"; Go's encoding/json writes < > & as < > & (PHP's
JSON_HEX_TAG writes < >).

encoded_forms() now registers the percent forms of quote() and quote_plus()
with safe="" and safe="/", each in upper- and lower-case hex, and the JSON
string form with and without "\/" and with no, lower-case and upper-case
HTML escapes. Forms are deduplicated by needles_for(), so a value without
those characters collapses to the few it needs. Forms that stay unmasked, as
the documentation lists them: nested encodings, base64 wrapped across lines,
other escapers (for example Gson's = for "=" or another safe set) and
fragments; a test pins that list.

Failed first: EncodedFormTests, over the output of urllib.parse.quote,
quote_plus, json.dumps and the two hand-written variants for a bare value
(+ / = : @) and a quoted one (space, < > & '), failed on 16 encodings; the
runner printed for example tvly-<hex>/%2B%3D%3A%40<hex> and
tvly-<hex>\/+=:@<hex> unmasked. Checked with tests.test_credential_run: 43
tests pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py: keep at most the longest needle minus one byte, however long a run of matches is

Review finding 4 (2026-09-29, read-only GPT-6 review at effort max of
e437a361). Overlapping matches moved the hold-back boundary back to the
start of the merged run, so a run such as six identical characters was never
written, stayed in memory whole and was scanned again at every chunk: 31
chunks of 4,096 matching bytes emitted nothing and retained 126,976 bytes.

Masker.feed() now writes everything up to the start of the tail that could
still begin a needle, or up to the end of a complete match that runs into
that tail, and keeps only the tail: at most the longest needle minus one
byte. The bytes of a match that stay in the tail count as written, so a
later match that starts in them is masked from there on, and a tail that
never completes is not printed. A long run of repeated matches may therefore
come out as several markers, and a match that B's partial tail overlaps is
written whole at once (as one marker before, when B failed to complete); the
existing straddle, overlap and idle-flush tests pass unchanged. close() now
weighs the held tail itself (SHORT_TAIL_MAX), not the tail plus a match it
overlaps, which also stops a complete key at the end of the output whose
last byte could begin another form (a hex "d") from being replaced by a
partial marker.

Failed first: BoundedHoldbackTests retained 4,096 bytes after the first
4,096-byte chunk of the review's value (bound 5) and wrote nothing, wrote
nothing for the straddling match, and exceeded the bound in 76 random and
pathological rounds. Now: the same input keeps at most 5 bytes and writes at
every chunk; 250 random outputs that are not pathological equal the
whole-buffer masker byte for byte; 80 periodic runs leave the same
unmasked bytes as it does. A scratch fuzz over 40,000 dense-overlap streams
in four chunkings (not committed) found no difference in the unmasked bytes
and no retention above the bound. Checked with tests.test_credential_run:
49 tests pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py: write output without blocking, so a stalled consumer cannot hold the runner

Review finding 5 (2026-09-29, read-only GPT-6 review at effort max of
e437a361). Output was written with blocking os.write calls, so a consumer
that stopped reading held the runner in write: it stayed alive 2.7 s after
SIGTERM and exited 143 only when the consumer resumed, and the drain
deadline meant nothing.

The relay now writes through a Sink per output descriptor. A pipe or socket
is made non-blocking for the relay and put back afterwards; what the
consumer does not take waits in a queue of at most QUEUE_LIMIT (256 KiB) per
stream, watched for writability by the same select loop, and the relay stops
reading the command's pipe while the queue is full, so the command waits as
in any pipeline. Once a shutdown signal has arrived, nothing waits for the
consumer: the queue is dropped, later output is written only if it is taken
at once, and reading goes on so the command can exit. Once the command has
exited, the consumer has DRAIN_SECONDS to take what is queued, then the rest
is dropped. Dropping is safe: only masked bytes are ever queued. A terminal,
a regular file and /dev/null are written directly, since they cannot wait on
a reader that never comes, and a descriptor that shares its inode with the
command's stdin is never flipped, because its flags are the command's too
(an interactive terminal); EPIPE ends that stream as before. A runner killed
by SIGKILL leaves a flipped shared descriptor non-blocking.

Failed first: with a flooding command and a consumer that never reads,
the runner was still alive 20 s after SIGTERM, and with a command that wrote
150 kB to each stream and exited 7 it was still alive 20 s after that exit
(both subprocess.TimeoutExpired); the Sink tests had no Sink. Now the first
exits 143 within 10 s of the signal (measured: about 0.1 s), the second
exits 7 about DRAIN_SECONDS after the command (measured: 2.1 s in all), a
slow consumer still gets
700,000 bytes on each stream intact, a closed reader still ends the command,
stdout and stderr on one pipe (2>&1) shut down together, and a terminal
shared with stdin stays blocking. Checked with tests.test_credential_run: 59
tests pass, also with CREDENTIAL_RUN_TEST_LAUNCHER=1 (2 skipped).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* tests: pin the read-side backpressure and the no-wait after a shutdown signal

Follow-up to the non-blocking output change (review finding 5). The
mutation run showed two rules that no test pinned: a mutant that kept the
queue after a shutdown signal still exited within the drain deadline that
follows a command's exit, and a mutant without the read-side backpressure
passed every test.

The SIGTERM test now requires the runner to exit in less than
DRAIN_SECONDS - 0.5 after the signal (measured: about 0.1 s), because after
a shutdown signal nothing waits for the consumer. A new test has a command
that counts the 64 KiB chunks it has written to a consumer that never reads:
with the queue bound and the pipes it stops after a handful, where a runner
that kept reading would let the count run into the thousands; the test asks
for 1 to 24. Both mutants are caught now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Docs: the key runner is available, not the default path, until the command guard models it

Review finding 1 (2026-09-29, read-only GPT-6 review at effort max of
e437a361). AGENTS.md and the docs called the runner the default path, but the
command guard does not model it yet. A check of this checkout's guard
returned native_token_print for "tvly auth" and None for "python3
tools/credentials/credential_run.py tavily -- tvly auth", and a plain
"tvly auth" prints a key's first eight and last four characters, which
masking cannot catch. Making the runner the default first would reopen shapes
the guard refuses today. The guard model is phase 2 of the same pull request
series, by another builder, after the guard tightening lands.

AGENTS.md is restored to the base sentence. recipes/tavily.md is main's text
again (the interim rotation step of #490 and the keyring exec commands are the
default) with the runner added as an option: its commands, --check and
--json, and a dated sentence that the guard does not read its command yet
(phase 2) and that a "tvly auth" behind it would print part of the key.
docs/secret-storage.md "Using a key" (now titled "available") says the runner
is available and becomes the default after the guard model; the Alpaca
consumer and keyring exec passages it had replaced say subshell and exec
again, with the runner as an alternative. The section also lists the new
core_pattern refusal, the encoder forms, the bounded hold-back, the
non-blocking output with its 256 KiB queue and 2 s drain, the forms that
stay unmasked (nested encodings, wrapped base64, other escapers, fragments)
and the residual that a same-uid debugger, ptrace or /proc read is out of
scope. The decision record's Part 4 gets a "Not yet" line for the guard model
and the default-path flip, the repair round with its pins (torvalds/linux
v6.16 fs/coredump.c, systemd v257 src/coredump/coredump.c), new evidence
counts and an overturn condition; the macOS page's runner paragraph says
"may" and notes that the core-pattern check is skipped there. The module
docstring of credential_run.py carries the same status.

Checked with tests.test_credential_run, tests.test_credential_status,
tests.test_credential_tools, tests.test_install_claude_profile,
tests.test_adoption_docs_consistency and tests.test_secret_path_guard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py: end the command's process group on every way out, forward all fatal signals, ask for SIGTERM when the runner dies (second review, item 1)

The second review found that the command could outlive the runner: it runs
in its own session, only SIGINT, SIGTERM and SIGHUP were forwarded, a
SIGQUIT, SIGUSR1, SIGUSR2 or SIGALRM ended the runner alone, and
descendants still running after the 2 s drain were left running with the
key in their environment.

- run_command ends the command's process group on every way out (a
  finally): SIGTERM, TERM_GRACE_SECONDS (2 s), SIGKILL, then reap. It
  returns at once when no member is left, so a normal run pays nothing.
  Same order as bazelbuild/bazel@d2545923 src/main/tools/process-tools.cc
  KillEverything L94-110.
- SIGQUIT joins the shutdown signals; SIGUSR1, SIGUSR2 and SIGALRM are
  forwarded too, without the shutdown flag, because a command may handle
  them and keep running (dd prints progress on SIGUSR1) and the flag would
  drop its output for the rest of the run. The exit code is the command's,
  128+N when it died of N.
- On Linux the command asks the kernel for SIGTERM when the runner dies
  (PR_SET_PDEATHSIG through ctypes, set in the forked command before exec,
  skipped where unavailable or on failure, never failing the start). It
  fires when the thread that forked the command ends; run_command's
  signal.signal calls already confine it to the main thread. A runner that
  ended before the call is noticed by comparing the parent pid.

Failed first (the five new or changed tests on the previous head, 11
failures): SIGQUIT, SIGUSR1, SIGUSR2 and SIGALRM ended the runner alone
(exit -3, -10, -12, -14 instead of the command's code or 128+N); a
descendant outlived the runner; a failure inside the relay left the
command running (returncode None instead of -15); a SIGKILLed runner sent
its command nothing.

Limit that remains, stated in the documentation with the next commits: the
parent-death signal reaches the command only (the kernel clears it for the
command's children, man7.org PR_SET_PDEATHSIG(2const)); a descendant that
leaves the process group (setsid, setpgid) is out of reach of the group
kill; a SIGKILL of the runner before it forked, and a same-user debugger,
are out of scope.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py: a watchdog ends the command's whole process group when the runner is killed (second review, item 1, the SIGKILL case)

PR_SET_PDEATHSIG (the previous commit) reaches the command only: the
kernel clears it for the command's own children (man7.org
PR_SET_PDEATHSIG(2const)). Measured on the previous head with the new
test: a SIGKILLed runner ended the command, and a descendant it had
started went on and wrote its marker file. The brief's own test (the
grandchild dies after a SIGKILL of the runner) cannot pass with the
parent-death signal alone, so the runner starts a watchdog.

- The watchdog is a second interpreter (-I -S, an empty environment, its
  own session) started right after the command. It holds a pipe whose
  write end only the runner has (made after the command, not
  inheritable) and the number of the command's process group. When the
  runner dies by any signal the kernel closes the pipe, the watchdog
  reads end of file and ends the group: SIGTERM, TERM_GRACE_SECONDS,
  SIGKILL. A runner that ends in order writes one byte first, waits for
  the watchdog to exit and leaves none behind; the watchdog then touches
  nothing (exit 0; it exits 1 after acting on end of file).
- It ignores SIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGUSR1, SIGUSR2 and
  SIGALRM, and lives in its own session, so a group-wide kill of the
  runner (timeout -k) does not reach it. CPython's multiprocessing
  resource tracker is the same shape: a helper that waits for the end of a
  pipe and ignores SIGINT and SIGTERM (python/cpython@v3.13.15
  Lib/multiprocessing/resource_tracker.py L8, L246-267, L425-429).
- It cannot fail the run: a watchdog that cannot start leaves the
  parent-death signal and the group kill at the end of run_command.
  WATCHDOG is a module constant that the test launcher can switch off, to
  see the parent-death signal alone; there is no environment switch.

Tests: the tree test starts the command, a descendant and one that
ignores SIGTERM, each writing a marker after a delay; the runner is
SIGKILLed and none of the three may write it (the SIGKILL comes after the
2 s grace). The watchdog is also tested on its own: end of file ends the
group, a byte first leaves it alone, a member that ignores SIGTERM is
killed after the grace, and the seven signals do not stop it. A runner
that ends in order tells its watchdog (exit 0) and leaves none.

Limits, unchanged from the previous commit and to be stated in the
documentation: a SIGKILL of the runner before the watchdog started (a
few milliseconds after the command's fork; the parent-death signal still
reaches the command), a descendant that leaves the process group with
setsid or setpgid, a set-user-ID command (the kernel clears the
parent-death signal and none can be signalled), and a same-user debugger.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py: strip other entries' pointer variables; pin the reserved-name rule and the relay's stdin sharing (second review, items 2 and 3)

Item 2. The runner removed the variables, optional_variables and
must_not_be_set names of every entry from the command's environment, but
not the pointer_variables of the other entries. A command run under one
id could therefore load another entry's store file through a pointer that
the documented shell profile sets (ENV_FILE, SEC_CONTACT_ENV,
PIT_ALPACA_ENV_PATH, PAPER_ENV_FILE_2, ...). child_environment now takes
the selected entry and removes every entry's pointer variable except the
ones that entry itself declares (alpaca-paper keeps PAPER_ENV_FILE,
ENV_FILE and PIT_ALPACA_ENV_PATH, the paper units' own pointers; tavily
keeps none).

Failed first: the changed test_strips_other_inventory_names_from_the_child
and a new inventory-driven test (for each of the seven injectable ids, all
pointers of the inventory set in the caller's environment; the command
must see exactly its own entry's) failed on the previous head, 8 failures.

Item 3. Two rules had no pinning test. Both were checked with a mutant
against a copy of the previous head's test module: the mutant survived.
- reserved_variable: a planted inventory entry that declares LD_*, DYLD_*
  or PYTHON* in variables or optional_variables is refused before any
  store is read (in-process main with a planted inventory, and a
  read_store that fails if it is reached), and a name that merely starts
  alike (LDAP_BIND_KEY, MY_PYTHON_KEY) is not refused. Mutants: the check
  removed; only LD_; required variables only. All three survived the
  previous suite and are caught now.
- the relay's stdin identity: one socketpair end is fds 0, 1 and 2 of the
  runner, the command looks at os.get_blocking(0) after its output has
  passed through the relay, and it must still be blocking. The existing
  pty test cannot fail on this (a terminal is never flipped). The mutant
  that builds the sinks without the stdin identity survived the previous
  suite and is caught now.
- also new: a command that handles SIGUSR1 and keeps writing is delivered
  every byte although the consumer is slow, which pins that only the
  shutdown signals start the runner's no-wait rule (a mutant that treats
  every forwarded signal alike is caught).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* tests: the unmasked-forms test reads the runbook's list, the encoder tests are renamed, a neutral fixture name for CodeQL (second review, items 5 and 6)

Item 5, tests. The decision record said "a test pins the list" of unmasked
forms, but the test printed five forms and never looked at the runbook.
- print_forms reads the "What masking does not cover" list of
  docs/secret-storage.md (whitespace normalised) and asserts, for every
  form it prints, that the list names it; it then checks the runner prints
  the form as it is. Negative control: with the phrase "colour codes"
  removed from the runbook the test fails.
- New forms, each named in the runbook now: fold-wrapped, xxd and
  hexdump -C columns, wrapped table cells, colour codes, hex with
  separators, a value with a single quote requoted the way set -x, printf
  %q and shlex.quote write it, a value interleaved with the bytes of a
  second writer (a second process on the same pipe), and a value written
  by the command to an inherited read-write stdin (a socket end stands in
  for a terminal), which never passes the relay.
- The tests named after "every real encoder" are renamed
  (test_the_percent_and_json_encoder_forms_...): the PHP and Go JSON forms
  are string-replacement models of what those encoders write, and only the
  urllib.parse and json.dumps forms are executed; the helper's docstring
  says so.
- credential_run.py: the comment on the JSON escape forms had lost its
  escape examples (a tool pipeline decodes a backslash followed by u and
  four hex digits); they are restored. No code change.

Item 6. The pull request's code scanning reported
py/clear-text-storage-sensitive-data (high) at line 141, the
path.write_bytes(...) in the plant helper. The SARIF data flow of the
analysis (1856995192, gh api with Accept: application/sarif+json) starts at
the variable named "secret" in RunnerCase.alpaca(), which holds a fake()
value: a name heuristic on a synthetic fixture. The variable is renamed to
a neutral name, with a comment saying why; no suppression comment is added.
CodeQL was not run locally, so CI is the check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: the second review's limits, corrections and repair round for the key runner (items 4 and 5)

docs/secret-storage.md, "Using a key" only:
- says which signals the runner forwards (SIGINT, SIGTERM, SIGHUP,
  SIGQUIT, SIGUSR1, SIGUSR2, SIGALRM) instead of "signals pass through";
- says that the other entries' pointer variables are removed from the
  command's environment and the entry's own stay;
- restores the JSON escape examples (Go's and PHP's forms) that a tool
  pipeline had turned into the characters they stand for;
- adds "The command's lifetime": the group kill at the end and on every way
  out, the parent-death signal, the watchdog;
- adds to the limits list, once each and dated 2026-09-29: the
  non-blocking flag on the open file description (with the planned fix);
  that "cannot hold the runner" holds only for the pipes and sockets the
  runner can flip; the new unmasked forms and the rule that a value is
  masked only where a whole form appears unbroken in one stream; and the
  command's lifetime limits (a SIGKILL before the watchdog started, a
  descendant that leaves the process group, a set-user-ID command, Linux
  only, ptrace).

docs/decisions/2026-09-29-key-management.md, Part 4 only:
- the second repair round with its six items, the sources it read
  (man7.org PR_SET_PDEATHSIG(2const), python/cpython@v3.13.15
  resource_tracker.py, bazelbuild/bazel@d2545923 process-tools.cc,
  krallin/tini@924c4bd6 tini.c, torvalds/linux@v6.16 fs/coredump.c), the
  limits, and the CodeQL finding with its data flow;
- an unverified lead, not a claim: at RLIMIT_CORE exactly 1 the kernel
  aborts a piped core dump (fs/coredump.c L801-819), so the runner might
  set (1, 1) and work on a host with a piped core pattern; not tested
  against a real collector;
- claims the review showed unsupported are corrected: "a test pins the
  list" (a test now reads the list), "the same unmasked bytes" (the
  committed check compares outputs with the markers removed), the timing
  figures (the tests assert under 1.5 s and 1.5 to 10 s; 0.1 s and 2.1 s
  were single manual measurements), the Go escape example that had lost
  its backslash sequence;
- a second deviation from the build contract: public names skip the
  6-byte minimum length, being unmasked;
- the mise spike's note that __MISE_DIFF can be closed with unset in a
  task, which does not change the rejection (a masked run passes no stdin);
- the evidence class updated to the test and mutant counts of this round.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py: wrap two comment lines that ran past 120 columns

A docstring line and the WATCHDOG comment were 121 and 122 columns; the
file had none before this round. No code change: the final mutation pass
runs on this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py: signal the command's group only while its unreaped zombie holds the number, and put the runner's handlers back before the parent-death signal is armed (third review, findings 1 and 2)

Finding 1 (high). The runner's final killpg and the watchdog kept only
the number of the command's process group and acted after the command had
been reaped. Once nothing holds a number the kernel may give it to a
stranger, and a signal to it reaches the stranger's group.

- Command wraps the command and is the one place that reaps it. The
  runner learns that the command exited without reaping it (os.waitid with
  WEXITED|WNOHANG|WNOWAIT in the relay, WEXITED|WNOWAIT when it waits), signals
  the group while the command is still a zombie, whose pid and so the group's
  number nobody can take (Linux kernel/pid.c __change_pid; XNU kern_fork.c
  skips a pid that is a process, group or session id), then tells the
  watchdog to stand down, and reaps last. signal_group() refuses once the
  command is reaped, forward() uses it, and the forwarded signals are
  blocked while the command is reaped, so no handler runs between the reap
  and its record. The watchdog therefore acts only when the runner died
  without telling it.
- kill(-pgid, 0) cannot tell that a group is empty while the command's
  zombie is in it (a zombie stays a member until reaped, on Linux and in
  XNU's reap_child_locked), so the live members are counted from /proc
  (live_members): a command that leaves nothing but its own zombie is not
  signalled at all, and the wait after SIGTERM ends when the count is 0.
- A default SIGCHLD is set before the command starts: an inherited ignored
  one makes the kernel reap the command at once (no zombie, no status).
- PINNED (os.waitid with WNOWAIT and a /proc) is False on macOS: os.waitid
  is not compiled for Apple before CPython 3.13 (posixmodule.c
  HAVE_WAITID && !defined(__APPLE__) in v3.9.25 to v3.12.0), and macOS has no
  /proc. There the exit is seen by reaping, the group is never signalled
  after that, and only a failure inside the runner (the command unreaped)
  ends it: SIGTERM, the grace, SIGKILL. A recorded loss, in the docs. The
  brief took os.waitid for available on macOS; it is from 3.13.
- What remains, in the docs: if the runner dies before it stands the
  watchdog down and init reaps the command before the watchdog acts, a
  recycled number is theoretically possible on a host with a small pid space.

Failed first (the previous head with only PINNED declared): the recorded
events showed killpg on a reaped command in the runner's final cleanup and
in the probes after it ([killpg 15 reaped], [killpg 0 reaped] ...), the
same for a command that left nothing behind, and a signal that reached the
runner after the reap was forwarded. Tests: the recording launcher shows
every killpg with the command running or a zombie and the stand-down before
the reap (a lingering descendant that ignores SIGTERM and holds no pipe: TERM,
KILL, both on the zombie); a command that leaves nothing is not signalled;
Command and live_members on real processes; the fallback (PINNED False)
never signals after the reap, and a failure in the relay still ends the group;
a signal after the reap is not forwarded; SIGCHLD ignored at start still
gives the command's status; a descendant that obeys SIGTERM ends the wait
at once and is never sent SIGKILL. The recorder also sees the stand-down byte
itself (a write of one "."), which precedes the reap. The whole module also
passes with PINNED forced off in this process and every runner, as a macOS
without os.waitid and /proc would run it (92 tests, 11 skipped: 9 that need the
pinned cleanup and the 2 of the real re-execution).

Finding 2 (medium). The command's pre-exec code ran with a copy of the
runner's handlers; forward() only records a signal, so a parent-death SIGTERM
that arrived between the parent-pid check and the exec was queued in the copy
and the command went on to exec and run (the reviewer's probe: pause after
the check, kill the runner, resume: exit 42). The hook now puts every signal
the runner handles back to SIG_DFL and clears the inherited signal mask
before it arms PR_SET_PDEATHSIG, and keeps the parent-pid check after arming.
pre_exec_pause(stage) is a module function that does nothing; the test
launcher replaces it (RUNNER_TEST_SETUP is read by the launcher, never by the
tool) to hold the forked command at "before_arming" or "after_check".

Failed first (with the seam in place and the reset absent): the command
outlived its killed runner, and all seven handlers were the runner's and
SIGUSR1 stayed blocked. New tests: killed while held after the check the
command is gone and never runs; killed while held before arming it ends by
the parent-pid check and never runs (a pin of a rule that had none); started
with a signal blocked it sees default handlers and an empty mask.

Mutants, one per new rule (16, all caught). The mutation script now clears
the cached bytecode around each mutant: a same-size mutant written in the
same second as an earlier compile was served the earlier code once (a
survivor that a rerun caught).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: the third repair round for the key runner (group number held while signalled, pre-exec handlers)

docs/secret-storage.md, "Using a key" only:
- "The command's lifetime" says that the runner signals what is left of the
  group while the command is still a zombie that holds the group's number,
  then stands the watchdog down and reaps last, and that the forked command
  arms PR_SET_PDEATHSIG only after its copy of the runner's handlers is put
  back to the defaults;
- the lifetime limit (dated 2026-09-29, once): what remains if the runner dies
  before it stands the watchdog down and init reaps the command first (a
  recycled number on a host with a small pid space, macOS stops at 99,999; the
  watchdog's prompt reaction bounds the window); that the pinned cleanup is
  Linux-only (no os.waitid on macOS before Python 3.13, no /proc there) and
  what the runner does elsewhere (sees the exit by reaping, signals nothing
  after it, so a descendant of a command that already exited is left running).

docs/decisions/2026-09-29-key-management.md, Part 4 only:
- the third repair round with its two findings, the failing-first evidence,
  the platform correction (the brief took os.waitid for available on macOS; it
  is from CPython 3.13) and the sources read: man7.org wait(2) WNOWAIT,
  torvalds/linux@v6.16 kernel/pid.c __change_pid L349-369, xnu-12377.121.6
  kern_fork.c L972-974, kern_exit.c L2969 and proc_internal.h L767, and
  CPython posixmodule.c and os.rst for os.waitid;
- the selection bullet and the limits list brought up to date;
- the evidence class: the test and mutant counts of this round.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* credential_run.py, docs: drop a claim about Bazel's wrapper that the source does not support; record the watchdog's measured reaction

- end_group's docstring said that bazelbuild/bazel's KillEverything signals
  "after waitpid has reaped its child". Read at d2545923: the wrapper calls
  KillEverything from its signal handlers (process-wrapper-legacy.cc L172-180)
  while it waits, and process-tools.cc L94-110 gives the order (SIGTERM to the
  group, a timeout, SIGKILL to the group), which is all the docstring may
  claim. The clause is removed; no code changes.
- The runbook and Part 4 said the watchdog reacts "within milliseconds".
  Measured once, 25 runs on an idle host, from the pipe's closing to the death
  by SIGTERM of a sleeping member of its group: median 1.3 ms, largest 1.4 ms.
  The figure is now stated where the claim is, with its scope.

Also checked on this host (not a repository test): a zombie group leader
that is the only member keeps killpg(pgid, 0) succeeding until it is reaped,
and ESRCH after, which is why the group's live members are counted from /proc.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Register the key runner changes in manifests/evidence.json (hot-file commit, last)

Hashes of the seven changed pinned files on top of main's manifest at b2767df (docs/lanes.md protocol: main's copy, register
each file, component_matrix and new_host_grand_list --write; validate.py passes: 69 components, 8080 hashed files, 171
receipts). No receipt entry: this PR records no native run; the first real use is the later canary proof.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Scout <scout@local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant