managing-secrets

How secrets are stored, decrypted, and used on the devbox. Use when adding, removing, or debugging secrets.

SKILL.md

GitHub

Managing Secrets

Secrets are managed with sops-nix using age encryption. They're encrypted in the repo and auto-decrypted at boot.

Current Secrets

Secret Usage How it's consumed
github_ssh_key Git operations Deployed to ~/.ssh/id_ed25519_github
cloudflared_tunnel_token Cloudflare tunnel Systemd service reads from /run/secrets/
jenkins_host cloudbox only. Jenkins hostname (org-identifying). Exported as JENKINS_HOST; also substituted into /etc/hosts via sops.templates.hosts (see hosts/cloudbox/configuration.nix). Mac equivalent: Keychain jenkins-host.
cloudflare_api_token Wrangler CLI Exported as CLOUDFLARE_API_TOKEN in bash
claude_personal_oauth_token cloudbox only as of 2026-09-02. Removed from devbox: nothing consumed it. The old entry claimed the @ex-machina/opencode-anthropic-auth plugin did — it does not (zero references to CLAUDE_CODE_OAUTH_TOKEN in the shipped dist), and on devbox that plugin runs shape-only with a dummy credential while TeamClaude owns the tokens. Exported as CLAUDE_CODE_OAUTH_TOKEN on cloudbox. shell-env.ts still maps it for bash tool calls, but the mapping is guarded on /run/secrets/<name> existing, so it is inert on devbox.
ccr_api_key Pigeon daemon Read by systemd ExecStart from /run/secrets/
telegram_bot_token Pigeon daemon Read by systemd ExecStart from /run/secrets/
telegram_chat_id Pigeon daemon Read by systemd ExecStart from /run/secrets/
typesafe_api_key TypeSafe AI (jev decision model) via typesafe-sdk. devbox and cloudbox, each in its own secrets/<host>.yaml (neither host can encrypt to the other's file). On cloudbox (a work host) data sent is retained under TypeSafe's DPA; ZDR is enterprise-only. Exported as TYPESAFE_API_KEY in bash and by shell-env.ts
dd_pat Datadog CLI (dd-cli) + MCP — Personal Access Token (Bearer auth) Exported as DD_PAT in bash

How Secrets Flow

secrets/devbox.yaml (encrypted in git)
        ↓
sops-nix decrypts at boot using /persist/sops-age-key.txt
        ↓
/run/secrets/<secret_name> (plaintext, mode 0400)
        ↓
Consumed by: systemd services, bash exports, or file deployment

Option D: MCP server credentials ({file:...} references)

Never inline a credential into ~/.config/opencode/opencode.json. That file is machine-generated by home.activation.* in users/dev/opencode-config.nix, but it is a real 0600 file in $HOME — readable by anything running as dev, including an agent that reads its own config into a transcript. It is also copied verbatim into any opencode.json.bak.* that mergeOpencode spawns. (This is not hypothetical: on 2026-08-01 live Datadog, Slack, PagerDuty and Rollbar tokens were found sitting there in cleartext.)

opencode's config loader expands two forms over the raw config text, before it parses the JSON, so they work in any string — MCP headers values and MCP environment values alike:

Form Resolves to Missing target
{env:VAR} process.env[VAR] expands to "" (never throws)
{file:PATH} file contents, trimmed + JSON-escaped; ~/, absolute, or config-relative fails the ENTIRE config load

Use the secretRef helper in opencode-config.nix, which emits the right form per host (/run/secrets/<name> on NixOS, a Keychain-mirrored 0600 file under ~/.config/opencode/secrets/<name> on macOS):

${pkgs.jq}/bin/jq --arg tok "${secretRef "my_secret"}" \
  '.mcp.thing = { type: "local", command: [$cmd], enabled: false,
                  environment: { MY_TOKEN: $tok } }' "$runtime" > "$tmp"

Do not use {env:...} for these. opencode's bash tool runs non-interactive shells, so ~/.bashrc short-circuits and the token exports never run (that is why assets/opencode/plugins/shell-env.ts exists); the TUI and serve give no better guarantee. {file:} reads the source of truth regardless of how the process was started.

The gate is load-bearing, not tidiness

Because a missing {file:} target invalidates the whole config — bricking all of opencode, not just that one server — a reference may only ever be written when the target exists. Every inject block is therefore structured as:

secret present -> write .mcp.X (with the reference)
secret absent  -> del(.mcp.X)

Never "simplify" that del away, and if one secret backs two entries (e.g. slack + slack-ro), delete both or the orphan becomes a stale reference that fails the config load.

