Carve out consumed files so a dev machine stops carrying the whole secrets repo #21

Closed
opened 2026-07-31 02:00:01 +01:00 by vnprc-agent · 2 comments

Reduce what a dev machine carries from its deployment's secrets repo to the files that machine actually uses, instead of the whole repo. Two modules interpolate the secrets flake and append a path, and because a flake input resolves to a directory, that pulls in every encrypted file the repo holds — including ciphertexts belonging to other machines, which the machine cannot decrypt and has no reason to store.

Primary goals:

  • Confine the reference — each consumed file is carved out as its own store path, so a machine's closure holds the files it uses and nothing else.
  • Cut the blast radius of one guest compromise — an attacker on a dev guest currently walks away with a copy of every ciphertext in the deployment, to keep and attack offline; a host key that leaks later then decrypts whatever was kept.
  • Stop unrelated edits rebuilding every machine — any commit to the secrets repo rehashes the directory today, so editing one machine's data moves every machine that references it.
  • Keep the GitHub credential bound to its declared target — the check that ties a registered target to the generated age.secrets entry matches on a path suffix and rejects a carved path, so it changes with the carve rather than being weakened to let one through.
  • Keep the change inside this repo — no new export from the identity template and no change to any deployment's data repos.

Current state and specifics

modules/agent-hooks.nix:14,16,18,20 set four home.file sources by interpolating gitPolicySource and appending /git/<name>. modules/github-credentials.nix:32 does the same with "${secrets}/${consumer.secret}". In both cases the resulting string names a file inside the input's store directory, so the derivation depends on the directory. Both modules are composed only by mkDevVm (flake.nix:401, flake.nix:434); privacy VMs and the hypervisor import neither.

Measured on this repo's own public template at b8b2859, which is the smallest case that exists — one dev machine, six ciphertexts:

  • nixosConfigurations.allod-dev.config.system.build.toplevel has 867 requisites, and the secrets source root /nix/store/<hash>-source is one of them.
  • Exactly one path in that closure references the root: home-manager-files, the symlink tree holding the four policy files. The root's secrets/ directory travels with it — every .age file in the repo, plus keys/, secrets.nix, and the rest.
  • A deployment is the same shape with more ciphertexts: other machines' SSH host keys, other machines' Forge keys, and credentials that have nothing to do with git policy or with that machine.

This is exposure, not access. Which machine can decrypt which ciphertext is governed by the recipient graph in secrets.nix, and that is unaffected and correct: the machines cannot open what they are carrying, apart from credentials that are deliberately shared. The defect is that they carry it at all.

The framework already refuses this shape one layer up. flake.nix:328-377 asserts that mkDevVm's two token arguments are a Nix path or null, precisely because the directory-interpolating spelling leaks a whole repository; modules/host-user-credentials.nix:57-69 mirrors it for the hypervisor's own credential fields. That assertion reads the builder's arguments, so it cannot see either site here — a composed module setting age.secrets.<name>.file after the merge is past it, and a home.file source was never in it. Its comment at flake.nix:312-319 records both leaks as measured and open and names this issue; landing this makes that comment false, so it is part of the change.

Suggested default, verified to work:

builtins.path { path = "${gitPolicySource}/git/${name}"; name = name; }

This yields a content-addressed store path holding the single file, and it behaves the same whether the argument is a literal path or a string carrying the input's context, so no context-discarding helper is needed. Measured against the same input as above:

expression value string context
"${secrets}/git/protected-branches" /nix/store/irdys9…-source/git/protected-branches /nix/store/irdys9…-source — the whole repo
builtins.path { path = "${secrets}/git/protected-branches"; name = "protected-branches"; } /nix/store/1rciq5…-protected-branches itself, the one file

The carved value is a string, not a Nix path — builtins.isPath is false on it — which is why the builder-argument assertion above would refuse it and why nothing here routes through that assertion.

Two references that look similar are not in scope and should stay as they are: flake.nix:1108, :1244 and :1250 interpolate a source inside a check derivation, which is built on demand and never lands on a machine. A check that inspects the repo is supposed to see the repo.

