Appearance
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_abc123def456Echoing 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 # ← oopsBaking 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_abc123Why 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: trueThe 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
- Lifecycle hooks —
env:and how it's exposed to processes - YAML reference —
tags,skip,onlyschema for splitting hook execution by environment