glassbox-trace 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/glassbox.html +1 -1
- package/package.json +6 -2
- package/src/adhere.mjs +261 -0
- package/src/cli.mjs +41 -6
- package/src/collect.mjs +2 -1
- package/src/fence.mjs +261 -0
- package/src/textfile.mjs +17 -0
- package/src/trace-core.js +1 -1
package/README.md
CHANGED
|
@@ -84,6 +84,52 @@ Read top to bottom: stats → timeline (with minimap, search, fit-to-turn) → c
|
|
|
84
84
|
|
|
85
85
|
**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
86
|
|
|
87
|
+
### Secrets in transcripts — `glassbox fence`
|
|
88
|
+
|
|
89
|
+
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:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
glassbox fence every session under ~/.claude — exit 1 if a credential was found
|
|
93
|
+
glassbox fence 81c4cbfd one session (and its subagents)
|
|
94
|
+
glassbox fence ./archive every .jsonl under a folder
|
|
95
|
+
glassbox fence --format md --out fence.md
|
|
96
|
+
glassbox fence --shred overwrite each value in place with [FENCED:<rule>:<fingerprint>]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
Glassbox fence · 34 sessions under /home/aldo/.claude/projects · 212 MB
|
|
101
|
+
3 error · 1 warn · 2 info · 3 distinct secrets in 2 of 41 files
|
|
102
|
+
|
|
103
|
+
/home/aldo/.claude/projects/-home-aldo-api/7c1e…jsonl
|
|
104
|
+
ERROR anthropic-key line 212 sk-a…oP (62 chars) ×2 — tool result · Read /home/aldo/api/.env
|
|
105
|
+
ERROR github-token line 340 ghp_…r8 (40 chars) — tool input · Bash
|
|
106
|
+
INFO credential-file-read line 211 /home/aldo/api/.env — tool input · Read
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Each row is a masked preview and a fingerprint (first 8 hex of SHA-256) — enough to know which key it is and to see the same key across sessions, never enough to use it. `error` is a credential whose format identifies it (AWS, GitHub, Anthropic, OpenAI, Slack, Stripe, Google, npm, SendGrid, a private key block, a database URL with its password); `warn` is a secret named by its context (`password=`, `api_key:`, `Bearer …`, a URL with credentials, a JWT), kept only when the value has the entropy of a real one and is not a placeholder; `info` is a read of a credential file (`.env`, `.npmrc`, `.netrc`, `~/.aws/credentials`, `id_rsa`, `*.pem` …) whose contents are now in the transcript whether or not a rule recognised them. A secret that reached a transcript reached a disk, and whatever backs that disk up: rotate it, then `--shred`. Shredded transcripts still open and check. It verifies nothing against any provider (no network), and a password in prose with no context word passes through — it is a net, not a guarantee. Details in [docs/FENCE.md](docs/FENCE.md).
|
|
110
|
+
|
|
111
|
+
### Is my CLAUDE.md doing anything? — `glassbox adhere`
|
|
112
|
+
|
|
113
|
+
Every rule in the project's instruction files, judged against every session of that project, per occasion, with evidence:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
glassbox adhere the project in the current folder
|
|
117
|
+
glassbox adhere --project ~/code/api --since 30d --format md --out adhere.md
|
|
118
|
+
glassbox adhere --fail-under 80 exit 1 below that rate
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
Glassbox adhere · /home/aldo/api
|
|
123
|
+
11 rules · 9 checkable · 2 not checkable yet · 34 sessions · obeyed 212 of 301 occasions (70%)
|
|
124
|
+
|
|
125
|
+
IGNORED prefer-tool 0/38 0% Use rg rather than grep. (CLAUDE.md:11)
|
|
126
|
+
↳ 535876c9 t3 "Continue from where you left off." → grep -n "^export" src/cli.mjs | sed -n 1,80p
|
|
127
|
+
IGNORED ask-before 0/1 0% Ask before committing. (CLAUDE.md:7)
|
|
128
|
+
OBEYED never-touch 8/8 100% Never edit files in dist/ by hand. (CLAUDE.md:5)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
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).
|
|
132
|
+
|
|
87
133
|
## What it flags
|
|
88
134
|
|
|
89
135
|
| rule | fires when |
|
|
@@ -165,6 +211,7 @@ Your context is private — by construction, not by promise.
|
|
|
165
211
|
- **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
212
|
- **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
213
|
- **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»`.
|
|
214
|
+
- **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
215
|
- **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
216
|
|
|
170
217
|
Source: [github.com/aldohushi1-stack/glassbox](https://github.com/aldohushi1-stack/glassbox) · MIT © Aldo Hushi
|
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.0';
|
|
412
412
|
|
|
413
413
|
// ---------------------------------------------------------------------------
|
|
414
414
|
// Rate card (USD per million tokens). Prefix-matched against model ids so dated
|
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.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 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,261 @@
|
|
|
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
|
+
// Each matcher returns the rule's parameters or null. Order matters: first match wins.
|
|
34
|
+
const SHAPES = [
|
|
35
|
+
{ kind: 'ask-before', m: (s) => { let m = s.match(/\b(?:ask(?:\s+me)?|check with me|confirm(?:\s+with me)?|get (?:my )?(?:permission|approval|ok))\s*(?:first\s+)?(?:before|prior to)\s+(?:you\s+)?(?:\w+ing\s+)?(commit|push|delet|remov|install|creat|deploy|merg)\w*/i) || s.match(/\b(?:ask(?:\s+me)?|check with me)\s+before\s+(?:you\s+)?(commit|push|delet|remov|install|creat|deploy|merg)\w*/i); if (m) return { action: ACTION(m[1]) }; m = s.match(/\b(?:never|don't|do not|don’t)\s+(commit|push|delete|remove|install|deploy|merge)\b[^.]*?\b(?:without|unless)\s+(?:first\s+)?(?:asking|being asked|i ask|you ask|i tell|told|permission|approval|confirm|checking|explicit)/i); if (m) return { action: ACTION(m[1]) }; return null; } },
|
|
36
|
+
{ kind: 'run-before', m: (s) => { let m = s.match(/\b(?:always\s+)?run\s+(?:the\s+)?(`[^`]+`|[\w][\w .:/-]*?)\s+(?:before|prior to)\s+(?:you\s+|every\s+|each\s+|any\s+|a\s+)?(?:\w+ing\s+)?(commit|push|merg|pull request|pr\b|open)/i); if (m) return { what: whatOf(m[1]), trigger: /^(commit)/i.test(m[2]) ? 'commit' : 'push' }; m = s.match(/\b(?:never|don't|do not|don’t)\s+(commit|push)\b[^.]*?\b(?:without|unless)\s+(?:first\s+)?(?:running\s+|you(?:'ve| have)?\s+run\s+|the\s+)?(`[^`]+`|[\w][\w .:/-]*?)(?:\s+(?:pass(?:es|ing)?|first|succeed\w*))?\.?$/i); if (m && !/\bask|permission|approv|confirm|check/i.test(s)) return { what: whatOf(m[2]), trigger: m[1].toLowerCase() }; m = s.match(/\b(tests?|test suite|linter|lint|build|typecheck|type check)\s+(?:must|should|need to|has to|have to)\s+(?:pass|succeed|be green)\s+(?:before|prior to)\s+(?:you\s+|every\s+|each\s+|any\s+)?(?:\w+ing\s+)?(commit|push|merg)/i); if (m) return { what: whatOf(m[1]), trigger: /^commit/i.test(m[2]) ? 'commit' : 'push' }; return null; } },
|
|
37
|
+
{ kind: 'run-after', m: (s) => { let m = s.match(/\brun\s+(?:the\s+)?(`[^`]+`|[\w][\w .:/-]*?)\s+after\s+(?:you\s+)?(?:making\s+|every\s+|each\s+|any\s+|all\s+)?(?:change|edit|modif|writ)/i); if (m) return { what: whatOf(m[1]) }; m = s.match(/\b(format|lint|typecheck)\s+(?:the\s+)?(?:code|files?)\s+after\s+(?:you\s+)?(?:making\s+|every\s+|each\s+|any\s+)?(?:change|edit|modif)/i); if (m) return { what: /^format/i.test(m[1]) ? 'formatter' : /^lint/i.test(m[1]) ? 'linter' : m[1] }; return null; } },
|
|
38
|
+
{ 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; } },
|
|
39
|
+
{ 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; } },
|
|
40
|
+
{ 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; } },
|
|
41
|
+
{ 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 },
|
|
42
|
+
{ 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; } },
|
|
43
|
+
{ 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 },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// CLAUDE.md → rules + not-checkable lines. Headings, blank lines and code fences are skipped; list markers and
|
|
47
|
+
// bold are stripped; multi-sentence lines are split; rule text is kept verbatim (minus backticks).
|
|
48
|
+
export function parseRules(text, file) {
|
|
49
|
+
const rules = [], unchecked = [];
|
|
50
|
+
let fence = false, n = 0;
|
|
51
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
52
|
+
n++;
|
|
53
|
+
const line = raw.trim();
|
|
54
|
+
if (/^(```|~~~)/.test(line)) { fence = !fence; continue; }
|
|
55
|
+
if (fence || !line || /^#/.test(line) || /^[-*_]{3,}$/.test(line) || /^<!--/.test(line)) continue;
|
|
56
|
+
const body = line.replace(/^(?:[-*+]|\d+[.)])\s+/, '').replace(/^\[[ x]\]\s+/i, '').replace(/\*\*|__/g, '').trim();
|
|
57
|
+
if (!body) continue;
|
|
58
|
+
const parts = body.split(/(?<=[.!])\s+(?=[A-Z`])/);
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
const s = part.trim(); if (!s) continue;
|
|
61
|
+
let hit = null;
|
|
62
|
+
for (const sh of SHAPES) { const p = sh.m(s); if (p) { hit = Object.assign({ kind: sh.kind }, p); break; } }
|
|
63
|
+
const rec = { id: `${file}:${n}${parts.length > 1 ? ':' + (parts.indexOf(part) + 1) : ''}`, file, line: n, text: strip(s) };
|
|
64
|
+
if (hit) rules.push(Object.assign(rec, hit)); else unchecked.push(rec);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { rules, unchecked };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function findInstructionFiles(project, home) {
|
|
71
|
+
const out = [];
|
|
72
|
+
const add = (p, label) => { if (fs.existsSync(p) && fs.statSync(p).isFile()) out.push({ file: p, label }); };
|
|
73
|
+
add(path.join(project, 'CLAUDE.md'), 'CLAUDE.md');
|
|
74
|
+
add(path.join(project, '.claude', 'CLAUDE.md'), '.claude/CLAUDE.md');
|
|
75
|
+
add(path.join(project, 'CLAUDE.local.md'), 'CLAUDE.local.md');
|
|
76
|
+
add(path.join(home || claudeHome(), 'CLAUDE.md'), '~/.claude/CLAUDE.md (global)');
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Judging
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
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;
|
|
84
|
+
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;
|
|
85
|
+
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;
|
|
86
|
+
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();
|
|
87
|
+
function commandMatches(cmd, what) {
|
|
88
|
+
const c = surface(cmd);
|
|
89
|
+
if (what === 'tests') return TEST_RE.test(c);
|
|
90
|
+
if (what === 'formatter') return FORMAT_RE.test(c);
|
|
91
|
+
if (what === 'linter') return LINT_RE.test(c);
|
|
92
|
+
return pmNorm(c).includes(pmNorm(what));
|
|
93
|
+
}
|
|
94
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
95
|
+
function forbiddenRe(pattern) {
|
|
96
|
+
if (/^git push --force$/i.test(pattern)) return new RegExp(GIT + 'push\\b.*\\s(?:--force(?:-with-lease)?|-f)\\b', 'i');
|
|
97
|
+
return new RegExp('^' + escapeRe(pattern).replace(/\\\s+|\s+/g, '\\s+') + '(?=\\s|$)', 'i');
|
|
98
|
+
}
|
|
99
|
+
// A command's shell surface: heredoc bodies and inline programs (node -e "…", python -c "…") are content, not
|
|
100
|
+
// commands — a `git push --force` inside a document being written is not a force push.
|
|
101
|
+
export function surface(cmd) {
|
|
102
|
+
return String(cmd)
|
|
103
|
+
.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\s|$|\))/g, '<<HEREDOC')
|
|
104
|
+
.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) + '"…"');
|
|
105
|
+
}
|
|
106
|
+
// Shell segments (split on && || | ; and newlines), each with env assignments, sudo, time and exec looked through.
|
|
107
|
+
export function segments(cmd) {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const seg of surface(cmd).split(/\s*(?:&&|\|\||\||;|\n)\s*/)) {
|
|
110
|
+
const toks = seg.trim().replace(/^[(\s]+/, '').split(/\s+/).filter(Boolean);
|
|
111
|
+
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++;
|
|
112
|
+
if (i < toks.length) out.push(toks.slice(i).join(' '));
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
// The tool a segment starts with: `python -m pip` is pip, `pip3` is pip, a path prefix is dropped.
|
|
117
|
+
function segmentTools(cmd) {
|
|
118
|
+
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; });
|
|
119
|
+
}
|
|
120
|
+
const hasSeg = (cmd, re) => segments(cmd).some((seg) => re.test(seg));
|
|
121
|
+
function commitMessage(cmd) {
|
|
122
|
+
cmd = String(cmd);
|
|
123
|
+
const hd = cmd.match(/<<-?\s*['"]?(\w+)['"]?\n([\s\S]*?)\n\1/); if (hd) return hd[2].split('\n')[0].trim();
|
|
124
|
+
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();
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const CONVENTIONAL = /^(?:feat|fix|chore|docs|refactor|test|perf|ci|build|style|revert)(?:\([^)]*\))?!?: \S/;
|
|
128
|
+
const TICKET = /^\[?[A-Z][A-Z0-9]+-\d+\]?[:\s-]/;
|
|
129
|
+
const pathHits = (rulePath, filePath, project) => {
|
|
130
|
+
let rel = norm(filePath); const pj = norm(project).replace(/\/$/, '');
|
|
131
|
+
if (pj && rel.startsWith(pj + '/')) rel = rel.slice(pj.length + 1);
|
|
132
|
+
const rp = norm(rulePath).replace(/^\.\//, '');
|
|
133
|
+
if (rp.includes('*')) return new RegExp('(?:^|/)' + escapeRe(rp).replace(/\\\*\\\*/g, '.*').replace(/\\\*/g, '[^/]*') + '$').test(rel);
|
|
134
|
+
if (rp.endsWith('/')) return rel.startsWith(rp) || rel.includes('/' + rp);
|
|
135
|
+
return rel === rp || rel.endsWith('/' + rp) || rel.startsWith(rp + '/') || rel.includes('/' + rp + '/');
|
|
136
|
+
};
|
|
137
|
+
// `git -c k=v -C dir --no-pager commit …` is still a commit.
|
|
138
|
+
const OPTV = String.raw`(?:"[^"]*"|'[^']*'|\S)+`;
|
|
139
|
+
const GIT = String.raw`^git\s+(?:(?:-c\s+${OPTV}|-C\s+${OPTV}|--no-pager|--git-dir=${OPTV}|--work-tree=${OPTV})\s+)*`;
|
|
140
|
+
const git = (sub) => new RegExp(GIT + sub + '\\b');
|
|
141
|
+
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') };
|
|
142
|
+
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 };
|
|
143
|
+
|
|
144
|
+
// Judge every rule against one session. Returns per-rule { occasions:[{obeyed, session, turn, prompt, detail, at}] }.
|
|
145
|
+
function judgeSession(rules, s, project) {
|
|
146
|
+
const { trace, cost } = analyse(loadSessionFiles(s));
|
|
147
|
+
const calls = trace.toolCalls.filter((c) => !c.unmatched).slice().sort((a, b) => a.start - b.start);
|
|
148
|
+
const turns = trace.turns;
|
|
149
|
+
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 }; };
|
|
150
|
+
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); };
|
|
151
|
+
const short = (t) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
152
|
+
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 });
|
|
153
|
+
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 });
|
|
154
|
+
const bash = calls.filter((c) => c.name === 'Bash' && typeof c.input?.command === 'string');
|
|
155
|
+
const writes = calls.filter((c) => WRITE_TOOLS.has(c.name) && c.input && typeof c.input.file_path === 'string');
|
|
156
|
+
const out = rules.map(() => []);
|
|
157
|
+
rules.forEach((r, i) => {
|
|
158
|
+
const O = out[i];
|
|
159
|
+
if (r.kind === 'run-before') {
|
|
160
|
+
const trig = actionRe[r.trigger];
|
|
161
|
+
let lastAt = -Infinity;
|
|
162
|
+
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; }
|
|
163
|
+
} else if (r.kind === 'run-after') {
|
|
164
|
+
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)); }
|
|
165
|
+
} else if (r.kind === 'prefer-tool') {
|
|
166
|
+
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)); }
|
|
167
|
+
} else if (r.kind === 'never-run') {
|
|
168
|
+
const re = forbiddenRe(r.pattern); const hits = bash.filter((c) => hasSeg(c.input.command, re));
|
|
169
|
+
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));
|
|
170
|
+
} else if (r.kind === 'never-touch') {
|
|
171
|
+
for (const c of writes) O.push(occ(c, !pathHits(r.path, c.input.file_path, project), c.input.file_path));
|
|
172
|
+
} else if (r.kind === 'ask-before') {
|
|
173
|
+
const targets = r.action === 'create' ? calls.filter((c) => c.name === 'Write') : bash.filter((c) => actionRe[r.action] && hasSeg(c.input.command, actionRe[r.action]));
|
|
174
|
+
for (const c of targets) {
|
|
175
|
+
const p = turnPrompt(c.turnIndex);
|
|
176
|
+
const inPrompt = p.kind === 'human' && askedRe[r.action].test(p.text);
|
|
177
|
+
const askedTool = calls.some((k) => k.name === 'AskUserQuestion' && k.turnIndex === c.turnIndex && k.start < c.start);
|
|
178
|
+
const replied = p.kind === 'human' && prevTurnAsked(c.turnIndex);
|
|
179
|
+
O.push(occ(c, inPrompt || askedTool || replied, r.action === 'create' ? c.input.file_path : c.input.command));
|
|
180
|
+
}
|
|
181
|
+
} else if (r.kind === 'read-before-edit') {
|
|
182
|
+
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)); }
|
|
183
|
+
} else if (r.kind === 'commit-format') {
|
|
184
|
+
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)); }
|
|
185
|
+
} else if (r.kind === 'no-new-docs') {
|
|
186
|
+
const hits = calls.filter((c) => c.name === 'Write' && c.input && /\.(?:md|mdx|markdown|rst|txt)$/i.test(String(c.input.file_path || '')));
|
|
187
|
+
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); };
|
|
188
|
+
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));
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
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 } };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// The report
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
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.' };
|
|
198
|
+
export function adhere(opts = {}) {
|
|
199
|
+
const home = opts.home || claudeHome();
|
|
200
|
+
const project = path.resolve(opts.project || process.cwd());
|
|
201
|
+
const files = opts.claudeMd ? [{ file: path.resolve(opts.claudeMd), label: path.basename(opts.claudeMd) }] : findInstructionFiles(project, home);
|
|
202
|
+
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.`);
|
|
203
|
+
const rules = [], unchecked = [], fileRows = [];
|
|
204
|
+
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 }); }
|
|
205
|
+
// Sessions of this project: the folder name Claude Code would use; else a decoded-path substring match.
|
|
206
|
+
const enc = encodeProject(project).toLowerCase();
|
|
207
|
+
let list = findSessions({ home }).filter((s) => path.basename(s.projectDir).toLowerCase() === enc);
|
|
208
|
+
if (!list.length) list = findSessions({ home, project: project.replace(/\\/g, '/') });
|
|
209
|
+
if (opts.since != null) list = list.filter((s) => s.mtime >= opts.since);
|
|
210
|
+
const perRule = rules.map(() => []); const sessions = [];
|
|
211
|
+
for (const s of list) { const j = judgeSession(rules, s, project); j.perRule.forEach((o, i) => perRule[i].push(...o)); sessions.push(j.session); }
|
|
212
|
+
const R = (t) => opts.redact ? `«${String(t).length} chars»` : t;
|
|
213
|
+
const rows = rules.map((r, i) => {
|
|
214
|
+
const occ = perRule[i]; const obeyed = occ.filter((o) => o.obeyed).length; const broken = occ.length - obeyed;
|
|
215
|
+
const rate = occ.length ? obeyed / occ.length : null;
|
|
216
|
+
const verdict = !occ.length ? 'never came up' : rate === 1 ? 'obeyed' : rate >= 0.8 ? 'mostly obeyed' : rate > 0 ? 'often ignored' : 'ignored';
|
|
217
|
+
const examples = occ.filter((o) => !o.obeyed).slice(0, 3).map((o) => ({ session: o.session, turn: o.turn, prompt: R(o.prompt), detail: R(o.detail) }));
|
|
218
|
+
const row = { id: r.id, file: r.file, line: r.line, text: r.text, kind: r.kind, occasions: occ.length, obeyed, broken, rate, verdict, examples, sessionsBroken: new Set(occ.filter((o) => !o.obeyed).map((o) => o.session)).size };
|
|
219
|
+
for (const k of ['what', 'trigger', 'prefer', 'avoid', 'pattern', 'path', 'action', 'style', 'unlessAsked']) if (r[k] !== undefined) row[k] = r[k];
|
|
220
|
+
if (NOTES[r.kind]) row.note = NOTES[r.kind];
|
|
221
|
+
return row;
|
|
222
|
+
});
|
|
223
|
+
const occasions = rows.reduce((n, r) => n + r.occasions, 0), obeyed = rows.reduce((n, r) => n + r.obeyed, 0);
|
|
224
|
+
const brokenSessions = new Set(); rules.forEach((r, i) => perRule[i].filter((o) => !o.obeyed).forEach((o) => brokenSessions.add(o.session)));
|
|
225
|
+
const breachCost = sessions.filter((s) => brokenSessions.has(s.id)).reduce((n, s) => n + (s.cost || 0), 0);
|
|
226
|
+
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,
|
|
227
|
+
files: fileRows, sessions: sessions.map((s) => Object.assign({}, s, { title: R(s.title || ''), breached: brokenSessions.has(s.id) })),
|
|
228
|
+
rules: rows, unchecked,
|
|
229
|
+
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 } };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const pct = (r) => r == null ? '—' : Math.round(r * 100) + '%';
|
|
233
|
+
const usd = (n) => n == null ? '—' : '$' + n.toFixed(2);
|
|
234
|
+
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)})`}`; }
|
|
235
|
+
|
|
236
|
+
export function adhereText(rep) {
|
|
237
|
+
const L = [`Glassbox adhere · ${rep.project}${rep.since ? ` · since ${rep.since.slice(0, 10)}` : ''}`, ` ${headline(rep)}`];
|
|
238
|
+
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)`);
|
|
239
|
+
L.push('');
|
|
240
|
+
const order = { 'ignored': 0, 'often ignored': 1, 'mostly obeyed': 2, 'obeyed': 3, 'never came up': 4 };
|
|
241
|
+
for (const r of rep.rules.slice().sort((a, b) => order[a.verdict] - order[b.verdict] || b.occasions - a.occasions)) {
|
|
242
|
+
L.push(` ${r.verdict.toUpperCase().padEnd(14)} ${r.kind.padEnd(17)} ${r.occasions ? `${r.obeyed}/${r.occasions} ${pct(r.rate).padStart(4)}` : ' — '} ${r.text} (${r.file}:${r.line})`);
|
|
243
|
+
for (const e of r.examples) L.push(` ↳ ${e.session.slice(0, 8)} t${e.turn == null ? '?' : e.turn} "${e.prompt}" → ${e.detail}`);
|
|
244
|
+
}
|
|
245
|
+
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})`); }
|
|
246
|
+
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.');
|
|
247
|
+
return L.join('\n') + '\n';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function adhereMarkdown(rep) {
|
|
251
|
+
const s = rep.summary;
|
|
252
|
+
const L = ['# Is my CLAUDE.md doing anything?', '', `Project \`${rep.project}\` · ${rep.generated.slice(0, 10)}${rep.since ? ` · sessions since ${rep.since.slice(0, 10)}` : ''}`, '', `**${headline(rep)}**`, ''];
|
|
253
|
+
if (s.sessionsWithBreach) L.push(`${s.sessionsWithBreach} of ${s.sessions} sessions broke at least one rule; those sessions cost ${usd(s.breachCost)} in total (where the breaches happened, not what they cost).`, '');
|
|
254
|
+
L.push('## Rules', '', '| rule | kind | occasions | obeyed | broken | rate | verdict |', '|---|---|---:|---:|---:|---:|---|');
|
|
255
|
+
for (const r of rep.rules) L.push(`| ${r.text.replace(/\|/g, '\\|')} <br><sub>${r.file}:${r.line}</sub> | \`${r.kind}\` | ${r.occasions} | ${r.obeyed} | ${r.broken} | ${pct(r.rate)} | ${r.verdict} |`);
|
|
256
|
+
const withEx = rep.rules.filter((r) => r.examples.length);
|
|
257
|
+
if (withEx.length) { L.push('', '## Evidence', ''); for (const r of withEx) { L.push(`### ${r.text}`, '', `\`${r.kind}\` · ${r.broken} of ${r.occasions} occasions broken across ${r.sessionsBroken} session${r.sessionsBroken === 1 ? '' : 's'}${r.note ? ` · ${r.note}` : ''}`, ''); for (const e of r.examples) L.push(`- \`${e.session.slice(0, 8)}\` turn ${e.turn == null ? '?' : e.turn} — prompt: "${e.prompt}" — ${e.detail.replace(/\|/g, '\\|')}`); L.push(''); } }
|
|
258
|
+
if (rep.unchecked.length) { L.push('## Not checkable yet', '', 'No mechanical test exists for these shapes; they are loaded into every session all the same.', ''); for (const u of rep.unchecked) L.push(`- ${u.text} <sub>${u.file}:${u.line}</sub>`); L.push(''); }
|
|
259
|
+
L.push('## How to read this', '', 'Every number comes from tool calls in the transcripts, judged per occasion (a commit, a write, a command). "Asked" means the turn\'s own prompt named the action, an AskUserQuestion ran first, or the previous turn ended with a question — a standing instruction from an earlier turn counts as unasked. "Ran the tests" means a test command ran, not that it passed. A rule that never came up is not an obeyed rule. `glassbox adhere --format json` has every occasion.', '');
|
|
260
|
+
return L.join('\n');
|
|
261
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn } from 'node:child_process';
|
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { readTextFile } from './textfile.mjs';
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
const core = require('./trace-core.js');
|
|
11
12
|
// fileURLToPath, not URL.pathname: on Windows the latter gives "/C:/…" which path.resolve turns into "\C:\…".
|
|
@@ -89,7 +90,7 @@ export function analyse(files, rates) { const trace = core.parseTrace(files); co
|
|
|
89
90
|
// Entries override or extend the built-in card; cache-write rates default to 1.25× / 2× input.
|
|
90
91
|
export function loadRates(file) {
|
|
91
92
|
if (!file) return null;
|
|
92
|
-
let json; try { json = JSON.parse(
|
|
93
|
+
let json; try { json = JSON.parse(readTextFile(String(file))); } catch (e) { throw new Error(`--rates ${file}: ${e.code === 'ENOENT' ? 'no such file' : 'not valid JSON'}`); }
|
|
93
94
|
if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error(`--rates ${file}: expected an object keyed by model id`);
|
|
94
95
|
const out = Object.assign({}, core.RATES);
|
|
95
96
|
for (const [model, r] of Object.entries(json)) {
|
|
@@ -136,7 +137,7 @@ export class Legend {
|
|
|
136
137
|
constructor(data) { this.salt = data && data.salt || crypto.randomBytes(32).toString('hex'); this.files = data && data.files || {}; this.created = data && data.created || new Date().toISOString(); this.byPath = new Map(Object.entries(this.files).map(([k, v]) => [core.normalisePath(v), k])); this.dirty = !data; }
|
|
137
138
|
static load(file) {
|
|
138
139
|
if (!file || !fs.existsSync(file)) return new Legend(null);
|
|
139
|
-
let json; try { json = JSON.parse(
|
|
140
|
+
let json; try { json = JSON.parse(readTextFile(file)); } catch (e) { throw new Error(`--legend ${file}: not valid JSON (delete it to start a new legend)`); }
|
|
140
141
|
if (!json || typeof json.salt !== 'string' || typeof json.files !== 'object') throw new Error(`--legend ${file}: not a Glassbox legend`);
|
|
141
142
|
return new Legend(json);
|
|
142
143
|
}
|
|
@@ -293,7 +294,7 @@ export function settingsPath(home) { return path.join(home || claudeHome(), 'set
|
|
|
293
294
|
export function installHook(opts = {}) {
|
|
294
295
|
const file = settingsPath(opts.home);
|
|
295
296
|
let settings = {};
|
|
296
|
-
if (fs.existsSync(file)) { try { settings = JSON.parse(
|
|
297
|
+
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'); }
|
|
297
298
|
settings.hooks = settings.hooks || {};
|
|
298
299
|
const cmd = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.context ? ' --context' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
|
|
299
300
|
for (const ev of opts.events || (opts.context ? ['Stop', 'SessionStart'] : ['Stop'])) {
|
|
@@ -309,7 +310,7 @@ export function installHook(opts = {}) {
|
|
|
309
310
|
export function uninstallHook(opts = {}) {
|
|
310
311
|
const file = settingsPath(opts.home);
|
|
311
312
|
if (!fs.existsSync(file)) return { file, removed: 0 };
|
|
312
|
-
const settings = JSON.parse(
|
|
313
|
+
const settings = JSON.parse(readTextFile(file));
|
|
313
314
|
let removed = 0;
|
|
314
315
|
for (const ev of Object.keys(settings.hooks || {})) {
|
|
315
316
|
const list = settings.hooks[ev]; if (!Array.isArray(list)) continue;
|
|
@@ -324,7 +325,7 @@ export function parseArgs(argv) {
|
|
|
324
325
|
const args = { _: [], flags: {} };
|
|
325
326
|
for (let i = 0; i < argv.length; i++) {
|
|
326
327
|
const a = argv[i];
|
|
327
|
-
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; }
|
|
328
|
+
if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend', 'claude-md', 'fail-under'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
|
|
328
329
|
else args._.push(a);
|
|
329
330
|
}
|
|
330
331
|
return args;
|
|
@@ -362,6 +363,15 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
|
|
|
362
363
|
check --all --redact --legend audit.legend.json --format json > <share>/<name>.json
|
|
363
364
|
(unredacted reports are skipped unless --allow-unredacted)
|
|
364
365
|
glassbox clean delete what open and the hook left in the temp folder (viewer files, hook state)
|
|
366
|
+
glassbox adhere [--project DIR] [--claude-md FILE] [--since 30d] [--format text|md|json] [--out FILE] [--redact] [--fail-under N]
|
|
367
|
+
is my CLAUDE.md doing anything? every rule in the project's instruction files judged
|
|
368
|
+
against every session of that project: obeyed / broken per occasion, with evidence;
|
|
369
|
+
shapes it cannot check are listed as such. --fail-under 80 exits 1 below that rate
|
|
370
|
+
glassbox fence [ID|FILE|DIR] [--since 30d] [--format text|md|json] [--out FILE] [--fail-on error|warn|info] [--shred]
|
|
371
|
+
secrets that reached a transcript: known key formats, secrets named by context,
|
|
372
|
+
credential-file reads — with a masked preview and a fingerprint, never the value.
|
|
373
|
+
no target = every session under the home; exit 1 when anything at/above --fail-on was found
|
|
374
|
+
--shred overwrites each value in place with [FENCED:<rule>:<fingerprint>] (no backup)
|
|
365
375
|
glassbox hook (what Claude Code runs: reads the hook JSON on stdin, replies on stdout)
|
|
366
376
|
|
|
367
377
|
Options --home DIR use DIR instead of ~/.claude (or set GLASSBOX_HOME)
|
|
@@ -417,7 +427,7 @@ export async function main(argv, io = {}) {
|
|
|
417
427
|
if (cmd === 'reveal') {
|
|
418
428
|
if (!args._[1]) throw new Error('reveal needs a file: glassbox reveal report.md --legend audit.legend.json');
|
|
419
429
|
if (!legendFile || !fs.existsSync(legendFile)) throw new Error('reveal needs --legend FILE (the legend written by check --redact --legend)');
|
|
420
|
-
const r = legend.reveal(
|
|
430
|
+
const r = legend.reveal(readTextFile(args._[1]));
|
|
421
431
|
out(r.text.replace(/\n$/, ''));
|
|
422
432
|
if (r.unknown) err(`reveal: ${r.unknown} key${r.unknown === 1 ? '' : 's'} not in this legend (left as they are)`);
|
|
423
433
|
return 0;
|
|
@@ -519,6 +529,31 @@ export async function main(argv, io = {}) {
|
|
|
519
529
|
out(JSON.stringify(reply));
|
|
520
530
|
return 0;
|
|
521
531
|
}
|
|
532
|
+
if (cmd === 'fence') {
|
|
533
|
+
// Secrets that reached a transcript. No target = every session under the home; an id, a file, or a folder of .jsonl.
|
|
534
|
+
const { fence, fenceText, fenceMarkdown, failedAt } = await import('./fence.mjs');
|
|
535
|
+
const since = args.flags.since ? Date.now() - parseSince(args.flags.since) : null;
|
|
536
|
+
const failOn = args.flags['fail-on'] || 'error';
|
|
537
|
+
const fmtOut = outputFormat(args.flags);
|
|
538
|
+
const rep = fence({ home, target: args._[1], since, project: args.flags.project, shred: !!args.flags.shred });
|
|
539
|
+
const failed = failedAt(rep, failOn);
|
|
540
|
+
const text = fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? fenceMarkdown(rep) : fenceText(rep);
|
|
541
|
+
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` : ''})`); }
|
|
542
|
+
else out(text.replace(/\n$/, ''));
|
|
543
|
+
return failed ? 1 : 0;
|
|
544
|
+
}
|
|
545
|
+
if (cmd === 'adhere') {
|
|
546
|
+
// Is my CLAUDE.md doing anything? Rules from the instruction files, occasions from the project's transcripts.
|
|
547
|
+
const { adhere, adhereText, adhereMarkdown } = await import('./adhere.mjs');
|
|
548
|
+
const since = args.flags.since ? Date.now() - parseSince(args.flags.since) : null;
|
|
549
|
+
const fmtOut = outputFormat(args.flags);
|
|
550
|
+
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']}")`); }
|
|
551
|
+
const rep = adhere({ home, project: args.flags.project, claudeMd: args.flags['claude-md'], since, redact: !!args.flags.redact });
|
|
552
|
+
const text = fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? adhereMarkdown(rep) : adhereText(rep);
|
|
553
|
+
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'})`); }
|
|
554
|
+
else out(text.replace(/\n$/, ''));
|
|
555
|
+
return failUnder != null && rep.summary.rate != null && rep.summary.rate * 100 < failUnder ? 1 : 0;
|
|
556
|
+
}
|
|
522
557
|
err('Unknown command: ' + cmd + '\n'); out(HELP); return 2;
|
|
523
558
|
} catch (e) { err('glassbox: ' + e.message); return 2; }
|
|
524
559
|
}
|
package/src/collect.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { createRequire } from 'node:module';
|
|
12
|
+
import { readTextFile } from './textfile.mjs';
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const core = require('./trace-core.js');
|
|
14
15
|
|
|
@@ -28,7 +29,7 @@ export function readSources(dir, opts = {}) {
|
|
|
28
29
|
const entries = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && /\.json$/i.test(e.name)).sort((a, b) => a.name.localeCompare(b.name));
|
|
29
30
|
for (const e of entries) {
|
|
30
31
|
const file = path.join(dir, e.name);
|
|
31
|
-
let doc; try { doc = JSON.parse(
|
|
32
|
+
let doc; try { doc = JSON.parse(readTextFile(file)); } catch (err) { skipped.push({ file: e.name, reason: 'not JSON' }); continue; }
|
|
32
33
|
const src = sourceFromJson(e.name.replace(/\.json$/i, ''), doc);
|
|
33
34
|
if (!src) { skipped.push({ file: e.name, reason: 'not a glassbox check report' }); continue; }
|
|
34
35
|
if (!src.redacted && !opts.allowUnredacted) { skipped.push({ file: e.name, reason: 'not redacted (run check with --redact, or pass --allow-unredacted)' }); continue; }
|
package/src/fence.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
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
|
+
// No network: nothing is verified against a provider, and the report is meant to stay on the machine.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { findSessions, subagentFiles, resolveTarget, claudeHome } from './cli.mjs';
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const core = require('./trace-core.js');
|
|
16
|
+
|
|
17
|
+
const SEV = { error: 0, warn: 1, info: 2 };
|
|
18
|
+
const PLACEHOLDER = /your[_-]?|example|sample|placeholder|changeme|change[_-]me|xxxx|dummy|redacted|fenced|<|>|\$\{|\*\*\*/i;
|
|
19
|
+
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;
|
|
20
|
+
const CRED_FILE_NOT = /\.env\.(?:example|sample|template|dist|schema)$|\.pub$/i;
|
|
21
|
+
export const isCredentialFile = (p) => !!p && CRED_FILE.test(String(p).trim()) && !CRED_FILE_NOT.test(String(p).trim());
|
|
22
|
+
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;
|
|
23
|
+
|
|
24
|
+
// Shannon entropy in bits per character.
|
|
25
|
+
export function entropy(s) { const n = s.length; if (!n) return 0; const c = new Map(); for (const ch of s) c.set(ch, (c.get(ch) || 0) + 1); let h = 0; for (const k of c.values()) { const p = k / n; h -= p * Math.log2(p); } return h; }
|
|
26
|
+
export const fingerprint = (v) => crypto.createHash('sha256').update(v).digest('hex').slice(0, 8);
|
|
27
|
+
export function mask(v) { const s = String(v); if (/-----BEGIN/.test(s)) return `PRIVATE KEY block (${s.length} chars)`; return `${s.length > 6 ? s.slice(0, 4) : ''}…${s.slice(-2)} (${s.length} chars)`; }
|
|
28
|
+
|
|
29
|
+
// Detectors, most specific first: a hit whose span overlaps an earlier hit is dropped, so a GitHub token after
|
|
30
|
+
// "Bearer" is one github-token, not also a generic-secret, and a database URL is not also a basic-auth URL.
|
|
31
|
+
// `re` must have the g flag; `value(m)` picks the secret out of the match (default: the whole match).
|
|
32
|
+
// 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
|
|
33
|
+
// an "n"; B is the word boundary that also accepts an escaped \n, \r or \t before the match.
|
|
34
|
+
const B = String.raw`(?:(?<![A-Za-z0-9_])|(?<=\\[nrt]))`;
|
|
35
|
+
const R = (src, flags = 'g') => new RegExp(B + src, flags);
|
|
36
|
+
export const DETECTORS = [
|
|
37
|
+
{ 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' },
|
|
38
|
+
{ id: 'aws-access-key', severity: 'error', re: R(String.raw`(?:AKIA|ASIA)[0-9A-Z]{16}\b`), what: 'an AWS access key id' },
|
|
39
|
+
{ 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' },
|
|
40
|
+
{ id: 'anthropic-key', severity: 'error', re: R(String.raw`sk-ant-[A-Za-z0-9_-]{20,}`), what: 'an Anthropic API key' },
|
|
41
|
+
{ id: 'openai-key', severity: 'error', re: R(String.raw`sk-(?!ant-)(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}`), what: 'an OpenAI API key' },
|
|
42
|
+
{ id: 'slack-token', severity: 'error', re: R(String.raw`xox[abprs]-[A-Za-z0-9-]{10,}`), what: 'a Slack token' },
|
|
43
|
+
{ id: 'stripe-key', severity: 'error', re: R(String.raw`[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b`), what: 'a Stripe key' },
|
|
44
|
+
{ id: 'google-api-key', severity: 'error', re: R(String.raw`AIza[0-9A-Za-z_-]{35}\b`), what: 'a Google API key' },
|
|
45
|
+
{ id: 'npm-token', severity: 'error', re: R(String.raw`npm_[A-Za-z0-9]{36}\b`), what: 'an npm token' },
|
|
46
|
+
{ 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' },
|
|
47
|
+
{ 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' },
|
|
48
|
+
{ id: 'basic-auth-url', severity: 'warn', re: R(String.raw`https?:\/\/[^\s:/@'"\\]+:[^\s@'"\\]{4,}@[^\s'"\\]+`), what: 'a URL with credentials in it' },
|
|
49
|
+
{ 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' },
|
|
50
|
+
{ 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' },
|
|
51
|
+
];
|
|
52
|
+
export const ADVICE = {
|
|
53
|
+
'private-key': 'Generate a new key pair and remove the old public key everywhere it is authorised; then shred.',
|
|
54
|
+
'aws-access-key': 'Deactivate the key in IAM and issue a new one; check CloudTrail for use; then shred.',
|
|
55
|
+
'github-token': 'Revoke it under Settings → Developer settings; then shred.',
|
|
56
|
+
'anthropic-key': 'Revoke it in the Console and issue a new one; then shred.',
|
|
57
|
+
'openai-key': 'Revoke it in the dashboard and issue a new one; then shred.',
|
|
58
|
+
'slack-token': 'Revoke the token or reinstall the app; then shred.',
|
|
59
|
+
'stripe-key': 'Roll the key in the Stripe dashboard; then shred.',
|
|
60
|
+
'google-api-key': 'Regenerate the key in Google Cloud and restrict it; then shred.',
|
|
61
|
+
'npm-token': 'Revoke it on npmjs.com (Access Tokens); then shred.',
|
|
62
|
+
'sendgrid-key': 'Delete the key in SendGrid and create a new one; then shred.',
|
|
63
|
+
'db-url-password': 'Change the database password and update the app; then shred.',
|
|
64
|
+
'basic-auth-url': 'Change the password behind the URL; prefer a credential helper over a URL with the password in it; then shred.',
|
|
65
|
+
'jwt': 'Short-lived by design, but treat as live until it expires; shred.',
|
|
66
|
+
'generic-secret': 'Confirm what it is, rotate it, then shred. If it is not a secret, nothing to do.',
|
|
67
|
+
'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.',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// A line-start index so a match offset maps to a 1-based line number by binary search.
|
|
71
|
+
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; }
|
|
72
|
+
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; }
|
|
73
|
+
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$/, ''); }
|
|
74
|
+
|
|
75
|
+
// Where a hit sits: the record type and, for tool traffic, the tool (and the path for a Read).
|
|
76
|
+
function locate(rec, tools) {
|
|
77
|
+
if (!rec || typeof rec !== 'object') return { where: 'other' };
|
|
78
|
+
const m = rec.message;
|
|
79
|
+
if (rec.type === 'assistant' && m && Array.isArray(m.content)) {
|
|
80
|
+
const tu = m.content.find((b) => b && b.type === 'tool_use');
|
|
81
|
+
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 }; }
|
|
82
|
+
return { where: 'assistant text' };
|
|
83
|
+
}
|
|
84
|
+
if (rec.type === 'user' && m) {
|
|
85
|
+
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 }; } }
|
|
86
|
+
return { where: rec.isMeta || rec.isCompactSummary ? 'system' : 'user prompt' };
|
|
87
|
+
}
|
|
88
|
+
return { where: rec.type || 'other' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Credential-file reads: a Read of a credential file, or a Bash command that dumps one.
|
|
92
|
+
function fileReadHit(rec) {
|
|
93
|
+
if (!rec || rec.type !== 'assistant' || !rec.message || !Array.isArray(rec.message.content)) return null;
|
|
94
|
+
const tu = rec.message.content.find((b) => b && b.type === 'tool_use'); if (!tu || !tu.input) return null;
|
|
95
|
+
const p = core.filePathOf(tu);
|
|
96
|
+
if (p && /^(Read|View|Cat|NotebookRead)$/i.test(tu.name) && isCredentialFile(p)) return { tool: tu.name, path: String(p) };
|
|
97
|
+
const cmd = typeof tu.input.command === 'string' ? tu.input.command : null;
|
|
98
|
+
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) }; }
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Scan raw transcript text. Returns hits in file order: { rule, severity, line, start, end, value, preview, fingerprint, where, tool?, path? }.
|
|
103
|
+
export function scanText(text) {
|
|
104
|
+
const starts = lineIndex(text);
|
|
105
|
+
const hits = [];
|
|
106
|
+
const spans = []; // [start, end] of accepted hits, for overlap suppression
|
|
107
|
+
const overlaps = (a, b) => spans.some(([x, y]) => a < y && b > x);
|
|
108
|
+
for (const d of DETECTORS) {
|
|
109
|
+
d.re.lastIndex = 0; let m;
|
|
110
|
+
while ((m = d.re.exec(text))) {
|
|
111
|
+
const value = d.value ? d.value(m) : m[0];
|
|
112
|
+
const start = d.value ? m.index + m[0].length - value.length : m.index, end = start + value.length;
|
|
113
|
+
if (d.ok && !d.ok(value)) continue;
|
|
114
|
+
if (overlaps(start, end)) continue;
|
|
115
|
+
spans.push([start, end]);
|
|
116
|
+
hits.push({ rule: d.id, severity: d.severity, start, end, value, preview: mask(value), fingerprint: fingerprint(value), line: lineAt(starts, start) });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
hits.sort((a, b) => a.start - b.start);
|
|
120
|
+
// Attribution needs the record; parse each line once, and walk every line for tool_use ids (results come after calls)
|
|
121
|
+
// and for credential-file reads.
|
|
122
|
+
const tools = new Map(); const parsed = new Map(); const fileReads = [];
|
|
123
|
+
const hitLines = new Set(hits.map((h) => h.line));
|
|
124
|
+
for (let n = 1; n <= starts.length; n++) {
|
|
125
|
+
const raw = lineText(text, starts, n); if (!raw.trim()) continue;
|
|
126
|
+
let rec = null; try { rec = JSON.parse(raw); } catch (e) { continue; }
|
|
127
|
+
const fr = fileReadHit(rec);
|
|
128
|
+
if (fr) fileReads.push({ rule: 'credential-file-read', severity: 'info', line: n, start: starts[n - 1], end: starts[n - 1], value: null, preview: fr.path, fingerprint: fingerprint(fr.path), where: 'tool input', tool: fr.tool, path: fr.path });
|
|
129
|
+
const loc = locate(rec, tools);
|
|
130
|
+
if (hitLines.has(n)) parsed.set(n, loc);
|
|
131
|
+
}
|
|
132
|
+
for (const h of hits) Object.assign(h, parsed.get(h.line) || { where: 'other' });
|
|
133
|
+
return hits.concat(fileReads).sort((a, b) => a.start - b.start || SEV[a.severity] - SEV[b.severity]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Same value in the same place collapses to one finding with a count. Values are kept on the object (for shred)
|
|
137
|
+
// but never serialised: `toJSON` drops them.
|
|
138
|
+
function rows(hits) {
|
|
139
|
+
const map = new Map();
|
|
140
|
+
for (const h of hits) {
|
|
141
|
+
const k = [h.rule, h.fingerprint, h.where, h.tool || '', h.path || ''].join('|');
|
|
142
|
+
if (map.has(k)) { const r = map.get(k); r.count++; r.spans.push([h.start, h.end]); continue; }
|
|
143
|
+
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 }, {
|
|
144
|
+
value: { value: h.value, enumerable: false }, spans: { value: h.value == null ? [] : [[h.start, h.end]], enumerable: false },
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
return [...map.values()];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function scanFile(file, meta = {}) {
|
|
151
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
152
|
+
const findings = rows(scanText(text)).map((r) => Object.assign(r, { file, session: meta.session || path.basename(file).replace(/\.jsonl$/, ''), project: meta.project || undefined }));
|
|
153
|
+
return { file, bytes: Buffer.byteLength(text), lines: text.split('\n').length, findings, text };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Rewrite every hit in place. `replacer(finding)` gives the replacement text; the default contains no quote,
|
|
157
|
+
// backslash or control character so a JSON string stays a JSON string. Every changed line is parsed again before
|
|
158
|
+
// anything is written; on failure the file is left as it was.
|
|
159
|
+
export function shredFile(file, findings, opts = {}) {
|
|
160
|
+
const replacer = opts.replacer || ((f) => `[FENCED:${f.rule}:${f.fingerprint}]`);
|
|
161
|
+
const text = opts.text != null ? opts.text : fs.readFileSync(file, 'utf8');
|
|
162
|
+
const spans = [];
|
|
163
|
+
for (const f of findings) for (const [s, e] of (f.spans || [])) spans.push({ s, e, rep: replacer(f) });
|
|
164
|
+
if (!spans.length) return { written: false, values: 0, reason: 'nothing to shred' };
|
|
165
|
+
spans.sort((a, b) => a.s - b.s);
|
|
166
|
+
let out = '', pos = 0;
|
|
167
|
+
for (const sp of spans) { if (sp.s < pos) continue; out += text.slice(pos, sp.s) + sp.rep; pos = sp.e; }
|
|
168
|
+
out += text.slice(pos);
|
|
169
|
+
const before = text.split('\n'), after = out.split('\n');
|
|
170
|
+
if (before.length !== after.length) return { written: false, values: spans.length, reason: 'line count changed; nothing written' };
|
|
171
|
+
for (let i = 0; i < after.length; i++) { if (after[i] === before[i]) continue; const l = after[i].replace(/\r$/, ''); if (!l.trim()) continue; try { JSON.parse(l); } catch (e) { return { written: false, values: spans.length, reason: `line ${i + 1} would not parse after the rewrite; nothing written` }; } }
|
|
172
|
+
const tmp = file + '.glassbox-shred~';
|
|
173
|
+
fs.writeFileSync(tmp, out); fs.renameSync(tmp, file);
|
|
174
|
+
return { written: true, values: spans.length };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Which files to scan. opts: { home, target, since (ms epoch), project, shred }.
|
|
178
|
+
export function targets(opts = {}) {
|
|
179
|
+
const home = opts.home || claudeHome();
|
|
180
|
+
const list = [];
|
|
181
|
+
const add = (file, session, project) => list.push({ file, session, project });
|
|
182
|
+
const addSession = (s) => { add(s.file, s.id, s.project); for (const f of subagentFiles(s)) add(f.path, s.id, s.project); };
|
|
183
|
+
const t = opts.target;
|
|
184
|
+
if (!t) { for (const s of findSessions({ home, project: opts.project })) if (opts.since == null || s.mtime >= opts.since) addSession(s); return { list, mode: 'home', home }; }
|
|
185
|
+
if (fs.existsSync(t) && fs.statSync(t).isDirectory()) {
|
|
186
|
+
const walk = (dir) => { for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { const p = path.join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.isFile() && /\.jsonl$/i.test(e.name) && e.name !== 'journal.jsonl' && (opts.since == null || fs.statSync(p).mtimeMs >= opts.since)) add(p, e.name.replace(/\.jsonl$/i, ''), null); } };
|
|
187
|
+
walk(path.resolve(t)); return { list, mode: 'dir', dir: path.resolve(t) };
|
|
188
|
+
}
|
|
189
|
+
const s = resolveTarget(t, { home }); addSession(s); return { list, mode: s.project == null ? 'file' : 'session', home };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The report. opts as `targets` plus { shred }.
|
|
193
|
+
export function fence(opts = {}) {
|
|
194
|
+
const tg = targets(opts);
|
|
195
|
+
const files = [], findings = []; let bytes = 0; const sessions = new Set();
|
|
196
|
+
const shredded = { files: 0, values: 0, refused: [] };
|
|
197
|
+
for (const t of tg.list) {
|
|
198
|
+
let r; try { r = scanFile(t.file, { session: t.session, project: t.project }); } catch (e) { shredded.refused.push({ file: t.file, reason: e.message }); continue; }
|
|
199
|
+
files.push({ file: t.file, session: t.session, bytes: r.bytes, findings: r.findings.length });
|
|
200
|
+
bytes += r.bytes; sessions.add(t.session);
|
|
201
|
+
findings.push(...r.findings);
|
|
202
|
+
if (opts.shred) { const s = shredFile(t.file, r.findings, { text: r.text }); if (s.written) { shredded.files++; shredded.values += s.values; } else if (s.reason !== 'nothing to shred') shredded.refused.push({ file: t.file, reason: s.reason }); }
|
|
203
|
+
}
|
|
204
|
+
findings.sort((a, b) => SEV[a.severity] - SEV[b.severity] || a.file.localeCompare(b.file) || a.line - b.line);
|
|
205
|
+
const bySeverity = { error: 0, warn: 0, info: 0 }; for (const f of findings) bySeverity[f.severity]++;
|
|
206
|
+
const byRule = {}; for (const f of findings) byRule[f.rule] = (byRule[f.rule] || 0) + f.count;
|
|
207
|
+
const secretMap = new Map();
|
|
208
|
+
for (const f of findings) { if (f.rule === 'credential-file-read') continue; const k = f.fingerprint; if (!secretMap.has(k)) secretMap.set(k, { fingerprint: k, rule: f.rule, severity: f.severity, preview: f.preview, sessions: new Set(), files: new Set(), occurrences: 0, advice: ADVICE[f.rule] }); const s = secretMap.get(k); s.sessions.add(f.session); s.files.add(f.file); s.occurrences += f.count; }
|
|
209
|
+
const secrets = [...secretMap.values()].map((s) => Object.assign(s, { sessions: s.sessions.size, files: s.files.size })).sort((a, b) => SEV[a.severity] - SEV[b.severity] || b.sessions - a.sessions || b.occurrences - a.occurrences);
|
|
210
|
+
const rep = { glassbox: core.VERSION, kind: 'fence', schema: 1, generated: new Date().toISOString(), mode: tg.mode, home: tg.home, dir: tg.dir, since: opts.since ? new Date(opts.since).toISOString() : undefined,
|
|
211
|
+
scanned: { files: files.length, sessions: sessions.size, bytes },
|
|
212
|
+
summary: { bySeverity, byRule, filesWithFindings: files.filter((f) => f.findings).length, distinctSecrets: secrets.length, secrets },
|
|
213
|
+
findings, files };
|
|
214
|
+
if (opts.shred) rep.shredded = shredded;
|
|
215
|
+
return rep;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function failedAt(rep, failOn = 'error') { if (!(failOn in SEV)) throw new Error(`--fail-on must be error, warn or info (got "${failOn}")`); return rep.findings.some((f) => SEV[f.severity] <= SEV[failOn]); }
|
|
219
|
+
|
|
220
|
+
const fmtBytes = (n) => n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' KB' : n + ' B';
|
|
221
|
+
const place = (f) => `${f.where}${f.tool ? ' · ' + f.tool : ''}${f.path && f.rule !== 'credential-file-read' ? ' ' + f.path : ''}`;
|
|
222
|
+
|
|
223
|
+
export function fenceText(rep) {
|
|
224
|
+
const L = [];
|
|
225
|
+
const what = rep.mode === 'home' ? `${rep.scanned.sessions} session${rep.scanned.sessions === 1 ? '' : 's'} under ${path.join(rep.home, 'projects')}` : rep.mode === 'dir' ? `${rep.scanned.files} file${rep.scanned.files === 1 ? '' : 's'} under ${rep.dir}` : `${rep.scanned.files} file${rep.scanned.files === 1 ? '' : 's'}`;
|
|
226
|
+
const s = rep.summary.bySeverity;
|
|
227
|
+
L.push(`Glassbox fence · ${what} · ${fmtBytes(rep.scanned.bytes)}${rep.since ? ` · since ${rep.since.slice(0, 10)}` : ''}`);
|
|
228
|
+
if (!rep.findings.length) { L.push(' nothing found — no known credential formats, no secrets named by context, no credential-file reads'); }
|
|
229
|
+
else {
|
|
230
|
+
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`, '');
|
|
231
|
+
let last = null;
|
|
232
|
+
for (const f of rep.findings) {
|
|
233
|
+
if (f.file !== last) { L.push(` ${f.file}${f.session && !f.file.includes(f.session) ? ` (session ${f.session.slice(0, 8)})` : ''}`); last = f.file; }
|
|
234
|
+
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)}`);
|
|
235
|
+
}
|
|
236
|
+
L.push('', ' Secrets, by how far they spread:');
|
|
237
|
+
for (const x of rep.summary.secrets.slice(0, 20)) L.push(` ${x.severity.toUpperCase().padEnd(5)} ${x.rule.padEnd(21)} ${x.fingerprint} ${x.preview} in ${x.sessions} session${x.sessions === 1 ? '' : 's'}, ${x.occurrences} place${x.occurrences === 1 ? '' : 's'}`);
|
|
238
|
+
L.push('', ' A secret that reached a transcript reached a disk: rotate it, then run again with --shred to overwrite it in place.');
|
|
239
|
+
}
|
|
240
|
+
if (rep.shredded) { L.push('', ` Shredded ${rep.shredded.values} value${rep.shredded.values === 1 ? '' : 's'} in ${rep.shredded.files} file${rep.shredded.files === 1 ? '' : 's'} (each is now [FENCED:<rule>:<fingerprint>]; no backup was kept).`); for (const r of rep.shredded.refused) L.push(` refused ${r.file}: ${r.reason}`); }
|
|
241
|
+
return L.join('\n') + '\n';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function fenceMarkdown(rep) {
|
|
245
|
+
const L = [`# Glassbox fence — ${rep.generated.slice(0, 10)}`, '', `Scanned ${rep.scanned.files} file${rep.scanned.files === 1 ? '' : 's'} (${rep.scanned.sessions} session${rep.scanned.sessions === 1 ? '' : 's'}, ${fmtBytes(rep.scanned.bytes)})${rep.since ? ` written since ${rep.since.slice(0, 10)}` : ''}. Previews are masked and fingerprints are the first 8 hex of SHA-256; this report holds no secret values.`, ''];
|
|
246
|
+
const s = rep.summary.bySeverity;
|
|
247
|
+
if (!rep.findings.length) { L.push('**Nothing found.** No known credential formats, no secrets named by context, no credential-file reads.'); return L.join('\n') + '\n'; }
|
|
248
|
+
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.`, '');
|
|
249
|
+
L.push('## Secrets, by how far they spread', '', '| severity | rule | fingerprint | preview | sessions | places | do |', '|---|---|---|---|---:|---:|---|');
|
|
250
|
+
for (const x of rep.summary.secrets) L.push(`| ${x.severity} | \`${x.rule}\` | \`${x.fingerprint}\` | ${x.preview} | ${x.sessions} | ${x.occurrences} | ${x.advice} |`);
|
|
251
|
+
L.push('', '## Where, file by file', '');
|
|
252
|
+
const byFile = new Map(); for (const f of rep.findings) { if (!byFile.has(f.file)) byFile.set(f.file, []); byFile.get(f.file).push(f); }
|
|
253
|
+
for (const [file, list] of byFile) {
|
|
254
|
+
L.push(`### ${file}`, '', `session \`${list[0].session}\`${list[0].project ? ` · project ${list[0].project}` : ''}`, '', '| rule | severity | line | preview | where |', '|---|---|---:|---|---|');
|
|
255
|
+
for (const f of list) L.push(`| \`${f.rule}\` | ${f.severity} | ${f.line} | ${f.preview}${f.count > 1 ? ` ×${f.count}` : ''} | ${place(f)} |`);
|
|
256
|
+
L.push('');
|
|
257
|
+
}
|
|
258
|
+
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.', '');
|
|
259
|
+
if (rep.shredded) { L.push(`Shredded ${rep.shredded.values} value${rep.shredded.values === 1 ? '' : 's'} in ${rep.shredded.files} file${rep.shredded.files === 1 ? '' : 's'}; no backup was kept.`); for (const r of rep.shredded.refused) L.push(`- refused ${r.file}: ${r.reason}`); L.push(''); }
|
|
260
|
+
return L.join('\n');
|
|
261
|
+
}
|
package/src/textfile.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Text files a person may have saved on Windows. PowerShell 5.1's `>` writes UTF-16LE with a byte-order
|
|
2
|
+
// mark; `Set-Content -Encoding UTF8` and older Notepad write a UTF-8 BOM. Read all of them as the same text,
|
|
3
|
+
// so a report, rate card or legend works however it was saved. Transcripts go through parseLines, which
|
|
4
|
+
// already drops a UTF-8 BOM; everything a user hands to the CLI goes through here.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
|
|
7
|
+
export function decodeText(input) {
|
|
8
|
+
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
9
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) return buf.subarray(2).toString('utf16le');
|
|
10
|
+
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) { const le = Buffer.from(buf.subarray(2)); le.swap16(); return le.toString('utf16le'); }
|
|
11
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return buf.subarray(3).toString('utf8');
|
|
12
|
+
return buf.toString('utf8');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readTextFile(file) {
|
|
16
|
+
return decodeText(fs.readFileSync(file));
|
|
17
|
+
}
|
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.0';
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Rate card (USD per million tokens). Prefix-matched against model ids so dated
|