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:
- 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. The recipient list is the complete answer to “who can read this secret”, and the header dates the rotation.- 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/secrets.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.