Add pi-provider named-token lifecycle and forward migration #30

Merged
vnprc merged 2 commits from agent/named-tokens-lifecycle into master 2026-08-24 02:42:35 +01:00
Member

Nothing changes on a running machine until someone runs one of the new commands by hand: this touches the host-side pi-provider tool and its tests, not any VM configuration.

A Pi credential can now hold several named bearer tokens instead of one. Each token is encrypted into its own file at secrets/pi-credentials/<credential>/<token>.age, and at most one of them is marked as the deployment default.

New commands manage them: pi-provider token list|add|rotate|remove|default <credential> [<name>], plus pi-provider migrate, which converts an existing checkout to the new record shape and file layout.

Migration never asks for or decrypts a bearer. It rewrites registry records and moves already-encrypted files, and it refuses a checkout that mixes old and new records, a destination that already exists, or a dirty working tree.

The old provider-scoped pi-provider rotate and the --rotation-strategy flag are gone; both now print the command that replaced them rather than failing obscurely.

What stays identical: a bearer is still accepted only from a hidden prompt and piped straight into Age, never into argv, environment, regular staging, logs, diagnostics, or output.

What stays identical: the clean-tree, tmpfs staging, lock, journal, rollback, and pi-provider recover guarantees, now extended to every per-token path, the token directory, and the migration moves.

What proves it: nix build .#checks.x86_64-linux.provisioning-contract, which runs shellcheck plus 192 assertions, including crash-and-recover sweeps of every new mutation boundary and a leak scan of runtime-generated synthetic bearers.

If this is wrong, revert the commit. Nothing deployed depends on it until the secrets and archetypes slices land, and migrate changes data only when a human runs it.

Risk

Residual risk is R3 High, matching the plan's slice table: recoverable multi-file moves, prompt-only plaintext, no accidental activation or removal. Migration now moves several files and creates directories in one transaction, so its failure modes are broader than any previous single-file operation; the journal records the token directory as a first-class object with its pre-run absence, and rollback restores or prunes it accordingly. Every prune is empty-directory-only rmdir after the managed files are gone, so an operator or concurrent writer that populated the directory is preserved and reported rather than removed recursively. token add never activates its token or moves the default, and token remove refuses both the current default and the last token of a credential that still serves providers — that last refusal makes a directory prune on token remove unreachable in practice, since registry validation keeps providers non-empty; the branch stays so the directory can never be orphaned, and the test witnesses the refusal and the preserved directory instead. Retarget prompts for every token before the journal exists, so an incomplete set of bearers commits nothing. Synthetic fixtures bound public behavior only; no private registry, ciphertext, or live token was read, migrated, or tested.

Validation

nix build .#checks.x86_64-linux.provisioning-contract from this worktree: succeeds, building provisioning-contract and nexus-provisioning-scripts. Its log ends with pi-provider tests passed: 192 assertions.

bash -n and shellcheck -x over scripts/pi-provider and tests/pi-provider.sh (run by that check, and directly): clean, with no findings at any severity.

Four deliberate sabotages of the implementation, each run against the unmodified test suite and reverted afterwards, confirm the new assertions can fail: making token add set defaultToken fails "token add never activates the new token or moves the deployment default"; adding a recursive rm -rf fallback to the prune fails "retirement reports a token directory a concurrent writer populated"; dropping migrate's delete of the pre-named-token ciphertext fails "migrate kept the flat ciphertext"; and leaving directory creation off the journal fails "ordinary rollback kept the created token directory".

The witness covers migration with no bearer input and no age invocation at all; independent token add, rotate, remove, default, and list; no activation on add; all-token retarget including a partial-bearer run that commits nothing; shared-provider impact reporting; last-token and current-default removal refusals; provider retirement removing the record and every named ciphertext; token-directory creation and empty-only pruning across migrate, rollback, recover, and retire, including a populated-directory race driven by the rm wrapper; dry runs; interrupted mutation, migration, and retirement at twelve, ten, and four kill boundaries respectively, each recovered exactly; and a final scan proving no runtime-generated synthetic bearer reaches any file, child argv, child environment, or command output.

Refs allod/strategy#36

