The secret that reached the transcript

The interesting question about a secret store is not whether it is encrypted. It is where the plaintext has to exist once the system is running, because that is where it gets out.

This fleet’s secrets live as ciphertext in version control — as they did in a public repository until 2026-09-23, when the single store was split by recipient set and moved to a private one. That part is sound either way, and it is worth understanding exactly why — the failure mode that actually happened here is not in the repository at all.

Ciphertext is the easy part

.sops.yaml lists recipients by age public key and maps a path pattern to the set allowed to decrypt. Until 2026-09-23 there was one store, in the public nix-config repository, and one catch-all rule over it:

1
2
3
4
5
6
7
8
9
10
11
# until 2026-09-23, in the public repository
creation_rules:
  - path_regex: secrets/[^/]+\.(yaml|json|env|ini)$
    key_groups:
    - age:
      - *admin_truva
      - *hermes_agent
      - *giga
      - *mail
      - *public
      - *vpn

That single rule is the defect the split fixed: one file, six recipient identities, so mail’s host key opened the portal’s tokens and the VPN’s private keys. The store is now one file per set, and a file’s name is its recipient set — secrets/shared-mail-vpn.yaml is readable by mail, vpn, the owner and the operator, and by nobody else — in the private configuration repository. What the public .sops.yaml still carries is each host’s public identity anchor, which is the complete answer to “who can read a given secret”, plus one deliberately bogus secrets/demo.yaml kept as a worked example.

Every value in the store is ENC[AES256_GCM,...]. The ciphertext is in git, nothing is exposed, and the file’s name states who can open it. Each host generated its own age key on the host, wrote the private half to /var/lib/sops-nix/keys.txt (root:root 0600), and published only the public half. The headers record two rotations: a full one on 2026-09-17, and the 2026-09-23 re-key whose identities superseded those. The second matters because the escrow ciphertext of the 2026-09-17 identities sits in the public repository’s history to this day — which is why the fleet was re-keyed rather than merely re-encrypted. No retired identity can recover a current value from any revision.

That is the positive control, and it is the part people miss: because the recipients are enumerated and the store is in git, you can say precisely who could read a given secret, and rotating is a reviewable commit rather than an incident channel.

But the plaintext has to be rendered somewhere

sops-nix decrypts at activation, not at build. The plaintext then lands in the running system in the shapes a unit needs: an EnvironmentFile, or a templated file under a runtime path. Both are ordinary configuration:

1
environmentFiles = [ config.sops.secrets.hermes-env.path ];   # hosts/giga/default.nix

So the plaintext is reachable by anything that can read that rendered path, or that dumps a unit’s resolved environment. A diagnostic that prints a unit’s resolved configuration prints secrets. A transcript that captures such a command pushes those values off-host, and a transcript has no expiry.

The scar this policy has

Two records are kept in the repository itself, which is where they belong.

The first is in .sops.yaml: the pre-rotation identity list is described as the four ssh-host-key-derived keys, and the header says, in a parenthetical, that mail’s had leaked into a transcript. A host key used as an age identity is a host key that can be extracted from wherever it was printed.

The second is an assertion. sops.age.generateKey = true makes sops-install-secrets generate an age key at activation and print its secret half to stdout — and the deploy jobs run nixos-rebuild switch with stdout and stderr going straight into the job log, which any reader of the repository can open. The guard in hosts/common/global/sops-generate-key-guard.nix fails evaluation rather than publish a key in production: assertion = cfg.allow || !(config.sops.age.generateKey or false);

The scale of the transcript risk is not hypothetical either. A bare sops -d on a store file returns every value. When that happened here the single store held 48 live secrets: one tool result, off-host to the model provider, with nothing left but a full rotation. The operational rule is the narrow one: use sops -d secrets/<set>.yaml | cut -d: -f1 when you want key names.

The net under the rule

modules/hermes-plugin-secret-guard exists because Hermes’ own redaction catches credential-shaped strings, and a sops value is opaque — a passphrase, a PreSharedKey, a plain hex token — so it passes through untouched. The plugin has two hooks: transform_tool_result rewrites every known value in any tool result to [REDACTED:<name>] before it reaches the model, the session store or the logs, and pre_tool_call notices commands that print raw values (sops -d, cat /run/secrets/..., age -d, printenv ...TOKEN). Its own README states that the incident it prevents has happened twice.

A guard is a net, not a policy. The policy is four rules:

What you can check

This is the same argument as what “no logs” can and cannot promise: a policy is meaningful when you can name the surface it protects. For how the store is wired into the hosts, see how the stack is built, and for the automated check over the repository, the audit page.

󰔟 Running the same kind of stack?
The tunnel this site is written from is the one I sell: WireGuard to a gateway in Montréal, three DNS postures, one price — and the same "check it yourself" habit as everything above.
[ CREATE ACCOUNT ] [ WHAT IS THE VPN ]
󰣨 ymrtech@ymrtech | 󰌠 NixOS | 󰍢 UTF-8