Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "claude-code-redact",
"description": "A Claude Code mod that redacts secrets and PII before Claude reads them and before the transcript stores them.",
"owner": {
"name": "Karan Bansal",
"url": "https://github.com/karanb192"
},
"plugins": [
{
"name": "redact",
"source": "./plugins/redact",
"description": "Redacts secrets and PII from every row Claude Code stores: prompts, file reads, shell output, MCP results, attachments. Placeholders are restored only inside Edit and Write arguments, so file edits still work. Reach L0: no network, no processes, no files.",
"category": "security",
"tags": ["mods", "security", "secrets", "pii", "redaction", "dlp"]
}
]
}
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: test
on:
push:
branches: [main]
pull_request:
jobs:
plugin:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g @anthropic-ai/claude-code
- run: claude --version
- run: claude plugin validate plugins/redact --strict
- run: claude plugin test plugins/redact
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.DS_Store
plugins/*/.claude-plugin/types/
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Karan Bansal

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
31 changes: 31 additions & 0 deletions plugins/redact/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "redact",
"version": "0.1.0",
"description": "Redacts secrets and PII from every row Claude Code stores, before the model reads it and before the transcript keeps it. Reach L0: no network, no processes, no files.",
"author": { "name": "Karan Bansal", "url": "https://github.com/karanb192" },
"homepage": "https://redact.aidojo.si",
"repository": "https://github.com/karanb192/claude-code-redact",
"license": "MIT",
"keywords": ["mods", "security", "secrets", "pii", "redaction", "dlp"],
"types": "./types/index.d.ts",
"userConfig": {
"pii": {
"type": "boolean",
"title": "Redact PII",
"description": "Also hide email addresses, phone numbers, payment cards, SSNs, Aadhaar, PAN and IBAN. Secrets are always hidden.",
"default": false
},
"quiet": {
"type": "boolean",
"title": "Quiet",
"description": "Hide the transcript line that reports what was redacted. /redact still shows the session total.",
"default": false
},
"off": {
"type": "string",
"title": "Rules off",
"description": "Comma-separated rule ids to skip, for example generic-assignment,email. Ids are listed in RULES.md.",
"default": ""
}
}
}
2 changes: 2 additions & 0 deletions plugins/redact/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.claude-plugin/types/
node_modules/
20 changes: 20 additions & 0 deletions plugins/redact/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Decisions: redact

## Decisions
- Redact at `session.append`, the one event that sees every row before it is stored and sent (prompts, tool results, Claude's own blocks, attachments, notices). Rejected: rewriting results in `tool.call` after `next(e)`, because it misses prompts, attachments and plugin notes, and it leaves the transcript file raw.
- Also redact at `prompt.submit`. Rejected: relying on `session.append` alone, because the engine writes a `queue-operation` record and a command's `args` stamp from the typed text, and those are stored as made. `prompt.submit` cleans the text the screen echoes and the command args; the headless `-p` path still writes the queue record first (see Assumptions).
- Restore real values only inside `Edit`, `Write` and `NotebookEdit` arguments. Rejected: restoring in `Bash`, because the model could echo a placeholder into a network command and the real value would leave the machine.
- Placeholders are `[REDACTED:LABEL#hash]` with an FNV-1a hash of a per-session salt plus the value. Rejected: HMAC with `node:crypto`, because the validator admits no bare import other than `claude-code`. Rejected: a counter, because the same value must map to the same placeholder across rows so edits and compaction summaries line up.
- Fail closed. A row the scanner throws on is stored as `[redact withheld this content ...]` with its `tool_use_id` kept. Rejected: fail open, because a redactor that stores raw on error is a redactor that stops working silently.
- PII rules are opt-in (`pii: true`). Rejected: on by default, because email addresses and phone numbers in code and docs are common and mostly harmless, and a redactor that hides a package author's email on every read gets switched off.
- One standing note in the `env_info_simple` section tells Claude the placeholders are opaque. Rejected: no note, because Claude otherwise asks the person for the hidden value or tries to "fix" the placeholder. Rejected: `prompt.compose`, because adding a section is more surface than appending to one that is always rendered.
- The vault lives in `$.state`, not `$.store`. Rejected: `$.store`, because real values would persist across sessions on disk.
- No `turn.complete` line and no `$.ui.notice`. Rejected: a per-turn summary, because the per-row `$.ui.log` line already says what was hidden and where; `$.ui.notice` is removed when the tool call resolves, which is before the result row is appended.
- The scrub logic is a pure module (`hooks/scrub.ts`) tested directly. Rejected: testing it through `session.append` in the kit, because on Claude Code 2.1.288 a test stub for `session.append` cannot answer (every answer must follow a `next`, and nothing is beneath the stub).

## Assumptions not verified
- In an interactive session, `prompt.submit` runs before the `queue-operation` record is written, so that record holds the placeholder. In the headless `-p` path it is written first and holds the typed text. If wrong: the typed prompt stays raw in that one bookkeeping record (never sent to the model). How to check: type a prompt with a fake key in an interactive session and grep the session file for `queue-operation`.
- Subagent rows and subagent `Edit` calls pass through the same hooks with `agentId` set, as the types say. If wrong: a subagent could read a raw secret. How to check: run a subagent that reads a file with a fake key and grep its rows in the session file.
- The restore path works against a real file edit by the model. The kit proves the argument rewrite; a live run with a logged-in session has not been done. How to check: ask Claude to move a key from `.env` to another file and confirm the new file holds the real value.
- `$.state` survives `/reload-plugins`, so placeholders made before a reload still restore. If wrong: an edit using an older placeholder fails and Claude re-reads the file. How to check: hide a value, run `/reload-plugins`, ask for an edit of that line.
- The `$.ui.log` line draws in the terminal transcript. The `-p` path receives it as `ui_log`. If wrong: the person loses the per-row notice; `/redact` still reports totals. How to check: an interactive session with a fake key in the prompt.
70 changes: 70 additions & 0 deletions plugins/redact/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# redact

Redacts secrets and PII from every row Claude Code stores, before the model reads it and before the transcript keeps it, and puts the real value back only inside `Edit`, `Write` and `NotebookEdit` arguments so file edits still work.

Built on Claude Code 2.1.288. Proven on 2.1.288 (stages: validate, load, typecheck, test, command, isolation). Requires Claude Code 2.1.287 or later.

## What it can reach

❯ ./register.ts hooks: session.start, prompt.submit, session.append, tool.call{tool=Edit|Write|NotebookEdit}, prompt.section{name=env_info_simple}, command.run{command=redact}
❯ ./register.ts calls: $.command.register, $.state.get, $.state.set, $.ui.log
❯ ./register.ts state writes: redact.counts, redact.restored, redact.salt, redact.vault
❯ ./register.ts state reads: redact.counts, redact.restored, redact.salt, redact.vault

Reach L0, draws and remembers.

```
Threat model for redact (reach L0, draws and remembers)
1. Reads: the text of every prompt and every stored row (prompts, tool results, Claude's blocks, attachments, notices) and the arguments of Edit, Write and NotebookEdit calls, in memory; state keys redact.vault, redact.salt, redact.counts, redact.restored; no files, no env vars, no settings
2. Runs: nothing
3. Sends: nothing leaves the machine; no network call, no model call
4. Persists: placeholder-to-value pairs, the salt and counters in $.state for this session only (reset by /clear, /resume, /branch; gone at exit); nothing in $.store, no files
5. Hostile input: a crafted row can only change what gets redacted; the rule set is fixed in source and nothing reaches a process, a file, the network or the model. A crafted placeholder in a tool argument restores only a value this session hid itself; an unknown one passes through unchanged. A row that makes the scanner throw is withheld from the model, never stored raw
```

## How it works

1. `prompt.submit`: the typed prompt is scanned and each hit becomes `[REDACTED:LABEL#hash]` before the engine queues it.
2. `session.append`: every row the conversation stores is scanned the same way: tool results (file reads, shell output, MCP results), Claude's own blocks, attachments such as nested memory, notices. The rewritten row is what the model reads and what the session file keeps.
3. `tool.call` on `Edit`, `Write` and `NotebookEdit`: placeholders in the arguments are swapped back for the real value, so an edit to a line that held a key still matches the file. `Bash` never gets the real value.
4. `prompt.section`: one paragraph is appended to the environment section telling Claude the placeholders are opaque.
5. `/redact`: the session total by label and how many placeholders were restored.

The hash is FNV-1a over a per-session salt plus the value, so the same secret gets the same placeholder across rows, edits and compaction summaries, and a placeholder from another session means nothing here.

Detection is deterministic: 23 secret rules and 7 PII rules, regex plus checksums (Luhn, Verhoeff, IBAN mod-97, JWT header decode), no model call. The list with sources is in `RULES.md`.

## Install

One session: `claude --plugin-dir ./plugins/redact`. To keep it:

```
claude plugin marketplace add karanb192/claude-code-redact
claude plugin install redact@claude-code-redact
```

then `/reload-plugins` in an open session. Installed copies are cached by version: bump `version` before reinstalling.

## Options

Stored under `pluginConfigs` in settings; a change reloads the mod.

- `pii` (boolean, default `false`): also hide email, phone, payment cards, US SSN, Aadhaar, PAN and IBAN. Secrets are always hidden.
- `quiet` (boolean, default `false`): no `redact: hid ...` line in the transcript. `/redact` still reports totals.
- `off` (string, default empty): comma-separated rule ids to skip, for example `generic-secret,email`. Ids are in `RULES.md`.

## Limitations

- Two engine bookkeeping records in the session file are stored as made and can keep the typed prompt: the `queue-operation` record written at enqueue, and a command's `args` stamp. Neither is sent to the model. In the headless `-p` path the queue record is written before `prompt.submit` runs.
- A secret the rules do not know stays visible. Generic `key=value` detection needs the value to look like a secret (length and entropy); `password=hunter2` is caught, `token=abc` is not.
- A secret split across two rows, or shown partially (the last four characters), is not caught.
- Images are not scanned. A screenshot of a key goes through.
- Claude cannot search for a secret by value: `Grep` for a placeholder finds nothing. Search by the line around it.
- Plugins loaded ahead of this one see the row before it is rewritten.
- The screen may show a row just before its rewrite; the model and the session file never see that form.
- Nothing is drawn in `claude -p`, the SDK, the VS Code panel or cloud sessions; the `redact: hid ...` line arrives as `ui_log` there, and `/redact` works everywhere.
- A pattern guard is not a permission rule. A determined prompt can describe a secret in words the rules do not match.

## Uninstall

Disable it in `/plugin`. It leaves nothing: no store keys, no files.
Loading
Loading