@spool-lab/core 0.4.12 → 0.4.20

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.
Files changed (63) hide show
  1. package/dist/db/db.d.ts +10 -2
  2. package/dist/db/db.d.ts.map +1 -1
  3. package/dist/db/db.js +111 -3
  4. package/dist/db/db.js.map +1 -1
  5. package/dist/db/queries.d.ts +1 -2
  6. package/dist/db/queries.d.ts.map +1 -1
  7. package/dist/db/queries.js +23 -9
  8. package/dist/db/queries.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/projects/sessions.d.ts +29 -1
  14. package/dist/projects/sessions.d.ts.map +1 -1
  15. package/dist/projects/sessions.js +115 -11
  16. package/dist/projects/sessions.js.map +1 -1
  17. package/dist/security/index.d.ts +14 -0
  18. package/dist/security/index.d.ts.map +1 -0
  19. package/dist/security/index.js +7 -0
  20. package/dist/security/index.js.map +1 -0
  21. package/dist/security/maintenance.d.ts +15 -0
  22. package/dist/security/maintenance.d.ts.map +1 -0
  23. package/dist/security/maintenance.js +68 -0
  24. package/dist/security/maintenance.js.map +1 -0
  25. package/dist/security/profile.d.ts +47 -0
  26. package/dist/security/profile.d.ts.map +1 -0
  27. package/dist/security/profile.js +122 -0
  28. package/dist/security/profile.js.map +1 -0
  29. package/dist/security/purge.d.ts +49 -0
  30. package/dist/security/purge.d.ts.map +1 -0
  31. package/dist/security/purge.js +159 -0
  32. package/dist/security/purge.js.map +1 -0
  33. package/dist/security/repo.d.ts +141 -0
  34. package/dist/security/repo.d.ts.map +1 -0
  35. package/dist/security/repo.js +469 -0
  36. package/dist/security/repo.js.map +1 -0
  37. package/dist/security/scan.d.ts +50 -0
  38. package/dist/security/scan.d.ts.map +1 -0
  39. package/dist/security/scan.js +120 -0
  40. package/dist/security/scan.js.map +1 -0
  41. package/dist/security/types.d.ts +128 -0
  42. package/dist/security/types.d.ts.map +1 -0
  43. package/dist/security/types.js +10 -0
  44. package/dist/security/types.js.map +1 -0
  45. package/dist/security/worker.d.ts +53 -0
  46. package/dist/security/worker.d.ts.map +1 -0
  47. package/dist/security/worker.js +180 -0
  48. package/dist/security/worker.js.map +1 -0
  49. package/dist/sync/source-paths.d.ts +1 -0
  50. package/dist/sync/source-paths.d.ts.map +1 -1
  51. package/dist/sync/source-paths.js +13 -3
  52. package/dist/sync/source-paths.js.map +1 -1
  53. package/dist/sync/syncer.d.ts +8 -1
  54. package/dist/sync/syncer.d.ts.map +1 -1
  55. package/dist/sync/syncer.js +26 -14
  56. package/dist/sync/syncer.js.map +1 -1
  57. package/dist/types.d.ts +13 -0
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/util/resolve-bin.d.ts +34 -0
  60. package/dist/util/resolve-bin.d.ts.map +1 -1
  61. package/dist/util/resolve-bin.js +175 -20
  62. package/dist/util/resolve-bin.js.map +1 -1
  63. package/package.json +5 -2
