Skip to content

feat: add MCP redaction filtering for sensitive data#1607

Open
silasjmatson wants to merge 23 commits into
masterfrom
feat/mcp-redaction
Open

feat: add MCP redaction filtering for sensitive data#1607
silasjmatson wants to merge 23 commits into
masterfrom
feat/mcp-redaction

Conversation

@silasjmatson
Copy link
Copy Markdown
Contributor

@silasjmatson silasjmatson commented Apr 23, 2026

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 new mcpRedaction field on Reactotron.configure.

What gets redacted

  • Sensitive keys (case-insensitive, any nesting level): credentials (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).
  • Value patterns (regex against strings): Bearer tokens, JWTs, OpenAI / Anthropic / GitHub / Slack / AWS / Google / Stripe keys, PEM private-key blocks.
  • URL query params (?api_key=…) and form-urlencoded bodies (k=v&k=v) where the param name matches a sensitive key.
  • JSON-encoded strings are parsed, redacted, and re-stringified (depth/size-guarded).
  • AsyncStorage mutations — including setItem, mergeItem, multiSet, multiMerge — checked by storage-key substring.
  • State-path patterns with trailing wildcard (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. additionalRules is always honored. removeRules and disableRedaction only 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's additionalRules don't affect App B's events, and App B's removeRules can't weaken App A's redaction.

Desktop UI

  • Settings modal (gear icon next to the MCP button) for editing sensitiveKeys, statePathPatterns, valuePatterns, the two client permissions, and a reset-to-defaults button. Persisted via electron-store. Defaults sourced from reactotron-mcp (single source of truth).
  • Shield badge next to the MCP indicator: green while no client has actually opted out, amber when a connected client's intro requests an opt-out the server is honoring (toggling the permission alone doesn't flip it).

Architecture

  • New reactotron-core-contract/src/mcpRedaction.ts with McpRedactionConfig / McpRedactionRules shared types. ClientIntroPayload carries it through the websocket handshake.
  • New reactotron-core-client option mcpRedaction forwarded on client.intro.
  • reactotron-mcp/src/redaction.ts exposes createRedactor(server, config) returning { redact, redactState, redactAsyncStorage }. Resolved rules and compiled regex are cached per clientId for the lifetime of one MCP request.
  • resources.ts / tools.ts route every outbound payload through the redactor with the originating event's clientId.

Performance

  • Value patterns are validated individually then folded into a single alternation regex (one pass per string).
  • Sensitive keys are pre-lowercased into a Set; rules are parsed once per request.
  • State-path tracking only allocates path strings when patterns exist.
  • JSON-string redaction is depth-capped (5) and size-capped (1MB) to bound work on large payloads.

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.md gains 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-app adds a RedactionTestScreen manual 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:local passes
  • Tests added for new behavior
  • docs/mcp.md updated

@silasjmatson silasjmatson requested a review from joshuayoes April 23, 2026 22:22
joshuayoes added a commit that referenced this pull request Apr 24, 2026
…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>
@joshuayoes
Copy link
Copy Markdown
Contributor

🐛 Redaction gap: request bodies aren't redacted by key

What 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 reactotron://timeline/api.response via MCP, the response body is fully redacted, but the request body leaks:

"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 sk- token inside note (via valuePatterns) are redacted correctly. But password, api_key, accessToken in the body string pass through.

Root cause: fetch({ body: JSON.stringify(...) }) gives the networking plugin a string, cached verbatim as tronRequest.data (lib/reactotron-react-native/src/plugins/networking.ts:92-96). The redactor walks object keys but only applies valuePatterns to strings (lib/reactotron-mcp/src/redaction.ts:219-239), so the JSON-shaped string is never parsed and key-level redaction never runs on the body.

Who's affected: anyone using fetch / axios / apisauce with a JSON body — auth endpoints ({ password }), token refresh, payment flows, etc. will leak to the MCP client.

