Skip to content

Guard: a numbered pointer such as PAPER_ENV_FILE_2 is a credential pointer; alpaca-paper-2 inventory row - #481

Merged
seathatflowsinourveins merged 4 commits into
mainfrom
claude/guard-pointer-coverage-20260929
Sep 29, 2026
Merged

seathatflowsinourveins merged 4 commits into
mainfrom
claude/guard-pointer-coverage-20260929

Conversation

@seathatflowsinourveins

Copy link
Copy Markdown
Owner

Scope

  • What this PR changes, in one or two sentences: secret_path_guard.py now recognises a numbered credential pointer such as $PAPER_ENV_FILE_2 (before, every reader, redirect, source and dump on it passed the guard while the account-1 pointer was blocked), and adoption/credential-inventory.json gains the alpaca-paper-2 row that names the second paper account's existing file (nothing here writes it).
  • Base commit: 6d626654
  • Lane: lane:foundation (the guard, the inventory and the docs are foundation paths in docs/lanes.md; the trading lane was told and asked for exactly this inventory-only form).
  • Owned paths touched: scripts/hooks/secret_path_guard.py, adoption/hooks/claude/SHA256SUMS, adoption/credential-inventory.json, docs/secret-storage.md (a table row and one export line), tests/test_secret_path_guard.py, tests/test_credential_status.py, and the last commit's manifests/evidence.json hashes.

SOTA sources

  • Platform contract the guard implements: Claude Code hooks reference, PreToolUse (exit 2 blocks the call and returns the reason), https://code.claude.com/docs/en/hooks, fetched 2026-09-29 against Claude Code 2.1.284 (installed and the latest release, v2.1.284 of 2026-09-28).
  • The pointer contract this fixes: this repository's adoption/credential-inventory.json (pointer_variables) and the existing tests/test_secret_path_guard.py::test_inventory_pointer_variables_are_guarded, which derives every pointer from the inventory and requires the guard to block a reader on it.
  • No upstream tool replaces this guard: the 2026-09-29 injector survey of 25 candidates (op, bws, infisical, doppler, OpenBao/Vault Agent, SOPS+age, dotenvx, varlock, secretspec and others, with a live maintenance check of each) found none that stands in for a PreToolUse command guard; the guard stays the repository's own heuristic and is documented as such (docs/secret-storage.md, Threat model).
  • Input re-derived, not trusted: a peer's unreviewed partial patch (session ecosystem-roadmap-2026, 2026-09-29) had the same regex idea; it was read as untrusted input and every row here was checked against the previous guard.

Evidence-class table

Claim Evidence class Command / receipt
At 94894f54 a reader, redirect, source and dump on "$PAPER_ENV_FILE_2" passed the guard local_integration in-process check() on inert strings, previous guard against the new one: all 9 new BLOCKED rows flip from allowed to blocked, and the 4 new ALLOWED rows (--env-file, the rate-limit probe, wc, stat) keep their verdict
Failing first: with only the new inventory row, test_inventory_pointer_variables_are_guarded failed for PAPER_ENV_FILE_2 (check() returned None, not credential_file_read) local_integration unittest output, AssertionError: None != 'credential_file_read'; it passes with the regex change
Four suites after the change: 130 tests, 1 failure (test_host_profile_copy_is_verbatim, expected until the host copy is reinstalled; it skips in CI), 3 skipped local_integration commands below
Repository validation local_integration python3 scripts/validate.py: passed (69 components, 7990 hashed files, 165 receipts)
The numbered-pointer rule is conservative: only _<digits> is recognised; PAPER_ENV_FILE_B still passes local_integration asserted in EXPECTED_PASS_THROUGH so a change that closes it is noticed

Local commands run

$ TMPDIR=/var/tmp PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_secret_path_guard tests.test_credential_status tests.test_credential_tools tests.test_install_claude_profile
Ran 130 tests ... FAILED (failures=1, skipped=3)   # the one failure is the host-copy check below
$ python3 scripts/validate.py
{"components": 69, "hashed_files": 7990, "profiles": 4, "receipts": 165, "status": "passed"}

