glassbox-trace 0.9.0 → 0.10.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/src/fence.mjs CHANGED
@@ -5,6 +5,10 @@
5
5
  // are never deleted, they sync wherever the home folder syncs, and they get attached to bug reports. `fence`
6
6
  // scans them (a session, a file, a folder, or the whole home), reports each hit with a masked preview and a
7
7
  // fingerprint (never the value), and with --shred rewrites the value in place as [FENCED:<rule>:<fingerprint>].
8
+ // Run over the whole home it also scans the other places Claude Code keeps text (STORES: prompt history, the
9
+ // paste cache, file-history snapshots, debug logs, shell snapshots).
10
+ // Fingerprints are keyed (HMAC-SHA256 under a 32-byte key that stays on the machine), so a report holding the
11
+ // fingerprint of a weak password gives nobody anything to check guesses against.
8
12
  // No network: nothing is verified against a provider, and the report is meant to stay on the machine.
9
13
  import fs from 'node:fs';
10
14
  import path from 'node:path';
@@ -23,7 +27,36 @@ const DUMP_CMD = /(?:^|[;&|]\s*)(?:cat|type|less|more|head|tail|Get-Content|gc)\
23
27
 
24
28
  // Shannon entropy in bits per character.
25
29
  export function entropy(s) { const n = s.length; if (!n) return 0; const c = new Map(); for (const ch of s) c.set(ch, (c.get(ch) || 0) + 1); let h = 0; for (const k of c.values()) { const p = k / n; h -= p * Math.log2(p); } return h; }