Suggested fix: in redactStringValue or summarizeNetworkEntry, detect strings that look like JSON (start with { or [, or Content-Type application/json), JSON.parseredactJSON.stringify, fall back to valuePatterns if parsing throws.

@joshuayoes
Copy link
Copy Markdown
Contributor

🐛 State path patterns silently fail when requesting a subtree

Repro:

  1. Added a Redux slice so state.redactionTest.auth.tokens = { access: "...", refresh: "..." }.
  2. In the MCP redaction settings modal, added redactionTest.auth.tokens.* to State Path Patterns.
  3. Via MCP: request_state({ path: "redactionTest.auth" }).

Actual:

{ "username": "alice", "password": "[REDACTED]", "api_key": "[REDACTED]",
  "tokens": { "access": "access-raw-value", "refresh": "refresh-raw-value" } }

tokens.access / tokens.refresh pass through despite the pattern.

Expected: both redacted. Confirmed the pattern matcher itself works — changing the pattern to tokens.* (relative to the returned subtree) does redact correctly.

Root cause: lib/reactotron-mcp/src/tools.ts:162 calls applyRedaction(stateValue, ...) with the path-scoped subtree, and redact() defaults currentPath = "" (lib/reactotron-mcp/src/redaction.ts:109). So tokens.access is walked with currentPath = "tokens.access" — the user-written absolute pattern redactionTest.auth.tokens.* has no chance of matching.

Why it matters: the settings-modal UI encourages absolute paths (the placeholder example is auth.tokens.*, suggesting patterns anchored to the state root). But those patterns silently no-op as soon as a caller uses a path-scoped request — and LLMs are strongly encouraged to do so by the request_state tool description ("Always specify a path to avoid oversized responses"). State path patterns effectively only work when reading reactotron://state/current (which returns the whole tree).

Suggested fix: thread the requested path through applyRedaction to redact() as the initial currentPath:

// 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.

@joshuayoes
Copy link
Copy Markdown
Contributor

ℹ️ State path patterns have limited effective surface

Related to the state-path-pattern bug above: in the example app, request_state({ path: "" }) returns only { repo, logo } even though request_state_keys({ path: "" }) correctly reports ["logo", "repo", "error", "redactionTest"]. The error and redactionTest slices initialize with empty or nested-empty payloads and don't appear in the root state snapshot that reactotron-redux pushes.

Combined with the subtree bug, this means state-path patterns written as absolute paths only fire in a narrow window: the reactotron://state/current resource, and only for slices that reactotron-redux bothers to serialize at the root. Worth double-checking whether this is a reactotron-redux serialization quirk or something else — either way, state-path patterns as-designed are more of a last-resort filter than a primary line of defense.

Not blocking this PR — flagging for context.

@joshuayoes
Copy link
Copy Markdown
Contributor

⚠️ Low severity — AsyncStorage mutations leak when keys/values carry secrets

Caveat up front: AsyncStorage isn't encrypted storage, and no one should be stashing secrets there. But in practice people do — tokens, session blobs, cached auth headers — and the MCP server exposes these mutations verbatim via reactotron://asyncstorage, so it's worth pointing out what does and doesn't redact.

Repro:

AsyncStorage.multiSet([
  ["auth:password", "hunter2"],
  ["auth:api_key", "leaked-api-key"],
  ["auth:accessToken", "leaked-access-token"],
  ["auth:session", JSON.stringify({ password: "hunter2", api_key: "leaked",
                                   note: "Bearer abcdef1234567890ABCDEFGH" })],
])

Actual (via reactotron://asyncstorage):

{
  "pairs": [
    { "0": "auth:password",    "1": "hunter2" },
    { "0": "auth:api_key",     "1": "leaked-api-key" },
    { "0": "auth:accessToken", "1": "leaked-access-token" },
    { "0": "auth:session",     "1": "{\"password\":\"hunter2\",\"api_key\":\"leaked\",\"note\":\"[REDACTED]\"}" }
  ]
}

Only the Bearer value pattern inside the auth:session JSON string got caught. The bare key names (auth:password), the bare values (hunter2), and the password/api_key fields inside the JSON string body all pass through.

Why: the multiSet payload shape is { pairs: [{0: "auth:password", 1: "hunter2"}] }. The redactor walks object keys — pairs, 0, 1 — none of which match sensitive keys. String values are only tested against valuePatterns, and none of the default regexes match bare secrets or namespaced storage keys. Same root issue as the JSON-body gap for network requests.

Pragmatic suggestion:

  • In whatever shapes AsyncStorage mutations (summarizeCommand for asyncStorage.mutation), when serializing key / value pairs, split the key on common separators (. : _ /) and test each segment against sensitiveKeys — if any match, redact the value.
  • Reuse whatever JSON-string-aware fix ends up landing for the request-body gap here too — a value like {"password":"..."} should get parsed → redacted → re-stringified.

Not security-critical on its own (AsyncStorage isn't encrypted to begin with), but it's the third place where "string values carrying structured data" sidestep the redactor, so the fix probably lands once and covers all three.

@joshuayoes
Copy link
Copy Markdown
Contributor

BUG — multiSet payloads bypass redactAsyncStorageItem

Severity: medium. Same class as the original AsyncStorage finding from the prior review, but specifically scoped to multiSet / multiRemove / multiMerge — the JSON-string-value branch (introduced by 7a7876e) does fire correctly, so a value that's a stringified JSON blob containing password etc. is redacted. Bare key/value pairs are not.

Reproduction

apps/example-app/app/screens/RedactionTestScreen.tsx (the existing "multiSet sensitive pairs" button) emits:

AsyncStorage.multiSet([
  ["auth:password", "hunter2"],
  ["auth:api_key", "leaked-api-key"],
  ["auth:accessToken", "leaked-access-token"],
  ["auth:session", JSON.stringify({password: "hunter2", api_key: "leaked", note: "Bearer ..."})],
])

Reading reactotron://asyncstorage returns:

{
  "mutations": [{
    "action": "multiSet",
    "data": {
      "pairs": [
        ["auth:password",     "hunter2"],            // ← LEAK
        ["auth:api_key",      "leaked-api-key"],     // ← LEAK
        ["auth:accessToken",  "leaked-access-token"],// ← LEAK
        ["auth:session",      "{\"password\":\"[REDACTED]\",\"api_key\":\"[REDACTED]\",\"note\":\"[REDACTED]\"}"]
      ]
    }
  }]
}

The fourth pair redacts because its value happens to be a JSON-shaped string and goes through redactStringValue. The first three leak completely.

Root cause

The wire format from lib/reactotron-react-native/src/plugins/asyncStorage.ts:69 is:

sendToReactotron("multiSet", { pairs: shippablePairs })  // shippablePairs: [string, string][]

i.e. the React Native AsyncStorageStatic["multiSet"] parameter type, which is [string, string][] — array of two-string arrays.

lib/reactotron-mcp/src/redaction.ts:441-475 (redactAsyncStorageItem) only matches two shapes:

  1. { "0": key, "1": value } (object with positional string keys) — line 451
  2. { key, value } — line 464

Neither matches a plain [key, value] 2-tuple array. The function recurses through arrays at line 443 (Array.isArray(data) → map), so it descends into pairs, then into each [key, value], then sees two strings and returns them unchanged at line 475.

The entry point also doesn't unwrap data.pairsapplyAsyncStorageRedaction(c.payload?.data, ...) at resources.ts:245 passes {pairs: [...]} straight in. The top-level branch sees an object with no "0" and no "key" keys, so it returns immediately with no recursion into pairs.

Why the test suite doesn't catch this

lib/reactotron-mcp/test/redaction.test.ts:670-742 uses the synthetic {"0": "auth:password", "1": "hunter2"} shape:

const data = [
  { "0": "auth:password", "1": "hunter2" },
  { "0": "auth:api_key", "1": "leaked-api-key" },
  ...
]

That shape is never produced by the RN plugin. The setItem helper at line 32 of the plugin does send { key, value } — covered by the test at line 726 — so the single-set path likely works. multiSet / multiRemove / multiMerge (and their setMulti...Handler cousins, if any) are uncovered.

Suggested fix

In redactAsyncStorageItem, add an array-of-pair branch before the object branches:

function redactAsyncStorageItem(data: unknown, ctx: RedactionContext): unknown {
  // Wire shape: { pairs: [[key, value], ...] }
  if (typeof data === "object" && data !== null && "pairs" in data && Array.isArray((data as any).pairs)) {
    return { ...data, pairs: (data as any).pairs.map((p: unknown) => redactAsyncStorageItem(p, ctx)) }
  }

  // Two-element [key, value] tuple
  if (Array.isArray(data) && data.length === 2 && typeof data[0] === "string") {
    const [key, value] = data
    if (storageKeyIsSensitive(key, ctx.sensitiveKeysLower)) return [key, REDACTED]
    if (typeof value === "string") return [key, redactStringValue(value, ctx)]
    return data
  }

  // existing array recursion (multiGet/multiRemove of bare keys, etc.)
  if (Array.isArray(data)) return data.map((item) => redactAsyncStorageItem(item, ctx))

  // ...existing {0,1} and {key,value} branches
}

And mirror that with a test fixture that uses the actual wire format:

const data = { pairs: [["auth:password", "hunter2"], ...] }

Happy to push the fix as a follow-up commit on this branch if useful.

— Verified end-to-end against feat/mcp-redaction@4e32c650 with iOS sim + Reactotron Electron + MCP curl probe.

@joshuayoes
Copy link
Copy Markdown
Contributor

Re-QA against b9ec83b9 — ✅ AsyncStorage fix verified, full pass green

Pulled, rebuilt, re-ran the harness against an Android emulator with iOS sim still connected (so the multi-app per-client surface gets exercised in earnest this time).

The original report — closed

multiSet of [["auth:password","hunter2"], ["auth:api_key","leaked-api-key"], ["auth:accessToken","leaked-access-token"], ["auth:session", JSON.stringify({...})]] now reads back as:

{
  "pairs": [
    ["auth:password", "[REDACTED]"],
    ["auth:api_key", "[REDACTED]"],
    ["auth:accessToken", "[REDACTED]"],
    ["auth:session", "[REDACTED]"]
  ]
}

Wire-format 2-tuple branch in redactAsyncStorageItem is doing the work. Storage-key sensitivity wins over the JSON-string-value path for the 4th pair, which is the right priority order.

Other surfaces re-checked

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.

Copy link
Copy Markdown
Contributor

@joshuayoes joshuayoes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The mcpRedactionHeaderNames electron-store key persists after d8587b60 removed 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.
  2. Existing users upgrading won't get the merged header names in their saved mcpRedactionSensitiveKeys until they hit Reset to defaults. Their Bearer/Cookie values still redact via valuePatterns, but the key-name match for non-Bearer cookie values won't fire on saved configs. Worth a one-liner in the release notes.

silasjmatson and others added 20 commits May 4, 2026 12:30
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>
silasjmatson and others added 3 commits May 4, 2026 12:30
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>
@joshuayoes joshuayoes force-pushed the feat/mcp-redaction branch from b9ec83b to 051fae2 Compare May 4, 2026 19:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants