Assert mkDevVm token file arguments are paths, never strings #69

Merged
vnprc merged 3 commits from agent/assert-token-paths into master 2026-09-04 00:47:11 +01:00
Member

This does not stop a dev VM from carrying the whole secrets repository. That already happens today through a different seam, and it still happens after this merges. What this adds is a guard on one specific way it could get worse: mkDevVm accepts its two encrypted-token arguments as either a Nix path or a string, and a string can drag a whole repository into a machine's closure. The change refuses a string at the one point every value for those two arguments arrives, and adds two deliberately broken fixtures that prove the guard fires. Nothing about a running machine changes: all three machines produce byte-identical derivations to master. If it is ever wrong, a rebuild stops with a message naming the machine and the argument, and the fix is to spell that value ./secrets + "/x.age". The larger leak — every Forge-enabled dev VM already carrying the entire secrets flake — is allod/archetypes#21 and is untouched here, so read this as regression prevention whose benefit stays bounded until #21 lands.

What changed

flake.nix, mkDevVm: a new nonPathTokenArguments binding filters { tokenFile, httpsTokenFile } for values that are neither null nor builtins.isPath, and an assert between the builder's let and its nixpkgs.lib.nixosSystem call refuses any that remain. This matches the contract modules/pi-provider-lifecycle.nix already enforces for Pi token ciphertexts (badTokenFiles), and the message follows the same shape: it names the machine, the offending argument(s), and the correct form.

The assertion reads the effective argument values rather than any one supplier, because that is the single funnel every value for these two arguments passes through:

  • the defaults, identity.agentTokenFile and identity.forgeTokenFile, which the secrets template spells as ./secrets + "/…" today;
  • profiles.lib.profileData.<machine>, documented in allod/profiles as "builder-arg overrides merged into that machine's builder call" and empty in the public example, so a private profiles fork can set either argument;
  • inventory's machines.<name>.profile minus definition, merged into the same builder call at machineConfigurations;
  • direct mkDevVm calls from the checks in this flake.

All four land in the formal arguments before the builder body runs, so none of them can bypass the assertion. Note that the productionPiContract forcing at machineConfigurations covers only the four Pi arguments — the token arguments are still overridable from profiles and inventory, which is why the check belongs in the builder rather than at the secrets template.

What this does not fix

The option class this assertion protects is not closed by it, and two measured counterexamples are open right now. Both are allod/archetypes#21.

modules/agent-hooks.nix:14-20 interpolates gitPolicySource per policy file with a bare "${gitPolicySource}/git/…". Measured on this branch: nixosConfigurations.allod-dev.config.home-manager.users.allod.home.file.".config/git/protected-branches".source is /nix/store/43y4nc3czj9dbn2pbyndil0hl6ra46fh-source/git/protected-branches, whose string context is the whole secrets flake input, and nix-store --query --requisites on allod-dev's toplevel drv contains that store path. So every Forge-enabled dev VM already carries the entire secrets repository, independently of anything this PR does.

modules/github-credentials.nix:33 sets age.secrets.<name>.file = "${secrets}/${consumer.secret}" — a whole-repository-context string written directly into the very option this assertion is about, from inside a composed module, with no mkForce needed and no way for a builder-boundary assertion to see it. It is inert in public data because secrets.lib.githubCredentialTargets is {}, but #21 records it as measured on a real deployment.

Stated plainly: after this merges, age.secrets.<name>.file is not a guarded option class. What is guarded is mkDevVm's two token arguments.

String context, measured

The two spellings do not behave alike, which is why the message wording changed in the second commit. With d a directory path:

  • "${d}/a.age" interpolates the directory: context is the whole directory's store path.
  • "${d + "/a.age"}" interpolates the file: context is only that one file's store path — the same closure as passing the path.

The assertion refuses both anyway. The contract is the value's type, not the spelling, because the leaking form is one keystroke from the safe one and nothing downstream separates them. The message says that instead of claiming, as the first commit did, that every string carries the whole repository.

Witness

Rung 2 of testing.md: the standing witness is the evaluation assertion itself, so no fixture check re-simulates the property. What the dev-forge-opt-out check gains is the sabotage pair that principle 11 requires — proof the validator can fail. It reuses that check's existing withTokens seam, which already varies only the two token files, so the check's derivation content is unchanged and the new proof is entirely at evaluation time.

