Skip to content

Anti-pattern: secrets in version-controlled hooks

Hooks are committed code. Secrets in committed code are public — to anyone who clones the repo, anyone who scrapes GitHub, anyone whose machine has the repo cached in ~/.cache. Once a secret hits a commit, it's compromised; rotating it is the only fix.

daft.yml lives in version control. Anything in it is shared with the team — and, if the repo is public or the host is breached, with the world. The recipes below cover what people accidentally commit and what to do instead.

What people try

Hardcoding API keys in daft.yml

yaml
# daft.yml — DON'T DO THIS
hooks:
  worktree-post-create:
    jobs:
      - name: register-with-staging
        run: curl -X POST https://staging-api.example.com/register
        env:
          API_KEY: sk_live_abc123def456

Echoing secrets in hook output

yaml
- name: log-config
  run: |
    echo "Configured with DATABASE_URL=$DATABASE_URL"
    echo "API_KEY first chars: ${API_KEY:0:4}..."

Exposing secrets via long-running env: for backgrounded jobs

yaml
- name: dev-server
  run: ./bin/server
  background: true
  env:
    AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
    STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}

Committing the decrypted output of a sops/age workflow

bash
sops --decrypt secrets.enc.env > .env
git add .env  # ← oops

Baking secrets into a prebuilt CI image

dockerfile
# .ci/Dockerfile — DON'T DO THIS
ENV DATABASE_URL=postgres://prod-readonly:hunter2@db/app
ENV API_KEY=sk_live_abc123

Why it breaks

Once a secret hits git history, it's compromised. git rebase or git filter-branch won't save you — anyone who pulled in the intervening time has it. GitHub's secret scanner notifies the issuer; some tokens get auto-revoked. The expensive ones (private API keys for infra) require a manual rotation + audit cycle.

Echoing secrets to logs leaks them. daft hooks log to ~/.local/state/daft/hook-logs/ (and to stdout via daft hooks log show). Anyone with shell access to the dev's machine can read the logs. CI log aggregators (Datadog, Sumo Logic) ingest stdout; secrets in the prefix end up indexed and searchable. "First 4 chars" leaks 4 chars of entropy per occurrence — over many hooks, that adds up.

Process-list visibility. A backgrounded job's env: shows up in ps -e ww for any user on the system. macOS and most Linux distros don't restrict this by default. Read-only env files (with chmod 600) are fine; env vars on a long-running process aren't.

Decrypted state belongs in .gitignore. A .env file generated by sops isn't supposed to be a tracked artifact. Always add to .gitignore before first decrypt; that way git add can't include it accidentally.

Baked-in image secrets are visible in image layers. docker history shows every ENV line. Anyone who can pull the image can read the secrets — even if you remove the layer in a later step. Pull-secret control is the only protection, and CI registries are routinely breached.

What to do instead

For each pattern above:

Don't hardcode — fetch from a vault at hook time

yaml
- name: register-with-staging
  run: |
    API_KEY=$(op read "op://daft-dev/staging-api/key")
    curl -X POST https://staging-api.example.com/register \
      -H "Authorization: Bearer $API_KEY"

The hook is committed; the secret is not. See Env vars & secrets → direnv with a vault lookup.

Decrypt to a file with restrictive permissions

yaml
- name: decrypt-secrets
  run: |
    sops --decrypt secrets.enc.env > .env
    chmod 600 .env

.env is in .gitignore. chmod 600 restricts to the owning user. The app reads from .env (via direnv, dotenv, or directly).

Don't echo secrets — log placeholder text

yaml
- name: log-config
  run: echo "Configured (secrets redacted from log)"

If you really need to confirm a secret was loaded (debugging), use [ -n "$API_KEY" ] && echo "API_KEY is set" — never reveal even partial contents.

For long-running services, use file-based config, not env vars

yaml
- name: dev-server
  run: ./bin/server --config-file .env.json
  background: true

The server reads from .env.json (gitignored, mode 600) directly. No secret env vars for ps -e to expose.

For CI, use the CI secret store

GitHub Actions: ${{ secrets.API_KEY }} in the workflow env:, injected only into the steps that need it. GitLab: variables with "Mask" enabled. See CI parity.

Composes well with

  • Env vars & secrets — the full positive treatment: vault lookups, sops + age, direnv, mise [env].
  • CI parity — split local-vault hooks from CI-secret-store injection via skip:/only: env conditions.
  • Trust & security — why daft.yml needs trust before secret-touching jobs run.

See also

Released under MIT or Apache-2.0.