An alternative worth weighing before implementing: have the identity template export the carved paths and have these modules consume them. That moves the choice to the repo that owns the files, but it costs a new export in the template plus a matching change in every fork's data repo, for the same result. The default above is preferred because it is confined to the consuming modules.

The GitHub target matcher changes with the carve

credential-profiles binds each registered credential target to the age.secrets entry a machine actually generates, and it does so differently for the two registries:

matcher location accepts a carved path?
targetMatchesGeneratedAgeSecret (Forgejo) flake.nix:745-761 yes — a full /<secret_path> suffix or a -<basename> suffix
githubTargetMatchesGeneratedAgeSecret (GitHub) flake.nix:783-796 no — /<consumer.secret> suffix only

A carved file is /nix/store/<hash>-<basename>.age, which has no /secrets/<name>.age suffix, so the GitHub matcher fails on exactly the credential this change carves. Do not copy the Forgejo matcher's basename fallback: a suffix proves only the label, so an unrelated carved file with the same basename would pass. Instead construct the expected source independently and require exact equality:

expectedFile = builtins.path {
  path = "${secrets}/${consumer.secret}";
  name = baseNameOf consumer.secret;
};

There is one GitHub construction site and no fork consumes the old spelling, so replace the suffix condition rather than keeping a compatibility branch. The rest of that matcher — destination, owner, group, mode — is already exact and stays.

githubCredentialTargets reaches the module as an argument of mkDevVm (flake.nix:275), defaulted from the registry, so the module's own signature is { secrets, machineName, targets } and a check can apply it directly with synthetic data.

Scope

In scope: the two modules above, the GitHub matcher, the stale comment at flake.nix:312-319, and a check that pins the property so the reference cannot creep back.

Out of scope:

  • The recipient graph, which is correct, and the identity template's exports; no deployment data repo changes.
  • The check-derivation references at flake.nix:1108, :1244 and :1250.
  • modules/agent-forgejo-token.nix and the token arguments generally. Those values originate as ./secrets + "/<name>.age", a Nix path, which already stages the single file.
  • modules/agent-hooks.nix:11 and the allod-tools input. The tools input carries no ciphertext, so removing its root is derivation-churn work with a different justification; do it as its own change with its own closure claim.
  • The predicate behind the token-argument assertion. Whether mkDevVm should also accept a builtins.path value handed in from a deployment is allod/archetypes#70, which is the same construction seen from the outside; neither issue blocks the other, and nothing proposed there reaches these two sites.

Validation

  • A dev machine's toplevel closure no longer contains the secrets source directory. Query the realised output's requisites for that store path before and after; it should disappear entirely, not merely shrink. The before query is a tripwire: if the baseline does not contain it, the premise is wrong and implementation stops rather than proceeding to a vacuously passing after query.
  • The four policy files and the GitHub credential still land with identical content, destination path, owner, and mode.
  • A check that fails if either module reintroduces a bare interpolation of the input root, so the property is enforced rather than reviewed. It asserts generated values, not source spelling: the four home.file sources equal independently carved paths, and a synthetic non-empty GitHub target produces the exact expected file, destination, owner, group and mode. Sabotage witnesses: an un-carved policy source, a same-basename carved file with different bytes, an empty target list, and a target whose credential has no unique agenix consumer.
  • credential-profiles still binds every declared target to a generated secret. The public template declares no GitHub targets, so its passing is not evidence for the GitHub matcher — the synthetic fixture above is.
  • Activation on a disposable dev VM before any machine an agent works from takes the change: agenix decrypts the declared credential to the unchanged destination, and home-manager installs all four policy files with readable targets.
