@spool-lab/core 0.4.12 → 0.5.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 +2 -2
- package/dist/db/db.d.ts +10 -2
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +119 -10
- package/dist/db/db.js.map +1 -1
- package/dist/db/queries.d.ts +5 -5
- package/dist/db/queries.d.ts.map +1 -1
- package/dist/db/queries.js +32 -14
- package/dist/db/queries.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/observability/exporter-file.d.ts +30 -0
- package/dist/observability/exporter-file.d.ts.map +1 -0
- package/dist/observability/exporter-file.js +125 -0
- package/dist/observability/exporter-file.js.map +1 -0
- package/dist/observability/exporter-pretty.d.ts +27 -0
- package/dist/observability/exporter-pretty.d.ts.map +1 -0
- package/dist/observability/exporter-pretty.js +84 -0
- package/dist/observability/exporter-pretty.js.map +1 -0
- package/dist/observability/index.d.ts +2 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +2 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/layer.d.ts +29 -0
- package/dist/observability/layer.d.ts.map +1 -0
- package/dist/observability/layer.js +45 -0
- package/dist/observability/layer.js.map +1 -0
- package/dist/parsers/opencode.d.ts +23 -0
- package/dist/parsers/opencode.d.ts.map +1 -0
- package/dist/parsers/opencode.js +358 -0
- package/dist/parsers/opencode.js.map +1 -0
- package/dist/projects/sessions.d.ts +29 -1
- package/dist/projects/sessions.d.ts.map +1 -1
- package/dist/projects/sessions.js +115 -11
- package/dist/projects/sessions.js.map +1 -1
- package/dist/security/index.d.ts +14 -0
- package/dist/security/index.d.ts.map +1 -0
- package/dist/security/index.js +7 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/maintenance.d.ts +15 -0
- package/dist/security/maintenance.d.ts.map +1 -0
- package/dist/security/maintenance.js +68 -0
- package/dist/security/maintenance.js.map +1 -0
- package/dist/security/profile.d.ts +47 -0
- package/dist/security/profile.d.ts.map +1 -0
- package/dist/security/profile.js +131 -0
- package/dist/security/profile.js.map +1 -0
- package/dist/security/purge.d.ts +68 -0
- package/dist/security/purge.d.ts.map +1 -0
- package/dist/security/purge.js +193 -0
- package/dist/security/purge.js.map +1 -0
- package/dist/security/repo.d.ts +190 -0
- package/dist/security/repo.d.ts.map +1 -0
- package/dist/security/repo.js +594 -0
- package/dist/security/repo.js.map +1 -0
- package/dist/security/scan.d.ts +50 -0
- package/dist/security/scan.d.ts.map +1 -0
- package/dist/security/scan.js +122 -0
- package/dist/security/scan.js.map +1 -0
- package/dist/security/types.d.ts +146 -0
- package/dist/security/types.d.ts.map +1 -0
- package/dist/security/types.js +10 -0
- package/dist/security/types.js.map +1 -0
- package/dist/security/worker.d.ts +69 -0
- package/dist/security/worker.d.ts.map +1 -0
- package/dist/security/worker.js +228 -0
- package/dist/security/worker.js.map +1 -0
- package/dist/sync/source-paths.d.ts +1 -0
- package/dist/sync/source-paths.d.ts.map +1 -1
- package/dist/sync/source-paths.js +48 -6
- package/dist/sync/source-paths.js.map +1 -1
- package/dist/sync/syncer.d.ts +9 -1
- package/dist/sync/syncer.d.ts.map +1 -1
- package/dist/sync/syncer.js +128 -16
- package/dist/sync/syncer.js.map +1 -1
- package/dist/sync/watcher.d.ts.map +1 -1
- package/dist/sync/watcher.js +8 -2
- package/dist/sync/watcher.js.map +1 -1
- package/dist/types.d.ts +15 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/util/resolve-bin.d.ts +34 -0
- package/dist/util/resolve-bin.d.ts.map +1 -1
- package/dist/util/resolve-bin.js +175 -20
- package/dist/util/resolve-bin.js.map +1 -1
- package/package.json +11 -2
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync, statSync, } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
// Listed in the UI: any snapshot Spool itself produced under
|
|
4
|
+
// ~/.spool/backups/. The narrower regex below distinguishes automated
|
|
5
|
+
// schema-migration snapshots (kind: 'auto') from human-named rollback
|
|
6
|
+
// points (kind: 'manual') so the UI can surface that difference.
|
|
7
|
+
const SPOOL_BACKUP_RE = /^spool-pre-.+\.db$/;
|
|
8
|
+
const AUTO_BACKUP_RE = /^spool-pre-v\d+-/;
|
|
9
|
+
export function backupDirFor(db) {
|
|
10
|
+
if (db.memory)
|
|
11
|
+
return null;
|
|
12
|
+
const dbPath = db.name;
|
|
13
|
+
if (!dbPath)
|
|
14
|
+
return null;
|
|
15
|
+
return join(dirname(dbPath), 'backups');
|
|
16
|
+
}
|
|
17
|
+
export function listBackups(db) {
|
|
18
|
+
const backupDir = backupDirFor(db);
|
|
19
|
+
if (!backupDir || !existsSync(backupDir))
|
|
20
|
+
return [];
|
|
21
|
+
return readdirSync(backupDir)
|
|
22
|
+
.filter((name) => SPOOL_BACKUP_RE.test(name))
|
|
23
|
+
.map((name) => {
|
|
24
|
+
try {
|
|
25
|
+
const st = statSync(join(backupDir, name));
|
|
26
|
+
return {
|
|
27
|
+
name,
|
|
28
|
+
sizeBytes: st.size,
|
|
29
|
+
mtimeMs: st.mtimeMs,
|
|
30
|
+
kind: AUTO_BACKUP_RE.test(name) ? 'auto' : 'manual',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// stat may race with a concurrent delete; skip on error.
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
.filter((e) => e !== null)
|
|
39
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
40
|
+
}
|
|
41
|
+
export function deleteBackups(db, names) {
|
|
42
|
+
const backupDir = backupDirFor(db);
|
|
43
|
+
if (!backupDir || !existsSync(backupDir) || names.length === 0) {
|
|
44
|
+
return { deleted: 0, bytesFreed: 0 };
|
|
45
|
+
}
|
|
46
|
+
let deleted = 0;
|
|
47
|
+
let bytesFreed = 0;
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
// Reject path traversal: a malicious or buggy caller must not be
|
|
50
|
+
// able to delete files outside the backups dir.
|
|
51
|
+
if (basename(name) !== name)
|
|
52
|
+
continue;
|
|
53
|
+
if (!SPOOL_BACKUP_RE.test(name))
|
|
54
|
+
continue;
|
|
55
|
+
const full = join(backupDir, name);
|
|
56
|
+
try {
|
|
57
|
+
const st = statSync(full);
|
|
58
|
+
rmSync(full, { force: true });
|
|
59
|
+
deleted += 1;
|
|
60
|
+
bytesFreed += st.size;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// best-effort; missing file = already gone, treat as no-op.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { deleted, bytesFreed };
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=maintenance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"maintenance.js","sourceRoot":"","sources":["../../src/security/maintenance.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare const REDACT_DETECTOR_VERSION = 2;
|
|
2
|
+
export interface ProfileOpts {
|
|
3
|
+
/** Regex detector revision. Bump in lockstep with rule changes. */
|
|
4
|
+
regexVersion?: number;
|
|
5
|
+
/** Whether the Privacy Filter ML provider is enabled. */
|
|
6
|
+
pfEnabled?: boolean;
|
|
7
|
+
/** Privacy Filter model + quant level, e.g. '1.5b-q4'. Required
|
|
8
|
+
* when `pfEnabled` is true. */
|
|
9
|
+
pfVersion?: string;
|
|
10
|
+
/** Per-kind allowlist (Settings → Security → "Don't report"). Hashed
|
|
11
|
+
* into the profile string so flipping a kind triggers a real
|
|
12
|
+
* re-scan via `listSessionsNeedingScan` — otherwise sessions
|
|
13
|
+
* scanned before the toggle would never re-evaluate their findings
|
|
14
|
+
* and stale `state='active'` rows would linger for the now-
|
|
15
|
+
* allowlisted kind.
|
|
16
|
+
*
|
|
17
|
+
* Order-insensitive: we sort + dedupe before hashing. Empty list
|
|
18
|
+
* collapses the segment so the profile stays `regex@4` (not
|
|
19
|
+
* `regex@4,allow@0`) — backward-compat with sessions stamped
|
|
20
|
+
* before the allowlist feature existed. */
|
|
21
|
+
kindAllowlist?: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
export interface ParsedProfile {
|
|
24
|
+
regex: number;
|
|
25
|
+
pf?: string;
|
|
26
|
+
/** Short hex digest of the sorted kind allowlist; only present when
|
|
27
|
+
* the user has configured a non-empty list. */
|
|
28
|
+
allow?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Build the canonical profile string for the current detector set.
|
|
31
|
+
* Order is fixed (regex → pf → allow) so equality is structural. */
|
|
32
|
+
export declare function currentProfileString(opts?: ProfileOpts): string;
|
|
33
|
+
/** Parse a profile string back into structured fields. Returns null
|
|
34
|
+
* for inputs that don't look like a profile (e.g. legacy values,
|
|
35
|
+
* manual edits). Strict — unknown tokens cause a null return so the
|
|
36
|
+
* worker treats them as "stale, rescan". */
|
|
37
|
+
export declare function parseProfile(s: string | null | undefined): ParsedProfile | null;
|
|
38
|
+
/** Structural equality on profile strings — order-insensitive but
|
|
39
|
+
* same-content. */
|
|
40
|
+
export declare function profilesMatch(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
41
|
+
/** Names of the providers active in a profile. Used by
|
|
42
|
+
* `deleteActiveFindings(... providers)` so rescanning with a smaller
|
|
43
|
+
* provider set doesn't clobber prior findings the user may still
|
|
44
|
+
* want (e.g. disabling pf shouldn't delete pf's historical hits;
|
|
45
|
+
* they just stop being refreshed). */
|
|
46
|
+
export declare function providersInProfile(s: string): string[];
|
|
47
|
+
//# sourceMappingURL=profile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"profile.d.ts","sourceRoot":"","sources":["../../src/security/profile.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yDAAyD;IACzD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;oCACgC;IAChC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;;;;;gDAU4C;IAC5C,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAClC;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAA;IACb,EAAE,CAAC,EAAE,MAAM,CAAA;IACX;oDACgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;qEACqE;AACrE,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,WAAgB,GAAG,MAAM,CAYnE;AAqBD;;;6CAG6C;AAC7C,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,aAAa,GAAG,IAAI,CA6B/E;AAED;oBACoB;AACpB,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAKjG;AAED;;;;uCAIuC;AACvC,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAMtD"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Scan profile — a composite identifier for the set of providers
|
|
2
|
+
// that produced the findings in a session.
|
|
3
|
+
//
|
|
4
|
+
// Persisted on `sessions.scan_profile`. Compare to the worker's
|
|
5
|
+
// current profile string at startup to detect rescan candidates:
|
|
6
|
+
//
|
|
7
|
+
// stored = NULL → never scanned, enqueue
|
|
8
|
+
// stored = 'regex@1' → matches current 'regex@1', skip
|
|
9
|
+
// stored = 'regex@1,pf@1.5b-q4.r2' → user disabled pf, enqueue rescan
|
|
10
|
+
//
|
|
11
|
+
// Provider versions are bumped manually when detection logic changes.
|
|
12
|
+
// REDACT_DETECTOR_VERSION lives next to the regex detector in
|
|
13
|
+
// `@spool-lab/redact` (added in PR 1a); we re-export it here for
|
|
14
|
+
// callers that only depend on @spool-lab/core.
|
|
15
|
+
// Cache-nonce for the regex detector rule set. Bumping it makes
|
|
16
|
+
// `currentProfileString()` differ from what's stored on
|
|
17
|
+
// `sessions.scan_profile`, which the backfill loop reads as "this
|
|
18
|
+
// session needs a rescan". No semantic meaning outside that diff —
|
|
19
|
+
// it's NOT a user-visible "version of the security feature".
|
|
20
|
+
//
|
|
21
|
+
// Scope: consumed only by code gated behind VITE_FEATURE_SECURITY.
|
|
22
|
+
// Bump on every rule change once the feature ships so users with old
|
|
23
|
+
// stamps automatically pick up the new rules.
|
|
24
|
+
// 1 — initial release
|
|
25
|
+
// 2 — 2026-05-28: env-var detector now rejects values containing
|
|
26
|
+
// non-ASCII letters (CJK / Cyrillic / Hangul / Arabic / emoji
|
|
27
|
+
// are placeholder description text, never real env-var secrets).
|
|
28
|
+
export const REDACT_DETECTOR_VERSION = 2;
|
|
29
|
+
/** Build the canonical profile string for the current detector set.
|
|
30
|
+
* Order is fixed (regex → pf → allow) so equality is structural. */
|
|
31
|
+
export function currentProfileString(opts = {}) {
|
|
32
|
+
const regexVersion = opts.regexVersion ?? REDACT_DETECTOR_VERSION;
|
|
33
|
+
const parts = [`regex@${regexVersion}`];
|
|
34
|
+
if (opts.pfEnabled) {
|
|
35
|
+
if (!opts.pfVersion) {
|
|
36
|
+
throw new Error('currentProfileString: pfVersion is required when pfEnabled is true');
|
|
37
|
+
}
|
|
38
|
+
parts.push(`pf@${opts.pfVersion}`);
|
|
39
|
+
}
|
|
40
|
+
const allowHash = hashKindAllowlist(opts.kindAllowlist);
|
|
41
|
+
if (allowHash)
|
|
42
|
+
parts.push(`allow@${allowHash}`);
|
|
43
|
+
return parts.join(',');
|
|
44
|
+
}
|
|
45
|
+
/** FNV-1a 32-bit over the sorted+deduped kind list. Returns null for
|
|
46
|
+
* empty / undefined so the profile string stays minimal when no kinds
|
|
47
|
+
* are allowlisted (the common case). 8-char hex; collision risk is
|
|
48
|
+
* irrelevant — drift, not authenticity, is what we're detecting. */
|
|
49
|
+
function hashKindAllowlist(kinds) {
|
|
50
|
+
if (!kinds || kinds.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
const sorted = [...new Set(kinds)].sort();
|
|
53
|
+
let h = 0x811c9dc5;
|
|
54
|
+
for (const k of sorted) {
|
|
55
|
+
for (let i = 0; i < k.length; i++) {
|
|
56
|
+
h ^= k.charCodeAt(i);
|
|
57
|
+
h = Math.imul(h, 0x01000193);
|
|
58
|
+
}
|
|
59
|
+
h ^= 0x2c; // ',' delimiter so ['a','bc'] !== ['ab','c']
|
|
60
|
+
h = Math.imul(h, 0x01000193);
|
|
61
|
+
}
|
|
62
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
63
|
+
}
|
|
64
|
+
/** Parse a profile string back into structured fields. Returns null
|
|
65
|
+
* for inputs that don't look like a profile (e.g. legacy values,
|
|
66
|
+
* manual edits). Strict — unknown tokens cause a null return so the
|
|
67
|
+
* worker treats them as "stale, rescan". */
|
|
68
|
+
export function parseProfile(s) {
|
|
69
|
+
if (!s)
|
|
70
|
+
return null;
|
|
71
|
+
const parts = s.split(',').map(p => p.trim()).filter(Boolean);
|
|
72
|
+
let regex;
|
|
73
|
+
let pf;
|
|
74
|
+
let allow;
|
|
75
|
+
for (const part of parts) {
|
|
76
|
+
const at = part.indexOf('@');
|
|
77
|
+
if (at <= 0)
|
|
78
|
+
return null;
|
|
79
|
+
const name = part.slice(0, at);
|
|
80
|
+
const ver = part.slice(at + 1);
|
|
81
|
+
if (!ver)
|
|
82
|
+
return null;
|
|
83
|
+
if (name === 'regex') {
|
|
84
|
+
const n = Number(ver);
|
|
85
|
+
if (!Number.isInteger(n) || n < 1)
|
|
86
|
+
return null;
|
|
87
|
+
regex = n;
|
|
88
|
+
}
|
|
89
|
+
else if (name === 'pf') {
|
|
90
|
+
pf = ver;
|
|
91
|
+
}
|
|
92
|
+
else if (name === 'allow') {
|
|
93
|
+
allow = ver;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (regex === undefined)
|
|
100
|
+
return null;
|
|
101
|
+
const result = { regex };
|
|
102
|
+
if (pf !== undefined)
|
|
103
|
+
result.pf = pf;
|
|
104
|
+
if (allow !== undefined)
|
|
105
|
+
result.allow = allow;
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
/** Structural equality on profile strings — order-insensitive but
|
|
109
|
+
* same-content. */
|
|
110
|
+
export function profilesMatch(a, b) {
|
|
111
|
+
const pa = parseProfile(a);
|
|
112
|
+
const pb = parseProfile(b);
|
|
113
|
+
if (!pa || !pb)
|
|
114
|
+
return false;
|
|
115
|
+
return pa.regex === pb.regex && pa.pf === pb.pf && pa.allow === pb.allow;
|
|
116
|
+
}
|
|
117
|
+
/** Names of the providers active in a profile. Used by
|
|
118
|
+
* `deleteActiveFindings(... providers)` so rescanning with a smaller
|
|
119
|
+
* provider set doesn't clobber prior findings the user may still
|
|
120
|
+
* want (e.g. disabling pf shouldn't delete pf's historical hits;
|
|
121
|
+
* they just stop being refreshed). */
|
|
122
|
+
export function providersInProfile(s) {
|
|
123
|
+
const parsed = parseProfile(s);
|
|
124
|
+
if (!parsed)
|
|
125
|
+
return [];
|
|
126
|
+
const out = ['regex'];
|
|
127
|
+
if (parsed.pf)
|
|
128
|
+
out.push('pf');
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=profile.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"profile.js","sourceRoot":"","sources":["../../src/security/profile.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import type Database from 'better-sqlite3';
|
|
3
|
+
import type { SensitiveKind } from '@spool-lab/redact';
|
|
4
|
+
import type { FindingsChange } from './types.js';
|
|
5
|
+
declare const PurgeError_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: "PurgeError";
|
|
7
|
+
} & Readonly<A>;
|
|
8
|
+
export declare class PurgeError extends PurgeError_base<{
|
|
9
|
+
readonly findingId: number;
|
|
10
|
+
readonly reason: 'not-found' | 'already-purged' | 'message-missing' | 'db-failed';
|
|
11
|
+
readonly cause?: unknown;
|
|
12
|
+
}> {
|
|
13
|
+
}
|
|
14
|
+
export interface PurgeResult {
|
|
15
|
+
findingId: number;
|
|
16
|
+
sessionId: number;
|
|
17
|
+
/** The mask string that replaced the raw value. */
|
|
18
|
+
maskUsed: string;
|
|
19
|
+
purgedAt: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PurgeDeps {
|
|
22
|
+
db: Database.Database;
|
|
23
|
+
publish: (change: FindingsChange) => Effect.Effect<void>;
|
|
24
|
+
}
|
|
25
|
+
/** Purge one finding. Idempotent on "already purged" → returns a
|
|
26
|
+
* PurgeError so the UI can surface a no-op. */
|
|
27
|
+
export declare function purgeFinding(findingId: number, deps: PurgeDeps): Effect.Effect<PurgeResult, PurgeError>;
|
|
28
|
+
/** Bulk purge across many findings. Caller passes an arbitrary
|
|
29
|
+
* ordering; we re-sort to the only correct order:
|
|
30
|
+
*
|
|
31
|
+
* 1. Group by `message_id`
|
|
32
|
+
* 2. Within each group, apply in **descending** `start_offset` so
|
|
33
|
+
* earlier offsets stay valid as the string shifts.
|
|
34
|
+
*
|
|
35
|
+
* Without this re-sort, two findings inside the same message at
|
|
36
|
+
* offsets [10..20] and [40..60] would corrupt: purging [10..20]
|
|
37
|
+
* first shifts everything after offset 20 by `mask.length - 10`,
|
|
38
|
+
* and the second slice [40..60] now points at the wrong bytes
|
|
39
|
+
* (often leaking part of the second secret into the mask, or
|
|
40
|
+
* losing it entirely). */
|
|
41
|
+
export declare function purgeFindings(findingIds: readonly number[], deps: PurgeDeps): Effect.Effect<PurgeResult[], PurgeError>;
|
|
42
|
+
/** Purge EVERY active occurrence of one leaked value — the
|
|
43
|
+
* `(kind, value_hash)` pair — across all sessions in the archive, in a
|
|
44
|
+
* single logical operation. The natural follow-through to "I've rotated
|
|
45
|
+
* this key at the source → now scrub every copy from Spool's surfaces".
|
|
46
|
+
*
|
|
47
|
+
* Delegates to {@link purgeFindings}, which re-orders the collected ids
|
|
48
|
+
* via {@link orderForBulkPurge} so per-message offsets stay valid. The
|
|
49
|
+
* original `~/.claude` session files are NOT touched — this masks only
|
|
50
|
+
* Spool's stored copies (DB + FTS), closing the search / AI / browse
|
|
51
|
+
* exposure.
|
|
52
|
+
*
|
|
53
|
+
* Returns the purge results plus the distinct session ids touched, in
|
|
54
|
+
* first-seen order, so the IPC layer can emit one change event per
|
|
55
|
+
* session. */
|
|
56
|
+
export declare function purgeEverywhere(kind: SensitiveKind, valueHash: string, deps: PurgeDeps): Effect.Effect<{
|
|
57
|
+
results: PurgeResult[];
|
|
58
|
+
sessionIds: number[];
|
|
59
|
+
}, PurgeError>;
|
|
60
|
+
/** Produce a purge order that's safe against offset drift. Findings
|
|
61
|
+
* with `message_id IS NULL` (orphan rows that the schema permits but
|
|
62
|
+
* should never happen in practice) fall to the end so they don't
|
|
63
|
+
* interfere with message-grouped batches.
|
|
64
|
+
*
|
|
65
|
+
* Exported only for tests. */
|
|
66
|
+
export declare function orderForBulkPurge(db: Database.Database, findingIds: readonly number[]): number[];
|
|
67
|
+
export {};
|
|
68
|
+
//# sourceMappingURL=purge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"purge.d.ts","sourceRoot":"","sources":["../../src/security/purge.ts"],"names":[],"mappings":"AAkBA,OAAO,EAAQ,MAAM,EAAE,MAAM,QAAQ,CAAA;AACrC,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAA;AAC1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAGtD,OAAO,KAAK,EAAE,cAAc,EAAgB,MAAM,YAAY,CAAA;;;;AAE9D,qBAAa,UAAW,SAAQ,gBAA+B;IAC7D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,gBAAgB,GAAG,iBAAiB,GAAG,WAAW,CAAA;IACjF,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CACzB,CAAC;CAAG;AAEL,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAYD,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,QAAQ,CAAC,QAAQ,CAAA;IACrB,OAAO,EAAE,CAAC,MAAM,EAAE,cAAc,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;CACzD;AAED;gDACgD;AAChD,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,SAAS,GACd,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,UAAU,CAAC,CAsCxC;AAED;;;;;;;;;;;;2BAY2B;AAC3B,wBAAgB,aAAa,CAC3B,UAAU,EAAE,SAAS,MAAM,EAAE,EAC7B,IAAI,EAAE,SAAS,GACd,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,EAAE,UAAU,CAAC,CAyB1C;AAED;;;;;;;;;;;;;eAae;AACf,wBAAgB,eAAe,CAC7B,IAAI,EAAE,aAAa,EACnB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,SAAS,GACd,MAAM,CAAC,MAAM,CAAC;IAAE,OAAO,EAAE,WAAW,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,EAAE,CAAA;CAAE,EAAE,UAAU,CAAC,CAoB7E;AAED;;;;;+BAK+B;AAC/B,wBAAgB,iBAAiB,CAC/B,EAAE,EAAE,QAAQ,CAAC,QAAQ,EACrB,UAAU,EAAE,SAAS,MAAM,EAAE,GAC5B,MAAM,EAAE,CAkCV"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Purge mechanism — the only destructive action in the Security
|
|
2
|
+
// Scan feature.
|
|
3
|
+
//
|
|
4
|
+
// What it does: rewrites the raw secret in `messages.content_text`
|
|
5
|
+
// with the per-kind mask from @spool-lab/redact (e.g. `[redacted:
|
|
6
|
+
// AWS key]`), flips the finding to `state='purged'`, recomputes the
|
|
7
|
+
// session's denormalised counts, and lets the existing FTS triggers
|
|
8
|
+
// sync messages_fts automatically.
|
|
9
|
+
//
|
|
10
|
+
// What it does NOT do: touch the source transcript file in
|
|
11
|
+
// `~/.claude/sessions/`, the `.spool` exports the user may already
|
|
12
|
+
// have published, or OS backups of `spool.db`. Those are Phase 2/3
|
|
13
|
+
// vault concerns documented in the spec.
|
|
14
|
+
//
|
|
15
|
+
// Bulk-by-message ordering: when several findings sit in the same
|
|
16
|
+
// message, apply them in DESCENDING start_offset order so earlier
|
|
17
|
+
// offsets stay valid as the string shifts.
|
|
18
|
+
import { Data, Effect } from 'effect';
|
|
19
|
+
import { maskValueByKind } from '@spool-lab/redact';
|
|
20
|
+
import { updateSessionCounts } from './repo.js';
|
|
21
|
+
export class PurgeError extends Data.TaggedError('PurgeError') {
|
|
22
|
+
}
|
|
23
|
+
/** Purge one finding. Idempotent on "already purged" → returns a
|
|
24
|
+
* PurgeError so the UI can surface a no-op. */
|
|
25
|
+
export function purgeFinding(findingId, deps) {
|
|
26
|
+
return Effect.gen(function* () {
|
|
27
|
+
const row = yield* Effect.try({
|
|
28
|
+
try: () => deps.db.prepare(`SELECT id, session_id, message_id, kind, start_offset, end_offset, state
|
|
29
|
+
FROM findings WHERE id = ?`).get(findingId),
|
|
30
|
+
catch: (cause) => new PurgeError({ findingId, reason: 'db-failed', cause }),
|
|
31
|
+
});
|
|
32
|
+
if (!row) {
|
|
33
|
+
return yield* Effect.fail(new PurgeError({ findingId, reason: 'not-found' }));
|
|
34
|
+
}
|
|
35
|
+
if (row.state === 'purged') {
|
|
36
|
+
return yield* Effect.fail(new PurgeError({ findingId, reason: 'already-purged' }));
|
|
37
|
+
}
|
|
38
|
+
if (row.message_id === null) {
|
|
39
|
+
return yield* Effect.fail(new PurgeError({ findingId, reason: 'message-missing' }));
|
|
40
|
+
}
|
|
41
|
+
const messageId = row.message_id;
|
|
42
|
+
const kind = row.kind;
|
|
43
|
+
const result = yield* Effect.try({
|
|
44
|
+
try: () => applyPurgeTxn(deps.db, row, messageId, kind),
|
|
45
|
+
catch: (cause) => new PurgeError({ findingId, reason: 'db-failed', cause }),
|
|
46
|
+
});
|
|
47
|
+
yield* deps.publish({
|
|
48
|
+
type: 'state-changed',
|
|
49
|
+
sessionId: row.session_id,
|
|
50
|
+
findingId: row.id,
|
|
51
|
+
state: 'purged',
|
|
52
|
+
});
|
|
53
|
+
return result;
|
|
54
|
+
}).pipe(Effect.withSpan('security.purge.single', { attributes: { findingId } }));
|
|
55
|
+
}
|
|
56
|
+
/** Bulk purge across many findings. Caller passes an arbitrary
|
|
57
|
+
* ordering; we re-sort to the only correct order:
|
|
58
|
+
*
|
|
59
|
+
* 1. Group by `message_id`
|
|
60
|
+
* 2. Within each group, apply in **descending** `start_offset` so
|
|
61
|
+
* earlier offsets stay valid as the string shifts.
|
|
62
|
+
*
|
|
63
|
+
* Without this re-sort, two findings inside the same message at
|
|
64
|
+
* offsets [10..20] and [40..60] would corrupt: purging [10..20]
|
|
65
|
+
* first shifts everything after offset 20 by `mask.length - 10`,
|
|
66
|
+
* and the second slice [40..60] now points at the wrong bytes
|
|
67
|
+
* (often leaking part of the second secret into the mask, or
|
|
68
|
+
* losing it entirely). */
|
|
69
|
+
export function purgeFindings(findingIds, deps) {
|
|
70
|
+
return Effect.gen(function* () {
|
|
71
|
+
// Resolve offsets/message ids up front so we can deterministically
|
|
72
|
+
// order. The Effect.try preserves db-failure → PurgeError mapping.
|
|
73
|
+
const ordered = yield* Effect.try({
|
|
74
|
+
try: () => orderForBulkPurge(deps.db, findingIds),
|
|
75
|
+
catch: (cause) => new PurgeError({ findingId: -1, reason: 'db-failed', cause }),
|
|
76
|
+
});
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const id of ordered) {
|
|
79
|
+
const result = yield* purgeFinding(id, deps).pipe(Effect.catchTag('PurgeError', (err) => err.reason === 'already-purged'
|
|
80
|
+
? Effect.succeed(null)
|
|
81
|
+
: Effect.fail(err)));
|
|
82
|
+
if (result)
|
|
83
|
+
out.push(result);
|
|
84
|
+
}
|
|
85
|
+
yield* Effect.annotateCurrentSpan('purged', out.length);
|
|
86
|
+
return out;
|
|
87
|
+
}).pipe(Effect.withSpan('security.purge.bulk', { attributes: { requested: findingIds.length } }));
|
|
88
|
+
}
|
|
89
|
+
/** Purge EVERY active occurrence of one leaked value — the
|
|
90
|
+
* `(kind, value_hash)` pair — across all sessions in the archive, in a
|
|
91
|
+
* single logical operation. The natural follow-through to "I've rotated
|
|
92
|
+
* this key at the source → now scrub every copy from Spool's surfaces".
|
|
93
|
+
*
|
|
94
|
+
* Delegates to {@link purgeFindings}, which re-orders the collected ids
|
|
95
|
+
* via {@link orderForBulkPurge} so per-message offsets stay valid. The
|
|
96
|
+
* original `~/.claude` session files are NOT touched — this masks only
|
|
97
|
+
* Spool's stored copies (DB + FTS), closing the search / AI / browse
|
|
98
|
+
* exposure.
|
|
99
|
+
*
|
|
100
|
+
* Returns the purge results plus the distinct session ids touched, in
|
|
101
|
+
* first-seen order, so the IPC layer can emit one change event per
|
|
102
|
+
* session. */
|
|
103
|
+
export function purgeEverywhere(kind, valueHash, deps) {
|
|
104
|
+
return Effect.gen(function* () {
|
|
105
|
+
const ids = yield* Effect.try({
|
|
106
|
+
try: () => deps.db.prepare(`SELECT id FROM findings
|
|
107
|
+
WHERE kind = ? AND value_hash = ? AND state = 'active'`).all(kind, valueHash).map(r => r.id),
|
|
108
|
+
catch: (cause) => new PurgeError({ findingId: -1, reason: 'db-failed', cause }),
|
|
109
|
+
});
|
|
110
|
+
const results = yield* purgeFindings(ids, deps);
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const sessionIds = [];
|
|
113
|
+
for (const r of results) {
|
|
114
|
+
if (!seen.has(r.sessionId)) {
|
|
115
|
+
seen.add(r.sessionId);
|
|
116
|
+
sessionIds.push(r.sessionId);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { results, sessionIds };
|
|
120
|
+
}).pipe(Effect.withSpan('security.purge.everywhere', { attributes: { kind } }));
|
|
121
|
+
}
|
|
122
|
+
/** Produce a purge order that's safe against offset drift. Findings
|
|
123
|
+
* with `message_id IS NULL` (orphan rows that the schema permits but
|
|
124
|
+
* should never happen in practice) fall to the end so they don't
|
|
125
|
+
* interfere with message-grouped batches.
|
|
126
|
+
*
|
|
127
|
+
* Exported only for tests. */
|
|
128
|
+
export function orderForBulkPurge(db, findingIds) {
|
|
129
|
+
if (findingIds.length === 0)
|
|
130
|
+
return [];
|
|
131
|
+
const placeholders = findingIds.map(() => '?').join(',');
|
|
132
|
+
const rows = db.prepare(`SELECT id, message_id, start_offset
|
|
133
|
+
FROM findings
|
|
134
|
+
WHERE id IN (${placeholders})`).all(...findingIds);
|
|
135
|
+
const byMessage = new Map();
|
|
136
|
+
for (const r of rows) {
|
|
137
|
+
const key = r.message_id;
|
|
138
|
+
let bucket = byMessage.get(key);
|
|
139
|
+
if (!bucket) {
|
|
140
|
+
bucket = [];
|
|
141
|
+
byMessage.set(key, bucket);
|
|
142
|
+
}
|
|
143
|
+
bucket.push({ id: r.id, start_offset: r.start_offset });
|
|
144
|
+
}
|
|
145
|
+
for (const bucket of byMessage.values()) {
|
|
146
|
+
bucket.sort((a, b) => b.start_offset - a.start_offset);
|
|
147
|
+
}
|
|
148
|
+
// Stable iteration order over the messages doesn't matter for
|
|
149
|
+
// correctness — purges in different messages can't shift each
|
|
150
|
+
// other's offsets. We sort by message_id (nulls last) for
|
|
151
|
+
// determinism in tests.
|
|
152
|
+
const messageKeys = [...byMessage.keys()].sort((a, b) => {
|
|
153
|
+
if (a === null)
|
|
154
|
+
return 1;
|
|
155
|
+
if (b === null)
|
|
156
|
+
return -1;
|
|
157
|
+
return a - b;
|
|
158
|
+
});
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const key of messageKeys) {
|
|
161
|
+
for (const r of byMessage.get(key))
|
|
162
|
+
out.push(r.id);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
function applyPurgeTxn(db, finding, messageId, kind) {
|
|
167
|
+
const msg = db.prepare('SELECT content_text FROM messages WHERE id = ?').get(messageId);
|
|
168
|
+
if (!msg) {
|
|
169
|
+
throw new Error(`Message ${messageId} disappeared between read and purge`);
|
|
170
|
+
}
|
|
171
|
+
const original = msg.content_text.slice(finding.start_offset, finding.end_offset);
|
|
172
|
+
const mask = maskValueByKind(original, kind);
|
|
173
|
+
const purgedAt = new Date().toISOString();
|
|
174
|
+
db.transaction(() => {
|
|
175
|
+
const newText = msg.content_text.slice(0, finding.start_offset) +
|
|
176
|
+
mask +
|
|
177
|
+
msg.content_text.slice(finding.end_offset);
|
|
178
|
+
db.prepare('UPDATE messages SET content_text = ? WHERE id = ?')
|
|
179
|
+
.run(newText, messageId);
|
|
180
|
+
db.prepare(`UPDATE findings
|
|
181
|
+
SET state = 'purged',
|
|
182
|
+
state_changed_at = ?
|
|
183
|
+
WHERE id = ?`).run(purgedAt, finding.id);
|
|
184
|
+
updateSessionCounts(db, finding.session_id);
|
|
185
|
+
})();
|
|
186
|
+
return {
|
|
187
|
+
findingId: finding.id,
|
|
188
|
+
sessionId: finding.session_id,
|
|
189
|
+
maskUsed: mask,
|
|
190
|
+
purgedAt,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=purge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"purge.js","sourceRoot":"","sources":["../../src/security/purge.ts"],"names":[],"mappings":"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"}
|