Carve consumed secrets files out of the input root #24
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
allod/archetypes!24
Loading…
Reference in a new issue
No description provided.
Delete branch "agent/consumed-file-carve-out"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Every file a dev machine consumes from the secrets input is now carved out as its own single-file store object, so the machine's system closure no longer carries the whole secrets repo; implemented and validated against the public composition root, awaiting the human rebuild gate before any machine takes it.
Five consumer sites change.
modules/agent-hooks.nixcarves the four git policy files it selects fromgitPolicySource, andmodules/github-credentials.nixcarves the ciphertext it selects from the secrets input. Each becomesbuiltins.path { path = <existing interpolated path>; name = <basename>; }, which yields a content-addressed store path holding the single file with no reference back to the source directory. Nothing else moves: the recipient graph, theallod-toolsinput, the token files that already resolve to individual store paths (./secrets + "/<name>.age"is a Nix path, so agenix's own coercion already copies the single file), and the check derivations that deliberately inspect the repo are all untouched.One matcher changes.
githubTargetMatchesGeneratedAgeSecretaccepted a generated secret by an in-root/<source-relative-path>suffix, which a carved file no longer has, so following the issue literally would have broken it the moment a deployment composed non-empty GitHub target data. It now constructs the expected carved source independently and requires exact equality. It deliberately does not copy the Forgejo matcher's basename-suffix fallback: a suffix proves only the label, so an unrelated carved file with the same basename would pass. The comparison is lifted into a shared predicate so the new check can drive the same code path with synthetic data.One check is added.
consumed-file-carve-outpins generated values rather than source spelling — the fourhome.filesources a real machine produces, a sweep that fails if anyhome.filesource still names a child of the input root, and the exact file, destination, owner, group and mode a GitHub credential target produces. It needs a synthetic fixture because the public secrets template declares no GitHub targets, which is whycredential-profilespassing is not evidence for that matcher.Risk
R3 High, as assessed in the plan.
The blast radius is every dev machine at once: each carve moves
age.secrets.<name>.fileor ahome.file.<name>.source, so every dev machine's derivation changes. Privacy VMs and the hypervisor are unaffected — the modules in scope are imported only frommkDevVm. It is a secret-handling boundary change that alters activation-time behavior, so a happy-path evaluation can pass while activation fails. A wrongbuiltins.pathsource could in principle put an unintended ciphertext in a machine closure while the source root is absent, which the closure test alone cannot catch; that is why exact-source equality and the same-basename wrong-content mutation are part of the proof rather than optional extras.What holds it at R3 rather than R4: no authoritative secret, recipient graph, key or persistent datum is mutated; the exact-source and generated-metadata checks are agent-runnable; the built output closure is mechanically inspectable; rollout starts on a disposable VM; and rollback is a previous NixOS generation, or a pin revert plus rebuild, with no state to migrate.
Validation
Acceptance tests 1 through 7 from the plan were run verbatim from the candidate checkout against
allod/deployas the composition root. All seven passed.allod-devtoplevel closure does contain the secrets source root, so the premise holds and test 2 is not vacuous.allod-devtoplevel has 869 requisites and the secrets source root is absent. The four carved policy files and the two token ciphertexts are present as store-root objects, so the machine still gets what it consumes. The only-sourceroots left in the closure areallod-tools, explicitly out of scope, and nixpkgs.consumed-file-carve-out: policy source mismatch: .config/git/protected-branches, and the implementation survives the restore.credential-profilesanddev-forge-opt-outstill build. Note thatcredential-profilesis not evidence for the GitHub matcher here — the public template's target list is empty, so that branch is vacuous and test 3 supplies the only non-empty exercise.allod-dev's generated age secret files coerce, by interpolation rather thantoString, to store-root regular files.archetypeschecks build green.Six further sabotage probes were run beyond the plan's test 5, because a check that has only been seen to pass is not yet known to be able to fail. Each was reverted from a mode-preserving scratch copy. Un-carving the GitHub consumer, weakening the matcher back to a basename suffix so that it accepts the same-basename wrong-content mutation, pointing the generated secret at a wrong destination, adding a fifth uncarved
home.filepolicy consumer, neutering the module's invalid-consumer assertion, and making that assertion stop naming the credential each make the check fail on the specific assertion that guards them.Test 8 did not run and cannot be simulated. It is the human generated-lifecycle gate: advance the private deploy pin, rebuild a disposable dev VM that has a real GitHub target, confirm agenix decrypts the declared credential to the unchanged destination and home-manager installs all four policy files, reboot once, and only then rebuild the working VM. An agent cannot rebuild or provision a machine.
Residual uncertainty this leaves:
builtins.pathever stopped carving, the generated and expected sides would move together and the check would still pass. The store-root shape assertion narrows that, but the closure query in tests 1 and 2 is what actually covers it, and that query is not a flake check — it must be re-run per composed dev machine wherever this lands.allod-dev, like every other check in this flake. A dev machine that overridesgitPolicySourceis covered only by the closure query, not by the generated-value assertions.--override-input archetypes path:...is not out-path-equivalent to a git-pinned lock, so the specific out-paths recorded above are not the ones a pinned deploy will build. The closure-membership question they answer is unaffected, but a post-pin rebuild will legitimately produce different hashes.Refs allod/strategy#27
Closes allod/archetypes#21
Adversarial read-only review pass by
gpt-5.6-solatxhigheffort, cross-vendor from theclaude-opus-5that wrote the code. Four findings, no blockers. Every one is a way the new check can stay green while the property it guards is false — none is a defect in the carves themselves, which the pass confirmed correct.Confirmed correct and worth not undoing later: the five carves use the right source and basename; nested
consumer.secretpaths work;toStringis valid for the current path values; two conflicting duplicate targets cannot both match; empty target lists, Forge opt-out, and invalid consumers all behave; no secrets-root child construction remains anywhere in themkDevVmmodule graph; andmkPrivacyVm/mkHypervisorgenuinely do not import either changed module.1. [GAP] The root sweep misses the root itself — verified
flake.nix:976buildssecretsRootPrefix = "${toString secrets}/"with a trailing slash, solib.hasPrefixmatches children of the root but not the root itself. Addinghome.file.".cache/secrets-root".source = gitPolicySourceleaves all four named assertions green while the machine carries the entire secrets repository — the exact failure this check exists to prevent. Being fixed in a follow-up commit.2. [GAP] Exact equality does not prove a closure dependency — verified
Nix string equality compares contents and ignores store context. Confirmed on 2.31.5 against a two-file fixture:
So setting
file = builtins.unsafeDiscardStringContext (toString (carvedSecretFile consumer))passes the matcher, the equality assertion, and the store-root shape assertion, while the generated system names the correct path without retaining it as an input. The ciphertext is then not guaranteed to be in the runtime closure, and agenix can fail after transfer or after a garbage collection. The current code preserves context correctly; the check simply does not prove it does. Being fixed in a follow-up commit by asserting the carved value's context retains its own store path.3. [GAP] Policy coverage is hardwired to
allod-devwith the default sourceflake.nix:943keys the policy assertions toallod-devand compares against the secrets input directly. A second dev profile thatmkForces one policyhome.file.sourceback to"${secrets}/git/..."keeps the check green while that machine retains the root. The converse also holds: a legitimategitPolicySourceoverride with different policy bytes is carved correctly by the module but rejected by the check against the hardcoded input.4. [GAP] The GitHub fixture bypasses the real consumer resolution
flake.nix:1043passesfixtureConsumerdirectly rather than exercisinggithubAgenixConsumeratflake.nix:505. Make that lookup returnnullor filter the wrong repository and both this check andcredential-profilesstay green while every real non-empty GitHub target is rejected. The lookup is statically correct today; it is simply not proven end to end.What is being fixed here and what is not
Findings 1 and 2 are being fixed in a follow-up commit on this branch. Both are cheap and both close a hole through which a real regression would pass unseen.
Findings 3 and 4 are recorded rather than fixed, because both are coverage limits rather than defects, and both need something this repository does not have: 3 wants the check to iterate composed dev machines instead of assuming one machine and the default source, and 4 wants a synthetic registry faithful enough to drive the real resolver. They also sit on top of a larger gap already known — the public secrets template declares no GitHub targets at all, so the entire GitHub path has no real-machine coverage anywhere public. Confirmed arithmetically: the candidate closure gained four files, not five, because only the policy files are carved for
allod-dev. Closing 3 and 4 properly is the same work as giving that path real coverage, and it wants a decision about whether it belongs here or against the private fleet.Verdict from the pass: safe to merge with these gaps recorded, and not safe to deploy until the human rebuild and reboot gate has run. That gate now carries more weight than the plan assumed, since it is the first time the GitHub credential path is exercised against real data.
Findings 1 and 2 are fixed in
bc438c4. Both guards were shown to fail on sabotage rather than assumed to work.Finding 1: adding
home.file.".cache/secrets-root".source = gitPolicySource— a consumer equal to the root with no appended path — now fails withhome.file source(s) still retain the secrets input root: .cache/secrets-root. Before the fix it passed silently.Finding 2: deleting the context guard from
githubSecretMatchesTargetmakes the check fail on the newly addedcontext-stripped carved pathmutation. That mutation compares equal to the real carved value, so nothing else in the check rejects it. The guard isbuiltins.hasAttr (unsafeDiscardStringContext p) (builtins.getContext p), applied to both the four policy sources and the GitHub predicate, so it covers every machinecredential-profilessees rather than only the fixture.Verified after the change: all ten archetypes checks pass;
allod-dev's toplevel is byte-identical to the pre-fix candidate (99frlacp…), so this is check-only and moves no machine; and the secrets root is still absent from the runtime closure.Findings 3 and 4 remain open as recorded above — both need coverage this repository cannot supply on its own, and both fold into the larger question of giving the GitHub path any real-machine coverage at all.
bc438c4fa1to38711aa943Rebase check: one stale call site, now fixed; the carve-out itself is sound
74565b2repairs a test call site the rebase exposed../check.shpasses: all 32 steps.What broke.
modules/github-credentials.nixgained an explicittargetsargument on master after this PR was written — part of the microvm-refusal work inallod/archetypes#29. Production moved with it (mkGithubCredentialModulepassestargetsin), but this PR'sconsumed-file-carve-outcheck still called the module the old way, reading targets offsecrets.lib. After the rebase the check failed withfunction 'anonymous lambda' called without required argument 'targets'.The fixture now threads one target list to both the module argument and the synthetic secrets input, which is the seam production uses. Passing them separately would let the two halves of a fixture disagree about which targets are declared.
Worth stating plainly: the rebase is what caught this. A stale PR that still evaluates is more dangerous than one that fails to.
The sabotage still bites. Un-carving one policy source in place fails with
consumed-file-carve-out: policy source mismatch: .config/git/protected-branches (expected /nix/store/…-protected-branches, got /nix/store/…-source/git/protected-branches). The check is not passing for a new and wrong reason.Closure re-measured against current master
Built
allod-devand inspected the realised closure rather than re-reading the earlier test log:agent-pr-token.age, andforgejo-https-token-allod-dev.age.-sourceroots remaining are nixpkgs andallod-tools, matching what this PR claimed and both out of scope.No consumer was missed. That is structural rather than lucky: the carve changes how a file enters the closure, not which files are consumed, so an uncarved consumer would still drag in the whole root — and the root is gone. A grep for interpolations of the secrets root also finds no consumer added since this PR was written; the remaining hits are checks that deliberately read the repo.
A trap for the next reader
toStringon either age secret shows a path inside the secrets source root, which reads exactly like the carve failed for those two files. It did not.builtins.getContext "${secret.file}"shows the closure depending on individual store objects (…-agent-pr-token.age), because Nix path coercion copies the single file on its own — which is what this PR's body says and what its test 6 asserts by interpolation rather thantoString.Recording it here because the obvious check gives the wrong answer, and the next person to audit this will reach for
toStringfirst.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.