blind / documentation

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.

Your API key is forwarded untouched and is never stored, logged or inspected.

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:

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.

RealSent
AKIA3NFXDW2K8QZLPT9RAKIABLINDPLACE0001
postgres://u:pw@db.acme.io:5432/ledgerpostgres://user3:password3@db3.internal:5432/database3
AcmeCapitalCompany4

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.

Rewriting a transcript does not un-send it. Anything in there already went to an API. Rotate first, then clean up.

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.

Values are always masked in the output. A CI log is frequently public, and a scanner that prints the secret it found has moved the leak rather than stopped it.

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):

HookEffect
PreToolUseRefuses to open anything matching a deny rule, before the tool runs at all
PostToolUseRewrites 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
Restart Claude Code after running 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.

Always read the generated file. It is a starting point, not an authority, and it will occasionally suggest a word that is not actually yours.

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.

StrategyWhat happens
maskSwapped for a stand-in of the same shape. The default.
denyThe whole request is refused with a 403 and never sent.
warnSent as-is, but recorded in the audit log so you can see it happened.
offIgnored entirely.
{
  "protect": {
    "credential": "deny",
    "identity":   "mask",
    "pii":        "mask",
    "custom":     "warn"
  }
}
CategoryCovers
credentialAPI keys, tokens, private keys, JWTs, connection strings
identityYour company, product, client and codebase names
piiEmail addresses, IP addresses, internal hostnames
customPatterns you add yourself
An unrecognised strategy falls back to mask, never to sending. A typo in your config should fail safe.

Profiles

$ blind --profile=strict
ProfileBehaviour
strictCredentials refused outright, everything else masked
balancedEverything masked. The default and the recommendation
permissiveCredentials 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.

ShapeCaught
Nested inside a JSON objectyes
Inside a stringified JSON blobyes
With escaped quotesyes
URL encodedyes
Base64 encodedyes
Split across two separate fieldsno

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
NameUpstream
anthropicapi.anthropic.com (default)
openaiapi.openai.com
geminigenerativelanguage.googleapis.com
openrouteropenrouter.ai

Or set BLIND_UPSTREAM to anything, including a plain http gateway you run yourself.

Environment

VariableDefaultPurpose
BLIND_PROVIDERanthropicUpstream preset
BLIND_PORT8787Listening port
BLIND_UPSTREAMhttps://api.anthropic.comUpstream 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

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

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.