@@ -0,0 +1,469 @@
1
+ // Findings + allowlist repository.
2
+ //
3
+ // Plain better-sqlite3 prepared statements. No Effect here — this
4
+ // module is called from both the scan worker (Effect, wraps these in
5
+ // Effect.sync) and the IPC handlers (Promise-shaped). Keeping it
6
+ // effect-free preserves the worker / boundary split documented in
7
+ // the spec's "Implementation: Effect TS scope" section.
8
+ //
9
+ // Invariant: `findings.value_hash` is FNV-1a, computed via the
10
+ // share-kit-aligned `hashValueForRedactExclude` so identity matches
11
+ // across surfaces (share editor's exclude list, security allowlists).
12
+ import { HIGH_SEVERITY_KINDS, INFO_SEVERITY_KINDS, severityOf } from '@spool-lab/redact';
13
+ function rowToFinding(r) {
14
+ return {
15
+ id: r.id,
16
+ sessionId: r.session_id,
17
+ messageId: r.message_id,
18
+ kind: r.kind,
19
+ valueHash: r.value_hash,
20
+ confidence: r.confidence,
21
+ provider: r.provider,
22
+ startOffset: r.start_offset,
23
+ endOffset: r.end_offset,
24
+ state: r.state,
25
+ detectedAt: r.detected_at,
26
+ stateChangedAt: r.state_changed_at,
27
+ };
28
+ }
29
+ /** Insert a batch of findings. Caller wraps in a transaction. */
30
+ export function insertFindings(db, rows) {
31
+ if (rows.length === 0)
32
+ return;
33
+ const stmt = db.prepare(`INSERT INTO findings
34
+ (session_id, message_id, kind, value_hash, confidence, provider,
35
+ start_offset, end_offset, state, state_changed_at)
36
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
37
+ for (const r of rows) {
38
+ stmt.run(r.sessionId, r.messageId, r.kind, r.valueHash, r.confidence, r.provider, r.startOffset, r.endOffset, r.state, r.state !== 'active' ? new Date().toISOString() : null);
39
+ }
40
+ }
41
+ /** Delete all non-purged rows for the providers being rescanned —
42
+ * i.e. wipe the producer's previous output so the upcoming
43
+ * `insertFindings` is the canonical truth.
44
+ *
45
+ * Includes `state='dismissed'` deliberately: those rows are derived
46
+ * state. The user's "ignore" decisions live in `allowlist_session`
47
+ * and `allowlist_global` (preserved here), and the scan path
48
+ * re-emits findings as `state='dismissed'` whenever it encounters a
49
+ * hit that matches an allowlist row. Re-inserting also keeps
50
+ * `findings.value_hash` aligned with the per-kind allowlist
51
+ * preference (security.json `kindAllowlist`) — without this delete,
52
+ * a mute → unmute cycle would accumulate phantom dismissed rows
53
+ * (one set per cycle), inflating audit counts and breaking
54
+ * `riskByCategory` totals over time.
55
+ *
56
+ * Purged rows are the only state that's NOT producer-derived —
57
+ * they correspond to destructive `messages.content_text` rewrites
58
+ * the user explicitly approved. Preserving them is the audit
59
+ * contract documented in the design doc. */
60
+ export function deleteRefreshableFindings(db, sessionId, providers) {
61
+ if (providers.length === 0)
62
+ return;
63
+ const placeholders = providers.map(() => '?').join(',');
64
+ db.prepare(`DELETE FROM findings
65
+ WHERE session_id = ?
66
+ AND state IN ('active','dismissed')
67
+ AND provider IN (${placeholders})`).run(sessionId, ...providers);
68
+ }
69
+ /** @deprecated Renamed to {@link deleteRefreshableFindings} after the
70
+ * active-only filter was found to leak phantom dismissed rows
71
+ * across mute→unmute cycles. Kept as a thin alias so callers
72
+ * outside the repo don't break mid-stack. */
73
+ export const deleteActiveFindings = deleteRefreshableFindings;
74
+ // ─── Counts (denormalised on sessions) ────────────────────────────
75
+ /** Recompute sessions.scan_finding_count + scan_high_count from the
76
+ * findings table.
77
+ *
78
+ * Counts EXCLUDE info-tier kinds (absolute-path, ip, internal-host).
79
+ * Those are stored — useful for an opt-in "Show informational
80
+ * signals" toggle — but they don't drive the Library row badge
81
+ * because their pattern-only signal has too high a false-positive
82
+ * rate to be meaningful at first glance. */
83
+ export function updateSessionCounts(db, sessionId) {
84
+ const active = db.prepare(`SELECT
85
+ SUM(CASE WHEN kind NOT IN (${infoKindsPlaceholders()}) THEN 1 ELSE 0 END) AS total,
86
+ SUM(CASE WHEN kind IN (${highKindsPlaceholders()}) THEN 1 ELSE 0 END) AS high
87
+ FROM findings
88
+ WHERE session_id = ? AND state = 'active'`).get(...INFO_SEVERITY_KINDS_ARRAY, ...HIGH_SEVERITY_KINDS_ARRAY, sessionId);
89
+ const purged = db.prepare(`SELECT COUNT(*) AS c
90
+ FROM findings
91
+ WHERE session_id = ? AND state = 'purged'`).get(sessionId);
92
+ db.prepare(`UPDATE sessions
93
+ SET scan_finding_count = ?,
94
+ scan_high_count = ?,
95
+ scan_purged_count = ?
96
+ WHERE id = ?`).run(active.total ?? 0, active.high ?? 0, purged.c, sessionId);
97
+ }
98
+ const HIGH_SEVERITY_KINDS_ARRAY = Array.from(HIGH_SEVERITY_KINDS);
99
+ const INFO_SEVERITY_KINDS_ARRAY = Array.from(INFO_SEVERITY_KINDS);
100
+ function highKindsPlaceholders() {
101
+ return HIGH_SEVERITY_KINDS_ARRAY.map(() => '?').join(',');
102
+ }
103
+ function infoKindsPlaceholders() {
104
+ return INFO_SEVERITY_KINDS_ARRAY.map(() => '?').join(',');
105
+ }
106
+ export function setSessionScanProfile(db, sessionId, profile, completedAt) {
107
+ db.prepare(`UPDATE sessions
108
+ SET scan_profile = ?, scan_completed_at = ?
109
+ WHERE id = ?`).run(profile, completedAt, sessionId);
110
+ }
111
+ /** Set every session's scan_profile to NULL — used by "Rescan all" so
112
+ * every session re-enqueues. */
113
+ export function invalidateAllScanProfiles(db) {
114
+ const r = db.prepare('UPDATE sessions SET scan_profile = NULL').run();
115
+ return r.changes;
116
+ }
117
+ /** Drop the scan profile for one session — used by the sync cascade
118
+ * when a session's messages change after first scan. */
119
+ export function invalidateSessionScanProfile(db, sessionId) {
120
+ db.prepare(`UPDATE sessions
121
+ SET scan_profile = NULL,
122
+ scan_completed_at = NULL
123
+ WHERE id = ?`).run(sessionId);
124
+ }
125
+ /** Sessions whose stored profile doesn't structurally match
126
+ * `currentProfile`. Used at boot for backfill enqueue. */
127
+ export function listSessionsNeedingScan(db, currentProfile) {
128
+ const rows = db.prepare(`SELECT id, scan_profile
129
+ FROM sessions
130
+ ORDER BY started_at DESC`).all();
131
+ const out = [];
132
+ for (const r of rows) {
133
+ if (r.scan_profile === null || r.scan_profile !== currentProfile) {
134
+ out.push(r.id);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ // ─── Reads (UI path) ──────────────────────────────────────────────
140
+ export function listFindings(db, filter) {
141
+ const where = [];
142
+ const params = [];
143
+ if (filter.sessionId !== undefined) {
144
+ where.push('session_id = ?');
145
+ params.push(filter.sessionId);
146
+ }
147
+ if (filter.kinds && filter.kinds.length > 0) {
148
+ const placeholders = filter.kinds.map(() => '?').join(',');
149
+ where.push(`kind IN (${placeholders})`);
150
+ params.push(...filter.kinds);
151
+ }
152
+ else if (filter.kind !== undefined) {
153
+ where.push('kind = ?');
154
+ params.push(filter.kind);
155
+ }
156
+ if (filter.state && filter.state !== 'any') {
157
+ where.push('state = ?');
158
+ params.push(filter.state);
159
+ }
160
+ else if (!filter.state) {
161
+ // Default: active only.
162
+ where.push("state = 'active'");
163
+ }
164
+ if (filter.severity === 'high') {
165
+ where.push(`kind IN (${highKindsPlaceholders()})`);
166
+ params.push(...HIGH_SEVERITY_KINDS_ARRAY);
167
+ }
168
+ else if (filter.severity === 'low') {
169
+ where.push(`kind NOT IN (${highKindsPlaceholders()})`);
170
+ params.push(...HIGH_SEVERITY_KINDS_ARRAY);
171
+ }
172
+ let pagination = '';
173
+ if (filter.limit !== undefined) {
174
+ pagination = ' LIMIT ? OFFSET ?';
175
+ params.push(filter.limit, filter.offset ?? 0);
176
+ }
177
+ const sql = `SELECT * FROM findings
178
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
179
+ ORDER BY detected_at DESC, id DESC${pagination}`;
180
+ const rows = db.prepare(sql).all(...params);
181
+ return rows.map(rowToFinding);
182
+ }
183
+ /** limit+1 peek to set `hasMore` without an extra COUNT round-trip. */
184
+ function paginate(filter, fetch) {
185
+ if (filter.limit === undefined)
186
+ return { rows: fetch(filter), hasMore: false };
187
+ const peeked = fetch({ ...filter, limit: filter.limit + 1 });
188
+ const hasMore = peeked.length > filter.limit;
189
+ return { rows: hasMore ? peeked.slice(0, filter.limit) : peeked, hasMore };
190
+ }
191
+ export function listFindingsPage(db, filter) {
192
+ return paginate(filter, f => listFindings(db, f));
193
+ }
194
+ /** Shared WHERE-clause builder for the session-findings join. Used by
195
+ * both `listSessionsWithFindings` (paginated rows) and
196
+ * `countSessionsWithFindings` (total) so the two stay consistent. */
197
+ function buildSessionFindingWhereSql(filter) {
198
+ const params = [];
199
+ const stateCondition = filter.state && filter.state !== 'any'
200
+ ? 'f.state = ?'
201
+ : "f.state = 'active'";
202
+ if (filter.state && filter.state !== 'any')
203
+ params.push(filter.state);
204
+ let kindCondition = '';
205
+ const explicitKinds = filter.kinds && filter.kinds.length > 0
206
+ ? filter.kinds
207
+ : filter.kind !== undefined ? [filter.kind] : undefined;
208
+ if (explicitKinds) {
209
+ const placeholders = explicitKinds.map(() => '?').join(',');
210
+ kindCondition = `AND f.kind IN (${placeholders})`;
211
+ params.push(...explicitKinds);
212
+ }
213
+ let severityCondition = '';
214
+ if (filter.severity === 'high') {
215
+ severityCondition = `AND f.kind IN (${highKindsPlaceholders()})`;
216
+ params.push(...HIGH_SEVERITY_KINDS_ARRAY);
217
+ }
218
+ else if (filter.severity === 'low') {
219
+ // Exclude both HIGH (not low) and INFO (noisy infra signals) so
220
+ // `severity:low` returns only the identity-tier kinds (email,
221
+ // phone, person-name, etc.).
222
+ severityCondition = `AND f.kind NOT IN (${highKindsPlaceholders()}) AND f.kind NOT IN (${infoKindsPlaceholders()})`;
223
+ params.push(...HIGH_SEVERITY_KINDS_ARRAY, ...INFO_SEVERITY_KINDS_ARRAY);
224
+ }
225
+ // Default exclusion: info-tier findings don't surface a session on
226
+ // their own — they're stored as an audit record (see Info drawer at
227
+ // the bottom of the page). Only when the user has explicitly pinned
228
+ // an info kind via filter.kind/filter.kinds do we let them through.
229
+ let infoExclusion = '';
230
+ if (!explicitKinds && filter.severity !== 'high' && filter.severity !== 'low') {
231
+ infoExclusion = `AND f.kind NOT IN (${infoKindsPlaceholders()})`;
232
+ params.push(...INFO_SEVERITY_KINDS_ARRAY);
233
+ }
234
+ let textCondition = '';
235
+ if (filter.text && filter.text.trim().length > 0) {
236
+ textCondition = `AND s.title LIKE ?`;
237
+ params.push(`%${filter.text.trim()}%`);
238
+ }
239
+ const whereClause = `${stateCondition} ${kindCondition} ${severityCondition} ${infoExclusion} ${textCondition}
240
+ AND COALESCE(s.message_count, 0) > 0`;
241
+ return { whereClause, params };
242
+ }
243
+ export function listSessionsWithFindings(db, filter) {
244
+ // For category/severity-aware filtering we need to compute counts
245
+ // from the findings table directly (denormalised counters can't
246
+ // express "only api-key findings"). We always recompute here.
247
+ const { whereClause, params } = buildSessionFindingWhereSql(filter);
248
+ const sql = `
249
+ SELECT
250
+ s.id AS id,
251
+ s.session_uuid AS session_uuid,
252
+ s.title AS title,
253
+ s.started_at AS started_at,
254
+ s.scan_completed_at AS scan_completed_at,
255
+ s.scan_purged_count AS purged_count,
256
+ s.message_count AS message_count,
257
+ s.model AS model,
258
+ s.cwd AS cwd,
259
+ src.name AS source,
260
+ p.display_name AS project_display_name,
261
+ SUM(1) AS finding_count,
262
+ SUM(CASE WHEN f.kind IN (${highKindsPlaceholders()}) THEN 1 ELSE 0 END) AS high_count
263
+ FROM sessions s
264
+ JOIN findings f ON f.session_id = s.id
265
+ JOIN sources src ON src.id = s.source_id
266
+ JOIN projects p ON p.id = s.project_id
267
+ WHERE ${whereClause}
268
+ GROUP BY s.id
269
+ ORDER BY high_count DESC, finding_count DESC, s.started_at DESC, s.id DESC
270
+ ${filter.limit !== undefined ? 'LIMIT ? OFFSET ?' : ''}
271
+ `;
272
+ // Placeholder order matches SQL: SELECT's CASE WHEN IN (?) binds first,
273
+ // then the WHERE clause's params.
274
+ const allParams = [...HIGH_SEVERITY_KINDS_ARRAY, ...params];
275
+ if (filter.limit !== undefined) {
276
+ allParams.push(filter.limit, filter.offset ?? 0);
277
+ }
278
+ const rows = db.prepare(sql).all(...allParams);
279
+ return rows.map(r => ({
280
+ id: r.id,
281
+ sessionUuid: r.session_uuid,
282
+ title: r.title,
283
+ startedAt: r.started_at,
284
+ scanCompletedAt: r.scan_completed_at,
285
+ findingCount: r.finding_count,
286
+ highCount: r.high_count ?? 0,
287
+ purgedCount: r.purged_count ?? 0,
288
+ source: r.source,
289
+ messageCount: r.message_count ?? 0,
290
+ model: r.model,
291
+ cwd: r.cwd,
292
+ projectDisplayName: r.project_display_name,
293
+ }));
294
+ }
295
+ export function listSessionsWithFindingsPage(db, filter) {
296
+ return paginate(filter, f => listSessionsWithFindings(db, f));
297
+ }
298
+ /** Total distinct sessions matching the same filter as
299
+ * `listSessionsWithFindings` — used by the renderer to show
300
+ * "涉及 N 个会话" without loading every page. Cheaper than the list
301
+ * query: no SELECT projection, no GROUP BY, no ORDER BY. */
302
+ export function countSessionsWithFindings(db, filter) {
303
+ const { whereClause, params } = buildSessionFindingWhereSql(filter);
304
+ const sql = `
305
+ SELECT COUNT(DISTINCT s.id) AS total
306
+ FROM sessions s
307
+ JOIN findings f ON f.session_id = s.id
308
+ JOIN sources src ON src.id = s.source_id
309
+ JOIN projects p ON p.id = s.project_id
310
+ WHERE ${whereClause}
311
+ `;
312
+ const row = db.prepare(sql).get(...params);
313
+ return row.total;
314
+ }
315
+ /** Risk by category — one row per kind that has ≥ 1 active finding.
316
+ * Drives the Watchtower-style panel on the Security page. */
317
+ export function riskByCategory(db) {
318
+ const rows = db.prepare(`SELECT kind,
319
+ COUNT(*) AS count,
320
+ COUNT(DISTINCT session_id) AS sessions
321
+ FROM findings
322
+ WHERE state = 'active'
323
+ GROUP BY kind
324
+ ORDER BY count DESC`).all();
325
+ return rows.map(r => ({
326
+ kind: r.kind,
327
+ severity: severityOf(r.kind),
328
+ count: r.count,
329
+ sessions: r.sessions,
330
+ }));
331
+ }
332
+ /** Read the live raw value for one finding. Used by the UI's review
333
+ * panel and the Purge confirm dialog. Returns null when the finding
334
+ * no longer points at a valid message (race against session
335
+ * deletion) or has been purged (offsets now point at the mask). */
336
+ export function getFindingValue(db, findingId) {
337
+ const row = db.prepare(`SELECT f.start_offset, f.end_offset, f.state, m.content_text
338
+ FROM findings f
339
+ LEFT JOIN messages m ON m.id = f.message_id
340
+ WHERE f.id = ?`).get(findingId);
341
+ if (!row || row.content_text === null)
342
+ return null;
343
+ if (row.state === 'purged')
344
+ return null;
345
+ return row.content_text.slice(row.start_offset, row.end_offset);
346
+ }
347
+ /** Bulk version of `getFindingValue` — one SQL query for N finding
348
+ * ids instead of N round-trips. Caller fans the result map back out
349
+ * by id; missing keys are treated as `null` (purged / vanished). */
350
+ export function getFindingValues(db, findingIds) {
351
+ const out = {};
352
+ if (findingIds.length === 0)
353
+ return out;
354
+ const placeholders = findingIds.map(() => '?').join(',');
355
+ const rows = db.prepare(`SELECT f.id, f.start_offset, f.end_offset, f.state, m.content_text
356
+ FROM findings f
357
+ LEFT JOIN messages m ON m.id = f.message_id
358
+ WHERE f.id IN (${placeholders})`).all(...findingIds);
359
+ for (const r of rows) {
360
+ if (r.content_text === null || r.state === 'purged') {
361
+ out[r.id] = null;
362
+ }
363
+ else {
364
+ out[r.id] = r.content_text.slice(r.start_offset, r.end_offset);
365
+ }
366
+ }
367
+ // Fill in nulls for ids that didn't come back (deleted / cascade).
368
+ for (const id of findingIds) {
369
+ if (!(id in out))
370
+ out[id] = null;
371
+ }
372
+ return out;
373
+ }
374
+ const allowKey = (kind, hash) => `${kind}|${hash}`;
375
+ export function getAllowlists(db, sessionId) {
376
+ const sessionRows = db.prepare('SELECT kind, value_hash FROM allowlist_session WHERE session_id = ?').all(sessionId);
377
+ const globalRows = db.prepare('SELECT kind, value_hash FROM allowlist_global').all();
378
+ return {
379
+ session: new Set(sessionRows.map(r => allowKey(r.kind, r.value_hash))),
380
+ global: new Set(globalRows.map(r => allowKey(r.kind, r.value_hash))),
381
+ };
382
+ }
383
+ export function isAllowlisted(allow, kind, valueHash) {
384
+ const key = allowKey(kind, valueHash);
385
+ return allow.session.has(key) || allow.global.has(key);
386
+ }
387
+ export function addAllowlistSession(db, sessionId, kind, valueHash) {
388
+ db.prepare(`INSERT OR IGNORE INTO allowlist_session (session_id, kind, value_hash)
389
+ VALUES (?, ?, ?)`).run(sessionId, kind, valueHash);
390
+ }
391
+ export function addAllowlistGlobal(db, kind, valueHash) {
392
+ db.prepare(`INSERT OR IGNORE INTO allowlist_global (kind, value_hash) VALUES (?, ?)`).run(kind, valueHash);
393
+ }
394
+ export function removeAllowlistSession(db, sessionId, kind, valueHash) {
395
+ db.prepare(`DELETE FROM allowlist_session
396
+ WHERE session_id = ? AND kind = ? AND value_hash = ?`).run(sessionId, kind, valueHash);
397
+ }
398
+ export function removeAllowlistGlobal(db, kind, valueHash) {
399
+ db.prepare(`DELETE FROM allowlist_global WHERE kind = ? AND value_hash = ?`).run(kind, valueHash);
400
+ }
401
+ /** All allowlist rows from both tables, joined with the originating
402
+ * session metadata. Drives the Settings → Security pane's "Manage…"
403
+ * modal so the user can review and revoke past decisions. */
404
+ export function listAllowlistEntries(db) {
405
+ const sessionRows = db.prepare(`SELECT a.kind AS kind,
406
+ a.value_hash AS value_hash,
407
+ a.created_at AS created_at,
408
+ s.session_uuid AS session_uuid,
409
+ s.title AS session_title
410
+ FROM allowlist_session a
411
+ JOIN sessions s ON s.id = a.session_id
412
+ ORDER BY a.created_at DESC`).all();
413
+ const globalRows = db.prepare(`SELECT kind, value_hash, created_at
414
+ FROM allowlist_global
415
+ ORDER BY created_at DESC`).all();
416
+ return [
417
+ ...globalRows.map((r) => ({
418
+ scope: 'global',
419
+ kind: r.kind,
420
+ valueHash: r.value_hash,
421
+ createdAt: r.created_at,
422
+ sessionUuid: null,
423
+ sessionTitle: null,
424
+ })),
425
+ ...sessionRows.map((r) => ({
426
+ scope: 'session',
427
+ kind: r.kind,
428
+ valueHash: r.value_hash,
429
+ createdAt: r.created_at,
430
+ sessionUuid: r.session_uuid,
431
+ sessionTitle: r.session_title,
432
+ })),
433
+ ];
434
+ }
435
+ // ─── Mutations called from IPC dismiss handlers ───────────────────
436
+ /** Flip a finding to 'dismissed' and, depending on scope, write the
437
+ * allowlist entry so future rescans honor the decision. Caller
438
+ * wraps in a transaction. */
439
+ export function dismissFinding(db, findingId, scope) {
440
+ const f = db.prepare('SELECT session_id, kind, value_hash FROM findings WHERE id = ?').get(findingId);
441
+ if (!f)
442
+ return;
443
+ db.prepare(`UPDATE findings
444
+ SET state = 'dismissed',
445
+ state_changed_at = datetime('now')
446
+ WHERE id = ?`).run(findingId);
447
+ if (scope === 'session') {
448
+ addAllowlistSession(db, f.session_id, f.kind, f.value_hash);
449
+ }
450
+ else {
451
+ addAllowlistGlobal(db, f.kind, f.value_hash);
452
+ }
453
+ updateSessionCounts(db, f.session_id);
454
+ }
455
+ /** Re-activate a dismissed finding and remove the allowlist entry
456
+ * that pinned it (both scopes, since UI doesn't always know which). */
457
+ export function undismissFinding(db, findingId) {
458
+ const f = db.prepare('SELECT session_id, kind, value_hash FROM findings WHERE id = ?').get(findingId);
459
+ if (!f)
460
+ return;
461
+ db.prepare(`UPDATE findings
462
+ SET state = 'active',
463
+ state_changed_at = datetime('now')
464
+ WHERE id = ?`).run(findingId);
465
+ removeAllowlistSession(db, f.session_id, f.kind, f.value_hash);
466
+ removeAllowlistGlobal(db, f.kind, f.value_hash);
467
+ updateSessionCounts(db, f.session_id);
468
+ }
469
+ //# sourceMappingURL=repo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repo.js","sourceRoot":"","sources":["../../src/security/repo.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ import { Effect } from 'effect';
2
+ import type Database from 'better-sqlite3';
3
+ import type { RedactProvider } from '@spool-lab/redact';
4
+ import type { FindingsChange } from './types.js';
5
+ declare const ScanError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
6
+ readonly _tag: "ScanError";
7
+ } & Readonly<A>;
8
+ export declare class ScanError extends ScanError_base<{
9
+ readonly sessionId: number;
10
+ readonly cause: unknown;
11
+ readonly reason: 'session-not-found' | 'provider-failed' | 'db-failed';
12
+ }> {
13
+ }
14
+ /** Result of one session scan — useful for tests and progress UIs. */
15
+ export interface ScanResult {
16
+ sessionId: number;
17
+ inserted: number;
18
+ /** Profile string written to sessions.scan_profile. */
19
+ profile: string;
20
+ }
21
+ export interface ScanSessionDeps {
22
+ /** SQLite database handle (same instance the Syncer uses). */
23
+ db: Database.Database;
24
+ /** Active providers, evaluated in priority order. First entry wins
25
+ * on overlap (regex before pf — pattern matches are authoritative
26
+ * for known credential prefixes). */
27
+ providers: readonly RedactProvider[];
28
+ /** Profile string for the active provider set; persisted on
29
+ * successful scan. */
30
+ currentProfile: string;
31
+ /** Names of providers whose prior `active` findings should be
32
+ * deleted before re-insert. Almost always equals the names in
33
+ * `providers`; passed explicitly so the worker can choose to
34
+ * preserve historical pf findings even when pf is disabled. */
35
+ providerNames: readonly string[];
36
+ /** Sink for change notifications. The worker layer wraps PubSub
37
+ * here; tests pass a simple collector. */
38
+ publish: (change: FindingsChange) => Effect.Effect<void>;
39
+ /** Kind-level allowlist. Findings whose kind is in this set get
40
+ * inserted with state='dismissed' (instead of 'active') without
41
+ * needing a per-value allowlist row. Driven by the Settings →
42
+ * Security pane's multi-select. */
43
+ kindAllowlist?: ReadonlySet<string>;
44
+ }
45
+ /** Scan one session end to end. Idempotent: running twice produces
46
+ * the same findings (same value_hash + same offsets → same rows
47
+ * after the delete-then-insert reset). */
48
+ export declare function scanSession(sessionId: number, deps: ScanSessionDeps): Effect.Effect<ScanResult, ScanError>;
49
+ export {};
50
+ //# sourceMappingURL=scan.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../../src/security/scan.ts"],"names":[],"mappings":"AAaA,OAAO,EAAQ,MAAM,EAAE,MAAM,QAAQ,CAAA;AACrC,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAA;AAC1C,OAAO,KAAK,EAAkB,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAWvE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;;;;AAEhD,qBAAa,SAAU,SAAQ,eAA8B;IAC3D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,mBAAmB,GAAG,iBAAiB,GAAG,WAAW,CAAA;CACvE,CAAC;CAAG;AAOL,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,8DAA8D;IAC9D,EAAE,EAAE,QAAQ,CAAC,QAAQ,CAAA;IACrB;;0CAEsC;IACtC,SAAS,EAAE,SAAS,cAAc,EAAE,CAAA;IACpC;2BACuB;IACvB,cAAc,EAAE,MAAM,CAAA;IACtB;;;oEAGgE;IAChE,aAAa,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC;+CAC2C;IAC3C,OAAO,EAAE,CAAC,MAAM,EAAE,cAAc,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;IACxD;;;wCAGoC;IACpC,aAAa,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CACpC;AAED;;2CAE2C;AAC3C,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,eAAe,GACpB,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,SAAS,CAAC,CAiFtC"}
@@ -0,0 +1,120 @@
1
+ // Per-session scan pipeline.
2
+ //
3
+ // Effect-shaped so the worker can compose it with Queue / Stream /
4
+ // supervisor primitives. The detection itself runs against
5
+ // `messages.content_text` only — Purge later mutates that field,
6
+ // findings re-detect, and the audit trail stays consistent.
7
+ //
8
+ // Transaction shape: detection runs *outside* the transaction (it
9
+ // can take seconds with ML); only the final apply step is atomic.
10
+ // If the worker dies mid-detection, scan_profile was never updated
11
+ // → restart re-enqueues from scratch. If it dies inside the
12
+ // transaction, SQLite atomicity guarantees no half-applied state.
13
+ import { Data, Effect } from 'effect';
14
+ import { hashValueForRedactExclude } from '@spool-lab/redact';
15
+ import { deleteRefreshableFindings, insertFindings, setSessionScanProfile, updateSessionCounts, getAllowlists, isAllowlisted, } from './repo.js';
16
+ export class ScanError extends Data.TaggedError('ScanError') {
17
+ }
18
+ /** Scan one session end to end. Idempotent: running twice produces
19
+ * the same findings (same value_hash + same offsets → same rows
20
+ * after the delete-then-insert reset). */
21
+ export function scanSession(sessionId, deps) {
22
+ return Effect.gen(function* () {
23
+ // 1. Load messages outside the transaction.
24
+ const messages = yield* Effect.try({
25
+ try: () => deps.db.prepare(`SELECT id, content_text
26
+ FROM messages
27
+ WHERE session_id = ?
28
+ ORDER BY seq ASC`).all(sessionId),
29
+ catch: (cause) => new ScanError({ sessionId, cause, reason: 'db-failed' }),
30
+ });
31
+ if (messages.length === 0) {
32
+ // Session has no messages yet. Still mark it scanned with the
33
+ // current profile so we don't loop on it; future sync will
34
+ // invalidate the profile and re-enqueue.
35
+ yield* applyEmpty(sessionId, deps);
36
+ yield* deps.publish({ type: 'session-rescanned', sessionId });
37
+ return { sessionId, inserted: 0, profile: deps.currentProfile };
38
+ }
39
+ // 2. Run providers per message. The flat list is enough — we
40
+ // dedupe later by (kind, value_hash, start_offset) so producers
41
+ // can be naive about overlap.
42
+ const allMatches = [];
43
+ for (const msg of messages) {
44
+ for (const provider of deps.providers) {
45
+ if (!provider.available())
46
+ continue;
47
+ const matches = yield* Effect.tryPromise({
48
+ try: () => provider.analyze(msg.content_text),
49
+ catch: (cause) => new ScanError({ sessionId, cause, reason: 'provider-failed' }),
50
+ });
51
+ for (const m of matches) {
52
+ allMatches.push({ ...m, messageId: msg.id });
53
+ }
54
+ }
55
+ }
56
+ // 3. Dedupe across providers — pick the highest-confidence match
57
+ // for each (kind, value_hash, start_offset).
58
+ const merged = mergeMatches(allMatches);
59
+ // 4. Allowlist join → assign initial state.
60
+ const allow = yield* Effect.try({
61
+ try: () => getAllowlists(deps.db, sessionId),
62
+ catch: (cause) => new ScanError({ sessionId, cause, reason: 'db-failed' }),
63
+ });
64
+ const inputs = merged.map((m) => ({
65
+ sessionId,
66
+ messageId: m.messageId,
67
+ kind: m.kind,
68
+ valueHash: m.valueHash,
69
+ confidence: m.confidence,
70
+ provider: m.provider,
71
+ startOffset: m.start,
72
+ endOffset: m.end,
73
+ state: (deps.kindAllowlist?.has(m.kind) || isAllowlisted(allow, m.kind, m.valueHash))
74
+ ? 'dismissed'
75
+ : 'active',
76
+ }));
77
+ // 5. Apply atomically.
78
+ yield* Effect.try({
79
+ try: () => {
80
+ deps.db.transaction(() => {
81
+ deleteRefreshableFindings(deps.db, sessionId, deps.providerNames);
82
+ insertFindings(deps.db, inputs);
83
+ setSessionScanProfile(deps.db, sessionId, deps.currentProfile, new Date().toISOString());
84
+ updateSessionCounts(deps.db, sessionId);
85
+ })();
86
+ },
87
+ catch: (cause) => new ScanError({ sessionId, cause, reason: 'db-failed' }),
88
+ });
89
+ yield* deps.publish({ type: 'session-rescanned', sessionId });
90
+ return { sessionId, inserted: inputs.length, profile: deps.currentProfile };
91
+ });
92
+ }
93
+ function applyEmpty(sessionId, deps) {
94
+ return Effect.try({
95
+ try: () => {
96
+ deps.db.transaction(() => {
97
+ deleteRefreshableFindings(deps.db, sessionId, deps.providerNames);
98
+ setSessionScanProfile(deps.db, sessionId, deps.currentProfile, new Date().toISOString());
99
+ updateSessionCounts(deps.db, sessionId);
100
+ })();
101
+ },
102
+ catch: (cause) => new ScanError({ sessionId, cause, reason: 'db-failed' }),
103
+ });
104
+ }
105
+ /** Pick the highest-confidence match per (messageId, kind, value_hash,
106
+ * start). The same vendor token detected by two providers should
107
+ * produce one finding, attributed to whoever was most confident. */
108
+ function mergeMatches(matches) {
109
+ const byKey = new Map();
110
+ for (const m of matches) {
111
+ const hash = hashValueForRedactExclude(m.value);
112
+ const key = `${m.messageId}|${m.kind}|${hash}|${m.start}`;
113
+ const existing = byKey.get(key);
114
+ if (!existing || m.confidence > existing.confidence) {
115
+ byKey.set(key, { ...m, valueHash: hash });
116
+ }
117
+ }
118
+ return Array.from(byKey.values());
119
+ }
120
+ //# sourceMappingURL=scan.js.map