glassbox-trace 0.7.1 → 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 +59 -0
- package/dist/glassbox.html +2 -2
- package/package.json +6 -2
- package/src/adhere.mjs +285 -0
- package/src/cli.mjs +62 -13
- package/src/fence.mjs +338 -0
- 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).
|
|
@@ -84,6 +89,54 @@ Read top to bottom: stats → timeline (with minimap, search, fit-to-turn) → c
|
|
|
84
89
|
|
|
85
90
|
**Accessibility:** WCAG 2.2 AA contrast in both themes, full keyboard operation with roving focus on the timeline and chart, screen-reader names on every span and row, live announcements on load and search, focus-managed detail panel, reduced-motion respected. `npm run audit` re-checks all of it with axe-core and fails the build on regressions.
|
|
86
91
|
|
|
92
|
+
### Secrets in transcripts — `glassbox fence`
|
|
93
|
+
|
|
94
|
+
Every session transcript is plain text, and everything the agent read is in it: the `.env` it opened to find a port, the token `git remote -v` printed, the key you pasted. `fence` scans them and tells you what is there — never the value itself:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
glassbox fence every session under ~/.claude — exit 1 if a credential was found
|
|
98
|
+
glassbox fence 81c4cbfd one session (and its subagents)
|
|
99
|
+
glassbox fence ./archive every .jsonl under a folder
|
|
100
|
+
glassbox fence --format md --out fence.md
|
|
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
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
Glassbox fence · 34 sessions under /home/aldo/.claude/projects · 212 MB
|
|
108
|
+
3 error · 1 warn · 2 info · 3 distinct secrets in 2 of 41 files
|
|
109
|
+
|
|
110
|
+
/home/aldo/.claude/projects/-home-aldo-api/7c1e…jsonl
|
|
111
|
+
ERROR anthropic-key line 212 sk-a…oP (62 chars) ×2 — tool result · Read /home/aldo/api/.env
|
|
112
|
+
ERROR github-token line 340 ghp_…r8 (40 chars) — tool input · Bash
|
|
113
|
+
INFO credential-file-read line 211 /home/aldo/api/.env — tool input · Read
|
|
114
|
+
```
|
|
115
|
+
|
|
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).
|
|
117
|
+
|
|
118
|
+
### Is my CLAUDE.md doing anything? — `glassbox adhere`
|
|
119
|
+
|
|
120
|
+
Every rule in the project's instruction files, judged against every session of that project, per occasion, with evidence:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
glassbox adhere the project in the current folder
|
|
124
|
+
glassbox adhere --project ~/code/api --since 30d --format md --out adhere.md
|
|
125
|
+
glassbox adhere --fail-under 80 exit 1 below that rate
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
Glassbox adhere · /home/aldo/api
|
|
130
|
+
11 rules · 9 checkable · 2 not checkable yet · 34 sessions · obeyed 212 of 301 occasions (70%)
|
|
131
|
+
|
|
132
|
+
IGNORED prefer-tool 0/38 0% Use rg rather than grep. (CLAUDE.md:11)
|
|
133
|
+
↳ 535876c9 t3 "Continue from where you left off." → grep -n "^export" src/cli.mjs | sed -n 1,80p
|
|
134
|
+
IGNORED ask-before 0/1 0% Ask before committing. (CLAUDE.md:7)
|
|
135
|
+
OBEYED never-touch 8/8 100% Never edit files in dist/ by hand. (CLAUDE.md:5)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Nine rule shapes are checkable from tool calls — run X before commit/push, run X after changes, use A not B, never run, never touch, ask before, read before edit, commit message format, no new docs — and everything else is listed as *not checkable yet* with its line number, so the report never claims more than it measured. Commands are judged on their shell surface (a `git push --force` inside a document being written is not a force push). The first real run, on the session that built it, scored 22%. Details and the honest caveats in [docs/ADHERE.md](docs/ADHERE.md).
|
|
139
|
+
|
|
87
140
|
## What it flags
|
|
88
141
|
|
|
89
142
|
| rule | fires when |
|
|
@@ -134,6 +187,7 @@ src/trace-core.js pure engine: parseTrace · diagnose · estimateCost · red
|
|
|
134
187
|
src/viewer.html the UI; the build inlines trace-core and the demo
|
|
135
188
|
src/cli.mjs CLI library (session discovery, embed, check, compare, hook); bin/glassbox.mjs is the entry point
|
|
136
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`)
|
|
137
191
|
scripts/build.mjs build
|
|
138
192
|
scripts/sanitize.mjs turn a real transcript into a shareable fixture (demo or structure mode)
|
|
139
193
|
scripts/corpus-audit.mjs run every rule over your local sessions; counts only (--baseline to diff two runs)
|
|
@@ -165,6 +219,11 @@ Your context is private — by construction, not by promise.
|
|
|
165
219
|
- **Nothing leaves.** The viewer makes no network requests at all — fonts, scripts and the demo are inside the one file, and the test suite fails if a build references anything outside it. The CLI sends nothing anywhere. `glassbox watch` binds to `127.0.0.1` only and stops with the command. The one network activity is installation: `npx` fetching the package, or the plugin marketplace fetching this repo.
|
|
166
220
|
- **What it touches.** It reads `~/.claude/projects/**` and writes only what you ask for: `open` writes a self-contained HTML to your temp folder (transcript included — delete it when done, or use `--out`), `hook install` edits `~/.claude/settings.json` with a backup beside it, `--context` writes `<project>/.glassbox/last-session.md`, `--legend` writes the legend. The full inventory — files, processes, hooks, network, uninstall — is in [docs/IT.md](docs/IT.md), written for whoever has to approve it.
|
|
167
221
|
- **Counts, not text.** Transcripts contain everything the agent saw; that's why share mode and `--redact` exist. Redacted output keeps tool names, numbers, timings and cost and drops every string — prompts, commands, results, titles. Where a finding would quote something it says `«240 chars»`.
|
|
222
|
+
- **What is already on disk.** `glassbox fence` finds credentials that reached a transcript (known key formats, secrets named by context, credential-file reads), reports them masked and fingerprinted, and `--shred` overwrites them in place. See above.
|
|
168
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.
|
|
169
224
|
|
|
170
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.
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glassbox-trace",
|
|
3
|
-
"version": "0.
|
|
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 CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
|
|
3
|
+
"version": "0.9.1",
|
|
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": {
|
|
7
7
|
"test": "node --test test/*.test.mjs",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"keywords": [
|
|
31
31
|
"claude-code",
|
|
32
32
|
"claude-code-log",
|
|
33
|
+
"secrets",
|
|
34
|
+
"secret-scanning",
|
|
35
|
+
"claude-md",
|
|
36
|
+
"adherence",
|
|
33
37
|
"compare",
|
|
34
38
|
"live-tail",
|
|
35
39
|
"claude",
|
package/src/adhere.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// glassbox adhere — is my CLAUDE.md doing anything?
|
|
2
|
+
//
|
|
3
|
+
// A CLAUDE.md is a list of instructions loaded into every session; the evidence of whether any of them worked is
|
|
4
|
+
// scattered across thirty transcripts nobody reads. `adhere` turns the file into rules, finds every session whose
|
|
5
|
+
// cwd is the project, and judges each rule on each occasion it applied to. Only nine rule shapes are checkable
|
|
6
|
+
// mechanically (see DESIGN.md §15); everything else is listed as "not checkable yet" so the report never claims
|
|
7
|
+
// more than it measured. Nothing leaves the machine.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { findSessions, loadSessionFiles, analyse, claudeHome } from './cli.mjs';
|
|
12
|
+
import { readTextFile } from './textfile.mjs';
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const core = require('./trace-core.js');
|
|
15
|
+
|
|
16
|
+
// Claude Code files a project's sessions under projects/<path with every separator and colon as '-'>.
|
|
17
|
+
export const encodeProject = (p) => String(p).replace(/[\\/:]/g, '-');
|
|
18
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').toLowerCase();
|
|
19
|
+
const WRITE_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
|
|
20
|
+
const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'NotebookEdit']);
|
|
21
|
+
const READ_TOOLS = new Set(['Read', 'NotebookRead']);
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Rules
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const PM_PAIRS = { pnpm: ['npm', 'yarn'], yarn: ['npm', 'pnpm'], bun: ['npm', 'yarn', 'pnpm'], npm: ['yarn', 'pnpm'], uv: ['pip', 'poetry'], poetry: ['pip'], pip: [], rg: ['grep'], ripgrep: ['grep'], fd: ['find'], grep: [], find: [] };
|
|
27
|
+
const PREFER_FOR = { npm: 'pnpm', yarn: 'pnpm', pnpm: 'npm', bun: 'npm', pip: 'uv', poetry: 'uv', grep: 'rg', find: 'fd' };
|
|
28
|
+
const TOOLS_RE = 'pnpm|npm|yarn|bun|uv|pip|poetry|rg|ripgrep|fd|grep|find';
|
|
29
|
+
const ACTION = (w) => /^commit/i.test(w) ? 'commit' : /^push/i.test(w) ? 'push' : /^(delet|remov)/i.test(w) ? 'delete' : /^install/i.test(w) ? 'install' : /^creat/i.test(w) ? 'create' : /^deploy/i.test(w) ? 'deploy' : /^merg/i.test(w) ? 'merge' : w.toLowerCase();
|
|
30
|
+
const strip = (s) => s.replace(/`/g, '').replace(/\s+/g, ' ').trim();
|
|
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
|
+
|
|
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.
|
|
44
|
+
const SHAPES = [
|
|
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
|
+
} },
|
|
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; } },
|
|
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; } },
|
|
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; } },
|
|
61
|
+
{ kind: 'never-run', m: (s) => { if (/\b(?:never|don't|do not|don’t)\s+(?:force[- ]push|push\s+(?:with\s+)?--force|use\s+--force\s+(?:with|on|when)\s+push)/i.test(s)) return { pattern: 'git push --force' }; let m = s.match(/\b(?:never|don't|do not|don’t)\s+(?:run|execute|use|call|invoke)\s+`([^`]+)`/i); if (m) return { pattern: strip(m[1]) }; m = s.match(/\b(?:never|don't|do not|don’t)\s+(?:run|execute)\s+((?:git|rm|sudo|npm|pnpm|yarn|docker|curl|wget|kubectl|terraform|aws|gcloud|az|make|chmod|chown|dd|mkfs)\b[\w .:/-]*?)(?:\s+(?:anywhere|ever|in\b|on\b|under|unless|without|against)|[.,;]|$)/i); if (m) return { pattern: strip(m[1]) }; return null; } },
|
|
62
|
+
{ kind: 'never-touch', m: (s) => { const m = s.match(/\b(?:never|don't|do not|don’t)\s+(?:ever\s+)?(?:edit|modify|touch|change|alter|write to|delete|remove|rewrite|overwrite)\s+(?:the\s+|any\s+|anything\s+in\s+)?(?:files?\s+(?:in|under|inside|within)\s+)?(?:the\s+)?`?([\w][\w./*\\-]*)`?/i); if (m && !/^(?:it|them|this|that|anything|code|files?)$/i.test(m[1])) return { path: m[1] }; return null; } },
|
|
63
|
+
{ kind: 'read-before-edit', m: (s) => /\b(?:read|understand|look at|review|inspect)\s+(?:the\s+|an?\s+|existing\s+|any\s+)?(?:file|files|code|codebase|existing code|source)\b[^.]*?\b(?:before|prior to)\s+(?:you\s+)?(?:edit|modif|chang|writ|touch)/i.test(s) ? {} : null },
|
|
64
|
+
{ kind: 'commit-format', m: (s) => { if (/\bconventional\s+commits?\b/i.test(s)) return { style: 'conventional' }; if (/\bcommit\s+messages?\s+(?:must|should|need to|have to|has to|always)\s+(?:start with|be prefixed|begin with|follow|use|include|reference)/i.test(s)) return { style: /ticket|issue|jira|[A-Z]{2,}-\d+/.test(s) ? 'ticket' : 'conventional' }; return null; } },
|
|
65
|
+
{ kind: 'no-new-docs', m: (s) => /\b(?:never|don't|do not|don’t)\s+(?:create|add|write|generate|make)\s+(?:new\s+|any\s+|extra\s+)?(?:markdown|\.md\b|md\b|readme|documentation|docs?\b)/i.test(s) ? {} : null },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// CLAUDE.md → rules + not-checkable lines. Headings, blank lines and code fences are skipped; list markers and
|
|
69
|
+
// bold are stripped; multi-sentence lines are split; rule text is kept verbatim (minus backticks).
|
|
70
|
+
export function parseRules(text, file) {
|
|
71
|
+
const rules = [], unchecked = [];
|
|
72
|
+
let fence = false, n = 0;
|
|
73
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
74
|
+
n++;
|
|
75
|
+
const line = raw.trim();
|
|
76
|
+
if (/^(```|~~~)/.test(line)) { fence = !fence; continue; }
|
|
77
|
+
if (fence || !line || /^#/.test(line) || /^[-*_]{3,}$/.test(line) || /^<!--/.test(line)) continue;
|
|
78
|
+
const body = line.replace(/^(?:[-*+]|\d+[.)])\s+/, '').replace(/^\[[ x]\]\s+/i, '').replace(/\*\*|__/g, '').trim();
|
|
79
|
+
if (!body) continue;
|
|
80
|
+
const parts = body.split(/(?<=[.!])\s+(?=[A-Z`])/);
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
const s = part.trim(); if (!s) continue;
|
|
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; } }
|
|
85
|
+
const rec = { id: `${file}:${n}${parts.length > 1 ? ':' + (parts.indexOf(part) + 1) : ''}`, file, line: n, text: strip(s) };
|
|
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));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { rules, unchecked };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function findInstructionFiles(project, home) {
|
|
94
|
+
const out = [];
|
|
95
|
+
const add = (p, label) => { if (fs.existsSync(p) && fs.statSync(p).isFile()) out.push({ file: p, label }); };
|
|
96
|
+
add(path.join(project, 'CLAUDE.md'), 'CLAUDE.md');
|
|
97
|
+
add(path.join(project, '.claude', 'CLAUDE.md'), '.claude/CLAUDE.md');
|
|
98
|
+
add(path.join(project, 'CLAUDE.local.md'), 'CLAUDE.local.md');
|
|
99
|
+
add(path.join(home || claudeHome(), 'CLAUDE.md'), '~/.claude/CLAUDE.md (global)');
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Judging
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
const TEST_RE = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|\bnpx\s+(?:vitest|jest|mocha|ava)\b|\bpytest\b|\bpython3?\s+-m\s+(?:pytest|unittest)\b|\bcargo\s+test\b|\bgo\s+test\b|\bnode\s+--test\b|\bvitest\b|\bjest\b|\bmocha\b|\bphpunit\b|\brspec\b|\bmvn\s+(?:test|verify)\b|\bgradle(?:w)?\s+test\b|\bdotnet\s+test\b|\bmake\s+(?:test|check)\b|\bctest\b/i;
|
|
107
|
+
const FORMAT_RE = /\bprettier\b|\bcargo\s+fmt\b|\bgofmt\b|\bgo\s+fmt\b|\brustfmt\b|\bblack\b|\bruff\s+format\b|\bbiome\s+format\b|\bdprint\b|\bclang-format\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:format|fmt|prettier)\b/i;
|
|
108
|
+
const LINT_RE = /\beslint\b|\bruff(?:\s+check)?\b|\bflake8\b|\bpylint\b|\bcargo\s+clippy\b|\bgolangci-lint\b|\bbiome\s+(?:lint|check)\b|\bstylelint\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?lint\b/i;
|
|
109
|
+
const pmNorm = (s) => String(s).replace(/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?/g, 'run ').replace(/\brun\s+run\s+/g, 'run ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
110
|
+
function commandMatches(cmd, what) {
|
|
111
|
+
const c = surface(cmd);
|
|
112
|
+
if (what === 'tests') return TEST_RE.test(c);
|
|
113
|
+
if (what === 'formatter') return FORMAT_RE.test(c);
|
|
114
|
+
if (what === 'linter') return LINT_RE.test(c);
|
|
115
|
+
return pmNorm(c).includes(pmNorm(what));
|
|
116
|
+
}
|
|
117
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
118
|
+
function forbiddenRe(pattern) {
|
|
119
|
+
if (/^git push --force$/i.test(pattern)) return new RegExp(GIT + 'push\\b.*\\s(?:--force(?:-with-lease)?|-f)\\b', 'i');
|
|
120
|
+
return new RegExp('^' + escapeRe(pattern).replace(/\\\s+|\s+/g, '\\s+') + '(?=\\s|$)', 'i');
|
|
121
|
+
}
|
|
122
|
+
// A command's shell surface: heredoc bodies and inline programs (node -e "…", python -c "…") are content, not
|
|
123
|
+
// commands — a `git push --force` inside a document being written is not a force push.
|
|
124
|
+
export function surface(cmd) {
|
|
125
|
+
return String(cmd)
|
|
126
|
+
.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\s|$|\))/g, '<<HEREDOC')
|
|
127
|
+
.replace(/\b(?:node|python3?|ruby|perl|php|deno)\s+(?:-[a-zA-Z]+\s+)*(?:-e|-c|--eval|eval)\s+("(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*')/g, (m, q) => m.slice(0, m.length - q.length) + '"…"');
|
|
128
|
+
}
|
|
129
|
+
// Shell segments (split on && || | ; and newlines), each with env assignments, sudo, time and exec looked through.
|
|
130
|
+
export function segments(cmd) {
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const seg of surface(cmd).split(/\s*(?:&&|\|\||\||;|\n)\s*/)) {
|
|
133
|
+
const toks = seg.trim().replace(/^[(\s]+/, '').split(/\s+/).filter(Boolean);
|
|
134
|
+
let i = 0; while (i < toks.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]) || /^(?:sudo|time|exec|nice|nohup|env)$/.test(toks[i]))) i++;
|
|
135
|
+
if (i < toks.length) out.push(toks.slice(i).join(' '));
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
// The tool a segment starts with: `python -m pip` is pip, `pip3` is pip, a path prefix is dropped.
|
|
140
|
+
function segmentTools(cmd) {
|
|
141
|
+
return segments(cmd).map((seg) => { const toks = seg.split(/\s+/); let t = toks[0].replace(/^.*[\\/]/, '').toLowerCase(); if (/^python3?$/.test(t) && toks[1] === '-m' && toks[2]) t = toks[2].toLowerCase(); if (t === 'pip3') t = 'pip'; return t; });
|
|
142
|
+
}
|
|
143
|
+
const hasSeg = (cmd, re) => segments(cmd).some((seg) => re.test(seg));
|
|
144
|
+
function commitMessage(cmd) {
|
|
145
|
+
cmd = String(cmd);
|
|
146
|
+
const hd = cmd.match(/<<-?\s*['"]?(\w+)['"]?\n([\s\S]*?)\n\1/); if (hd) return hd[2].split('\n')[0].trim();
|
|
147
|
+
const q = cmd.match(/(?:^|\s)(?:-m|--message)(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|(\S+))/); if (q) return (q[1] != null ? q[1] : q[2] != null ? q[2] : q[3]).split('\n')[0].trim();
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const CONVENTIONAL = /^(?:feat|fix|chore|docs|refactor|test|perf|ci|build|style|revert)(?:\([^)]*\))?!?: \S/;
|
|
151
|
+
const TICKET = /^\[?[A-Z][A-Z0-9]+-\d+\]?[:\s-]/;
|
|
152
|
+
const pathHits = (rulePath, filePath, project) => {
|
|
153
|
+
let rel = norm(filePath); const pj = norm(project).replace(/\/$/, '');
|
|
154
|
+
if (pj && rel.startsWith(pj + '/')) rel = rel.slice(pj.length + 1);
|
|
155
|
+
const rp = norm(rulePath).replace(/^\.\//, '');
|
|
156
|
+
if (rp.includes('*')) return new RegExp('(?:^|/)' + escapeRe(rp).replace(/\\\*\\\*/g, '.*').replace(/\\\*/g, '[^/]*') + '$').test(rel);
|
|
157
|
+
if (rp.endsWith('/')) return rel.startsWith(rp) || rel.includes('/' + rp);
|
|
158
|
+
return rel === rp || rel.endsWith('/' + rp) || rel.startsWith(rp + '/') || rel.includes('/' + rp + '/');
|
|
159
|
+
};
|
|
160
|
+
// `git -c k=v -C dir --no-pager commit …` is still a commit.
|
|
161
|
+
const OPTV = String.raw`(?:"[^"]*"|'[^']*'|\S)+`;
|
|
162
|
+
const GIT = String.raw`^git\s+(?:(?:-c\s+${OPTV}|-C\s+${OPTV}|--no-pager|--git-dir=${OPTV}|--work-tree=${OPTV})\s+)*`;
|
|
163
|
+
const git = (sub) => new RegExp(GIT + sub + '\\b');
|
|
164
|
+
const actionRe = { commit: git('commit'), push: git('push'), delete: new RegExp('^(?:rm|rmdir|del|Remove-Item)\\b|' + GIT + 'rm\\b'), install: /^(?:npm|pnpm|yarn|bun|pip3?|uv|poetry|cargo|gem|brew|apt(?:-get)?|choco|winget)\s+(?:install|add|i)\b/, deploy: /^(?:vercel|netlify|fly\s+deploy|wrangler\s+(?:publish|deploy)|gcloud\s+run\s+deploy|serverless\s+deploy|eb\s+deploy)\b|^\S+\s+deploy\b/i, merge: git('merge') };
|
|
165
|
+
const askedRe = { commit: /\bcommit/i, push: /\bpush/i, delete: /\b(?:delete|remove|rm\b|clean up|get rid)/i, install: /\binstall|\badd (?:the )?(?:package|dependency|dep)/i, create: /\b(?:create|make|add|write|new)\b/i, deploy: /\bdeploy|ship|release/i, merge: /\bmerge/i };
|
|
166
|
+
|
|
167
|
+
// Judge every rule against one session. Returns per-rule { occasions:[{obeyed, session, turn, prompt, detail, at}] }.
|
|
168
|
+
function judgeSession(rules, s, project) {
|
|
169
|
+
const { trace, cost } = analyse(loadSessionFiles(s));
|
|
170
|
+
const calls = trace.toolCalls.filter((c) => !c.unmatched).slice().sort((a, b) => a.start - b.start);
|
|
171
|
+
const turns = trace.turns;
|
|
172
|
+
const turnPrompt = (i) => { const t = turns.find((x) => x.index === i && x.agent === 'main') || turns.find((x) => x.index === i); return t ? { text: t.promptText || '', kind: t.promptKind } : { text: '', kind: null }; };
|
|
173
|
+
const prevTurnAsked = (i) => { const prev = trace.requests.filter((r) => r.turnIndex === i - 1 && r.agent === 'main'); if (!prev.length) return false; const last = prev[prev.length - 1]; const texts = (last.blocks || []).filter((b) => b.type === 'text').map((b) => b.text).join(' ').trim(); return /\?\s*$/.test(texts); };
|
|
174
|
+
const short = (t) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
175
|
+
const occ = (c, obeyed, detail) => ({ obeyed, session: s.id, turn: c.turnIndex, prompt: turnPrompt(c.turnIndex).text.replace(/\s+/g, ' ').slice(0, 80), detail: short(detail), at: c.start });
|
|
176
|
+
const sessionOcc = (obeyed, detail, c) => ({ obeyed, session: s.id, turn: c ? c.turnIndex : null, prompt: c ? turnPrompt(c.turnIndex).text.replace(/\s+/g, ' ').slice(0, 80) : '', detail: short(detail), at: c ? c.start : trace.meta.start });
|
|
177
|
+
const bash = calls.filter((c) => c.name === 'Bash' && typeof c.input?.command === 'string');
|
|
178
|
+
const writes = calls.filter((c) => WRITE_TOOLS.has(c.name) && c.input && typeof c.input.file_path === 'string');
|
|
179
|
+
const out = rules.map(() => []);
|
|
180
|
+
rules.forEach((r, i) => {
|
|
181
|
+
const O = out[i];
|
|
182
|
+
if (r.kind === 'run-before') {
|
|
183
|
+
const trig = actionRe[r.trigger];
|
|
184
|
+
let lastAt = -Infinity;
|
|
185
|
+
for (const c of bash) { if (!hasSeg(c.input.command, trig)) continue; const ran = bash.some((b) => b.start > lastAt && b.start < c.start && commandMatches(b.input.command, r.what)); O.push(occ(c, ran, c.input.command)); lastAt = c.start; }
|
|
186
|
+
} else if (r.kind === 'run-after') {
|
|
187
|
+
if (writes.length) { const last = writes[writes.length - 1]; const ran = bash.some((b) => b.start > last.start && commandMatches(b.input.command, r.what)); O.push(sessionOcc(ran, `last write ${last.input.file_path}${ran ? '' : ' — no ' + r.what + ' after it'}`, last)); }
|
|
188
|
+
} else if (r.kind === 'prefer-tool') {
|
|
189
|
+
for (const c of bash) { const tools = segmentTools(c.input.command); const used = tools.filter((t) => t === r.prefer || r.avoid.includes(t)); if (!used.length) continue; const bad = used.some((t) => r.avoid.includes(t)); O.push(occ(c, !bad, c.input.command)); }
|
|
190
|
+
} else if (r.kind === 'never-run') {
|
|
191
|
+
const re = forbiddenRe(r.pattern); const hits = bash.filter((c) => hasSeg(c.input.command, re));
|
|
192
|
+
if (hits.length) for (const c of hits) O.push(occ(c, false, c.input.command)); else O.push(sessionOcc(true, 'no match in ' + bash.length + ' commands', null));
|
|
193
|
+
} else if (r.kind === 'never-touch') {
|
|
194
|
+
for (const c of writes) O.push(occ(c, !pathHits(r.path, c.input.file_path, project), c.input.file_path));
|
|
195
|
+
} else if (r.kind === 'ask-before') {
|
|
196
|
+
const targets = r.action === 'create' ? calls.filter((c) => c.name === 'Write') : bash.filter((c) => actionRe[r.action] && hasSeg(c.input.command, actionRe[r.action]));
|
|
197
|
+
for (const c of targets) {
|
|
198
|
+
const p = turnPrompt(c.turnIndex);
|
|
199
|
+
const inPrompt = p.kind === 'human' && askedRe[r.action].test(p.text);
|
|
200
|
+
const askedTool = calls.some((k) => k.name === 'AskUserQuestion' && k.turnIndex === c.turnIndex && k.start < c.start);
|
|
201
|
+
const replied = p.kind === 'human' && prevTurnAsked(c.turnIndex);
|
|
202
|
+
O.push(occ(c, inPrompt || askedTool || replied, r.action === 'create' ? c.input.file_path : c.input.command));
|
|
203
|
+
}
|
|
204
|
+
} else if (r.kind === 'read-before-edit') {
|
|
205
|
+
for (const c of calls.filter((k) => EDIT_TOOLS.has(k.name) && k.input && typeof k.input.file_path === 'string')) { const p = norm(c.input.file_path); const read = calls.some((k) => (READ_TOOLS.has(k.name) || k.name === 'Write') && k.agent === c.agent && k.start < c.start && k.input && norm(k.input.file_path || k.input.notebook_path) === p); O.push(occ(c, read, c.input.file_path)); }
|
|
206
|
+
} else if (r.kind === 'commit-format') {
|
|
207
|
+
for (const c of bash) { if (!hasSeg(c.input.command, actionRe.commit)) continue; const msg = commitMessage(c.input.command); if (msg == null) continue; const ok = r.style === 'ticket' ? TICKET.test(msg) : CONVENTIONAL.test(msg); O.push(occ(c, ok, msg)); }
|
|
208
|
+
} else if (r.kind === 'no-new-docs') {
|
|
209
|
+
const hits = calls.filter((c) => c.name === 'Write' && c.input && /\.(?:md|mdx|markdown|rst|txt)$/i.test(String(c.input.file_path || '')));
|
|
210
|
+
const asked = (c) => { const p = turnPrompt(c.turnIndex); return p.kind === 'human' && /\b(?:doc|docs|documentation|readme|markdown|\.md\b|notes?|write[- ]?up|report|pdf|release notes|changelog|guide)\b/i.test(p.text); };
|
|
211
|
+
if (hits.length) for (const c of hits) O.push(occ(c, r.unlessAsked && asked(c), c.input.file_path)); else O.push(sessionOcc(true, 'no new docs in ' + writes.length + ' writes', null));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
return { perRule: out, session: { id: s.id, start: trace.meta.start ? new Date(trace.meta.start).toISOString() : null, cost: cost && cost.total != null ? cost.total : null, title: trace.meta.title || null } };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// The report
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
const NOTES = { 'ask-before': 'The least certain of the nine checks: "asked" means the turn\'s own prompt names the action, an AskUserQuestion ran earlier in the turn, or the previous turn ended with a question. A standing instruction from an earlier turn counts as unasked.', 'run-before': 'Counts that the command ran, not that it passed.', 'read-before-edit': 'A read by another agent does not count — the editing agent must have seen the file.' };
|
|
221
|
+
export function adhere(opts = {}) {
|
|
222
|
+
const home = opts.home || claudeHome();
|
|
223
|
+
const project = path.resolve(opts.project || process.cwd());
|
|
224
|
+
const files = opts.claudeMd ? [{ file: path.resolve(opts.claudeMd), label: path.basename(opts.claudeMd) }] : findInstructionFiles(project, home);
|
|
225
|
+
if (!files.length) throw new Error(`adhere: no CLAUDE.md found for ${project}\n Looked for CLAUDE.md, .claude/CLAUDE.md, CLAUDE.local.md there and ~/.claude/CLAUDE.md.\n Point at one with --claude-md FILE, or at the project with --project DIR.`);
|
|
226
|
+
const rules = [], unchecked = [], fileRows = [];
|
|
227
|
+
for (const f of files) { if (!fs.existsSync(f.file)) throw new Error(`adhere: ${f.file} not found`); const r = parseRules(readTextFile(f.file), f.label); rules.push(...r.rules); unchecked.push(...r.unchecked); fileRows.push({ file: f.file, label: f.label, rules: r.rules.length, unchecked: r.unchecked.length }); }
|
|
228
|
+
// Sessions of this project: the folder name Claude Code would use; else a decoded-path substring match.
|
|
229
|
+
const enc = encodeProject(project).toLowerCase();
|
|
230
|
+
let list = findSessions({ home }).filter((s) => path.basename(s.projectDir).toLowerCase() === enc);
|
|
231
|
+
if (!list.length) list = findSessions({ home, project: project.replace(/\\/g, '/') });
|
|
232
|
+
if (opts.since != null) list = list.filter((s) => s.mtime >= opts.since);
|
|
233
|
+
const perRule = rules.map(() => []); const sessions = [];
|
|
234
|
+
for (const s of list) { const j = judgeSession(rules, s, project); j.perRule.forEach((o, i) => perRule[i].push(...o)); sessions.push(j.session); }
|
|
235
|
+
const R = (t) => opts.redact ? `«${String(t).length} chars»` : t;
|
|
236
|
+
const rows = rules.map((r, i) => {
|
|
237
|
+
const occ = perRule[i]; const obeyed = occ.filter((o) => o.obeyed).length; const broken = occ.length - obeyed;
|
|
238
|
+
const rate = occ.length ? obeyed / occ.length : null;
|
|
239
|
+
const verdict = !occ.length ? 'never came up' : rate === 1 ? 'obeyed' : rate >= 0.8 ? 'mostly obeyed' : rate > 0 ? 'often ignored' : 'ignored';
|
|
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) }));
|
|
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 };
|
|
243
|
+
for (const k of ['what', 'trigger', 'prefer', 'avoid', 'pattern', 'path', 'action', 'style', 'unlessAsked']) if (r[k] !== undefined) row[k] = r[k];
|
|
244
|
+
if (NOTES[r.kind]) row.note = NOTES[r.kind];
|
|
245
|
+
return row;
|
|
246
|
+
});
|
|
247
|
+
const occasions = rows.reduce((n, r) => n + r.occasions, 0), obeyed = rows.reduce((n, r) => n + r.obeyed, 0);
|
|
248
|
+
const brokenSessions = new Set(); rules.forEach((r, i) => perRule[i].filter((o) => !o.obeyed).forEach((o) => brokenSessions.add(o.session)));
|
|
249
|
+
const breachCost = sessions.filter((s) => brokenSessions.has(s.id)).reduce((n, s) => n + (s.cost || 0), 0);
|
|
250
|
+
return { glassbox: core.VERSION, kind: 'adhere', schema: 1, generated: new Date().toISOString(), project, home, since: opts.since ? new Date(opts.since).toISOString() : undefined, redacted: !!opts.redact,
|
|
251
|
+
files: fileRows, sessions: sessions.map((s) => Object.assign({}, s, { title: R(s.title || ''), breached: brokenSessions.has(s.id) })),
|
|
252
|
+
rules: rows, unchecked,
|
|
253
|
+
summary: { rules: rules.length, checkable: rules.length, unchecked: unchecked.length, sessions: sessions.length, occasions, obeyed, broken: occasions - obeyed, rate: occasions ? obeyed / occasions : null, neverCameUp: rows.filter((r) => !r.occasions).length, sessionsWithBreach: brokenSessions.size, breachCost } };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const pct = (r) => r == null ? '—' : Math.round(r * 100) + '%';
|
|
257
|
+
const usd = (n) => n == null ? '—' : '$' + n.toFixed(2);
|
|
258
|
+
function headline(rep) { const s = rep.summary; return `${s.rules} rule${s.rules === 1 ? '' : 's'} · ${s.checkable} checkable · ${s.unchecked} not checkable yet · ${s.sessions} session${s.sessions === 1 ? '' : 's'} · obeyed ${s.obeyed} of ${s.occasions} occasions${s.rate == null ? '' : ` (${pct(s.rate)})`}`; }
|
|
259
|
+
|
|
260
|
+
export function adhereText(rep) {
|
|
261
|
+
const L = [`Glassbox adhere · ${rep.project}${rep.since ? ` · since ${rep.since.slice(0, 10)}` : ''}`, ` ${headline(rep)}`];
|
|
262
|
+
if (rep.summary.sessionsWithBreach) L.push(` ${rep.summary.sessionsWithBreach} session${rep.summary.sessionsWithBreach === 1 ? '' : 's'} broke at least one rule (${usd(rep.summary.breachCost)} of spend in those sessions — not the cost of the breach, just where it happened)`);
|
|
263
|
+
L.push('');
|
|
264
|
+
const order = { 'ignored': 0, 'often ignored': 1, 'mostly obeyed': 2, 'obeyed': 3, 'never came up': 4 };
|
|
265
|
+
for (const r of rep.rules.slice().sort((a, b) => order[a.verdict] - order[b.verdict] || b.occasions - a.occasions)) {
|
|
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})`);
|
|
267
|
+
for (const e of r.examples) L.push(` ↳ ${e.session.slice(0, 8)} t${e.turn == null ? '?' : e.turn} "${e.prompt}" → ${e.detail}`);
|
|
268
|
+
}
|
|
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})`); }
|
|
270
|
+
L.push('', ' Rules are judged per occasion from tool calls only; "asked" and "ran the tests" are inferred, not known. Read the examples before believing a number.');
|
|
271
|
+
return L.join('\n') + '\n';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function adhereMarkdown(rep) {
|
|
275
|
+
const s = rep.summary;
|
|
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)}**`, ''];
|
|
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).`, '');
|
|
278
|
+
L.push('## Rules', '', '| rule | kind | occasions | obeyed | broken | rate | 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} |`);
|
|
280
|
+
const withEx = rep.rules.filter((r) => r.examples.length);
|
|
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(''); } }
|
|
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(''); }
|
|
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.', '');
|
|
284
|
+
return L.join('\n');
|
|
285
|
+
}
|
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'].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,16 +366,26 @@ 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
|
|
363
377
|
check --all --redact --legend audit.legend.json --format json > <share>/<name>.json
|
|
364
378
|
(unredacted reports are skipped unless --allow-unredacted)
|
|
365
379
|
glassbox clean delete what open and the hook left in the temp folder (viewer files, hook state)
|
|
380
|
+
glassbox adhere [--project DIR] [--claude-md FILE] [--since 30d] [--format text|md|json] [--out FILE] [--redact] [--fail-under N]
|
|
381
|
+
is my CLAUDE.md doing anything? every rule in the project's instruction files judged
|
|
382
|
+
against every session of that project: obeyed / broken per occasion, with evidence;
|
|
383
|
+
shapes it cannot check are listed as such. --fail-under 80 exits 1 below that rate
|
|
384
|
+
glassbox fence [ID|FILE|DIR] [--since 30d] [--format text|md|json] [--out FILE] [--fail-on error|warn|info] [--shred] [--key FILE] [--sessions-only]
|
|
385
|
+
secrets that reached a transcript: known key formats, secrets named by context,
|
|
386
|
+
credential-file reads — with a masked preview and a fingerprint, never the value.
|
|
387
|
+
no target = every session under the home; exit 1 when anything at/above --fail-on was found
|
|
388
|
+
--shred overwrites each value in place with [FENCED:<rule>:<fingerprint>] (no backup)
|
|
366
389
|
glassbox hook (what Claude Code runs: reads the hook JSON on stdin, replies on stdout)
|
|
367
390
|
|
|
368
391
|
Options --home DIR use DIR instead of ~/.claude (or set GLASSBOX_HOME)
|
|
@@ -511,15 +534,41 @@ export async function main(argv, io = {}) {
|
|
|
511
534
|
}
|
|
512
535
|
if (cmd === 'hook') {
|
|
513
536
|
const sub = args._[1];
|
|
514
|
-
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; }
|
|
515
538
|
if (sub === 'uninstall') { const r = uninstallHook({ home }); out(`Removed ${r.removed} Glassbox hook${r.removed === 1 ? '' : 's'} from ${r.file}`); return 0; }
|
|
516
539
|
// A hook must never fail loudly: exit 2 from a Stop hook blocks Claude with the error as the reason.
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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));
|
|
521
545
|
return 0;
|
|
522
546
|
}
|
|
547
|
+
if (cmd === 'fence') {
|
|
548
|
+
// Secrets that reached a transcript. No target = every session under the home; an id, a file, or a folder of .jsonl.
|
|
549
|
+
const { fence, fenceText, fenceMarkdown, failedAt } = await import('./fence.mjs');
|
|
550
|
+
const since = args.flags.since ? Date.now() - parseSince(args.flags.since) : null;
|
|
551
|
+
const failOn = args.flags['fail-on'] || 'error';
|
|
552
|
+
const fmtOut = outputFormat(args.flags);
|
|
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'] });
|
|
554
|
+
const failed = failedAt(rep, failOn);
|
|
555
|
+
const text = fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? fenceMarkdown(rep) : fenceText(rep);
|
|
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` : ''})`); }
|
|
557
|
+
else out(text.replace(/\n$/, ''));
|
|
558
|
+
return failed ? 1 : 0;
|
|
559
|
+
}
|
|
560
|
+
if (cmd === 'adhere') {
|
|
561
|
+
// Is my CLAUDE.md doing anything? Rules from the instruction files, occasions from the project's transcripts.
|
|
562
|
+
const { adhere, adhereText, adhereMarkdown } = await import('./adhere.mjs');
|
|
563
|
+
const since = args.flags.since ? Date.now() - parseSince(args.flags.since) : null;
|
|
564
|
+
const fmtOut = outputFormat(args.flags);
|
|
565
|
+
let failUnder = null; if (args.flags['fail-under'] !== undefined) { failUnder = Number(args.flags['fail-under']); if (!Number.isFinite(failUnder) || failUnder < 0 || failUnder > 100) throw new Error(`--fail-under must be a percentage 0–100 (got "${args.flags['fail-under']}")`); }
|
|
566
|
+
const rep = adhere({ home, project: args.flags.project, claudeMd: args.flags['claude-md'], since, redact: !!args.flags.redact });
|
|
567
|
+
const text = fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? adhereMarkdown(rep) : adhereText(rep);
|
|
568
|
+
if (args.flags.out) { const f = path.resolve(args.flags.out); fs.writeFileSync(f, text); out(`${f} (${rep.summary.rules} rules, ${rep.summary.sessions} sessions, ${rep.summary.rate == null ? 'no occasions' : Math.round(rep.summary.rate * 100) + '% obeyed'})`); }
|
|
569
|
+
else out(text.replace(/\n$/, ''));
|
|
570
|
+
return failUnder != null && rep.summary.rate != null && rep.summary.rate * 100 < failUnder ? 1 : 0;
|
|
571
|
+
}
|
|
523
572
|
err('Unknown command: ' + cmd + '\n'); out(HELP); return 2;
|
|
524
573
|
} catch (e) { err('glassbox: ' + e.message); return 2; }
|
|
525
574
|
}
|
package/src/fence.mjs
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// glassbox fence — secrets that reached a transcript: find them, fingerprint them, shred them.
|
|
2
|
+
//
|
|
3
|
+
// Every Claude Code session is a plain-text JSONL under ~/.claude/projects, and everything the agent read is in
|
|
4
|
+
// it: the .env it opened to find a port, the token `git remote -v` printed, the key the user pasted. Those files
|
|
5
|
+
// are never deleted, they sync wherever the home folder syncs, and they get attached to bug reports. `fence`
|
|
6
|
+
// scans them (a session, a file, a folder, or the whole home), reports each hit with a masked preview and a
|
|
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.
|
|
12
|
+
// No network: nothing is verified against a provider, and the report is meant to stay on the machine.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { findSessions, subagentFiles, resolveTarget, claudeHome } from './cli.mjs';
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const core = require('./trace-core.js');
|
|
20
|
+
|
|
21
|
+
const SEV = { error: 0, warn: 1, info: 2 };
|
|
22
|
+
const PLACEHOLDER = /your[_-]?|example|sample|placeholder|changeme|change[_-]me|xxxx|dummy|redacted|fenced|<|>|\$\{|\*\*\*/i;
|
|
23
|
+
const CRED_FILE = /(^|[\\/])(?:\.env(?:\.[\w.-]+)?|\.npmrc|\.netrc|\.pgpass|\.git-credentials|credentials(?:\.json)?|id_rsa|id_ed25519|id_ecdsa|id_dsa|[\w.-]*_key|[\w.-]*\.(?:pem|key|p12|pfx|ppk)|secrets?\.(?:json|ya?ml|toml|env|txt)|service[_-]?account[\w.-]*\.json)$/i;
|
|
24
|
+
const CRED_FILE_NOT = /\.env\.(?:example|sample|template|dist|schema)$|\.pub$/i;
|
|
25
|
+
export const isCredentialFile = (p) => !!p && CRED_FILE.test(String(p).trim()) && !CRED_FILE_NOT.test(String(p).trim());
|
|
26
|
+
const DUMP_CMD = /(?:^|[;&|]\s*)(?:cat|type|less|more|head|tail|Get-Content|gc)\s+(?:-\S+\s+)*(\S+)|(?:^|[;&|]\s*)(?:printenv|env)\s*(?:$|[;&|])|echo\s+\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\w*\}?/im;
|
|
27
|
+
|
|
28
|
+
// Shannon entropy in bits per character.
|
|
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; }
|
|
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);
|
|
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)`; }
|
|
61
|
+
|
|
62
|
+
// Detectors, most specific first: a hit whose span overlaps an earlier hit is dropped, so a GitHub token after
|
|
63
|
+
// "Bearer" is one github-token, not also a generic-secret, and a database URL is not also a basic-auth URL.
|
|
64
|
+
// `re` must have the g flag; `value(m)` picks the secret out of the match (default: the whole match).
|
|
65
|
+
// In raw JSONL a newline inside a string is the two characters \n, so a key at the start of a line sits right after
|
|
66
|
+
// an "n"; B is the word boundary that also accepts an escaped \n, \r or \t before the match.
|
|
67
|
+
const B = String.raw`(?:(?<![A-Za-z0-9_])|(?<=\\[nrt]))`;
|
|
68
|
+
const R = (src, flags = 'g') => new RegExp(B + src, flags);
|
|
69
|
+
export const DETECTORS = [
|
|
70
|
+
{ id: 'private-key', severity: 'error', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g, what: 'a private key' },
|
|
71
|
+
{ id: 'aws-access-key', severity: 'error', re: R(String.raw`(?:AKIA|ASIA)[0-9A-Z]{16}\b`), what: 'an AWS access key id' },
|
|
72
|
+
{ id: 'github-token', severity: 'error', re: R(String.raw`(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b`), what: 'a GitHub token' },
|
|
73
|
+
{ id: 'anthropic-key', severity: 'error', re: R(String.raw`sk-ant-[A-Za-z0-9_-]{20,}`), what: 'an Anthropic API key' },
|
|
74
|
+
{ id: 'openai-key', severity: 'error', re: R(String.raw`sk-(?!ant-)(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}`), what: 'an OpenAI API key' },
|
|
75
|
+
{ id: 'slack-token', severity: 'error', re: R(String.raw`xox[abprs]-[A-Za-z0-9-]{10,}`), what: 'a Slack token' },
|
|
76
|
+
{ id: 'stripe-key', severity: 'error', re: R(String.raw`[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b`), what: 'a Stripe key' },
|
|
77
|
+
{ id: 'google-api-key', severity: 'error', re: R(String.raw`AIza[0-9A-Za-z_-]{35}\b`), what: 'a Google API key' },
|
|
78
|
+
{ id: 'npm-token', severity: 'error', re: R(String.raw`npm_[A-Za-z0-9]{36}\b`), what: 'an npm token' },
|
|
79
|
+
{ id: 'sendgrid-key', severity: 'error', re: R(String.raw`SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b`), what: 'a SendGrid key' },
|
|
80
|
+
{ id: 'db-url-password', severity: 'error', re: R(String.raw`(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|rediss|amqp|amqps|mssql|clickhouse):\/\/[^\s:/@'"\\]+:[^\s@'"\\]{4,}@[^\s'"\\]+`), what: 'a database URL with its password' },
|
|
81
|
+
{ id: 'basic-auth-url', severity: 'warn', re: R(String.raw`https?:\/\/[^\s:/@'"\\]+:[^\s@'"\\]{4,}@[^\s'"\\]+`), what: 'a URL with credentials in it' },
|
|
82
|
+
{ id: 'jwt', severity: 'warn', re: R(String.raw`eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`), what: 'a JWT' },
|
|
83
|
+
{ id: 'generic-secret', severity: 'warn', re: R(String.raw`(?:(?:api[_-]?key|secret[_-]?key|client[_-]?secret|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|token|passwd|password|pwd)\b(?:\\?["']?\s*[:=]\s*\\?["']?)|bearer\s+)([A-Za-z0-9_\-/+=.]{16,})`, 'gi'), value: (m) => m[1], ok: (v) => !PLACEHOLDER.test(v) && /[0-9]/.test(v) && /[A-Za-z]/.test(v) && entropy(v) >= 3.5, what: 'a secret named by its context' },
|
|
84
|
+
];
|
|
85
|
+
export const ADVICE = {
|
|
86
|
+
'private-key': 'Generate a new key pair and remove the old public key everywhere it is authorised; then shred.',
|
|
87
|
+
'aws-access-key': 'Deactivate the key in IAM and issue a new one; check CloudTrail for use; then shred.',
|
|
88
|
+
'github-token': 'Revoke it under Settings → Developer settings; then shred.',
|
|
89
|
+
'anthropic-key': 'Revoke it in the Console and issue a new one; then shred.',
|
|
90
|
+
'openai-key': 'Revoke it in the dashboard and issue a new one; then shred.',
|
|
91
|
+
'slack-token': 'Revoke the token or reinstall the app; then shred.',
|
|
92
|
+
'stripe-key': 'Roll the key in the Stripe dashboard; then shred.',
|
|
93
|
+
'google-api-key': 'Regenerate the key in Google Cloud and restrict it; then shred.',
|
|
94
|
+
'npm-token': 'Revoke it on npmjs.com (Access Tokens); then shred.',
|
|
95
|
+
'sendgrid-key': 'Delete the key in SendGrid and create a new one; then shred.',
|
|
96
|
+
'db-url-password': 'Change the database password and update the app; then shred.',
|
|
97
|
+
'basic-auth-url': 'Change the password behind the URL; prefer a credential helper over a URL with the password in it; then shred.',
|
|
98
|
+
'jwt': 'Short-lived by design, but treat as live until it expires; shred.',
|
|
99
|
+
'generic-secret': 'Confirm what it is, rotate it, then shred. If it is not a secret, nothing to do.',
|
|
100
|
+
'credential-file-read': 'The file\'s contents are in the transcript. Rotate anything it held that the rules above did not name; shred what they did.',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// A line-start index so a match offset maps to a 1-based line number by binary search.
|
|
104
|
+
function lineIndex(text) { const starts = [0]; for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1); return starts; }
|
|
105
|
+
function lineAt(starts, off) { let lo = 0, hi = starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= off) lo = mid; else hi = mid - 1; } return lo + 1; }
|
|
106
|
+
function lineText(text, starts, n) { const a = starts[n - 1], b = n < starts.length ? starts[n] - 1 : text.length; return text.slice(a, b).replace(/\r$/, ''); }
|
|
107
|
+
|
|
108
|
+
// Where a hit sits: the record type and, for tool traffic, the tool (and the path for a Read).
|
|
109
|
+
function locate(rec, tools) {
|
|
110
|
+
if (!rec || typeof rec !== 'object') return { where: 'other' };
|
|
111
|
+
const m = rec.message;
|
|
112
|
+
if (rec.type === 'assistant' && m && Array.isArray(m.content)) {
|
|
113
|
+
const tu = m.content.find((b) => b && b.type === 'tool_use');
|
|
114
|
+
if (tu) { tools.set(tu.id, { name: tu.name, path: core.filePathOf(tu), input: tu.input }); return { where: 'tool input', tool: tu.name, path: tools.get(tu.id).path || undefined }; }
|
|
115
|
+
return { where: 'assistant text' };
|
|
116
|
+
}
|
|
117
|
+
if (rec.type === 'user' && m) {
|
|
118
|
+
if (Array.isArray(m.content)) { const tr = m.content.find((b) => b && b.type === 'tool_result'); if (tr) { const t = tools.get(tr.tool_use_id); return { where: 'tool result', tool: t ? t.name : undefined, path: t && t.path || undefined }; } }
|
|
119
|
+
return { where: rec.isMeta || rec.isCompactSummary ? 'system' : 'user prompt' };
|
|
120
|
+
}
|
|
121
|
+
return { where: rec.type || 'other' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Credential-file reads: a Read of a credential file, or a Bash command that dumps one.
|
|
125
|
+
function fileReadHit(rec) {
|
|
126
|
+
if (!rec || rec.type !== 'assistant' || !rec.message || !Array.isArray(rec.message.content)) return null;
|
|
127
|
+
const tu = rec.message.content.find((b) => b && b.type === 'tool_use'); if (!tu || !tu.input) return null;
|
|
128
|
+
const p = core.filePathOf(tu);
|
|
129
|
+
if (p && /^(Read|View|Cat|NotebookRead)$/i.test(tu.name) && isCredentialFile(p)) return { tool: tu.name, path: String(p) };
|
|
130
|
+
const cmd = typeof tu.input.command === 'string' ? tu.input.command : null;
|
|
131
|
+
if (cmd) { const m = cmd.match(DUMP_CMD); if (m && (m[1] === undefined ? true : isCredentialFile(m[1]))) return { tool: tu.name, path: m[1] || cmd.trim().slice(0, 60) }; }
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Scan raw transcript text. Returns hits in file order: { rule, severity, line, start, end, value, preview, fingerprint, where, tool?, path? }.
|
|
136
|
+
export function scanText(text, opts = {}) {
|
|
137
|
+
const fp = (v) => fingerprint(v, opts.key);
|
|
138
|
+
const starts = lineIndex(text);
|
|
139
|
+
const hits = [];
|
|
140
|
+
const spans = []; // [start, end] of accepted hits, for overlap suppression
|
|
141
|
+
const overlaps = (a, b) => spans.some(([x, y]) => a < y && b > x);
|
|
142
|
+
for (const d of DETECTORS) {
|
|
143
|
+
d.re.lastIndex = 0; let m;
|
|
144
|
+
while ((m = d.re.exec(text))) {
|
|
145
|
+
const value = d.value ? d.value(m) : m[0];
|
|
146
|
+
const start = d.value ? m.index + m[0].length - value.length : m.index, end = start + value.length;
|
|
147
|
+
if (d.ok && !d.ok(value)) continue;
|
|
148
|
+
if (overlaps(start, end)) continue;
|
|
149
|
+
spans.push([start, end]);
|
|
150
|
+
hits.push({ rule: d.id, severity: d.severity, start, end, value, preview: mask(value), fingerprint: fp(value), line: lineAt(starts, start) });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
hits.sort((a, b) => a.start - b.start);
|
|
154
|
+
// Attribution needs the record; parse each line once, and walk every line for tool_use ids (results come after calls)
|
|
155
|
+
// and for credential-file reads.
|
|
156
|
+
const tools = new Map(); const parsed = new Map(); const fileReads = [];
|
|
157
|
+
const hitLines = new Set(hits.map((h) => h.line));
|
|
158
|
+
for (let n = 1; n <= starts.length; n++) {
|
|
159
|
+
const raw = lineText(text, starts, n); if (!raw.trim()) continue;
|
|
160
|
+
let rec = null; try { rec = JSON.parse(raw); } catch (e) { continue; }
|
|
161
|
+
const fr = fileReadHit(rec);
|
|
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 });
|
|
163
|
+
const loc = locate(rec, tools);
|
|
164
|
+
if (hitLines.has(n)) parsed.set(n, loc);
|
|
165
|
+
}
|
|
166
|
+
for (const h of hits) Object.assign(h, opts.where ? { where: opts.where } : parsed.get(h.line) || { where: 'other' });
|
|
167
|
+
return hits.concat(fileReads).sort((a, b) => a.start - b.start || SEV[a.severity] - SEV[b.severity]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Same value in the same place collapses to one finding with a count. Values are kept on the object (for shred)
|
|
171
|
+
// but never serialised: `toJSON` drops them.
|
|
172
|
+
function rows(hits) {
|
|
173
|
+
const map = new Map();
|
|
174
|
+
for (const h of hits) {
|
|
175
|
+
const k = [h.rule, h.fingerprint, h.where, h.tool || '', h.path || ''].join('|');
|
|
176
|
+
if (map.has(k)) { const r = map.get(k); r.count++; r.spans.push([h.start, h.end]); continue; }
|
|
177
|
+
map.set(k, Object.defineProperties({ rule: h.rule, severity: h.severity, where: h.where, tool: h.tool, path: h.path, line: h.line, preview: h.preview, fingerprint: h.fingerprint, count: 1 }, {
|
|
178
|
+
value: { value: h.value, enumerable: false }, spans: { value: h.value == null ? [] : [[h.start, h.end]], enumerable: false },
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
return [...map.values()];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function scanFile(file, meta = {}) {
|
|
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 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Rewrite every hit in place. `replacer(finding)` gives the replacement text; the default contains no quote,
|
|
195
|
+
// backslash or control character so a JSON string stays a JSON string. Every changed line is parsed again before
|
|
196
|
+
// anything is written; on failure the file is left as it was.
|
|
197
|
+
export function shredFile(file, findings, opts = {}) {
|
|
198
|
+
const replacer = opts.replacer || ((f) => `[FENCED:${f.rule}:${f.fingerprint}]`);
|
|
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);
|
|
202
|
+
const spans = [];
|
|
203
|
+
for (const f of findings) for (const [s, e] of (f.spans || [])) spans.push({ s, e, rep: replacer(f) });
|
|
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' };
|
|
206
|
+
spans.sort((a, b) => a.s - b.s);
|
|
207
|
+
let out = '', pos = 0;
|
|
208
|
+
for (const sp of spans) { if (sp.s < pos) continue; out += text.slice(pos, sp.s) + sp.rep; pos = sp.e; }
|
|
209
|
+
out += text.slice(pos);
|
|
210
|
+
const before = text.split('\n'), after = out.split('\n');
|
|
211
|
+
if (before.length !== after.length) return { written: false, values: spans.length, reason: 'line count changed; 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` }; } }
|
|
213
|
+
const tmp = file + '.glassbox-shred~';
|
|
214
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
215
|
+
fs.writeFileSync(tmp, out, { mode }); fs.renameSync(tmp, file);
|
|
216
|
+
return { written: true, values: spans.length };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Which files to scan. opts: { home, target, since (ms epoch), project, sessionsOnly }.
|
|
220
|
+
export function targets(opts = {}) {
|
|
221
|
+
const home = opts.home || claudeHome();
|
|
222
|
+
const list = [];
|
|
223
|
+
const add = (file, session, project) => list.push({ file, session, project });
|
|
224
|
+
const addSession = (s) => { add(s.file, s.id, s.project); for (const f of subagentFiles(s)) add(f.path, s.id, s.project); };
|
|
225
|
+
const t = opts.target;
|
|
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
|
+
}
|
|
246
|
+
if (fs.existsSync(t) && fs.statSync(t).isDirectory()) {
|
|
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); } };
|
|
248
|
+
walk(path.resolve(t)); return { list, mode: 'dir', dir: path.resolve(t) };
|
|
249
|
+
}
|
|
250
|
+
const s = resolveTarget(t, { home }); addSession(s); return { list, mode: s.project == null ? 'file' : 'session', home };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The report. opts as `targets` plus { shred }.
|
|
254
|
+
export function fence(opts = {}) {
|
|
255
|
+
const tg = targets(opts);
|
|
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();
|
|
260
|
+
const shredded = { files: 0, values: 0, refused: [] };
|
|
261
|
+
for (const t of tg.list) {
|
|
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);
|
|
267
|
+
findings.push(...r.findings);
|
|
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 }); }
|
|
269
|
+
}
|
|
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; } }
|
|
273
|
+
const bySeverity = { error: 0, warn: 0, info: 0 }; for (const f of findings) bySeverity[f.severity]++;
|
|
274
|
+
const byRule = {}; for (const f of findings) byRule[f.rule] = (byRule[f.rule] || 0) + f.count;
|
|
275
|
+
const secretMap = new Map();
|
|
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; }
|
|
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);
|
|
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 },
|
|
281
|
+
summary: { bySeverity, byRule, filesWithFindings: files.filter((f) => f.findings).length, distinctSecrets: secrets.length, secrets },
|
|
282
|
+
findings, files };
|
|
283
|
+
if (opts.shred) rep.shredded = shredded;
|
|
284
|
+
return rep;
|
|
285
|
+
}
|
|
286
|
+
|
|
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]); }
|
|
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(', ')})`; };
|
|
291
|
+
const fmtBytes = (n) => n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' KB' : n + ' B';
|
|
292
|
+
const place = (f) => `${f.where}${f.tool ? ' · ' + f.tool : ''}${f.path && f.rule !== 'credential-file-read' ? ' ' + f.path : ''}`;
|
|
293
|
+
|
|
294
|
+
export function fenceText(rep) {
|
|
295
|
+
const L = [];
|
|
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'}`;
|
|
297
|
+
const s = rep.summary.bySeverity;
|
|
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})`);
|
|
301
|
+
if (!rep.findings.length) { L.push(' nothing found — no known credential formats, no secrets named by context, no credential-file reads'); }
|
|
302
|
+
else {
|
|
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`, '');
|
|
304
|
+
let last = null;
|
|
305
|
+
for (const f of rep.findings) {
|
|
306
|
+
if (f.file !== last) { L.push(` ${f.file}${f.session && !f.file.includes(f.session) ? ` (session ${f.session.slice(0, 8)})` : ''}`); last = f.file; }
|
|
307
|
+
L.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.rule.padEnd(21)} line ${String(f.line).padStart(6)} ${f.preview}${f.count > 1 ? ` ×${f.count}` : ''} — ${place(f)}`);
|
|
308
|
+
}
|
|
309
|
+
L.push('', ' Secrets, by how far they spread:');
|
|
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'}`);
|
|
311
|
+
L.push('', ' A secret that reached a transcript reached a disk: rotate it, then run again with --shred to overwrite it in place.');
|
|
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)`);
|
|
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}`); }
|
|
316
|
+
return L.join('\n') + '\n';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function fenceMarkdown(rep) {
|
|
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}._`, '');
|
|
322
|
+
const s = rep.summary.bySeverity;
|
|
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'; }
|
|
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.`, '');
|
|
325
|
+
L.push('## Secrets, by how far they spread', '', '| severity | rule | fingerprint | preview | sessions | places | do |', '|---|---|---|---|---:|---:|---|');
|
|
326
|
+
for (const x of rep.summary.secrets) L.push(`| ${x.severity} | \`${x.rule}\` | \`${x.fingerprint}\` | ${x.preview} | ${x.sessions} | ${x.occurrences} | ${x.advice} |`);
|
|
327
|
+
L.push('', '## Where, file by file', '');
|
|
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); }
|
|
329
|
+
for (const [file, list] of byFile) {
|
|
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 |', '|---|---|---:|---|---|');
|
|
331
|
+
for (const f of list) L.push(`| \`${f.rule}\` | ${f.severity} | ${f.line} | ${f.preview}${f.count > 1 ? ` ×${f.count}` : ''} | ${place(f)} |`);
|
|
332
|
+
L.push('');
|
|
333
|
+
}
|
|
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).`, '');
|
|
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(''); }
|
|
337
|
+
return L.join('\n');
|
|
338
|
+
}
|
package/src/guard.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Glassbox guard — a PreToolUse hook that stops the one loop worth stopping live: the agent is
|
|
2
|
+
// about to make a call that has already failed, unchanged, twice in a row this turn.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately narrow, so it doesn't get in the way:
|
|
5
|
+
// - only failures count (denied-by-the-human and interrupted calls don't; they have their own flow);
|
|
6
|
+
// - the chain is broken by anything that could have changed the outcome: an edit, another command,
|
|
7
|
+
// any non-read call, a success, or a new human prompt. Re-running tests after a fix is never blocked;
|
|
8
|
+
// - calls are compared on what they do, not how they're labelled: Bash/PowerShell `description`,
|
|
9
|
+
// `timeout` and `run_in_background` are ignored, because the agent often rewords them on a retry.
|
|
10
|
+
// It never approves anything: when it has nothing to say it prints nothing, which leaves Claude Code's
|
|
11
|
+
// normal permission flow in charge ("allow" would skip the user's permission prompts).
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const core = require('./trace-core.js');
|
|
16
|
+
|
|
17
|
+
export const GUARD_DEFAULTS = { maxFailures: 2, tailBytes: 4 * 1048576 };
|
|
18
|
+
const IGNORED_KEYS = new Set(['description', 'timeout', 'run_in_background']);
|
|
19
|
+
|
|
20
|
+
export function guardKey(name, input) {
|
|
21
|
+
const i = {};
|
|
22
|
+
for (const [k, v] of Object.entries(input || {})) if (!IGNORED_KEYS.has(k)) i[k] = v;
|
|
23
|
+
return name + '|' + core.stableStringify(i);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The last few MB of a transcript: the current turn is at the end, and a PreToolUse hook runs before
|
|
27
|
+
// every tool call, so it must not parse a 20 MB session each time.
|
|
28
|
+
function readTail(file, bytes) {
|
|
29
|
+
const size = fs.statSync(file).size;
|
|
30
|
+
const start = Math.max(0, size - bytes);
|
|
31
|
+
const fd = fs.openSync(file, 'r');
|
|
32
|
+
try {
|
|
33
|
+
const buf = Buffer.alloc(size - start); fs.readSync(fd, buf, 0, buf.length, start);
|
|
34
|
+
let text = buf.toString('utf8');
|
|
35
|
+
if (start > 0) text = text.slice(text.indexOf('\n') + 1); // drop the partial first line
|
|
36
|
+
return text;
|
|
37
|
+
} finally { fs.closeSync(fd); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isHumanPrompt(rec) {
|
|
41
|
+
if (rec.type !== 'user' || rec.isMeta || rec.isCompactSummary) return false;
|
|
42
|
+
const c = rec.message && rec.message.content;
|
|
43
|
+
if (Array.isArray(c) && c.some((b) => b && b.type === 'tool_result')) return false;
|
|
44
|
+
const text = core.textOf(c).trim();
|
|
45
|
+
return !!text && !/^<(system-reminder|local-command|command-name|bash-input|bash-stdout)/.test(text) && !/^\[Request interrupted by user/.test(text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Returns null (say nothing) or the PreToolUse deny reply.
|
|
49
|
+
export function guardResponse(input, opts = {}) {
|
|
50
|
+
const o = Object.assign({}, GUARD_DEFAULTS, opts);
|
|
51
|
+
if (!input || !input.tool_name) return null;
|
|
52
|
+
const file = input.agent_transcript_path || input.transcript_path;
|
|
53
|
+
if (!file || !fs.existsSync(file)) return null;
|
|
54
|
+
const text = readTail(file, o.tailBytes);
|
|
55
|
+
if (!/"is_error":\s*true/.test(text)) return null; // nothing has failed recently: the common case, and fast
|
|
56
|
+
|
|
57
|
+
const agent = input.agent_id || null;
|
|
58
|
+
const calls = []; const results = new Map(); let turnStart = 0;
|
|
59
|
+
for (const line of text.split('\n')) {
|
|
60
|
+
if (!line.trim()) continue;
|
|
61
|
+
let rec; try { rec = JSON.parse(line); } catch (e) { continue; }
|
|
62
|
+
if ((rec.agentId || null) !== agent && !(agent && input.agent_transcript_path)) continue; // other agents' records in a shared file
|
|
63
|
+
if (isHumanPrompt(rec)) { turnStart = calls.length; continue; }
|
|
64
|
+
const content = rec.message && Array.isArray(rec.message.content) ? rec.message.content : [];
|
|
65
|
+
if (rec.type === 'assistant') for (const b of content) if (b && b.type === 'tool_use') calls.push({ id: b.id, name: b.name, key: guardKey(b.name, b.input) });
|
|
66
|
+
if (rec.type === 'user') for (const b of content) if (b && b.type === 'tool_result') {
|
|
67
|
+
const t = core.textOf(b.content);
|
|
68
|
+
results.set(b.tool_use_id, { isError: !!b.is_error, text: t, denial: b.is_error ? core.denialKind(rec, t) : null });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const target = guardKey(input.tool_name, input.tool_input);
|
|
73
|
+
let failures = 0, lastError = null;
|
|
74
|
+
for (let i = calls.length - 1; i >= turnStart; i--) {
|
|
75
|
+
const c = calls[i];
|
|
76
|
+
if (c.id === input.tool_use_id) continue; // the call being decided may already be in the transcript
|
|
77
|
+
const r = results.get(c.id);
|
|
78
|
+
if (!r) continue; // still running (a parallel call): no outcome yet
|
|
79
|
+
if (c.key === target) {
|
|
80
|
+
if (!r.isError || r.denial) break; // it worked last time, or a person stopped it: not a loop
|
|
81
|
+
failures++; if (lastError == null) lastError = r.text;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (core.toolCategory(c.name) === 'read') continue; // looking around changes nothing
|
|
85
|
+
break; // an edit, another command, any action: the outcome may be different now
|
|
86
|
+
}
|
|
87
|
+
if (failures < o.maxFailures) return null;
|
|
88
|
+
const err = String(lastError || '').split('\n').map((l) => l.trim()).find(Boolean) || 'no error text';
|
|
89
|
+
return {
|
|
90
|
+
hookSpecificOutput: {
|
|
91
|
+
hookEventName: 'PreToolUse',
|
|
92
|
+
permissionDecision: 'deny',
|
|
93
|
+
permissionDecisionReason: `Glassbox guard: this exact ${input.tool_name} call has already failed ${failures} times in a row this turn, with nothing changed in between. Last error: ${err.slice(0, 300)}\nDon't repeat it unchanged. Read the error, then change the input, fix what it depends on, try a different approach, or ask the user. A call that differs, or comes after an edit or another command, is not blocked.`,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/trace-core.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
10
10
|
'use strict';
|
|
11
11
|
|
|
12
|
-
const VERSION = '0.
|
|
12
|
+
const VERSION = '0.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
|
});
|