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 +13 -1
- package/dist/glassbox.html +2 -2
- package/package.json +1 -1
- package/src/adhere.mjs +33 -9
- package/src/cli.mjs +30 -15
- package/src/fence.mjs +101 -24
- package/src/guard.mjs +96 -0
- package/src/trace-core.js +2 -2
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
|
|
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).
|
package/dist/glassbox.html
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
//
|
|
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) => {
|
|
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
|
|
62
|
-
for (const sh of SHAPES) { const p = sh.m(s); if (p) {
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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.9.
|
|
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
|
});
|