diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9d6abe8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing + +## Run the checks + +``` +claude plugin validate plugins/redact --strict +claude plugin test plugins/redact +``` + +Both need Claude Code 2.1.287 or later on PATH and no login. The test kit runs the mod's hooks in the real engine. + +For type checking, load the mod once so Claude Code writes the type declarations for your build, then run tsc: + +``` +claude -p "/redact" --plugin-dir plugins/redact +npx -y -p typescript tsc -p plugins/redact +``` + +## Add or change a rule + +Rules live in `plugins/redact/hooks/rules.ts`, one literal per rule, and are listed in `plugins/redact/RULES.md`. A rule change ships with: + +- at least three positive fixtures and three look-alike negatives in `plugins/redact/tests/rules.test.ts`, built from pieces so no whole token sits in the source; +- the row in `RULES.md` with the public source of the pattern (the gitleaks config, the provider's docs, a spec); +- a bounded pattern: no unbounded nested quantifiers, and the 2 MB performance test still under one second. + +A rule that fires on ordinary code is worse than a rule that misses a rare token. When in doubt, tighten the verify function instead of widening the regex. + +## Report a miss or a false positive + +Open an issue with the shape of the text (a fake value in the same format), the rule you expected, and what happened. Never paste a real secret into an issue. + +## Scope + +The mod stays at reach L0: no network, no processes, no files, no model calls. A change that adds any of those needs a reason in `DECISIONS.md` and a new line in the threat model, and will usually be declined. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2e6836 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# redact for Claude Code + +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#license) + +**Every key a developer pastes into Claude Code goes to the model and stays in a session file on disk. redact swaps it for a placeholder inside Claude Code, before the model reads it and before the transcript keeps it, with zero network reach.** + +Site: [redact.aidojo.si](https://redact.aidojo.si). It is a Claude Code [mod](https://code.claude.com/docs/en/plugins/mods/overview): a plugin of TypeScript function hooks that runs in the Claude Code process. + +## What happens + +``` +# Headless `claude -p` run on Claude Code 2.1.288. The prompt held a fake AWS access key id. +# Typed (key masked here): +my aws key is AKIA**************** please remember it +# The user row as stored in the session file: +my aws key is [REDACTED:AWS_KEY#20fae26c] please remember it +# The stored system-prompt snapshot carried the redaction notice that tells Claude the placeholder is opaque. +``` + +The same value gets the same placeholder for the whole session. When Claude later edits a file that holds it, redact puts the real value back inside the `Edit`, `Write` or `NotebookEdit` arguments only. + +## Install + +``` +claude plugin marketplace add karanb192/claude-code-redact +claude plugin install redact@claude-code-redact +``` + +Then run `/reload-plugins` in an open session. Requires Claude Code 2.1.287 or later. Type `/redact` to see what it hid. + +To try it for one session without installing: `claude --plugin-dir ./plugins/redact`. + +## What makes it safe to run + +- **Reach L0.** It runs no process, reads no file, env var or setting, and writes no file. The validator lines in [What it reaches](#what-it-reaches) list every hook, call and state key. +- **No network.** Nothing leaves the machine. It starts no daemon and calls no scanner binary. +- **No model call.** Detection never asks a model. A redactor that sent your text to a model to find secrets would defeat its own purpose. +- **Deterministic rules.** 23 secret rules and 7 opt-in PII rules: regex plus checksums (Luhn, Verhoeff, IBAN mod-97, JWT header decode). The same input gives the same output. Every quantifier is bounded. +- **Reversible only inside `Edit`, `Write` and `NotebookEdit`.** Placeholders turn back into real values only in those arguments, so an edit to a line that held a key still matches the file. `Bash` never gets the real value, so the model cannot echo a placeholder into a network command and send the secret out. +- **Fail closed.** A row that makes the scanner throw is replaced with `[redact withheld this content: the redactor failed while scanning it]`. It is never stored raw. +- **The transcript file too.** It rewrites 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. The model and the session file get the same rewritten row. +- **Proof files.** The repo ships the threat model below, [`RULES.md`](plugins/redact/RULES.md) with a public source for every rule, and [`DECISIONS.md`](plugins/redact/DECISIONS.md) with every rejected option and every assumption not yet checked. + +## Proof so far + +From a run on Claude Code 2.1.288 with the mod-builder harness: + +- `claude plugin validate` passed. +- The module loaded with events `session.start`, `prompt.submit`, `session.append`, `tool.call`, `prompt.section`, `command.run`. +- `tsc` passed. +- 60 kit tests pass (`claude plugin test`). +- `/redact` answered in a headless run. +- A headless `claude -p` run with a fake AWS key stored the user row as shown in [What happens](#what-happens), and the stored system-prompt snapshot carried the redaction notice. + +Not done yet: a live interactive run with a logged-in session and a real file edit by the model. The kit tests cover the argument rewrite in `Edit`, `Write` and `NotebookEdit`. A real edit end to end has not been run. + +## What it reaches + + ❯ ./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. Each hit becomes `[REDACTED:LABEL#hash]` before the engine queues it. +2. **`session.append`.** Every row the conversation stores is scanned the same way: 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. If the scanner throws, the row is replaced with `[redact withheld this content: the redactor failed while scanning it]`. It fails closed. +3. **`tool.call` on `Edit`, `Write`, `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. It tells Claude the placeholders are opaque, so it does not ask you for the hidden value. +5. **`/redact`.** Shows the session total by label and how many placeholders were restored. + +The hash is FNV-1a over a per-session salt plus the value: + +```ts +export function placeholder(hit: Hit, salt: string): string { + return `[REDACTED:${hit.label}#${fnv1a(salt + hit.value)}]` +} +``` + +The same secret gets the same placeholder across rows, edits and compaction summaries. A placeholder from another session means nothing here. The value map lives in `$.state` for this session only, never on disk. + +## What it catches + +Secrets, always on: AWS access keys and secret keys, GitHub, GitLab, Slack tokens and webhooks, Stripe live keys, SendGrid, Twilio, Google API keys and OAuth secrets, Anthropic, OpenAI, Hugging Face, npm and PyPI tokens, Azure storage keys, JWTs, PEM private keys, database URLs with a password, `Authorization` header values, and generic `password=` / `api_key=` style assignments. + +PII, opt-in with `pii: true`: email, payment cards (Luhn), India Aadhaar (Verhoeff), US SSN, IBAN (mod-97), India PAN, phone numbers. + +Every rule, with its id, a fake example and its source: [`RULES.md`](plugins/redact/RULES.md). + +## 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`](plugins/redact/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` run above, both still held the typed text. In the headless path the queue record is written before `prompt.submit` runs. This is a known gap. +- 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. `/redact` still reports totals; it answered in the headless run. +- A pattern guard is not a permission rule. A determined prompt can describe a secret in words the rules do not match. + +Assumptions not yet checked (subagent rows, restore after `/reload-plugins`, the interactive queue record) are listed with a way to check each one in [`DECISIONS.md`](plugins/redact/DECISIONS.md). + +## For teams + +- **Distribute it from a managed marketplace.** Add this repo, or your fork of it, to the marketplace your organization already manages, so every developer installs the same reviewed copy. +- **Pin a version.** Installed copies are cached by `version`. Review a release, then pin it. Upgrade on purpose. +- **Set the policy in `pluginConfigs`.** Turn on `pii` if your code and logs carry customer data. Use `off` to drop a rule that fires on your own fixtures, by id. +- **Know the limit of self-install.** A developer can disable any plugin they installed themselves. Org-wide enforcement needs managed settings. See Anthropic's [plugin admin docs](https://code.claude.com/docs/en/plugins/mods/admin). +- **Read the threat model before rollout.** It is five lines, above. The hook code is [`hooks/register.ts`](plugins/redact/hooks/register.ts) (127 lines) and [`hooks/scrub.ts`](plugins/redact/hooks/scrub.ts) (98 lines). The rules live in [`hooks/rules.ts`](plugins/redact/hooks/rules.ts). + +## Uninstall + +Disable it in `/plugin`. It leaves nothing: no store keys, no files. + +## Contributing + +New rules and fixes are welcome. A rule PR needs: + +1. The rule in [`hooks/rules.ts`](plugins/redact/hooks/rules.ts) with bounded quantifiers. +2. A row in [`RULES.md`](plugins/redact/RULES.md): id, label, what it catches, a fake example, and a public source for the format. +3. Fixtures in [`tests/rules.test.ts`](plugins/redact/tests/rules.test.ts): at least one fake value that must match and one near miss that must not. +4. A green `claude plugin test plugins/redact` and `claude plugin validate plugins/redact --strict`. + +Never put a real secret in a fixture. Fake values with the right shape are enough. + +Built with the mod-builder skill from [claude-code-mods](https://github.com/karanb192/claude-code-mods). + +## License + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..54ea5dd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security + +This mod runs inside Claude Code with your own access. It makes no network call, starts no process, writes no file, and calls no model. The validator prints that reach before any code runs: + +``` +claude plugin validate plugins/redact --strict +``` + +## Report a problem + +Email karan@karanbansal.in. Include the Claude Code version (`claude --version`), the mod version from `plugins/redact/.claude-plugin/plugin.json`, and the shape of the text involved with fake values. Never send a real secret. + +A bypass (text the rules should catch and do not) is a bug, not a vulnerability; open a public issue for it unless it also leaks something already hidden. A way to make a hidden value leave the machine, or to make the mod store a raw value it reported as hidden, is a vulnerability; email it. + +## What is in scope + +- A placeholder restored outside `Edit`, `Write` and `NotebookEdit` arguments. +- A row stored raw after the mod reported it hidden. +- A crafted row that makes the mod call anything beyond `$.command.register`, `$.state`, and `$.ui.log`. +- A regex that can be made to run past the ten-second hook budget. + +## What is out of scope + +- Secrets in formats the rules do not know. See `plugins/redact/README.md` under Limitations and open an issue. +- Engine bookkeeping records the hooks cannot rewrite (the `queue-operation` record and a command's `args` stamp). Those are documented and not sent to the model. diff --git a/plugins/redact/.claude-plugin/plugin.json b/plugins/redact/.claude-plugin/plugin.json index f2d54c7..954937f 100644 --- a/plugins/redact/.claude-plugin/plugin.json +++ b/plugins/redact/.claude-plugin/plugin.json @@ -24,7 +24,7 @@ "off": { "type": "string", "title": "Rules off", - "description": "Comma-separated rule ids to skip, for example generic-assignment,email. Ids are listed in RULES.md.", + "description": "Comma-separated rule ids to skip, for example generic-secret,email. Ids are listed in RULES.md.", "default": "" } } diff --git a/plugins/redact/hooks/register.ts b/plugins/redact/hooks/register.ts index 0efcfc7..4473a15 100644 --- a/plugins/redact/hooks/register.ts +++ b/plugins/redact/hooks/register.ts @@ -65,8 +65,9 @@ export const register: Register = (on, options) => { return started }) - // The typed prompt is scrubbed here, before the engine queues it, so the - // queue record, the on-screen echo and the command args never hold the value. + // The typed prompt is scrubbed before the turn starts, so the on-screen echo + // and the command args hold the placeholder. The engine's queue record is + // written earlier in the headless path and is out of reach (see README). on('prompt.submit', async ($, e, next) => { const s = await ensureSalt($) const r = redactText(e.text, s, cfg) diff --git a/site/CNAME b/site/CNAME new file mode 100644 index 0000000..5e5f09a --- /dev/null +++ b/site/CNAME @@ -0,0 +1 @@ +redact.aidojo.si diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..884c753 --- /dev/null +++ b/site/index.html @@ -0,0 +1,336 @@ + + + + + +redact for Claude Code + + + + + +
+
+ redact. + +
+
+ +
+
+
+
A Claude Code mod
+