Nothing changes on a running machine until someone runs one of the new commands by hand: this touches the host-side `pi-provider` tool and its tests, not any VM configuration. A Pi credential can now hold several named bearer tokens instead of one. Each token is encrypted into its own file at `secrets/pi-credentials/<credential>/<token>.age`, and at most one of them is marked as the deployment default. New commands manage them: `pi-provider token list|add|rotate|remove|default <credential> [<name>]`, plus `pi-provider migrate`, which converts an existing checkout to the new record shape and file layout. Migration never asks for or decrypts a bearer. It rewrites registry records and moves already-encrypted files, and it refuses a checkout that mixes old and new records, a destination that already exists, or a dirty working tree. The old provider-scoped `pi-provider rotate` and the `--rotation-strategy` flag are gone; both now print the command that replaced them rather than failing obscurely. What stays identical: a bearer is still accepted only from a hidden prompt and piped straight into Age, never into argv, environment, regular staging, logs, diagnostics, or output. What stays identical: the clean-tree, tmpfs staging, lock, journal, rollback, and `pi-provider recover` guarantees, now extended to every per-token path, the token directory, and the migration moves. What proves it: `nix build .#checks.x86_64-linux.provisioning-contract`, which runs shellcheck plus 192 assertions, including crash-and-recover sweeps of every new mutation boundary and a leak scan of runtime-generated synthetic bearers. If this is wrong, revert the commit. Nothing deployed depends on it until the secrets and archetypes slices land, and `migrate` changes data only when a human runs it. ## Risk Residual risk is R3 High, matching the plan's slice table: recoverable multi-file moves, prompt-only plaintext, no accidental activation or removal. Migration now moves several files and creates directories in one transaction, so its failure modes are broader than any previous single-file operation; the journal records the token directory as a first-class object with its pre-run absence, and rollback restores or prunes it accordingly. Every prune is empty-directory-only `rmdir` after the managed files are gone, so an operator or concurrent writer that populated the directory is preserved and reported rather than removed recursively. `token add` never activates its token or moves the default, and `token remove` refuses both the current default and the last token of a credential that still serves providers — that last refusal makes a directory prune on `token remove` unreachable in practice, since registry validation keeps `providers` non-empty; the branch stays so the directory can never be orphaned, and the test witnesses the refusal and the preserved directory instead. Retarget prompts for every token before the journal exists, so an incomplete set of bearers commits nothing. Synthetic fixtures bound public behavior only; no private registry, ciphertext, or live token was read, migrated, or tested. ## Validation `nix build .#checks.x86_64-linux.provisioning-contract` from this worktree: succeeds, building `provisioning-contract` and `nexus-provisioning-scripts`. Its log ends with `pi-provider tests passed: 192 assertions`. `bash -n` and `shellcheck -x` over `scripts/pi-provider` and `tests/pi-provider.sh` (run by that check, and directly): clean, with no findings at any severity. Four deliberate sabotages of the implementation, each run against the unmodified test suite and reverted afterwards, confirm the new assertions can fail: making `token add` set `defaultToken` fails "token add never activates the new token or moves the deployment default"; adding a recursive `rm -rf` fallback to the prune fails "retirement reports a token directory a concurrent writer populated"; dropping migrate's delete of the pre-named-token ciphertext fails "migrate kept the flat ciphertext"; and leaving directory creation off the journal fails "ordinary rollback kept the created token directory". The witness covers migration with no bearer input and no `age` invocation at all; independent `token add`, `rotate`, `remove`, `default`, and `list`; no activation on add; all-token retarget including a partial-bearer run that commits nothing; shared-provider impact reporting; last-token and current-default removal refusals; provider retirement removing the record and every named ciphertext; token-directory creation and empty-only pruning across migrate, rollback, `recover`, and retire, including a populated-directory race driven by the `rm` wrapper; dry runs; interrupted mutation, migration, and retirement at twelve, ten, and four kill boundaries respectively, each recovered exactly; and a final scan proving no runtime-generated synthetic bearer reaches any file, child argv, child environment, or command output. Refs allod/strategy#36
A Pi credential now carries one or more named tokens, each its own ciphertext at
secrets/pi-credentials/<credential>/<token>.age, plus an optional deployment
default. Records become exactly providers, targets, tokens, and defaultToken.

Provider-scoped rotate and --rotation-strategy are removed; token list, add,
rotate, remove, and default replace them, and migrate converts a pre-named-token
checkout in one recoverable transaction without reading or decrypting a bearer.

The token directory is a journaled transaction object: its creation is recorded
so rollback and recover undo it, and every prune is empty-directory-only, so
contents this tool does not own are preserved and reported.

Refs allod/strategy#36

Model: claude-opus-5
Author
Member

Review pass (required post-open review): read-only diff review by gpt-5.6-sol at reasoning xhigh against the plan contract, with three findings reproduced by execution in the review sandbox. Findings below; fixes will land as follow-up commits on this branch. Two findings expose plan-level defects; the plan is being amended in allod/strategy to reserve the literal token name none (finding 5) and to restate the last-token-remove witness as refusal semantics (finding 6).

  1. [BLOCKER] scripts/pi-provider:630, scripts/pi-provider:641, scripts/pi-provider:650 — The bearer can enter Age’s environment. Bash’s local token inherits the export attribute from an existing exported token; after read assigns the bearer, age receives token=<bearer>. Running the witness under env token=outside fails at tests/pi-provider.sh:473 with “token reached child argv or environment.” Additionally, printf is not forced to the builtin, so an imported printf function can shadow it and receive the bearer as an argument.

  2. [BLOCKER] scripts/pi-provider:990, scripts/pi-provider:1017, scripts/pi-provider:1027 — Recovery follows a symlink standing in for a directory recorded as previously absent. Such directories skip the initial occupant check; recovery first runs rm -f on child paths and checks the directory type only afterward while pruning. After interrupting add before mkdir, replacing the planned directory with a symlink to a directory containing default.age, and running recover, I observed the outside file deleted, recovery returning success, and the journal removed. assert_dirt_confined accepts the symlink because its path is journaled.

  3. [BLOCKER] scripts/pi-provider:982-990 — Recovery is not idempotent across a crash while recreating a pruned directory. If killed after mkdir but before chmod, the next recovery sees the directory and immediately continues, never restoring its recorded mode. I reproduced retire being interrupted after rmdir, then recovery being killed after its restoring mkdir; the directory remained 0755 after the second successful recovery instead of its original 0700, while the journal was removed.

  4. [GAP] scripts/pi-provider:402-415, scripts/pi-provider:1250-1259migrate exits successfully as soon as every record has either tokens or defaultToken, before validating the new schema or checking repository cleanliness. A dirty already-migrated checkout therefore reports “nothing to migrate” instead of refusing. Likewise, a checkout where every record combines legacy rotationStrategy with named-token fields is treated as complete rather than refused as mixed state, even though every ordinary command subsequently rejects it.

  5. [GAP] scripts/pi-provider:274-275, scripts/pi-provider:495-498, docs/credentials.md:23-27none matches the contract’s opaque token-name regex and token add ... none accepts it, but token default ... none then refuses both selecting that token and clearing the default. A schema-valid token is therefore unusable as a deployment default, and while it exists the documented clearing spelling is unavailable. docs/credentials.md:60 records this narrowing, but the controlling contract does not exclude none from valid names.

  6. [GAP] scripts/pi-provider:357-359, scripts/pi-provider:487-488, scripts/pi-provider:776-778, tests/pi-provider.sh:567-578 — The required last-token-remove pruning witness is absent. Registry validation requires both providers and tokens to be non-empty, and the command refuses the last token while providers remain, so the prune branch is unreachable. The test witnesses refusal and directory preservation, not empty-only pruning; deleting the dead PRUNE_DIRS branch would still pass all 192 assertions. This does not discharge the acceptance sentence requiring pruning across last-token remove.

  7. [GAP] scripts/pi-provider:734-741, tests/pi-provider.sh:600-619, tests/pi-provider.sh:661-702 — No crash-and-recover test interrupts a multi-token retarget installation. The only mutation sweep creates one new token through add; retarget is tested only for pre-journal incomplete input and uninterrupted success. A kill after replacing the first existing ciphertext but before replacing the second—precisely the new per-token atomicity boundary—is never exercised, so the PR claim that every new mutation boundary is swept is false.

  8. [GAP] tests/pi-provider.sh:499-520, tests/pi-provider.sh:604-619, tests/pi-provider.sh:952-958, tests/pi-provider.sh:995-1000 — The bearer witness does not actually cover labeled prompts or every command’s output. Inputs are piped, and Bash suppresses read -p prompts on non-terminal stdin, so unlabeled retarget prompts would pass. Only the initial add and token-add outputs are checked for their bearer; the final grep scans fixture files, not shell variables or discarded stdout. A rotate- or retarget-specific bearer print would therefore pass, contrary to the PR’s final-output-scan claim.

  9. [GAP] scripts/pi-provider:827-830, scripts/pi-provider:866-889, tests/pi-provider.sh:926-960 — The new grandparent-walk and kind-aware journal allowlist lack negative sabotage witnesses. The sole token-directory symlink test places the symlink at the credential directory itself, which is rejected by the separate directory-kind check; removing the realpath comparison still passes the suite and would allow a symlinked secrets/pi-credentials ancestor to redirect creation outside the repository. No malformed journal test proves that invalid kind values or unsafe two-segment/sibling paths are rejected.

