diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0a75743f..fe3ebdab 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mobile-dev", - "version": "0.1.162", + "version": "0.1.163", "description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, analyze CPU and React profiles with TraceSift, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.", "author": { "name": "Callstack", @@ -68,7 +68,7 @@ "commerce": false }, "publication": { - "release_notes": "Adds product-specific privacy, terms and support links, three starter prompts, and reproducible marketplace review cases and setup documentation." + "release_notes": "Adds product-specific privacy, terms and support links, three starter prompts, and reproducible review cases. Corrects TraceSift write/service annotations. Filters storage secrets and binary values before host delivery, scopes reads before decoding, and adds exact key/column selection." } } }, diff --git a/PRIVACY.md b/PRIVACY.md index 8900dfd6..04d8df2f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -37,7 +37,10 @@ Depending on the tools you use, Mobile Dev processes: and stack traces. - App CPU, memory, thread, FPS, and frame-timing measurements and saved recordings. - App storage keys, values, database rows, and file metadata when you inspect - storage. Clear/reset operations can delete data in the selected app. + storage. Results are filtered before reaching the host or panel: recognized + sensitive fields, credential-like values, and binary values are redacted. These + rules cannot identify every secret or personal detail. Clear/reset operations + can delete data in the selected app. - CPU and React profiles, call trees, component names, source paths, analysis results, and investigation context when you use TraceSift. The integration sends profiles to a TraceSift service on your own computer and saves analysis diff --git a/README.md b/README.md index 77b63f35..674a4daa 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ Track app CPU and memory, expand individual thread charts, and view device-wide See what an app has saved without adding code to it: MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite databases, and the largest files in its data directory. Open SQLite tables as a paged grid, search across every store at once, and open the Changes tab to watch keys being added, changed, and removed while you use the app, with only the changed part of JSON values highlighted. SDK data from Firebase, Google Analytics and similar libraries is grouped separately. Clear one store or reset all app data. Works on iOS simulators and debuggable Android builds; MMKV value types are guessed from the stored bytes, and encrypted MMKV instances show as locked. +Storage results are filtered before reaching the host or panel. Recognized sensitive +fields and credential-like values show `[REDACTED]`; binary values are withheld. +Ask for a specific store/key or table/columns to narrow inspection. This filtering +cannot identify all personal or confidential data; use synthetic development data. +See [storage privacy and review checks](docs/storage-privacy.md). + ## Requirements The prebuilt plugin targets Codex desktop on an Apple Silicon Mac and uses diff --git a/docs/storage-privacy.md b/docs/storage-privacy.md new file mode 100644 index 00000000..e24613d9 --- /dev/null +++ b/docs/storage-privacy.md @@ -0,0 +1,71 @@ +# Storage results and sensitive data + +`mobile_app_storage` and `mobile_app_storage_table` return data to the host and to +the Storage panel. Sentry scrubbing does not cover this boundary. The storage +reader now filters complete values before length truncation or MCP serialization; +the text and structured results contain the same filtered data. There is no +raw-value flag or panel-only bypass. + +## What is filtered + +- Recognized sensitive key/column names (including password, token, auth, cookie, + session, API/private keys, and common personal-data fields), including nested + JSON objects and arrays. Sensitive store/table names also redact their values. +- Credential-shaped text such as authorization headers, JWTs, common provider + tokens, private keys, URL credentials, credential assignments, email addresses, + and long opaque strings that may encode credentials. +- Binary values, including SQLite blobs and decoded key/value binary entries. +- Generic SQLite value columns when the same row's key/name label identifies a + sensitive field, including when the caller projects only the value column. +- Malformed JSON-like values and values beyond bounded redaction depth/size limits + are withheld. The filter runs before truncation so it cannot return a token prefix. +- Credential-looking keys are replaced and their values withheld; suspicious + path components are omitted from file/store listings. Such stores may therefore + be unavailable for inspection. Long hashes can also be hidden by this rule. +- Raw command/SQLite error text is never returned. Failures use fixed recovery + messages based on outcome, keeping paths and diagnostics out of host results. + Clear-store success returns a generic confirmation, not an echoed store name. + +This is conservative, heuristic filtering. It may hide benign identifiers or +values and cannot recognize every secret, encoding, medical record, personal +name, or arbitrary sensitive string. Use synthetic development data. A successful +filter is not a claim that a result is safe to publish. Screenshots, logs, +profiles, accessibility results and other tool families are outside this filter. +Source app storage is not modified by inspection. Because redacted values are +stable placeholders, the Changes tab cannot reveal changes within those values. + +## Narrow the request + +Use the device ID and bundle/package ID of the intended app. In +`mobile_app_storage`, `store` is matched to the actual container listing **before +any store content is read**. Unselected stores are not decoded and their file +metadata is excluded. An invalid store fails rather than returning a broader +snapshot. `keys` accepts up to 100 exact names and requires `store`; it filters +entries returned from that store. Some formats still require reading/decoding the +whole selected store locally. Omit these selectors for the panel's app-wide, +filtered overview. + +For `mobile_app_storage_table`, `store` and `table` are always required. `columns` +accepts up to 100 exact names, validated against the schema and quoted as SQL +identifiers. Unselected columns are not selected except local key/name labels +needed for credential filtering. Known sensitive columns and blobs are replaced +in the SQL result itself. Use a small `limit` and explicit `offset`. Temporary +SQLite copies are cleaned up as before. + +File counts/sizes describe the requested scope after suspicious paths are +excluded, not the complete disk usage. Entry counts describe decoded entries +before the optional key projection and display cap. + +## Verification + +Run `node --test tests/app-storage.test.ts tests/app-storage-redaction.test.ts`. +The tests exercise real MCP responses, both output channels, selected/unselected +stores, nested and malformed JSON, redaction before truncation, credential column +projection, actual SQLite blobs and key/value rows, static errors, pagination, +and unchanged source database bytes. They use synthetic secrets only. + +Before submission, demonstrate the final ZIP on a synthetic app containing +`password`, `access_token`, a harmless `theme` value, nested JSON credentials, and +a SQLite `key`/`value` table. Show `[REDACTED]` in the tool result and panel, +`theme` still readable, one-store scope excluding unrelated stores, and a projected +value column still redacted for a credential row. Do not record production data. diff --git a/docs/telemetry.md b/docs/telemetry.md index 1c0e1fd3..7aa86b4e 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -217,15 +217,17 @@ The Storage tool (`mobile_app_storage`, `mobile_app_storage_table`, `mobile_clea surface. The server records `app_storage.operation.duration` in milliseconds and `app_storage.operation.samples` per operation (`read`, `clear`, `reset`, `table`) with the device platform and a success or failure outcome. The duration covers listing the data -directory, reading and decoding every recognized store, and for clears stopping the -app first. Unexpected failures report under `app_storage.` with a fixed +directory, reading and decoding the requested stores, filtering results before +returning them, and for clears stopping the app first. Scoped reads skip decoding +unselected stores; duration therefore depends on the requested scope. Unexpected failures report under `app_storage.` with a fixed message; a release Android build, a missing app, or a stopped device counts only through `server.error.outcome`. The panel records `ui.storage.list_apps`, `.icons`, `.read`, `.table`, `.clear`, `.reset`, and `.launch` round trips, `ui.storage.process` for validating a snapshot and computing the Changes feed, gauges `ui.storage.stores` and `ui.storage.entries`, and counts `ui.storage.failure`. Bundle IDs, store names, paths, keys, values, and -file names are never sent; table names and cell values from `mobile_app_storage_table` -stay local too. Since 0.1.161 the Changes tab reads storage again every +file names are never sent to Sentry. Filtered table names and cell values from +`mobile_app_storage_table` are returned to the host application; this is separate +from telemetry. See [storage result filtering](storage-privacy.md). Since 0.1.161 the Changes tab reads storage again every 5 seconds while it is open and the page is visible; each read is one `ui.storage.read` sample, and the interval stops when the tab, panel, or page is hidden. diff --git a/package-lock.json b/package-lock.json index 523fb789..389b2edd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.162", + "version": "0.1.163", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-dev-plugin", - "version": "0.1.162", + "version": "0.1.163", "dependencies": { "@base-ui/react": "^1.8.0", "@callstack/tracesift-explore": "^0.5.0", diff --git a/package.json b/package.json index e71013d7..34f8d697 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.162", + "version": "0.1.163", "private": true, "type": "module", "engines": { diff --git a/src/server/app-storage/reader.ts b/src/server/app-storage/reader.ts index 052e4569..e132929f 100644 --- a/src/server/app-storage/reader.ts +++ b/src/server/app-storage/reader.ts @@ -10,8 +10,9 @@ import type { AppContainer, ContainerFile } from "./container.ts"; import { basename, classifyFiles, nullEntry, parseDataStorePreferences, parsePlistXml, parseSharedPreferences, plistEntries, storeId, textEntry } from "./formats.ts"; import type { ParsedEntry, StoreCandidate } from "./formats.ts"; import { decodeMmkv, guessMmkvValue } from "./mmkv.ts"; +import { REDACTED, redactStorageEntry, redactStorageValue, sensitiveStorageName, sensitiveStorageText, sensitiveStoragePath } from "./redaction.ts"; -export type ReadOptions = { entries: number; valueLength: number; bundleId: string; run: CommandRunner }; +export type ReadOptions = { entries: number; valueLength: number; bundleId: string; run: CommandRunner; store?: string; keys?: string[] }; // AsyncStorage databases are SQLite files recognized by their tables, so reading one can change its kind. type StoreContent = { status: StorageStore["status"]; note?: string; entries: ParsedEntry[]; kind?: StoreKind }; @@ -118,10 +119,13 @@ async function mapLimited(items: readonly T[], limit: number, work: (item: return results; } -export async function readStorage(container: AppContainer, { entries: entryLimit, valueLength, bundleId, run }: ReadOptions): Promise { +export async function readStorage(container: AppContainer, { entries: entryLimit, valueLength, bundleId, run, store, keys }: ReadOptions): Promise { const files = await container.list(); const sizes = new Map(files.map(file => [file.path, file.bytes])); - const candidates = classifyFiles(files.map(file => file.path), container.platform); + const allCandidates = classifyFiles(files.map(file => file.path), container.platform).filter(candidate => !sensitiveStoragePath(candidate.path)); + const candidates = store === undefined ? allCandidates : allCandidates.filter(candidate => storeId(candidate) === store); + if (store !== undefined && candidates.length === 0) throw new ExpectedOperationError("invalid_input", "That storage no longer exists. Refresh and try again."); + const selectedKeys = keys === undefined ? undefined : new Set(keys); const owner = new Map(); const stores = await mapLimited(candidates, 4, async (candidate): Promise => { const id = storeId(candidate); @@ -136,15 +140,18 @@ export async function readStorage(container: AppContainer, { entries: entryLimit id, kind: content.kind ?? candidate.kind, name: candidate.name, path: candidate.path, status: content.status, ...content.note ? { note: content.note } : {}, bytes: candidate.files.reduce((total, file) => total + (sizes.get(file) ?? 0), 0), entryCount: content.entries.length, - entries: content.entries.slice(0, entryLimit).map(entry => limitEntry(entry, valueLength)), + entries: content.entries.filter(entry => selectedKeys === undefined || selectedKeys.has(entry.key)).slice(0, entryLimit) + .map(entry => limitEntry(redactStorageEntry(entry, candidate.name), valueLength)), }; }); - const sorted = [...files].sort((a, b) => b.bytes - a.bytes).slice(0, storageLimits.files); + // A scoped request must not expose other stores' paths, sizes, or values. + const scopedFiles = files.filter(file => !sensitiveStoragePath(file.path) && (store === undefined || owner.has(file.path))); + const sorted = [...scopedFiles].sort((a, b) => b.bytes - a.bytes).slice(0, storageLimits.files); return { platform: container.platform, bundleId, readAt: Date.now(), stores, files: sorted.map((file: ContainerFile): StorageFile => ({ ...file, ...owner.has(file.path) ? { store: owner.get(file.path) } : {} })), - fileCount: files.length, - totalBytes: files.reduce((total, file) => total + file.bytes, 0), + fileCount: scopedFiles.length, + totalBytes: scopedFiles.reduce((total, file) => total + file.bytes, 0), }; } @@ -164,23 +171,34 @@ export async function clearStore(container: AppContainer, id: string) { } // One page of a table. The table must be in the database's own list, so callers cannot inject SQL. -export async function readTable(container: AppContainer, id: string, table: string, { offset, limit, valueLength, run }: { offset: number; limit: number; valueLength: number; run: CommandRunner }): Promise { +export async function readTable(container: AppContainer, id: string, table: string, { offset, limit, valueLength, run, columns: requestedColumns }: { offset: number; limit: number; valueLength: number; run: CommandRunner; columns?: string[] }): Promise { const candidate = await findStore(container, id, "sqlite"); + if (sensitiveStoragePath(candidate.path) || sensitiveStorageText(table)) throw new ExpectedOperationError("invalid_input", "Sensitive storage identifiers cannot be returned."); return withDatabase(container, candidate, async file => { - if (!(await listTables(run, file)).includes(table)) throw new ExpectedOperationError("invalid_input", `The table ${table} no longer exists. Refresh and try again.`); + if (!(await listTables(run, file)).includes(table)) throw new ExpectedOperationError("invalid_input", "That table no longer exists. Refresh and try again."); const name = quoteIdentifier(table); const [info, [count]] = await Promise.all([sqlite(run, file, `PRAGMA table_info(${name})`), sqlite(run, file, `SELECT count(*) AS total FROM ${name}`)]); - const columns = info.map(column => ({ name: String(column.name), type: String(column.type ?? "") })); - // Blobs cannot travel as JSON, so they become a hex preview no longer than the value limit. + const allColumns = info.map(column => ({ name: String(column.name), type: String(column.type ?? "") })).filter(column => !sensitiveStorageText(column.name) && !sensitiveStorageText(column.type)); + if (requestedColumns?.some(name => !allColumns.some(column => column.name === name))) { + throw new ExpectedOperationError("invalid_input", "A requested column no longer exists. Refresh and try again."); + } + const columns = requestedColumns === undefined ? allColumns : allColumns.filter(column => requestedColumns.includes(column.name)); + // Key/value tables often store credentials in a generically named value column. + // Read their key/name label even when it is outside the requested projection; + // it is only used locally to decide whether the whole row must be redacted. + const labels = allColumns.filter(column => /^(key|name|k|(?:setting|preference|property|attribute)[_-]?(?:key|name))$/i.test(column.name)); const select = columns.map((column, index) => { const quoted = quoteIdentifier(column.name); - return `CASE typeof(${quoted}) WHEN 'blob' THEN 'x''' || substr(hex(${quoted}), 1, ${valueLength}) || '''' ELSE ${quoted} END AS c${index}`; - }).join(", ") || "NULL AS c0"; - const rows = await sqlite(run, file, `SELECT ${select} FROM ${name} LIMIT ${limit} OFFSET ${offset}`); + if (sensitiveStorageName(`${table}.${column.name}`)) return `'${REDACTED}' AS c${index}`; + return `CASE typeof(${quoted}) WHEN 'blob' THEN '${REDACTED}' ELSE ${quoted} END AS c${index}`; + }); + select.push(...labels.map((column, index) => `${quoteIdentifier(column.name)} AS k${index}`)); + const rows = await sqlite(run, file, `SELECT ${select.join(", ") || "NULL AS c0"} FROM ${name} LIMIT ${limit} OFFSET ${offset}`); return { table, columns, offset, total: Number(count?.total ?? 0), - rows: rows.map(row => columns.map((_, index) => { - const cell = row[`c${index}`]; + rows: rows.map(row => columns.map((column, index) => { + const sensitiveRow = labels.some((_, label) => sensitiveStorageName(String(row[`k${label}`] ?? ""))); + const cell = sensitiveRow ? REDACTED : redactStorageValue(`${table}.${column.name}`, row[`c${index}`]); if (cell === null || cell === undefined || typeof cell === "number") return cell ?? null; const text = String(cell); return text.length > valueLength ? `${text.slice(0, valueLength)}…` : text; diff --git a/src/server/app-storage/redaction.ts b/src/server/app-storage/redaction.ts new file mode 100644 index 00000000..c2845ee9 --- /dev/null +++ b/src/server/app-storage/redaction.ts @@ -0,0 +1,71 @@ +import type { StorageEntry } from "../../shared/app-storage.ts"; +import type { ParsedEntry } from "./formats.ts"; + +export const REDACTED = "[REDACTED]"; +const maxText = 1_000_000; +const maxDepth = 16; +const maxNodes = 10_000; + +// Conservative field-name matching, including common personal-data fields. This +// is defense in depth, not a guarantee that arbitrary app data is non-sensitive. +export function sensitiveStorageName(name: string): boolean { + const normalized = name.normalize("NFKC").toLowerCase().replace(/[^a-z0-9]/g, ""); + return /password|passwd|passphrase|pwd|secret|token|credential|auth|cookie|session|apikey|privatekey|signingkey|encryptionkey|accesskey|clientkey|email|phone|address|birth|socialsecurity|ssn|creditcard|cardnumber|cvv|medical|health/.test(normalized); +} + +export function sensitiveStorageText(text: string): boolean { + return /\b(?:bearer|basic)\s+\S+/i.test(text) + || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text) + || /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*)?/.test(text) + || /\b(?:gh[pousr]_|github_pat_|sk-(?:proj-|live_|test_)?|AKIA|ASIA)[A-Za-z0-9_-]{8,}/.test(text) + || /[a-z][a-z0-9+.-]*:\/\/[^\s/@]+:[^\s/@]+@/i.test(text) + || /\b(?:password|passwd|pwd|secret|token|api[_-]?key|authorization|cookie)\b\s*[=:]/i.test(text) + || /[\w.+-]+@[\w.-]+\.[a-z]{2,}/i.test(text) + // Opaque hex/base64/URL-safe strings can be encoded credentials. Do not decode + // them for display, and do not expose a partial token through truncation. + || /[A-Za-z0-9+/_=-]{32,}/.test(text); +} + +export function sensitiveStoragePath(path: string): boolean { + return path.split(/[\\/]/).some(part => sensitiveStorageText(part)); +} + +/** Sanitize the complete value before applying any display-length limit. */ +export function redactStorageValue(name: string, value: unknown, binary = false): string | number | boolean | null { + if (binary || sensitiveStorageName(name)) return REDACTED; + let nodes = 0; + function visit(value: unknown, depth: number): unknown { + if (++nodes > maxNodes || depth > maxDepth) throw new Error("Storage value exceeds redaction limits"); + if (value === null || typeof value === "number" || typeof value === "boolean") return value; + if (typeof value === "string") { + if (value.length > maxText) throw new Error("Storage value exceeds redaction limits"); + const trimmed = value.trim(); + if (/^[{[\"]/.test(trimmed)) { + let parsed: unknown; + try { parsed = JSON.parse(trimmed); } + catch { return REDACTED; } // Never inspect only a prefix of malformed/nested JSON. + const safe = visit(parsed, depth + 1); + return typeof safe === "string" ? safe : JSON.stringify(safe); + } + return sensitiveStorageText(value) ? REDACTED : value; + } + if (Array.isArray(value)) return value.map(item => visit(item, depth + 1)); + if (typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [ + sensitiveStorageText(key) ? REDACTED : key, + sensitiveStorageName(key) || sensitiveStorageText(key) ? REDACTED : visit(item, depth + 1), + ])); + return REDACTED; + } + try { + const safe = visit(value, 0); + return typeof safe === "object" && safe !== null ? JSON.stringify(safe) : safe as string | number | boolean | null; + } catch { return REDACTED; } +} + +export function redactStorageEntry(entry: ParsedEntry, context = ""): StorageEntry { + // Table entries are schema summaries, not cell values. Actual rows use the same + // value filter in readTable. Keep table names so they remain selectable. + const value = sensitiveStorageText(entry.key) ? REDACTED : entry.type === "table" ? (sensitiveStorageText(entry.value) ? REDACTED : entry.value) + : String(redactStorageValue(`${context}.${entry.key}`, entry.value, entry.type === "data")); + return { ...entry, key: sensitiveStorageText(entry.key) ? REDACTED : entry.key, value, ...(value === REDACTED ? { type: "string" as const } : {}) }; +} diff --git a/src/server/app-storage/tools.ts b/src/server/app-storage/tools.ts index 00a7d21d..0b559438 100644 --- a/src/server/app-storage/tools.ts +++ b/src/server/app-storage/tools.ts @@ -2,8 +2,7 @@ import { z } from "zod"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { captureServerError, recordAppStorageOperation } from "../telemetry.ts"; -import { errorMessage } from "../../shared/protocol.ts"; -import { expectedOutcome } from "../../shared/error-reporting.ts"; +import { ExpectedOperationError, expectedOutcome } from "../../shared/error-reporting.ts"; import { appTargetShape, bundleIdSchema } from "../../shared/app-control.ts"; import { storageLimits } from "../../shared/app-storage.ts"; import { systemCommands } from "../app-control/commands.ts"; @@ -27,6 +26,17 @@ export function createContainers(commands: Commands = systemCommands): Container const bundleId = bundleIdSchema.describe("App bundle ID or Android package name."); const storeIdSchema = z.string().max(4096); +// Never relay device/SQLite diagnostics: even expected failures can contain paths, +// table names, SQL fragments, or values. Outcome categories retain useful recovery advice. +function safeStorageError(error: unknown): string { + switch (expectedOutcome(error)) { + case "invalid_input": return "The app, store, table, or requested fields are unavailable or invalid. Refresh the selected app storage and check the request."; + case "unsupported_platform": return "App storage requires an iOS simulator or a debuggable Android app and the platform SDK. Physical iOS and Android release-build reads are unsupported."; + case "device_unavailable": return "The selected device is unavailable. Check that it is booted, connected, and authorized."; + case "cancelled": return "App storage operation cancelled."; + default: return "App storage operation failed. Check the selected app and device, then retry."; + } +} const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; export function registerAppStorageTools(server: McpServer, commands: Commands = systemCommands, driverFor: DriverFactory = createDrivers(commands), containerFor: ContainerFactory = createContainers(commands)) { @@ -43,40 +53,42 @@ export function registerAppStorageTools(server: McpServer, commands: Commands = recordAppStorageOperation(operation, platform, "failed", performance.now() - startedAt); // Paths, keys, and values can be personal, so Sentry receives only the operation. captureServerError(expectedOutcome(error) === undefined ? new Error(`App storage ${operation} failed.`) : error, `app_storage.${operation}`); - return { isError: true, content: [{ type: "text" as const, text: errorMessage(error) }] }; + return { isError: true, content: [{ type: "text" as const, text: safeStorageError(error) }] }; } } registerAppTool(server, "mobile_app_storage", { title: "Read app storage", - description: "Read an app's saved data (MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite, largest files) on iOS simulators and debuggable Android builds. MMKV types are guessed.", + description: "Read an app's saved data (MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite, largest files) on iOS simulators and debuggable Android builds. MMKV types are guessed. Values under recognized sensitive keys, credential-like text and binary data are redacted before results reach the host or panel. Use store and keys to narrow the result; filtering is not a guarantee that arbitrary app data is non-sensitive. Use synthetic test data.", inputSchema: { ...appTargetShape, bundleId, - store: storeIdSchema.optional().describe("A store ID to return alone."), + store: storeIdSchema.optional().describe("Read only this store; other stores are not decoded or returned."), + keys: z.array(z.string().min(1).max(4096)).min(1).max(100).optional().describe("Exact keys to return from the specified store. Requires store."), entries: z.number().int().min(1).max(storageLimits.maxEntries).optional(), valueLength: z.number().int().min(40).max(storageLimits.maxValueLength).optional(), }, annotations: readOnly, _meta: { ui: { visibility: ["app", "model"] } }, }, input => run("read", input, async container => { - const snapshot = await readStorage(container, { bundleId: input.bundleId, entries: input.entries ?? storageLimits.entries, valueLength: input.valueLength ?? storageLimits.valueLength, run: commands.run }); - return input.store ? { ...snapshot, stores: snapshot.stores.filter(store => store.id === input.store) } : snapshot; + if (input.keys !== undefined && input.store === undefined) throw new ExpectedOperationError("invalid_input", "Key selection requires a store."); + return readStorage(container, { bundleId: input.bundleId, entries: input.entries ?? storageLimits.entries, valueLength: input.valueLength ?? storageLimits.valueLength, run: commands.run, store: input.store, keys: input.keys }); })); registerAppTool(server, "mobile_app_storage_table", { title: "Read a database table", - description: "Read rows from a SQLite table listed by mobile_app_storage, a page at a time. Returns column names and types, the total row count, and cells as text, numbers, or null; blobs come back as x'…' hex.", + description: "Read rows from a SQLite table listed by mobile_app_storage, a page at a time. Returns column names/types, total row count and filtered cells. Sensitive columns, credential-like values, sensitive key/value rows and blobs are redacted. Use columns and a small limit to minimize data returned; use synthetic test data.", inputSchema: { ...appTargetShape, bundleId, store: storeIdSchema.describe("A SQLite store ID from mobile_app_storage."), table: z.string().min(1).max(512).describe("A table name listed for that store."), + columns: z.array(z.string().min(1).max(512)).min(1).max(100).optional().describe("Exact columns to return; unselected cells are not read except key/name labels used locally for credential filtering."), offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(storageLimits.maxTableRows).optional().describe(`Rows to return; defaults to ${storageLimits.tableRows}.`), }, annotations: readOnly, _meta: { ui: { visibility: ["app", "model"] } }, }, input => run("table", input, container => readTable(container, input.store, input.table, { - offset: input.offset ?? 0, limit: input.limit ?? storageLimits.tableRows, valueLength: storageLimits.tableValueLength, run: commands.run, + offset: input.offset ?? 0, limit: input.limit ?? storageLimits.tableRows, valueLength: storageLimits.tableValueLength, run: commands.run, columns: input.columns, }))); registerAppTool(server, "mobile_clear_app_storage", { @@ -92,6 +104,7 @@ export function registerAppStorageTools(server: McpServer, commands: Commands = await container.reset(); return { cleared: "all", stopped: true }; } - return { cleared: (await clearStore(container, input.store)).name, stopped: true }; + await clearStore(container, input.store); + return { cleared: "store", stopped: true }; })); } diff --git a/src/shared/app-storage.ts b/src/shared/app-storage.ts index fac1503c..1ff69ff6 100644 --- a/src/shared/app-storage.ts +++ b/src/shared/app-storage.ts @@ -11,7 +11,7 @@ export const storeKindLabels: Record = { export const entryTypes = ["string", "json", "number", "integer", "boolean", "date", "data", "array", "object", "null", "table"] as const; export type EntryType = typeof entryTypes[number]; -// Values arrive as display text: JSON for json, arrays and objects, hex for data. `guessed` marks a type +// Values arrive as filtered display text: JSON for json, arrays and objects. Binary values are redacted. `guessed` marks a type // inferred from bytes, because MMKV files do not record value types. const entry = z.object({ key: z.string(), @@ -46,7 +46,7 @@ export const storageSnapshotSchema = z.object({ bundleId: z.string(), readAt: z.number(), stores: z.array(store), - // Largest files first, capped; fileCount and totalBytes cover the whole data directory. + // Largest allowed files first, capped; totals cover the requested store or app scope. files: z.array(file), fileCount: z.number().int().nonnegative(), totalBytes: z.number().int().nonnegative(), @@ -58,7 +58,7 @@ export const storageClearSchema = z.object({ cleared: z.string(), stopped: z.boo // Defaults keep model-visible results small; the panel asks for more. Tables are read a page of rows at a time. export const storageLimits = { entries: 200, maxEntries: 2000, valueLength: 300, maxValueLength: 16000, files: 400, tableRows: 100, maxTableRows: 500, tableValueLength: 2000 }; -// One page of rows from a SQLite table. Cells are text, numbers, or null; blobs arrive as x'…' hex. +// One filtered page of rows from SQLite. Cells are text, numbers, or null; blobs are redacted. const tableCell = z.union([z.string(), z.number(), z.null()]); export const storageTableSchema = z.object({ table: z.string(), diff --git a/src/shared/version.ts b/src/shared/version.ts index bb9747e8..2783690b 100644 --- a/src/shared/version.ts +++ b/src/shared/version.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = "0.1.162"; +export const PLUGIN_VERSION = "0.1.163"; diff --git a/tests/app-storage-redaction.test.ts b/tests/app-storage-redaction.test.ts new file mode 100644 index 00000000..356c135f --- /dev/null +++ b/tests/app-storage-redaction.test.ts @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as Sentry from "@sentry/node"; +import type { Envelope } from "@sentry/core"; +import { scrubMetric } from "../src/shared/telemetry.ts"; +import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { REDACTED, redactStorageValue } from "../src/server/app-storage/redaction.ts"; +import { registerAppStorageTools } from "../src/server/app-storage/tools.ts"; +import { SimulatorContainer } from "../src/server/app-storage/container.ts"; +import type { AppContainer } from "../src/server/app-storage/container.ts"; +import { systemCommands } from "../src/server/app-control/commands.ts"; +import type { AppDriver } from "../src/server/app-control/driver.ts"; +import { ExpectedOperationError } from "../src/shared/error-reporting.ts"; +import { storageSnapshotSchema, storageTableSchema } from "../src/shared/app-storage.ts"; + +const target = { platform: "ios", deviceId: "B5C969F6-58A4-4C31-AB12-FB9E56D681DE", bundleId: "com.example" }; +const token = "ghp_0123456789abcdefghijklmnopqrstuvwxyz"; +const containerFor = (files: Record, reads: string[] = []): AppContainer => ({ + platform: "ios", + list: async () => Object.entries(files).map(([path, value]) => ({ path, bytes: Buffer.byteLength(value) })), + read: async path => { reads.push(path); if (!(path in files)) throw new Error("unexpected read"); return Buffer.from(files[path]); }, + remove: async () => { throw new Error("Must not delete data"); }, + reset: async () => { throw new Error("Must not reset data"); }, +}); +async function connect(container: AppContainer) { + const server = new McpServer({ name: "storage-redaction-test", version: "1" }); + registerAppStorageTools(server, systemCommands, () => ({} as AppDriver), async () => container); + const client = new Client({ name: "test", version: "1" }); + const [c, s] = InMemoryTransport.createLinkedPair(); + await server.connect(s); + await client.connect(c); + return { client, close: async () => { await client.close(); await server.close(); } }; +} + +// Assert against the entire wire response, including both text and structured content. +function absent(result: unknown, secrets: string[]) { + const serialized = JSON.stringify(result); + for (const secret of secrets) assert.ok(!serialized.includes(secret), `Leaked synthetic fixture value: ${secret}`); +} + +test("storage filtering handles sensitive names, nested objects, encoded JSON, opaque data and known credential formats", () => { + for (const key of ["access_token", "refreshToken", "PASSWORD", "API_KEY", "session.id", "private-key", "email", "creditCardNumber"]) { + assert.equal(redactStorageValue(key, "small-secret"), REDACTED, key); + } + for (const value of [token, "Bearer short-secret", "Basic dXNlcjpwYXNz", "https://user:pass@example.com/path", "-----BEGIN RSA PRIVATE KEY-----\nbody", "password=short", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig", "person@example.com"]) { + assert.equal(redactStorageValue("neutral", value), REDACTED, value); + } + const nested = JSON.stringify({ theme: "dark", prefs: [{ password: "nested-secret", enabled: true }], encoded: JSON.stringify({ apiKey: "encoded-secret", count: 2 }) }); + const filtered = JSON.parse(String(redactStorageValue("settings", nested))); + assert.deepEqual(filtered.prefs, [{ password: REDACTED, enabled: true }]); + assert.deepEqual(JSON.parse(filtered.encoded), { apiKey: REDACTED, count: 2 }); + assert.equal(filtered.theme, "dark"); + assert.equal(redactStorageValue("blob", "1234", true), REDACTED); + assert.equal(redactStorageValue("settings", '{"password":"partial-secret"'), REDACTED); + assert.equal(redactStorageValue("settings", "[".repeat(30) + '"deep-secret"' + "]".repeat(30)), REDACTED); + assert.equal(redactStorageValue("settings", JSON.stringify({ value: "x".repeat(1_000_001) })), REDACTED); +}); + +test("scoped MCP reads never decode or return unrelated stores and redact before truncation", async t => { + const reads: string[] = []; + const path = "Library/Application Support/com.example/RCTAsyncLocalStorage_V1/manifest.json"; + const container = containerFor({ + [path]: JSON.stringify({ password: "pvt-a", nested: JSON.stringify({ summary: "safe text ".repeat(80), token: "pvt-b" }), theme: "dark", tokenAfterPrefix: "pvt-c", generic: "safe prefix ".repeat(10) + token }), + "Library/Preferences/unrelated.plist": "unrelated-private-value", + "Documents/person@example.com.txt": "personal-path", + }, reads); + const { client, close } = await connect(container); t.after(close); + const result = await client.callTool({ name: "mobile_app_storage", arguments: { ...target, store: `async-storage:${path}`, valueLength: 40 } }); + assert.equal(result.isError, undefined); + const snapshot = storageSnapshotSchema.parse(result.structuredContent); + assert.deepEqual(reads, [path]); + assert.deepEqual(snapshot.files.map(file => file.path), [path]); + assert.equal(snapshot.fileCount, 1); + const entries = Object.fromEntries(snapshot.stores[0].entries.map(entry => [entry.key, entry])); + assert.equal(entries.password.value, REDACTED); + assert.equal(entries.generic.value, REDACTED); + assert.equal(entries.theme.value, "dark"); + assert.equal(entries.nested.truncated, true); + absent(result, ["pvt-a", "pvt-b", "pvt-c", token, "unrelated", "person@example.com", "private-value"]); + assert.deepEqual(JSON.parse((result.content as { text: string }[])[0].text), result.structuredContent); + const filtered = await client.callTool({ name: "mobile_app_storage", arguments: { ...target, store: `async-storage:${path}`, keys: ["theme"] } }); + assert.deepEqual(storageSnapshotSchema.parse(filtered.structuredContent).stores[0].entries.map(entry => entry.key), ["theme"]); + const before = reads.length; + const missing = await client.callTool({ name: "mobile_app_storage", arguments: { ...target, store: "async-storage:missing" } }); + assert.equal(missing.isError, true); + assert.equal(reads.length, before); +}); + +test("unscoped MCP reads filter plaintext plist values and sensitive metadata", async t => { + const container = containerFor({ + "Library/Preferences/com.example.plist": `passwordplist-privatethemedarkblobc2VjcmV0${token}credential-key-value`, + "Documents/person@example.com.txt": "ignore", + "Library/Preferences/person@example.com.plist": "ignore", + }); + const { client, close } = await connect(container); t.after(close); + const result = await client.callTool({ name: "mobile_app_storage", arguments: target }); + assert.equal(result.isError, undefined); + absent(result, ["plist-private", "736563726574", token, "person@example.com", "credential-key-value"]); + assert.ok(JSON.stringify(result).includes("dark")); +}); + +test("MCP storage errors do not echo raw or expected diagnostic content", async t => { + for (const error of [new Error("SQL pvt-error /private/path"), new ExpectedOperationError("device_unavailable", "pvt-error /private/path"), new ExpectedOperationError("invalid_input", "pvt-error /private/path")]) { + const container = containerFor({}); container.list = async () => { throw error; }; + const { client, close } = await connect(container); t.after(close); + const result = await client.callTool({ name: "mobile_app_storage", arguments: target }); + assert.equal(result.isError, true); + absent(result, ["pvt-error", "/private/path"]); + } +}); + +test("MCP SQLite results filter columns, key/value rows, nested JSON and blobs before crossing either output channel", { skip: process.platform !== "darwin" }, async t => { + const root = await mkdtemp(join(tmpdir(), "storage-redaction-")); t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, "Documents")); + const path = join(root, "Documents/app.db"); + execFileSync("/usr/bin/sqlite3", [path, ` + CREATE TABLE settings (key TEXT, value TEXT, password TEXT, data BLOB, count INTEGER); + INSERT INTO settings VALUES ('theme', 'dark', 'sql-password', x'736563726574', 2); + INSERT INTO settings VALUES ('refresh_token', 'sql-refresh', 'sql-password', NULL, 3); + INSERT INTO settings VALUES ('nested', '{"enabled":true,"secret":"sql-nested"}', 'sql-password', NULL, 4); + INSERT INTO settings VALUES ('generic', '${token}', 'sql-password', NULL, 5); + CREATE TABLE tokens (label TEXT, value TEXT); INSERT INTO tokens VALUES ('test', 'sql-token-table'); + `]); + const before = await readFile(path); + const { client, close } = await connect(new SimulatorContainer(root, target.deviceId, systemCommands)); t.after(close); + const args = { ...target, store: "sqlite:Documents/app.db", table: "settings", limit: 4 }; + const result = await client.callTool({ name: "mobile_app_storage_table", arguments: args }); + assert.equal(result.isError, undefined); + absent(result, ["sql-password", "sql-refresh", "sql-nested", token, "736563726574"]); + const table = storageTableSchema.parse(result.structuredContent); + assert.deepEqual(table.rows[0], ["theme", "dark", REDACTED, REDACTED, 2]); + assert.ok(table.rows[1].every(value => value === REDACTED)); + assert.deepEqual(JSON.parse(String(table.rows[2][1])), { enabled: true, secret: REDACTED }); + assert.deepEqual(JSON.parse((result.content as { text: string }[])[0].text), result.structuredContent); + // Projection cannot bypass the key/credential label by requesting only value. + const projected = await client.callTool({ name: "mobile_app_storage_table", arguments: { ...args, columns: ["value"], offset: 1, limit: 1 } }); + assert.deepEqual(storageTableSchema.parse(projected.structuredContent).rows, [[REDACTED]]); + assert.deepEqual(storageTableSchema.parse(projected.structuredContent).columns.map(column => column.name), ["value"]); + const sensitiveTable = await client.callTool({ name: "mobile_app_storage_table", arguments: { ...args, table: "tokens" } }); + absent(sensitiveTable, ["sql-token-table"]); + const invalid = await client.callTool({ name: "mobile_app_storage_table", arguments: { ...args, columns: ["value\"; DROP TABLE settings; --"] } }); + assert.equal(invalid.isError, true); + absent(invalid, ["DROP TABLE"]); + assert.deepEqual(await readFile(path), before, "Inspection must not alter the source database"); +}); + + +test("filtered MCP reads preserve operation metrics without storage content and honor telemetry opt-out", async t => { + const envelopes: Envelope[] = []; + Sentry.init({ + dsn: "https://public@example.com/1", defaultIntegrations: false, beforeSendMetric: scrubMetric, + transport: () => ({ async send(envelope) { envelopes.push(envelope); return { statusCode: 200 }; }, async flush() { return true; } }), + }); + const previous = process.env.MOBILE_DEV_TELEMETRY; + delete process.env.MOBILE_DEV_TELEMETRY; + t.after(async () => { + if (previous === undefined) delete process.env.MOBILE_DEV_TELEMETRY; + else process.env.MOBILE_DEV_TELEMETRY = previous; + await Sentry.close(); + }); + const path = "Library/Preferences/com.example.plist"; + const { client, close } = await connect(containerFor({ [path]: 'passwordprivate-metric-value' })); + t.after(close); + const result = await client.callTool({ name: "mobile_app_storage", arguments: target }); + assert.equal(result.isError, undefined); + await client.callTool({ name: "mobile_app_storage", arguments: { ...target, store: "private-missing-store" } }); + await Sentry.flush(); + const sent = JSON.stringify(envelopes); + for (const fragment of ["app_storage.operation.samples", "app_storage.operation.duration", '"surface":{"value":"storage"', '"operation":{"value":"read"', '"outcome":{"value":"success"', '"outcome":{"value":"failed"']) assert.ok(sent.includes(fragment), fragment); + absent(envelopes, ["private-metric-value", "private-missing-store", "password", path, target.bundleId, target.deviceId]); + process.env.MOBILE_DEV_TELEMETRY = "off"; + await client.callTool({ name: "mobile_app_storage", arguments: target }); + await Sentry.flush(); + assert.equal(JSON.stringify(envelopes), sent); +}); diff --git a/tests/app-storage.test.ts b/tests/app-storage.test.ts index 810d7dca..a2ef1fd0 100644 --- a/tests/app-storage.test.ts +++ b/tests/app-storage.test.ts @@ -152,7 +152,7 @@ class MemoryContainer implements AppContainer { test("snapshots read every store, cap values, and only clear files of a store that exists", async () => { const [data, meta] = await mmkv("fixture"); const [locked, lockedMeta] = await mmkv("locked"); - const long = "x".repeat(2000); + const long = "safe prose ".repeat(200); const manifest = JSON.stringify({ short: "1", long: null, missing: null }); const container = new MemoryContainer({ "Documents/mmkv/fixture": data, "Documents/mmkv/fixture.crc": meta, "Documents/mmkv/locked": locked, "Documents/mmkv/locked.crc": lockedMeta, @@ -173,7 +173,8 @@ test("snapshots read every store, cap values, and only clear files of a store th assert.equal(byName["com.example"].entries[0].value, "true"); assert.equal(snapshot.files[0].path, "Documents/big.bin"); assert.equal(snapshot.files.find(file => file.path === "Documents/mmkv/fixture")?.store, "mmkv:Documents/mmkv/fixture"); - assert.equal(snapshot.fileCount, 8); + // Opaque hash-named files are hidden from metadata; their selected store can still be decoded. + assert.equal(snapshot.fileCount, 7); const cleared = await clearStore(container, "mmkv:Documents/mmkv/fixture"); assert.equal(cleared.name, "fixture"); @@ -266,7 +267,7 @@ test("the change feed hides SDK changes until the toggle shows them", async () = panel.dispose(); }); -test("database tables are read page by page with blobs as hex, and unknown tables are rejected", { skip: process.platform !== "darwin" && "needs sqlite3" }, async () => { +test("database tables are read page by page with blobs redacted, and unknown tables are rejected", { skip: process.platform !== "darwin" && "needs sqlite3" }, async () => { const { mkdtemp, mkdir, rm } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { execFileSync } = await import("node:child_process"); @@ -283,7 +284,7 @@ test("database tables are read page by page with blobs as hex, and unknown table const first = await readTable(container, "sqlite:Documents/app.db", "items", { ...options, offset: 0 }); assert.equal(first.total, 250); assert.deepEqual(first.columns, [{ name: "id", type: "INTEGER" }, { name: "name", type: "TEXT" }, { name: "data", type: "BLOB" }]); - assert.deepEqual(first.rows[0], [1, "item 1", "x'CAFE'"]); + assert.deepEqual(first.rows[0], [1, "item 1", "[REDACTED]"]); assert.deepEqual(first.rows[1], [2, "item 2", null]); const last = await readTable(container, "sqlite:Documents/app.db", "items", { ...options, offset: 200 }); assert.deepEqual([last.offset, last.rows.length, last.rows[0][0]], [200, 50, 201]);