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
- Web UI
- 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.
shade ui serves a page for the rules, so a rule can be written and tried in a browser
instead of through the CLI. It prints the address and waits; it does not open the browser:
$ shade ui
http://127.0.0.1:8788
It listens on 127.0.0.1 only (default port 8788, --port N to change it, --port 0 to let
the OS choose — the printed address always names the port actually taken). Like serve, it
resolves the project once, at startup, from --project DIR or the working directory. It is not
a proxy, and shade serve serves no page. The page is built into the binary by make build;
see Install.
The page is one screen, Rules:
- List. The global rules and the rules of the project, the builtin and the disabled ones included, each with its type, scope, kind and pattern. Any rule can be switched on and off; a rule that is not builtin can be deleted, after a second click that confirms it.
- New rule. The fields of
shade rules add: name, type, kind (regexorliteral), pattern, secret group and scope — this project by default, or global. A pattern that does not compile is refused with the engine's own message, and so is a name already taken in the scope, whereshade rules addwould rewrite the rule (unless it is builtin). - Live test. Paste a text and the matches are highlighted, each labelled with the rule that
found it. While the form holds a pattern, the page tries that pattern alone, unsaved, and the
label is
adhoc; with the form empty it tries all the active rules, asshade testdoes. A test stores nothing.
Rules live in the same store as shade rules: a rule created on the page shows in shade rules list and applies in the hook and the proxy from their next call; one added from the CLI shows
on the page after a reload.
Protection. The page serves no value from entities — only rule definitions, and the result
of a test over the text the browser itself sent — which is why this slice has no token. What it
has is a set of checks that keep the pages of other sites away from the port, since a browser on
the same machine reaches loopback too:
Hostmust be127.0.0.1:<port>orlocalhost:<port>, with the port the server bound; aHostthat parses but is none of these is a403. That stops DNS rebinding, where a page of another site arrives under its own name. Only on port 80, where browsers leave the port out, do the bare127.0.0.1andlocalhostpass too. It also means an SSH tunnel must keep the server's port as its local one (ssh -L 8788:127.0.0.1:8788 host); with another, every request is a403.- A request that changes something — any method but
GET,HEADandOPTIONS— is a403if it carries anOriginother than the page's own (nullincluded), or aSec-Fetch-Siteother thansame-originornone. - A
POSTorPATCHmust beapplication/json(415otherwise), and a body over 1 MiB is a413on the calls that read one: create, toggle and test. - No
Access-Control-*header is ever sent, so another site cannot read an answer. Every answer to a well-formed request for a path, a refusal and the page included, carriesX-Content-Type-Options: nosniff,Referrer-Policy: no-referrerandContent-Security-Policy: default-src 'self'; frame-ancestors 'none'; answers of the API addCache-Control: no-store. - Requests are not logged, bodies are kept nowhere and the journal is not written.
Residual risk. The checks are aimed at browsers. A local process can send any Host and
Origin it likes, so any process on the machine that can reach the port — yours, or another
user's on a shared machine — can read the rules, create new ones, switch any of them off and
delete those that are not builtin. Patterns are in clear text, and the pattern of a literal
rule may be the secret itself; switching rules off weakens what the hook and the proxy
anonymize. A token becomes mandatory before the page can show a stored value (reveal, planned
for the Entities screen).
flowchart TB
CLI["cmd/shade<br/>anon · deanon · hook · init · mcp · serve · rules · entities · audit · test · doctor · ui · 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 --> Proxy["internal/proxy<br/>HTTP proxy · streamed restore"]
CLI --> Set["internal/settings<br/>settings.json · plugin files"]
CLI --> API["internal/api<br/>rules REST · live test · loopback guard"]
CLI --> Web["web<br/>the built page, embedded"]
Hook --> Core
Hook --> Dir["internal/directive<br/>the model directive"]
MCP --> Core
MCP --> Dir
Proxy --> Core
Proxy --> Dir
API --> Core
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"]
API --> Rules
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/proxy |
The proxy adapter behind shade serve: anonymizes the request, restores the answer |
internal/api |
The HTTP handler of shade ui: the rules endpoints, the live test, the loopback guard, the page |
web |
The Svelte page of shade ui (sources in web/src) and embed.go, which embeds its build from dist/ |
internal/settings |
Claude Code settings.json: load, merge, diff, and the plugin files |
internal/skills |
The Claude Code skills shade init installs: the placeholder contract and the guide to writing rules |
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, and bun 1.3.11 to build the page of shade ui.
make build # builds the page with bun, then CGO_ENABLED=0 go build -o shade ./cmd/shade
./shade version
The binary is pure Go end to end — SQLite comes from modernc.org/sqlite, so
CGO_ENABLED=0 produces a static binary. bun only builds the page, which the binary embeds
as files. Without bun a plain go build ./cmd/shade still works, but it leaves the page out:
every command runs except shade ui, which exits 1 and asks for make build. The same goes
for go install github.com/werbot/shade/cmd/shade@latest: the repository tracks the sources of
the page, not its build. Build from a checkout with make build to get the page.
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, serve and ui
take no input but --project DIR and --port N, 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 ui [--project DIR] [--port N] |
Serve the web UI — a page for the rules — on the loopback interface (default port 8788) and print its address. See Web UI |
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.
shade ui has no code 3 either. SIGINT and SIGTERM stop it with 0; a port that is already
taken, or a binary built without the page, is 1.
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.
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. - The web UI has no token.
shade uiguards its loopback port against the pages of other sites, not against a local process. See Web UI.
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 # the page is built first; then go test ./... and bun test
make lint # go vet ./... plus a gofmt check, and svelte-check on the page
make build # the page, then the binary
make web # the page alone, into web/dist
All four need bun (see Install). Plain go build, go vet and go test need
neither bun nor a built page (the tracked web/dist/.gitkeep keeps the embed valid); the
process tests of shade ui skip themselves until make web has run. The page has no dev
server: change web/src, run make build and start ./shade ui again.
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. The page logic that needs no browser is covered
by bun test.
Dependencies are deliberately few — github.com/BurntSushi/toml for config and
rules, github.com/modelcontextprotocol/go-sdk for the MCP server, modernc.org/sqlite
for a CGO-free SQLite driver. Everything else in the Go code comes from the standard
library. The page's toolchain is used at build time only and is listed in web/package.json.
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. Phase 5, the
web UI, is under way: its first slice, the Rules screen of shade ui, is done. 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,serve,ui,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 - Web UI, the Rules screen —
shade uilists the rules, creates, switches and deletes them, and tests a text live; the page is embedded in the binary - Rule packages — the
packagestable exists; nothing writes to it yet - Usage UI — the other screens (Entities, Audit, Settings), the UI token that has to arrive with
reveal, editing and ordering of rules, and thekeywords,allowlist,entropy_minandvalidatorfields of a rule.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.