Reduce what a dev machine carries from its deployment's secrets repo to the files that machine actually uses, instead of the whole repo. Two modules interpolate the secrets flake and append a path, and because a flake input resolves to a directory, that pulls in every encrypted file the repo holds — including ciphertexts belonging to other machines, which the machine cannot decrypt and has no reason to store. Primary goals: - **Confine the reference** — each consumed file is carved out as its own store path, so a machine's closure holds the files it uses and nothing else. - **Cut the blast radius of one guest compromise** — an attacker on a dev guest currently walks away with a copy of every ciphertext in the deployment, to keep and attack offline; a host key that leaks later then decrypts whatever was kept. - **Stop unrelated edits rebuilding every machine** — any commit to the secrets repo rehashes the directory today, so editing one machine's data moves every machine that references it. - **Keep the GitHub credential bound to its declared target** — the check that ties a registered target to the generated `age.secrets` entry matches on a path suffix and rejects a carved path, so it changes with the carve rather than being weakened to let one through. - **Keep the change inside this repo** — no new export from the identity template and no change to any deployment's data repos. ### Current state and specifics `modules/agent-hooks.nix:14,16,18,20` set four `home.file` sources by interpolating `gitPolicySource` and appending `/git/<name>`. `modules/github-credentials.nix:32` does the same with `"${secrets}/${consumer.secret}"`. In both cases the resulting string names a file *inside* the input's store directory, so the derivation depends on the directory. Both modules are composed only by `mkDevVm` (`flake.nix:401`, `flake.nix:434`); privacy VMs and the hypervisor import neither. Measured on this repo's own public template at `b8b2859`, which is the smallest case that exists — one dev machine, six ciphertexts: - `nixosConfigurations.allod-dev.config.system.build.toplevel` has 867 requisites, and the secrets source root `/nix/store/<hash>-source` is one of them. - Exactly one path in that closure references the root: `home-manager-files`, the symlink tree holding the four policy files. The root's `secrets/` directory travels with it — every `.age` file in the repo, plus `keys/`, `secrets.nix`, and the rest. - A deployment is the same shape with more ciphertexts: other machines' SSH host keys, other machines' Forge keys, and credentials that have nothing to do with git policy or with that machine. This is exposure, not access. Which machine can decrypt which ciphertext is governed by the recipient graph in `secrets.nix`, and that is unaffected and correct: the machines cannot open what they are carrying, apart from credentials that are deliberately shared. The defect is that they carry it at all. The framework already refuses this shape one layer up. `flake.nix:328-377` asserts that `mkDevVm`'s two token arguments are a Nix path or null, precisely because the directory-interpolating spelling leaks a whole repository; `modules/host-user-credentials.nix:57-69` mirrors it for the hypervisor's own credential fields. That assertion reads the builder's arguments, so it cannot see either site here — a composed module setting `age.secrets.<name>.file` after the merge is past it, and a `home.file` source was never in it. Its comment at `flake.nix:312-319` records both leaks as measured and open and names this issue; landing this makes that comment false, so it is part of the change. Suggested default, verified to work: ```nix builtins.path { path = "${gitPolicySource}/git/${name}"; name = name; } ``` This yields a content-addressed store path holding the single file, and it behaves the same whether the argument is a literal path or a string carrying the input's context, so no context-discarding helper is needed. Measured against the same input as above: | expression | value | string context | | --- | --- | --- | | `"${secrets}/git/protected-branches"` | `/nix/store/irdys9…-source/git/protected-branches` | `/nix/store/irdys9…-source` — the whole repo | | `builtins.path { path = "${secrets}/git/protected-branches"; name = "protected-branches"; }` | `/nix/store/1rciq5…-protected-branches` | itself, the one file | The carved value is a **string**, not a Nix path — `builtins.isPath` is `false` on it — which is why the builder-argument assertion above would refuse it and why nothing here routes through that assertion. Two references that look similar are not in scope and should stay as they are: `flake.nix:1108`, `:1244` and `:1250` interpolate a source inside a check derivation, which is built on demand and never lands on a machine. A check that inspects the repo is supposed to see the repo. An alternative worth weighing before implementing: have the identity template export the carved paths and have these modules consume them. That moves the choice to the repo that owns the files, but it costs a new export in the template plus a matching change in every fork's data repo, for the same result. The default above is preferred because it is confined to the consuming modules. ### The GitHub target matcher changes with the carve `credential-profiles` binds each registered credential target to the `age.secrets` entry a machine actually generates, and it does so differently for the two registries: | matcher | location | accepts a carved path? | | --- | --- | --- | | `targetMatchesGeneratedAgeSecret` (Forgejo) | `flake.nix:745-761` | yes — a full `/<secret_path>` suffix **or** a `-<basename>` suffix | | `githubTargetMatchesGeneratedAgeSecret` (GitHub) | `flake.nix:783-796` | no — `/<consumer.secret>` suffix only | A carved file is `/nix/store/<hash>-<basename>.age`, which has no `/secrets/<name>.age` suffix, so the GitHub matcher fails on exactly the credential this change carves. Do not copy the Forgejo matcher's basename fallback: a suffix proves only the label, so an unrelated carved file with the same basename would pass. Instead construct the expected source independently and require exact equality: ```nix expectedFile = builtins.path { path = "${secrets}/${consumer.secret}"; name = baseNameOf consumer.secret; }; ``` There is one GitHub construction site and no fork consumes the old spelling, so replace the suffix condition rather than keeping a compatibility branch. The rest of that matcher — destination, owner, group, mode — is already exact and stays. `githubCredentialTargets` reaches the module as an argument of `mkDevVm` (`flake.nix:275`), defaulted from the registry, so the module's own signature is `{ secrets, machineName, targets }` and a check can apply it directly with synthetic data. ### Scope In scope: the two modules above, the GitHub matcher, the stale comment at `flake.nix:312-319`, and a check that pins the property so the reference cannot creep back. Out of scope: - The recipient graph, which is correct, and the identity template's exports; no deployment data repo changes. - The check-derivation references at `flake.nix:1108`, `:1244` and `:1250`. - `modules/agent-forgejo-token.nix` and the token arguments generally. Those values originate as `./secrets + "/<name>.age"`, a Nix path, which already stages the single file. - `modules/agent-hooks.nix:11` and the `allod-tools` input. The tools input carries no ciphertext, so removing its root is derivation-churn work with a different justification; do it as its own change with its own closure claim. - The predicate behind the token-argument assertion. Whether `mkDevVm` should also accept a `builtins.path` value handed in from a deployment is allod/archetypes#70, which is the same construction seen from the outside; neither issue blocks the other, and nothing proposed there reaches these two sites. ### Validation - A dev machine's toplevel closure no longer contains the secrets source directory. Query the realised output's requisites for that store path before and after; it should disappear entirely, not merely shrink. The before query is a tripwire: if the baseline does not contain it, the premise is wrong and implementation stops rather than proceeding to a vacuously passing after query. - The four policy files and the GitHub credential still land with identical content, destination path, owner, and mode. - A check that fails if either module reintroduces a bare interpolation of the input root, so the property is enforced rather than reviewed. It asserts generated values, not source spelling: the four `home.file` sources equal independently carved paths, and a synthetic non-empty GitHub target produces the exact expected file, destination, owner, group and mode. Sabotage witnesses: an un-carved policy source, a same-basename carved file with different bytes, an empty target list, and a target whose credential has no unique agenix consumer. - `credential-profiles` still binds every declared target to a generated secret. The public template declares no GitHub targets, so its passing is not evidence for the GitHub matcher — the synthetic fixture above is. - Activation on a disposable dev VM before any machine an agent works from takes the change: agenix decrypts the declared credential to the unchanged destination, and home-manager installs all four policy files with readable targets.
Member

