Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-pentest sends “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

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 .19 survives; 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 nginx version banner, the latency, the port, and the Dell OUI comment are untouched. They aren’t client-identifying.
  • github.com is untouched, because it’s on the built-in allowlist.
  • xyzcorp in 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.uk becomes tok<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 system field. It is Claude Code’s own prompt boilerplate, and this is where the working-directory leak in Known gaps comes from.
  • thinking and redacted_thinking blocks. 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 .env piped through base64 doesn’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 .style is a real suffix) can occasionally get tokenized. This is harmless in operation, since the real value is substituted back before anything executes, and rules allow fixes 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.db maps real client values to placeholders in plaintext, and debug.log contains 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 show and tokens remove cannot 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). You enable or disable it.
  • A value is a literal string or regex you add yourself. You block or allow it.

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, block and allow refuse to add a value already covered by the other list, and rules show flags 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:

CategoryExempts
allowlist.wellknown_platformsgithub.com, npmjs.com, stackoverflow.com and similar
allowlist.web_infrastructureCDN, font, analytics and widget hosts
allowlist.security_testing_servicesBurp Collaborator, interact.sh, webhook.site and similar
allowlist.third_party_saasspecific 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 allow instead, 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/allow are the simplified form. Everything after the verb is one literal value, so rules block XYZ Another Corp works without quoting, but there is no --regex, --domain or --note. Use the standalone CLI in a second terminal for those.
  • rules commands would work from another terminal anyway, since rules.json is 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 from rules.json or --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. Run redactproxy rules validate to 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 set is_domain on 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’s old_string won’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/Read rather 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 Bash and 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:

SettingValue
Base URLhttps://api.z.ai/api/anthropic
AuthANTHROPIC_AUTH_TOKEN (Bearer)
Main modelglm-5.2
Background/haiku modelglm-4.7
API_TIMEOUT_MS3000000
CLAUDE_CODE_AUTO_COMPACT_WINDOW1000000

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.txt gets https://REPLACE-ME.example.com
  • .claude/settings.local.json gets REPLACE-ME env 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:

CategoryExamples
Client identitycompany names, trading names, product names, codenames
Network identitydomains, hostnames, IPv4/IPv6 addresses, MAC addresses
Peopleemail addresses, phone numbers
CredentialsAPI keys for 25+ vendors, JWTs, bearer tokens, connection strings, private keys, password hashes
Regional PIIIndian Aadhaar and PAN numbers
AD artifactsmachine 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

  1. The host is trusted. Real values live on it, in tokens.db and in Claude Code’s own transcripts. Compromise of the host defeats everything here.
  2. Loopback is trusted. The proxy binds loopback only and refuses to start on any other address, including the bare :8787 shorthand. Any local process that can reach the port can send it traffic.
  3. The upstream connection is TLS. The proxy terminates TLS to the upstream. A vulnerability in Go’s net/http or crypto/tls is treated as a finding about this tool, which is why go.mod pins a patch-level Go version and CI runs govulncheck.
  4. 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 WebFetch preflight do not go through ANTHROPIC_BASE_URL at 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.md and .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 off writes real client values to debug.log in 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.

LevelLogs
off (default)nothing
newthe real value the first time each one is seen
replacementsevery substitution, every time
fullentire 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

  • new answers “was this value ever detected at all?” It is the right level for a suspected miss, and by far the smallest.
  • replacements answers “is this value being substituted consistently?” Use it when a mapping looks unstable.
  • full answers “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 show would start a live proxy rather than run a read-only command, and rules block xyzcorp-fixture.internal --domain would treat --domain as a second positional argument. Both are caught and explained rather than silently doing the wrong thing. The correct order is redactproxy rules block --domain xyzcorp-fixture.internal.

Shared flags

--engagement and --data-dir are accepted by the proxy, wizard, and every rules and tokens subcommand.

FlagDefaultMeaning
--engagementfrom .redactproxy-engagementengagement name; remembered per-folder after the first explicit use
--data-dir$HOME/.redactproxybase directory for engagement data

redactproxy (start the proxy)

FlagDefaultMeaning
--listen127.0.0.1:8787address to listen on; loopback only
--upstreamupstream.txt, else https://api.anthropic.comthe API base URL to proxy to
--disablenonecomma-separated categories to disable for this run only
--debug-leveloffoff, new, replacements, full; see Debug logging
--debug-log-max-mb10size at which debug.log is gzipped and restarted
--max-body-mb64maximum request/response body this proxy will buffer
--max-concurrent16maximum 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>
FlagApplies toMeaning
--regexblock, allowtreat the value as a regular expression
--domainblockvalue is a base domain; give it structure-preserving treatment everywhere it appears
--noteblock, allownote 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

CategoryDetects
ai_providers.anthropicAnthropic API keys.
ai_providers.openaiOpenAI API keys.

allowlist

CategoryExempts from redaction
allowlist.security_testing_servicesOut-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_saasSpecific 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_infrastructureCommon 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_platformsWell-known public dev platforms (github.com, npmjs.com, pastebin.com, stackoverflow.com, etc.); see wellknown.go’s wellKnownPlatformDomains for the full list.

cicd

CategoryDetects
cicd.circleciCircleCI API tokens.
cicd.snykSnyk API tokens.
cicd.terraformTerraform Cloud/Enterprise API tokens.
cicd.vaultHashiCorp Vault tokens.

cloud

CategoryDetects
cloud.artifactoryJFrog Artifactory API tokens.
cloud.awsAWS 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_keyAzure Storage account keys.
cloud.cloudflareCloudflare API tokens.
cloud.digitaloceanDigitalOcean API tokens.
cloud.dockerhubDocker Hub access tokens.
cloud.google_api_keyGoogle API keys.