There are two sabotages rather than one because each defends its own argument's membership in the filtered attrset: with one fixture, deleting httpsTokenFile from the guard would leave the check green. A pathTokens control forces the identical fixture with both values left as paths, so a sabotage failing for an unrelated construction reason cannot read as proof. Neither fixture asserts on message text, so the reword does not touch them.

Risk

Low, and the blast radius is evaluation only. The assertion adds nothing to any closure and changes no generated output; the three machines' system.build.toplevel derivation paths are identical before and after (see Validation). The one behavioural change is that a deploy that today passes a string token file through profileData or an inventory profile setting would stop evaluating instead of building a leaking machine — which is the point, and it fails loudly with an actionable message rather than silently.

One narrowing deserves calling out, because it is easy to discover later rather than now. Pure evaluation cannot mint a path value into another flake's tree: flakeInput + "/x.age" and .outPath are strings, not paths. So after this PR the override seams described above can supply only null or a path inside the overriding repo's own tree. A private profiles fork can no longer point tokenFile at a file in the secrets input, and out-of-store runtime strings such as "/persist/…" are refused too. That is security-aligned, and no current caller is affected — every machine and every dev-VM check evaluates green — but it does foreclose part of the flexibility the override seam otherwise offers.

The evaluates helper in the check forces its fixture only to weak head normal form, deliberately: the guard runs before there is a configuration to evaluate, so forcing deeper would let an unrelated evaluation failure read as proof of the guard.

Validation

All commands run in the change worktree with nix eval --no-write-lock-file; nothing beyond the one small check derivation was built. Everything below was re-run after the second commit's reword.

Positive evaluation:

  • nix eval .#nixosConfigurations.{allod-dev,nexus,privacy-1}.config.system.build.toplevel.drvPath — all three evaluate.
  • The same three evaluated from the master checkout produce identical derivation paths, e.g. /nix/store/wwcq3mjn6090671z48vyjjf69snjpnhv-nixos-system-allod-dev-25.11.20260630.b6018f8.drv. Generated output is unchanged.
  • nix eval .#checks.x86_64-linux.<name>.drvPath for every check that composes a dev VM — dev-forge-opt-out, runtime-module-selection, microvm-credentials, microvm-host-join, agent-vm-status, pi-integration, pi-provider-lifecycle — all evaluate, and dev-forge-opt-out's derivation path is unchanged from master.
  • nix build .#checks.x86_64-linux.dev-forge-opt-out succeeds.

Failure demonstrations, each run against a temporary sabotage and then reverted:

  • tokenFile ? "${identity.agentTokenFile}" (a string arriving through the default seam) makes nix eval .#nixosConfigurations.allod-dev…drvPath fail with error: machine 'allod-dev' passes a non-path value to mkDevVm argument(s) tokenFile; each must be null or a Nix path — write ./secrets + "/agent-pr-token.age", not a string. ….
  • The same sabotage applied to httpsTokenFile fails with the same message naming httpsTokenFile.
  • Neutering the guard's predicate to false makes nix eval .#checks.x86_64-linux.dev-forge-opt-out.drvPath fail with error: dev-forge-opt-out: mkDevVm accepted a string agentTokenFile, so the path-form contract is no longer enforced on that argument.
  • Dropping only httpsTokenFile from the guard's attrset makes the same evaluation fail with the forgeTokenFile variant. Each sabotage fixture defends its own argument.

Supporting measurements:

  • The string-context experiment above, run with builtins.getContext on a scratch directory.
  • The agent-hooks.nix leak, measured with builtins.getContext on the generated home.file source and confirmed with nix-store --query --requisites on allod-dev's toplevel drv.

Not run, and not claimed: no VM was built or booted, and nix flake check was not run in full — evaluating every check attribute in one process was killed for memory on this machine, so the checks above were evaluated one process at a time.

Observed, not changed

While reading dev-forge-opt-out I probed its header claim that "a machine with one but not the other refuses to evaluate". It does not: a fixture with agentTokenFile set and forgeTokenFile = null evaluates to a full config.age.secrets, and no fixture in the check witnesses that sentence. This matches the secrets template's own comment that the dev builder "treats each file as independently optional and does not check them against each other". The comment is stale, but correcting it is unrelated to this change and is left alone rather than folded in.

Closes allod/archetypes#66