Revised the body against b8b2859, after the secrets rearchitecture arc. What changed and why:

  • The premise is re-measured, not assumed. The public template's allod-dev toplevel has 867 requisites and the secrets source root is one of them; exactly one closure path references it, home-manager-files. So the four policy files alone hold the root in, and the GitHub site adds nothing in the template because no GitHub target is declared there.
  • One receipt moved. modules/github-credentials.nix:29 is now :32. The four agent-hooks.nix lines are unchanged. The out-of-scope check derivations were flake.nix:717,723 and are now flake.nix:1108, :1244 and :1250 — the third arrived with the credential-store URL parity check.
  • A corrected claim. The old Validation said credential-profiles "already accepts a carved single-file path … so this should pass unchanged". That is true of the Forgejo matcher and false of the GitHub one, which is the matcher this change actually touches: githubTargetMatchesGeneratedAgeSecret (flake.nix:783-796) tests a /<consumer.secret> suffix only, and a carved file has no such suffix. The body now states the matcher change and says not to copy the Forgejo basename fallback, which would pass an unrelated file of the same name.
  • New neighbour. mkDevVm now asserts its token arguments are a Nix path or null (flake.nix:328, :370-377), and modules/host-user-credentials.nix:57-69 mirrors it for the hypervisor. The assertion's comment at flake.nix:312-319 names this issue and calls both leaks open; that comment is now in scope, since landing this makes it false. This also retires the old closing note about a deployment's hypervisor interpolating its own source.
  • Related, not blocking. allod/archetypes#70 is the same builtins.path construction seen from outside the builder — it decides whether a deployment may hand one in as a token argument. Recorded as out of scope in both directions.
  • Folded in the decisions from the plan. allod/strategy dev-plans/archetypes-consumed-file-carve-out.md predates the current rule that a plan is written for R4 work only; this is R3. Its live decisions — the carve construction, the exact-equality matcher, and what the new check must witness — are in the body now, so the issue is the single spec.