Host step after merge (a host write, announced to the live sessions first): reinstall the guard so the user-level copy matches, python3 tools/adoption/install_claude_profile.py --only guard, then test_host_profile_copy_is_verbatim passes.

Decision record

None new for this change: it applies the existing inventory-to-guard contract. The key-management decision record (docs/decisions/2026-09-29-key-management.md, selection, alternatives, sources with pins and the overturn comparison) follows with the larger change.

Cross-family review: one read-only GPT-6 round (codex exec -s read-only, gpt-6-astra, effort max, live search). One note (a new test row was already blocked by another rule before the change): repaired in a2cf068e by checking every candidate row against the previous guard; the reviewer then reported no further findings.

Host evidence

Not applicable: no file under evidence/hosts/ changes.

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: the inventory row names variables and a pointer only, and the file it names already exists.
  • 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

@seathatflowsinourveins seathatflowsinourveins added the lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers label Sep 29, 2026
@seathatflowsinourveins
seathatflowsinourveins force-pushed the claude/guard-pointer-coverage-20260929 branch from f1198bc to 1a505bf Compare September 29, 2026 02:16
Scout and others added 4 commits September 28, 2026 22:48
…inter; alpaca-paper-2 inventory row

At 94894f5 every reader, redirect, source and dump on "$PAPER_ENV_FILE_2" passed
secret_path_guard.py: the word boundary after PAPER_ENV_FILE failed before "_2", so only
the account-1 pointer was guarded although the second account's pointer already exists in
the operator's shell startup file. The fix gives every pointer name an optional numeric
suffix.

Failing first: with only the new inventory row (alpaca-paper-2, pointing at the existing
file; no tool writes it), tests.test_secret_path_guard's existing
test_inventory_pointer_variables_are_guarded failed for PAPER_ENV_FILE_2 (check() returned
None, not credential_file_read); it passes with the guard change. New rows: BLOCKED forms
for the numbered pointer (cat, head, a read loop, tracing while sourcing, an environment
dump after sourcing, SEC_CONTACT_ENV_2), ALLOWED forms (--env-file, wc, stat), and a status
test that mirrors the account-1 row. adoption/hooks/claude/SHA256SUMS carries the new guard
hash; a host reinstalls the guard with tools/adoption/install_claude_profile.py --only guard
after merge (tests.test_secret_path_guard.test_host_profile_copy_is_verbatim compares the
host copy and skips in CI).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…non-numeric-suffix gap

Cross-family review note: one new BLOCKED row (a `set -a` source of the numbered pointer
followed by an env dump) was already blocked by the environment_dump rule before the change,
so it tested a reason change, against the comment above it. Checked every candidate row
against the previous guard (the origin/main version, inert strings): replaced it with three
rows that pass the old guard and are blocked by the new one (a declare -p after sourcing, an
inline os.environ dump after sourcing, xtrace with source), and recorded that a pointer with
a non-numeric suffix (PAPER_ENV_FILE_B) is not recognised, as an asserted known gap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…; record the systemd-run launcher gap

The trading lane asked for a row proving the loader path a unit uses (systemd-run --user, a
bash -ic that hands the pointer to a loader as --env-file) still passes for both accounts, so
the numbered-pointer fix never blocks a unit. Added three ALLOWED rows (account 1, account 2,
a double-quoted form). Checked against the previous guard and the new one: all pass on both.

While checking, found that systemd-run is not a modelled launcher: a reader it starts is not
inspected, for either account, and with --pipe --wait its output returns to the caller. Recorded
as two asserted known gaps (EXPECTED_PASS_THROUGH) so the change that closes it is noticed; the
guard work in the key-management change models it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…idence.json (hot-file commit, last)

