diff --git a/apps/example-app/app/devtools/ReactotronConfig.ts b/apps/example-app/app/devtools/ReactotronConfig.ts index 5b3f0910b..1a746b2c5 100644 --- a/apps/example-app/app/devtools/ReactotronConfig.ts +++ b/apps/example-app/app/devtools/ReactotronConfig.ts @@ -22,6 +22,12 @@ const reactotron = Reactotron.configure({ /** since this file gets hot reloaded, let's clear the past logs every time we connect */ Reactotron.clear() }, + // REDACTION TEST — exercises the two-key permission model. + mcpRedaction: { + disableRedaction: true, + removeRules: { sensitiveKeys: ["authorization"] }, + additionalRules: { sensitiveKeys: ["myInternalField"] }, + }, }) .use(apisaucePlugin({ ignoreContentTypes: /^(image)\/.*$/i })) .use(reactotronRedux()) diff --git a/apps/example-app/app/navigators/AppNavigator.tsx b/apps/example-app/app/navigators/AppNavigator.tsx index b72d59ced..7d65aa5c1 100644 --- a/apps/example-app/app/navigators/AppNavigator.tsx +++ b/apps/example-app/app/navigators/AppNavigator.tsx @@ -37,6 +37,7 @@ export type AppStackParamList = { MobxStateTree: undefined AsyncStorage: undefined Redux: undefined + RedactionTest: undefined } /** @@ -103,6 +104,11 @@ const AppStack = function AppStack() { options={{ title: "Async Storage" }} /> + ) diff --git a/apps/example-app/app/redux/index.ts b/apps/example-app/app/redux/index.ts index 4a83c83d2..c496dd94f 100644 --- a/apps/example-app/app/redux/index.ts +++ b/apps/example-app/app/redux/index.ts @@ -3,6 +3,8 @@ import type { GetDefaultEnhancers } from "@reduxjs/toolkit/dist/getDefaultEnhanc import logoReducer from "../redux/logoSlice" import repoReducer from "../redux/repoSlice" import errorReducer from "../redux/errorSlice" +// REDACTION TEST +import redactionTestReducer from "../redux/redactionSlice" const createEnhancers = (getDefaultEnhancers: GetDefaultEnhancers) => { if (__DEV__) { @@ -18,6 +20,8 @@ export const store = configureStore({ logo: logoReducer, repo: repoReducer, error: errorReducer, + // REDACTION TEST + redactionTest: redactionTestReducer, }, enhancers: createEnhancers, }) diff --git a/apps/example-app/app/redux/redactionSlice.ts b/apps/example-app/app/redux/redactionSlice.ts new file mode 100644 index 000000000..485a599fe --- /dev/null +++ b/apps/example-app/app/redux/redactionSlice.ts @@ -0,0 +1,29 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit" + +export interface RedactionTestState { + auth: { + username?: string + password?: string + accessToken?: string + api_key?: string + tokens?: { access?: string; refresh?: string } + } + settings: { + theme?: string + note?: string + } +} + +const initialState: RedactionTestState = { auth: {}, settings: {} } + +const redactionSlice = createSlice({ + name: "redactionTest", + initialState, + reducers: { + setSensitive: (_state, action: PayloadAction) => action.payload, + clearSensitive: () => initialState, + }, +}) + +export const { setSensitive, clearSensitive } = redactionSlice.actions +export default redactionSlice.reducer diff --git a/apps/example-app/app/screens/RedactionTestScreen.tsx b/apps/example-app/app/screens/RedactionTestScreen.tsx new file mode 100644 index 000000000..4d5005130 --- /dev/null +++ b/apps/example-app/app/screens/RedactionTestScreen.tsx @@ -0,0 +1,332 @@ +/** + * Manual test harness for Reactotron's MCP redaction engine. + * + * Each button emits a sensitive payload onto one of the surfaces the MCP + * server exposes (network / redux / logs / async storage). To verify + * redaction, read back the same data from an MCP client (e.g. Claude Code + * via `claude mcp add --transport http reactotron http://localhost:4567/mcp`) + * and check the values listed in the comment above each button. + * + * Success criteria live inline in the code, next to the payload they + * describe. If a test begins failing, the comment tells you what SHOULD + * happen based on the intended contract — not what's currently + * working or broken in a specific build. + * + * The two-key permission model is exercised via this app's static + * `mcpRedaction` client config in `ReactotronConfig.ts` plus the two + * permission toggles in Reactotron's MCP settings modal. + */ +import React from "react" +import AsyncStorage from "@react-native-async-storage/async-storage" +import { ScrollView, TextStyle, View, ViewStyle } from "react-native" +import { useDispatch } from "react-redux" +import { Button, Text } from "app/components" +import { AppStackScreenProps } from "app/navigators" +import { colors, spacing } from "app/theme" +import type { AppDispatch } from "app/redux" +import { setSensitive as redactionSetSensitive } from "app/redux/redactionSlice" +import { useSafeAreaInsetsStyle } from "app/utils/useSafeAreaInsetsStyle" + +interface RedactionTestScreenProps extends AppStackScreenProps<"RedactionTest"> {} + +export const RedactionTestScreen: React.FC = function RedactionTestScreen() { + const dispatch = useDispatch() + const $bottomContainerInsets = useSafeAreaInsetsStyle(["bottom"]) + + return ( + + + MCP redaction test harness + + Tap a button to emit a sensitive payload, then read it back via MCP + (e.g. in Claude Code). Expected redaction behavior is described above + each button in the source. + + + + {/* ──────────────────────────────────────────────────────────────────── */} + Network + + {/* + Fires an HTTPS POST carrying: + Request headers: + Authorization: Bearer → matches headerName "authorization" AND Bearer valuePattern + Cookie: session= → matches headerName "cookie" + X-Api-Key: → matches headerName "x-api-key" + X-Keep-Me: → no rule, should pass through + URL query string: + ?api_key= → matches sensitiveKey "api_key" in URL params + &page=2 → not sensitive, passes through + Request body (JSON string): + { password, api_key, accessToken } → sensitiveKeys + { note: "... sk- ..." } → value pattern sk-[A-Za-z0-9]{20,} + { user, title } → not sensitive + + Read `reactotron://timeline/api.response` or + `reactotron://network/log` + + Expected on both request AND response objects: + - headers.authorization / cookie / x-api-key → [REDACTED] + - headers.x-keep-me → preserved + - url / location include "api_key=[REDACTED]" but still show "page=2" + - response body.password / api_key / accessToken → [REDACTED] + - response body.title / user → preserved + - any string containing sk-<20+ chars> → the substring becomes [REDACTED] + */} +