Revised the body against `b8b2859`, after the secrets rearchitecture arc. What changed and why: - **The premise is re-measured, not assumed.** The public template's `allod-dev` toplevel has 867 requisites and the secrets source root is one of them; exactly one closure path references it, `home-manager-files`. So the four policy files alone hold the root in, and the GitHub site adds nothing in the template because no GitHub target is declared there. - **One receipt moved.** `modules/github-credentials.nix:29` is now `:32`. The four `agent-hooks.nix` lines are unchanged. The out-of-scope check derivations were `flake.nix:717,723` and are now `flake.nix:1108`, `:1244` and `:1250` — the third arrived with the credential-store URL parity check. - **A corrected claim.** The old Validation said `credential-profiles` "already accepts a carved single-file path … so this should pass unchanged". That is true of the Forgejo matcher and false of the GitHub one, which is the matcher this change actually touches: `githubTargetMatchesGeneratedAgeSecret` (`flake.nix:783-796`) tests a `/<consumer.secret>` suffix only, and a carved file has no such suffix. The body now states the matcher change and says not to copy the Forgejo basename fallback, which would pass an unrelated file of the same name. - **New neighbour.** `mkDevVm` now asserts its token arguments are a Nix path or null (`flake.nix:328`, `:370-377`), and `modules/host-user-credentials.nix:57-69` mirrors it for the hypervisor. The assertion's comment at `flake.nix:312-319` names this issue and calls both leaks open; that comment is now in scope, since landing this makes it false. This also retires the old closing note about a deployment's hypervisor interpolating its own source. - **Related, not blocking.** allod/archetypes#70 is the same `builtins.path` construction seen from outside the builder — it decides whether a deployment may hand one in as a token argument. Recorded as out of scope in both directions. - **Folded in the decisions from the plan.** `allod/strategy` `dev-plans/archetypes-consumed-file-carve-out.md` predates the current rule that a plan is written for R4 work only; this is R3. Its live decisions — the carve construction, the exact-equality matcher, and what the new check must witness — are in the body now, so the issue is the single spec.
Member

Implemented in allod/archetypes#87, on current master. allod/archetypes#24, which implemented this issue in July from a base 86 commits behind, is closed in its favour; #87 carries its carves and its retention sweep and closes the two findings its review pass left open.

The R3 dev plan in allod/strategy is archived by allod/strategy#58, so this body is the single spec.

What remains before any machine takes it is the human gate the Validation section names: rebuild a disposable dev VM first, not the machine you work from, and confirm agenix decrypts the declared credential to its unchanged destination and home-manager installs all four policy files.

Implemented in allod/archetypes#87, on current master. allod/archetypes#24, which implemented this issue in July from a base 86 commits behind, is closed in its favour; #87 carries its carves and its retention sweep and closes the two findings its review pass left open. The R3 dev plan in `allod/strategy` is archived by allod/strategy#58, so this body is the single spec. What remains before any machine takes it is the human gate the Validation section names: rebuild a disposable dev VM first, not the machine you work from, and confirm agenix decrypts the declared credential to its unchanged destination and home-manager installs all four policy files.
vnprc closed this issue 2026-09-21 16:43:24 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
allod/archetypes#21
No description provided.