This does not stop a dev VM from carrying the whole secrets repository. That already happens today through a different seam, and it still happens after this merges. What this adds is a guard on one specific way it could get worse: `mkDevVm` accepts its two encrypted-token arguments as either a Nix path or a string, and a string can drag a whole repository into a machine's closure. The change refuses a string at the one point every value for those two arguments arrives, and adds two deliberately broken fixtures that prove the guard fires. Nothing about a running machine changes: all three machines produce byte-identical derivations to master. If it is ever wrong, a rebuild stops with a message naming the machine and the argument, and the fix is to spell that value `./secrets + "/x.age"`. The larger leak — every Forge-enabled dev VM already carrying the entire secrets flake — is allod/archetypes#21 and is untouched here, so read this as regression prevention whose benefit stays bounded until #21 lands. ## What changed `flake.nix`, `mkDevVm`: a new `nonPathTokenArguments` binding filters `{ tokenFile, httpsTokenFile }` for values that are neither `null` nor `builtins.isPath`, and an `assert` between the builder's `let` and its `nixpkgs.lib.nixosSystem` call refuses any that remain. This matches the contract `modules/pi-provider-lifecycle.nix` already enforces for Pi token ciphertexts (`badTokenFiles`), and the message follows the same shape: it names the machine, the offending argument(s), and the correct form. The assertion reads the *effective* argument values rather than any one supplier, because that is the single funnel every value for these two arguments passes through: - the defaults, `identity.agentTokenFile` and `identity.forgeTokenFile`, which the secrets template spells as `./secrets + "/…"` today; - `profiles.lib.profileData.<machine>`, documented in `allod/profiles` as "builder-arg overrides merged into that machine's builder call" and empty in the public example, so a private profiles fork can set either argument; - inventory's `machines.<name>.profile` minus `definition`, merged into the same builder call at `machineConfigurations`; - direct `mkDevVm` calls from the checks in this flake. All four land in the formal arguments before the builder body runs, so none of them can bypass the assertion. Note that the `productionPiContract` forcing at `machineConfigurations` covers only the four Pi arguments — the token arguments are still overridable from profiles and inventory, which is why the check belongs in the builder rather than at the secrets template. ## What this does not fix The option class this assertion protects is not closed by it, and two measured counterexamples are open right now. Both are allod/archetypes#21. `modules/agent-hooks.nix:14-20` interpolates `gitPolicySource` per policy file with a bare `"${gitPolicySource}/git/…"`. Measured on this branch: `nixosConfigurations.allod-dev.config.home-manager.users.allod.home.file.".config/git/protected-branches".source` is `/nix/store/43y4nc3czj9dbn2pbyndil0hl6ra46fh-source/git/protected-branches`, whose string context is the whole secrets flake input, and `nix-store --query --requisites` on allod-dev's `toplevel` drv contains that store path. So every Forge-enabled dev VM already carries the entire secrets repository, independently of anything this PR does. `modules/github-credentials.nix:33` sets `age.secrets.<name>.file = "${secrets}/${consumer.secret}"` — a whole-repository-context string written directly into the very option this assertion is about, from inside a composed module, with no `mkForce` needed and no way for a builder-boundary assertion to see it. It is inert in public data because `secrets.lib.githubCredentialTargets` is `{}`, but #21 records it as measured on a real deployment. Stated plainly: after this merges, `age.secrets.<name>.file` is not a guarded option class. What is guarded is `mkDevVm`'s two token arguments. ## String context, measured The two spellings do not behave alike, which is why the message wording changed in the second commit. With `d` a directory path: - `"${d}/a.age"` interpolates the directory: context is the whole directory's store path. - `"${d + "/a.age"}"` interpolates the file: context is only that one file's store path — the same closure as passing the path. The assertion refuses both anyway. The contract is the value's type, not the spelling, because the leaking form is one keystroke from the safe one and nothing downstream separates them. The message says that instead of claiming, as the first commit did, that every string carries the whole repository. ## Witness Rung 2 of `testing.md`: the standing witness is the evaluation assertion itself, so no fixture check re-simulates the property. What the `dev-forge-opt-out` check gains is the sabotage pair that principle 11 requires — proof the validator can fail. It reuses that check's existing `withTokens` seam, which already varies only the two token files, so the check's derivation content is unchanged and the new proof is entirely at evaluation time. There are two sabotages rather than one because each defends its own argument's membership in the filtered attrset: with one fixture, deleting `httpsTokenFile` from the guard would leave the check green. A `pathTokens` control forces the identical fixture with both values left as paths, so a sabotage failing for an unrelated construction reason cannot read as proof. Neither fixture asserts on message text, so the reword does not touch them. ## Risk Low, and the blast radius is evaluation only. The assertion adds nothing to any closure and changes no generated output; the three machines' `system.build.toplevel` derivation paths are identical before and after (see Validation). The one behavioural change is that a deploy that today passes a string token file through `profileData` or an inventory profile setting would stop evaluating instead of building a leaking machine — which is the point, and it fails loudly with an actionable message rather than silently. One narrowing deserves calling out, because it is easy to discover later rather than now. Pure evaluation cannot mint a path value into another flake's tree: `flakeInput + "/x.age"` and `.outPath` are strings, not paths. So after this PR the override seams described above can supply only `null` or a path inside the *overriding repo's own tree*. A private profiles fork can no longer point `tokenFile` at a file in the `secrets` input, and out-of-store runtime strings such as `"/persist/…"` are refused too. That is security-aligned, and no current caller is affected — every machine and every dev-VM check evaluates green — but it does foreclose part of the flexibility the override seam otherwise offers. The `evaluates` helper in the check forces its fixture only to weak head normal form, deliberately: the guard runs before there is a configuration to evaluate, so forcing deeper would let an unrelated evaluation failure read as proof of the guard. ## Validation All commands run in the change worktree with `nix eval --no-write-lock-file`; nothing beyond the one small check derivation was built. Everything below was re-run after the second commit's reword. Positive evaluation: - `nix eval .#nixosConfigurations.{allod-dev,nexus,privacy-1}.config.system.build.toplevel.drvPath` — all three evaluate. - The same three evaluated from the master checkout produce **identical** derivation paths, e.g. `/nix/store/wwcq3mjn6090671z48vyjjf69snjpnhv-nixos-system-allod-dev-25.11.20260630.b6018f8.drv`. Generated output is unchanged. - `nix eval .#checks.x86_64-linux.<name>.drvPath` for every check that composes a dev VM — `dev-forge-opt-out`, `runtime-module-selection`, `microvm-credentials`, `microvm-host-join`, `agent-vm-status`, `pi-integration`, `pi-provider-lifecycle` — all evaluate, and `dev-forge-opt-out`'s derivation path is unchanged from master. - `nix build .#checks.x86_64-linux.dev-forge-opt-out` succeeds. Failure demonstrations, each run against a temporary sabotage and then reverted: - `tokenFile ? "${identity.agentTokenFile}"` (a string arriving through the default seam) makes `nix eval .#nixosConfigurations.allod-dev…drvPath` fail with `error: machine 'allod-dev' passes a non-path value to mkDevVm argument(s) tokenFile; each must be null or a Nix path — write ./secrets + "/agent-pr-token.age", not a string. …`. - The same sabotage applied to `httpsTokenFile` fails with the same message naming `httpsTokenFile`. - Neutering the guard's predicate to `false` makes `nix eval .#checks.x86_64-linux.dev-forge-opt-out.drvPath` fail with `error: dev-forge-opt-out: mkDevVm accepted a string agentTokenFile, so the path-form contract is no longer enforced on that argument`. - Dropping only `httpsTokenFile` from the guard's attrset makes the same evaluation fail with the `forgeTokenFile` variant. Each sabotage fixture defends its own argument. Supporting measurements: - The string-context experiment above, run with `builtins.getContext` on a scratch directory. - The `agent-hooks.nix` leak, measured with `builtins.getContext` on the generated `home.file` source and confirmed with `nix-store --query --requisites` on allod-dev's toplevel drv. Not run, and not claimed: no VM was built or booted, and `nix flake check` was not run in full — evaluating every check attribute in one process was killed for memory on this machine, so the checks above were evaluated one process at a time. ## Observed, not changed While reading `dev-forge-opt-out` I probed its header claim that "a machine with one but not the other refuses to evaluate". It does not: a fixture with `agentTokenFile` set and `forgeTokenFile = null` evaluates to a full `config.age.secrets`, and no fixture in the check witnesses that sentence. This matches the secrets template's own comment that the dev builder "treats each file as independently optional and does not check them against each other". The comment is stale, but correcting it is unrelated to this change and is left alone rather than folded in. Closes allod/archetypes#66
tokenFile and httpsTokenFile must each be null or a Nix path. A string value carries the source path of the whole repository that produced it, so a Forge-enabled dev VM would silently start carrying every machine's ciphertext.