redact

+

Paste a key into Claude Code and it goes to the model and into a session file on disk. redact swaps it for a placeholder inside Claude Code first, with no network reach.

+ +
+
$ claude plugin marketplace add karanb192/claude-code-redact
+$ claude plugin install redact@claude-code-redact
+ +
+

Then run /reload-plugins in an open session. Needs Claude Code 2.1.287 or later.

+ +
+
A headless claude -p run on Claude Code 2.1.288. The prompt held a fake AWS access key id.
+
+
Typed
my aws key is AKIA**************** please remember it
+
Stored
my aws key is [REDACTED:AWS_KEY#20fae26c] please remember it
+
+

"Stored" is the user row as written to the session file. The key is masked here on the page. The stored system-prompt snapshot carried the redaction notice.

+
+
+
+ +
+
+

How it keeps a secret in

+
+
+

Nothing leaves the machine

+

Reach L0. It runs no process, makes no network call and asks no model. Detection is regex plus checksums. The value map lives in session memory and is gone at exit.

+
+
+

Edits get the real value

+

Placeholders are swapped back only inside Edit, Write and NotebookEdit arguments, so an edit to a line that held a key still matches the file. Bash never gets the real value.

+

Kit tests cover the argument rewrite. A live edit in a logged-in session has not been run yet.

+
+
+

The transcript is clean too

+

It rewrites rows at session.append, the one event that sees every row before it is stored and sent. File reads, shell output, MCP results and Claude's own replies reach the session file as placeholders.

+
+
+
+
+ +
+
+

What it catches

+

30 rules. Each one has a fake example and a public source in RULES.md. Any rule can be switched off by id.

+
+
+

Secrets always on, 23 rules

+
    +
  • AWS access key ids and secret access keys
  • +
  • GitHub, GitLab, npm, PyPI and Hugging Face tokens
  • +
  • Slack tokens and webhook URLs
  • +
  • Stripe live keys, SendGrid and Twilio keys
  • +
  • Google API keys and OAuth client secrets
  • +
  • Anthropic and OpenAI keys
  • +
  • Azure storage account keys
  • +
  • JWTs (header must decode to JSON with alg)
  • +
  • PEM private key blocks
  • +
  • Database URLs that carry a password
  • +
  • Authorization header values
  • +
  • Generic password=, api_key= style values with enough entropy
  • +
+
+
+

PII opt-in, 7 rules

+
    +
  • Email addresses
  • +
  • Payment cards (Luhn check)
  • +
  • Phone numbers (E.164 and common forms)
  • +
  • US SSN
  • +
  • IBAN (mod-97 check)
  • +
  • India Aadhaar (Verhoeff check)
  • +
  • India PAN
  • +
+

Set pii to true to turn these on.

+
+
+
+
+ +
+
+

What it does not do

+
    +
  • Two bookkeeping records keep the typed prompt. The queue-operation record and a command's args stamp are stored as made. In the headless run both still held the typed text. Neither is sent to the model.
  • +
  • Unknown formats pass. A secret the rules do not know stays visible. password=hunter2 is caught, token=abc is not.
  • +
  • Split or partial secrets pass. A key across two rows, or only its last four characters, is not caught.
  • +
  • Images are not scanned. A screenshot of a key goes through.
  • +
  • No search by value. Grep for a placeholder finds nothing. Search by the line around it.
  • +
  • Plugins loaded ahead of it see the raw row.
  • +
  • The screen may show the raw row just before its rewrite. The model and the session file never see that form.
  • +
  • No drawn notice outside the terminal. In claude -p, the SDK, the VS Code panel and cloud sessions the notice arrives as ui_log.
  • +
  • It is not a permission rule. A determined prompt can describe a secret in words the rules do not match.
  • +
+
+
+ +
+
+
+

For teams

+
    +
  • Ship it from your managed marketplace and pin a reviewed version.
  • +
  • Set pii and off under pluginConfigs in settings.
  • +
  • Users can disable a plugin they installed themselves. Org-wide enforcement needs managed settings: see the plugin admin docs.
  • +
  • Read the five-line threat model and the hook code before rollout: 225 lines, plus the rule file.
  • +
+ Read the code on GitHub +
+
+
+
+ + + + + +