A local barrier between your codebase and the model. Send the logic, withhold the identity.
Install & run
$ npx blind blind → https://api.anthropic.com listening on http://localhost:8787
No install, no account, no backend. It is a proxy on your own machine.
Connect your agent
$ export ANTHROPIC_BASE_URL=http://localhost:8787
Then run your agent normally. Every request passes through the barrier; the reply is restored before you see it.
Preview a file
$ blind inspect src/billing.py
Prints the exact text that would be sent, plus a count of substitutions by rule. Use it before you trust the proxy with anything real.
If the file matches a deny rule, it says so instead of showing a redacted version, because a denied file is never sent in any form.
Session summary
Stopping the proxy with Ctrl-C prints what that session actually protected: values kept, requests refused, values sent with a warning, and the busiest rules. A proxy that runs all day and then exits silently teaches you nothing.
Where the line is
If the model never sees your code, it cannot fix your code. "Leak nothing" is the wrong goal - it produces an assistant that is useless. The workable line is different:
- Logic goes out. Control flow, algorithms, structure, standard library calls - this is what the model needs to reason, and it is rarely the secret.
- Identity stays home. Company and product names, client names, credentials, hostnames, database names.
A leaked trading algorithm is not dangerous because of its loop structure. It is dangerous because it reveals that this firm does this, this way, for this client. Those are separable, and this separates them.
Type-preserving shapes
Placeholders keep the shape of what they replace.
| Real | Sent |
|---|---|
AKIA3NFXDW2K8QZLPT9R | AKIABLINDPLACE0001 |
postgres://u:pw@db.acme.io:5432/ledger | postgres://user3:password3@db3.internal:5432/database3 |
AcmeCapital | Company4 |
The alternative - opaque tokens - is what breaks these tools. Substituting Mail.ReadWrite with domain-external-042.net leaves the model unable to tell what API it is even looking at.
The round trip
Substitution is deterministic within a session: the same value always maps to the same stand-in, so the model sees a consistent world. On the way back, placeholders are restored longest-first so a short one cannot clobber a longer one.
This is covered by tests asserting that scrub-then-restore returns byte-identical source. Anything less would corrupt patches.
blind run - scrub before it reaches anything
$ blind run -- env $ blind run -- npm run deploy $ blind run -- cat .env
Redaction after the fact is late. This wraps the command itself, so values are replaced before they reach your terminal, your scrollback, your CI log, or anything else that captures stdout.
Output is buffered by line, because a secret split across two chunks would otherwise slip through the gap. The exit code passes through unchanged, so it drops into a script without breaking anything, and stderr is scrubbed alongside stdout.
blind clean - the ones already in your logs
$ blind clean
blind clean 32 transcript(s) still hold secrets
Email address 379
DB connection string 26
Slack token 4
Private key block 2
AWS access key 2
Nothing was modified. Re-run with --write to replace these with stand-ins.
blind stops the next leak. This deals with the ones already sitting in
~/.claude/projects from before it was installed.
It reports and changes nothing by default. With --write it replaces each
secret with a stand-in, keeps the original alongside as .bak, and rewrites line by
line so a malformed entry cannot corrupt the file. Any line that would stop being valid JSON is
left exactly as it was.
blind check - CI and pre-commit
$ blind check
blind check 3 finding(s)
src/config.py:12 AWS access key AKIA************9R
src/db.py:4 DB connection string post************er
tests/seed.py:22 Email address ops@************io
Values are masked. Exit code 1 so CI can gate on this.
One scan of the working tree with the same detectors the proxy uses. No server, no agent.
Exit code is 1 when anything is found and 0 when nothing is, so it
drops straight into a pre-commit hook or a build step. Add --json for machine output.
Overlapping matches
A connection string contains something email-shaped and something host-shaped. Reporting all three would make one leak look like three and bury the real finding, so a match sitting wholly inside a longer one is dropped. On a real file this took the count from 14 to 9, all genuine.
blind guard - protect Claude Code itself
$ blind guard
The proxy protects what leaves over the network. This protects something the proxy cannot reach: the transcript on your disk.
It writes two hooks into ~/.claude/settings.json (or the project, with
--project):
| Hook | Effect |
|---|---|
PreToolUse | Refuses to open anything matching a deny rule, before the tool runs at all |
PostToolUse | Rewrites tool output before it enters context, so a key printed by cat .env never reaches the session log |
Connected tools are covered
The output matcher includes mcp__*, so data pulled from Slack, GitHub, Linear or a
warehouse through an MCP server is scrubbed the same way a file read is. That content never
crosses the network boundary a proxy watches, so nothing else would catch it.
This closes the loop with spilled: instead of
finding credentials already sitting in ~/.claude/projects/**.jsonl, they never get
written there.
Why write tools are excluded
Only read-shaped tools are scrubbed: Bash, Grep, Glob, Read, WebFetch, WebSearch. Edit and
Write are deliberately left alone. If a placeholder were substituted into the output of an edit,
Claude could echo that placeholder back into a real file on the next turn, and you would have
AKIABLINDPLACE0001 committed where a real key used to be. The hook also attaches a
note telling Claude these are stand-ins and must never be written back.
It fails open, always
Malformed input, a bad config, an unreadable file: every error path exits cleanly and the original output stands. A privacy tool that breaks your editor gets uninstalled the same day, and then it protects nothing at all.
$ blind unguard # removes the hooks again
guard. Hooks bind when the
session starts.blind init
Rather than typing out everything that identifies you, let it read the project:
$ blind init crypto-priest git org data-migration-slm git repo Migration README title FoxPro appears 5x in source Wrote .blindrc.json with 4 term(s).
It reads the package name and author, the git remote org and repo, the README title, and capitalised identifiers that recur across your source.
Names that appear on an import line are dropped. If a symbol was imported it belongs to a library, not to you. That single rule removes almost all of the noise, and it generalises better than a blocklist of framework class names ever could.
blind status
$ blind status
blind status 412 requests | 2026-08-30 -> 2026-09-07
1,284 value(s) kept on this machine
3 request(s) refused outright by a deny rule
By rule
Project term ▪▪▪▪▪▪▪▪▪▪▪▪▪▪ 611
DB connection string ▪▪▪▪▪▪ 240
Email address ▪▪▪▪ 173
Reads ~/.blind/audit.jsonl. Counts and rule names only, never values.
Watching the reply
Masking can fail quietly. A pattern misses, or the model infers a name from context and says it back to you. Either way the value is in the reply and nothing downstream would notice.
blind scans every response for the real values it substituted on the way out. If one appears, it is reported in the log and counted in the session summary. It does not block the reply, since you already have the value; it tells you that a substitution did not hold.
Choose what to protect, and how
Four categories. Each gets one of four strategies.
| Strategy | What happens |
|---|---|
mask | Swapped for a stand-in of the same shape. The default. |
deny | The whole request is refused with a 403 and never sent. |
warn | Sent as-is, but recorded in the audit log so you can see it happened. |
off | Ignored entirely. |
{
"protect": {
"credential": "deny",
"identity": "mask",
"pii": "mask",
"custom": "warn"
}
}
| Category | Covers |
|---|---|
credential | API keys, tokens, private keys, JWTs, connection strings |
identity | Your company, product, client and codebase names |
pii | Email addresses, IP addresses, internal hostnames |
custom | Patterns you add yourself |
Profiles
$ blind --profile=strict
| Profile | Behaviour |
|---|---|
strict | Credentials refused outright, everything else masked |
balanced | Everything masked. The default and the recommendation |
permissive | Credentials and names masked, personal data only logged |
blind init asks which profile to start from, then offers to adjust each category
individually. Anything in .blindrc.json overrides the profile.
blind doctor
$ blind doctor ! ANTHROPIC_BASE_URL is not pointed at blind, so nothing is being filtered ok 3 project term(s) configured ok policy: credential=mask identity=mask pii=mask custom=mask ok 2 deny rule(s) ? proxy is not running on :8787
Catches the failure that matters most: a proxy that is running perfectly while your agent talks straight past it to the API. It also flags a policy with every category switched off, since that is protection in name only.
Your vocabulary
.blindrc.json in your project root:
{
"terms": ["AcmeCapital", "ProjectPhoenix", "Reliance"]
}
Matched case-insensitively on word boundaries, longest first. Add company names, product codenames, client names, internal service names.
Encoded secrets
A secret does not always travel in the clear. A connection string url encoded into a callback, or a key base64 encoded into a header, reads as ordinary text to a pattern match.
blind decodes candidate spans, reruns the rules against the decoded form, and then substitutes the encoded span, so what actually leaves the machine is replaced rather than a decoded copy that was never there.
| Shape | Caught |
|---|---|
| Nested inside a JSON object | yes |
| Inside a stringified JSON blob | yes |
| With escaped quotes | yes |
| URL encoded | yes |
| Base64 encoded | yes |
| Split across two separate fields | no |
The last row is deliberate. Reassembling fragments across fields would need guesswork that produces far more false alarms than real findings, and half a key is not a working credential.
Base64 decoding only applies to short and medium runs that decode to printable text. Encoded files are stripped before any of this, so an embedded image cannot turn into findings.
Deny rules
Masking is the normal path. Some things should not leave at all, even disguised:
{ "deny": ["PROPRIETARY MODEL WEIGHTS", "BEGIN OPENSSH PRIVATE KEY"] }
A matching request is rejected at the proxy with a 403 and never reaches the
network. Your agent sees a clear error saying blind refused it locally. Use this for the
handful of things where a stand-in is not good enough.
Personal and infrastructure data
On by default. Covers email addresses, IP addresses, internal hostnames
(.internal, .corp, .local) and bearer tokens, on top of
every credential format. Turn it off with --no-pii or "pii": false.
Custom rules
For the shapes only your team has:
{
"rules": [
{ "name": "Internal ticket", "pattern": "ACME-[0-9]{4,}" },
{ "name": "Customer code", "pattern": "CUST_[A-Z]{3}[0-9]{5}" }
]
}
A pattern that fails to compile is skipped rather than crashing the proxy.
Providers
$ blind --provider=openai $ blind --provider=gemini --port=9000
| Name | Upstream |
|---|---|
anthropic | api.anthropic.com (default) |
openai | api.openai.com |
gemini | generativelanguage.googleapis.com |
openrouter | openrouter.ai |
Or set BLIND_UPSTREAM to anything, including a plain http gateway you run yourself.
Environment
| Variable | Default | Purpose |
|---|---|---|
BLIND_PROVIDER | anthropic | Upstream preset |
BLIND_PORT | 8787 | Listening port |
BLIND_UPSTREAM | https://api.anthropic.com | Upstream API; http allowed for local gateways |
Audit log
Every request appends one line to ~/.blind/audit.jsonl:
{"at":"2026-09-06T18:22:04.113Z","method":"POST","path":"/v1/messages",
"bytesIn":805,"bytesOut":777,"substitutions":6,
"byRule":{"AWS access key":1,"DB connection string":1,"Project term":3}}
Counts and rule names only - never values. The log tells you what was protected without becoming a second copy of your secrets.
What gets masked
- AWS access keys, GitHub tokens, OpenAI and Anthropic keys, Slack tokens, Stripe live keys, Google API keys, npm tokens
- PEM private key blocks and JWTs
- Database connection strings, including the database name
- Assigned secrets -
password = "...",api_key: "..." - Every term in your
.blindrc.json
Obvious placeholders (your-api-key-here, changeme) are ignored, and base64-encoded binaries are stripped before analysis so an embedded image cannot shatter into phantom matches.
Streaming
Server-sent events are handled: a trailing buffer is carried between chunks so a placeholder split across a chunk boundary is still mapped back correctly.
Limits, stated plainly
- It is not a guarantee. A novel secret format or a prompt injection can defeat any redactor. The promise is visibility, not an absolute.
- Quality can shift. Anonymised identifiers occasionally make an answer less specific. Measure it on your own work early.
- Proprietary logic still goes out. Identity is stripped; algorithms are not. If an algorithm itself is the secret, do not send it at all.
FAQ
Will my patches still apply?
Yes. Restoration is byte-exact and covered by tests. The reply references your real names.
Does it work with agents other than Claude Code?
Anything that honours a base-URL override and speaks the Messages API shape. Non-JSON bodies are forwarded untouched.
Does it slow things down?
Substitution is linear over the request body and runs locally. The upstream call dominates.
What has already leaked, before I installed this?
Run spilled. It reads your existing transcripts and reports what went out and what is still live.