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:
- Read the encrypted source and the metadata — key names, recipients, rotation date — rather than the rendered value.
- Identify a secret by name and version, never by value.
- Treat transcripts, issue bodies and CI logs as publication surfaces, because they are.
- Rotate anything that was exposed. “It was only a transcript” is not a threat model.
What you can check
cat .sops.yamlin the public nix-config repository: every host’s public identity anchor, and the header that dates the split and the rotations. The store’s own rules are in the private repository; there, a file’s name is its recipient set.- The rendered paths on a host.
systemctl show hermes-agent -p Environment, and the file behind anyconfig.sops.secrets.*.path— this is what a diagnostic prints, and the reason the quoting rule exists. - The assertion.
hosts/common/global/sops-generate-key-guard.nixfails a build if a host is configured to generate its own key at activation, with the log-reading reasoning in the failure message. - The key names only.
sops -d secrets/<set>.yaml | cut -d: -f1returns identifiers, which is the whole of what a review needs.
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.