feat: add MCP redaction filtering for sensitive data#1607
feat: add MCP redaction filtering for sensitive data#1607silasjmatson wants to merge 23 commits into
Conversation
…ed body support (#1608) ## Summary Stacks on top of #1607. Expands the MCP redactor's default denylists to match the cross-tool industry consensus and adds per-field redaction for `application/x-www-form-urlencoded` request bodies. Research comparing how other developer tools handle this is below — the short version: the closest analogs (Proxyman MCP, Sentry MCP, GitHub MCP, Postman) all redact at the server boundary by default, and their built-in denylists are broader than what #1607 currently ships. ## Changes ### Default rules — additions **Header names** - CSRF / XSRF variants: `x-csrf-token`, `x-xsrf-token`, `csrf-token` - IP-forwarding PII headers: `x-forwarded-for`, `x-real-ip` **Sensitive keys** - Password aliases: `passwd`, `pwd` - Generic auth-token names: `token`, `bearer`, `jwt`, `id_token`, `idtoken` - Session & CSRF: `session`, `sessionid`, `session_id`, `csrf`, `xsrf`, `csrf_token`, `xsrf_token` - OAuth: `client_secret`, `clientsecret`, `x-api-key` **Value patterns** - Anthropic API keys (`sk-ant-…`) - AWS access key IDs (`AKIA…`) - Google API keys (`AIza…` + 35 chars) - Stripe secret/publishable/restricted keys, live + test (`(?:sk|pk|rk)_(?:test|live)_…`) - PEM-encoded private key blocks (RSA, EC, DSA, OPENSSH, PGP, generic) - GitHub PAT regex broadened from `ghp_` only to `gh[pousr]_` — covers classic, server-to-server, OAuth, user-to-server, and refresh tokens ### Form-urlencoded body redaction A new code path catches strings shaped like `k=v&k=v` with no URL prefix (typical `application/x-www-form-urlencoded` POST bodies). If any key matches `sensitiveKeys`, just that value is redacted — the same semantics already used for URL query params. A strict full-match regex prevents false positives on prose that happens to contain `=`. ### Tests 105 tests passing. New coverage: - Each category of new default rule - Each new value pattern, with test literals constructed at runtime so GitHub secret-scanning doesn't flag the test file - Form-urlencoded body redaction, including negative tests for casual strings and URL-containing strings ### Docs `docs/mcp.md` updated to reflect the expanded default list and call out form-body handling. --- ## Research — how other tools handle this We spawned parallel research on how similar developer tools handle sensitive-data redaction. Full notes kept in the PR discussion; the convergent findings: ### 1. Redact at the server/MCP boundary — unanimous Every closest analog does it at the MCP serialization layer, not in the UI and not in the model: - **Proxyman MCP** — *"Sensitive data (auth tokens, passwords, API keys) is automatically redacted in responses"* ([docs](https://docs.proxyman.com/mcp)) - **Sentry MCP** — inherits Sentry's server-side scrubber - **GitHub MCP** — scans inputs for secrets and blocks by default ([changelog](https://github.blog/changelog/2025-08-13-github-mcp-server-secret-scanning-push-protection-and-more/)) - **Postman Repro** — case-insensitive default-key redaction - **mitmproxy `FilteredDumper` pattern** — redact at display/egress, not on the wire **OWASP MCP Top 10 — MCP01:2025** explicitly mandates: *"redact or sanitize inputs and outputs before logging… redact or mask secrets before writing to logs or telemetry."* ([link](https://owasp.org/www-project-mcp-top-10/2025/MCP01-2025-Token-Mismanagement-and-Secret-Exposure)) ### 2. No `sensitive` / `secretHint` annotation exists in the MCP spec today The 2025-03-26 spec adds `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` — but the maintainers are explicit: *"clients MUST NOT rely solely on these for security decisions."* ([MCP blog](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/)) Treat server-side redaction as the hard boundary; don't wait for an annotation. ### 3. The de-facto default denylist Union across **Sentry**, **Bugsnag**, **google/har-sanitizer**, **Postman**, **Chrome DevTools sanitized HAR**, **Presidio**: - Headers: `Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-Api-Key`, `X-CSRF-Token`, `X-XSRF-Token`, `X-Forwarded-For` - Keys: `password`/`passwd`/`pwd`, `secret`, `token`, `bearer`, `jwt`, `auth`, `authorization`, `api_key`/`apikey`, `credentials`, `session`/`sessionid`, `csrf`/`xsrf`, `access_token`, `refresh_token`, `id_token`, `client_secret`, `private_key` - Value patterns: AWS (`AKIA…`), Google (`AIza…`), JWT (`eyJ…`), Stripe, GitHub PATs (all prefixes), PEM private key blocks, Anthropic (`sk-ant-…`) This PR brings our defaults in line with that union. ### 4. Tool-by-tool highlights | Tool | Redaction approach | What we took / avoided | |---|---|---| | **Charles Proxy** | None built-in; user-written Rewrite rules only | Avoid its "bring your own regex" UX — ship opinionated defaults | | **Wireshark** | `editcap` + third-party TraceWrangler; fail-closed pattern | Noted `strictMode` allowlist as future work | | **Postman** | "Secret" variable type masks UI only; still exfiltrated in analytics URLs — cautionary tale | Redact the fully-rendered payload at MCP boundary, not at display | | **mitmproxy / Proxyman** | `modify_headers`, Python addons; Proxyman MCP auto-redacts but rules are opaque/non-tunable | Keep user-tunable config; don't ship an opaque rule set | | **Chrome DevTools** | `Export HAR (sanitized)` strips `Authorization`, `Cookie`, `Set-Cookie` only (Chrome 130, Oct 2024) | That's the floor. We already go beyond. | | **google/har-sanitizer** | Public [wordlist](https://github.com/google/har-sanitizer/blob/master/harsanitizer/static/wordlist.json) — `state`, `token`, `access_token`, `client_secret`, `SAMLRequest`, etc. | Directly informed our expanded default key list | | **Cloudflare HAR sanitizer** | Conditional, not denylist — strips JWT signature but keeps claims for debugging | Filed as a future enhancement (partial/format-preserving redaction) | | **Sentry / Bugsnag / Datadog / LogRocket** | Opinionated server-side defaults + user-extendable via `beforeSend`-style hook; Datadog offers partial redaction & Luhn-validated card detection | Union of their default lists → our new defaults. Partial redaction & Luhn are follow-ups. | ### Key canonical incident **Okta support breach (Oct 2023)** — attacker stole HAR files from 134 customer support tickets; the HARs contained live session tokens that were used to hijack sessions at BeyondTrust, Cloudflare, and 1Password. The PR's default-on posture is the right response to this class of leak. --- ## What is intentionally NOT in this PR Tracked as follow-ups so the review stays focused: - **Substring matching on keys.** Sentry JS and Bugsnag match substrings; that catches `sessionToken`/`userPassword` automatically but false-positives on `author`/`authored_by` when `auth` is in the list. Would need a separate denylist/pattern split. - **Typed redaction markers** (`[REDACTED:jwt]`) and a `_redacted` summary sibling field. Useful for LLM reasoning and defensive-sandwich logging but changes the public output shape. - **Luhn-validated credit-card detection.** A bare 13–19 digit regex produces too many false positives on random IDs and unix timestamps; needs Luhn to be safe. - **Cookie-value parsing within the `Cookie` header.** Currently the whole header is blunt-redacted. Cloudflare's per-cookie approach (keep names, redact values) would preserve more debug info. - **Partial / format-preserving masking** (keep last 4 of card, keep JWT claims but strip signature) — the strongest idea from Cloudflare/Datadog, worth a dedicated PR. - **`strictMode` allowlist** (à la TraceWrangler's "drop unknown layers" / mitmproxy's `FilteredDumper`) — only forward known-safe headers, redact the rest. ## Test plan - [x] `yarn test` in `lib/reactotron-mcp` — 105 tests pass - [x] `yarn typecheck` clean - [x] `yarn build` succeeds - [ ] Reviewer sanity-check: no new default key is an obvious false-positive trigger for any team's app-specific field names - [ ] Reviewer sanity-check: form-encoded regex doesn't false-positive on real-world payloads in your apps 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🐛 Redaction gap: request bodies aren't redacted by keyWhat I saw (manual QA on iOS sim + Android emulator): Fired an API call with: fetch("https://jsonplaceholder.typicode.com/posts?api_key=X&page=2", {
method: "POST",
headers: { Authorization: "Bearer abc...", Cookie: "session=..." },
body: JSON.stringify({ password: "hunter2", api_key: "x", accessToken: "y",
note: "embedded sk-ABCDEFGHIJKLMNOPQRSTUVWX" }),
})Reading "request": {
"data": "{\"password\":\"hunter2\",\"api_key\":\"x\",\"accessToken\":\"y\",\"note\":\"embedded [REDACTED] ...\"}",
"headers": { "authorization": "[REDACTED]", "cookie": "[REDACTED]" },
"params": { "api_key": "[REDACTED]", "page": "2" }
}Headers, URL query params, and the Root cause: Who's affected: anyone using Suggested fix: in |
🐛 State path patterns silently fail when requesting a subtreeRepro:
Actual: { "username": "alice", "password": "[REDACTED]", "api_key": "[REDACTED]",
"tokens": { "access": "access-raw-value", "refresh": "refresh-raw-value" } }
Expected: both redacted. Confirmed the pattern matcher itself works — changing the pattern to Root cause: Why it matters: the settings-modal UI encourages absolute paths (the placeholder example is Suggested fix: thread the requested path through // tools.ts
const redactedState = applyRedaction(stateValue, server, serverRedactionConfig, clientId, args.path ?? "")// redaction.ts
export function applyRedaction(data, server, config, clientId, basePath = "")
// -> return redact(data, rules, basePath)Same treatment for any other path-scoped code paths that return subtrees. |
ℹ️ State path patterns have limited effective surfaceRelated to the state-path-pattern bug above: in the example app, Combined with the subtree bug, this means state-path patterns written as absolute paths only fire in a narrow window: the Not blocking this PR — flagging for context. |
|
BUG —
|
Re-QA against
|
| Concern from previous round | Status |
|---|---|
createRedactor + per-client cache (9baca65b) |
both apps connected simultaneously, each event redacts under its own clientId's rules; iOS sees iosOnlyKey: [REDACTED] + androidOnlyKey preserved, Android sees the inverse, zero bleed |
headerNames -> sensitiveKeys collapse (d8587b60) |
Authorization / Cookie / X-Api-Key still redact in request headers, in URL params, under non-canonical wrappers like requestHeaders / lastResponse / setCookieArray, and via the literal headers key. Identical to pre-collapse. |
Combined-regex valuePatterns + path-tracking skip (1385a0e6) |
Bearer, JWT, sk-, ghp_, xoxb- all caught in one pass; multiple secrets across different log lines all redact |
| JSON-string body parsing | nested extras wrapper with stringified JSON: inner password/api_key redact, outer user preserved |
One non-blocking observation worth knowing
After d8587b60 removed mcpRedactionHeaderNames from the electron-store schema, the persisted config.json from a pre-collapse run still had it as a leftover key (silently ignored, harmless), and mcpRedactionSensitiveKeys was the older 30-entry version. Reset to defaults loads the new 40-entry merged list correctly. Users upgrading to this build won't get the merged header names in their saved sensitiveKeys list until they hit Reset — most won't, since their Bearer/Cookie patterns will still catch the values via valuePatterns. Just calling it out.
Approving.
joshuayoes
left a comment
There was a problem hiding this comment.
LGTM. Verified end-to-end against b9ec83b9 with iOS sim + Android emulator both connected — AsyncStorage multiSet fix lands the original report, per-client redaction holds across the createRedactor refactor, headerNames collapse is behaviorally identical, combined-regex valuePatterns still catch every default. Comment thread above has the full breakdown.
Two minor things that aren't blockers:
- The
mcpRedactionHeaderNameselectron-store key persists afterd8587b60removed it from the schema (silently ignored, but stays on disk). Could add a one-shot migration to delete it on next boot if you want, otherwise it's noise. - Existing users upgrading won't get the merged header names in their saved
mcpRedactionSensitiveKeysuntil they hit Reset to defaults. Their Bearer/Cookie values still redact via valuePatterns, but the key-name match for non-Bearercookie values won't fire on saved configs. Worth a one-liner in the release notes.
Add a secure-by-default redaction system that filters sensitive data from all MCP resource and tool responses. Uses a two-key model where default rules apply automatically and disabling requires agreement from both the client app and the Reactotron desktop app. Redaction engine: - Key matching: redacts object keys matching sensitiveKeys (case-insensitive) - Header matching: redacts HTTP headers matching headerNames - Value patterns: regex-based detection of Bearer tokens, JWTs, API keys - State path patterns: dot-separated paths with wildcard support - URL query params: redacts query param values matching sensitiveKeys - Handles circular references, nested objects, and arrays Client apps can send additionalRules (always allowed) or removeRules/ disableRedaction (requires server permission via settings modal). Desktop app: settings modal for editing rules and client permissions, redaction status indicator (shield icon) in footer, config persisted via electron-store. 44 tests (30 unit + 14 integration). Docs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ed body support (#1608) ## Summary Stacks on top of #1607. Expands the MCP redactor's default denylists to match the cross-tool industry consensus and adds per-field redaction for `application/x-www-form-urlencoded` request bodies. Research comparing how other developer tools handle this is below — the short version: the closest analogs (Proxyman MCP, Sentry MCP, GitHub MCP, Postman) all redact at the server boundary by default, and their built-in denylists are broader than what #1607 currently ships. ## Changes ### Default rules — additions **Header names** - CSRF / XSRF variants: `x-csrf-token`, `x-xsrf-token`, `csrf-token` - IP-forwarding PII headers: `x-forwarded-for`, `x-real-ip` **Sensitive keys** - Password aliases: `passwd`, `pwd` - Generic auth-token names: `token`, `bearer`, `jwt`, `id_token`, `idtoken` - Session & CSRF: `session`, `sessionid`, `session_id`, `csrf`, `xsrf`, `csrf_token`, `xsrf_token` - OAuth: `client_secret`, `clientsecret`, `x-api-key` **Value patterns** - Anthropic API keys (`sk-ant-…`) - AWS access key IDs (`AKIA…`) - Google API keys (`AIza…` + 35 chars) - Stripe secret/publishable/restricted keys, live + test (`(?:sk|pk|rk)_(?:test|live)_…`) - PEM-encoded private key blocks (RSA, EC, DSA, OPENSSH, PGP, generic) - GitHub PAT regex broadened from `ghp_` only to `gh[pousr]_` — covers classic, server-to-server, OAuth, user-to-server, and refresh tokens ### Form-urlencoded body redaction A new code path catches strings shaped like `k=v&k=v` with no URL prefix (typical `application/x-www-form-urlencoded` POST bodies). If any key matches `sensitiveKeys`, just that value is redacted — the same semantics already used for URL query params. A strict full-match regex prevents false positives on prose that happens to contain `=`. ### Tests 105 tests passing. New coverage: - Each category of new default rule - Each new value pattern, with test literals constructed at runtime so GitHub secret-scanning doesn't flag the test file - Form-urlencoded body redaction, including negative tests for casual strings and URL-containing strings ### Docs `docs/mcp.md` updated to reflect the expanded default list and call out form-body handling. --- ## Research — how other tools handle this We spawned parallel research on how similar developer tools handle sensitive-data redaction. Full notes kept in the PR discussion; the convergent findings: ### 1. Redact at the server/MCP boundary — unanimous Every closest analog does it at the MCP serialization layer, not in the UI and not in the model: - **Proxyman MCP** — *"Sensitive data (auth tokens, passwords, API keys) is automatically redacted in responses"* ([docs](https://docs.proxyman.com/mcp)) - **Sentry MCP** — inherits Sentry's server-side scrubber - **GitHub MCP** — scans inputs for secrets and blocks by default ([changelog](https://github.blog/changelog/2025-08-13-github-mcp-server-secret-scanning-push-protection-and-more/)) - **Postman Repro** — case-insensitive default-key redaction - **mitmproxy `FilteredDumper` pattern** — redact at display/egress, not on the wire **OWASP MCP Top 10 — MCP01:2025** explicitly mandates: *"redact or sanitize inputs and outputs before logging… redact or mask secrets before writing to logs or telemetry."* ([link](https://owasp.org/www-project-mcp-top-10/2025/MCP01-2025-Token-Mismanagement-and-Secret-Exposure)) ### 2. No `sensitive` / `secretHint` annotation exists in the MCP spec today The 2025-03-26 spec adds `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` — but the maintainers are explicit: *"clients MUST NOT rely solely on these for security decisions."* ([MCP blog](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/)) Treat server-side redaction as the hard boundary; don't wait for an annotation. ### 3. The de-facto default denylist Union across **Sentry**, **Bugsnag**, **google/har-sanitizer**, **Postman**, **Chrome DevTools sanitized HAR**, **Presidio**: - Headers: `Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-Api-Key`, `X-CSRF-Token`, `X-XSRF-Token`, `X-Forwarded-For` - Keys: `password`/`passwd`/`pwd`, `secret`, `token`, `bearer`, `jwt`, `auth`, `authorization`, `api_key`/`apikey`, `credentials`, `session`/`sessionid`, `csrf`/`xsrf`, `access_token`, `refresh_token`, `id_token`, `client_secret`, `private_key` - Value patterns: AWS (`AKIA…`), Google (`AIza…`), JWT (`eyJ…`), Stripe, GitHub PATs (all prefixes), PEM private key blocks, Anthropic (`sk-ant-…`) This PR brings our defaults in line with that union. ### 4. Tool-by-tool highlights | Tool | Redaction approach | What we took / avoided | |---|---|---| | **Charles Proxy** | None built-in; user-written Rewrite rules only | Avoid its "bring your own regex" UX — ship opinionated defaults | | **Wireshark** | `editcap` + third-party TraceWrangler; fail-closed pattern | Noted `strictMode` allowlist as future work | | **Postman** | "Secret" variable type masks UI only; still exfiltrated in analytics URLs — cautionary tale | Redact the fully-rendered payload at MCP boundary, not at display | | **mitmproxy / Proxyman** | `modify_headers`, Python addons; Proxyman MCP auto-redacts but rules are opaque/non-tunable | Keep user-tunable config; don't ship an opaque rule set | | **Chrome DevTools** | `Export HAR (sanitized)` strips `Authorization`, `Cookie`, `Set-Cookie` only (Chrome 130, Oct 2024) | That's the floor. We already go beyond. | | **google/har-sanitizer** | Public [wordlist](https://github.com/google/har-sanitizer/blob/master/harsanitizer/static/wordlist.json) — `state`, `token`, `access_token`, `client_secret`, `SAMLRequest`, etc. | Directly informed our expanded default key list | | **Cloudflare HAR sanitizer** | Conditional, not denylist — strips JWT signature but keeps claims for debugging | Filed as a future enhancement (partial/format-preserving redaction) | | **Sentry / Bugsnag / Datadog / LogRocket** | Opinionated server-side defaults + user-extendable via `beforeSend`-style hook; Datadog offers partial redaction & Luhn-validated card detection | Union of their default lists → our new defaults. Partial redaction & Luhn are follow-ups. | ### Key canonical incident **Okta support breach (Oct 2023)** — attacker stole HAR files from 134 customer support tickets; the HARs contained live session tokens that were used to hijack sessions at BeyondTrust, Cloudflare, and 1Password. The PR's default-on posture is the right response to this class of leak. --- ## What is intentionally NOT in this PR Tracked as follow-ups so the review stays focused: - **Substring matching on keys.** Sentry JS and Bugsnag match substrings; that catches `sessionToken`/`userPassword` automatically but false-positives on `author`/`authored_by` when `auth` is in the list. Would need a separate denylist/pattern split. - **Typed redaction markers** (`[REDACTED:jwt]`) and a `_redacted` summary sibling field. Useful for LLM reasoning and defensive-sandwich logging but changes the public output shape. - **Luhn-validated credit-card detection.** A bare 13–19 digit regex produces too many false positives on random IDs and unix timestamps; needs Luhn to be safe. - **Cookie-value parsing within the `Cookie` header.** Currently the whole header is blunt-redacted. Cloudflare's per-cookie approach (keep names, redact values) would preserve more debug info. - **Partial / format-preserving masking** (keep last 4 of card, keep JWT claims but strip signature) — the strongest idea from Cloudflare/Datadog, worth a dedicated PR. - **`strictMode` allowlist** (à la TraceWrangler's "drop unknown layers" / mitmproxy's `FilteredDumper`) — only forward known-safe headers, redact the rest. ## Test plan - [x] `yarn test` in `lib/reactotron-mcp` — 105 tests pass - [x] `yarn typecheck` clean - [x] `yarn build` succeeds - [ ] Reviewer sanity-check: no new default key is an obvious false-positive trigger for any team's app-specific field names - [ ] Reviewer sanity-check: form-encoded regex doesn't false-positive on real-world payloads in your apps 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One screen exercises every MCP redaction surface (network, Redux state, logging, AsyncStorage). Each button emits a sensitive payload; the block comment above each button documents the input and the expected MCP-side redaction so future verification doesn't need an external QA doc. Client-side mcpRedaction config sends all three optional relaxations so the two-key permission model can be driven from the desktop settings modal alone, without rebuilding the app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rees, and AsyncStorage keys
- Parse JSON-shaped strings in redactStringValue so request bodies from
fetch(body: JSON.stringify({password: "..."})) get key-level redaction
- Thread basePath through applyRedaction/redact so state path patterns
match correctly on subtree requests (e.g. request_state({path: "a.b"}))
- Add redactAsyncStorageData to catch sensitive storage keys via
case-insensitive substring matching (e.g. "auth:password", "auth_password")
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ctionConfig into redaction.ts Removes duplicate definitions from tools.ts and resources.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tionContext sensitiveKeysLower, headerNamesLower, and the circular-reference seen Set are now built once per redact() call and threaded through all recursive paths. Previously they were rebuilt on every object entry and the seen Set was scoped per-subtree. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nd deduplicate rules resolution - Cap JSON string parsing at 5 nested layers and 1MB to prevent unbounded recursion and expensive parsing of huge strings - Thread jsonStringDepth through RedactionContext so the limit is enforced across the full recursion tree - Resolve redaction rules once in the asyncstorage handler instead of resolving in redactAsyncStorageData and again in applyRedaction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ngle source of truth The Electron store had a divergent, trimmed-down copy of the default redaction rules. Import DEFAULT_REDACTION_RULES and DEFAULT_SERVER_CONFIG from reactotron-mcp so the two can never drift apart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ettings modal Imports DEFAULT_SERVER_CONFIG and uses it to restore all rule lists and permission toggles to their shipped values with a single click. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add box-sizing: border-box to tag inputs so they match container width - Hide TagContainer when empty instead of rendering an empty box - Move reset-to-defaults button to top-right inline with modal title - Fix setState-during-render warning by moving onUpdate calls out of setLocalConfig updater functions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…simplify redactObject - Compile value pattern regexes once in buildContext instead of on every string value encountered during the tree walk - Pass precomputed sensitiveKeysLower Set to URL/form-encoded param redaction instead of rebuilding it per call - Replace duplicated null/string/array/object handling in redactObject with a single redactValue call - Reuse RedactionContext (with fresh seen set) for JSON string sub-parses instead of rebuilding from raw rules - Rename mcpRedactionEnabled → mcpRedactionEnforced to accurately reflect its meaning (clients cannot override redaction, not just "redaction is on") Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Apply headerNames at every key, not only inside `headers` objects. Previously, header-name rules only fired when the parent key was literally `headers` — so a cached API response stored under `requestHeaders` / `lastResponse` would leak Cookie values, which don't match any default value pattern. - Walk Set-Cookie array values: the old `redactHeaders` only handled string values, so an array of cookies passed through unredacted. - Form-encoded body regex now accepts +, ~, *, [, ], % in keys — covers `+`-encoded spaces and other RFC-3986 unreserved chars. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When multiple apps are connected, each app's events/state/mutations are redacted with that app's own mcpRedaction config. Previously, multi-app reads fell back to server defaults only — one app's additionalRules were ignored, but more importantly there was no mechanism for app-specific rules to apply just to that app's data. Resources now thread cmd.clientId through applyRedaction per-event, so timeline / network / state / benchmarks / subscriptions / asyncstorage all respect the originating client's config without leaking rules across apps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, mcpRedactionEnforced was just !allowClientDisable — so flipping the permission on flipped the shield to its warning state even with zero clients having actually requested disableRedaction. Now it checks each connected client's mcpRedaction config and only warns when at least one client is actively opted out (and the server is honoring it). Also drops the stale "Drop before merge" comment from the example app's redaction test slice — the harness ships per 606511a. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single applyRedaction(data, ..., clientId, basePath) with three named operations matching the three real call sites: - applyRedaction — generic payloads (logs, network entries, actions) - applyStateRedaction — state subtrees, takes statePath explicitly - applyAsyncStorageRedaction — encapsulates the storage-key + deep walk two-pass `basePath` was state-tree-specific knowledge in a generic-looking parameter, only ever non-empty for request_state. It now lives only on applyStateRedaction as `statePath`. Also: the state resource now anchors redaction at the cached state's originating path (state.values.response carries `payload.path`), so state path patterns match correctly even when the cached snapshot is a subtree from a prior request_state call. Resources.ts shrinks: the asyncstorage two-pass and manual rule resolution are gone — one one-liner per resource. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous applyRedaction / applyStateRedaction / applyAsyncStorageRedaction trio resolved rules from scratch on every call. With per-event redaction in multi-app reads, a 500-event timeline triggers 500 identical resolve+build cycles per request. Replace with a scoped Redactor: const redactor = createRedactor(server, serverConfig) events.map(e => redactor.redact(e, e.clientId)) Created per MCP request handler. Caches resolved rules per clientId so a timeline read with N events from K distinct apps does K resolves instead of N. Single API surface (was three exported functions), tighter call sites in resources.ts and tools.ts. Behavior identical otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two lists were semantically identical after the universal-key match landed — both redact a matching key at any nesting level. Two lists is just two places to look, two places to typo, and two settings sections. Changes: - McpRedactionRules.headerNames is removed from the type - DEFAULT_REDACTION_RULES merges all default header names into sensitiveKeys (authorization, cookie, set-cookie, proxy-authorization, x-auth-token, x-csrf-token, x-xsrf-token, csrf-token, x-forwarded-for, x-real-ip) - RedactionContext drops headerNamesLower - McpSettingsModal loses the "Redacted Header Names" section - electron-store drops mcpRedactionHeaderNames key - example-app's removeRules.headerNames becomes removeRules.sensitiveKeys - docs and tests updated Nothing has shipped yet, so no migration / deprecation path needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass over the comments I added during the review/refactor work. Drop ones that just narrate what the code clearly says, tighten the rest to match the project's terse waypoint style. Removed: - Section preamble on DEFAULT_REDACTION_RULES (the structure self-explains) - Inline narrators on each branch of resolveEffectiveRules - "Cache resolved rules per clientId..." 3-line comment (the Redactor jsdoc above it already covers this) - 4-line "Universal key denylist" comment on the sensitiveKeys check Tightened: - Redactor interface jsdoc (was 4 lines, now 2) - Per-method jsdocs on Redactor (one line each) - getClientRedactionConfig jsdoc - form-encoded body regex jsdoc - mcpRedactionEnforced rationale in Standalone context - state resource's basePath comment Beefed up: - resolveEffectiveRules jsdoc now explicitly notes the permission asymmetry (additionalRules unconditional, disable/remove gated) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… path tracking Three changes to the redaction hot path. On a 500-event timeline of realistic network payloads, redaction throughput improves ~2.1× (5.89μs → 2.80μs per event in a local microbench). 1. **Parsed rules cache.** The Redactor now caches `ParsedRules` (precompiled Set + combined regex) per clientId, not raw rules. A 500-event read does one parse per distinct app instead of one per event — saving ~500 Set builds and ~5000 RegExp constructions. 2. **Combined value-pattern regex.** All `valuePatterns` fold into one alternation regex (`(?:p1)|(?:p2)|...`) at parse time. String redaction does one scan per string instead of N. Each pattern is still validated individually so one bad regex doesn't sink the rest. 3. **Skip path tracking when no state patterns.** `currentPath` was built for every key/index even when `statePathPatterns` is empty (the default). `redactObject` and `redactValue` now branch on `parsed.trackPaths` and skip the string concat in the common case. Bonus: reordered the per-key check in `redactObject` so the O(1) sensitive-key Set lookup runs before the O(N) state-path scan — both identical when both empty, but the Set hit is the common branch. Behavior unchanged. 133 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The reactotron-react-native asyncStorage plugin emits multiSet and
multiMerge as `{ pairs: [[key, value], ...] }` — an object with a
`pairs` field holding 2-tuple arrays. The redactor's existing branches
matched `{ key, value }` (setItem) and `{ "0": k, "1": v }` (a synthetic
shape no plugin actually produces), so neither fired here:
- The top-level object had neither `key` nor `"0"`, so the function
returned the wrapper unchanged with no recursion into `pairs`.
- Even if recursion had happened, the generic Array branch would have
mapped over each tuple's two strings and returned them as-is.
Result: bare key/value pairs leaked verbatim. The fourth pair in the
example test happened to redact only because its value was a JSON-shaped
string that fell into redactStringValue.
Add explicit handling for both shapes (`{ pairs: [...] }` wrapper and
`[key, value]` 2-tuple) and drop the dead `{0,1}` branch — JSON.stringify
of `[k, v]` produces `["k","v"]`, never `{"0":"k","1":"v"}`, and no
client code path produces it.
Tests pivoted to use the actual wire format. Verified the new tests
fail against the unfixed code (6 failures, all in the multiSet block)
before applying the fix.
Reported by joshuayoes in PR review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop or shorten four comments that were either duplicating a JSDoc right above them, or written as post-mortem decision narration that assumes the reader was around for the perf work. - createRedactor cache comment: duplicated the Redactor JSDoc and cited the 500-event benchmark scenario specifically - ParsedRules JSDoc: collapsed three lines of "what this is and why it exists" into one line — the type contents already say it - RedactionContext JSDoc: dropped "the parsed bundle stays shared" — visible from the field types - redactObject ordering note: shortened the O(1)/O(N) framing into one line about common case ordering Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a "Limitations and gotchas" section calling out the things users need to know before pointing an LLM at the MCP server: - Audit your payloads BEFORE connecting any LLM — once it has seen a secret, you can't unsend it. Two LLM-free audit paths: grep the codebase for field names that store credentials/tokens/PII, and inspect a normal Reactotron desktop session (the UI is intentionally unredacted). - Reactotron desktop UI is intentionally not redacted (it's for the developer debugging their own app) - Redaction is a blocklist — anything app-specific that doesn't match a default rule passes through. Add via additionalRules. - AsyncStorage values only redact when the storage key carries a sensitive name; bare keys with bare values leak - State path patterns only fire at known anchors (state resource and request_state tool) and only support trailing wildcards - Custom commands / display payloads use the generic deep walk only - Data is unredacted at rest — anyone with desktop access can see it Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Shield icon: was "green when redaction is active". The badge always shows once MCP starts; it's green while enforcement is in effect and amber when a connected client has opted out via a granted permission. - Configuration: removed the implication that mcpPort is changeable from in-app settings — there's no UI for it. Spell out where the electron-store config lives on each OS and how to change it. - Add the 500-command buffer behaviour to Configuration so users know why old events disappear and that clear_timeline resets it. - Soften the redaction lead — "tokens, passwords, and API keys are never exposed" overstated what a blocklist can guarantee. Link to the gotchas section that already covers the caveats. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b9ec83b to
051fae2
Compare
Summary
Adds default-on redaction at the MCP boundary so connected LLMs don't see secrets the desktop app sees. Server applies a built-in rule set to every resource and tool response; opting out requires both a client request and a matching server-side permission ("two-key" model). All redaction work lives in
reactotron-mcp— the React Native client only opts in via a newmcpRedactionfield onReactotron.configure.What gets redacted
password,secret,client_secret,private_key,ssn,creditcard), API keys (api_key,apikey,x-api-key), auth tokens (token,bearer,jwt,access_token,refresh_token,id_token), session/CSRF (session*,csrf*,xsrf*), HTTP headers (Authorization,Cookie,Set-Cookie,Proxy-Authorization,X-Auth-Token,X-CSRF-Token,X-XSRF-Token,X-Forwarded-For,X-Real-IP).?api_key=…) and form-urlencoded bodies (k=v&k=v) where the param name matches a sensitive key.setItem,mergeItem,multiSet,multiMerge— checked by storage-key substring.auth.tokens.*) at known anchors (reactotron://state/current,request_state).Two-key opt-out
Clients can send
mcpRedaction.{additionalRules, removeRules, disableRedaction}in the intro payload.additionalRulesis always honored.removeRulesanddisableRedactiononly take effect when the desktop app has the matching permission enabled (both off by default). Per-app rules are scoped per-client: in multi-app reads, App A'sadditionalRulesdon't affect App B's events, and App B'sremoveRulescan't weaken App A's redaction.Desktop UI
sensitiveKeys,statePathPatterns,valuePatterns, the two client permissions, and a reset-to-defaults button. Persisted viaelectron-store. Defaults sourced fromreactotron-mcp(single source of truth).Architecture
reactotron-core-contract/src/mcpRedaction.tswithMcpRedactionConfig/McpRedactionRulesshared types.ClientIntroPayloadcarries it through the websocket handshake.reactotron-core-clientoptionmcpRedactionforwarded onclient.intro.reactotron-mcp/src/redaction.tsexposescreateRedactor(server, config)returning{ redact, redactState, redactAsyncStorage }. Resolved rules and compiled regex are cached perclientIdfor the lifetime of one MCP request.resources.ts/tools.tsroute every outbound payload through the redactor with the originating event'sclientId.Performance
Set; rules are parsed once per request.Tests
redaction.test.ts— 75 unit tests covering rule resolution, key/value/path matching, URL/form-encoded handling, JSON-string recursion, AsyncStorage shapes, circular refs, and edge cases.redaction-integration.test.ts— 14 tests against realistic resource payloads (network entries, state snapshots, AsyncStorage mutations, custom commands).mcp-server.test.ts— adds 2 new HTTP/MCP-level tests proving per-client redaction across multi-app reads.Docs
docs/mcp.mdgains a Redaction section covering defaults, configuration, multi-app behavior, and a Limitations and gotchas subsection that flags audit-before-LLM-connection (custom field names, opaque storage keys, state paths without anchors, defaults at the MCP boundary not at storage).Example app
apps/example-appadds aRedactionTestScreenmanual test harness that exercises every code path (sensitive keys at depth, JWT/Bearer values, form-encoded bodies, AsyncStorage shapes, state path patterns) and a redux slice / nav entry to reach it.Please verify
yarn build-and-test:localpassesdocs/mcp.mdupdated