Validation: the sandbox denied access to the Nix daemon socket, so I could not rerun nix build .#checks.x86_64-linux.provisioning-contract. bash -n and the direct tests/pi-provider.sh witness succeeded with 192 assertions; shellcheck is unavailable in this environment.

Review pass (required post-open review): read-only diff review by gpt-5.6-sol at reasoning xhigh against the plan contract, with three findings reproduced by execution in the review sandbox. Findings below; fixes will land as follow-up commits on this branch. Two findings expose plan-level defects; the plan is being amended in allod/strategy to reserve the literal token name none (finding 5) and to restate the last-token-remove witness as refusal semantics (finding 6). 1. [BLOCKER] `scripts/pi-provider:630`, `scripts/pi-provider:641`, `scripts/pi-provider:650` — The bearer can enter Age’s environment. Bash’s `local token` inherits the export attribute from an existing exported `token`; after `read` assigns the bearer, `age` receives `token=<bearer>`. Running the witness under `env token=outside` fails at `tests/pi-provider.sh:473` with “token reached child argv or environment.” Additionally, `printf` is not forced to the builtin, so an imported `printf` function can shadow it and receive the bearer as an argument. 2. [BLOCKER] `scripts/pi-provider:990`, `scripts/pi-provider:1017`, `scripts/pi-provider:1027` — Recovery follows a symlink standing in for a directory recorded as previously absent. Such directories skip the initial occupant check; recovery first runs `rm -f` on child paths and checks the directory type only afterward while pruning. After interrupting add before `mkdir`, replacing the planned directory with a symlink to a directory containing `default.age`, and running recover, I observed the outside file deleted, recovery returning success, and the journal removed. `assert_dirt_confined` accepts the symlink because its path is journaled. 3. [BLOCKER] `scripts/pi-provider:982-990` — Recovery is not idempotent across a crash while recreating a pruned directory. If killed after `mkdir` but before `chmod`, the next recovery sees the directory and immediately continues, never restoring its recorded mode. I reproduced retire being interrupted after `rmdir`, then recovery being killed after its restoring `mkdir`; the directory remained `0755` after the second successful recovery instead of its original `0700`, while the journal was removed. 4. [GAP] `scripts/pi-provider:402-415`, `scripts/pi-provider:1250-1259` — `migrate` exits successfully as soon as every record has either `tokens` or `defaultToken`, before validating the new schema or checking repository cleanliness. A dirty already-migrated checkout therefore reports “nothing to migrate” instead of refusing. Likewise, a checkout where every record combines legacy `rotationStrategy` with named-token fields is treated as complete rather than refused as mixed state, even though every ordinary command subsequently rejects it. 5. [GAP] `scripts/pi-provider:274-275`, `scripts/pi-provider:495-498`, `docs/credentials.md:23-27` — `none` matches the contract’s opaque token-name regex and `token add ... none` accepts it, but `token default ... none` then refuses both selecting that token and clearing the default. A schema-valid token is therefore unusable as a deployment default, and while it exists the documented clearing spelling is unavailable. `docs/credentials.md:60` records this narrowing, but the controlling contract does not exclude `none` from valid names. 6. [GAP] `scripts/pi-provider:357-359`, `scripts/pi-provider:487-488`, `scripts/pi-provider:776-778`, `tests/pi-provider.sh:567-578` — The required last-token-remove pruning witness is absent. Registry validation requires both providers and tokens to be non-empty, and the command refuses the last token while providers remain, so the prune branch is unreachable. The test witnesses refusal and directory preservation, not empty-only pruning; deleting the dead `PRUNE_DIRS` branch would still pass all 192 assertions. This does not discharge the acceptance sentence requiring pruning across last-token remove. 7. [GAP] `scripts/pi-provider:734-741`, `tests/pi-provider.sh:600-619`, `tests/pi-provider.sh:661-702` — No crash-and-recover test interrupts a multi-token retarget installation. The only mutation sweep creates one new token through `add`; retarget is tested only for pre-journal incomplete input and uninterrupted success. A kill after replacing the first existing ciphertext but before replacing the second—precisely the new per-token atomicity boundary—is never exercised, so the PR claim that every new mutation boundary is swept is false. 8. [GAP] `tests/pi-provider.sh:499-520`, `tests/pi-provider.sh:604-619`, `tests/pi-provider.sh:952-958`, `tests/pi-provider.sh:995-1000` — The bearer witness does not actually cover labeled prompts or every command’s output. Inputs are piped, and Bash suppresses `read -p` prompts on non-terminal stdin, so unlabeled retarget prompts would pass. Only the initial add and token-add outputs are checked for their bearer; the final `grep` scans fixture files, not shell variables or discarded stdout. A rotate- or retarget-specific bearer print would therefore pass, contrary to the PR’s final-output-scan claim. 9. [GAP] `scripts/pi-provider:827-830`, `scripts/pi-provider:866-889`, `tests/pi-provider.sh:926-960` — The new grandparent-walk and kind-aware journal allowlist lack negative sabotage witnesses. The sole token-directory symlink test places the symlink at the credential directory itself, which is rejected by the separate directory-kind check; removing the realpath comparison still passes the suite and would allow a symlinked `secrets/pi-credentials` ancestor to redirect creation outside the repository. No malformed journal test proves that invalid `kind` values or unsafe two-segment/sibling paths are rejected. Validation: the sandbox denied access to the Nix daemon socket, so I could not rerun `nix build .#checks.x86_64-linux.provisioning-contract`. `bash -n` and the direct `tests/pi-provider.sh` witness succeeded with 192 assertions; `shellcheck` is unavailable in this environment.
Bearer variables can no longer inherit an export attribute and the secret pipe forces the printf builtin, witnessed under a hostile exported printf and a pre-exported token variable. Recovery type-checks a previously absent directory before touching children, restores recorded modes idempotently across recovery crashes, and a multi-token retarget install is crash-swept at every ciphertext boundary. Migrate refuses dirty, mixed, and invalid already-migrated states before reporting nothing to do. The name none is rejected as a token name per the amended plan, and the unreachable last-token prune branch is gone with the refusal witnessed instead. The journal allowlist and planned-parent walk gained negative witnesses including a symlinked ancestor, and the leak witness now checks labeled prompts and per-command output.