Hashes of the six changed files re-registered on top of main's manifest (docs/lanes.md protocol:
checkout main's copy, register_file for each, component_matrix and new_host_grand_list --write,
validate.py passes: 69 components, 7990 hashed files, 165 receipts).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@seathatflowsinourveins
seathatflowsinourveins force-pushed the claude/guard-pointer-coverage-20260929 branch from 1a505bf to b40b359 Compare September 29, 2026 02:49
@seathatflowsinourveins
seathatflowsinourveins merged commit c26800f into main Sep 29, 2026
25 checks passed
@seathatflowsinourveins
seathatflowsinourveins deleted the claude/guard-pointer-coverage-20260929 branch September 29, 2026 03:42
seathatflowsinourveins added a commit that referenced this pull request Sep 29, 2026
…, memory-only status rows, coverage warnings, decision record started (#490)

* Tavily moves to a 0600 store file; the checker reports memory-only rows 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>

* set_credential.py --from-env: create-only, isolated, one-variable writer 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>

* --from-env needs python3 -I -S, refuses before any write, and never re-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>

* Docs and decision record: Tavily's stored key, the keyring as transport, 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>

* credential_status: compare /proc/keys descriptions whole and report them 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>

* Tavily rotation: a dated interim step to refresh the keyring copy

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>

* Register the Tavily persistence changes in manifests/evidence.json (hot-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>

---------

Co-authored-by: Scout <scout@local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
seathatflowsinourveins added a commit that referenced this pull request Oct 8, 2026
### Scope

- What this PR changes: it adds the optional `anthropic-api-2` entry directly after `anthropic-api` in `adoption/credential-inventory.json`, so one command can select an additional Console key. It is an additional key: nothing is replaced. The setter, the runner and the guard are unchanged: the setter and the runner read the inventory, and the guard's rules already cover the variable and the whole store directory. Both entries declare only `ANTHROPIC_API_KEY`, which stays in `must_not_be_set`.
- Base commit: `e48de2e6fa1dd3b740af4793ea5824ea35476dba`
- Lane: `lane:foundation`
- Owned paths touched: `adoption/credential-inventory.json`, `docs/secret-storage.md`, `docs/decisions/2026-10-08-anthropic-api-second-key.md`, `docs/harness-defaults.md`, `tests/test_credential_run.py`, `catalogs/foundation/upstream-surface-dispositions.json`, and `manifests/evidence.json` (the hashes of those six files, written by `scripts/host_receipts.py:register_file`).

The new entry differs from the first in exactly six fields: `id`, `label`, `store.path_template` (file `anthropic-api-2.env` in the same directory), `loaders`, `rotation` and `notes`. Class, status, lane, variables, optional and pointer variables, consumers and store kind are identical. The repository precedent is `alpaca-paper-2` (#481, `c26800f3`).

**Scope beyond the brief.** Four edits go beyond adding the entry. They are declared here so that the read of this PR covers them:

1. **One sentence of the first entry's `notes` is corrected.** Old: "exported in a shell it would override every Claude Code session's native sign-in". New: "a shell export can switch Claude Code from subscription sign-in to API billing (headless mode uses the key when present; interactive mode first asks for approval)". Source: Claude Code's [environment variables](https://code.claude.com/docs/en/env-vars#variables) page, where a present key is always used in non-interactive mode (`-p`) and is approved once in interactive mode before it overrides the subscription. The new entry gives the same reason, so the two entries agree. No other field of `anthropic-api` changes.
2. **One row is added to the anti-pattern log of `docs/harness-defaults.md`.** It records that correction, which that page's "Record a correction the same turn" rule asks for.
3. **`docs/secret-storage.md` gains the first key's table row as well.** The entry table had no `anthropic-api` row. It now lists both entries, followed by one paragraph on selecting a key for one command.
4. **Five line citations into `docs/secret-storage.md` are updated in `catalogs/foundation/upstream-surface-dispositions.json`,** because the added rows moved the cited lines down by 18. The `source` locators of `CLAUDE_CODE_ENABLE_TELEMETRY` (1697 → 1715), `OTEL_LOG_USER_PROMPTS` and `OTEL_LOG_ASSISTANT_RESPONSES` (1684 → 1702), and `OTEL_LOG_TOOL_CONTENT` and `OTEL_LOG_RAW_API_BODIES` (1686 → 1704) change; no disposition or reason does. Hosted `validate-shard-3` failed five subtests of `DispositionCitationTests.test_every_cited_line_names_the_key` at `88d981c7`; #858 made the same repair for its own insertion.

### SOTA sources

Public primary documentation, fetched 2026-10-08; every cited anchor resolves.

- Anthropic [API overview, Getting API keys](https://platform.claude.com/docs/en/api/overview#getting-api-keys) and [Authentication, Create and use a key](https://platform.claude.com/docs/en/manage-claude/authentication#create-and-use-a-key): keys are created on the Console key page, the client SDKs pick up `ANTHROPIC_API_KEY` automatically, and a key scoped to one workspace needs no workspace header.
- Anthropic [Get your API key, Choose a key type](https://platform.claude.com/docs/en/get-api-key#choose-a-key-type), [Workspaces, API keys and resource scoping](https://platform.claude.com/docs/en/manage-claude/workspaces#api-keys-and-resource-scoping) and [Set workspace limits](https://platform.claude.com/docs/en/manage-claude/workspaces#set-workspace-limits): personal, service-account and legacy workspace keys; a multi-workspace key needs the `anthropic-workspace-id` header; spend limits are set per workspace.
- Claude Code [environment variables](https://code.claude.com/docs/en/env-vars#variables) and [authentication precedence](https://code.claude.com/docs/en/authentication#authentication-precedence): in non-interactive mode (`-p`) a present key is always used; in interactive mode the key is approved once before it overrides the subscription. These support the corrected wording.
- Anthropic [WIF reference, Profile configuration file](https://platform.claude.com/docs/en/manage-claude/wif-reference#profile-configuration-file): the configuration-profile alternative that the decision record names.
- [This repository at `c9ab2212142398234b6ba39a4cf33c5c2a6a643e`](https://github.com/seathatflowsinourveins/native-agent-stack/tree/c9ab2212142398234b6ba39a4cf33c5c2a6a643e), the reference implementation this change follows; the cited files are unchanged at the base commit: inventory entries `anthropic-api` (#841, `a35369aa`) and `alpaca-paper-2`; `tools/credentials/set_credential.py:load_entry`; `tools/credentials/credential_run.py:find_entry` and `injectable`; the guard's `SECRET_NAMES`, `STORE_PATHS` and `ENV_FILE_WORD` in `scripts/hooks/secret_path_guard.py`; the `RunnerCase` fixtures in `tests/test_credential_run.py`; `scripts/host_receipts.py:register_file` and `scripts/validate.py`. No credential runtime or dependency is added.

### Evidence-class table

| Claim | Evidence class | Command / receipt |
| --- | --- | --- |
| The Console key and environment contract, and the mode-dependent sign-in wording | source_review | The official pages linked above, fetched 2026-10-08 |
| The new entry differs from the first in six fields only; the inventory goes from 20 to 21 entries and from 10 to 11 injectable ids; of `anthropic-api`, only `notes` changes | source_review | Field-by-field comparison of both entries against `origin/main`, using the runner's own `injectable()` |
| The setter and the runner select separate files, leave the first file unchanged when the second is written, accept the same bare and quoted grammar, inject only the selected value and refuse expansion | local_integration; synthetic | `InjectionTests.test_second_anthropic_key_uses_the_same_setter_and_runner_grammar`: temporary store, generated fake values |
| The guard needs no edit: it lists no ids, its store rule covers the whole store directory, and the variable is already a guarded name | source_review; synthetic | `STORE_PATHS` and `SECRET_NAMES` in the guard; `tests.test_secret_path_guard`; `guard.read_command` on nine synthetic command forms returns the same reason for both ids (four store-path forms `credential_store_path`, the documented runner form allowed) |
| No leak in the six files of the first commit | local_integration | `betterleaks dir` 1.9.0 with `.gitleaks.toml` on a copy of those six files: no leaks, rc 0. The required gate is CI's pinned gitleaks 8.30.1, which is not installed on this host; hosted `secret-scan` passed at `88d981c7` |
| Each of the five re-pointed catalog locators names its key again, and no other maintained citation moved | local_integration; source_review | `tests.test_upstream_surface_watch`: 5 of 128 repository citations failed with the catalog at `88d981c7`, 0 fail now. Of 263 line citations into the four edited files, 126 point past the inserted lines: these 5; 46 pinned to a commit; 75 in dated records, frozen evidence or code comments that already pointed elsewhere before this PR or carry their own source revision |

Not measured here: the actual key type, the workspace spend limit, credit, fast mode and provider acceptance. A multi-workspace key needs the `anthropic-workspace-id` header and is outside this single-variable entry.

### Every changed existing test expectation

One constant changes, and two existing tests assert on it. The exact injectable set was `alpaca-paper`, `alpaca-paper-2`, `sec-contact`, `databento`, `typesafe`, `omniroute`, `tavily`, `claude-oauth-token`, `canary-e2e` and `anthropic-api`. `INJECTABLE_IDS` now adds only `anthropic-api-2`.

| Existing test | Old contract → new contract |
| --- | --- |
| `InjectionTests.test_only_the_selected_entrys_own_pointer_variables_stay_in_the_child` | Exactly the ten ids above are injectable, and each keeps only its own pointer variables → the same contract for eleven ids, adding `anthropic-api-2`, whose own pointer list is empty. |
| `InventoryAndGrammarTests.test_every_injected_name_is_masked_or_public` | Exactly those ten ids classify every injected variable as masked or public → eleven ids, adding the masked required variable of `anthropic-api-2`. The exact optional-variable classification is unchanged, because the new entry has none. |

No other existing assertion is edited. `NOT_INJECTABLE_IDS` is unchanged. The status module checks the canonical inventory dynamically and by subset. The boot-receipt allowlist test compares the receipt's rows with the canonical inventory, so its expected id sequence follows the inventory (20 → 21 rows; file-kind rows 18 → 19). The guard's tie tests read the inventory's variable and pointer names, which do not change. Fixtures are unchanged.

`InjectionTests.test_second_anthropic_key_uses_the_same_setter_and_runner_grammar` is new coverage, not a relaxed assertion.

### Local commands run

One module per command (no suite discovery). The twelve modules below ran on the tree committed as `5117734b`, the first commit on `e48de2e6`. The second commit, `88d981c7`, only removes two process claims from the decision record that nothing in the repository can verify; the docs module and the validator were run again on its tree. The third commit, `75fa64b3`, re-points five catalog citations; the last block covers it and the whole suite.

```
$ timeout 600 nice -n 10 ionice -c2 -n7 python3 -m unittest tests.<module> -v
tests.test_credential_status            rc 0   Ran 48    OK
tests.test_credential_tools             rc 0   Ran 34    OK
tests.test_credential_run               rc 0   Ran 94    OK
tests.test_credential_boot_receipt      rc 0   Ran 21    OK
tests.test_secret_path_guard            rc 0   Ran 90    OK (skipped=2: scratch adapter supplied by the permanent-test mutation driver)
tests.test_canary_proof                 rc 0   Ran 143   OK (skipped=134: reviewed ripgrep unavailable on this host)
tests.test_codex_worker_lane            rc 0   Ran 123   OK (skipped=12: real app-server runs need NAS_CODEX_INTEGRATION=1)
tests.test_host_requests                rc 0   Ran 49    OK
tests.test_new_wsl_definitive_defaults  rc 0   Ran 130   OK (skipped=3: GNU chmod --reference and readlink -f commands)
tests.test_adoption_docs_consistency    rc 0   Ran 39    OK (skipped=1: no profile's coverage differs between the pinned release and HEAD)
tests.test_sota_convergence             rc 0   Ran 157   OK
tests.test_ecosystem_manifest           rc 0   Ran 82    OK
$ python3 scripts/evidence_manifest.py --check
rc 0   {"files": 10901, "status": "passed"}
$ timeout 900 nice -n 10 ionice -c2 -n7 python3 scripts/validate.py
rc 0   {"components": 70, "hashed_files": 10901, "profiles": 4, "receipts": 239, "status": "passed"}
```

On the tree committed as `88d981c7`:

```
$ timeout 600 nice -n 10 ionice -c2 -n7 python3 -m unittest tests.test_adoption_docs_consistency -v
rc 0   Ran 39    OK (skipped=1)
$ timeout 900 nice -n 10 ionice -c2 -n7 python3 scripts/validate.py
rc 0   {"components": 70, "hashed_files": 10901, "profiles": 4, "receipts": 239, "status": "passed"}
```

Hosted validate at `88d981c7` (run 37846469750) failed one shard: `validate-shard-3`, `Ran 1514 tests`, `FAILED (failures=5, skipped=127)`. All five failures were the stale catalog citations. The third commit, `75fa64b3`, fixes them. On its tree:

```
$ timeout 600 python3 -m unittest tests.test_upstream_surface_watch -v
rc 0   Ran 114   OK (skipped=6)
$ timeout 600 python3 -m unittest tests.test_adoption_docs_consistency tests.test_credential_run
rc 0   Ran 133   OK (skipped=1)
$ timeout 900 python3 scripts/validate.py
rc 0   {"components": 70, "hashed_files": 10901, "profiles": 4, "receipts": 239, "status": "passed"}
$ for m in tests/test_*.py: timeout 600 python3 -m unittest tests.$m   (each module on its own, no discovery)
276 modules: 255 rc 0, 21 rc 1; 10,725 tests ran, 911 skipped
```

The 21 nonzero modules all passed in hosted validate at `88d981c7`. Locally they fail for host reasons, not because of this diff:

- **18 modules: `exchange_calendars` is not installed** in this host's Python 3.13. Hosted installs 4.13.2 from `.github/requirements-calendar.txt`. Sixteen modules do not import: the 13 `test_adaptive_paper_*` modules other than credential_race, plus `test_alpaca_admission_regressions`, `test_native_faults_min` and `test_order_throughput`. `test_ingest_snapshot` has 2 errors. `test_adaptive_paper_credential_race` has 5 mutation self-test failures, because its pristine run imports the runner module. All 18 fail the same way on a checkout of the base `e48de2e6`.
- **`test_token_e2e_grader`:** `git clone --local` from the worktree (on disk) into `/tmp` (tmpfs) fails with `Invalid cross-device link`. It passes on the base checkout, which lives on tmpfs.
- **`test_windows_terminal_defaults`:** the Claude Code client installed on this host reports a `plugin_notification` notification type that has no repository decision. The base gives the same failure.
- **`test_new_wsl_mcp_conformance_lifecycle`:** host-state dependent. Three runs at this head gave a listener assertion, a missing `cleanup.json`, then a pass; it passes on the base.

The fourth commit, `67d9e8d5`, corrected a stale comment citation in `blueprints/runtime-workers/openhands/resolver/patch_policy.py`. That citation was already wrong on main, so it is outside this PR's scope and will be handled separately. The fifth commit, `2fa607e1`, reverts it. The head's tree is identical to `75fa64b3`'s (tree `d399373a`). On it, `timeout 900 python3 scripts/validate.py` returns rc 0.

### Decision record

`docs/decisions/2026-10-08-anthropic-api-second-key.md`: the decision, the handling rules, the sources, the `alpaca-paper-2` precedent, the correction, the evidence boundaries, the alternatives and what would reopen the decision, and the inverse.

### Host evidence

Not applicable: this PR adds or changes no file under `evidence/hosts/`.

### Checklist

- [x] New/changed GitHub Actions are pinned to a full commit SHA with a version comment (none changed).
- [x] New/changed workflows declare least-privilege permissions (none changed).
- [x] No secrets are printed, logged or committed; no new required secret was added. No credential value or credential store was read, inspected, created or changed for this PR: the tests use a temporary store and generated fake values, and the setter, launcher and runner were not run against a real entry.
- [x] No new paid hosting, subscription or billing surface is introduced by this declaration. The key itself is supplied later through the existing hidden prompt; its credit and spend limit are not measured here.
- [x] Peer-owned untracked files and worktrees were preserved.

### Landing notes

- This PR declares a store; it neither creates one nor opens the paste window.
- #850 (open) inserts `anthropic-admin` at the same position in the inventory and edits the same `INJECTABLE_IDS` line; #769 and #770 (drafts) also touch the inventory, `docs/secret-storage.md` and `tests/test_credential_run.py`. Whichever lands second needs a rebase and a fresh `register_file` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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