Introduction
Claude does the work. The client’s data stays home.
redactproxy is a local, two-way redaction proxy for Claude Code, or anything else that speaks the Anthropic Messages API. It sits between the client and the real API: on the way out it replaces real client data (domains, IPs, emails, credentials, and more) with stable fake values, and on the way back it puts the real ones in again. Claude only ever sees the fakes. Your tool calls still run against real infrastructure.
It rewrites API traffic and nothing else. No added prompts, no tool restrictions, no change to how Claude Code behaves. It listens on loopback and refuses to start on any other address. There is no telemetry, no sync, and no backup: everything it stores stays on the machine you run it on.
Who this manual is for
Pentest and consulting teams who want to use an AI coding agent on a live engagement without that client’s data reaching the model provider.
Getting started takes you from install to a working engagement. Running an engagement covers the day-to-day: adding rules, fixing a bad mapping mid-session, pointing an engagement at a different provider. Security is the part worth reading before you point this at real client data: what it protects, what it does not, and why the debug log is dangerous. Reference holds the command line, the detector categories, the placeholder shapes, and the troubleshooting list.
If you just want it running, start with Install.
Background
Point an AI coding agent at a live engagement and everything ends up in the model: client domains, internal hostnames, credentials out of a config dump, employee emails, the client’s own name. Almost none of it has any reason to leave your machine. The model doesn’t need the real hostname to reason about a finding. It needs one that stays the same every time it sees it.
That stability is the whole design. A given real value always maps to the same fake one for the life of an engagement (one client project), so Claude can still work out that two hosts belong to the same organization without ever seeing which organization. Each engagement keeps its own storage and shares nothing with the others.
The redaction is two-way and it happens in the right order. Real values
go out as placeholders, and the placeholders in Claude’s reply are
swapped back to real values before the reply reaches Claude Code. So a
Bash command Claude writes against a placeholder hostname runs against
the real one, every time it runs, not just the first.
Warning
Authorised testing only. This is a tool for testers working under an engagement. It reduces what a model provider sees; it does not grant permission to test anything. You remain responsible for the scope you work in and for the data you handle.
What “token” and “engagement” mean here
“Token” throughout this manual means a redaction placeholder, not the unit an LLM’s context window is measured in. An engagement is one client project, with its own token store, its own rules, and its own choice of upstream provider.
Disclaimer
Examples here use synthetic values chosen so they can never collide with
anything real. XYZCorp is not a real customer, and
xyzcorp-fixture.internal can never be registered by anyone: .internal
is permanently reserved for private use and will never be delegated as a
public TLD. Client addresses come from RFC 1918 private space, which is
never publicly routable, and the placeholders they are replaced with come
from the RFC 2544 benchmarking range redactproxy mints its own IPv4
tokens in.
Note that the reserved-for-documentation names, example.com and the
RFC 5737 address ranges, are
deliberately not used here. redactproxy excludes them from
redaction on purpose (see
Detector categories),
so an example built on them would show a redaction that never happens.
Nothing in this documentation describes a real target or a real engagement.
Install
redactproxy is a single binary with no runtime to install and no database server to run. It needs Go 1.26.6 or newer to build.
With go install
go install github.com/CSPF-Founder/redactproxy/cmd/redactproxy@latest
From a clone
Use this if you plan to change anything, or want the make targets.
git clone https://github.com/CSPF-Founder/redactproxy.git
cd redactproxy
make install # builds into $(go env GOPATH)/bin
Check your shell can find it
redactproxy version
If that comes back “command not found”, $GOPATH/bin isn’t on your
PATH. Either add it, or run make build instead and use
./bin/redactproxy everywhere redactproxy appears in this manual.
redactproxy version prints the build the binary was made from. For a
binary from go install ...@v1.2.3 that’s the module version; for one
built from a clone it prints (devel). Include it in any bug report.
For a team
make dist cross-compiles a stripped static binary per platform into
dist/:
make dist # linux/darwin/windows, amd64/arm64
Those are meant to be copied straight onto a team member’s machine.
They carry the version stamp from the nearest git tag, so
redactproxy version still answers usefully on a machine that never
had the source.
What it needs at runtime
Nothing beyond a writable data directory, $HOME/.redactproxy by
default. See Engagements and storage.
Next: Your first engagement.
Your first engagement
This walks through setting up one engagement end to end. It assumes
redactproxy is on your PATH; see Install if it
isn’t.
Pick a folder
Work in one folder per client project. Everything below is per-folder:
the engagement marker, the Claude Code settings, the CLAUDE.md note.
Warning
Don’t name the folder after the client. Claude Code puts its own working directory into a part of every request this proxy deliberately never scans, so a folder called
acme-bank-pentestsends “acme bank” to the model on every single request, no matter what your rules say. Use an engagement code. This is the one leak redaction cannot close; see Known gaps.
mkdir ~/engagements/eng-2026-014
cd ~/engagements/eng-2026-014
Run the wizard
redactproxy wizard --engagement eng-2026-014
The wizard is a normal, separate invocation of the binary. It doesn’t need a running proxy, and you can re-run it at any time, including mid-session against an engagement whose proxy is already up.
It asks four things.
1. Customer name variations
Names have no detectable shape, so nothing finds them automatically. This is where you list them: the legal name, the trading name, abbreviations, product names, internal codenames. One per line, blank line to move on.
Customer name variations (legal name, abbreviations, product names, one per line, blank line to move on):
> XYZCorp
> XYZ Corporation
>
These become case-insensitive substring matches, so XYZCorp
also catches XYZCorporation.
2. Domains
Domains (one per line, blank line to finish):
> xyzcorp-fixture.internal
>
Give the base domain only: no www., no subdomain. Everything else
resolves from it automatically, so one entry covers subdomains, email
addresses at that domain, and URLs. Entries from this prompt get the
structure-preserving domain treatment described in
Rules, the same as
rules block --domain.
Right after this prompt the wizard checks the folder you are standing in
against the rules you just entered. ~/engagements/eng-2026-014 doesn’t
contain any of them, so nothing is printed here and the wizard moves
straight on to the next question.
If the folder were named after the client, say
~/clients/xyzcorp-pentest, its name would contain a value you just
asked to have redacted, and the wizard would print this instead:
WARNING: this folder's path ("/home/you/clients/xyzcorp-pentest") currently matches your own block-list pattern "(?i)xyzcorp":
it WILL be sent unredacted on every request from here. Consider a different folder name.
That is the folder-naming problem from the top of this page, caught after the fact. Renaming the folder is the only fix; no rule can cover it. See Known gaps.
3. Which API this engagement talks to
Which API is this engagement talking to?
1) Claude (Anthropic): uses whatever Claude Code is already signed in with
2) z.ai
3) Manual / another provider: writes a placeholder skeleton to edit by hand
(currently: Claude)
> 1
Choose 1 unless you have a reason not to. redactproxy never asks for an Anthropic credential: Claude Code’s own authentication is forwarded through untouched. See Other API providers for 2 and 3.
4. Two conveniences for this folder
Add a CLAUDE.md note explaining the redaction placeholders (./CLAUDE.md)? [Y/n] y
appended the redaction-placeholder note to ./CLAUDE.md
Configure Claude Code in this folder to use the proxy automatically (writes ./.claude/settings.local.json)? [Y/n] y
wrote ./.claude/settings.local.json
Say yes to both. The first teaches Claude what the placeholder values mean so it treats them as values to reuse rather than typos to correct. The second points Claude Code at the proxy and closes several channels that bypass the proxy entirely. Both are covered in Configuring Claude Code.
If .claude/settings.local.json already exists, the wizard prints the
current file, lists exactly what it would change, and asks before
writing. It merges; it never overwrites.
Start the proxy
In the same folder:
redactproxy
level=INFO msg="rules loaded" disabled_count=0 block_entries=2 allow_entries=0
level=INFO msg="redactproxy listening" addr=127.0.0.1:8787 engagement=eng-2026-014 ...
redactproxy is running. This folder's Claude Code is already configured to use it
(.claude/settings.local.json), so just run `claude` here, no export needed.
Type here any time without stopping the proxy: "show", "remove <value>", "rules ...", "help".
No --engagement needed: the wizard left a .redactproxy-engagement
marker in this folder and every later command run from here picks the
name up from it.
Leave this terminal open. It is also a live console: see Tokens and the live console.
Start Claude Code
In a second terminal, in the same folder:
claude
That’s it. Claude Code reads ANTHROPIC_BASE_URL from this folder’s
settings file and its traffic now goes through the proxy.
Without the wizard
Skipping the wizard is fine. You lose the CLAUDE.md note and the
settings hardening, and have to point Claude Code at the proxy yourself:
redactproxy --engagement eng-2026-014 &
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
claude
ANTHROPIC_BASE_URL only applies to the shell you set it in. Close that
terminal when you’re done with the engagement, or unset it, so an
unrelated session later doesn’t go through the same proxy by accident.
Check it’s working
Ask Claude something that mentions a client value and watch what comes back. Or, in the proxy’s own terminal, type:
show
That prints every mapping minted so far, real value on the left and placeholder on the right.
What next
- How redaction works for what’s actually happening.
- Rules: what to redact to add values mid-engagement.
- Known gaps before you trust it with real client data. This is the important one.
How redaction works
The round trip
sequenceDiagram
participant CC as Claude Code
participant RP as redactproxy
participant API as api.anthropic.com
CC->>RP: request (real PII)
Note over RP: find text fields, tokenize:<br/>real value -> stable fake
RP->>API: request (tokens only)
API-->>RP: response (tokens)
Note over RP: detokenize:<br/>fake -> real
RP-->>CC: response (real PII)
Two directions, and the order is what makes it usable:
Outbound. The proxy parses the request body, finds the strings that carry content, runs the detectors over them, and replaces every real value it recognizes with a placeholder. The upstream API sees only placeholders.
Inbound. Every placeholder in the reply is swapped back to its real
value before the reply reaches Claude Code. So when Claude writes a
Bash command against a placeholder hostname, Claude Code receives the
real hostname and runs against real infrastructure.
That substitution happens on every response, not just the first. A command Claude re-issues ten turns later still resolves correctly, and Claude never has to have seen the real value to write a command that works against it.
A worked example
Some nmap and config-dump output, as Claude Code would send it:
Nmap scan report for mail.xyzcorp-fixture.internal (10.42.7.19)
Host is up (0.021s latency).
443/tcp open ssl/http nginx
Found admin contact: rahul.menon@xyzcorp-fixture.internal
MAC Address: 00:1b:44:11:3a:b7 (Dell)
Recovered from the app config file:
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
DATABASE_URL=postgres://appuser:s3cr3tpw@db.xyzcorp-fixture.internal:5432/prod
Reference doc: https://github.com/xyzcorp/deploy-notes
What the model actually receives:
Nmap scan report for mail.tok5198ede8bdbb1ada.internal (198.18.0.19)
Host is up (0.021s latency).
443/tcp open ssl/http nginx
Found admin contact: user-428791a054d2@tok5198ede8bdbb1ada.internal
MAC Address: 02:00:00:aa:14:b7 (Dell)
Recovered from the app config file:
AWS_ACCESS_KEY_ID=AKIAFAKEA7F002EFA477
DATABASE_URL=postgres://REDACTED-CREDS-26d467f58e4caccccca3eda7/prod
Reference doc: https://github.com/xyzcorp/deploy-notes
Read that side by side, because nearly every design decision is visible in it:
- The
mail.subdomain survives, and the same org placeholder appears in both the hostname and the email address, so the relationship between them is intact. - The host octet
.19survives; only the /24 network changed. - The AWS key still looks like an AWS key, so Claude knows what it found.
- The connection string collapses to one opaque placeholder, because the whole credential span is sensitive.
- The
nginxversion banner, the latency, the port, and the Dell OUI comment are untouched. They aren’t client-identifying. github.comis untouched, because it’s on the built-in allowlist.xyzcorpin the GitHub URL path is untouched, because a company name in a path has no detectable shape. That is exactly what the wizard’s “Customer name variations” prompt is for, and the reason it’s the first question it asks.
Stability
A given real value always maps to the same placeholder for the life of an engagement.
Claude can still reason that tok1a2b3c4d5e6f7890.com and
mail.tok1a2b3c4d5e6f7890.com belong to the same organization, that a
finding on one host relates to a finding on another, that an email
address belongs to the same company as a web server. It just never
learns which organization that is.
Mappings live in tokens.db in the engagement’s own directory. They
persist across restarts, so the placeholder Claude saw yesterday is
still the same one today.
Structure is preserved where it helps
Placeholders are not opaque blobs when the structure carries useful, non-identifying context:
- A domain keeps its real public suffix.
xyzcorp-fixture.co.ukbecomestok<hex>.co.uk, so country and sector context survives. - An IPv4 address keeps its real host octet, and only the /24 network is replaced. Hosts that were adjacent stay adjacent.
- An email keeps its shape, with a placeholder local part at a placeholder domain.
- A credential keeps its vendor prefix. An AWS key still looks like an
AWS key (
AKIAFAKE...), so Claude knows what kind of secret it is looking at without seeing the secret.
Every placeholder shape is documented in
Placeholder shapes. All of them are
structurally guaranteed never to collide with a real value: the fake
IPv4 range is RFC 2544 benchmarking space, fake phone numbers use the
reserved 555-01XX exchange, fake credentials embed a FAKE marker in a
position where real ones can only carry hex.
What gets scanned
Only the content-carrying strings inside a Messages API body: the
messages array in a request, and the content array in a response.
That is where operator and tool-output text lives.
Deliberately not scanned:
- The top-level
systemfield. It is Claude Code’s own prompt boilerplate, and this is where the working-directory leak in Known gaps comes from. thinkingandredacted_thinkingblocks. The text and its signature together are a cryptographic proof the API validates on replay, so any edit makes the next request fail.- Server-executed tool blocks (web search, code execution). These run on Anthropic’s own infrastructure and never carry local client data.
- Images and documents, which are binary payloads rather than text.
MCP tool calls and results are scanned, unlike the server-executed blocks above. What separates them is where the tool runs: web search and code execution run on Anthropic’s own infrastructure, while an MCP server is local infrastructure you control (a database query tool, an internal API client, a custom tool of your own), and its output is exactly the kind of real client data this proxy exists to keep off the wire.
The proxy sits between Claude Code and the API, never between Claude
Code and your MCP server.
The local connection to that server is not intercepted, not blocked, and
not modified. What gets scanned is the copy of the exchange carried in
the API body: an mcp_tool_result is tokenized on the way out, and an
mcp_tool_use in a response is detokenized before Claude Code executes
it, so the MCP server itself receives the real values, exactly as Bash
does.
The rewrite is surgical: only the matched spans change, and every other byte, including JSON key order, is preserved. That matters because Claude Code’s prompt-cache breakpoints are a prefix match on exact bytes, so a reserialized body would silently destroy caching.
Detection is regex-based
There is no model in the loop, no network call, and no learning. A detector is a pattern plus a validation step, and the full set is in Detector categories.
What follows from that:
- Shapes get caught, prose does not. An IP, an email, an API key, a domain: caught. A client’s name, a codename, a project name: not caught, because there is no shape to match. That is what rules are for, and why the wizard asks for names first.
- Encoded data passes straight through. A
.envpiped throughbase64doesn’t look like anything to a regex. Decode it locally first. - False positives happen. The proxy inspects text and has no notion
of code structure, so something merely domain-shaped (
table.style, where.styleis a real suffix) can occasionally get tokenized. This is harmless in operation, since the real value is substituted back before anything executes, andrules allowfixes it permanently.
Fail closed
If the proxy cannot finish redacting a request, it returns an error rather than forwarding bytes. An unredacted forward is the one outcome this project treats as worse than a broken request. A malformed body, a detector that errors, a token store that can’t commit: all of them fail the request instead of passing it through.
Cost
Scanning a body is the expensive part, so results are cached by content hash: any given string is scanned once, however many times you send it. Claude Code resends the whole conversation every turn, so after the first turn most of a request is cache hits and costs close to nothing to redact.
Two things are not cached.
The first is minting a token for a value the store has never seen. Each
new value is its own committed write, which is what lets the store
survive being killed at any point without losing the mapping for a value
already sent to the model. A body that discovers hundreds of new values
at once, the first run of a full-subnet nmap being the obvious case,
pays all of those writes in one request and can visibly stall it.
Sending the same output again is free, because by then every value is
known and the scan itself is cached.
The second is the response direction. Detokenizing scans for placeholders every time, with no equivalent cache, because the model can return known placeholders in any arrangement it likes. That cost scales with response size rather than with how much you have redacted so far.
Engagements and storage
An engagement is one client project. It owns a token store, a rule set, and a choice of upstream provider, and it shares none of them with any other engagement.
Engagement names are 1 to 64 characters of letters, digits, underscore and hyphen. No dots, no slashes: an engagement name becomes a directory name, and anything that could climb out of the data directory is rejected rather than sanitized.
Where the data lives
Each engagement is a self-contained directory under a base data
directory, $HOME/.redactproxy by default or wherever --data-dir
points:
$HOME/.redactproxy/
└── engagements/
└── eng-2026-014/
├── tokens.db # real <-> fake mapping (bbolt), persists across runs
├── rules.json # enabled categories, custom block/allow entries
├── upstream.txt # this engagement's API provider, if not Anthropic
└── debug.log # opt-in only, contains REAL values when enabled
The directory is created with mode 0700 and the files inside it with
0600.
None of this is sent anywhere. There is no telemetry, no sync, no backup. Deleting the directory deletes the engagement, and the next run under that name starts from nothing.
Warning
tokens.dbmaps real client values to placeholders in plaintext, anddebug.logcontains real values whenever debug logging is on. Treat the whole data directory as client data: it belongs under the same handling and retention rules as your scan output and your report drafts.
Note that this directory defaults to $HOME, deliberately outside any
project working folder, so a Claude Code session browsing its own
working directory can’t stumble into it.
The folder marker
Passing --engagement also drops a .redactproxy-engagement file in
the working directory. Later commands run from that folder read the name
from it, so you only type it once:
cd ~/engagements/eng-2026-014
redactproxy --engagement eng-2026-014 # first time
redactproxy # every time after
redactproxy rules show # and for subcommands
Passing a different name overwrites the marker. There is no default
engagement name and no fallback: a command run in a folder with no
marker and no --engagement fails rather than guessing, because a
shared default is exactly how two clients end up in one token store.
Crash safety
tokens.db survives an abrupt kill, not just a clean shutdown. A new
mapping is written and flushed to it before the placeholder is used in a
request, so the proxy can never be killed holding a token that isn’t on
disk. Everything minted in earlier runs is still there when you restart;
only the request in flight is lost.
This is also why minting a large batch of new values is slower than re-sending the same content: each value is its own committed write.
One process per engagement
bbolt lets only one process hold tokens.db open at a time. Two
consequences:
- You cannot run two proxies for the same engagement. The second exits immediately with a lock error.
redactproxy tokens showandtokens removecannot run while the proxy is up for that engagement. Use the proxy’s own console instead; see Tokens and the live console.
rules.json is not lock-held that way. It is a plain file, polled every
two seconds, so rules commands work fine from another terminal against
a running proxy.
Running several engagements at once
Start one redactproxy per engagement, each on its own --listen
address, and point each Claude Code session at the matching port:
# terminal 1
cd ~/engagements/eng-2026-014 && redactproxy --listen 127.0.0.1:8787
# terminal 2
cd ~/engagements/other-client && redactproxy --listen 127.0.0.1:8788
One process serves exactly one engagement. If you use a non-default port
in a folder the wizard configured, re-run the wizard with the matching
--listen so the settings file agrees, or the folder’s Claude Code will
keep pointing at the old port.
--listen must be a loopback address. 127.0.0.1, ::1 and
localhost are accepted; anything else, including the bare :8787
shorthand that binds every interface, is refused at startup. This proxy
handles real client data in transit and must never be reachable from the
network.
Retiring an engagement
There is no “close engagement” command. When the work is done:
rm -rf ~/.redactproxy/engagements/eng-2026-014
That removes the mappings, the rules, and the debug log if there is one.
Also clean up the working folder’s .redactproxy-engagement,
CLAUDE.md note and .claude/settings.local.json if the folder itself
is being kept for anything else, and remember that Claude Code’s own
transcripts still hold the real values you saw on screen.
Rules: what to redact
Rules are per-engagement and live in that engagement’s rules.json.
There are two separate kinds of thing you can name, and they are always
separate commands, never guessed from the argument:
- A category is a built-in detector (
cloud.aws,network.mac) or a built-in exception (allowlist.wellknown_platforms). Youenableordisableit. - A value is a literal string or regex you add yourself. You
blockorallowit.
The commands warn when a value you block happens to match a category name.
Changes apply live
rules.json is polled every two seconds, so a change from the CLI, the
wizard, the proxy console, or a hand edit reaches a running proxy within
a couple of seconds. No restart, and no dropped in-flight request.
Important
Rule changes only affect text tokenized from that point on. Adding a block rule will not scrub a value that already reached the model earlier in the same conversation. If that matters, start a fresh conversation. Disabling a category likewise only gates future detection; anything already tokenized keeps detokenizing correctly.
If a reload finds rules.json invalid, the proxy logs the error and
keeps the previous rules active rather than starting to send unredacted
traffic.
Blocking a value
redactproxy rules block "XYZCorp"
A block entry matches case-insensitively as a substring, so
XYZCorp also catches XYZCorporation. This is what you
use for everything with no detectable shape: client names, trading
names, product names, internal codenames, project names.
Add a note so the entry is still legible months later:
redactproxy rules block --note "acquired subsidiary" "XYZAnotherCorp"
Or use a regex:
redactproxy rules block --regex --note "customer VPN range" '10\.42\.\d+\.\d+'
Blocking a domain
By default rules block treats its value as opaque text: matched and
redacted, nothing about it preserved. Add --domain when the value
really is a domain, and it gets the same structure-preserving treatment
as any domain the tool finds on its own, everywhere that domain turns
up, whether as a subdomain, inside an email address, or in a URL:
redactproxy rules block --domain "xyzcorp-fixture.internal"
The wizard’s “Domains” prompt does this for you, so anything entered there already counts as a domain entry.
Always give the base domain: no www., no subdomain. Subdomains,
emails and URLs resolve from the base automatically; it does not work in
the other direction. Entering a subdomain by mistake still works, since
it resolves back to the base, but the CLI points it out.
Internal-only names work too: an Active Directory forest, a private
naming scheme, anything that will never appear on a public suffix list.
--domain takes your word for it and only checks that the value is
syntactically a hostname. A URL, an email address or an arbitrary string
still gets added, but as an ordinary literal value, with a warning
explaining why.
This flag exists partly because of a deliberate detection gap: bare
apex domains on file-extension TLDs (.do, .ai, .rs, .sh, .py
and friends) are not detected automatically, because main.rs and
logo.ai are far more often filenames. Use --domain for those.
Allowing a value
redactproxy rules allow "mylab.internal"
An allow entry matches the exact value only, case-insensitively.
That is deliberately narrower than block: an allow entry takes
protection away, so it should be as specific as possible.
Use it for your own infrastructure, your own testing tooling, and for false positives you want to stop seeing.
Warning
An allow entry always overrides a block entry for the same value, with no exception. Because a value in both lists would never mean anything,
blockandallowrefuse to add a value already covered by the other list, andrules showflags any existing contradictory pair left over from a hand edit.
Removing an entry
redactproxy rules remove "XYZCorp"
Removes the value from the block list, the allow list, or both, whichever it is in. It says which.
This changes what gets redacted in future. It does not delete a mapping already minted; for that see Tokens.
Turning detectors on and off
redactproxy rules disable india_pii.pan
redactproxy rules enable india_pii.pan
Naming a bare category toggles every subcategory under it in one call:
redactproxy rules disable india_pii # both aadhaar and pan
rules show lists every category with its current state and
description. The full list is in
Detector categories.
Disabling a detector is a real reduction in protection, so the proxy logs a warning line for it at startup and on every reload, rather than letting it scroll by as routine output.
The allowlist categories are inverted
Four categories work backwards from the rest:
| Category | Exempts |
|---|---|
allowlist.wellknown_platforms | github.com, npmjs.com, stackoverflow.com and similar |
allowlist.web_infrastructure | CDN, font, analytics and widget hosts |
allowlist.security_testing_services | Burp Collaborator, interact.sh, webhook.site and similar |
allowlist.third_party_saas | specific vendor products a client integrates with |
These exist so Claude can recognize infrastructure that carries no client identity. It is useful for Claude to know that a domain is GitHub, or that a callback host is Burp Collaborator and therefore your own tooling rather than the client’s.
Disabling one of these makes more get redacted, not less. rules show flags them with a warning for exactly that reason. Most
engagements should leave all four enabled.
allowlist.third_party_saas is the weakest judgment call of the four,
since seeing a vendor domain does reveal something about the client’s
tech stack, just not the client’s identity. It is a separate category so
you can disable that one alone on an engagement where even tech-stack
fingerprinting should stay hidden.
Disabling a category for one run
redactproxy --disable cloud.aws,network.mac
This merges with rules.json’s own disabled list rather than replacing
it, and is not persisted. It stays in effect for the life of that
process, including across rule reloads. For a change that outlives the
run, use rules disable.
Reviewing what’s active
redactproxy rules show # categories, block list, allow list
redactproxy rules validate # check rules.json without starting the proxy
rules validate is the one to run after hand-editing the file. It
reports invalid entries, and warns about category names it does not
recognize, which are usually a case typo.
Editing rules.json by hand
Supported, and the file is written to be self-documenting: every known
category appears in it with its description and, where relevant, its
warning. See rules.json for the format, and
run rules validate afterwards.
Tokens and the live console
Rules decide what gets tokenized on future requests. The token store holds the mappings already minted. They are separate commands because they answer separate questions.
Seeing what has been mapped
redactproxy tokens show
tokens for engagement "eng-2026-014"
/home/you/.redactproxy/engagements/eng-2026-014/tokens.db
aws_key (1):
AKIAIOSFODNN7EXAMPLE -> AKIAFAKEA7F002EFA477 (first seen 2026-09-05 11:20:14 IST)
conn_string (1):
appuser:s3cr3tpw@db.xyzcorp-fixture.internal:5432 -> REDACTED-CREDS-26d467f58e4caccccca3eda7 (first seen 2026-09-05 11:20:14 IST)
domain (1):
xyzcorp-fixture.internal -> tok5198ede8bdbb1ada (first seen 2026-09-05 11:20:14 IST)
email_local (1):
rahul.menon@xyzcorp-fixture.internal -> user-428791a054d2 (first seen 2026-09-05 11:20:14 IST)
ip_network (1):
10.42.7 -> 198.18.0 (first seen 2026-09-05 11:20:14 IST)
mac (1):
00:1b:44:11:3a:b7 -> 02:00:00:aa:14:b7 (first seen 2026-09-05 11:20:14 IST)
6 total
Entries are grouped by entity type, and what’s stored is the part that actually varies. A domain’s entry is the registrable domain and its org placeholder, with the real public suffix appended outside the mapping. An IPv4 entry is a /24 network, not a host address, which is why one entry covers every host you touched on that subnet.
This is the cheapest way to see what the model has actually been shown,
and it needs no debug logging, so nothing gets written to disk in
plaintext beyond what tokens.db already holds.
Undoing one mapping
redactproxy tokens remove "xyzcorp-fixture.internal"
Takes the real value, not the placeholder.
Important
Removing a mapping only forgets that one past mapping. It does not stop future redaction. If the value shows up again it gets caught and tokenized again, as a new, different placeholder, because tokens are random rather than derived from the value.
To stop a value being redacted at all, use
rules allowinstead, or as well.
The pair that usually makes sense together, for a false positive you want gone for good:
redactproxy rules allow "mylab.internal" # stop redacting it
redactproxy tokens remove "mylab.internal" # drop the mapping already minted
The lock, and why the console exists
bbolt lets only one process hold tokens.db open at a time. So the two
commands above fail while the proxy is running for that engagement:
open token store: the proxy is already running for this engagement, and only one
process can hold tokens.db open at a time; type "show" directly into that
proxy's own terminal instead
Stopping the proxy to fix a mapping would drop whatever Claude Code request is in flight and force it to time out. So the proxy reads commands from its own stdin instead, operating on the store it already has open.
The live console
The terminal running redactproxy is interactive. It says so at
startup:
Type here any time without stopping the proxy: "show", "remove <value>", "rules ...", "help".
Commands:
show every mapping in the store (same as `tokens show`)
remove <real-value> drop one mapping (same as `tokens remove`)
rules show
rules enable <category>
rules disable <category>
rules block <value>
rules allow <value>
rules remove <value>
help
The rules commands here call exactly the same code as the standalone
CLI, so they behave identically. Two differences worth knowing:
- The console’s
block/alloware the simplified form. Everything after the verb is one literal value, sorules block XYZ Another Corpworks without quoting, but there is no--regex,--domainor--note. Use the standalone CLI in a second terminal for those. rulescommands would work from another terminal anyway, sincerules.jsonis not lock-held. They are here for parity. The token commands are the ones that genuinely need the console.
The console only activates when stdin is a real terminal. A
nohup/systemd/background launch never treats redirected or piped stdin
as commands.
Reading the log lines
The proxy logs to stderr. Warnings and errors are printed in red when stderr is a terminal, because the ones that matter are easy to miss otherwise. The ones worth reacting to:
current directory matches a block-list pattern and WILL be sent to the upstream API unredacted: your folder is named after the client. See Known gaps.detection category disabled: real values in this category will NOT be redacted: a detector is off, either fromrules.jsonor--disable.allowlist category disabled: values normally exempted by this allowlist will now be redacted instead: the inverted case, more gets redacted rather than less.rules.json reload failed; keeping previous rules active: your edit is invalid. Runredactproxy rules validateto see why. The proxy is still running on the last good ruleset.block entry marked "is_domain" doesn't look like a real domain: a hand edit setis_domainon something that isn’t one. It has fallen back to an ordinary literal block entry.
Configuring Claude Code
The proxy only sees what Claude Code sends to /v1/messages. Two pieces
of Claude Code configuration matter: pointing it at the proxy, and
closing the channels that never go through the proxy at all. The wizard
writes both. This page explains what it wrote and why, so you can audit
it or do it by hand.
Pointing Claude Code at the proxy
Claude Code reads ANTHROPIC_BASE_URL to decide where to send its
traffic. Either set it per shell:
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
claude
or, better, put it in the folder’s .claude/settings.local.json, which
is what the wizard does:
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"
}
}
settings.local.json specifically, not settings.json: it is the file
meant for personal, machine-local settings that never get committed or
shared, which is exactly what a local proxy port is.
The env-var form only applies to the shell you set it in. Close that
terminal when the engagement is done, or unset it, so an unrelated
session later doesn’t route through the same proxy by accident. The
settings-file form has no such problem, since it is scoped to the
folder.
The hardening the wizard adds
This is the full file the wizard writes for a fresh folder:
{
"disableRemoteControl": true,
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787",
"CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY": "1",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"DISABLE_ERROR_REPORTING": "1",
"DISABLE_FEEDBACK_COMMAND": "1",
"DISABLE_TELEMETRY": "1"
},
"permissions": {
"deny": [
"Read(//home/you/.redactproxy/engagements/eng-2026-014/debug.log)",
"Artifact",
"RemoteTrigger",
"PushNotification",
"SendUserFile"
]
},
"skipWebFetchPreflight": true
}
Each entry closes a specific path that bypasses ANTHROPIC_BASE_URL.
The denied tools
Artifact is a confirmed leak path: a report published through it goes
straight to a hosted claude.ai URL, entirely unredacted, via a separate
service call rather than a Messages API request. Nothing this proxy does
can touch it.
RemoteTrigger, PushNotification and SendUserFile share the same
Remote-Control-adjacent architecture and are denied for the same reason,
as cheap defense in depth.
A bare tool name in a deny rule removes the tool from Claude’s context entirely, rather than prompting for permission each time. That distinction is the point: a permission prompt can still be approved by habit, but a tool that was never offered cannot leak anything.
disableRemoteControl
Remote Control’s session transcript carries real messages, responses and tool activity over a channel this proxy never sees.
Claude Code already disables Remote Control on its own whenever
ANTHROPIC_BASE_URL points somewhere other than api.anthropic.com, so
under today’s behavior this is redundant. It is set anyway, as a second
guarantee that does not depend on that auto-detection surviving into a
future version.
skipWebFetchPreflight
WebFetch’s safety check sends the target hostname to Anthropic
directly, as a preflight, before the actual fetch. A domain being
reconned is precisely the kind of value this tool exists to keep off any
channel that bypasses tokenization.
This check runs regardless of which model provider you use. So for an engagement pointed at a non-Anthropic provider, leaving it on means the target hostname still goes to Anthropic, a company otherwise not even in the loop for that engagement.
The debug log deny rule
Read(//path/to/debug.log) keeps a session from loading the debug log
into its own context by accident and burning tokens on a file nobody
asked it to read. Claude Code’s Read deny also covers cat, head,
tail and sed on that path via Bash.
It is not meant to stop you from deliberately analyzing that log. Loosen the rule, or point a separate unrestricted session at the file.
The quiet env vars
Telemetry, error reporting, and feedback/survey prompts. None of these are as high-stakes as the above: usage metrics and crash reports, not report content. They are disabled because it costs nothing and keeps every optional channel to non-proxied infrastructure closed by default.
Worth knowing: a plain custom ANTHROPIC_BASE_URL override, which is
what every engagement here uses, does not get these auto-disabled
the way recognized integrations like Bedrock or Vertex do. It is treated
as ordinary Claude API use, with telemetry and error reporting
defaulting on. This block is doing real suppression, not redundant
caution.
The startup warning
If any of the above is missing from the folder’s settings, the proxy says so at startup and lists exactly what is absent:
WARNING: this folder's Claude Code isn't fully hardened against channels that
bypass this proxy entirely. The Artifact tool, for example, publishes straight
to claude.ai, completely unredacted. Run `redactproxy wizard` here to fix that
automatically, or add the following to .claude/settings.local.json by hand:
- disableRemoteControl: true
- permissions.deny: "Artifact"
...
Re-running redactproxy wizard in the folder fixes it. The wizard
merges into an existing file rather than overwriting it, prints what is
there, lists exactly what it would change, and asks before writing.
The CLAUDE.md note
redactproxy memory # print it
redactproxy memory --write ./CLAUDE.md # append it
Without this note, a session sees placeholder values with no explanation and behaves accordingly: treating them as typos to correct, hesitating to use them in tool calls, or reconstructing them from memory slightly wrong. The note tells Claude what the shapes mean and that they are stable identifiers to reproduce verbatim.
It also covers several failure modes worth knowing about yourself:
- Never retype a placeholder from memory. Copy it from its most
recent literal appearance. A fragmented or approximated token means an
Edit’sold_stringwon’t match the real file, or a report line traces back to nothing. - A fake IP is not test data. The placeholder IPv4 range looks like documentation space because it is reserved benchmarking space, chosen so it can never collide with a real target. It stands in for a real, sensitive address and should be reasoned about as one.
- An unfamiliar-looking token is not a bug. It is either a token
doing its job or an over-redaction of something merely domain-shaped.
Either way the real value is substituted before execution, so the
command runs correctly. Verify against the real filesystem with
Bash/Readrather than guessing around the token. - Decode encoded content locally, not inline. Base64 or hex decoded
by Claude into its own output arrives completely unprotected, since
the encoded form passed through unredacted. Decoding it to a file with
Bashand reading the file back gives that content a normal pass through redaction.
Put it in the engagement’s project-level ./CLAUDE.md, not the
user-level ~/.claude/CLAUDE.md. The user-level one loads for every
session on the machine, including sessions with no proxy in front of
them.
Appending is idempotent. Re-running the wizard or memory --write
detects the existing note and leaves the file unchanged.
Other API providers
--upstream accepts any base URL that speaks the Anthropic Messages
API. It defaults to real Claude. Each engagement remembers its own
choice in upstream.txt, so different client projects can run against
different providers side by side.
Note
A provider with a genuinely different wire format, such as the OpenAI-shaped tools Codex CLI uses, will not work. The detection and streaming code is built around the Messages API shape specifically. z.ai works because it deliberately exposes an Anthropic-compatible endpoint, not because this proxy is provider-agnostic.
Choosing a provider
Re-run the wizard at any time and answer the provider question differently to switch an engagement:
Which API is this engagement talking to?
1) Claude (Anthropic): uses whatever Claude Code is already signed in with
2) z.ai
3) Manual / another provider: writes a placeholder skeleton to edit by hand
(currently: Claude)
Whichever you pick, the wizard clears out every env var a different provider choice would have set. Switching in any direction cannot leave a previous provider’s auth token, model name, or tuning variable behind.
Only three choices are offered, deliberately. Claude needs no input at all, z.ai is a known and verified integration that can be configured from one API key, and anything else gets a placeholder skeleton rather than a guess. A wrong guess at which auth header some provider expects fails silently rather than loudly, which is worse than asking you to fill it in.
1. Claude
The default. redactproxy never asks for an Anthropic credential: whatever Claude Code is already signed in with, subscription login or otherwise, reaches the upstream exactly as before, because every client header is forwarded untouched. The proxy only ever needs to know a URL.
Switching an engagement back to Claude removes upstream.txt entirely
rather than leaving an empty file behind.
2. z.ai
The wizard asks for one thing, your API key, and fills in the rest from z.ai’s own published configuration:
| Setting | Value |
|---|---|
| Base URL | https://api.z.ai/api/anthropic |
| Auth | ANTHROPIC_AUTH_TOKEN (Bearer) |
| Main model | glm-5.2 |
| Background/haiku model | glm-4.7 |
API_TIMEOUT_MS | 3000000 |
CLAUDE_CODE_AUTO_COMPACT_WINDOW | 1000000 |
The main model resolves ANTHROPIC_MODEL plus the opus and sonnet
aliases, so a fresh launch, /model opus and /model sonnet all land on
it. The background model covers the haiku alias and Claude Code’s own
internal background calls.
Leaving the key prompt blank means “don’t touch the existing one”, not “clear it”. Re-running the wizard on a working z.ai engagement to add a rule and pressing Enter at the key prompt will not silently delete a working credential.
3. Manual / another provider
Two paths. Answer yes to enter the details now, and the wizard asks for the base URL (validated as absolute), the API key, which auth header to send it in, and the model names. Answer no and it writes a deliberately non-functional skeleton for you to edit:
upstream.txtgetshttps://REPLACE-ME.example.com.claude/settings.local.jsongetsREPLACE-MEenv values
Non-functional on purpose. Starting redactproxy without editing it first
fails loudly rather than quietly talking to whatever
REPLACE-ME.example.com happens to resolve to.
Adding another fully-supported provider like z.ai is a code change, not a config option. See CONTRIBUTING.md’s “Adding a provider”.
Why ANTHROPIC_API_KEY gets blanked
Picking z.ai or manual sets ANTHROPIC_API_KEY to an empty string in
the folder’s env block.
Every client header is forwarded untouched. So if you, your shell, or
your user-level Claude settings already have a real Anthropic API
key set, and this folder’s ANTHROPIC_BASE_URL now points at a third
party, that real Anthropic credential would go out as a header on every
request the proxy forwards, straight to that third party.
Blanking the key overrides any inherited value for this project only. Switching back to Claude clears the override out rather than leaving it blank, so Claude’s own key resolution isn’t permanently short-circuited.
Overriding per run
redactproxy --upstream https://api.z.ai/api/anthropic
An explicit --upstream wins over the persisted value for that run
only, the same “CLI flag is a this-run override” convention --disable
follows.
The URL must be absolute and must use http or https. A typo like
htps:// is caught at startup rather than surfacing later as a generic
“request to provider failed” the first time Claude Code sends something.
What redactproxy stores
upstream.txt holds the bare base URL and never a credential. The
provider’s auth token is a Claude Code concern: it’s sent as a header
Claude Code already attaches, forwarded through untouched, and it lives
in .claude/settings.local.json.
Threat model
This page states what redactproxy is designed to do, against what, and under which assumptions. Read it with Known gaps, which is the same subject from the other direction.
What it defends against
One adversary: the model provider seeing client data it has no need to see. Not an attacker on your network, not a malicious client, not a compromised host. A tester using an AI coding agent on a live engagement produces API traffic full of client-identifying material that has no reason to leave the machine, and the model does not need any of it to be useful.
Concretely, redactproxy is designed to keep these off the wire:
| Category | Examples |
|---|---|
| Client identity | company names, trading names, product names, codenames |
| Network identity | domains, hostnames, IPv4/IPv6 addresses, MAC addresses |
| People | email addresses, phone numbers |
| Credentials | API keys for 25+ vendors, JWTs, bearer tokens, connection strings, private keys, password hashes |
| Regional PII | Indian Aadhaar and PAN numbers |
| AD artifacts | machine account names, GPP cpassword values |
The full list is in Detector categories.
What it does not defend against
It is not a data loss prevention system, a network control, or a sandbox. It has no opinion about what Claude does, no tool restrictions, and no added prompts. It edits API traffic and nothing else.
It is also not a defense against you. Anyone who can run the binary can
read tokens.db and see every mapping. The point is to control what
reaches a third party, not to withhold anything from the operator.
Assumptions it makes
- The host is trusted. Real values live on it, in
tokens.dband in Claude Code’s own transcripts. Compromise of the host defeats everything here. - Loopback is trusted. The proxy binds loopback only and refuses to
start on any other address, including the bare
:8787shorthand. Any local process that can reach the port can send it traffic. - The upstream connection is TLS. The proxy terminates TLS to the
upstream. A vulnerability in Go’s
net/httporcrypto/tlsis treated as a finding about this tool, which is whygo.modpins a patch-level Go version and CI runsgovulncheck. - Detection is regex-based, so coverage is shape-based. Anything without a recognizable shape is not detected. This is the single largest assumption and the source of most entries in Known gaps.
Design properties you can rely on
Fail closed. Anything on the request path that cannot finish its job returns an error rather than passing bytes through. A malformed body, a detector that errors, a token store that cannot commit: all fail the request. An unredacted forward is the one outcome this project treats as worse than a broken request.
That extends to inputs designed to slip past. A body that is not valid JSON is rejected rather than forwarded untouched, because a lenient parser would otherwise report “nothing to redact” for a body that was never parsed. Invalid UTF-8 inside a string is rejected for a related reason: a real value sliced out of it would corrupt whatever it was later substituted back into.
Tokens cannot be confused with real values. Every placeholder shape
is structurally guaranteed never to collide with something real: the
fake IPv4 range is RFC 2544 benchmarking space, phone placeholders use
the reserved 555-01XX exchange, Aadhaar placeholders start with a digit
UIDAI never issues, and credential placeholders embed FAKE in a
position where a real value can only carry hex. See
Placeholder shapes.
Tokens carry no information about the value. They are drawn from
crypto/rand, not derived from the real value. There is nothing in a
placeholder to reverse, and nothing that leaks through the mapping
itself. This is also why removing a mapping and re-encountering the same
value mints a different placeholder.
No network, no telemetry, no persistence beyond the engagement directory. Detection is local regex matching with no model in the loop. The proxy talks to exactly one upstream, the one you configured.
Nothing else in the request changes. The rewrite is surgical: only matched spans change, and every other byte including JSON key order is preserved, so Claude Code’s prompt caching is unaffected.
Scope boundaries
Three things sit outside the proxy entirely and are worth naming explicitly, because they look like they should be covered:
- Claude Code’s other traffic. Feature flags, token refresh, and the
WebFetchpreflight do not go throughANTHROPIC_BASE_URLat all. What configuration can close, the wizard closes. See Configuring Claude Code. - HTTP headers. Forwarded verbatim, never inspected. This is what makes credential passthrough work; it also means anything you put in a header is not redacted.
- Local files. The proxy inspects API traffic. It never reads or
writes anything in your working directory except the engagement
marker, and only the wizard touches
CLAUDE.mdand.claude/settings.local.json.
Reporting a leak
“This real value reached the model unredacted” is a bug in a detector. It goes in the public issue tracker, described by the shape of the value rather than the value itself. SECURITY.md lists what to report privately instead: findings where the proxy itself is the way in, such as code execution from a body it parses, an escape from the loopback bind, or another local process reading real values back out of it.
Known gaps
Detection is regex-based and only ever sees request and response bodies. These are the gaps that follow from that, written down so they are decisions you make rather than surprises you discover.
If you find one that isn’t here, please open an issue. See SECURITY.md for the smaller set of findings that go through private reporting instead.
Your folder name
The most important one, and the one redaction cannot fix.
Claude Code puts its working directory, and a project-memory directory
name derived from it, into the system field of every request. That
field is deliberately never scanned: it is Anthropic’s own prompt
boilerplate, and blanket-scanning it does more harm than good.
So a folder called acme-bank-pentest sends “acme bank” to the model on
every single request, bypassing every block and allow rule no matter how
thorough rules.json is. The redaction engine never sees that text.
There is no fix in the tool. There is nothing to tokenize against, because the path is not part of the body the proxy edits. Name engagement folders after an engagement code, never after the client.
redactproxy checks for this. If your working directory matches one of your own block entries, the wizard warns when you set up and the proxy warns at startup and on every rule reload:
level=WARN msg="current directory matches a block-list pattern and WILL be sent
to the upstream API unredacted..." cwd=/home/you/acme-bank-pentest
That check only fires when the folder name matches a rule you already added. It cannot catch a client name you never told it about.
Encoded data
A config file piped through base64, an xxd dump, Terraform state, a
gzipped blob: all of it goes straight through. Encoded text does not
look like a domain, an email, or a credential to a regex.
Decode it locally and look at it first. The CLAUDE.md note tells
Claude to decode to a file with Bash and read the file back, so the
decoded content gets a normal pass through redaction instead of arriving
unprotected in Claude’s own output. See
Configuring Claude Code.
Names and prose
Only recognizable shapes get detected. Client names, trading names,
product names, codenames, employee names in prose: none of them have a
shape. Add them with rules block, which is what the wizard’s first
prompt is for.
This is the gap with the widest blast radius after the folder name, because a company name appears in URL paths, ticket references, code comments, commit messages and file names, none of which any detector can recognize.
Bare apex domains on file-extension TLDs
.do, .ai, .rs, .sh, .py and friends are not detected as bare
apex domains, because main.rs and logo.ai are far more often
filenames, and a detector that fires on those costs more than one that
misses an edge case.
Add the client’s apex domain with rules block --domain when it sits on
one of those. Subdomains and URLs on such a TLD are still detected
normally; it is specifically the bare apex form that is excluded.
thinking blocks
Thinking content is signed by the API. The text and its signature together are a cryptographic proof the API validates on replay, so any edit invalidates it and the next request fails.
That means thinking blocks pass through unmodified in both directions. If a real value reaches the model some other way, the model can restate it in a thinking block, and that restatement is permanent for the life of the conversation. Start a new conversation if that happens.
HTTP headers
Forwarded verbatim, never inspected. This is what makes credential passthrough work, and it means anything carried in a header is not redacted.
Claude Code’s transcripts and your scrollback
Claude Code stores the real values you saw on screen, because detokenization happens before the response reaches it. Same for terminal scrollback. The proxy controls what reaches the provider, not what stays on your machine.
Handle those under the same rules as the rest of the engagement data.
Claude Code’s non-proxied traffic
Feature flags, token refresh, and several tools do not go through
ANTHROPIC_BASE_URL at all. The wizard’s settings hardening closes what
configuration can close: the Artifact tool (a confirmed leak path
publishing unredacted to claude.ai), Remote Control, and the WebFetch
preflight that sends target hostnames to Anthropic directly.
If you didn’t run the wizard, the proxy warns at startup and lists exactly what is missing. See Configuring Claude Code.
Over-redaction
The opposite failure, and mostly harmless. The proxy inspects text with
no notion of code structure, so something merely domain-shaped
(table.style, where .style is a real suffix) can get tokenized.
Operationally this costs nothing: the real value is substituted back
before anything executes, on every response rather than just the first,
so commands run correctly regardless. Fix it permanently with rules allow, and drop the mapping already minted with tokens remove.
Detection is best-effort, not a guarantee
The detector set covers the shapes that show up in pentest work and is tested against a realistic corpus (nmap, whois, dig, gobuster, Metasploit output) plus a false-positive trap corpus. It is not a proof of coverage.
Two things nobody has built yet, if you want the gaps closed: a local
NER model for prose-shaped PII, and more Windows/AD artifacts (UNC
paths, domain\username, LDAP DN components). See CONTRIBUTING.md.
Where rules do and don’t reach back
Rule changes only affect text tokenized from that point on. Adding a block rule does not scrub a value that already reached the model earlier in the same conversation. Start a fresh conversation if that matters.
Disabling a category likewise only gates future detection. Anything already tokenized keeps detokenizing correctly.
Debug logging
Warning
Every level above
offwrites real client values todebug.login plaintext. Leave it off unless you are actively debugging, never share the file, and delete it when you are done. Treat it exactly like the engagement data itself.
A log that showed only placeholders would be useless for the thing it exists for: working out why a real value was or was not detected. The safety has to come from handling instead.
Levels
--debug-level is off by default. Levels are cumulative: each one
includes everything the level before it logs.
| Level | Logs |
|---|---|
off (default) | nothing |
new | the real value the first time each one is seen |
replacements | every substitution, every time |
full | entire request/response bodies, real and tokenized |
redactproxy --debug-level new
Where it goes
debug.log in the engagement’s own directory, alongside tokens.db:
$HOME/.redactproxy/engagements/eng-2026-014/debug.log
That location is chosen deliberately. It defaults to $HOME, outside
any project working directory, so a Claude Code session browsing its own
folder cannot stumble into it.
If you point --data-dir somewhere inside the current working
directory and turn on debug logging, the proxy warns:
level=WARN msg="debug.log will contain real client values, and --data-dir
resolves inside the current working directory. If Claude Code runs from here,
it may be able to read this file"
The wizard also adds a permissions.deny rule for the file’s exact
path, so Claude Code will not read it via Read or via cat/head/
tail/sed in Bash. That rule is about stopping an accidental
context load, not about stopping you: loosen it, or point a separate
unrestricted session at the file, if you actually want a session to
analyze the log.
Rotation
full grows fast, because every logged request body is the whole
conversation history so far. The log is gzipped into a timestamped part
and restarted once it passes --debug-log-max-mb, 10 MiB by default:
redactproxy --debug-level full --debug-log-max-mb 50
Rotated parts are named debug.log.<when>.gz and sit in the same
directory. They contain the same real values the live log does, so they
need the same handling and the same deletion.
Choosing a level
newanswers “was this value ever detected at all?” It is the right level for a suspected miss, and by far the smallest.replacementsanswers “is this value being substituted consistently?” Use it when a mapping looks unstable.fullanswers “what exactly went over the wire?” Use it when reporting a suspected leak, and only for as long as it takes to reproduce.
Before reaching for any of them, try redactproxy tokens show or the
console’s show. It tells you what has been mapped without writing
anything new to disk.
Cleaning up
rm -f ~/.redactproxy/engagements/eng-2026-014/debug.log*
Deleting the whole engagement directory at the end of an engagement covers this too. See Engagements and storage.
Reporting a leak with it
If you are attaching log output to a report, redact the client values by hand first, or reproduce against synthetic data shaped like the real thing. Leak reports are public issues (see SECURITY.md), and a leak report should not itself be a leak.
Command line
redactproxy [flags] start the proxy
redactproxy wizard [flags] interactive engagement setup, start here
redactproxy rules <subcommand> show | validate | enable | disable | block | allow | remove
redactproxy tokens <subcommand> show | remove, against already-minted tokens
redactproxy memory [--write P] print (or append) the CLAUDE.md placeholder note
redactproxy version print the build this binary was made from
Every subcommand takes -h.
Important
Subcommands come before flags, and flags come before values. Go’s flag parser stops at the first non-flag argument, so
redactproxy --engagement foo rules showwould start a live proxy rather than run a read-only command, andrules block xyzcorp-fixture.internal --domainwould treat--domainas a second positional argument. Both are caught and explained rather than silently doing the wrong thing. The correct order isredactproxy rules block --domain xyzcorp-fixture.internal.
Shared flags
--engagement and --data-dir are accepted by the proxy, wizard, and
every rules and tokens subcommand.
| Flag | Default | Meaning |
|---|---|---|
--engagement | from .redactproxy-engagement | engagement name; remembered per-folder after the first explicit use |
--data-dir | $HOME/.redactproxy | base directory for engagement data |
redactproxy (start the proxy)
| Flag | Default | Meaning |
|---|---|---|
--listen | 127.0.0.1:8787 | address to listen on; loopback only |
--upstream | upstream.txt, else https://api.anthropic.com | the API base URL to proxy to |
--disable | none | comma-separated categories to disable for this run only |
--debug-level | off | off, new, replacements, full; see Debug logging |
--debug-log-max-mb | 10 | size at which debug.log is gzipped and restarted |
--max-body-mb | 64 | maximum request/response body this proxy will buffer |
--max-concurrent | 16 | maximum requests actively buffering a body at once |
--listen is validated as loopback at startup. 127.0.0.1, ::1 and
localhost are accepted; the bare :8787 shorthand is refused,
because it genuinely binds every interface.
--upstream must be absolute and use http or https. An explicit
value wins over the engagement’s persisted one for that run only.
--disable merges with rules.json’s own disabled list rather than
replacing it, and stays in effect across rule reloads for the life of
the process. For a persisted change use rules disable.
Ctrl-C or SIGTERM shuts down gracefully, with a 10 second window for
in-flight requests.
While it runs, the terminal is also a console: see Tokens and the live console.
redactproxy wizard
redactproxy wizard [--engagement NAME] [--data-dir DIR] [--listen ADDR]
Interactive setup. Collects customer names and domains, asks which API
provider the engagement talks to, offers to append the CLAUDE.md note,
and offers to write .claude/settings.local.json.
--listen here does not start anything. It is the address written into
the settings file, so it must match what you actually start the proxy
with.
With no --engagement and no folder marker, the wizard offers a picker
over existing engagements rather than erroring the way non-interactive
commands do. Typing a name that already exists asks for confirmation
first, since accidental reuse is how two clients end up in one token
store.
Safe to re-run at any time, including against a running engagement.
redactproxy rules
redactproxy rules show [--engagement NAME] [--data-dir DIR]
redactproxy rules validate [--engagement NAME] [--data-dir DIR]
redactproxy rules enable [flags] <category>
redactproxy rules disable [flags] <category>
redactproxy rules block [flags] [--regex] [--domain] [--note "..."] <value>
redactproxy rules allow [flags] [--regex] [--note "..."] <value>
redactproxy rules remove [flags] <value>
| Flag | Applies to | Meaning |
|---|---|---|
--regex | block, allow | treat the value as a regular expression |
--domain | block | value is a base domain; give it structure-preserving treatment everywhere it appears |
--note | block, allow | note explaining why the entry exists |
block matches case-insensitively as a substring. allow matches the
exact value only. An allow entry always overrides a block entry for the
same value, so adding a value already in the other list is refused.
enable/disable take a category (cloud.aws) or a bare category
prefix (cloud, which toggles every subcategory under it).
Changes reach a running proxy within about two seconds. Full detail in Rules.
redactproxy tokens
redactproxy tokens show [--engagement NAME] [--data-dir DIR]
redactproxy tokens remove [--engagement NAME] [--data-dir DIR] <real-value>
Operates on already-minted mappings. remove takes the real value,
not the placeholder, and only forgets that one mapping: it does not stop
future redaction.
Both need the proxy not running for that engagement, since only one
process can hold tokens.db open. Type show or remove <value> into
the running proxy’s own terminal instead. See
Tokens.
redactproxy memory
redactproxy memory # print the snippet
redactproxy memory --write ./CLAUDE.md # append it to a file
Prints the note explaining placeholder shapes to a Claude Code session.
--write appends, creating the file if needed, and is idempotent: a
file that already has the note is left unchanged.
Put it in the engagement’s project-level ./CLAUDE.md, not the
user-level ~/.claude/CLAUDE.md. See
Configuring Claude Code.
redactproxy version
redactproxy version
Prints the build the binary was made from, and works from any directory without an engagement being resolvable. Include it in bug reports.
Build targets
From a clone:
make build # bin/redactproxy for the current platform
make test # go test ./...
make race # go test -race -count=1 ./...
make vet # go vet ./...
make lint # golangci-lint (installed separately)
make check # vet + race, run this before calling anything done
make fuzz # exploratory fuzzing, 60s per target
make dist # cross-compiled binaries (linux/darwin/windows, amd64/arm64)
make install # build into $(go env GOPATH)/bin
Requires Go 1.26.6 or newer. See CONTRIBUTING.md to work on the code.
Detector categories
There are 44 detector categories. Each can be enabled or disabled per engagement:
redactproxy rules disable india_pii.pan # one subcategory
redactproxy rules disable india_pii # every subcategory under it
redactproxy rules enable india_pii.pan
The authoritative list for the build you are running is
redactproxy rules show, which prints exactly these descriptions from
the same source. This page is that list, grouped.
Warning
The four
allowlist.*categories work backwards from the rest. Disabling one makes more get redacted, not less. See The allowlist categories below.
Full list
ai_providers
| Category | Detects |
|---|---|
ai_providers.anthropic | Anthropic API keys. |
ai_providers.openai | OpenAI API keys. |
allowlist
| Category | Exempts from redaction |
|---|---|
allowlist.security_testing_services | Out-of-band/security-testing callback services (burpcollaborator.net, interact.sh, webhook.site, etc.); these are the tester’s OWN tooling, never the client’s. |
allowlist.third_party_saas | Specific third-party vendor products a client’s own site actively integrates with (razorpay.com, zoho.in, engati.ai, etc.); see wellknown.go’s thirdPartySaaSDomains for the full list. This is a weaker judgment call than the other allowlist categories, since seeing one of these does reveal something about the client’s tech stack, just not the client’s identity. It is its own toggleable category for exactly that reason. |
allowlist.web_infrastructure | Common third-party CDN/font/analytics/widget hosts (jsdelivr.net, googletagmanager.com, clarity.ms, recaptcha.net, etc.); these are embedded on almost any scanned website, client’s or not, and carry no client-identifying information in the hostname itself. See wellknown.go’s webInfrastructureDomains for the full list and what is deliberately excluded (customer-subdomain SaaS platforms like Zendesk and Okta, where the hostname itself is often the finding). |
allowlist.wellknown_platforms | Well-known public dev platforms (github.com, npmjs.com, pastebin.com, stackoverflow.com, etc.); see wellknown.go’s wellKnownPlatformDomains for the full list. |
cicd
| Category | Detects |
|---|---|
cicd.circleci | CircleCI API tokens. |
cicd.snyk | Snyk API tokens. |
cicd.terraform | Terraform Cloud/Enterprise API tokens. |
cicd.vault | HashiCorp Vault tokens. |
cloud
| Category | Detects |
|---|---|
cloud.artifactory | JFrog Artifactory API tokens. |
cloud.aws | AWS access key IDs (AKIA/ASIA-prefixed) and secret access keys (the 40-char value, when labeled by a nearby keyword like aws_secret_access_key). |
cloud.azure_storage_key | Azure Storage account keys. |
cloud.cloudflare | Cloudflare API tokens. |
cloud.digitalocean | DigitalOcean API tokens. |
cloud.dockerhub | Docker Hub access tokens. |
cloud.google_api_key | Google API keys. |
collab
| Category | Detects |
|---|---|
collab.slack_token | Slack API tokens. |
collab.slack_webhook | Slack incoming webhook URLs. |
comms
| Category | Detects |
|---|---|
comms.sendgrid | SendGrid API keys. |
comms.twilio | Twilio account SIDs. |
contact
| Category | Detects |
|---|---|
contact.email | Email addresses. |
contact.intl_phone | Non-NANP international phone numbers. |
contact.phone | NANP-shaped (US/Canada) phone numbers. |
india_pii
| Category | Detects |
|---|---|
india_pii.aadhaar | Indian Aadhaar numbers (12-digit, Verhoeff-checksum validated). |
india_pii.pan | Indian PAN numbers (Permanent Account Number, 10-character alphanumeric). |
network
| Category | Detects |
|---|---|
network.domain | Domain names and hostnames, bare or embedded in a URL. |
network.ipv4 | IPv4 addresses. |
network.ipv6 | IPv6 addresses. |
network.mac | MAC addresses. |
packages
| Category | Detects |
|---|---|
packages.npm | npm access tokens. |
payments
| Category | Detects |
|---|---|
payments.razorpay | Razorpay API keys. |
payments.stripe | Stripe API keys. |
secrets
| Category | Detects |
|---|---|
secrets.bearer_token | Opaque Bearer/API tokens following an Authorization header. |
secrets.connection_string | Database/service connection strings with embedded credentials. |
secrets.itsdangerous_token | Flask itsdangerous-signed tokens (session/CSRF tokens). |
secrets.jwt | JSON Web Tokens (JWTs). |
secrets.password_hash | Password hashes (MD5/NTLM/SHA-1/SHA-256), only when labeled by a nearby keyword, or in an Impacket secretsdump-style LM:NT pair. |
secrets.pem_key | PEM-armored private key blocks. |
vcs
| Category | Detects |
|---|---|
vcs.bitbucket | Bitbucket app passwords/tokens. |
vcs.github | GitHub personal access / OAuth tokens. |
vcs.gitlab | GitLab personal access tokens. |
windows_ad
| Category | Detects |
|---|---|
windows_ad.gpp_cpassword | Group Policy Preferences cpassword values, trivially decryptable via Microsoft’s published MS14-025 AES key. |
windows_ad.machine_account | $-suffixed Active Directory machine/computer account names (e.g. WORKSTATION01$) in a secretsdump.py/pwdump line. |
The allowlist categories
These four are exceptions, not detectors. They stop values from being redacted, so disabling one means more gets redacted, not less.
They exist because it is genuinely useful for Claude to recognize
infrastructure that carries no client identity: that a domain is GitHub
and it can therefore use the gh CLI, that a hostname is Google Tag
Manager rather than client infrastructure, that a callback domain is
Burp Collaborator and therefore your own tooling, likely an SSRF or RCE
test. Tokenize those and Claude loses context it would otherwise have
for free.
Most engagements should leave all four enabled. The one worth
considering individually is allowlist.third_party_saas, since a vendor
domain does fingerprint the client’s tech stack even though it does not
name the client. Disable that one alone on an engagement where even
that should stay hidden:
redactproxy rules disable allowlist.third_party_saas
rules show flags all four with a warning, and the proxy logs a
distinct message when one is disabled, so the inverted meaning is never
silent.
What is deliberately not detected
Some things are excluded on purpose, because a detector that fires on innocent text costs more than one that misses an edge case:
- Bare apex domains on file-extension TLDs (
.do,.ai,.rs,.sh,.py).main.rsandlogo.aiare far more often filenames. Userules block --domainfor these. - Reserved and documentation values.
example.com, RFC 5737 documentation IP ranges and similar are excluded, since they are not client data and tokenizing them just adds noise. - Names and prose, which have no shape at all. Use
rules block.
See Known gaps for the full picture.
Adding a detector
New detectors are welcome, particularly for prose-shaped PII and for Windows/AD artifacts. See CONTRIBUTING.md’s “Adding a detector”.
Placeholder shapes
Every placeholder is designed so it can never be mistaken for, or collide with, a real value. Where the structure of a value carries context that is useful but not identifying, that structure is preserved.
This is the same material redactproxy memory puts into an engagement’s
CLAUDE.md, in more detail and written for you rather than for Claude.
If you are wondering whether a strange-looking string in a session is a
placeholder, this is the page.
Structure-preserving shapes
These keep part of the real value, because that part is useful to reason with and does not identify anyone.
| Value | Placeholder | What survives |
|---|---|---|
| Domain | tok<16 hex>.<real suffix> | the real public suffix, and any subdomain |
user-<12 hex>@<domain token> | the shape, and which org the domain belongs to | |
| IPv4 | 198.18.<n>.<real host octet> | the host octet, so hosts stay distinguishable |
| IPv6 | fd00:c0de:<32 bits>:<real interface ID> | the 64-bit interface ID |
| NANP phone | <area code>-555-01<2 digits> | that it is a NANP number |
| International phone | <real country code> 555-<4 digits> | the real country calling code |
A worked example:
mail.xyzcorp-fixture.internal -> mail.tok5198ede8bdbb1ada.internal
rahul.menon@xyzcorp-... -> user-428791a054d2@tok5198ede8bdbb1ada.internal
10.42.7.19 -> 198.18.0.19
The subdomain, the shared org token across hostname and email, and the host octet all survive. That is what lets Claude reason about relationships between hosts without ever seeing whose they are.
Why these ranges
198.18.0.0/15is RFC 2544 benchmarking space, reserved for network interconnect device testing and never publicly routed. It is used deliberately instead of an RFC 1918 range, because internal engagements routinely target real10.x,172.16-31.xand192.168.xaddresses, and the token space must not overlap real targets.fd00::/8is RFC 4193 Unique Local Address space, never a real global address.555-01XXis the NANP exchange reserved for fictional use under every area code, the same convention film and TV rely on. Placeholder numbers draw from a spread of real geographic area codes, so the token space is large enough for an engagement cataloging hundreds of extensions.
Important
A placeholder IP is not test data. The range is reserved because that is what makes it unmistakable, not because the address behind it is any less real or less sensitive. A finding on
198.18.7.19is a finding on a real external target.The shape invites the opposite inference, which is why the
redactproxy memorynote tells Claude explicitly not to discount or deprioritize one. A report that reaches you with placeholders still in it describes real targets, not lab hosts.
Credential shapes
These keep the vendor prefix, so the type of credential stays recognizable, and replace everything after it. Knowing you found an AWS key is useful; knowing which one is not.
| Value | Placeholder prefix |
|---|---|
| AWS access key ID | AKIAFAKE |
| AWS secret access key | tok-aws-secret- |
| GitHub token | ghp_FAKE |
| GitLab token | glpat-FAKE |
| Bitbucket | ATBBFAKE |
| Slack token | xoxb-9999999999-9999999999-FAKE |
| Slack webhook | REDACTED-WEBHOOK- |
| Stripe | sk_live_FAKE |
| Razorpay | rzp_live_FAKE |
| Google API key | AIzaSyFAKE |
| npm | npm_FAKE |
| DigitalOcean | dop_v1_FAKE |
| Cloudflare | cfat_FAKE |
| Azure storage key | REDACTED-AZUREKEY- |
| Artifactory | AKCpFAKE |
| Docker Hub | dckr_pat_FAKE |
| CircleCI | CCIPAT_FAKE |
| Terraform | FAKE00000000.atlasv1.FAKE |
| Snyk | deadfake-dead-fake-snyk- |
| Vault | hvs.FAKE |
| Twilio SID | ACFAKE |
| SendGrid | SG.FAKE |
| OpenAI | sk-FAKE |
| Anthropic | sk-ant-api03-FAKE |
| JWT | eyJredacted... |
| Opaque bearer token | tok-bearer- |
| Connection string | REDACTED-CREDS- |
| PEM private key | -----BEGIN REDACTED PRIVATE KEY----- |
FAKE is the anchor: K is not a valid hex digit, so FAKE can never
appear inside a real random-hex secret. AWS
in particular never allocates FAKE as the four characters after
AKIA, so the placeholder is structurally non-issuable rather than just
visually distinct.
Other shapes
| Value | Placeholder | Why it cannot be real |
|---|---|---|
| MAC address | 02:00:00:xx:xx:xx | 02 sets IEEE 802’s locally-administered bit, so it can never be a real vendor OUI |
| Password hash | FAKEHASH<hex> | K, H and S are not hex digits |
| Aadhaar | 0000 <4 digits> <4 digits> | UIDAI never issues a number starting with 0 or 1 |
| PAN | FAKEP<4 digits><letter> | E is not a valid holder-type code in position 4 |
| AD machine account | FAKEHOST<hex>$ | keeps the $ suffix that makes it recognizable |
| GPP cpassword | FAKE-CPASSWORD-<hex> | contains -, which is not in the base64 alphabet, so it can never decrypt |
| Custom block entry | tok-blocked-<hex> | opaque by design |
| itsdangerous token | tok-signed-<hex> | opaque by design |
Properties worth relying on
Stable. The same real value always maps to the same placeholder for the life of the engagement.
Random, not derived. Placeholders come from crypto/rand. There is
nothing in one to reverse, and no information about the value it stands
for. This is also why tokens remove followed by re-encountering the
value produces a different placeholder.
Only safe copied verbatim. A placeholder has to be reproduced
character for character, from its most recent literal appearance. One
retyped from memory, fragmented into a bare subdomain plus suffix, or
swapped for a hand-typed <angle bracket> stand-in silently breaks
something downstream: an Edit’s old_string stops matching the real
file, or a report line traces back to nothing. The same applies to
placeholders you copy by hand.
An unfamiliar placeholder is not a bug. It is either a token doing
its job, or an over-redaction of something merely domain-shaped
(table.style, where .style is a real suffix). Either way the real
value is substituted before execution, on every response rather than
just the first, so the command runs correctly regardless. The real
filesystem is ground truth here, since the proxy only ever inspects API
traffic, which is why the memory note points Claude at Bash and Read
rather than at reasoning around the token.
Capacity limits
Two placeholder spaces are counter-allocated rather than random, and one of them is finite:
- IPv4 networks: 256 per engagement. Each distinct real /24 gets one. An engagement touching more than 256 distinct /24 networks would be unusual, but if it happens the request that hits the 257th fails closed rather than reusing a placeholder.
- IPv6 networks: about 4.3 billion per engagement. No realistic exhaustion risk.
Everything else is drawn at random from a space large enough that collisions are handled by retry rather than being a design concern.
rules.json
One per engagement, at
$HOME/.redactproxy/engagements/<name>/rules.json. It holds which
detector categories are enabled and any custom block and allow entries.
Hand-editing is supported and the file is written to be
self-documenting: every known category appears in it with its
description and, where relevant, its warning. Run
redactproxy rules validate afterwards.
Shape
{
"categories": {
"cloud.aws": {
"enabled": true,
"description": "AWS access key IDs (AKIA/ASIA-prefixed) and secret access keys ..."
},
"allowlist.wellknown_platforms": {
"enabled": true,
"description": "Exempts well-known public dev platforms (github.com, ...)",
"warning": "Disabling this makes MORE get redacted, not less: ..."
}
},
"block": [
{
"value": "XYZCorp",
"regex": false,
"note": "wizard 2026-09-05, customer name"
},
{
"value": "xyzcorp-fixture.internal",
"regex": false,
"note": "wizard 2026-09-05, domain",
"is_domain": true
}
],
"allow": [
{
"value": "mylab.internal",
"regex": false,
"note": "our own lab"
}
]
}
categories
A map of category name to state.
| Field | Type | Meaning |
|---|---|---|
enabled | bool | whether the detector runs |
description | string | maintained by redactproxy; edits are overwritten |
warning | string | present on the inverted allowlist.* categories |
The map is reconciled against the running build every time redactproxy writes the file: missing categories are added with their defaults, and names the build does not recognize are dropped, with a note on stderr:
note: rules.json had a category "Cloud.AWS" this build doesn't recognize, so it
was dropped. If it was meant to match an existing category, check for a typo
(case matters) ...
Case matters. That note exists because a silently-reverted typo on a file whose whole premise is “safe to hand-edit” is exactly the trap worth flagging.
rules validate warns about unrecognized names without writing
anything, which makes it the safe way to check an edit before it takes
effect.
block and allow
Arrays of entries.
| Field | Type | Default | Meaning |
|---|---|---|---|
value | string | required | the literal string or regex |
regex | bool | false | treat value as a regular expression |
note | string | omitted | free text explaining why the entry exists |
is_domain | bool | omitted | block entries only; give the value structure-preserving domain treatment |
Block entries match case-insensitively as a substring. Allow entries match the exact value only, case-insensitively.
An allow entry always overrides a block entry for the same value, with
no exception. The CLI refuses to create such a pair; rules show flags
one left over from a hand edit:
Block:
- [string] "mylab.internal" ⚠ has NO effect: an Allow entry for the same value always overrides Block
is_domain
{"value": "xyzcorp-fixture.internal", "regex": false, "is_domain": true}
Equivalent to rules block --domain. The value is treated as a real
domain and tokenized with structure preserved, everywhere it appears:
bare, as a subdomain, inside an email address, or in a URL. Without it,
the entry is opaque text matched literally.
Give the base domain, no www. and no subdomain.
A hand-edited file gets no interactive warning, so a nonsensical
is_domain (set on a URL by accident, say) is handled quietly rather
than breaking anything: it falls back to an ordinary literal block
value, and redactproxy logs a one-time warning at startup or reload:
level=WARN msg="block entry marked \"is_domain\" doesn't look like a real
domain; treating it as an ordinary literal block value instead" value=...
Regex entries
{"value": "10\\.42\\.\\d+\\.\\d+", "regex": true, "note": "customer VPN range"}
Go’s regexp syntax (RE2). Remember JSON string escaping: a regex
backslash is written \\.
An invalid pattern fails rules validate, and on a live reload the
proxy logs the error and keeps the previous rules active rather than
starting to send unredacted traffic.
Concurrency
Every command that modifies the file does the whole load-modify-save
cycle under a lock, so two concurrent rules commands cannot silently
discard each other’s change. A running proxy takes the same lock when it
reconciles categories at startup.
rules.json is not held open the way tokens.db is. It is polled every
two seconds, so edits from any source reach a running proxy within a
couple of seconds with no restart.
What is not in this file
- Minted mappings live in
tokens.db. See Tokens. - The upstream provider lives in
upstream.txt, one bare URL and never a credential. See Other API providers. - Claude Code settings live in the working folder’s
.claude/settings.local.json. See Configuring Claude Code.
Troubleshooting
Each heading below is a symptom. Find the one that matches what you are seeing and work from there.
redactproxy: command not found
$GOPATH/bin is not on your PATH. Either add it:
export PATH="$PATH:$(go env GOPATH)/bin"
or build locally and use the path directly:
make build
./bin/redactproxy -h
no --engagement given, and no .redactproxy-engagement marker found
You are in a folder that has never been set up. There is deliberately no default engagement name, because a shared default is how two clients end up in one token store.
redactproxy wizard --engagement eng-2026-014 # or
redactproxy --engagement eng-2026-014 # remembered from now on
invalid engagement name
Names are 1 to 64 characters of letters, digits, underscore and hyphen. No dots and no slashes, because the name becomes a directory name.
If a hand-edited .redactproxy-engagement file caused this, fix or
delete that file.
another redactproxy process is already running for this engagement
Only one process can hold tokens.db open. Either a proxy is already up
for this engagement, or one exited without releasing the lock.
pgrep -af redactproxy
To run two engagements at once, give each its own --listen address.
See Engagements and storage.
tokens show / tokens remove fails with a lock error
Same lock, and expected while the proxy is running. Type the command into the proxy’s own terminal instead:
show
remove xyzcorp-fixture.internal
Stopping the proxy would drop whatever Claude Code request is in flight. See Tokens and the live console.
--listen is not a loopback address; refusing to bind
Only 127.0.0.1, ::1 and localhost are accepted. The bare :8787
shorthand is refused on purpose: it genuinely binds every interface, and
this proxy handles real client data in transit.
Claude Code isn’t going through the proxy
The proxy prints no traffic and Claude Code behaves normally. Check, in order:
- Is the folder’s settings file pointing at the right port?
It must match the address the proxy actually bound, including a non-defaultgrep ANTHROPIC_BASE_URL .claude/settings.local.json--listen. - Is
ANTHROPIC_BASE_URLset in the shell to something else? A shell export overrides nothing here, but a stale one from another engagement points you at the wrong proxy.echo $ANTHROPIC_BASE_URL. - Did you start
claudefrom the same folder? The settings file is per-folder.
Re-running redactproxy wizard --listen <addr> in the folder fixes a
port mismatch.
The proxy warns that this folder isn’t hardened
WARNING: this folder's Claude Code isn't fully hardened against channels that
bypass this proxy entirely...
Settings the wizard normally writes are missing, usually because you set
ANTHROPIC_BASE_URL by hand or copied an older settings file. The
warning lists exactly what is absent. Run redactproxy wizard in the
folder to fix it, or add them by hand from
Configuring Claude Code.
The proxy warns that my folder name will leak
level=WARN msg="current directory matches a block-list pattern and WILL be sent
to the upstream API unredacted..."
Your working directory is named after the client. Claude Code puts its working directory into a part of every request the proxy deliberately never scans, so that name reaches the model on every request regardless of your rules.
There is no fix in the tool. Rename the folder to an engagement code and work from there. This is the most important entry in Known gaps.
Claude keeps “correcting” the placeholder values
The CLAUDE.md note is missing, so the session has no idea what those
strings are.
redactproxy memory --write ./CLAUDE.md
Then start a fresh session. See the CLAUDE.md note.
A real value reached the model unredacted
First establish which kind of gap it is:
- No detectable shape (a client name, a codename, a project name):
expected. Add it with
rules block. - Encoded (base64, hex, an
xxddump): expected. Decode locally first. - A bare apex domain on a
.do/.ai/.rs/.shTLD: expected. Userules block --domain. - Your folder name: see above.
- Anything else: this is a bug. Open an issue, and describe the shape of the value rather than the value itself.
Then, regardless: a value that already reached the model stays in that
conversation, and can be restated permanently in a thinking block that
cannot be rewritten. Start a new conversation.
Something innocent is being redacted
Over-redaction. The proxy inspects text with no notion of code
structure, so something merely domain-shaped (table.style) can get
tokenized.
It costs nothing operationally, since the real value is substituted back before anything executes. To stop it:
redactproxy rules allow "table.style" # stop redacting it
redactproxy tokens remove "table.style" # drop the mapping already minted
A rule I added isn’t doing anything
Three usual causes:
- It only applies going forward. A value that already reached the model earlier in the same conversation is not scrubbed retroactively. Start a fresh conversation.
- An allow entry is shadowing it. An allow entry always overrides a
block entry for the same value.
rules showflags the pair. - The file didn’t reload. Check the proxy’s output for
rules.json reload failed; keeping previous rules active, then runredactproxy rules validate.
rules.json is invalid on startup
The proxy refuses to start on a broken ruleset rather than running with less protection than you configured.
redactproxy rules validate
It names the bad entry. A common cause is an invalid regex; remember
that a backslash in a JSON string is written \\.
A category I disabled came back, or one I added disappeared
Category names are reconciled against the running build every time the
file is written, and names it does not recognize are dropped with a note
on stderr. Case matters: Cloud.AWS is not cloud.aws.
Run redactproxy rules show for the exact current names.
Disabling an allowlist.* category made things worse
Working as intended, and the reason those four carry a warning. Disabling an allowlist category makes more get redacted, not less, so Claude starts seeing tokens where it used to recognize GitHub or a CDN host. Re-enable it:
redactproxy rules enable allowlist.wellknown_platforms
The first big scan is slow
Expected. A body that mints hundreds of new values at once, a
full-subnet nmap being the obvious case, pays one committed write per
value, which is what makes the store crash-safe. Sending the same output
again costs nothing, because scan results are cached by content hash.
A request failed instead of going through
By design. Anything on the request path that cannot finish redacting returns an error rather than forwarding bytes. An unredacted forward is the one outcome this project treats as worse than a broken request.
Check the proxy’s output for the specific reason. A body over
--max-body-mb (64 MiB by default) is a common one.
--upstream must be an absolute URL / must use http or https
Caught at startup rather than surfacing later as a generic provider
failure. Check upstream.txt for the engagement, or the --upstream
value you passed. A REPLACE-ME.example.com here means the wizard wrote
a manual-provider skeleton you have not filled in yet; see
Other API providers.
Increasing verbosity
redactproxy --debug-level new
Warning
Every level above
offwrites real client values todebug.login plaintext. Read Debug logging before turning it on, and delete the file afterwards.
Try redactproxy tokens show first. It answers “what has the model
seen?” without writing anything new to disk.
Reporting a bug
Include redactproxy version. Bug reports, including suspected
redaction leaks, go to the issue
tracker, and should
not themselves contain client data.
SECURITY.md
lists what to report privately instead.