A warn-only activation guard (assertOpencodeConfigHasNoSecrets) substring-matches every /run/secrets/* value against the finished config and shouts if one leaked, so a regression is caught instead of sitting silently.

Gotcha: rebuilds disarm hand-enabled MCP servers

Each inject block replaces .mcp.<name> wholesale, and the replacement hardcodes enabled: false. So if you enable an MCP server by hand, the next home-manager switch — by you or by any other session sharing the machine — resets it to disabled, and running sessions lose those tools mid-flight.

This is deliberate (fail-closed: pagerduty/devcycle/slack carry write tools, so none of them can be left quietly armed across a rebuild), not a bug. Recovery is to re-enable and restart the session; nothing is corrupted.

Adding a New Secret

Step 1: Add to sops file

# Edit encrypted file (requires age key access)
sudo nix-shell -p sops --run "SOPS_AGE_KEY_FILE=/persist/sops-age-key.txt sops secrets/devbox.yaml"

# Or use sops set for non-interactive:
sudo nix-shell -p sops --run "SOPS_AGE_KEY_FILE=/persist/sops-age-key.txt sops set secrets/devbox.yaml '[\"my_new_secret\"]' '\"secret-value\"'"

Step 2: Declare in NixOS config

Edit hosts/devbox/configuration.nix, add to sops.secrets:

sops.secrets = {
  # ... existing secrets ...

  my_new_secret = {
    owner = "dev";
    group = "dev";
    mode = "0400";
    # Optional: deploy to specific path instead of /run/secrets/
    # path = "/home/dev/.config/app/secret";
  };
};

Step 3: Consume the secret

Option A: Export as env var (for CLI tools)

Edit the per-host home module — users/dev/home.devbox.nix or users/dev/home.cloudbox.nix (there is no home.linux.nix; do not put host secrets in home.base.nix, which is shared with Darwin):

programs.bash.initExtra = lib.mkAfter ''
  if [ -r /run/secrets/my_new_secret ]; then
    export MY_ENV_VAR="$(cat /run/secrets/my_new_secret)"
  fi
'';

Option B: Use in systemd service (for daemons)

systemd.services.my-service = {
  serviceConfig = {
    ExecStart = "${pkgs.writeShellScript "run" ''
      exec my-command --token "$(cat /run/secrets/my_new_secret)"
    ''}";
  };
};

Option C: Deploy as file (for apps expecting file path)

Set path in the secret declaration (Step 2).

Step 4: Apply changes

git add secrets/devbox.yaml hosts/devbox/configuration.nix
git commit -m "feat: add my_new_secret"

sudo nixos-rebuild switch --flake .#devbox
home-manager switch --flake .#dev  # if you added bash export

Removing a Secret

Step 1: Remove from consumers

  • Remove any bash exports from users/dev/home.devbox.nix (or home.cloudbox.nix)
  • Remove any systemd service references — including ExecStart wrappers that cat the secret, which are easy to miss because they are not bash exports
  • Remove the mapping in assets/opencode/plugins/shell-env.ts, if present. That plugin injects secrets into every opencode bash tool call, and is a separate path from programs.bash.initExtra
  • Remove the declaration from hosts/<host>/configuration.nix
  • Grep before believing you are done: grep -rn <secret_name> across the repo

Step 2: Remove from the sops file

Use sops unset. It edits in place, so it preserves the file's recipient list and never writes plaintext to disk:

sudo nix-shell -p sops --run \
  "SOPS_AGE_KEY_FILE=/persist/sops-age-key.txt sops unset secrets/devbox.yaml '[\"secret_to_remove\"]'"

File ownership survives the sudo (it stays dev:dev).

Do not decrypt to a temp file and re-encrypt. This skill used to document exactly that, and the recipe had two defects. Both were found while removing claude_personal_oauth_token (PR #462); it is kept here so nobody reinvents it.

# THE OLD RECIPE -- DO NOT USE
sops -d secrets/devbox.yaml > /tmp/secrets-plain.yaml
yq -i 'del(.secret_to_remove)' /tmp/secrets-plain.yaml
sops encrypt --age age1kyd7dzxtgte0rcd0nj3chfvcfvammhywe63f25tlsrf8knhf3u8sxp8z9n ... > secrets/devbox.yaml
  1. It silently drops recipients. That --age flag hardcodes devbox's key as the sole recipient. secrets/cloudbox.yaml's only recipient is the cloudbox key, so running this against it re-encrypts cloudbox's secrets to a key cloudbox does not hold. Nothing fails at edit time; it fails at cloudbox's next boot, when sops-nix cannot decrypt and every secret-consuming service breaks at once.

    (The original motivating example was secrets/chromebook.yaml, which had two recipients so the recipe would have half-worked. That host is decommissioned and the file is gone — cloudbox is the live hazard now, and it is the worse one, since a single-recipient file means total lockout rather than partial.)

  2. It writes every decrypted secret to /tmp. To delete one key it puts all ~28 others in plaintext on a multi-tenant box for the duration of the edit. sops unset never materialises them.

Step 3: Verify before you commit

A botched sops edit is not visibly botched — the file still looks like ciphertext. Check the three things that can silently go wrong:

cp secrets/devbox.yaml /tmp/before.yaml   # ciphertext, not plaintext

# ... run the sops unset ...

# 1. It still decrypts, and exactly one key went away.
#    NOTE: pass BOTH --input-type and --output-type. sops guesses from the file
#    extension, so a copy named *.pre or *.bak decrypts as "binary" and errors
#    to an EMPTY result -- which then compares equal against a filtered list and
#    reports a cheerful PASS. Ask me how I know.
sudo nix-shell -p sops -p yq-go --run \
  "SOPS_AGE_KEY_FILE=/persist/sops-age-key.txt sops -d --input-type yaml --output-type yaml /tmp/before.yaml | yq 'keys | .[]'" \
  | grep -v '^secret_to_remove$' > /tmp/expected.keys
sudo nix-shell -p sops -p yq-go --run \
  "SOPS_AGE_KEY_FILE=/persist/sops-age-key.txt sops -d --input-type yaml --output-type yaml secrets/devbox.yaml | yq 'keys | .[]'" \
  > /tmp/actual.keys
diff /tmp/expected.keys /tmp/actual.keys && echo "PASS: exactly one key removed"

# 2. The recipient list is unchanged.
diff <(grep recipient: /tmp/before.yaml) <(grep recipient: secrets/devbox.yaml) \
  && echo "PASS: recipients unchanged"

# 3. No other ciphertext moved. Only the removed key, mac and lastmodified
#    should differ -- a full re-encrypt rewrites every line, which is the
#    signature of having used the wrong tool.
git diff --stat secrets/devbox.yaml

rm -f /tmp/before.yaml /tmp/expected.keys /tmp/actual.keys

Compare key names only, never values — a verification step that prints secrets into a terminal or an agent transcript has leaked them.

Step 4: Apply and commit

git add -A && git commit -m "chore: remove secret_to_remove"
sudo nixos-rebuild switch --flake .#devbox

Step 5: Revoke at the source

Removing a credential from sops is not revocation. The value is still valid at whatever issued it, and the old ciphertext stays in git history forever. If the secret is being retired rather than moved, revoke it at the provider — and check the other hosts' secret files first (secrets/*.yaml), since one credential is often shared.

Key Files

File Purpose
secrets/devbox.yaml Encrypted secrets (committed to git)
secrets/.sops.yaml sops config (which keys can decrypt)
/persist/sops-age-key.txt Age private key (never in git, root-only)
hosts/devbox/configuration.nix Secret declarations for sops-nix
users/dev/home.devbox.nix, home.cloudbox.nix Bash exports for env vars (per host; there is no home.linux.nix)
assets/opencode/plugins/shell-env.ts Injects /run/secrets/* into every opencode bash tool call, since non-interactive shells never source ~/.bashrc

Troubleshooting

"permission denied" when editing secrets

The age key is root-only. Use sudo with nix-shell:

sudo nix-shell -p sops --run "SOPS_AGE_KEY_FILE=/persist/sops-age-key.txt sops secrets/devbox.yaml"

Secret not appearing after rebuild

  1. Check it's declared in sops.secrets in configuration.nix
  2. Run sudo nixos-rebuild switch (not just home-manager)
  3. Verify: ls -la /run/secrets/

Env var not exported

  1. Check the export is in the per-host module (home.devbox.nix / home.cloudbox.nix), not home.base.nix, which is shared with Darwin
  2. Run home-manager switch
  3. Start a new shell (exports only apply to new shells)

Security Notes

  • Secrets are encrypted at rest with age (AES-256)
  • Decrypted secrets are mode 0400 (owner read-only)
  • The age key lives on /persist/ which survives rebuilds but not re-provisioning
  • Never commit the age private key or decrypted secrets
  • Env var exports live in the per-host module (home.devbox.nix / home.cloudbox.nix), so they don't leak to Darwin
  • A secret with no consumer is not harmless: it still gets decrypted to /run/secrets and injected into every bash tool call. Remove the plumbing when the consumer goes away