collab

CategoryDetects
collab.slack_tokenSlack API tokens.
collab.slack_webhookSlack incoming webhook URLs.

comms

CategoryDetects
comms.sendgridSendGrid API keys.
comms.twilioTwilio account SIDs.

contact

CategoryDetects
contact.emailEmail addresses.
contact.intl_phoneNon-NANP international phone numbers.
contact.phoneNANP-shaped (US/Canada) phone numbers.

india_pii

CategoryDetects
india_pii.aadhaarIndian Aadhaar numbers (12-digit, Verhoeff-checksum validated).
india_pii.panIndian PAN numbers (Permanent Account Number, 10-character alphanumeric).

network

CategoryDetects
network.domainDomain names and hostnames, bare or embedded in a URL.
network.ipv4IPv4 addresses.
network.ipv6IPv6 addresses.
network.macMAC addresses.

packages

CategoryDetects
packages.npmnpm access tokens.

payments

CategoryDetects
payments.razorpayRazorpay API keys.
payments.stripeStripe API keys.

secrets

CategoryDetects
secrets.bearer_tokenOpaque Bearer/API tokens following an Authorization header.
secrets.connection_stringDatabase/service connection strings with embedded credentials.
secrets.itsdangerous_tokenFlask itsdangerous-signed tokens (session/CSRF tokens).
secrets.jwtJSON Web Tokens (JWTs).
secrets.password_hashPassword 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_keyPEM-armored private key blocks.

vcs

CategoryDetects
vcs.bitbucketBitbucket app passwords/tokens.
vcs.githubGitHub personal access / OAuth tokens.
vcs.gitlabGitLab personal access tokens.

windows_ad

CategoryDetects
windows_ad.gpp_cpasswordGroup 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.rs and logo.ai are far more often filenames. Use rules block --domain for 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.

ValuePlaceholderWhat survives
Domaintok<16 hex>.<real suffix>the real public suffix, and any subdomain
Emailuser-<12 hex>@<domain token>the shape, and which org the domain belongs to
IPv4198.18.<n>.<real host octet>the host octet, so hosts stay distinguishable
IPv6fd00: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/15 is 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 real 10.x, 172.16-31.x and 192.168.x addresses, and the token space must not overlap real targets.
  • fd00::/8 is RFC 4193 Unique Local Address space, never a real global address.
  • 555-01XX is 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.19 is a finding on a real external target.

The shape invites the opposite inference, which is why the redactproxy memory note 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.

ValuePlaceholder prefix
AWS access key IDAKIAFAKE
AWS secret access keytok-aws-secret-
GitHub tokenghp_FAKE
GitLab tokenglpat-FAKE
BitbucketATBBFAKE
Slack tokenxoxb-9999999999-9999999999-FAKE
Slack webhookREDACTED-WEBHOOK-
Stripesk_live_FAKE
Razorpayrzp_live_FAKE
Google API keyAIzaSyFAKE
npmnpm_FAKE
DigitalOceandop_v1_FAKE
Cloudflarecfat_FAKE
Azure storage keyREDACTED-AZUREKEY-
ArtifactoryAKCpFAKE
Docker Hubdckr_pat_FAKE
CircleCICCIPAT_FAKE
TerraformFAKE00000000.atlasv1.FAKE
Snykdeadfake-dead-fake-snyk-
Vaulthvs.FAKE
Twilio SIDACFAKE
SendGridSG.FAKE
OpenAIsk-FAKE
Anthropicsk-ant-api03-FAKE
JWTeyJredacted...
Opaque bearer tokentok-bearer-
Connection stringREDACTED-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

ValuePlaceholderWhy it cannot be real
MAC address02:00:00:xx:xx:xx02 sets IEEE 802’s locally-administered bit, so it can never be a real vendor OUI
Password hashFAKEHASH<hex>K, H and S are not hex digits
Aadhaar0000 <4 digits> <4 digits>UIDAI never issues a number starting with 0 or 1
PANFAKEP<4 digits><letter>E is not a valid holder-type code in position 4
AD machine accountFAKEHOST<hex>$keeps the $ suffix that makes it recognizable
GPP cpasswordFAKE-CPASSWORD-<hex>contains -, which is not in the base64 alphabet, so it can never decrypt
Custom block entrytok-blocked-<hex>opaque by design
itsdangerous tokentok-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.

FieldTypeMeaning
enabledboolwhether the detector runs
descriptionstringmaintained by redactproxy; edits are overwritten
warningstringpresent 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.

FieldTypeDefaultMeaning
valuestringrequiredthe literal string or regex
regexboolfalsetreat value as a regular expression
notestringomittedfree text explaining why the entry exists
is_domainboolomittedblock 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:

  1. Is the folder’s settings file pointing at the right port?
    grep ANTHROPIC_BASE_URL .claude/settings.local.json
    
    It must match the address the proxy actually bound, including a non-default --listen.
  2. Is ANTHROPIC_BASE_URL set 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.
  3. Did you start claude from 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 xxd dump): expected. Decode locally first.
  • A bare apex domain on a .do/.ai/.rs/.sh TLD: expected. Use rules 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:

  1. 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.
  2. An allow entry is shadowing it. An allow entry always overrides a block entry for the same value. rules show flags the pair.
  3. The file didn’t reload. Check the proxy’s output for rules.json reload failed; keeping previous rules active, then run redactproxy 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

See The allowlist categories.

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 off writes real client values to debug.log in 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.