The assertion reads the effective argument values, which is where the identity defaults, profiles' profileData, inventory profile settings, and direct check callers all converge. The dev-forge-opt-out check gains a sabotage pair, one per argument, proving the assertion can fail.

Refs allod/archetypes#66
The message claimed that every string form carries the whole source repository into the closure. Measured: "${dir}/x.age" does, while "${dir/x.age}" carries only the one file. The rule still refuses both, because the value type is the contract rather than the spelling, but the message now states the rule without asserting a consequence that does not always follow. The two check assertions are reworded the same way.

The comment also described a consumed-file carve-out that exists in no public repo; allod/archetypes#21 is unimplemented. It is replaced with the two measured leaks of this shape that this assertion does not reach: agent-hooks.nix interpolating gitPolicySource per policy file, and github-credentials.nix writing an interpolated string straight into age.secrets.<name>.file.

Refs allod/archetypes#66
Author
Member

An adversarial read-only review ran on a different model and returned "merge with changes — prose only". It re-derived every validation claim in the body independently and all of them held exactly, including the three byte-identical system.build.toplevel derivation hashes and all four sabotage demonstrations. It also independently confirmed the "observed, not changed" finding about the stale dev-forge-opt-out header comment. The mechanism was not in question; the defects were in how the change described itself. Recording the findings and the responses here.

