glassbox-trace 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,10 +56,15 @@ The npm package is `glassbox-trace` (plain `glassbox` was already taken); the co
56
56
 
57
57
  Feedback at the end of a session can only change that session's last reply. Add `--context` to carry it forward: the Stop hook keeps the findings (and, with `--feedback`, the agent's answer) in `<project>/.glassbox/last-session.md` — git-ignored, deleted after a clean session — and a SessionStart hook gives them to the next session in that project.
58
58
 
59
+ **Stop a loop before it happens.** `--guard` adds a PreToolUse hook: when the agent is about to repeat a call that has already failed twice in a row, unchanged, in the current turn, it blocks the call and tells the agent why, with the last error, and to change something or ask you rather than try again. It is deliberately narrow. Anything that could change the outcome in between — an edit, another command, a success, a new prompt — resets it, so re-running the tests after a fix is never blocked; calls a person stopped don't count; it never approves anything, so your permission prompts are untouched. Replayed over 4,121 real tool calls it blocked none, so it stays out of the way until an agent is actually stuck. It runs before every tool call, so it needs an installed copy: `--guard` writes a direct `node "…/bin/glassbox.mjs" hook …` command (for the Stop hook too) and refuses to install from an `npx` cache, where each call would cost seconds. The plugin doesn't include the guard ([why](docs/PLUGIN.md#the-loop-guard-is-not-in-the-plugin)); if both are installed, the plugin sees the CLI hook and steps aside.
60
+
59
61
  ```
60
62
  glassbox hook install --feedback --context --fail-on warn # summary, feedback, and notes for the next session
61
63
  glassbox hook install # summary only
62
64
  glassbox hook uninstall
65
+
66
+ npm i -g glassbox-trace # the guard needs an installed copy, not npx
67
+ glassbox hook install --feedback --context --guard # all of the above, plus the loop guard
63
68
  ```
64
69
 
65
70
  **Or install it as a Claude Code plugin** — the same hooks plus `/glassbox:check` and a skill Claude uses when you ask "what went wrong in this session?", running the bundled code (no npx): `/plugin marketplace add aldohushi1-stack/glassbox`, then `/plugin install glassbox@glassbox-trace`. Feedback and context are opt-in plugin options. See [docs/PLUGIN.md](docs/PLUGIN.md).
@@ -94,6 +99,8 @@ glassbox fence 81c4cbfd one session (and its subagents)
94
99
  glassbox fence ./archive every .jsonl under a folder
95
100
  glassbox fence --format md --out fence.md
96
101
  glassbox fence --shred overwrite each value in place with [FENCED:<rule>:<fingerprint>]
102
+ glassbox fence --sessions-only transcripts only (the default sweep also covers prompt history, paste cache, file history, debug logs, shell snapshots)
103
+ glassbox fence --key team.key one shared fingerprint key for a fleet
97
104
  ```
98
105
 
99
106
  ```
@@ -106,7 +113,7 @@ Glassbox fence · 34 sessions under /home/aldo/.claude/projects · 212 MB
106
113
  INFO credential-file-read line 211 /home/aldo/api/.env — tool input · Read
107
114
  ```
108
115
 
109
- Each row is a masked preview and a fingerprint (first 8 hex of SHA-256) — enough to know which key it is and to see the same key across sessions, never enough to use it. `error` is a credential whose format identifies it (AWS, GitHub, Anthropic, OpenAI, Slack, Stripe, Google, npm, SendGrid, a private key block, a database URL with its password); `warn` is a secret named by its context (`password=`, `api_key:`, `Bearer …`, a URL with credentials, a JWT), kept only when the value has the entropy of a real one and is not a placeholder; `info` is a read of a credential file (`.env`, `.npmrc`, `.netrc`, `~/.aws/credentials`, `id_rsa`, `*.pem` …) whose contents are now in the transcript whether or not a rule recognised them. A secret that reached a transcript reached a disk, and whatever backs that disk up: rotate it, then `--shred`. Shredded transcripts still open and check. It verifies nothing against any provider (no network), and a password in prose with no context word passes through — it is a net, not a guarantee. Details in [docs/FENCE.md](docs/FENCE.md).
116
+ Each row is a masked preview and a fingerprint (first 8 hex of HMAC-SHA256 under a key that stays on the machine, so even a weak password's fingerprint can't be guessed against) — enough to know which key it is and to see the same key across sessions, never enough to use it. A sweep of the whole home also covers the other places Claude Code keeps text: prompt history, the paste cache, file-history snapshots, debug logs and shell snapshots. `error` is a credential whose format identifies it (AWS, GitHub, Anthropic, OpenAI, Slack, Stripe, Google, npm, SendGrid, a private key block, a database URL with its password); `warn` is a secret named by its context (`password=`, `api_key:`, `Bearer …`, a URL with credentials, a JWT), kept only when the value has the entropy of a real one and is not a placeholder; `info` is a read of a credential file (`.env`, `.npmrc`, `.netrc`, `~/.aws/credentials`, `id_rsa`, `*.pem` …) whose contents are now in the transcript whether or not a rule recognised them. A secret that reached a transcript reached a disk, and whatever backs that disk up: rotate it, then `--shred`. Shredded transcripts still open and check. It verifies nothing against any provider (no network), and a password in prose with no context word passes through — it is a net, not a guarantee. Details in [docs/FENCE.md](docs/FENCE.md).
110
117
 
111
118
  ### Is my CLAUDE.md doing anything? — `glassbox adhere`
112
119
 
@@ -180,6 +187,7 @@ src/trace-core.js pure engine: parseTrace · diagnose · estimateCost · red
180
187
  src/viewer.html the UI; the build inlines trace-core and the demo
181
188
  src/cli.mjs CLI library (session discovery, embed, check, compare, hook); bin/glassbox.mjs is the entry point
182
189
  src/tail.mjs live tail: byte-offset tailer + loopback SSE server for `glassbox watch`
190
+ src/guard.mjs the PreToolUse loop guard (`hook --guard`)
183
191
  scripts/build.mjs build
184
192
  scripts/sanitize.mjs turn a real transcript into a shareable fixture (demo or structure mode)
185
193
  scripts/corpus-audit.mjs run every rule over your local sessions; counts only (--baseline to diff two runs)
@@ -215,3 +223,7 @@ Your context is private — by construction, not by promise.
215
223
  - **The shape, not the names.** A redacted report still needs to say "this one file was read 48 times and 6 of those failed". `check --redact --legend audit.legend.json` replaces every path with a keyed hash (`file:b94b1a35`, HMAC-SHA256 with a random salt) and writes the key → path map to the legend file, which stays on your machine. The redacted JSON carries `summary.files` (reads, writes, failures, agents, chars per key) and `evidence.files` on findings; `duplicate-subagent-read` keeps its sentence with the key in it. `glassbox reveal report.md --legend audit.legend.json` turns the keys back into paths for you. Re-using the legend keeps keys stable across runs; two machines with two legends produce different keys for the same file. Treat the legend like a password file: local, git-ignored, never attached to the same email as the report. Design notes in DESIGN.md §12.
216
224
 
217
225
  Source: [github.com/aldohushi1-stack/glassbox](https://github.com/aldohushi1-stack/glassbox) · MIT © Aldo Hushi
226
+
227
+ ## Security
228
+
229
+ Found a way Glassbox could leak, run something it shouldn't, or be tampered with? Please report it privately — see [SECURITY.md](SECURITY.md).
@@ -408,7 +408,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
408
408
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
409
409
  'use strict';
410
410
 
411
- const VERSION = '0.9.0';
411
+ const VERSION = '0.9.1';
412
412
 
413
413
  // ---------------------------------------------------------------------------
414
414
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -1575,7 +1575,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1575
1575
  }
1576
1576
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1577
1577
 
1578
- 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 };
1578
+ 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 };
1579
1579
  });
1580
1580
  </script>
1581
1581
  <script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glassbox-trace",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a fleet view, a secrets scanner for transcripts (fence), a CLAUDE.md compliance report (adhere), a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
5
5
  "main": "src/trace-core.js",
6
6
  "scripts": {
package/src/adhere.mjs CHANGED
@@ -30,9 +30,31 @@ const ACTION = (w) => /^commit/i.test(w) ? 'commit' : /^push/i.test(w) ? 'push'
30
30
  const strip = (s) => s.replace(/`/g, '').replace(/\s+/g, ' ').trim();
31
31
  const whatOf = (s) => { const w = strip(s).replace(/^(?:the|all|your|our)\s+/i, ''); if (/^(?:tests?|test suite|unit tests|the tests)$/i.test(w)) return 'tests'; if (/^(?:formatter|code formatter)$/i.test(w)) return 'formatter'; if (/^(?:linter|lint)$/i.test(w)) return 'linter'; return w.replace(/\.$/, ''); };
32
32
 
33
- // Each matcher returns the rule's parameters or null. Order matters: first match wins.
33
+ // ask-before: the actions named in a clause, in order, once each ("any commit / push / deploy" is three rules).
34
+ const ACT_WORD = String.raw`(?:commit|push|delet|remov|install|creat|deploy|merg)\w*`;
35
+ const actionsIn = (s) => [...new Set((s.match(new RegExp(String.raw`\b${ACT_WORD}`, 'gi')) || []).map(ACTION))];
36
+ // A run of actions joined by , / or and: "committing, pushing or deploying". Stops at the first non-action word.
37
+ const actList = (w) => String.raw`${w}(?:\s*(?:,|\/|\bor\b|\band\b)\s*(?:(?:or|and)\s+)?(?:any\s+|a\s+|an\s+)?${w})*`;
38
+ const ACT_LIST = actList(ACT_WORD);
39
+ const ACT_LIST_NC = actList(ACT_WORD.replace('creat|', '')); // "don't create … unless I ask" belongs to no-new-docs
40
+ const ASK_VERB = String.raw`(?:ask(?:\s+me)?|check with me|confirm(?:\s+with me)?|get (?:my )?(?:explicit\s+)?(?:permission|approval|ok|confirmation)|wait for (?:my\s+|your\s+|an?\s+)?(?:explicit\s+)?(?:confirmation|approval|permission|go-?ahead|ok))`;
41
+ const CONFIRM_NOUN = String.raw`(?:wait(?:ing)? for|get(?:ting)?|obtain(?:ing)?|ask(?:ing)? for|receiv(?:e|ing))\s+(?:my\s+|your\s+|an?\s+|the\s+)?(?:explicit\s+|express\s+|written\s+|clear\s+)?(?:confirmation|approval|permission|sign-?off|go-?ahead|ok)\b`;
42
+
43
+ // Each matcher returns the rule's parameters (an array for one rule per action) or null. Order matters: first match wins.
34
44
  const SHAPES = [
35
- { kind: 'ask-before', m: (s) => { let m = s.match(/\b(?:ask(?:\s+me)?|check with me|confirm(?:\s+with me)?|get (?:my )?(?:permission|approval|ok))\s*(?:first\s+)?(?:before|prior to)\s+(?:you\s+)?(?:\w+ing\s+)?(commit|push|delet|remov|install|creat|deploy|merg)\w*/i) || s.match(/\b(?:ask(?:\s+me)?|check with me)\s+before\s+(?:you\s+)?(commit|push|delet|remov|install|creat|deploy|merg)\w*/i); if (m) return { action: ACTION(m[1]) }; m = s.match(/\b(?:never|don't|do not|don’t)\s+(commit|push|delete|remove|install|deploy|merge)\b[^.]*?\b(?:without|unless)\s+(?:first\s+)?(?:asking|being asked|i ask|you ask|i tell|told|permission|approval|confirm|checking|explicit)/i); if (m) return { action: ACTION(m[1]) }; return null; } },
45
+ { kind: 'ask-before', m: (s) => {
46
+ const each = (acts) => acts.length ? acts.map((action) => ({ action })) : null;
47
+ // "ask before committing or pushing" · "wait for approval before any deploy"
48
+ let m = s.match(new RegExp(String.raw`\b${ASK_VERB}\s*(?:first\s+)?(?:before|prior to)\s+(?:you\s+)?(?:any\s+|a\s+|an\s+)?(?:\w+ing\s+)?(${ACT_LIST})`, 'i'));
49
+ if (m) return each(actionsIn(m[1]));
50
+ // Reversed: "Before editing X, or any commit / push / deploy, state the change and wait for explicit confirmation."
51
+ m = s.match(new RegExp(String.raw`\b(?:before|prior to)\s+([^.]*?)\b${CONFIRM_NOUN}`, 'i'));
52
+ if (m) { const r = each(actionsIn(m[1])); if (r) return r; }
53
+ // "never push without asking" · "do not commit or push unless I ask"
54
+ m = s.match(new RegExp(String.raw`\b(?:never|don't|do not|don’t)\s+(${ACT_LIST_NC})\b[^.]*?\b(?:without|unless)\s+(?:first\s+)?(?:asking|being asked|i ask|you ask|i tell|told|permission|approval|confirm|checking|explicit)`, 'i'));
55
+ if (m) return each(actionsIn(m[1]));
56
+ return null;
57
+ } },
36
58
  { kind: 'run-before', m: (s) => { let m = s.match(/\b(?:always\s+)?run\s+(?:the\s+)?(`[^`]+`|[\w][\w .:/-]*?)\s+(?:before|prior to)\s+(?:you\s+|every\s+|each\s+|any\s+|a\s+)?(?:\w+ing\s+)?(commit|push|merg|pull request|pr\b|open)/i); if (m) return { what: whatOf(m[1]), trigger: /^(commit)/i.test(m[2]) ? 'commit' : 'push' }; m = s.match(/\b(?:never|don't|do not|don’t)\s+(commit|push)\b[^.]*?\b(?:without|unless)\s+(?:first\s+)?(?:running\s+|you(?:'ve| have)?\s+run\s+|the\s+)?(`[^`]+`|[\w][\w .:/-]*?)(?:\s+(?:pass(?:es|ing)?|first|succeed\w*))?\.?$/i); if (m && !/\bask|permission|approv|confirm|check/i.test(s)) return { what: whatOf(m[2]), trigger: m[1].toLowerCase() }; m = s.match(/\b(tests?|test suite|linter|lint|build|typecheck|type check)\s+(?:must|should|need to|has to|have to)\s+(?:pass|succeed|be green)\s+(?:before|prior to)\s+(?:you\s+|every\s+|each\s+|any\s+)?(?:\w+ing\s+)?(commit|push|merg)/i); if (m) return { what: whatOf(m[1]), trigger: /^commit/i.test(m[2]) ? 'commit' : 'push' }; return null; } },
37
59
  { kind: 'run-after', m: (s) => { let m = s.match(/\brun\s+(?:the\s+)?(`[^`]+`|[\w][\w .:/-]*?)\s+after\s+(?:you\s+)?(?:making\s+|every\s+|each\s+|any\s+|all\s+)?(?:change|edit|modif|writ)/i); if (m) return { what: whatOf(m[1]) }; m = s.match(/\b(format|lint|typecheck)\s+(?:the\s+)?(?:code|files?)\s+after\s+(?:you\s+)?(?:making\s+|every\s+|each\s+|any\s+)?(?:change|edit|modif)/i); if (m) return { what: /^format/i.test(m[1]) ? 'formatter' : /^lint/i.test(m[1]) ? 'linter' : m[1] }; return null; } },
38
60
  { kind: 'prefer-tool', m: (s) => { let m = s.match(new RegExp(`\\b(?:never|don't|do not|don\u2019t|avoid)\\s+(?:us(?:e|ing)\\s+)?\`?(${TOOLS_RE})\`?\\b`, 'i')); if (m) { const avoid = m[1].toLowerCase(); return { prefer: PREFER_FOR[avoid] || null, avoid: [avoid] }; } m = s.match(new RegExp(`\\buse\\s+\`?(${TOOLS_RE})\`?\\b(?:[^.]*?\\b(?:not|instead of|rather than|never|over)\\s+\`?(${TOOLS_RE})\`?)?`, 'i')); if (m) { const prefer = m[1].toLowerCase(); const avoid = m[2] ? [m[2].toLowerCase()] : (PM_PAIRS[prefer] || []); return avoid.length ? { prefer, avoid } : null; } return null; } },
@@ -58,10 +80,11 @@ export function parseRules(text, file) {
58
80
  const parts = body.split(/(?<=[.!])\s+(?=[A-Z`])/);
59
81
  for (const part of parts) {
60
82
  const s = part.trim(); if (!s) continue;
61
- let hit = null;
62
- for (const sh of SHAPES) { const p = sh.m(s); if (p) { hit = Object.assign({ kind: sh.kind }, p); break; } }
83
+ let hits = null;
84
+ for (const sh of SHAPES) { const p = sh.m(s); if (p) { hits = [].concat(p).map((x) => Object.assign({ kind: sh.kind }, x)); break; } }
63
85
  const rec = { id: `${file}:${n}${parts.length > 1 ? ':' + (parts.indexOf(part) + 1) : ''}`, file, line: n, text: strip(s) };
64
- if (hit) rules.push(Object.assign(rec, hit)); else unchecked.push(rec);
86
+ if (!hits) unchecked.push(rec);
87
+ else for (const h of hits) rules.push(Object.assign({}, rec, hits.length > 1 ? { id: `${rec.id}#${h.action}` } : null, h));
65
88
  }
66
89
  }
67
90
  return { rules, unchecked };
@@ -215,7 +238,8 @@ export function adhere(opts = {}) {
215
238
  const rate = occ.length ? obeyed / occ.length : null;
216
239
  const verdict = !occ.length ? 'never came up' : rate === 1 ? 'obeyed' : rate >= 0.8 ? 'mostly obeyed' : rate > 0 ? 'often ignored' : 'ignored';
217
240
  const examples = occ.filter((o) => !o.obeyed).slice(0, 3).map((o) => ({ session: o.session, turn: o.turn, prompt: R(o.prompt), detail: R(o.detail) }));
218
- const row = { id: r.id, file: r.file, line: r.line, text: r.text, kind: r.kind, occasions: occ.length, obeyed, broken, rate, verdict, examples, sessionsBroken: new Set(occ.filter((o) => !o.obeyed).map((o) => o.session)).size };
241
+ // One line can hold several ask-before rules ("commit / push / deploy"); the label tells them apart.
242
+ const row = { id: r.id, file: r.file, line: r.line, text: r.text, kind: r.kind, label: r.id.includes('#') ? `${r.kind}:${r.action}` : r.kind, occasions: occ.length, obeyed, broken, rate, verdict, examples, sessionsBroken: new Set(occ.filter((o) => !o.obeyed).map((o) => o.session)).size };
219
243
  for (const k of ['what', 'trigger', 'prefer', 'avoid', 'pattern', 'path', 'action', 'style', 'unlessAsked']) if (r[k] !== undefined) row[k] = r[k];
220
244
  if (NOTES[r.kind]) row.note = NOTES[r.kind];
221
245
  return row;
@@ -239,7 +263,7 @@ export function adhereText(rep) {
239
263
  L.push('');
240
264
  const order = { 'ignored': 0, 'often ignored': 1, 'mostly obeyed': 2, 'obeyed': 3, 'never came up': 4 };
241
265
  for (const r of rep.rules.slice().sort((a, b) => order[a.verdict] - order[b.verdict] || b.occasions - a.occasions)) {
242
- L.push(` ${r.verdict.toUpperCase().padEnd(14)} ${r.kind.padEnd(17)} ${r.occasions ? `${r.obeyed}/${r.occasions} ${pct(r.rate).padStart(4)}` : ' — '} ${r.text} (${r.file}:${r.line})`);
266
+ L.push(` ${r.verdict.toUpperCase().padEnd(14)} ${r.label.padEnd(17)} ${r.occasions ? `${r.obeyed}/${r.occasions} ${pct(r.rate).padStart(4)}` : ' — '} ${r.text} (${r.file}:${r.line})`);
243
267
  for (const e of r.examples) L.push(` ↳ ${e.session.slice(0, 8)} t${e.turn == null ? '?' : e.turn} "${e.prompt}" → ${e.detail}`);
244
268
  }
245
269
  if (rep.unchecked.length) { L.push('', ` Not checkable yet (${rep.unchecked.length}) — no mechanical test for these shapes; they are loaded every session all the same:`); for (const u of rep.unchecked) L.push(` · ${u.text} (${u.file}:${u.line})`); }
@@ -252,9 +276,9 @@ export function adhereMarkdown(rep) {
252
276
  const L = ['# Is my CLAUDE.md doing anything?', '', `Project \`${rep.project}\` · ${rep.generated.slice(0, 10)}${rep.since ? ` · sessions since ${rep.since.slice(0, 10)}` : ''}`, '', `**${headline(rep)}**`, ''];
253
277
  if (s.sessionsWithBreach) L.push(`${s.sessionsWithBreach} of ${s.sessions} sessions broke at least one rule; those sessions cost ${usd(s.breachCost)} in total (where the breaches happened, not what they cost).`, '');
254
278
  L.push('## Rules', '', '| rule | kind | occasions | obeyed | broken | rate | verdict |', '|---|---|---:|---:|---:|---:|---|');
255
- for (const r of rep.rules) L.push(`| ${r.text.replace(/\|/g, '\\|')} <br><sub>${r.file}:${r.line}</sub> | \`${r.kind}\` | ${r.occasions} | ${r.obeyed} | ${r.broken} | ${pct(r.rate)} | ${r.verdict} |`);
279
+ for (const r of rep.rules) L.push(`| ${r.text.replace(/\|/g, '\\|')} <br><sub>${r.file}:${r.line}</sub> | \`${r.label}\` | ${r.occasions} | ${r.obeyed} | ${r.broken} | ${pct(r.rate)} | ${r.verdict} |`);
256
280
  const withEx = rep.rules.filter((r) => r.examples.length);
257
- if (withEx.length) { L.push('', '## Evidence', ''); for (const r of withEx) { L.push(`### ${r.text}`, '', `\`${r.kind}\` · ${r.broken} of ${r.occasions} occasions broken across ${r.sessionsBroken} session${r.sessionsBroken === 1 ? '' : 's'}${r.note ? ` · ${r.note}` : ''}`, ''); for (const e of r.examples) L.push(`- \`${e.session.slice(0, 8)}\` turn ${e.turn == null ? '?' : e.turn} — prompt: "${e.prompt}" — ${e.detail.replace(/\|/g, '\\|')}`); L.push(''); } }
281
+ if (withEx.length) { L.push('', '## Evidence', ''); for (const r of withEx) { L.push(`### ${r.text}`, '', `\`${r.label}\` · ${r.broken} of ${r.occasions} occasions broken across ${r.sessionsBroken} session${r.sessionsBroken === 1 ? '' : 's'}${r.note ? ` · ${r.note}` : ''}`, ''); for (const e of r.examples) L.push(`- \`${e.session.slice(0, 8)}\` turn ${e.turn == null ? '?' : e.turn} — prompt: "${e.prompt}" — ${e.detail.replace(/\|/g, '\\|')}`); L.push(''); } }
258
282
  if (rep.unchecked.length) { L.push('## Not checkable yet', '', 'No mechanical test exists for these shapes; they are loaded into every session all the same.', ''); for (const u of rep.unchecked) L.push(`- ${u.text} <sub>${u.file}:${u.line}</sub>`); L.push(''); }
259
283
  L.push('## How to read this', '', 'Every number comes from tool calls in the transcripts, judged per occasion (a commit, a write, a command). "Asked" means the turn\'s own prompt named the action, an AskUserQuestion ran first, or the previous turn ended with a question — a standing instruction from an earlier turn counts as unasked. "Ran the tests" means a test command ran, not that it passed. A rule that never came up is not an obeyed rule. `glassbox adhere --format json` has every occasion.', '');
260
284
  return L.join('\n');
package/src/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import crypto from 'node:crypto';
7
7
  import { createRequire } from 'node:module';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { readTextFile } from './textfile.mjs';
10
+ import { guardResponse } from './guard.mjs';
10
11
  const require = createRequire(import.meta.url);
11
12
  const core = require('./trace-core.js');
12
13
  // fileURLToPath, not URL.pathname: on Windows the latter gives "/C:/…" which path.resolve turns into "\C:\…".
@@ -248,6 +249,8 @@ export function hookResponse(input, opts = {}) {
248
249
  const failOn = opts.failOn || 'warn';
249
250
  const feedback = !!opts.feedback;
250
251
  if (input && input.hook_event_name === 'SessionStart') return opts.context ? sessionStartResponse(input) : { suppressOutput: true };
252
+ // PreToolUse: null means "say nothing" (normal permission flow), never an approval.
253
+ if (input && input.hook_event_name === 'PreToolUse') return opts.guard ? guardResponse(input) : null;
251
254
  // Second Stop after a feedback block: record what the agent said it would do differently, then let it stop.
252
255
  if (opts.context && input && input.hook_event_name === 'Stop' && input.stop_hook_active) {
253
256
  const file = notesPath(input.cwd);
@@ -289,23 +292,33 @@ export function readStdinJson() {
289
292
  return new Promise((resolve) => { let buf = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (d) => { buf += d; }); process.stdin.on('end', () => { try { resolve(JSON.parse(buf || '{}')); } catch (e) { resolve({}); } }); if (process.stdin.isTTY) resolve({}); });
290
293
  }
291
294
 
292
- const HOOK_RE = /\bglassbox(?:-trace)?\s+hook\b/;
295
+ // A Glassbox hook command in settings.json: `npx -y glassbox-trace hook …`, `glassbox hook …`, or the direct
296
+ // form `node "…/bin/glassbox.mjs" hook …` that --guard installs.
297
+ export const HOOK_RE = /\bglassbox(?:-trace)?(?:\.mjs)?["']?\s+hook\b/;
293
298
  export function settingsPath(home) { return path.join(home || claudeHome(), 'settings.json'); }
294
299
  export function installHook(opts = {}) {
295
300
  const file = settingsPath(opts.home);
296
301
  let settings = {};
297
302
  if (fs.existsSync(file)) { try { settings = JSON.parse(readTextFile(file)); } catch (e) { throw new Error(`${file} is not valid JSON — fix it first (nothing was changed)`); } fs.copyFileSync(file, file + '.glassbox-backup'); }
298
303
  settings.hooks = settings.hooks || {};
299
- const cmd = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.context ? ' --context' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
300
- for (const ev of opts.events || (opts.context ? ['Stop', 'SessionStart'] : ['Stop'])) {
304
+ const cmd = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.context ? ' --context' : ''}${opts.guard ? ' --guard' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
305
+ const events = opts.events || ['Stop', ...(opts.context ? ['SessionStart'] : []), ...(opts.guard ? ['PreToolUse'] : [])];
306
+ for (const ev of events) {
301
307
  const list = Array.isArray(settings.hooks[ev]) ? settings.hooks[ev] : [];
302
308
  const kept = list.filter((entry) => !(entry && Array.isArray(entry.hooks) && entry.hooks.some((h) => h && typeof h.command === 'string' && HOOK_RE.test(h.command))));
303
- kept.push({ hooks: [{ type: 'command', command: cmd, timeout: 60 }] });
309
+ // PreToolUse runs before every tool call, so it gets a short timeout; no matcher means all tools.
310
+ kept.push({ hooks: [{ type: 'command', command: cmd, timeout: ev === 'PreToolUse' ? 10 : 60 }] });
304
311
  settings.hooks[ev] = kept;
305
312
  }
306
313
  fs.mkdirSync(path.dirname(file), { recursive: true });
307
314
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
308
- return { file, command: cmd };
315
+ return { file, command: cmd, events };
316
+ }
317
+ // The guard runs before every tool call, so it can't go through npx (seconds per call). Point the hook at
318
+ // this install's own entry point instead — unless this *is* a throwaway npx cache copy.
319
+ export function guardCommand(root) {
320
+ if (/[\\/]_npx[\\/]/.test(root)) throw new Error('--guard runs before every tool call, and through npx that takes seconds each time. Install Glassbox once (npm i -g glassbox-trace) and run `glassbox hook install --guard` again, or pass --command with a direct path.');
321
+ return `node "${path.join(root, 'bin', 'glassbox.mjs')}"`;
309
322
  }
310
323
  export function uninstallHook(opts = {}) {
311
324
  const file = settingsPath(opts.home);
@@ -325,7 +338,7 @@ export function parseArgs(argv) {
325
338
  const args = { _: [], flags: {} };
326
339
  for (let i = 0; i < argv.length; i++) {
327
340
  const a = argv[i];
328
- if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend', 'claude-md', 'fail-under'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
341
+ if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend', 'claude-md', 'fail-under', 'key'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
329
342
  else args._.push(a);
330
343
  }
331
344
  return args;
@@ -353,10 +366,11 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
353
366
  same task, two sessions: what changed in time, tokens, cost, tools and findings
354
367
  glassbox watch [ID|FILE] [--port N] [--no-open]
355
368
  live tail: serves the viewer on 127.0.0.1 and pushes the transcript as it grows
356
- glassbox hook install [--feedback] [--context] [--fail-on warn]
369
+ glassbox hook install [--feedback] [--context] [--guard] [--fail-on warn]
357
370
  add a Claude Code Stop hook so every session ends with a Glassbox summary;
358
371
  --feedback also hands the findings back to the agent once, so it can learn from them;
359
- --context keeps them in <project>/.glassbox/last-session.md and gives them to the next session
372
+ --context keeps them in <project>/.glassbox/last-session.md and gives them to the next session;
373
+ --guard blocks a tool call that already failed twice in a row, unchanged, and says why
360
374
  glassbox hook uninstall remove it (a .glassbox-backup of settings.json is kept)
361
375
  glassbox collect DIR [--since 30d] [--format text|md|json] [--out FILE] [--top N]
362
376
  fleet view from a folder of check reports: each machine writes one with
@@ -367,7 +381,7 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
367
381
  is my CLAUDE.md doing anything? every rule in the project's instruction files judged
368
382
  against every session of that project: obeyed / broken per occasion, with evidence;
369
383
  shapes it cannot check are listed as such. --fail-under 80 exits 1 below that rate
370
- glassbox fence [ID|FILE|DIR] [--since 30d] [--format text|md|json] [--out FILE] [--fail-on error|warn|info] [--shred]
384
+ glassbox fence [ID|FILE|DIR] [--since 30d] [--format text|md|json] [--out FILE] [--fail-on error|warn|info] [--shred] [--key FILE] [--sessions-only]
371
385
  secrets that reached a transcript: known key formats, secrets named by context,
372
386
  credential-file reads — with a masked preview and a fingerprint, never the value.
373
387
  no target = every session under the home; exit 1 when anything at/above --fail-on was found
@@ -520,13 +534,14 @@ export async function main(argv, io = {}) {
520
534
  }
521
535
  if (cmd === 'hook') {
522
536
  const sub = args._[1];
523
- if (sub === 'install') { const r = installHook({ home, feedback: !!args.flags.feedback, context: !!args.flags.context, failOn: args.flags['fail-on'], command: args.flags.command, events: args.flags.events ? String(args.flags.events).split(',') : undefined }); out(`Installed ${args.flags.context ? 'Stop and SessionStart hooks' : 'Stop hook'} in ${r.file}\n ${r.command}\nEvery session now ends with a Glassbox summary${args.flags.feedback ? ', and findings are handed back to the agent once' : ''}${args.flags.context ? `; findings are kept in <project>/${NOTES_FILE.replace(/\\/g, '/')} and handed to the next session there` : ''}. Restart Claude Code to pick it up.`); return 0; }
537
+ if (sub === 'install') { const r = installHook({ home, feedback: !!args.flags.feedback, context: !!args.flags.context, guard: !!args.flags.guard, failOn: args.flags['fail-on'], command: args.flags.command || (args.flags.guard ? guardCommand(ROOT) : undefined), events: args.flags.events ? String(args.flags.events).split(',') : undefined }); out(`Installed ${r.events.join(', ')} hook${r.events.length > 1 ? 's' : ''} in ${r.file}\n ${r.command}\nEvery session now ends with a Glassbox summary${args.flags.feedback ? ', and findings are handed back to the agent once' : ''}${args.flags.context ? `; findings are kept in <project>/${NOTES_FILE.replace(/\\/g, '/')} and handed to the next session there` : ''}${args.flags.guard ? '; a call that already failed twice in a row, unchanged, is blocked with the reason' : ''}. Restart Claude Code to pick it up.`); return 0; }
524
538
  if (sub === 'uninstall') { const r = uninstallHook({ home }); out(`Removed ${r.removed} Glassbox hook${r.removed === 1 ? '' : 's'} from ${r.file}`); return 0; }
525
539
  // A hook must never fail loudly: exit 2 from a Stop hook blocks Claude with the error as the reason.
526
- let reply;
527
- try { const input = io.stdin !== undefined ? io.stdin : await readStdinJson(); reply = hookResponse(input, { failOn: args.flags['fail-on'], feedback: !!args.flags.feedback, context: !!args.flags.context, rates, stateDir: io.stateDir }); }
528
- catch (e) { reply = { systemMessage: 'Glassbox: ' + e.message, suppressOutput: true }; }
529
- out(JSON.stringify(reply));
540
+ // A null reply prints nothing, which for PreToolUse means "no decision".
541
+ let reply = null, event = null;
542
+ try { const input = io.stdin !== undefined ? io.stdin : await readStdinJson(); event = input && input.hook_event_name; reply = hookResponse(input, { failOn: args.flags['fail-on'], feedback: !!args.flags.feedback, context: !!args.flags.context, guard: !!args.flags.guard, rates, stateDir: io.stateDir }); }
543
+ catch (e) { reply = event === 'PreToolUse' ? null : { systemMessage: 'Glassbox: ' + e.message, suppressOutput: true }; }
544
+ if (reply != null) out(JSON.stringify(reply));
530
545
  return 0;
531
546
  }
532
547
  if (cmd === 'fence') {
@@ -535,7 +550,7 @@ export async function main(argv, io = {}) {
535
550
  const since = args.flags.since ? Date.now() - parseSince(args.flags.since) : null;
536
551
  const failOn = args.flags['fail-on'] || 'error';
537
552
  const fmtOut = outputFormat(args.flags);
538
- const rep = fence({ home, target: args._[1], since, project: args.flags.project, shred: !!args.flags.shred });
553
+ const rep = fence({ home, target: args._[1], since, project: args.flags.project, shred: !!args.flags.shred, keyFile: args.flags.key, sessionsOnly: !!args.flags['sessions-only'] });
539
554
  const failed = failedAt(rep, failOn);
540
555
  const text = fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? fenceMarkdown(rep) : fenceText(rep);
541
556
  if (args.flags.out) { const f = path.resolve(args.flags.out); fs.writeFileSync(f, text); out(`${f} (${rep.scanned.files} files, ${rep.findings.length} findings${rep.shredded ? `, ${rep.shredded.values} values shredded` : ''})`); }
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.9.1';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -1176,5 +1176,5 @@
1176
1176
  }
1177
1177
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1178
1178
 
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 };
1179
+ 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
1180
  });