A coding agent has your shell. If you can read a key, it can read a key. If you can commit, it can commit. Count the steps it clears before it reaches one, and whether those steps leave a trail.

This repo’s answer is layered. Nothing secret sits in plain text. An automated caller has to state a reason before secretspec will resolve anything. Staged content is scanned before commit. Pushes to master are scanned again. OpenCode has to ask before it runs git commit or git push. Each layer is a guardrail. None of them is enforcement.

Nothing in plain text

Secrets live in secrets.age, an age-encrypted file committed next to the code. The identity that decrypts it sits in ~/.config/age/keys.txt, never in the repository. The recipient is public; the private key is not.

What the project needs is declared in secretspec.toml: the OpenAI and TypeSafe keys for the ask endpoint, and the Netlify deploy values the production workflow asserts. vp run dev injects them through secretspec. The ask keys are read at runtime, so a build needs nothing. For anything else:

secretspec run -p "age://secrets.age?identity=$HOME/.config/age/keys.txt" -- <command>

.env holds local public flags only and is gitignored. No R2 credentials exist anywhere in the tree; assets go out through the Cloudflare dashboard. CI takes public config from GitHub vars and its two deploy secrets from GitHub secrets.

The point of committing the encrypted file is boring and useful. A clone has the shape of the secret set. A new machine still needs the identity. An agent that only has the repo has ciphertext.

The move was cleanup. Scattered env files became one encrypted file that documents the set.

State a reason

secretspec’s policy for automated callers is simple. Pass --reason, or set SECRETSPEC_REASON, or the run is blocked.

That is the whole rule. There is no clever classification of which agent is “safe”. A reason string is cheap to require and annoying to forge at scale, and it turns up in the audit trail when you want to know why a key was resolved at 2am.

Scan before it leaves

Two scanners, at two moments, for the same reason I ordered checks by cost in Failing cheaply.

Betterleaks runs on staged content in the pre-commit hook. It is pinned (go install github.com/betterleaks/[email protected]) and it sees only what you are about to commit. A key that never reaches the index never reaches the hook either, which is fine; this layer is for the mistake of staging a secret, not for a key that stayed in a scratch file.

TruffleHog verifies credentials on commits pushed to master, and audits full history weekly. Push-time catches what pre-commit missed. The weekly job catches what history already contains. Verified findings matter more than pattern hits; a scanner that cries wolf trains you to skip it.

Pre-commit is cheap. Push is dearer. The weekly audit is the expensive pass you run because the cheap ones are not proof.

Ask before commit and push

The last layer is two lines in opencode.jsonc:

{
  "action": "shell",
  "resource": "*git commit*",
  "effect": "ask"
},
{
  "action": "shell",
  "resource": "*git push*",
  "effect": "ask"
}

OpenCode has to get a yes from me before either command runs. It can edit files, run tests, regenerate a corpus under secretspec. It cannot land a commit or push to a remote without stopping.

That will not stop a determined agent with a different tool. It stops the default path, which is the path accidents take.

This happened: an agent committed without asking, which is why the ask rule exists.

Guardrail, not enforcement

The secrets doc ends with the line that matters:

git commit --no-verify bypasses the hook, so this is a guardrail, not enforcement.

Every layer above has a bypass. You can skip the hook. You can run a command outside secretspec. You can paste a key into a prompt. You can approve an OpenCode ask without reading it.

What the layers buy you is friction in the right places, and a record when something sensitive moved. They do not buy you a guarantee. Treating them as a guarantee is how you stop reading the diffs.

The agent still has your shell. Make it ask. Make it say why. Scan what it stages. Then read the commit yourself.