26
- export const fingerprint = (v) => crypto.createHash('sha256').update(v).digest('hex').slice(0, 8);
30
+ // Keyed fingerprint: the first 8 hex of HMAC-SHA256(key, value). Without a key (pure use, tests) a random key for
31
+ // this process is used, so fingerprints are stable within a run and are never a plain, guessable hash.
32
+ const PROCESS_KEY = crypto.randomBytes(32);
33
+ export const fingerprint = (v, key) => crypto.createHmac('sha256', key || PROCESS_KEY).update(String(v)).digest('hex').slice(0, 8);
34
+ export const defaultKeyFile = (home) => path.join(home || claudeHome(), 'glassbox', 'fence.key');
35
+ // A key file holds 64 hex characters. loadKey(file) reads one; ensureKey(file) creates it (owner-only) when missing.
36
+ export function loadKey(file) {
37
+ let raw; try { raw = fs.readFileSync(file, 'utf8').trim(); } catch (e) { throw new Error(`--key ${file}: not found`); }
38
+ if (!/^[0-9a-fA-F]{64}$/.test(raw)) throw new Error(`--key ${file}: must hold 64 hex characters (a 32-byte key)`);
39
+ return Buffer.from(raw, 'hex');
40
+ }
41
+ export function ensureKey(file) {
42
+ if (fs.existsSync(file)) return loadKey(file);
43
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
44
+ const key = crypto.randomBytes(32);
45
+ fs.writeFileSync(file, key.toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
46
+ return key;
47
+ }
48
+
49
+ // The other places under the Claude home that hold text a secret can land in (anthropics/claude-code#50014).
50
+ // Scanned when fence runs over the whole home without --project or --sessions-only.
51
+ export const STORES = [
52
+ { id: 'history', where: 'prompt history', file: 'history.jsonl' },
53
+ { id: 'paste-cache', where: 'paste cache', dir: 'paste-cache' },
54
+ { id: 'file-history', where: 'file history', dir: 'file-history', session: (rel) => rel.split(/[\\/]/)[0] },
55
+ { id: 'debug', where: 'debug log', dir: 'debug', session: (rel) => path.basename(rel).replace(/\.[^.]+$/, '') },
56
+ { id: 'shell-snapshots', where: 'shell snapshot', dir: 'shell-snapshots' },
57
+ ];
58
+ const STORE_MAX_BYTES = 64 * 1024 * 1024;
59
+ const isBinary = (buf) => buf.subarray(0, 8192).includes(0);
27
60
  export function mask(v) { const s = String(v); if (/-----BEGIN/.test(s)) return `PRIVATE KEY block (${s.length} chars)`; return `${s.length > 6 ? s.slice(0, 4) : ''}…${s.slice(-2)} (${s.length} chars)`; }
28
61
 
29
62
  // Detectors, most specific first: a hit whose span overlaps an earlier hit is dropped, so a GitHub token after
@@ -100,7 +133,8 @@ function fileReadHit(rec) {
100
133
  }
101
134
 
102
135
  // Scan raw transcript text. Returns hits in file order: { rule, severity, line, start, end, value, preview, fingerprint, where, tool?, path? }.
103
- export function scanText(text) {
136
+ export function scanText(text, opts = {}) {
137
+ const fp = (v) => fingerprint(v, opts.key);
104
138
  const starts = lineIndex(text);
105
139
  const hits = [];
106
140
  const spans = []; // [start, end] of accepted hits, for overlap suppression
@@ -113,7 +147,7 @@ export function scanText(text) {
113
147
  if (d.ok && !d.ok(value)) continue;
114
148
  if (overlaps(start, end)) continue;
115
149
  spans.push([start, end]);
116
- hits.push({ rule: d.id, severity: d.severity, start, end, value, preview: mask(value), fingerprint: fingerprint(value), line: lineAt(starts, start) });
150
+ hits.push({ rule: d.id, severity: d.severity, start, end, value, preview: mask(value), fingerprint: fp(value), line: lineAt(starts, start) });
117
151
  }
118
152
  }
119
153
  hits.sort((a, b) => a.start - b.start);
@@ -125,11 +159,11 @@ export function scanText(text) {
125
159
  const raw = lineText(text, starts, n); if (!raw.trim()) continue;
126
160
  let rec = null; try { rec = JSON.parse(raw); } catch (e) { continue; }
127
161
  const fr = fileReadHit(rec);
128
- if (fr) fileReads.push({ rule: 'credential-file-read', severity: 'info', line: n, start: starts[n - 1], end: starts[n - 1], value: null, preview: fr.path, fingerprint: fingerprint(fr.path), where: 'tool input', tool: fr.tool, path: fr.path });
162
+ if (fr) fileReads.push({ rule: 'credential-file-read', severity: 'info', line: n, start: starts[n - 1], end: starts[n - 1], value: null, preview: fr.path, fingerprint: fp(fr.path), where: 'tool input', tool: fr.tool, path: fr.path });
129
163
  const loc = locate(rec, tools);
130
164
  if (hitLines.has(n)) parsed.set(n, loc);
131
165
  }
132
- for (const h of hits) Object.assign(h, parsed.get(h.line) || { where: 'other' });
166
+ for (const h of hits) Object.assign(h, opts.where ? { where: opts.where } : parsed.get(h.line) || { where: 'other' });
133
167
  return hits.concat(fileReads).sort((a, b) => a.start - b.start || SEV[a.severity] - SEV[b.severity]);
134
168
  }
135
169
 
@@ -148,9 +182,13 @@ function rows(hits) {
148
182
  }
149
183
 
150
184
  export function scanFile(file, meta = {}) {
151
- const text = fs.readFileSync(file, 'utf8');
152
- const findings = rows(scanText(text)).map((r) => Object.assign(r, { file, session: meta.session || path.basename(file).replace(/\.jsonl$/, ''), project: meta.project || undefined }));
153
- return { file, bytes: Buffer.byteLength(text), lines: text.split('\n').length, findings, text };
185
+ const buf = fs.readFileSync(file);
186
+ if (meta.store && isBinary(buf)) return { file, bytes: buf.length, skipped: 'binary file', findings: [] };
187
+ const text = buf.toString('utf8');
188
+ const hits = scanText(text, { key: meta.key, where: meta.where });
189
+ const findings = rows(meta.store ? hits.filter((h) => h.rule !== 'credential-file-read') : hits).map((r) => Object.assign(r, {
190
+ file, session: meta.store ? meta.session || undefined : meta.session || path.basename(file).replace(/\.jsonl$/, ''), project: meta.project || undefined, store: meta.store || undefined }));
191
+ return { file, bytes: buf.length, lines: text.split('\n').length, findings, text, raw: buf };
154
192
  }
155
193
 
156
194
  // Rewrite every hit in place. `replacer(finding)` gives the replacement text; the default contains no quote,
@@ -158,30 +196,53 @@ export function scanFile(file, meta = {}) {
158
196
  // anything is written; on failure the file is left as it was.
159
197
  export function shredFile(file, findings, opts = {}) {
160
198
  const replacer = opts.replacer || ((f) => `[FENCED:${f.rule}:${f.fingerprint}]`);
161
- const text = opts.text != null ? opts.text : fs.readFileSync(file, 'utf8');
199
+ const raw = opts.raw || fs.readFileSync(file);
200
+ const text = opts.text != null ? opts.text : raw.toString('utf8');
201
+ const json = opts.json != null ? opts.json : /\.jsonl$/i.test(file);
162
202
  const spans = [];
163
203
  for (const f of findings) for (const [s, e] of (f.spans || [])) spans.push({ s, e, rep: replacer(f) });
164
204
  if (!spans.length) return { written: false, values: 0, reason: 'nothing to shred' };
205
+ if (!Buffer.from(text, 'utf8').equals(raw)) return { written: false, values: spans.length, reason: 'not plain UTF-8 text; nothing written' };
165
206
  spans.sort((a, b) => a.s - b.s);
166
207
  let out = '', pos = 0;
167
208
  for (const sp of spans) { if (sp.s < pos) continue; out += text.slice(pos, sp.s) + sp.rep; pos = sp.e; }
168
209
  out += text.slice(pos);
169
210
  const before = text.split('\n'), after = out.split('\n');
170
211
  if (before.length !== after.length) return { written: false, values: spans.length, reason: 'line count changed; nothing written' };
171
- for (let i = 0; i < after.length; i++) { if (after[i] === before[i]) continue; const l = after[i].replace(/\r$/, ''); if (!l.trim()) continue; try { JSON.parse(l); } catch (e) { return { written: false, values: spans.length, reason: `line ${i + 1} would not parse after the rewrite; nothing written` }; } }
212
+ if (json) for (let i = 0; i < after.length; i++) { if (after[i] === before[i]) continue; const l = after[i].replace(/\r$/, ''); if (!l.trim()) continue; try { JSON.parse(l); } catch (e) { return { written: false, values: spans.length, reason: `line ${i + 1} would not parse after the rewrite; nothing written` }; } }
172
213
  const tmp = file + '.glassbox-shred~';
173
- fs.writeFileSync(tmp, out); fs.renameSync(tmp, file);
214
+ const mode = fs.statSync(file).mode & 0o777;
215
+ fs.writeFileSync(tmp, out, { mode }); fs.renameSync(tmp, file);
174
216
  return { written: true, values: spans.length };
175
217
  }
176
218
 
177
- // Which files to scan. opts: { home, target, since (ms epoch), project, shred }.
219
+ // Which files to scan. opts: { home, target, since (ms epoch), project, sessionsOnly }.
178
220
  export function targets(opts = {}) {
179
221
  const home = opts.home || claudeHome();
180
222
  const list = [];
181
223
  const add = (file, session, project) => list.push({ file, session, project });
182
224
  const addSession = (s) => { add(s.file, s.id, s.project); for (const f of subagentFiles(s)) add(f.path, s.id, s.project); };
183
225
  const t = opts.target;
184
- if (!t) { for (const s of findSessions({ home, project: opts.project })) if (opts.since == null || s.mtime >= opts.since) addSession(s); return { list, mode: 'home', home }; }
226
+ if (!t) {
227
+ for (const s of findSessions({ home, project: opts.project })) if (opts.since == null || s.mtime >= opts.since) addSession(s);
228
+ const res = { list, mode: 'home', home };
229
+ if (opts.sessionsOnly) return res;
230
+ if (opts.project) { res.storesNote = 'prompt history, paste cache, file history, debug logs and shell snapshots are not scanned with --project (they are not kept per project)'; return res; }
231
+ res.stores = {};
232
+ const recent = (p) => opts.since == null || fs.statSync(p).mtimeMs >= opts.since;
233
+ for (const st of STORES) {
234
+ const found = [];
235
+ if (st.file) { const p = path.join(home, st.file); if (fs.existsSync(p) && fs.statSync(p).isFile() && recent(p)) found.push({ p, rel: st.file }); }
236
+ else {
237
+ const root = path.join(home, st.dir);
238
+ const walk = (dir) => { let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; } for (const e of ents.sort((a, b) => a.name.localeCompare(b.name))) { const p = path.join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.isFile() && recent(p)) found.push({ p, rel: path.relative(root, p) }); } };
239
+ walk(root);
240
+ }
241
+ for (const f of found) list.push({ file: f.p, store: st.id, where: st.where, session: st.session ? st.session(f.rel) : null });
242
+ if (found.length) res.stores[st.id] = found.length;
243
+ }
244
+ return res;
245
+ }
185
246
  if (fs.existsSync(t) && fs.statSync(t).isDirectory()) {
186
247
  const walk = (dir) => { for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { const p = path.join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.isFile() && /\.jsonl$/i.test(e.name) && e.name !== 'journal.jsonl' && (opts.since == null || fs.statSync(p).mtimeMs >= opts.since)) add(p, e.name.replace(/\.jsonl$/i, ''), null); } };
187
248
  walk(path.resolve(t)); return { list, mode: 'dir', dir: path.resolve(t) };
@@ -192,23 +253,31 @@ export function targets(opts = {}) {
192
253
  // The report. opts as `targets` plus { shred }.
193
254
  export function fence(opts = {}) {
194
255
  const tg = targets(opts);
195
- const files = [], findings = []; let bytes = 0; const sessions = new Set();
256
+ const home = opts.home || claudeHome();
257
+ const keyFile = opts.keyFile ? path.resolve(opts.keyFile) : defaultKeyFile(home);
258
+ const key = opts.keyFile ? loadKey(keyFile) : ensureKey(keyFile);
259
+ const files = [], findings = [], skipped = []; let bytes = 0; const sessions = new Set();
196
260
  const shredded = { files: 0, values: 0, refused: [] };
197
261
  for (const t of tg.list) {
198
- let r; try { r = scanFile(t.file, { session: t.session, project: t.project }); } catch (e) { shredded.refused.push({ file: t.file, reason: e.message }); continue; }
199
- files.push({ file: t.file, session: t.session, bytes: r.bytes, findings: r.findings.length });
200
- bytes += r.bytes; sessions.add(t.session);
262
+ if (t.store && fs.statSync(t.file).size > STORE_MAX_BYTES) { skipped.push({ file: t.file, reason: `larger than ${STORE_MAX_BYTES / 1048576} MB` }); continue; }
263
+ let r; try { r = scanFile(t.file, { session: t.session, project: t.project, store: t.store, where: t.where, key }); } catch (e) { shredded.refused.push({ file: t.file, reason: e.message }); continue; }
264
+ if (r.skipped) { skipped.push({ file: t.file, reason: r.skipped }); continue; }
265
+ files.push({ file: t.file, session: t.session || undefined, store: t.store, bytes: r.bytes, findings: r.findings.length });
266
+ bytes += r.bytes; if (!t.store) sessions.add(t.session);
201
267
  findings.push(...r.findings);
202
- if (opts.shred) { const s = shredFile(t.file, r.findings, { text: r.text }); if (s.written) { shredded.files++; shredded.values += s.values; } else if (s.reason !== 'nothing to shred') shredded.refused.push({ file: t.file, reason: s.reason }); }
268
+ if (opts.shred) { const s = shredFile(t.file, r.findings, { text: r.text, raw: r.raw }); if (s.written) { shredded.files++; shredded.values += s.values; } else if (s.reason !== 'nothing to shred') shredded.refused.push({ file: t.file, reason: s.reason }); }
203
269
  }
204
270
  findings.sort((a, b) => SEV[a.severity] - SEV[b.severity] || a.file.localeCompare(b.file) || a.line - b.line);
271
+ // Files actually scanned per store (skipped ones are listed under scanned.skipped); undefined when stores were not in scope.
272
+ let storeCounts; if (tg.stores) { storeCounts = {}; for (const st of STORES) { const n = files.filter((f) => f.store === st.id).length; if (n) storeCounts[st.id] = n; } }
205
273
  const bySeverity = { error: 0, warn: 0, info: 0 }; for (const f of findings) bySeverity[f.severity]++;
206
274
  const byRule = {}; for (const f of findings) byRule[f.rule] = (byRule[f.rule] || 0) + f.count;
207
275
  const secretMap = new Map();
208
- for (const f of findings) { if (f.rule === 'credential-file-read') continue; const k = f.fingerprint; if (!secretMap.has(k)) secretMap.set(k, { fingerprint: k, rule: f.rule, severity: f.severity, preview: f.preview, sessions: new Set(), files: new Set(), occurrences: 0, advice: ADVICE[f.rule] }); const s = secretMap.get(k); s.sessions.add(f.session); s.files.add(f.file); s.occurrences += f.count; }
276
+ for (const f of findings) { if (f.rule === 'credential-file-read') continue; const k = f.fingerprint; if (!secretMap.has(k)) secretMap.set(k, { fingerprint: k, rule: f.rule, severity: f.severity, preview: f.preview, sessions: new Set(), files: new Set(), occurrences: 0, advice: ADVICE[f.rule] }); const s = secretMap.get(k); if (f.session && !f.store) s.sessions.add(f.session); s.files.add(f.file); s.occurrences += f.count; }
209
277
  const secrets = [...secretMap.values()].map((s) => Object.assign(s, { sessions: s.sessions.size, files: s.files.size })).sort((a, b) => SEV[a.severity] - SEV[b.severity] || b.sessions - a.sessions || b.occurrences - a.occurrences);
210
- const rep = { glassbox: core.VERSION, kind: 'fence', schema: 1, generated: new Date().toISOString(), mode: tg.mode, home: tg.home, dir: tg.dir, since: opts.since ? new Date(opts.since).toISOString() : undefined,
211
- scanned: { files: files.length, sessions: sessions.size, bytes },
278
+ const rep = { glassbox: core.VERSION, kind: 'fence', schema: 2, generated: new Date().toISOString(), mode: tg.mode, home: tg.home, dir: tg.dir, since: opts.since ? new Date(opts.since).toISOString() : undefined,
279
+ fingerprint: { alg: 'hmac-sha256', chars: 8, key: keyFile },
280
+ scanned: { files: files.length, sessions: sessions.size, bytes, stores: storeCounts, storesNote: tg.storesNote, skipped },
212
281
  summary: { bySeverity, byRule, filesWithFindings: files.filter((f) => f.findings).length, distinctSecrets: secrets.length, secrets },
213
282
  findings, files };
214
283
  if (opts.shred) rep.shredded = shredded;
@@ -217,6 +286,8 @@ export function fence(opts = {}) {
217
286
 
218
287
  export function failedAt(rep, failOn = 'error') { if (!(failOn in SEV)) throw new Error(`--fail-on must be error, warn or info (got "${failOn}")`); return rep.findings.some((f) => SEV[f.severity] <= SEV[failOn]); }
219
288
 
289
+ const STORE_WORDS = { history: 'prompt history', 'paste-cache': 'paste cache', 'file-history': 'file history', debug: 'debug logs', 'shell-snapshots': 'shell snapshots' };
290
+ const storeLine = (stores) => { const n = Object.values(stores).reduce((a, b) => a + b, 0); return `${n} other Claude Code file${n === 1 ? '' : 's'} (${Object.keys(stores).map((k) => STORE_WORDS[k] || k).join(', ')})`; };
220
291
  const fmtBytes = (n) => n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' KB' : n + ' B';
221
292
  const place = (f) => `${f.where}${f.tool ? ' · ' + f.tool : ''}${f.path && f.rule !== 'credential-file-read' ? ' ' + f.path : ''}`;
222
293
 
@@ -224,7 +295,9 @@ export function fenceText(rep) {
224
295
  const L = [];
225
296
  const what = rep.mode === 'home' ? `${rep.scanned.sessions} session${rep.scanned.sessions === 1 ? '' : 's'} under ${path.join(rep.home, 'projects')}` : rep.mode === 'dir' ? `${rep.scanned.files} file${rep.scanned.files === 1 ? '' : 's'} under ${rep.dir}` : `${rep.scanned.files} file${rep.scanned.files === 1 ? '' : 's'}`;
226
297
  const s = rep.summary.bySeverity;
227
- L.push(`Glassbox fence · ${what} · ${fmtBytes(rep.scanned.bytes)}${rep.since ? ` · since ${rep.since.slice(0, 10)}` : ''}`);
298
+ const nStore = rep.scanned.stores ? Object.values(rep.scanned.stores).reduce((a, b) => a + b, 0) : 0;
299
+ L.push(`Glassbox fence · ${what}${nStore ? ` + ${storeLine(rep.scanned.stores)}` : ''} · ${fmtBytes(rep.scanned.bytes)}${rep.since ? ` · since ${rep.since.slice(0, 10)}` : ''}`);
300
+ if (rep.scanned.storesNote) L.push(` (${rep.scanned.storesNote})`);
228
301
  if (!rep.findings.length) { L.push(' nothing found — no known credential formats, no secrets named by context, no credential-file reads'); }
229
302
  else {
230
303
  L.push(` ${s.error} error · ${s.warn} warn · ${s.info} info · ${rep.summary.distinctSecrets} distinct secret${rep.summary.distinctSecrets === 1 ? '' : 's'} in ${rep.summary.filesWithFindings} of ${rep.scanned.files} files`, '');
@@ -237,12 +310,15 @@ export function fenceText(rep) {
237
310
  for (const x of rep.summary.secrets.slice(0, 20)) L.push(` ${x.severity.toUpperCase().padEnd(5)} ${x.rule.padEnd(21)} ${x.fingerprint} ${x.preview} in ${x.sessions} session${x.sessions === 1 ? '' : 's'}, ${x.occurrences} place${x.occurrences === 1 ? '' : 's'}`);
238
311
  L.push('', ' A secret that reached a transcript reached a disk: rotate it, then run again with --shred to overwrite it in place.');
239
312
  }
313
+ if (rep.scanned.skipped && rep.scanned.skipped.length) L.push(` skipped ${rep.scanned.skipped.length} file${rep.scanned.skipped.length === 1 ? '' : 's'} (binary or too large)`);
314
+ L.push(` fingerprints: HMAC-SHA256 under ${rep.fingerprint.key} (keep that file on this machine)`);
240
315
  if (rep.shredded) { L.push('', ` Shredded ${rep.shredded.values} value${rep.shredded.values === 1 ? '' : 's'} in ${rep.shredded.files} file${rep.shredded.files === 1 ? '' : 's'} (each is now [FENCED:<rule>:<fingerprint>]; no backup was kept).`); for (const r of rep.shredded.refused) L.push(` refused ${r.file}: ${r.reason}`); }
241
316
  return L.join('\n') + '\n';
242
317
  }
243
318
 
244
319
  export function fenceMarkdown(rep) {
245
- const L = [`# Glassbox fence — ${rep.generated.slice(0, 10)}`, '', `Scanned ${rep.scanned.files} file${rep.scanned.files === 1 ? '' : 's'} (${rep.scanned.sessions} session${rep.scanned.sessions === 1 ? '' : 's'}, ${fmtBytes(rep.scanned.bytes)})${rep.since ? ` written since ${rep.since.slice(0, 10)}` : ''}. Previews are masked and fingerprints are the first 8 hex of SHA-256; this report holds no secret values.`, ''];
320
+ const L = [`# Glassbox fence — ${rep.generated.slice(0, 10)}`, '', `Scanned ${rep.scanned.files} file${rep.scanned.files === 1 ? '' : 's'} (${rep.scanned.sessions} session${rep.scanned.sessions === 1 ? '' : 's'}${rep.scanned.stores && Object.keys(rep.scanned.stores).length ? ` + ${storeLine(rep.scanned.stores)}` : ''}, ${fmtBytes(rep.scanned.bytes)})${rep.since ? ` written since ${rep.since.slice(0, 10)}` : ''}. Previews are masked and fingerprints are the first 8 hex of HMAC-SHA256 under a key kept on this machine; this report holds no secret values.`, ''];
321
+ if (rep.scanned.storesNote) L.push(`_${rep.scanned.storesNote}._`, '');
246
322
  const s = rep.summary.bySeverity;
247
323
  if (!rep.findings.length) { L.push('**Nothing found.** No known credential formats, no secrets named by context, no credential-file reads.'); return L.join('\n') + '\n'; }
248
324
  L.push(`**${s.error} error · ${s.warn} warn · ${s.info} info** — ${rep.summary.distinctSecrets} distinct secret${rep.summary.distinctSecrets === 1 ? '' : 's'} in ${rep.summary.filesWithFindings} of ${rep.scanned.files} files.`, '');
@@ -251,11 +327,12 @@ export function fenceMarkdown(rep) {
251
327
  L.push('', '## Where, file by file', '');
252
328
  const byFile = new Map(); for (const f of rep.findings) { if (!byFile.has(f.file)) byFile.set(f.file, []); byFile.get(f.file).push(f); }
253
329
  for (const [file, list] of byFile) {
254
- L.push(`### ${file}`, '', `session \`${list[0].session}\`${list[0].project ? ` · project ${list[0].project}` : ''}`, '', '| rule | severity | line | preview | where |', '|---|---|---:|---|---|');
330
+ L.push(`### ${file}`, '', list[0].store ? `${STORE_WORDS[list[0].store] || list[0].store}${list[0].session ? ` · session \`${list[0].session}\`` : ''}` : `session \`${list[0].session}\`${list[0].project ? ` · project ${list[0].project}` : ''}`, '', '| rule | severity | line | preview | where |', '|---|---|---:|---|---|');
255
331
  for (const f of list) L.push(`| \`${f.rule}\` | ${f.severity} | ${f.line} | ${f.preview}${f.count > 1 ? ` ×${f.count}` : ''} | ${place(f)} |`);
256
332
  L.push('');
257
333
  }
258
334
  L.push('## What to do', '', 'A secret that reached a transcript reached a disk, and whatever backs that disk up. Rotate each one above, then run `glassbox fence --shred` to overwrite the values in place with `[FENCED:<rule>:<fingerprint>]`. Shredded transcripts still open and check.', '');
335
+ if (rep.scanned.skipped && rep.scanned.skipped.length) L.push(`Skipped ${rep.scanned.skipped.length} file${rep.scanned.skipped.length === 1 ? '' : 's'} (binary or too large).`, '');
259
336
  if (rep.shredded) { L.push(`Shredded ${rep.shredded.values} value${rep.shredded.values === 1 ? '' : 's'} in ${rep.shredded.files} file${rep.shredded.files === 1 ? '' : 's'}; no backup was kept.`); for (const r of rep.shredded.refused) L.push(`- refused ${r.file}: ${r.reason}`); L.push(''); }
260
337
  return L.join('\n');
261
338
  }
package/src/guard.mjs ADDED
@@ -0,0 +1,96 @@
1
+ // Glassbox guard — a PreToolUse hook that stops the one loop worth stopping live: the agent is
2
+ // about to make a call that has already failed, unchanged, twice in a row this turn.
3
+ //
4
+ // Deliberately narrow, so it doesn't get in the way:
5
+ // - only failures count (denied-by-the-human and interrupted calls don't; they have their own flow);
6
+ // - the chain is broken by anything that could have changed the outcome: an edit, another command,
7
+ // any non-read call, a success, or a new human prompt. Re-running tests after a fix is never blocked;
8
+ // - calls are compared on what they do, not how they're labelled: Bash/PowerShell `description`,
9
+ // `timeout` and `run_in_background` are ignored, because the agent often rewords them on a retry.
10
+ // It never approves anything: when it has nothing to say it prints nothing, which leaves Claude Code's
11
+ // normal permission flow in charge ("allow" would skip the user's permission prompts).
12
+ import fs from 'node:fs';
13
+ import { createRequire } from 'node:module';
14
+ const require = createRequire(import.meta.url);
15
+ const core = require('./trace-core.js');
16
+
17
+ export const GUARD_DEFAULTS = { maxFailures: 2, tailBytes: 4 * 1048576 };
18
+ const IGNORED_KEYS = new Set(['description', 'timeout', 'run_in_background']);
19
+
20
+ export function guardKey(name, input) {
21
+ const i = {};
22
+ for (const [k, v] of Object.entries(input || {})) if (!IGNORED_KEYS.has(k)) i[k] = v;
23
+ return name + '|' + core.stableStringify(i);
24
+ }
25
+
26
+ // The last few MB of a transcript: the current turn is at the end, and a PreToolUse hook runs before
27
+ // every tool call, so it must not parse a 20 MB session each time.
28
+ function readTail(file, bytes) {
29
+ const size = fs.statSync(file).size;
30
+ const start = Math.max(0, size - bytes);
31
+ const fd = fs.openSync(file, 'r');
32
+ try {
33
+ const buf = Buffer.alloc(size - start); fs.readSync(fd, buf, 0, buf.length, start);
34
+ let text = buf.toString('utf8');
35
+ if (start > 0) text = text.slice(text.indexOf('\n') + 1); // drop the partial first line
36
+ return text;
37
+ } finally { fs.closeSync(fd); }
38
+ }
39
+
40
+ function isHumanPrompt(rec) {
41
+ if (rec.type !== 'user' || rec.isMeta || rec.isCompactSummary) return false;
42
+ const c = rec.message && rec.message.content;
43
+ if (Array.isArray(c) && c.some((b) => b && b.type === 'tool_result')) return false;
44
+ const text = core.textOf(c).trim();
45
+ return !!text && !/^<(system-reminder|local-command|command-name|bash-input|bash-stdout)/.test(text) && !/^\[Request interrupted by user/.test(text);
46
+ }
47
+
48
+ // Returns null (say nothing) or the PreToolUse deny reply.
49
+ export function guardResponse(input, opts = {}) {
50
+ const o = Object.assign({}, GUARD_DEFAULTS, opts);
51
+ if (!input || !input.tool_name) return null;
52
+ const file = input.agent_transcript_path || input.transcript_path;
53
+ if (!file || !fs.existsSync(file)) return null;
54
+ const text = readTail(file, o.tailBytes);
55
+ if (!/"is_error":\s*true/.test(text)) return null; // nothing has failed recently: the common case, and fast
56
+
57
+ const agent = input.agent_id || null;
58
+ const calls = []; const results = new Map(); let turnStart = 0;
59
+ for (const line of text.split('\n')) {
60
+ if (!line.trim()) continue;
61
+ let rec; try { rec = JSON.parse(line); } catch (e) { continue; }
62
+ if ((rec.agentId || null) !== agent && !(agent && input.agent_transcript_path)) continue; // other agents' records in a shared file
63
+ if (isHumanPrompt(rec)) { turnStart = calls.length; continue; }
64
+ const content = rec.message && Array.isArray(rec.message.content) ? rec.message.content : [];
65
+ if (rec.type === 'assistant') for (const b of content) if (b && b.type === 'tool_use') calls.push({ id: b.id, name: b.name, key: guardKey(b.name, b.input) });
66
+ if (rec.type === 'user') for (const b of content) if (b && b.type === 'tool_result') {
67
+ const t = core.textOf(b.content);
68
+ results.set(b.tool_use_id, { isError: !!b.is_error, text: t, denial: b.is_error ? core.denialKind(rec, t) : null });
69
+ }
70
+ }
71
+
72
+ const target = guardKey(input.tool_name, input.tool_input);
73
+ let failures = 0, lastError = null;
74
+ for (let i = calls.length - 1; i >= turnStart; i--) {
75
+ const c = calls[i];
76
+ if (c.id === input.tool_use_id) continue; // the call being decided may already be in the transcript
77
+ const r = results.get(c.id);
78
+ if (!r) continue; // still running (a parallel call): no outcome yet
79
+ if (c.key === target) {
80
+ if (!r.isError || r.denial) break; // it worked last time, or a person stopped it: not a loop
81
+ failures++; if (lastError == null) lastError = r.text;
82
+ continue;
83
+ }
84
+ if (core.toolCategory(c.name) === 'read') continue; // looking around changes nothing
85
+ break; // an edit, another command, any action: the outcome may be different now
86
+ }
87
+ if (failures < o.maxFailures) return null;
88
+ const err = String(lastError || '').split('\n').map((l) => l.trim()).find(Boolean) || 'no error text';
89
+ return {
90
+ hookSpecificOutput: {
91
+ hookEventName: 'PreToolUse',
92
+ permissionDecision: 'deny',
93
+ permissionDecisionReason: `Glassbox guard: this exact ${input.tool_name} call has already failed ${failures} times in a row this turn, with nothing changed in between. Last error: ${err.slice(0, 300)}\nDon't repeat it unchanged. Read the error, then change the input, fix what it depends on, try a different approach, or ask the user. A call that differs, or comes after an edit or another command, is not blocked.`,
94
+ },
95
+ };
96
+ }
package/src/trace-core.js CHANGED
@@ -9,7 +9,7 @@
9
9
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
10
10
  'use strict';
11
11
 
12
- const VERSION = '0.9.0';
12
+ const VERSION = '0.10.0';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -934,6 +934,10 @@
934
934
  'permission-denied': 'Before a call the user may not want (installs, network, deleting, publishing), say what it will do and ask; for calls they always allow, suggest a permissions allow rule so the prompt goes away.',
935
935
  'duplicate-subagent-read': 'When several subagents need the same file, put the relevant excerpt in their briefs or have one agent summarise it once, instead of every agent reading it into its own context.',
936
936
  'idle-cache-expiry': 'The cache expired while the session sat idle; resume sooner, use the 1-hour cache lifetime where available, or start a fresh session with a summary instead of re-sending the whole history.',
937
+ // claims ledger (src/claims.mjs): said vs did
938
+ 'unverified-claim': "Say it after you've checked it: a test claim needs a test run after your last edit, a shipped claim needs the command's result in this transcript, a 'nothing changed' claim needs a git status after the thing that could have changed it. If you did not check, say 'not re-run' instead.",
939
+ 'contradicted-claim': 'The transcript disagrees with what you told the user; read the tool result you are summarising before you summarise it, and correct the statement now.',
940
+ 'stale-claim': 'The check you are citing ran before your last edit, or covers only part of what you said; run it again or say exactly what it covered.',
937
941
  };
938
942
 
939
943
  // Advice for a concrete finding: the rule's line, except where the evidence says otherwise.
@@ -982,7 +986,7 @@
982
986
  }
983
987
 
984
988
  // Rules whose detail quotes tool output or model text (everything else is names and numbers).
985
- const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error', 'duplicate-subagent-read']);
989
+ const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error', 'duplicate-subagent-read', 'unverified-claim', 'contradicted-claim', 'stale-claim']);
986
990
  function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
987
991
 
988
992
  // ---------------------------------------------------------------------------
@@ -1176,5 +1180,5 @@
1176
1180
  }
1177
1181
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1178
1182
 
1179
- return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable, fileStats, filePathOf, normalisePath };
1183
+ return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, parseTrace, diagnose, denialKind, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable, fileStats, filePathOf, normalisePath };
1180
1184
  });