Model: gpt-5.6-sol
Author
Member

All nine findings are addressed in 0a9b30e (authored by gpt-5.6-sol; the shellcheck directives on the hostile-printf shim were added by the managing agent). Blockers 1-3: the bearer variable can no longer inherit an export attribute, the secret pipe forces the printf builtin, recovery type-checks previously absent directories before touching children, and mode restoration is idempotent across recovery crashes — each now has a reproducing witness. Gaps 4-9: migrate refuses dirty/mixed/invalid-migrated states; the name none is rejected per the amended plan (46d074e); the unreachable prune branch is deleted with refusal witnessed; the crash sweep covers multi-token retarget ciphertext boundaries; the leak witness covers labeled prompts and per-command output; the allowlist and planned-parent walk have negative witnesses including a symlinked ancestor. Suite is 221 assertions; nix build .#checks.x86_64-linux.provisioning-contract re-run green by the managing agent from this worktree before push.

All nine findings are addressed in 0a9b30e (authored by gpt-5.6-sol; the shellcheck directives on the hostile-printf shim were added by the managing agent). Blockers 1-3: the bearer variable can no longer inherit an export attribute, the secret pipe forces the printf builtin, recovery type-checks previously absent directories before touching children, and mode restoration is idempotent across recovery crashes — each now has a reproducing witness. Gaps 4-9: migrate refuses dirty/mixed/invalid-migrated states; the name none is rejected per the amended plan (46d074e); the unreachable prune branch is deleted with refusal witnessed; the crash sweep covers multi-token retarget ciphertext boundaries; the leak witness covers labeled prompts and per-command output; the allowlist and planned-parent walk have negative witnesses including a symlinked ancestor. Suite is 221 assertions; nix build .#checks.x86_64-linux.provisioning-contract re-run green by the managing agent from this worktree before push.
vnprc approved these changes 2026-08-24 02:42:30 +01:00
vnprc merged commit 0a9b30e3e1 into master 2026-08-24 02:42:35 +01:00
vnprc deleted branch agent/named-tokens-lifecycle 2026-08-24 02:42:35 +01:00
Sign in to join this conversation.
No description provided.