Hide secrets from LLMs when reading config files.
drape is a defense-in-depth tool for Claude Code and other AI agents. It masks credentials in .env, SOPS-encrypted .env.sops, and structured (YAML / JSON / TOML) config files so they never appear in LLM conversations, prompt-cache snapshots, or provider logs.
Your real
.envis never modified. Plaintext is never written to disk. The masking happens in-process on your machine before the agent sees anything.
A real-shaped .env (slightly bigger than a hello-world):
$ cat .env
DATABASE_URL=postgres://app:Y4nKee_Doodle@db.prod.internal:5432/orders
REDIS_URL=redis://default:Sk1pper-Jack@cache.prod.internal:6379
AWS_ACCESS_KEY_ID=AKIAQRSTUVWXYZ234567
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
GITHUB_TOKEN=ghp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8
OPENAI_API_KEY=sk-proj-aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5aB6cD7eF8gH9iJ0
SLACK_WEBHOOK=https://hooks.slack.com/services/T0123ABCD/B4567EFGH/abcdefghij1234567890
JWT_SIGNING_KEY=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.dozjgNryP4J3jVmNHl0w5N_XgL0JtMTPbqlSEqg7yfQ
SESSION_SECRET=k8jHs2pQrL5mN9wXcVbZyT3fG7hJ4dE6
COOKIE_SALT=correct horse battery staple
NODE_ENV=production
PORT=8080
LOG_LEVEL=info
AWS_REGION=us-east-1
$ drape .env
DATABASE_URL=<basic-auth>
REDIS_URL=<basic-auth>
AWS_ACCESS_KEY_ID=<aws-access-key>
AWS_SECRET_ACCESS_KEY=wJa...
GITHUB_TOKEN=<github-token>
OPENAI_API_KEY=sk-...
SLACK_WEBHOOK=<slack-token>
JWT_SIGNING_KEY=<jwt>
SESSION_SECRET=k8j...
COOKIE_SALT=cor...
NODE_ENV=pr...
PORT=<low-entropy-secret>
LOG_LEVEL=<low-entropy-secret>
AWS_REGION=<low-entropy-secret>Three buckets of behavior, all visible above:
- Pattern-matched → zero chars revealed.
DATABASE_URL,REDIS_URL,AWS_ACCESS_KEY_ID,GITHUB_TOKEN,SLACK_WEBHOOK,JWT_SIGNING_KEY— detect-secrets recognizes the shape, so the value collapses to a type label. (~21 detectors in total; full list further down.) - Pattern miss → prefix reveal.
AWS_SECRET_ACCESS_KEY(40-char base64, no fixed prefix),OPENAI_API_KEY(the newersk-proj-form isn't in detect-secrets yet),SESSION_SECRET,COOKIE_SALT,NODE_ENV— none matched a pattern and entropy was high enough that the fallback ran. You get at most 3 chars +..., capped to 25 % of the value's length. - Low-entropy →
<low-entropy-secret>.PORT=8080,LOG_LEVEL=info,AWS_REGION=us-east-1— these aren't secrets. They're short, low-variety strings that fall below the entropy threshold, so drape masks them anyway. Drape is deliberately conservative: the LLM doesn't need the value ofPORTto help you reason about config, only that the key is set. Over-masking is the right error to make.
Things to notice that the LLM still sees:
- Every key name. So the model can still answer "what services does this app talk to?"
- Whether a key is empty or set.
- The credential type for recognized shapes — enough to say "you have an AWS access key here" without ever seeing it.
Three strategies, applied in order — the most-protective hit wins:
-
Pattern recognition — known credential shapes are replaced with a type label, e.g.
<aws-access-key>,<github-token>. The LLM learns what kind of credential is at this key, but zero characters of it. Powered bydetect-secrets; ~21 detectors enabled by default:Detector Label AWS access keys <aws-access-key>Azure storage keys <azure-storage-key>GitHub / GitLab tokens <github-token>/<gitlab-token>Slack tokens <slack-token>Stripe keys <stripe-key>Square OAuth tokens <square-oauth-token>Twilio keys <twilio-key>Discord bot tokens <discord-bot-token>Mailchimp / Mailgun / SendGrid <mailchimp-key>/<mailgun-key>/<sendgrid-key>npm tokens <npm-token>JWTs <jwt>Private keys (RSA / EC / OpenSSH / PGP) <private-key>Basic-auth URIs <basic-auth>Artifactory tokens <artifactory-token>IBM Cloud (IAM, COS, Cloudant) <ibm-cloud-iam-key>etc.OpenAI / Anthropic keys <openai-key>/<anthropic-key> -
Entropy-aware reveal — values whose Shannon entropy is below the threshold (default 3.0 bits/char) get the label
<low-entropy-secret>. Catches short, low-variety passwords likehunter2,password,letmein, andaaaaaaaa. Does not catch passphrases likecorrect horse battery stapleat the default threshold — that one has enough character diversity to land above 3.0 and falls through to prefix reveal. Bump--entropy-threshold 3.5if you want passphrases caught too; the tradeoff is more non-secrets get the<low-entropy-secret>label. -
Length-bounded prefix — for high-entropy values that didn't match any pattern, reveal the first N characters (default 3), capped at 25 % of the value length, with a minimum of 1:
POSTGRES_PASSWORD=wFw...
Surrounding quotes are stripped before any of this, so KEY="abcd" reveals chars from abcd, not "abcd".
drape is distributed as a uv tool — install once, run from anywhere:
uv tool install drape # core (.env + .env.sops)
uv tool install 'drape[yaml]' # + YAML support
uv tool install 'drape[toml]' # + TOML on Python <3.11
uv tool install 'drape[all]' # everythingTo run without installing:
uvx drape .env
uvx --from 'drape[all]' drape --format yaml config/secrets.yamlOr from source:
git clone https://github.com/pike00/drape.git
cd drape
uv tool install .Requires Python 3.9+ (uv manages this automatically). SOPS support requires the sops binary on $PATH.
drape .env # plain .env
drape config/.env.production # variants ok
drape infra/secrets.env.sops # SOPS-encrypted (decrypts in-process)
drape --format yaml config/secrets.yaml # YAML key-pattern masking
drape --format json config/secrets.json # JSON
drape --format toml pyproject.toml # TOML
drape --prefix-chars 5 .env # reveal up to 5 chars (still 25%-capped)
drape --no-patterns .env # disable type-label detection
drape --entropy-threshold 0 .env # restore "reveal everything" behaviorFormat auto-detects from filename for .env, .env.sops, .yaml, .yml, .json, .toml. Override with --format when needed.
drape ships a PreToolUse hook that intercepts file reads before they reach the model. Install once per project:
uv tool install drape
bash scripts/install-claude-hook.sh --project-dir /path/to/projectThe installer adds the right entries to .claude/settings.json automatically. After restarting Claude:
Claude asks: What's in .env?
Claude sees: POSTGRES_PASSWORD=wFw...
AWS_KEY=<aws-access-key>
GITHUB_PAT=<github-token>
The hook covers three tools, not just Read:
Readon a secrets file — replaced with the masked renderingGrepon a secrets file — re-greps against the masked rendering, so matches on a key still surface but the value stays hiddenBashcommands that try to read a secrets file directly (cat .env,head .env.sops,grep PASSWORD .env,rg KEY .env.sops,awk -F= '...' .env, …) — the hook returns a denial that redirects the agent todrape
For step-by-step setup: docs/SETUP.md. For publishing notes: docs/PUBLISHING.md.
from pathlib import Path
from drape import parse_env_file, mask_value, classify_secret
# Mask a whole file
for line in parse_env_file(Path(".env"), prefix_chars=4):
print(line)
# Mask a single value
mask_value("AKIAIOSFODNN7EXAMPLE") # -> "<aws-access-key>"
mask_value("hunter2") # -> "<low-entropy-secret>"
mask_value("R8aFq9wKjL2pXmZbT3vH") # -> "R8a..."
# Just classify (no masking)
classify_secret("ghp_1234567890abcdef...") # -> "<github-token>"
classify_secret("just a string") # -> NoneWhat drape protects against
- Secrets appearing in LLM conversation transcripts
- Provider logging / prompt-cache snapshots of full secrets
- Accidental copy-paste of credentials into chat
- Third-party access to session history (e.g., shared/exported transcripts)
What drape does not protect against
- Compromised local machine — if an attacker can read your
.envdirectly, drape doesn't help - Compromised LLM account — anyone with your account can request unmasked files
- File permissions — drape reads whatever your shell can read
- Multiline secrets in
.env— only single-lineKEY=VALUEparses correctly (use a structured format for multiline values)
Threat scenario prevented
- Attacker gains read-only access to Claude conversation transcripts
- Masked content shows:
AWS_KEY=<aws-access-key>(zero chars revealed) orRANDOM=a8f...(3 chars, ~10^15 brute-force space for a 22-char value) - Even with full pattern knowledge, the type label is not enough to authenticate
See docs/architecture.md for the detailed threat model and design rationale, and docs/ROADMAP.md for planned features.
All knobs are settable via CLI flags and/or environment variables. The hook reads only the env vars (CLI flags don't apply when Claude invokes drape).
| Setting | CLI flag | Env var | Default |
|---|---|---|---|
| Max prefix chars revealed | --prefix-chars N |
DRAPE_PREFIX_CHARS |
3 |
| Entropy threshold (bits/char) | --entropy-threshold F |
DRAPE_ENTROPY_THRESHOLD |
3.0 |
| Disable pattern type-labels | --no-patterns |
(n/a) | enabled |
| Format override | --format <fmt> |
(n/a) | auto |
| Extra hook glob patterns | (n/a) | DRAPE_HOOK_PATTERNS |
unset |
| Audit log path (JSONL) | (n/a) | DRAPE_AUDIT_LOG |
unset |
| Log level | (n/a) | DRAPE_LOG_LEVEL |
INFO |
The 25 % cap is unconditional: asking for 8 chars on a 12-char secret reveals only 3 (12 // 4 = 3); asking for 5 on a 4-char secret reveals only 1. Order of precedence:
empty → ""
matches a pattern → <credential-type>
entropy < threshold → <low-entropy-secret>
otherwise → first min(prefix, len // 4, ≥1) chars + "..."
Set DRAPE_AUDIT_LOG=/path/to/audit.jsonl to record every masking operation. Each line is a JSON object with ts, event, file, format, key_count, and prefix_chars. Values are never logged — only the fact that a file was masked, what shape it was, and how many keys it contained.
$ DRAPE_AUDIT_LOG=~/.drape-audit.jsonl drape .env > /dev/null
$ tail -1 ~/.drape-audit.jsonl
{"ts":"2026-05-06T19:30:00+00:00","event":"cli_mask","file":".env","format":"env","key_count":12,"prefix_chars":3}The audit writer never raises — if the log path is unwritable, masking still succeeds.
The hook intercepts these file shapes by default:
.env,*.env(e.g.,production.env,app.env).env.sops,*.env.sops— decrypted via thesopsbinary in-process, then masked
It explicitly skips:
.env.example,.env.sample,.env.template.env.json,.env.yaml,.env.yml,.env.toml(use--formaton the CLI for these)
Add extra glob patterns via DRAPE_HOOK_PATTERNS=*.secrets.yaml,credentials.json.
For YAML / JSON / TOML, drape walks the document and masks any leaf value whose leaf key contains a secret-looking substring (case-insensitive):
password passwd secret token api_key apikey
auth credential private_key access_key client_secret
session cookie salt signature
Output is always rendered as flat dotted.path=masked lines so the LLM sees one consistent shape across all formats:
$ drape --format yaml secrets.yaml
service.name=orders-api
service.port=8080
database.host=db.prod.internal
database.user=app_user
database.password=Y4n... # ← key contains "password" → masked, but
# prefix path is taken because the value
# itself didn't match a pattern
auth.jwt_signing_key=eyJhbGciOiJIUzI1NiJ9... # ← LEAKS: "jwt_signing_key" doesn't
# contain any of the keywords above
auth.session_secret=k8j...
aws.access_key_id=<aws-access-key> # ← key matches AND value matches pattern
aws.secret_access_key=wJa... # ← key matches; value falls through to prefix
integrations.github_token=<github-token>
integrations.stripe_api_key=<stripe-key>
features[0]=newCheckout
features[1]=asyncEmailThe walker checks only the last segment of the path, not parent segments. auth.jwt_signing_key leaks because jwt_signing_key itself doesn't match password|secret|token|key-with-a-suffix. Workarounds:
- Rename the key to something the walker catches:
auth.jwt_signing_tokenwould be masked. - Use the CLI's
--format envmode, which runs full pattern + entropy detection on every value and would have caught the JWT shape regardless of key name. - Add to the keyword list — open an issue if you have a common case that's slipping through.
just context # mandatory before previewing a release
just release-dry patch # show the next version and planned actions without writes
just context # mandatory before cutting a release
just release patch # patch | minor | majorThe managed project-kit release command:
- Preflights a clean tree on
mainand verifies the git-cliff configuration. - Updates
CHANGELOG.mdviagit-cliff(mechanical, commits → grouped sections percliff.toml). - Drafts the GitHub release body via LiteLLM (
deepseek-v4-pro-cloud) and opens it in$EDITOR. - Commits
CHANGELOG.md, tags the commit, pushes, and runsgh release create.
Pushing the v*.*.* tag triggers .github/workflows/release.yml, which builds the sdist and wheel and publishes to PyPI via OIDC trusted publishing (no API token in source). The repository also keeps its Codacy analysis workflow. The PyPI page typically updates within a minute.
Supporting commands:
just changelog # regenerate CHANGELOG.md from git history
just notes v0.3.3 # draft notes for an existing tag to stdout
just version # compare the latest local tag with the PyPI versionCHANGELOG.md is generated; do not hand-edit it.
git clone https://github.com/pike00/drape.git
cd drape
uv sync --group dev # install dev deps
uv run pytest # 64 tests, ~1s
uv run pytest --cov # with coverage report (target: ≥84%)
uv run ruff check src tests
uv run black --check src testsThe test suite covers: masker logic, all detect-secrets pattern integrations, entropy thresholds, structured-format walkers, SOPS dispatch (with the sops binary stubbed), the Claude hook (Read / Grep / Bash variants), audit log emission, and CLI argument parsing.
- Single-line
.envvalues — multiline values aren't parsed; use YAML / JSON / TOML for those - Structured formats use key-name heuristics — a key not matching the secret-keyword list is rendered in the clear, even if its value looks like a credential. (The CLI's
--format envmode still runs full pattern + entropy detection on every value.) - No on-demand unmasking — once masked, the model can't ask for the real value (planned for a future release with re-authentication)
See CONTRIBUTING.md for guidelines.
MIT — see LICENSE.
For security issues, email will@khanpikehome.com instead of opening a public issue.
- direnv — load
.envfiles in your shell - python-dotenv — load
.envin Python apps - SOPS — encrypt
.envand YAML / JSON files at rest - detect-secrets — the credential-detection engine drape uses for pattern matching
- git-secrets — pre-commit-hook approach to keeping secrets out of repos