The summary overstated the security benefit. It read as though a string token file were the way a dev VM comes to carry the secrets repository. It is not. The reviewer measured this branch: nixosConfigurations.allod-dev.config.home-manager.users.allod.home.file.".config/git/protected-branches".source is /nix/store/43y4nc3czj9dbn2pbyndil0hl6ra46fh-source/git/protected-branches, whose string context is the whole secrets flake input, and that store path is a requisite of allod-dev's toplevel derivation. The source is the bare "${gitPolicySource}/git/…" interpolations at modules/agent-hooks.nix:14-20. Every Forge-enabled dev VM already carries the entire secrets repository, and this assertion does not change that. The owner summary now leads with exactly that, and says the benefit is bounded until allod/archetypes#21 lands.

A standing counterexample inside the guarded option class was unnamed. modules/github-credentials.nix:33 sets age.secrets.<name>.file = "${secrets}/${consumer.secret}" — a whole-repository-context string written straight into the option this assertion is about, from inside a composed module, needing no mkForce and invisible to a builder-boundary check. It is inert in public data because secrets.lib.githubCredentialTargets is {}, but #21 records it as measured on a real deployment. Without it a reader concludes age.secrets.<name>.file is now guarded, which it is not. The body has a new "What this does not fix" section naming both leaks and stating plainly that what is guarded is mkDevVm's two token arguments, not the option class.

The new code comment described a mechanism that does not exist. The "consumed-file carve-out sweeps home.file only" sentence came from issue #66's own text, but there is no builtins.path carve-out anywhere in any public repo — #21 is unimplemented. The comment no longer claims it, and now names the two real, measured leaks instead.

The assertion message was factually wrong for one string shape. Confirmed with a builtins.getContext micro-experiment: "${d}/a.age" carries whole-directory context, while "${d + "/a.age"}" carries only that one file's context — the same closure as passing the path. The assertion refuses both, which is deliberate and conservative, but the old message asserted a consequence that does not always follow. This is the only code change in the second commit. The message now reads:

machine 'allod-dev' passes a non-path value to mkDevVm argument(s) tokenFile; each must be null or a Nix path — write ./secrets + "/agent-pr-token.age", not a string. Every string is refused whatever it interpolates: the directory form "${dir}/x.age" carries the whole source repository into the machine's closure, and the single-file form is one keystroke from it

The two check assertions carried the same inaccuracy and were reworded the same way, to "…so the path-form contract is no longer enforced on that argument". Neither fixture asserts on message text, so nothing depended on the old wording, but all four sabotage demonstrations were re-run afterwards and all four still fail with the new messages. Both derivation paths — allod-dev's toplevel and the check — are unchanged by the reword.

A restriction the Risk section understated. Pure evaluation cannot mint a path value into another flake's tree: flakeInput + "/x.age" and .outPath are strings. So after this PR the profileData and inventory override seams can supply only null or a path inside the overriding repo's own tree — a private profiles fork can no longer point tokenFile at a file in the secrets input, and out-of-store strings such as "/persist/…" are refused too. Security-aligned, and no current caller is affected, but it narrows the very seam the body cites as the reason the builder is the right boundary, so it now appears under ## Risk rather than waiting to be discovered by a private fork.

