shade sits between your client and the LLM: it replaces sensitive values in
outgoing prompts with stable placeholders, then puts the real values back into
the model's answer.
The provider sees password="<SECRET_1>". Your terminal sees
password="correct-horse-battery-staple". The value itself never leaves the
machine: it is stored encrypted in a local SQLite database scoped to your
project.
$ shade anon req.txt
password="<SECRET_1>"
card: <CARD_1>
phone: <PHONE_1>
$ shade anon req.txt | shade deanon
password="correct-horse-battery-staple"
card: 4242 4242 4242 4242
phone: +1 415 555 0132
- Why
- How it works
- Claude Code
- Proxy
- MCP
- Architecture
- Install
- Quick start
- Command reference
- Exit codes
- Configuration
- Rules
- Placeholders
- Security model
- Development
- Status
- License
Pasting logs, configs and stack traces into a chat window hands your secrets to someone else's infrastructure. Redacting by hand is lossy β you cannot get the value back, so the model's answer is useless for anything that has to be applied to the real system.
shade makes redaction reversible. The zero-information paths:
- Stable placeholders. The same value always maps to the same token, across
restarts and parallel processes, so a follow-up question in the same
conversation still refers to
<SECRET_1>. - Local by default. Values live in
$SHADE_HOME/shade.db, encrypted with a key that never leaves the machine. - Fail-closed or fail-open, your call. By default an answer that still contains an unresolvable placeholder is blocked rather than handed over with a hole in it.
One caveat on the Claude Code integration: the prompt you type is not anonymized β a hook cannot rewrite a prompt β so what is shaded there is what the tools bring back. See Claude Code.
flowchart TB
Client["Client / chat UI"] -->|prompt| Anon["shade anon"]
Anon -->|"prompt with placeholders"| Model["LLM"]
Model -->|"answer with placeholders"| Deanon["shade deanon"]
Deanon -->|"answer with real values"| Client
Anon <--> DB[("shade.db<br/>values encrypted")]
Deanon <--> DB
Anonymization is a linear pipeline over the text:
flowchart LR
In["text"] --> Guard["Guard<br/>hide placeholders<br/>already in the input"]
Guard --> Detect["Detect<br/>keyword prefilter, then<br/>regex β entropy β validator β allowlist"]
Detect --> Merge["Merge<br/>union of overlapping spans"]
Merge --> Clip["clip<br/>trim spans around<br/>hidden placeholders"]
Clip --> Alloc["Allocate<br/>value hash β placeholder<br/>AES-256-GCM value"]
Alloc --> Out["text with placeholders"]
Two details in that pipeline are worth knowing, because both exist to prevent data loss rather than to improve matching:
- Guard runs first. If the input already contains placeholders β a tool
output, the history of the dialogue β they are masked before the rules see the
text, so a repeated run cannot turn
<EMAIL_1>into<EMAIL_2>. Because the mask is not opaque to a regex, the spans that cover a masked region are clipped to its bounds instead of being dropped: a secret sitting next to a placeholder is still caught. - Merge takes the union, not the winner. When two rules overlap, the higher-priority rule decides the placeholder type; the bounds are the union of both. Discarding the loser's bytes would leave text open that the engine had already classified as sensitive.
Restore runs the opposite way, right to left, because each substitution changes the length of the text.
shade init installs the hooks and the skills, and from then on the session is
shaded without you doing anything. It writes the plugin into $SHADE_HOME/claude
and prints what it changed:
Every file appears as path: followed by the lines it adds (+) or removes
(-), so a file that does not exist yet reads as fully added.
$ shade init
/Users/you/.shade/claude/.claude-plugin/marketplace.json:
+{ ... }
/Users/you/.shade/claude/.claude-plugin/plugin.json:
+{ ... }
/Users/you/.shade/claude/hooks/hooks.json:
+{ "hooks": { ... one entry per event ... } }
/Users/you/.shade/claude/skills/shade-rules/SKILL.md:
+...
/Users/you/.shade/claude/skills/shade/SKILL.md:
+...
/Users/you/.claude/settings.json:
+{
+ "extraKnownMarketplaces": {
+ "shade": {
+ "source": {
+ "path": "/Users/you/.shade/claude",
+ "source": "directory"
+ }
+ }
+ }
+}
/Users/you/code/my-app/.claude/settings.json:
+{
+ "enabledPlugins": {
+ "shade@shade": true
+ }
+}
The marketplace declaration always goes in the user settings, because Claude
Code does not honour it in a project file; enabledPlugins goes to the project of
the git root, or to the user settings under --global. The command is idempotent β
a second run prints nothing at all β and --dry-run prints the same diff
without writing anything. --keep-old-hook leaves an existing PostToolUse hook
in place, which is worth doing only if you want two rewrites on one event; shade
removes it by default, since two of them are non-deterministic.
The same init also installs the two skills into that plugin: shade hands the
model the placeholder contract, and shade-rules explains how to write and test a
rule. They live in the same directory as the hooks, so a skill and a hook cannot go
out of step.
| Event | What shade does |
|---|---|
SessionStart |
Registers the project and hands the model the directive that explains the tokens |
UserPromptSubmit |
With prompt_gate = "on" β or "auto" when this session's traffic does not go through a live proxy β blocks a prompt whose content looks sensitive and names the types; silent otherwise |
PreToolUse |
Puts the real values back into the tool arguments, so the tool runs for real |
PostToolUse |
Replaces values in the tool output with placeholders, so the model reads tokens |
MessageDisplay |
Shows the real values on your screen; the transcript and the model keep the tokens |
Two limits are worth knowing before you rely on it:
- The prompt you type is not anonymized. A hook cannot rewrite the prompt, so
the gate can only block it β and only when you turn it on, or run
autowhile this session's traffic misses the proxy. A secret pasted straight into the prompt reaches the model unless the gate stops it. What is protected is what the tools bring back: files, command output, logs. - Tool arguments are restored optimistically. The model writes those arguments, so a token it mangled is still matched; the other direction β tool output, which the outside world writes β is only ever anonymized.
A hook cannot rewrite the prompt or the system prompt, so shade serve closes that gap:
it is a local HTTP proxy that anonymizes the whole request on the way out and puts the real
values back in the answer, frame by frame. Point Claude Code at it with the one line the
command prints:
$ shade serve
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
$ export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
$ claude
It listens on 127.0.0.1 only (default port 8787, --port N to change it, --port 0 to
let the OS choose) and serves the Anthropic Messages shape (/v1/messages,
/v1/messages/count_tokens) and the OpenAI Chat Completions shape
(/v1/chat/completions). One project per process: the project is resolved once, at
startup, from --project DIR or the working directory β start a second serve for a
second repository. While it runs, it publishes a liveness marker carrying the address it
serves on, and the UserPromptSubmit gate reads that marker together with your session's
own ANTHROPIC_BASE_URL: only when the variable names exactly the address this proxy
prints for this project does the gate stand down. Anything else β the variable unset
because it was exported in another terminal, pointed at a third-party router, or at
another port β leaves the gate blocking, which is the safe direction. So export the
printed line in every terminal you start an agent from.
upstream and api_key_env decide where the traffic goes and whose credential rides on
it. With api_key_env empty the client's own credentials are forwarded untouched, so a
subscription session keeps working. The default is not empty: when the named variable is
set in your environment, the proxy drops the client's Authorization header and sends its
own X-Api-Key instead β and it says so on stderr, naming the variable but not the key,
because a session on a subscription whose environment happens to carry that variable would
otherwise move onto another billing without a word. Set api_key_env = "" to keep the
client's own credentials.
stream_mode decides how a streamed answer is relayed. incremental β the default β
restores and sends each text delta as it arrives, which is what Claude Code expects.
buffered collects a whole text block and restores it at its stop, so an unresolved token
can refuse the answer before it reaches the client; it is for non-interactive clients and
is not the behaviour Claude Code expects. Either way, a stream the provider cuts short
β the connection closing before the terminator frame β is not left to end silently: the
proxy closes it with the same error frame a refusal uses, so the client sees a failure
rather than a truncated answer it would take for a finished one.
shade mcp serves the same engine to any MCP client over stdio:
$ shade mcp [--project DIR]
The client starts the process, so the project is resolved once, at startup: --project DIR, or the working directory of the process. A client that launches the server from
$HOME would otherwise send every call to a "home directory" project that no other
adapter can see.
| Tool | Input | Output |
|---|---|---|
anonymize |
{text} |
{text, spans:[{type, rule}]} |
deanonymize |
{text} |
{text, unresolved:[{type, raw}]} |
scan |
{text, rule?} |
{spans:[{type, rule}]} |
rules_list |
{} |
{rules:[{name, type, enabled, builtin, scope}]} |
rules_test |
{pattern, sample, kind?, type?} |
{spans:[{type, rule}]} |
unresolved_report |
{since?, limit?} |
{entries:[{time, adapter, type, action, detail}]} |
The resource shade://directive carries the placeholder contract, so the model reads it
rather than receiving it in a prompt.
Tokens leave; values do not. Every answer carries placeholders and types β never the
value behind a token, and never the fragment or offset a rule matched. deanonymize is
the one tool that returns real values: it restores whatever text it is handed. Under
fail_closed a text with an unresolved placeholder is refused β isError with an empty
text, the MCP spelling of exit code 3 β rather than handed over with a hole in it.
Diagnostics go to stderr; stdout carries the protocol and nothing else.
flowchart TB
CLI["cmd/shade<br/>anon Β· deanon Β· hook Β· init Β· mcp Β· rules Β· entities Β· audit Β· test Β· doctor Β· version"]
CLI --> Core["internal/core<br/>Engine: Anonymize Β· Restore Β· Scan"]
CLI --> Hook["internal/hook<br/>Claude Code events β responses"]
CLI --> MCP["internal/mcp<br/>six tools Β· shade://directive"]
CLI --> Set["internal/settings<br/>settings.json Β· plugin files"]
Hook --> Core
Hook --> Dir["internal/directive<br/>the model directive"]
MCP --> Core
MCP --> Dir
Core --> Rules["internal/rules<br/>compile Β· detect Β· merge Β· validators"]
Core --> PH["internal/placeholder<br/>format Β· find Β· guard"]
Core --> Store["internal/store<br/>SQLite, projects, entities, rules"]
Core --> Cfg["internal/config<br/>layered TOML"]
Store --> Crypt["internal/crypt<br/>AES-256-GCM Β· HMAC-SHA256"]
| Package | Responsibility |
|---|---|
cmd/shade |
CLI: argument parsing, command registry, exit codes, output shapes |
internal/core |
Engine β glues rules, placeholders and the store into Anonymize/Restore |
internal/hook |
The Claude Code adapter: event payloads, per-event responses |
internal/jsonwalk |
Shared traversal that rewrites a JSON document's strings, keeping its shape |
internal/mcp |
The MCP adapter: the directive resource, and the tools the model calls |
internal/settings |
Claude Code settings.json: load, merge, diff, and the plugin files |
internal/directive |
The one text that tells the model how to treat the tokens |
internal/rules |
Compiles rule specs, finds matches, merges spans, runs validators |
internal/placeholder |
The <TYPE_N> format, tolerant token search, the guard |
internal/store |
SQLite schema, migrations, projects, entities, rules, stats, audit |
internal/config |
Three-layer config: defaults β global file β project file |
internal/crypt |
Key file, AES-256-GCM for values, keyed HMAC for lookups |
core.Engine takes the project directory as an argument rather than calling
os.Getwd internally, so the same engine can serve a CLI run (shade anon in
the current directory) and a hook or MCP adapter that passes the cwd from its
own payload.
Requires Go 1.27.1 or newer.
make build # CGO_ENABLED=0 go build -o shade ./cmd/shade
./shade version
The build is pure Go end to end β SQLite comes from modernc.org/sqlite, so
CGO_ENABLED=0 produces a static binary.
There are no releases or prebuilt packages yet; build from source.
State lives in $SHADE_HOME, defaulting to ~/.shade. It is created on first
run; nothing has to be initialized by hand.
$ shade doctor
directory: /Users/you/.shade β available
key: readable
database: /Users/you/.shade/shade.db
project: /Users/you/code/my-app
rules: 39 active
doctor never creates anything β it reports what it finds, so "configured" stays
distinguishable from "just configured".
Anonymize a prompt, then restore the answer:
$ shade anon req.txt
password="<SECRET_1>"
card: <CARD_1>
phone: <PHONE_1>
Piping the result back restores the real values:
$ shade anon req.txt | shade deanon
password="correct-horse-battery-staple"
card: 4242 4242 4242 4242
phone: +1 415 555 0132
Re-running anon on the same text yields the same placeholders, so a
conversation stays consistent. See what the rules catch and where:
$ shade test --sample 'AKIAIOSFODNN7EXAMPLE'
offset type rule fragment
4-20 TOKEN prefix "IOSFODNN7EXAMPLE"
--json gives a machine-readable shape β {"text": ..., "spans": [{"type": ..., "rule": ...}]}
for anon, {"text": ..., "unresolved": [...]} for deanon. anon, deanon,
test and rules import read a file argument or stdin and accept --project DIR;
hook reads its event from stdin, mcp speaks the protocol on stdio, and init
takes neither.
| Command | Purpose |
|---|---|
shade anon [--json] [--project DIR] [FILE] |
Replace secrets with placeholders |
shade deanon [--json] [--project DIR] [FILE] |
Restore real values |
shade hook |
Answer a Claude Code hook event read from stdin (always exits 0 for a hook event) |
shade init [--global] [--dry-run] [--keep-old-hook] |
Install the Claude Code hooks and skills: generate the plugin and wire it into the settings |
shade mcp [--project DIR] |
Serve the MCP protocol on stdio for an MCP client |
shade serve [--project DIR] [--port N] |
Run the anonymizing HTTP proxy for a coding agent on the loopback interface (default port 8787). It serves the Anthropic Messages shape (/v1/messages, /v1/messages/count_tokens) and the OpenAI Chat Completions shape (/v1/chat/completions), anonymizing each request and restoring each answer |
shade doctor |
Report the state of the environment without modifying it |
shade test [--rules NAME] [--sample TEXT] [FILE] |
Run the active rule set against a sample, no writes |
shade rules list |
List rules of the scope |
shade rules add --name N --type T --pattern P [--kind K] [--secret-group N] [--global] |
Add a rule |
shade rules rm --name N [--global] |
Delete a rule (builtin rules cannot be deleted) |
shade rules enable|disable --name N [--global] |
Toggle a rule |
shade rules import [--global] FILE |
Import a gitleaks TOML |
shade rules export |
Print non-builtin active rules as TOML |
shade rules test --pattern P [--kind K] [--type T] [--sample S] [FILE] |
Check a rule before saving it |
shade entities list |
List entities of the project (placeholders, types, hit counts) |
shade entities prune [--older-than 30d] |
Delete entities not seen for the given age |
shade audit [--since 7d] [--unresolved] |
Journal of unresolved placeholders |
shade version |
Print the build version |
Rules, entities and the journal are scoped to the project: --global edits the
rule set shared by every project, while the default scope is the project of the
current directory.
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Operational error: unreadable file, unusable environment, a rule that fails to compile against its own pattern |
2 |
Usage error: unknown command or flag, missing required flag, malformed argument |
3 |
deanon refused to hand over the answer: it still contains unresolvable placeholders, and fail_policy is not fail_open_log |
Code 3 is the interesting one. A placeholder with no entity in the store means
the response has a hole where a value should be β the text is blocked, the tokens
are listed on stderr, and under --json an empty text expresses the block.
Under fail_open_log the same situation produces the partial text, a warning on
stderr and exit 0.
shade hook is the exception to the table: answering a hook event always exits
0. A non-zero exit at Claude Code means "block" or "message the model", and a
hook that failed must do neither β a runtime failure comes back as a
systemMessage on stdout, and an unreadable payload is silence. Its own usage
errors are not an exception: shade hook with a stray argument still exits 2.
shade mcp is the other exception: it has no code 3. A deanonymize refusal
is an isError tool result, not an exit code β the server stays up and answers
the next call.
shade serve is the third exception: it has no code 3 either. It streams an
answer or sanitizes an error, and under fail_closed a restore it cannot complete
becomes a 502 the client can retry, never a process exit.
An auxiliary write failure never changes the code: if the hit counter or the journal cannot be written, the result is still delivered and the failure is reported on stderr. A ready prompt or answer is worth more than a statistics row.
Settings are read from three layers, each overriding the previous one:
- built-in defaults,
$SHADE_HOME/config.tomlβ global,<project root>/.shade.tomlβ per project.
A missing file is a skipped layer, not an error. The project root is the top of the git repository, which is also the boundary of a project: placeholders issued in one repository are invisible to another. Unknown keys are ignored, so a config written for a future version will not break today's binary.
| Key | Default | Effect |
|---|---|---|
fail_policy |
fail_closed |
fail_closed blocks an answer with unresolved placeholders (exit 3); fail_open_log lets it through with a warning |
entities_ttl |
90d |
Default age for shade entities prune |
prompt_gate |
off |
on makes the UserPromptSubmit hook block a prompt whose content looks sensitive, naming the types. auto blocks the same way unless a live shade serve covers this project and this session points at the address it printed, so a prompt whose traffic goes around the proxy is stopped |
stream_mode |
incremental |
How shade serve relays a streamed answer. incremental restores and sends each text delta as it arrives; buffered collects a block's text and restores it whole at the block's stop, so an unresolved token can refuse the answer before it reaches the client. buffered is not the behaviour Claude Code expects β it is for non-interactive clients |
upstream |
https://api.anthropic.com |
The real endpoint shade serve forwards anonymized traffic to |
api_key_env |
ANTHROPIC_API_KEY |
The environment variable whose value shade serve substitutes for the client's key; an empty value forwards the client's own credentials untouched |
fail_policy = "fail_open_log"
entities_ttl = "30d"
prompt_gate = "on"stream_mode matters only for shade serve. incremental streams the answer as it
arrives β what Claude Code expects β but a placeholder of an unknown type is only seen
once its bytes are already on the way, so the answer cannot be refused at that point.
buffered waits for a whole text block before restoring it, which makes a fail_closed
refusal possible; it is aimed at non-interactive clients and is not the behaviour Claude
Code expects.
SHADE_HOME overrides the state directory (~/.shade).
A rule has a type (which placeholder it produces), a kind, and a pattern:
kind |
Pattern | Notes |
|---|---|---|
regex |
RE2 regular expression | secret_group selects the capture group to replace; the rest of the match stays visible for context |
literal |
Fixed string | Quoted internally, no escaping needed in the config |
entropy |
β | Flags a value by Shannon entropy alone |
Above the pattern sits a chain of filters, all of which must pass:
- keywords β a cheap prefilter. A rule with keywords is skipped entirely when none of them appears in the text.
- entropy β
entropy_minrejects low-entropy matches, so a threshold can be layered on top of any regex. - validator β a named guard for values that are simply not what they look like. Unknown names fail closed: a typo disables a rule instead of letting it match unchecked.
| Validator | Accepts |
|---|---|
luhn |
Card numbers with a valid checksum, 13β19 digits |
ip_global / ipv6_global |
Public addresses only; private and 4-in-6 ranges are rejected |
phone |
8β15 digits, E.164 without the country code |
random_enough |
Long, or contains a digit β separates a real secret from token: string |
Built-in rules ship embedded in the binary and are re-seeded on every start without touching rules you have edited.
| Group | Rules | Active by default |
|---|---|---|
keys β key material (SSH, WireGuard, Putty, k8s, cloud keys) |
18 | 18 |
credentials β headers, CLI flags, netrc, assignments, vendor tokens |
18 | 17 |
pii β personal and network identifiers |
10 | 2 |
infra β the login and host of an ssh/scp/sftp command line |
2 | 2 |
48 rules in total, 39 of them active out of the box.
PII rules are intentionally conservative: only phone and card are on out of
the box. The remaining eight (email, ssn, iban, mac_addr, home_path,
public_ip, public_ip6, phone_loose) are shipped disabled, since a rule that
fires on ordinary text is worse than no rule. The same reasoning disables the
entropy rule in the credentials group. Enable what you need per scope:
$ shade rules enable --name email --global
Built-in rules can be disabled but never deleted, and shade rules export
deliberately omits them β they restore themselves on every machine and would be
dead weight in the file.
gitleaks import reads another tool's TOML and reports what it could not translate instead of failing the whole run:
$ shade rules import gitleaks.toml
imported: 1, skipped: 2
skipped "lookahead": pattern "token(?=\s*=)": error parsing regexp: invalid or unsupported Perl syntax: `(?=`
skipped "unknown-type": unknown type "NOPE"
Lookaround, paths, regexTarget, stopwords and condition have no equivalent
here, so such rules are narrowed to the match or skipped with a reason. Import is
one-way: export writes shade's own format, since there is no reverse mapping
for validators, kind and ordering.
The canonical form is <TYPE_N>, with N a per-project, per-type counter.
SECRET TOKEN KEY PERSON EMAIL PHONE CARD IBAN SSN MAC
IP HOST PATH USER DB URL TICKET ORG ADDR DSN
The type list is closed on purpose. A regex cannot tell <EMAIL_1> from
<div_1> without lookaround, and RE2 has none β so the set of types defines what
counts as a placeholder, and ordinary markup is left alone.
Recognition is tolerant of how models mangle tokens in their output: HTML-escaped
brackets (<), fullwidth brackets (οΌ), spaces or a newline inside the
token, a lowercase type, and truncation at the token limit where the closing
bracket is missing and the token ends at a word boundary. A token glued to a
letter or digit (<KEY_1ΡΡΡ) is rejected rather than guessed at β treating it as
a token would substitute a value where none was requested.
- Key. 32 random bytes in
$SHADE_HOME/key, mode0600. It is published atomically via a hard link and never regenerated: overwriting it would make every stored value unreadable. - Values at rest. AES-256-GCM with a fresh random nonce per record. The key is also the GCM key, so the database alone is not enough to read values.
- Lookup without decryption. A keyed HMAC-SHA256 over
type β 0x00 β valuegives a deterministic index, which is what makes placeholders stable without ever decrypting existing rows. The type is part of the hash, so one value used in two roles gets two placeholders. - The journal never stores values. It records tokens and types only. A value
cannot appear in the audit table, the exports or the listings β
entities listhas no value field at all, so a leak there is impossible by type rather than by discipline. - Nothing sensitive goes to stderr or stdout unredacted. Only
anon's output reaches stdout, and its secrets are already placeholders by then.
Retention is per project and explicit: shade entities prune drops entities by
last-seen age. A pruned value is unrecoverable, since the plaintext exists only
in value_enc.
make test # go test ./...
make lint # go vet ./... plus a gofmt check
make build
Tests live next to the code they cover and are mostly end-to-end: the CLI suite
drives real commands against a temporary SHADE_HOME, and the rule tests run
against a corpus rather than single strings.
Dependencies are deliberately few β github.com/BurntSushi/toml for config and
rules, modernc.org/sqlite for a CGO-free SQLite driver. Everything else comes
from the standard library.
Phases 1β4 are complete β the core and the CLI, the Claude Code hook adapter, the MCP adapter and the skills, then the HTTP proxy that wraps Claude Code end to end. The rest is ahead of us; the code is already sliced for it.
- Core engine β
Anonymize,Restore,Scan - Rule engine β keyword prefilter, entropy threshold, validators, span merging
- Encrypted store β projects, entities, audit journal, SQLite migrations
- Builtin rule set β 48 rules across
keys,credentials,pii,infra(39 active by default) - gitleaks rule import with a per-rule skip report
- CLI β
anon,deanon,hook,init,mcp,rules,entities,audit,test,doctor,version - Claude Code hooks β
SessionStart,UserPromptSubmit,PreToolUse,PostToolUseandMessageDisplay, installed byshade init, which recordshookas the adapter in the journal - Skills β
shadeandshade-rules, installed into the plugin byshade init - MCP adapter β
shade mcpserves six tools and theshade://directiveresource over stdio - MCP
scantool β reports the types and the rules that found them, never the fragment or the offset - Proxy adapter β
shade servewraps Claude Code throughANTHROPIC_BASE_URL, anonymizing the whole request and restoring the streamed answer, with theprompt_gate = "auto"liveness marker - Rule packages β the
packagestable exists; nothing writes to it yet - Usage UI β
rule_hitsis written on every anonymization; nothing reads it yet - Streaming mode and LLM provider integration β
stream_mode,upstreamandapi_key_envare consumed byshade serve; it serves both the Anthropic Messages shape and the OpenAI Chat Completions shape (streamed and non-streamed)
MIT β see LICENSE.
