Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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."
}
}
},
Expand Down
5 changes: 4 additions & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions docs/storage-privacy.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<operation>` 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.<operation>` 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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex-mobile-dev-plugin",
"version": "0.1.162",
"version": "0.1.163",
"private": true,
"type": "module",
"engines": {
Expand Down
50 changes: 34 additions & 16 deletions src/server/app-storage/reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -118,10 +119,13 @@ async function mapLimited<T, R>(items: readonly T[], limit: number, work: (item:
return results;
}

export async function readStorage(container: AppContainer, { entries: entryLimit, valueLength, bundleId, run }: ReadOptions): Promise<StorageSnapshot> {
export async function readStorage(container: AppContainer, { entries: entryLimit, valueLength, bundleId, run, store, keys }: ReadOptions): Promise<StorageSnapshot> {
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<string, string>();
const stores = await mapLimited(candidates, 4, async (candidate): Promise<StorageStore> => {
const id = storeId(candidate);
Expand All @@ -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),
};
}

Expand All @@ -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<StorageTable> {
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<StorageTable> {
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;
Expand Down
Loading
Loading