Second commit: 89bac5e. Apart from the assertion message wording, no code moved.

An adversarial read-only review ran on a different model and returned "merge with changes — prose only". It re-derived every validation claim in the body independently and all of them held exactly, including the three byte-identical `system.build.toplevel` derivation hashes and all four sabotage demonstrations. It also independently confirmed the "observed, not changed" finding about the stale `dev-forge-opt-out` header comment. The mechanism was not in question; the defects were in how the change described itself. Recording the findings and the responses here. **The summary overstated the security benefit.** It read as though a string token file were *the* way a dev VM comes to carry the secrets repository. It is not. The reviewer measured this branch: `nixosConfigurations.allod-dev.config.home-manager.users.allod.home.file.".config/git/protected-branches".source` is `/nix/store/43y4nc3czj9dbn2pbyndil0hl6ra46fh-source/git/protected-branches`, whose string context is the whole secrets flake input, and that store path is a requisite of allod-dev's toplevel derivation. The source is the bare `"${gitPolicySource}/git/…"` interpolations at `modules/agent-hooks.nix:14-20`. Every Forge-enabled dev VM already carries the entire secrets repository, and this assertion does not change that. The owner summary now leads with exactly that, and says the benefit is bounded until allod/archetypes#21 lands. **A standing counterexample inside the guarded option class was unnamed.** `modules/github-credentials.nix:33` sets `age.secrets.<name>.file = "${secrets}/${consumer.secret}"` — a whole-repository-context string written straight into the option this assertion is about, from inside a composed module, needing no `mkForce` and invisible to a builder-boundary check. It is inert in public data because `secrets.lib.githubCredentialTargets` is `{}`, but #21 records it as measured on a real deployment. Without it a reader concludes `age.secrets.<name>.file` is now guarded, which it is not. The body has a new "What this does not fix" section naming both leaks and stating plainly that what is guarded is `mkDevVm`'s two token arguments, not the option class. **The new code comment described a mechanism that does not exist.** The "consumed-file carve-out sweeps `home.file` only" sentence came from issue #66's own text, but there is no `builtins.path` carve-out anywhere in any public repo — #21 is unimplemented. The comment no longer claims it, and now names the two real, measured leaks instead. **The assertion message was factually wrong for one string shape.** Confirmed with a `builtins.getContext` micro-experiment: `"${d}/a.age"` carries whole-directory context, while `"${d + "/a.age"}"` carries only that one file's context — the same closure as passing the path. The assertion refuses both, which is deliberate and conservative, but the old message asserted a consequence that does not always follow. This is the only code change in the second commit. The message now reads: ``` machine 'allod-dev' passes a non-path value to mkDevVm argument(s) tokenFile; each must be null or a Nix path — write ./secrets + "/agent-pr-token.age", not a string. Every string is refused whatever it interpolates: the directory form "${dir}/x.age" carries the whole source repository into the machine's closure, and the single-file form is one keystroke from it ``` The two check assertions carried the same inaccuracy and were reworded the same way, to "…so the path-form contract is no longer enforced on that argument". Neither fixture asserts on message text, so nothing depended on the old wording, but all four sabotage demonstrations were re-run afterwards and all four still fail with the new messages. Both derivation paths — allod-dev's toplevel and the check — are unchanged by the reword. **A restriction the Risk section understated.** Pure evaluation cannot mint a path value into another flake's tree: `flakeInput + "/x.age"` and `.outPath` are strings. So after this PR the `profileData` and inventory override seams can supply only `null` or a path inside the overriding repo's own tree — a private profiles fork can no longer point `tokenFile` at a file in the `secrets` input, and out-of-store strings such as `"/persist/…"` are refused too. Security-aligned, and no current caller is affected, but it narrows the very seam the body cites as the reason the builder is the right boundary, so it now appears under `## Risk` rather than waiting to be discovered by a private fork. Second commit: `89bac5e`. Apart from the assertion message wording, no code moved.
Whitespace only; no change to any expression.
vnprc approved these changes 2026-09-04 00:47:02 +01:00
vnprc merged commit fe42f6323e into master 2026-09-04 00:47:11 +01:00
vnprc deleted branch agent/assert-token-paths 2026-09-04 00:47:12 +01:00
Sign in to join this conversation.
No description provided.