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 a public repository. That part is sound, 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 sits at the root of the nix-config repository and is readable. It lists recipients by age public key and maps a path pattern to the set allowed to decrypt:

1
2
3
4
5
6
7
8
9
10
creation_rules:
  - path_regex: secrets/[^/]+\.(yaml|json|env|ini)$
    key_groups:
    - age:
      - *admin_truva
      - *hermes_agent
      - *giga
      - *mail
      - *public
      - *vpn

Every value in secrets/secrets.yaml is ENC[AES256_GCM,...]. The file is in git, the repository is public, and nothing is exposed. 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 header records a full rotation on 2026-09-17, after which the store was re-encrypted under a fresh data key so no retired identity can recover current values from any revision of the public repository.

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 secrets/secrets.yaml returns every value. On this fleet that is 48 live secrets in 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/secrets.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