GitHub - werbot/shade: Reversible secret redaction for LLM prompts: replace sensitive data with stable placeholders before it leaves your machine, then restore it locally in responses. AES-256-GCM encrypted SQLite, 46 built-in rules, per-project scope, and fail-closed recovery. · GitHub
Skip to content
werbotPublic

About

Reversible secret redaction for LLM prompts: replace sensitive data with stable placeholders before it leaves your machine, then restore it locally in responses. AES-256-GCM encrypted SQLite, 46 built-in rules, per-project scope, and fail-closed recovery.

Topics

Resources

Code of conduct

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

140 Commits

Folders and files

Repository files navigation

shade

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

📖 Contents

🎯 Why

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.

🔄 How it works

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
Loading

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"]
Loading

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.

🤖 Claude Code

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 auto while 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.

📡 Proxy

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.

🔌 MCP

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.

🌐 Web UI

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 (regex or literal), 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, where shade rules add would 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, as shade test does. 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:

  • Host must be 127.0.0.1:<port> or localhost:<port>, with the port the server bound; a Host that parses but is none of these is a 403. 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 bare 127.0.0.1 and localhost pass 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 a 403.
  • A request that changes something — any method but GET, HEAD and OPTIONS — is a 403 if it carries an Origin other than the page's own (null included), or a Sec-Fetch-Site other than same-origin or none.
  • A POST or PATCH must be application/json (415 otherwise), and a body over 1 MiB is a 413 on 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, carries X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; answers of the API add Cache-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).

🧱 Architecture

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"]
Loading
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.

📦 Install

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.

🚀 Quick start

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 reference

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.

🚦 Exit codes

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.

🔧 Configuration

Settings are read from three layers, each overriding the previous one:

  1. built-in defaults,
  2. $SHADE_HOME/config.toml — global,
  3. <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).

📜 Rules

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_min rejects 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.

🔖 Placeholders

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 (&lt;), 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.

🔒 Security model

  • Key. 32 random bytes in $SHADE_HOME/key, mode 0600. 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 ‖ value gives 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 list has 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 ui guards 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.

🧪 Development

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.

📌 Status

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, PostToolUse and MessageDisplay, installed by shade init, which records hook as the adapter in the journal
  • Skills — shade and shade-rules, installed into the plugin by shade init
  • MCP adapter — shade mcp serves six tools and the shade://directive resource over stdio
  • MCP scan tool — reports the types and the rules that found them, never the fragment or the offset
  • Proxy adapter — shade serve wraps Claude Code through ANTHROPIC_BASE_URL, anonymizing the whole request and restoring the streamed answer, with the prompt_gate = "auto" liveness marker
  • Web UI, the Rules screen — shade ui lists the rules, creates, switches and deletes them, and tests a text live; the page is embedded in the binary
  • Rule packages — the packages table 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 the keywords, allowlist, entropy_min and validator fields of a rule. rule_hits is written on every anonymization; nothing reads it yet
  • Streaming mode and LLM provider integration — stream_mode, upstream and api_key_env are consumed by shade serve; it serves both the Anthropic Messages shape and the OpenAI Chat Completions shape (streamed and non-streamed)

📄 License

MIT — see LICENSE.

About

Reversible secret redaction for LLM prompts: replace sensitive data with stable placeholders before it leaves your machine, then restore it locally in responses. AES-256-GCM encrypted SQLite, 46 built-in rules, per-project scope, and fail-closed recovery.

Topics

Resources

Code of conduct

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages