| name | secret-hygiene |
| description | Preventive playbook for keeping secrets (tokens, API keys, PATs, passwords, private keys, signed URLs, session cookies) out of the chat transcript AND out of git. Covers the "never ask, never echo, never paste" rule for the conversation itself (because chat is sent to the cloud and persisted), plus the pre-commit / pre-push / repo-config layers that stop credentials from reaching GitHub. Use when handling anything credential-shaped — reading a token, configuring a service, writing a script that authenticates, asking the user to authenticate, or about to commit/push code that touches auth. |
| author | dfrysinger |
secret-hygiene
Secrets must not appear in two places: the chat transcript, and any git
history. Both are durable, both leak to third parties (the chat sends every
turn to a cloud model; git history is forever and trivially scraped on public
repos). This skill is the preventive layer. If something has already leaked,
rotation is the only real fix — file deletion and history-rewrite don't help
once a value has been seen by anyone outside your trust boundary.
Rule 0: chat is not private
Every user message, every assistant message, every tool output you display
back is sent to the cloud model provider and persisted in session-store
history. Treat the chat transcript as a public, durable log. If a value
shouldn't be on a billboard, it shouldn't be in chat.
This includes:
- The user pasting a token "just so you can use it"
- You echoing the token back to confirm receipt
- A
cat ~/.env or env dump that includes credential-shaped values
- A
gh auth token or aws configure get output rendered in your reply
- A debug log line
headers={Authorization: Bearer …}
- A screenshot the user attaches that shows the secret on screen
- A stack trace that includes a connection string with embedded password
- A test fixture you read that turns out to contain a real key
If any of these appear, the secret is already leaked. Apologize, tell the
user to rotate immediately, and continue the work without that value in
the transcript.
Rule 1: never ask the user to paste a secret into chat
Always prefer an indirection:
- "Put your token in
~/.config/<app>/token (mode 600) and I'll read it from
there with cat, redacting before any echo."
- "Set
export GITHUB_TOKEN=... in your shell and I'll consume it via env
without printing it."
- "Run
op item get <name> --fields password | pbcopy (1Password CLI) and
paste it directly into the target tool — not into this chat."
- For macOS specifically: store in login keychain
(
security add-generic-password -s <svc> -a <user> -w) and read with -w
inside a single-shot script that pipes straight to the consumer. Never
use -g — it prints the password in human-readable form to the terminal,
which means into your chat transcript.
If the user does paste a secret despite this rule, stop and have them
rotate. Don't try to "use it carefully." A leaked credential cannot be
un-leaked by being careful with it afterward.
Rule 2: never print, log, or echo a secret
When you must handle a credential value in a script you write or run:
- Pipe, don't print. Compose the value directly into the consumer:
TOK=$(security find-generic-password -s ... -w) && curl -H "Authorization: Bearer $TOK" ...
in a single bash tool call (in Copilot CLI, each bash call is a fresh
process — vars don't persist, so split-across-calls breaks anyway).
- Redact in any displayed output. If you
echo a header or env var for
debugging, mask all but the last 4 characters: gho_********************abcd.
Even better: print only len=40 starts=gho_ (length + prefix is enough to
diagnose "wrong token type" without disclosing the value).
- No
set -x / bash -x while a secret is in scope. Trace mode echoes
every expanded command including the substituted credential.
- Suppress error output that includes the value. Many CLIs print the auth
header on failure. Pipe stderr through a redactor or to
/dev/null and rely
on exit code + status code instead.
- Don't paste tool outputs verbatim if they contain secrets. Summarize:
"got 200, token validates, scopes are X/Y/Z" — not the raw
gh auth status
block that includes the token.
Rule 3: never commit a secret to git
Layered defenses (each layer assumes the previous one fails):
Layer 1 — gitignore from day one
Before any code: .env, .env.local, .env.*.local, *.pem, *.key,
*.p12, *.keystore, *credentials*, *secret*, id_rsa*, id_ed25519*,
*.kdbx, config/secrets.yml, .npmrc (if it contains an auth token),
.netrc. Add framework-specific entries (Rails master.key, Next .env*).
Layer 2 — committed .env.example, never the real one
Track an .env.example with shape only:
DATABASE_URL=postgres://USER:PASS@HOST:PORT/DB
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
This documents the contract without leaking values, and gives the
pre-commit scanner a known false-positive fixture (see Layer 3).
Layer 3 — pre-commit secret scanner
Install gitleaks (brew install gitleaks on macOS) and wire it as a
pre-commit hook:
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.x
hooks:
- id: gitleaks
Or as a raw git hook in .git/hooks/pre-commit:
#!/usr/bin/env bash
gitleaks protect --staged --redact --no-banner || {
echo "gitleaks found a secret in your staged changes. NOT committing." >&2
exit 1
}
--staged scans only what's about to be committed (fast). --redact keeps
the diagnostic itself from re-leaking the value. Alternatives:
trufflehog, detect-secrets (Yelp). Pick one, configure once, leave it on.
Layer 4 — server-side push protection
On GitHub: repo Settings → Code security → enable Secret scanning and
Push protection. Push protection rejects pushes that contain known
credential patterns (GitHub PATs, npm tokens, AWS keys, ~200 partners),
before the bad commit reaches the remote. This is the only layer that
catches you when you forget to install Layer 3 on a new clone.
For org-wide enforcement: GitHub Enterprise / org-level push protection +
custom patterns for your own token formats.
Layer 5 — repo visibility check before pushing anything new
Before git push on a fresh repo:
gh repo view --json visibility -q .visibility
If PUBLIC and you weren't expecting public, stop. A common failure
mode is creating a repo with gh repo create and accepting the public
default. Verify before pushing the first commit, not after.
Rule 4: if a secret leaks, rotate
A leaked secret is leaked the moment it leaves your trust boundary —
posted to chat, pushed to GitHub, pasted in a Slack DM, screen-shared in a
Zoom call, written to a log shipped to a SaaS, included in a stack-trace
emailed to an error tracker. None of those are reversible by deletion.
The only effective response is rotation:
- Generate a new credential at the source (GitHub PAT page, AWS console,
op item edit, etc.).
- Replace it everywhere it's consumed (env vars, keychain entries, CI
secrets, deploy targets).
- Revoke the old one. Don't "leave it for now in case something breaks" —
that's how leaks become incidents weeks later.
- Then clean up the visible artifact (delete the chat message, force-push
the history-rewritten branch, etc.) — but treat that as cosmetic. The
security-relevant action was step 3.
On a public repo: skip directly to rotation. There's no "maybe it wasn't
indexed yet" — GitHub's secret-scanning partner program actively notifies
issuers (AWS, GitHub itself, etc.) within seconds of a leaked credential
appearing in a public commit. Assume the value is compromised the moment
the push completes.
Rule 5: store secrets at rest encrypted, even in throwaway builds
Never store customer credentials or tokens unencrypted — for example, in
plaintext columns in a SQLite database — even for a beta, spike, or "temporary"
build. Use the OS keychain or a properly encrypted store. If code-signing (which
unlocks the OS keychain) is the friction, fix the signing rather than falling back
to a plaintext-in-DB store; a "temporary" plaintext credential store reliably
outlives its intended lifetime and becomes the leak.
Mode-specific traps
macOS keychain
security find-generic-password -w is safe (writes only the password to
stdout, no metadata, no human-readable header). Pipe straight to the
consumer or into an unexported shell variable used in the same command.
security find-generic-password -g is unsafe. It prints "password:
" in the GUI dialog AND in the terminal in a parseable format. The
difference is one character and one decade of muscle memory; pick the
right one every time.
- The interactive
-w prompt truncates input at 128 chars (the
readpassphrase cap). Storing a longer secret (JWTs, GitHub/HA long-lived
tokens ~183 chars) via the prompt silently saves a truncated, useless value.
For anything possibly over 128 chars pass the value non-interactively:
security add-generic-password -s <svc> -a <user> -w "$(pbpaste)" or read it
from a file — never the prompt.
GitHub Codespaces / Actions
- Never
echo $SECRET in an Actions step. Use ::add-mask:: if you must
pass it to a downstream step and need it visible in logs (it gets
redacted from the displayed log but is still in the workflow's memory).
- Codespaces inherit org/repo secrets as env vars — verify with
env | grep -iE 'token|secret|key' returns nothing visible (or only
expected names with redacted values).
LLM tool calls (Copilot CLI, Claude Code, etc.)
- Tool outputs from
bash, view, grep are inserted into the conversation
context and sent to the cloud model on the next turn. A cat ~/.env that
contains real values is a leak even if you "only meant to look."
- If you must read a credential file to verify shape, redact at the source:
awk -F= '{print $1"=...(redacted)..."}' ~/.env
- Be wary of debug commands that dump request/response:
curl -v prints
the Authorization header. Use -sS plus -w '%{http_code}\n' instead.
Test fixtures
- Test files that look like fixtures (
tests/fixtures/auth.json,
__fixtures__/cookies.txt) are still real secrets if they contain real
values. The "it's just a test" reasoning has caused production leaks more
than once.
- Use generated values (
uuidgen, deterministic hashes) for fixtures.
Real-shape, fake-value.
When this skill loads
The trigger is anything credential-shaped in the user request or your plan:
"token", "API key", "password", "secret", "PAT", "auth", ".env", "keychain",
"credentials", "rotate", "scope", "1Password" / "Bitwarden" / "Vault",
"AWS_ACCESS_KEY", "GH_TOKEN" / "GITHUB_TOKEN", "ssh-keygen", "private key",
"signed URL", or any HTTP Authorization header.
If the request is post-incident (you/the user just leaked something and
need to clean up), the rule is: rotate first, audit second, clean third.
Don't spend time on history rewrites or chat deletions until step 1 is done.
Bad habits to actively unlearn
- "Just paste the token, I'll be careful with it." — No. Indirect every time.
- "I'll redact it in the displayed output but the raw is fine in memory." —
Tool outputs go to the model. There is no "in memory but not displayed."
- "It's a low-privilege read-only token, so the bar is lower." — The bar is
the same. Read-only tokens enumerate your repos, your collaborators, your
CI variable names, and give an attacker the reconnaissance to escalate.
- "Force-pushing removes it from history so we're fine." — The value was
seen. Rotate.
- "It's a private repo so I don't need to worry about leaks." — Private
today, public tomorrow (visibility flip, fork, archive export). Treat
every repo as if it might become public.