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/README.md +42 -1
- package/dist/glassbox.html +7 -3
- package/package.json +1 -1
- package/src/adhere.mjs +41 -12
- package/src/claims.mjs +368 -0
- package/src/cli.mjs +65 -17
- package/src/fence.mjs +101 -24
- package/src/guard.mjs +96 -0
- package/src/trace-core.js +7 -3
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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) {
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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:
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
});
|