@slack/radar-mcp 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -185,4 +185,4 @@ The MCP server auto-detects the Android user ID via `ps` and targets the broadca
185
185
  Restart your Claude Code session. MCP servers are registered at session start.
186
186
 
187
187
  **Port already in use (web dashboard)**
188
- Another instance may be running. Kill it with `lsof -ti :8100 | xargs kill` or set a different port with `SLACK_RADAR_WEB_PORT=8101 npx @slack/radar-mcp web`.
188
+ If a Radar dashboard is already running on the port, launching again just opens it. If the port is held by another program, run Radar on a different port with `SLACK_RADAR_WEB_PORT=8101 npx @slack/radar-mcp web`.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * MCP tool handlers for the on-device SQLite browser.
3
+ *
4
+ * These expose the same read-only DB access the web dashboard already has (src/web/server.ts),
5
+ * but over MCP so terminal Claude can inspect the device store during a debugging session
6
+ * without opening the dashboard. All the work lives in shared/db.ts; these handlers are thin
7
+ * wrappers that resolve the bound profile and shape the result for MCP.
8
+ *
9
+ * Read-only is enforced in shared/db.ts: the copy-based design (the device file is `cat`'d to
10
+ * a throwaway local copy and never written back) plus `sqlite3 -safe` (blocks ATTACH/readfile/
11
+ * writefile/load_extension at the engine level) plus the single-statement read-only gate
12
+ * (assertReadOnlyQuery). NOTE: the copy is opened WITHOUT `-readonly` on purpose, so SQLite
13
+ * replays the WAL sidecar; the read-only guarantee does not depend on the open mode. This
14
+ * handler calls assertReadOnlyQuery() up front so a bad query is rejected BEFORE the device
15
+ * pull, and results are capped by both row count and serialized bytes (see capQueryRows) so
16
+ * neither a `SELECT *` nor a one-row `group_concat` dump can stream the whole plaintext store
17
+ * into the model context.
18
+ */
19
+ import type { DeviceTransport } from "../shared/transport.js";
20
+ /**
21
+ * Bound a result set for the model context by BOTH row count and serialized bytes. Pure (no
22
+ * I/O) so it is unit-testable without a device. Returns the rows to actually return plus the
23
+ * honest counters. truncated is true if EITHER ceiling trips. A single row larger than the
24
+ * byte budget cannot be split, so it is dropped (returnedRows 0) rather than blowing the
25
+ * budget — the note says so, and total_rows still reports the true count so the model knows
26
+ * data exists and can re-query a narrower projection.
27
+ */
28
+ export declare function capQueryRows(rows: unknown[]): {
29
+ rows: unknown[];
30
+ rowCount: number;
31
+ totalRows: number;
32
+ truncated: boolean;
33
+ note?: string;
34
+ };
35
+ export declare function handleListDatabases(transport: DeviceTransport): {
36
+ data: unknown;
37
+ isError: boolean;
38
+ };
39
+ export declare function handleListTables(transport: DeviceTransport, db: string, refresh?: boolean): Promise<{
40
+ data: unknown;
41
+ isError: boolean;
42
+ }>;
43
+ export declare function handleQueryDatabase(transport: DeviceTransport, db: string, sql: string, refresh?: boolean): Promise<{
44
+ data: unknown;
45
+ isError: boolean;
46
+ }>;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * MCP tool handlers for the on-device SQLite browser.
3
+ *
4
+ * These expose the same read-only DB access the web dashboard already has (src/web/server.ts),
5
+ * but over MCP so terminal Claude can inspect the device store during a debugging session
6
+ * without opening the dashboard. All the work lives in shared/db.ts; these handlers are thin
7
+ * wrappers that resolve the bound profile and shape the result for MCP.
8
+ *
9
+ * Read-only is enforced in shared/db.ts: the copy-based design (the device file is `cat`'d to
10
+ * a throwaway local copy and never written back) plus `sqlite3 -safe` (blocks ATTACH/readfile/
11
+ * writefile/load_extension at the engine level) plus the single-statement read-only gate
12
+ * (assertReadOnlyQuery). NOTE: the copy is opened WITHOUT `-readonly` on purpose, so SQLite
13
+ * replays the WAL sidecar; the read-only guarantee does not depend on the open mode. This
14
+ * handler calls assertReadOnlyQuery() up front so a bad query is rejected BEFORE the device
15
+ * pull, and results are capped by both row count and serialized bytes (see capQueryRows) so
16
+ * neither a `SELECT *` nor a one-row `group_concat` dump can stream the whole plaintext store
17
+ * into the model context.
18
+ */
19
+ import { assertReadOnlyQuery, listDatabases, pullDatabase, queryDatabase, pulledSize, } from "../shared/db.js";
20
+ /**
21
+ * Caps on what query_database streams into the MODEL CONTEXT. The on-device store holds tens
22
+ * of thousands of plaintext message/PII rows; an unbounded result would dump the whole store
23
+ * into the transcript (db.ts only bounds the raw sqlite output at 64MB).
24
+ *
25
+ * Two independent ceilings, because bounding rows alone is not enough: a single
26
+ * `SELECT group_concat(text) FROM messages` collapses the entire table into ONE row whose
27
+ * lone column is every message body — that sails past a row-count cap untouched. So we cap
28
+ * BOTH the row count AND the serialized byte size of the returned payload.
29
+ *
30
+ * The web dashboard is intentionally exempt (handleDbQuery in web/server.ts returns the
31
+ * uncapped set): it renders to a consenting local human behind a privacy banner, not into an
32
+ * LLM transcript, so a large render is a UI annoyance, not the context/PII harm this guards.
33
+ * (It does not paginate; the exemption is about WHO consumes the rows, not how they are shown.)
34
+ */
35
+ const MAX_QUERY_ROWS = 500;
36
+ const MAX_QUERY_BYTES = 256 * 1024; // serialized cap on rows returned to the model (~256KB)
37
+ /**
38
+ * Bound a result set for the model context by BOTH row count and serialized bytes. Pure (no
39
+ * I/O) so it is unit-testable without a device. Returns the rows to actually return plus the
40
+ * honest counters. truncated is true if EITHER ceiling trips. A single row larger than the
41
+ * byte budget cannot be split, so it is dropped (returnedRows 0) rather than blowing the
42
+ * budget — the note says so, and total_rows still reports the true count so the model knows
43
+ * data exists and can re-query a narrower projection.
44
+ */
45
+ export function capQueryRows(rows) {
46
+ const total = rows.length;
47
+ const byCount = rows.length > MAX_QUERY_ROWS ? rows.slice(0, MAX_QUERY_ROWS) : rows.slice();
48
+ // Then trim by serialized size: keep prefix rows until the JSON budget is hit. Measure UTF-8
49
+ // BYTES (Buffer.byteLength), not String.length: the MCP layer emits this payload as UTF-8 over
50
+ // stdio, and .length counts UTF-16 code units, so an emoji/CJK row (surrogate pairs: 2 code
51
+ // units, 4 UTF-8 bytes) would otherwise pass at up to ~2x the real wire size.
52
+ const kept = [];
53
+ let bytes = 0;
54
+ for (const r of byCount) {
55
+ const sz = Buffer.byteLength(JSON.stringify(r) ?? "", "utf8");
56
+ if (kept.length > 0 && bytes + sz > MAX_QUERY_BYTES)
57
+ break;
58
+ kept.push(r);
59
+ bytes += sz;
60
+ if (bytes > MAX_QUERY_BYTES)
61
+ break; // a single oversized first row trips here; dropped below
62
+ }
63
+ // If even the first kept row blew the budget, drop it: do not stream a >256KB blob.
64
+ const overBudget = kept.length === 1 && bytes > MAX_QUERY_BYTES;
65
+ const finalRows = overBudget ? [] : kept;
66
+ const truncated = finalRows.length < total;
67
+ let note;
68
+ if (overBudget) {
69
+ note = `first row alone exceeds the ${Math.round(MAX_QUERY_BYTES / 1024)}KB response budget; returned no rows. Select fewer/narrower columns (avoid group_concat/blob dumps).`;
70
+ }
71
+ else if (truncated) {
72
+ note = `result truncated to ${finalRows.length} of ${total} rows (row or byte budget); narrow with WHERE/LIMIT or fewer columns.`;
73
+ }
74
+ return { rows: finalRows, rowCount: finalRows.length, totalRows: total, truncated, note };
75
+ }
76
+ /** Resolve the Android profile Radar is bound to. Returns undefined so db.ts falls back to its first available user. */
77
+ function resolveUser(transport) {
78
+ try {
79
+ return transport.resolveActiveUser();
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ export function handleListDatabases(transport) {
86
+ try {
87
+ const { user, availableUsers, names } = listDatabases(resolveUser(transport));
88
+ return { data: { user, available_users: availableUsers, databases: names }, isError: false };
89
+ }
90
+ catch (e) {
91
+ return { data: { error: e.message, databases: [] }, isError: true };
92
+ }
93
+ }
94
+ export async function handleListTables(transport, db, refresh = false) {
95
+ if (!db)
96
+ return { data: { error: "db is required" }, isError: true };
97
+ const user = resolveUser(transport);
98
+ try {
99
+ // force=refresh re-pulls the live device copy; default reuses the cached pull so a
100
+ // multi-MB store is not re-streamed on every call.
101
+ await pullDatabase(db, user, refresh);
102
+ const tableRows = (await queryDatabase(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", user));
103
+ const tables = [];
104
+ for (const t of tableRows) {
105
+ let rows = "?";
106
+ try {
107
+ rows = (await queryDatabase(db, `SELECT COUNT(*) c FROM "${t.name}"`, user))[0].c;
108
+ }
109
+ catch {
110
+ // a view or virtual table may not COUNT cleanly; leave "?"
111
+ }
112
+ tables.push({ name: t.name, rows });
113
+ }
114
+ return { data: { db, size_bytes: pulledSize(db, user), tables }, isError: false };
115
+ }
116
+ catch (e) {
117
+ return { data: { error: e.message, tables: [] }, isError: true };
118
+ }
119
+ }
120
+ export async function handleQueryDatabase(transport, db, sql, refresh = false) {
121
+ if (!db)
122
+ return { data: { error: "db is required" }, isError: true };
123
+ if (!sql)
124
+ return { data: { error: "sql is required" }, isError: true };
125
+ // Gate the SQL BEFORE the device pull: a rejected write/exfil query must not trigger a
126
+ // multi-MB `exec-out cat` first. queryDatabase re-checks, so this is an early-out, not the
127
+ // sole boundary. Running device-free, it also makes the read-only gate unit-testable here.
128
+ try {
129
+ assertReadOnlyQuery(sql);
130
+ }
131
+ catch (e) {
132
+ return { data: { error: e.message, rows: [] }, isError: true };
133
+ }
134
+ const user = resolveUser(transport);
135
+ try {
136
+ // force=refresh re-pulls the live device copy; default reuses the cached pull. Pass
137
+ // refresh:true when the device state may have changed since the first query this session.
138
+ await pullDatabase(db, user, refresh);
139
+ const rows = await queryDatabase(db, sql, user);
140
+ // Cap what goes into the model context by BOTH row count and serialized bytes: the store
141
+ // is plaintext PII and a broad SELECT (or a group_concat that collapses the table into one
142
+ // huge row) would otherwise dump it into the transcript. See capQueryRows.
143
+ const capped = capQueryRows(rows);
144
+ return {
145
+ data: {
146
+ db,
147
+ sql,
148
+ row_count: capped.rowCount,
149
+ total_rows: capped.totalRows,
150
+ truncated: capped.truncated,
151
+ ...(capped.note ? { note: capped.note } : {}),
152
+ rows: capped.rows,
153
+ },
154
+ isError: false,
155
+ };
156
+ }
157
+ catch (e) {
158
+ return { data: { error: e.message, rows: [] }, isError: true };
159
+ }
160
+ }
package/dist/mcp/index.js CHANGED
@@ -12,6 +12,8 @@ import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js
12
12
  import { stopCapture } from "../shared/logcat-capture.js";
13
13
  import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
14
14
  import { buildBufferPath, createSnapshot } from "./snapshot.js";
15
+ import { handleListDatabases, handleListTables, handleQueryDatabase, } from "./db-tools.js";
16
+ import { cleanupPulledDatabases, initializeDatabasePath, resetDbState, } from "../shared/db.js";
15
17
  import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
16
18
  // --- Subcommand + bin-name dispatch ---
17
19
  //
@@ -164,7 +166,8 @@ async function openDashboardSpec(spec) {
164
166
  return r.json;
165
167
  }
166
168
  if (wantsWeb) {
167
- await import("../web/bin.js");
169
+ const { startWebServer } = await import("../web/bin.js");
170
+ startWebServer();
168
171
  }
169
172
  else {
170
173
  await runMcpServer();
@@ -183,6 +186,17 @@ async function runMcpServer() {
183
186
  }
184
187
  const device = createTransport();
185
188
  setTransport(device);
189
+ // Scope pulled-DB copies to THIS process by pid, then clean them on exit (mirrors the web
190
+ // server's lifecycle). The DB tools write plaintext message-store copies to a 0600 tmp dir;
191
+ // pid-scoping keeps two concurrent Radar MCP servers from sharing a dir and clobbering each
192
+ // other's live pulls (a fixed port would collide, since every MCP uses the same RADAR_PORT).
193
+ // A hard crash leaks this process's dir until the OS sweeps tmp; the files stay 0600 in a
194
+ // 0700 dir, so that is a bounded exposure, not a cross-user leak.
195
+ initializeDatabasePath(process.pid);
196
+ cleanupPulledDatabases();
197
+ for (const sig of ["SIGINT", "SIGTERM", "exit"]) {
198
+ process.once(sig, cleanupPulledDatabases);
199
+ }
186
200
  let sessionEnabled = false;
187
201
  function radarRequest(requestPath) {
188
202
  return new Promise((resolve, reject) => {
@@ -260,6 +274,10 @@ async function runMcpServer() {
260
274
  async function handleSessionEnable(args) {
261
275
  const timeout = args?.timeout_minutes || 5;
262
276
  sessionEnabled = true;
277
+ // A re-enable may be a different device or a re-armed profile, so drop the cached DB
278
+ // user set + sqlite path; the next DB tool call re-detects the profiles fresh. Mirrors
279
+ // the web server's handleEnable (server.ts), which resets for the same reason.
280
+ resetDbState();
263
281
  const forwarded = device.ensureForward();
264
282
  if (!forwarded) {
265
283
  sessionEnabled = false;
@@ -562,7 +580,7 @@ async function runMcpServer() {
562
580
  }
563
581
  }
564
582
  // --- MCP Server ---
565
- const server = new Server({ name: "slack-radar", version: "1.5.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
583
+ const server = new Server({ name: "slack-radar", version: "1.6.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
566
584
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
567
585
  tools: TOOL_DEFINITIONS,
568
586
  }));
@@ -617,6 +635,20 @@ async function runMcpServer() {
617
635
  if (name === "ping") {
618
636
  return handlePing(toolArgs);
619
637
  }
638
+ if (name === "list_databases") {
639
+ const { data, isError } = handleListDatabases(device);
640
+ return textResult(data, isError);
641
+ }
642
+ if (name === "list_tables") {
643
+ const { db, refresh } = toolArgs;
644
+ const { data, isError } = await handleListTables(device, db ?? "", refresh === true);
645
+ return textResult(data, isError);
646
+ }
647
+ if (name === "query_database") {
648
+ const { db, sql, refresh } = toolArgs;
649
+ const { data, isError } = await handleQueryDatabase(device, db ?? "", sql ?? "", refresh === true);
650
+ return textResult(data, isError);
651
+ }
620
652
  const apiPath = buildApiPath(name, toolArgs);
621
653
  if (apiPath === null) {
622
654
  return textResult(`Unknown tool: ${name}`, true);
package/dist/mcp/tools.js CHANGED
@@ -239,11 +239,11 @@ export const TOOL_DEFINITIONS = [
239
239
  },
240
240
  {
241
241
  name: "get_recent_logs",
242
- description: "Get recent logcat entries from the connected device, filtered by tag.",
242
+ description: "Get recent device logs for the Slack debug build, filtered by tag. Reads the app's own log buffer first (the DEBUG-and-above Timber lines the in-app Log Viewer shows, this app only), and falls back to an adb logcat snapshot when that buffer cannot be read. For native crashes, VERBOSE lines, or non-Slack processes, use adb logcat directly.",
243
243
  inputSchema: {
244
244
  type: "object",
245
245
  properties: {
246
- tag: { type: "string", description: "Logcat tag to filter by" },
246
+ tag: { type: "string", description: "Substring to filter log lines by (matched anywhere in the line, not an exact tag-column match)" },
247
247
  limit: { type: "number", description: "Max number of lines to return (default: 50)" },
248
248
  grep: { type: "string", description: "Optional text to grep within the filtered output" },
249
249
  },
@@ -418,6 +418,45 @@ export const TOOL_DEFINITIONS = [
418
418
  // Not read-only: it starts a local web server and pushes a spec to it.
419
419
  annotations: { readOnlyHint: false, idempotentHint: true },
420
420
  },
421
+ {
422
+ name: "list_databases",
423
+ description: "List the app's on-device SQLite databases (the message store, telemetry, etc.) for the bound profile.",
424
+ inputSchema: { type: "object", properties: {} },
425
+ annotations: { readOnlyHint: true },
426
+ },
427
+ {
428
+ name: "list_tables",
429
+ description: "List the tables (with row counts) in one on-device database. Pass a name from list_databases.",
430
+ inputSchema: {
431
+ type: "object",
432
+ properties: {
433
+ db: { type: "string", description: "Database name from list_databases" },
434
+ refresh: {
435
+ type: "boolean",
436
+ description: "Re-pull a fresh copy from the device instead of reusing the cached pull (default false)",
437
+ },
438
+ },
439
+ required: ["db"],
440
+ },
441
+ annotations: { readOnlyHint: true },
442
+ },
443
+ {
444
+ name: "query_database",
445
+ description: "Run a read-only SQL query against an on-device database and get the rows back. SELECT/PRAGMA/WITH only (writes and file access are blocked). Use it to answer 'is this row actually in the DB?' during a debug session. Results are capped by row count and total size (the store is plaintext PII); when trimmed the response sets truncated:true with total_rows, so narrow with WHERE/LIMIT and avoid group_concat/blob dumps.",
446
+ inputSchema: {
447
+ type: "object",
448
+ properties: {
449
+ db: { type: "string", description: "Database name from list_databases" },
450
+ sql: { type: "string", description: "A single read-only statement (SELECT, WITH, PRAGMA, EXPLAIN)" },
451
+ refresh: {
452
+ type: "boolean",
453
+ description: "Re-pull a fresh copy from the device before querying (default false reuses this session's cached pull)",
454
+ },
455
+ },
456
+ required: ["db", "sql"],
457
+ },
458
+ annotations: { readOnlyHint: true },
459
+ },
421
460
  ];
422
461
  /**
423
462
  * Check whether a tool call should be blocked by the session gate.
@@ -2,7 +2,8 @@ import { execSync } from "child_process";
2
2
  import fs from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
5
- import { getCaptureFilePath, isCapturing, readRecentLogs, registerCleanup, rotateCaptureIfNeeded, startCapture, stopCapture, } from "./logcat-capture.js";
5
+ import { readDebugLogBuffer } from "./debug-log.js";
6
+ import { filterLogLines, getCaptureFilePath, isCapturing, readRecentLogs, registerCleanup, rotateCaptureIfNeeded, startCapture, stopCapture, } from "./logcat-capture.js";
6
7
  const RADAR_PORT = 8099;
7
8
  const SOCKET_NAME = "slack-radar";
8
9
  const ENABLE_ACTION = "slack.debug.ENABLE_RADAR";
@@ -199,8 +200,19 @@ export class AndroidTransport {
199
200
  getRecentLogs(tag, limit, grep) {
200
201
  if (!ADB)
201
202
  throw new Error("adb not found on PATH or common SDK locations");
202
- // Preferred path: read from the session capture file so lines cannot
203
- // have been evicted from the device ring buffer between enable and now.
203
+ // Primary source: the app's own DebugLogger buffer, read off the sandbox via
204
+ // run-as. This is the app's structured log (exact tags, no logcat column
205
+ // padding) and survives the radar enable/disable cycle, so it does not lose
206
+ // lines to device-logcat ring eviction between enable and read. Target the
207
+ // profile Radar is bound to (resolveActiveUser prefers the non-zero/work
208
+ // profile), not a blind user 0, so a dual-profile device reads the right
209
+ // sandbox -- matching how the DB browser threads its user.
210
+ const appLog = readDebugLogBuffer(ADB, this.resolveActiveUser());
211
+ if (appLog !== null) {
212
+ return filterLogLines(appLog, tag, limit, grep);
213
+ }
214
+ // Fallback: read from the session capture file so lines cannot have been
215
+ // evicted from the device ring buffer between enable and now.
204
216
  if (isCapturing()) {
205
217
  // Opportunistic rotation. Stop+rename+restart when the file grows
206
218
  // past the threshold so the child never writes past a truncated EOF.
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Package version, read once from package.json at load — NOT a hardcoded copy, so it cannot
3
+ * drift from the published version. Surfaced as the `X-Radar-Version` header on /spec so a
4
+ * second launcher can tell whether the dashboard already holding the port is a different
5
+ * Radar version than itself (the adopt path notices a mismatch; it still adopts, never kills).
6
+ * Falls back to "unknown" if package.json cannot be read (should not happen in a real install).
7
+ */
8
+ export declare const RADAR_VERSION: string;
1
9
  /** Port used to reach the on-device radar HTTP server via forwarding. */
2
10
  export declare const RADAR_PORT = 8099;
3
11
  /** Host address for the forwarded connection. */
@@ -24,3 +32,10 @@ export declare const MAX_LOG_SESSION_BUFFER = 20000;
24
32
  * `SLACK_RADAR_WEB_PORT` env override.
25
33
  */
26
34
  export declare const WEB_PORT_DEFAULT = 8100;
35
+ /**
36
+ * The Slack debug build whose sandbox the host can `run-as` into. Both the
37
+ * on-device SQLite browser (db.ts) and the app log-buffer reader (debug-log.ts)
38
+ * read sandbox files through `adb exec-out "run-as <pkg> ..."`, which only works
39
+ * on a debuggable build. Defined once here so the two readers cannot drift.
40
+ */
41
+ export declare const SLACK_DEBUG_PKG = "com.Slack.internal.debug";
@@ -1,3 +1,20 @@
1
+ import { createRequire } from "module";
2
+ /**
3
+ * Package version, read once from package.json at load — NOT a hardcoded copy, so it cannot
4
+ * drift from the published version. Surfaced as the `X-Radar-Version` header on /spec so a
5
+ * second launcher can tell whether the dashboard already holding the port is a different
6
+ * Radar version than itself (the adopt path notices a mismatch; it still adopts, never kills).
7
+ * Falls back to "unknown" if package.json cannot be read (should not happen in a real install).
8
+ */
9
+ export const RADAR_VERSION = (() => {
10
+ try {
11
+ const require = createRequire(import.meta.url);
12
+ return require("../../package.json").version ?? "unknown";
13
+ }
14
+ catch {
15
+ return "unknown";
16
+ }
17
+ })();
1
18
  /** Port used to reach the on-device radar HTTP server via forwarding. */
2
19
  export const RADAR_PORT = 8099;
3
20
  /** Host address for the forwarded connection. */
@@ -24,3 +41,10 @@ export const MAX_LOG_SESSION_BUFFER = 20000;
24
41
  * `SLACK_RADAR_WEB_PORT` env override.
25
42
  */
26
43
  export const WEB_PORT_DEFAULT = 8100;
44
+ /**
45
+ * The Slack debug build whose sandbox the host can `run-as` into. Both the
46
+ * on-device SQLite browser (db.ts) and the app log-buffer reader (debug-log.ts)
47
+ * read sandbox files through `adb exec-out "run-as <pkg> ..."`, which only works
48
+ * on a debuggable build. Defined once here so the two readers cannot drift.
49
+ */
50
+ export const SLACK_DEBUG_PKG = "com.Slack.internal.debug";
@@ -13,8 +13,12 @@
13
13
  * cleaned on server start/stop (see `cleanupPulledDatabases`). The DB names are learned
14
14
  * live from the device (`listDatabases`); none are baked into this file.
15
15
  */
16
- /** Initialize DB_TMP with a port-scoped path. Call from the web server on startup. */
17
- export declare function initializeDatabasePath(port: number): void;
16
+ /**
17
+ * Initialize DB_TMP with a per-process-unique path. Call on startup. The web server scopes
18
+ * by its port; the MCP server scopes by process.pid (every MCP shares one fixed port, so a
19
+ * port key would collide between two concurrent MCP servers).
20
+ */
21
+ export declare function initializeDatabasePath(scopeKey: number): void;
18
22
  export declare function availableUsers(): string[];
19
23
  /**
20
24
  * Reset cached device state. Clears the sqlite-binary path so a device swap
@@ -50,20 +54,33 @@ export declare function listDatabases(user?: string | null): {
50
54
  */
51
55
  export declare function pullDatabase(name: string, user?: string | null, force?: boolean): Promise<string>;
52
56
  /**
53
- * Run a read-only query against a (lazily pulled) local copy.
57
+ * Read-only SQL gate. Throws on anything that is not a single read-only statement.
58
+ *
59
+ * Exported so a caller (the MCP query handler) can reject a bad query BEFORE pulling a
60
+ * multi-MB DB copy off the device. queryDatabase calls it too, so the boundary holds even
61
+ * if a caller forgets the pre-check.
54
62
  *
55
- * Read-only is NOT just about blocking writes. `sqlite3 -readonly` forbids writes to the
56
- * opened DB, but a single SELECT can still READ any host file the process can open, via
57
- * `ATTACH '/path' AS x; SELECT * FROM x.t` or `SELECT readfile('/path')` a confidentiality
58
- * bypass (the other plaintext Slack DBs, any file on disk), reachable through the query box
59
- * or a pushed db-spec's `sql`. Three layers close this:
60
- * 1. `sqlite3 -safe` engine-level: forbids ATTACH, readfile/writefile/edit, and
61
- * load_extension while still allowing ordinary SELECT/PRAGMA. This is the load-bearing
63
+ * What actually keeps this safe (the boundary does NOT rest on this gate alone):
64
+ * 1. copy-based design we only ever READ the device file (`exec-out cat`) and query a
65
+ * throwaway local copy; nothing is ever written back, so no query can mutate device
66
+ * state no matter what it does to the copy.
67
+ * 2. `sqlite3 -safe` engine-level: forbids ATTACH, readfile/writefile/edit, and
68
+ * load_extension while still allowing ordinary SELECT/PRAGMA. The load-bearing exfil
62
69
  * control; a keyword denylist alone is whack-a-mole.
63
- * 2. the guard below: only a SINGLE SELECT/PRAGMA statement (no `;`-chaining), and an
64
- * explicit reject of attach/readfile/writefile names as defense in depth.
65
- * 3. `-readonly` still blocks writes to the opened DB.
66
- * Surfaces sqlite's own error message on failure.
70
+ * 3. this gate a single SELECT/PRAGMA/WITH/EXPLAIN/VALUES statement (no `;`-chaining),
71
+ * a write-PRAGMA reject, and an explicit reject of file-access primitive names as
72
+ * defense in depth.
73
+ * NOTE: the copy is opened NORMALLY (not `-readonly`, not `immutable`) on purpose, so SQLite
74
+ * replays the `-wal` sidecar and recently-committed rows stay visible. `-readonly`/immutable
75
+ * would read the main file alone and silently drop them; writes to the throwaway copy are
76
+ * harmless. So `-readonly` is intentionally NOT passed — the read-only guarantee comes from
77
+ * (1) and (2), not from the open mode.
78
+ */
79
+ export declare function assertReadOnlyQuery(sql: string): void;
80
+ /**
81
+ * Run a read-only query against a (lazily pulled) local copy. The query is gated by
82
+ * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
83
+ * own error message on failure.
67
84
  */
68
85
  export declare function queryDatabase(name: string, sql: string, user?: string | null): Promise<unknown[]>;
69
86
  /** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
package/dist/shared/db.js CHANGED
@@ -19,13 +19,18 @@ import fs from "fs";
19
19
  import os from "os";
20
20
  import path from "path";
21
21
  import { resolveAdbPath } from "./android.js";
22
+ import { SLACK_DEBUG_PKG } from "./constants.js";
22
23
  /** The debug build whose sandbox we can `run-as` into. */
23
- const DB_PKG = "com.Slack.internal.debug";
24
- /** Dedicated tmp dir for pulled DB copies. Cleaned on start/stop; files written 0600. Port-scoped so concurrent dashboards do not collide. */
24
+ const DB_PKG = SLACK_DEBUG_PKG;
25
+ /** Dedicated tmp dir for pulled DB copies. Cleaned on start/stop; files written 0600. Scoped by a per-process key so concurrent readers do not collide. */
25
26
  let DB_TMP = path.join(os.tmpdir(), "slack-radar-db");
26
- /** Initialize DB_TMP with a port-scoped path. Call from the web server on startup. */
27
- export function initializeDatabasePath(port) {
28
- DB_TMP = path.join(os.tmpdir(), `slack-radar-db-${port}`);
27
+ /**
28
+ * Initialize DB_TMP with a per-process-unique path. Call on startup. The web server scopes
29
+ * by its port; the MCP server scopes by process.pid (every MCP shares one fixed port, so a
30
+ * port key would collide between two concurrent MCP servers).
31
+ */
32
+ export function initializeDatabasePath(scopeKey) {
33
+ DB_TMP = path.join(os.tmpdir(), `slack-radar-db-${scopeKey}`);
29
34
  }
30
35
  /** Every valid SQLite file begins with these 16 bytes. */
31
36
  const SQLITE_MAGIC = Buffer.from("SQLite format 3\0", "binary");
@@ -302,31 +307,32 @@ export async function pullDatabase(name, user, force = false) {
302
307
  return local;
303
308
  }
304
309
  /**
305
- * Run a read-only query against a (lazily pulled) local copy.
310
+ * Read-only SQL gate. Throws on anything that is not a single read-only statement.
311
+ *
312
+ * Exported so a caller (the MCP query handler) can reject a bad query BEFORE pulling a
313
+ * multi-MB DB copy off the device. queryDatabase calls it too, so the boundary holds even
314
+ * if a caller forgets the pre-check.
306
315
  *
307
- * Read-only is NOT just about blocking writes. `sqlite3 -readonly` forbids writes to the
308
- * opened DB, but a single SELECT can still READ any host file the process can open, via
309
- * `ATTACH '/path' AS x; SELECT * FROM x.t` or `SELECT readfile('/path')` a confidentiality
310
- * bypass (the other plaintext Slack DBs, any file on disk), reachable through the query box
311
- * or a pushed db-spec's `sql`. Three layers close this:
312
- * 1. `sqlite3 -safe` engine-level: forbids ATTACH, readfile/writefile/edit, and
313
- * load_extension while still allowing ordinary SELECT/PRAGMA. This is the load-bearing
316
+ * What actually keeps this safe (the boundary does NOT rest on this gate alone):
317
+ * 1. copy-based design we only ever READ the device file (`exec-out cat`) and query a
318
+ * throwaway local copy; nothing is ever written back, so no query can mutate device
319
+ * state no matter what it does to the copy.
320
+ * 2. `sqlite3 -safe` engine-level: forbids ATTACH, readfile/writefile/edit, and
321
+ * load_extension while still allowing ordinary SELECT/PRAGMA. The load-bearing exfil
314
322
  * control; a keyword denylist alone is whack-a-mole.
315
- * 2. the guard below: only a SINGLE SELECT/PRAGMA statement (no `;`-chaining), and an
316
- * explicit reject of attach/readfile/writefile names as defense in depth.
317
- * 3. `-readonly` still blocks writes to the opened DB.
318
- * Surfaces sqlite's own error message on failure.
323
+ * 3. this gate a single SELECT/PRAGMA/WITH/EXPLAIN/VALUES statement (no `;`-chaining),
324
+ * a write-PRAGMA reject, and an explicit reject of file-access primitive names as
325
+ * defense in depth.
326
+ * NOTE: the copy is opened NORMALLY (not `-readonly`, not `immutable`) on purpose, so SQLite
327
+ * replays the `-wal` sidecar and recently-committed rows stay visible. `-readonly`/immutable
328
+ * would read the main file alone and silently drop them; writes to the throwaway copy are
329
+ * harmless. So `-readonly` is intentionally NOT passed — the read-only guarantee comes from
330
+ * (1) and (2), not from the open mode.
319
331
  */
320
- export async function queryDatabase(name, sql, user) {
321
- assertSafeName(name);
322
- // Leading-keyword gate over the common read-query entry forms: SELECT, WITH
323
- // (CTEs, common against the message store), EXPLAIN, VALUES, PRAGMA. This shapes
324
- // UX; it is NOT the security boundary. A `WITH ... DELETE` would slip the gate,
325
- // but the device-safety property does not rest here — it rests on the copy-based
326
- // design (the device file is `cat`'d to a throwaway and never written back), plus
327
- // `-safe` (blocks file access), the single-statement gate, the write-PRAGMA
328
- // reject, and the file-primitive denylist below. Worst case a smuggled write
329
- // mutates the disposable copy; the device store is untouched.
332
+ export function assertReadOnlyQuery(sql) {
333
+ // Leading-keyword gate over the common read-query entry forms. This shapes UX; it is NOT
334
+ // the security boundary (a `WITH ... DELETE` would slip it, but that can only touch the
335
+ // throwaway copy). The boundary is the copy-based design + `-safe` above.
330
336
  if (!/^\s*(select|pragma|with|explain|values)\b/i.test(sql)) {
331
337
  throw new Error("only read-only statements are allowed (SELECT, WITH, EXPLAIN, VALUES, PRAGMA)");
332
338
  }
@@ -337,11 +343,8 @@ export async function queryDatabase(name, sql, user) {
337
343
  }
338
344
  // Reject the common write PRAGMAs: an assigning form (`PRAGMA user_version=5`,
339
345
  // `PRAGMA journal_mode=DELETE`) or a checkpoint (`PRAGMA wal_checkpoint(...)`).
340
- // This is not exhaustive — a few argless mutating PRAGMAs (`incremental_vacuum`,
341
- // `optimize`, `shrink_memory`) still pass. That is acceptable: the device-safety
342
- // guarantee comes from the copy-based design (we only ever read the device file
343
- // and write a throwaway local copy), not from this gate. Worst case such a PRAGMA
344
- // mutates the disposable copy; the device store is never written back.
346
+ // Not exhaustive — a few argless mutating PRAGMAs (`incremental_vacuum`, `optimize`,
347
+ // `shrink_memory`) still pass. Acceptable: worst case they mutate the disposable copy.
345
348
  if (/^\s*pragma\b/i.test(sql) && (/=/.test(sql) || /\bwal_checkpoint\b/i.test(sql))) {
346
349
  throw new Error("that PRAGMA writes; only read-only PRAGMAs are allowed");
347
350
  }
@@ -349,6 +352,15 @@ export async function queryDatabase(name, sql, user) {
349
352
  if (/\b(attach|readfile|writefile|edit|load_extension|fts3_tokenizer)\b/i.test(sql)) {
350
353
  throw new Error("that function/statement is not allowed (read-only, no file access)");
351
354
  }
355
+ }
356
+ /**
357
+ * Run a read-only query against a (lazily pulled) local copy. The query is gated by
358
+ * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
359
+ * own error message on failure.
360
+ */
361
+ export async function queryDatabase(name, sql, user) {
362
+ assertSafeName(name);
363
+ assertReadOnlyQuery(sql);
352
364
  const u = resolveUser(user);
353
365
  const local = localPath(name, u);
354
366
  if (!fs.existsSync(local))