skalpel 4.0.18 → 4.0.20

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/install.mjs CHANGED
@@ -60,6 +60,8 @@ function stageHookRuntime() {
60
60
  copyFileSync(join(PKG_DIR, "incremental-ingest.mjs"), join(HOOKS_DIR, "incremental-ingest.mjs"));
61
61
  copyFileSync(join(PKG_DIR, "skalpel-hook-session-end.mjs"), SESSION_END_FILE);
62
62
  copyFileSync(join(PKG_DIR, "skalpel-statusline.mjs"), STATUSLINE_FILE);
63
+ copyFileSync(join(PKG_DIR, "transcript.mjs"), join(HOOKS_DIR, "transcript.mjs")); // hooks import ./transcript.mjs (bounded tail read)
64
+ copyFileSync(join(PKG_DIR, "verify-shadow.mjs"), join(HOOKS_DIR, "verify-shadow.mjs")); // claim-verification SHADOW worker (spawned by skalpel-hook.mjs)
63
65
  copyFileSync(join(PKG_DIR, "auth.mjs"), join(HOOKS_DIR, "auth.mjs")); // hooks import ./auth.mjs
64
66
  copyFileSync(join(PKG_DIR, "metrics.mjs"), join(HOOKS_DIR, "metrics.mjs")); // hooks import ./metrics.mjs
65
67
  copyFileSync(join(PKG_DIR, "stats.mjs"), join(HOOKS_DIR, "stats.mjs")); // `node ~/.skalpel/hooks/stats.mjs`
@@ -236,8 +238,17 @@ function tomlMark(event, marker) {
236
238
  return `${TOML_MARK} ${event} ${marker}`;
237
239
  }
238
240
 
241
+ // Serialize a value into a TOML basic string. The wired command is `node "<path>"` — it ALWAYS
242
+ // contains double quotes, and on Windows the path is `C:\Users\First Last\...` full of backslashes.
243
+ // A raw `command = "${command}"` embeds those quotes unescaped (the string closes early) AND leaves
244
+ // backslashes as TOML escape sequences (`\U`, `\F`, `\.` are invalid) — either way the WHOLE
245
+ // config.toml fails to parse, so Codex drops all of the user's hooks. Escape `\` first, then `"`.
246
+ function tomlString(value) {
247
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
248
+ }
249
+
239
250
  function tomlBlock(event, marker, command) {
240
- return `\n${tomlMark(event, marker)}\n[[hooks.${event}]]\n[[hooks.${event}.hooks]]\ntype = "command"\ncommand = "${command}"\ntimeout = 8\n`;
251
+ return `\n${tomlMark(event, marker)}\n[[hooks.${event}]]\n[[hooks.${event}.hooks]]\ntype = "command"\ncommand = ${tomlString(command)}\ntimeout = 8\n`;
241
252
  }
242
253
 
243
254
  function stripManagedTomlBlocks(txt) {
@@ -451,6 +462,9 @@ function cleanupLocalData() {
451
462
  "client.json",
452
463
  "doctor.log",
453
464
  "hook.log",
465
+ "verify-shadow.log",
466
+ "verify-last.json",
467
+ "verify-shadow.lock",
454
468
  ]) {
455
469
  rm(join(SKALPEL_DIR, f), f);
456
470
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.18",
3
+ "version": "4.0.20",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
package/skalpel-hook.mjs CHANGED
@@ -418,6 +418,32 @@ async function main() {
418
418
  const fromCodex = process.env.CODEX_HOME || payload.transcript_path?.includes(".codex");
419
419
  const harness = fromClaude ? "claude" : fromCodex ? "codex" : null;
420
420
 
421
+ // CLAIM-VERIFICATION SHADOW (v0, DARK behind SKALPEL_VERIFY_SHADOW=1). When the agent's LAST turn
422
+ // CLAIMED success ("done / tests pass / fixed"), a DETACHED worker reconstructs the session's OWN most
423
+ // recent test/build/typecheck/lint command and RE-RUNs it out-of-band to log the real PASS/FAIL the
424
+ // agent can't forge. LOG ONLY — it writes nothing to this hook's stdout and never blocks the turn (the
425
+ // re-run, up to 60s, lives in a separate unref'd process). Wrapped so it can never affect the injection.
426
+ if (process.env.SKALPEL_VERIFY_SHADOW === "1" && payload.transcript_path) {
427
+ try {
428
+ const { spawn } = await import("node:child_process");
429
+ const { fileURLToPath } = await import("node:url");
430
+ const worker = fileURLToPath(new URL("./verify-shadow.mjs", import.meta.url));
431
+ const child = spawn(process.execPath, [worker, "--run"], {
432
+ detached: true,
433
+ stdio: "ignore",
434
+ env: {
435
+ ...process.env,
436
+ SKALPEL_VERIFY_TRANSCRIPT: payload.transcript_path,
437
+ SKALPEL_VERIFY_SESSION: String(sessionId || ""),
438
+ SKALPEL_VERIFY_CWD: payload.cwd || process.cwd(),
439
+ },
440
+ });
441
+ child.unref();
442
+ } catch {
443
+ /* the shadow is a bonus; a spawn failure must never touch the hook's output */
444
+ }
445
+ }
446
+
421
447
  if (!isRealPrompt(query)) {
422
448
  dbg(" -> skipped (not a real prompt)");
423
449
  record("prompt", "skipped", null);
package/skalpel-setup.mjs CHANGED
@@ -51,7 +51,15 @@ const isMain =
51
51
  // you in and wired hooks into Claude Code and Codex, having been asked only to print a version.
52
52
  // Unknown input must never install. Guarded on isMain so importing this module (install.test.mjs
53
53
  // pulls in userTurnCount) can never exit the test runner.
54
- const KNOWN_SUBS = new Set(["setup", "login", "logout", "uninstall", "autopsy", "__build"]);
54
+ const KNOWN_SUBS = new Set([
55
+ "setup",
56
+ "login",
57
+ "logout",
58
+ "uninstall",
59
+ "autopsy",
60
+ "__build",
61
+ "__verify-report",
62
+ ]);
55
63
  const VALUE_FLAGS = new Set(["--api", "--user"]);
56
64
  // Boolean flags that take no value. `--purge` on `uninstall` also removes auth + all local data.
57
65
  const BOOL_FLAGS = new Set(["--purge"]);
@@ -858,6 +866,14 @@ async function main() {
858
866
  spawnSync("node", [join(__dir, "autopsy.mjs"), ...argv.slice(1)], { stdio: "inherit" });
859
867
  return;
860
868
  }
869
+ // `skalpel __verify-report` — the claim-verification SHADOW instrument: reads the local
870
+ // verify-shadow.log and prints the three numbers (claim fire-rate, proof-reconstruction rate, and the
871
+ // headline MISMATCH rate = how often the agent claimed done but its OWN proof, re-run, failed).
872
+ // Local, read-only; never signs in, never touches the network.
873
+ if (sub === "__verify-report") {
874
+ spawnSync("node", [join(__dir, "verify-shadow.mjs"), "--report"], { stdio: "inherit" });
875
+ return;
876
+ }
861
877
  // Persist --api/--user to ~/.skalpel/client.json so the hooks can read them without shell-env
862
878
  // plumbing (env vars SKALPEL_API / SKALPEL_USER still win at hook runtime).
863
879
  if (apiFlag || userFlag) {
@@ -0,0 +1,804 @@
1
+ #!/usr/bin/env node
2
+ // verify-shadow.mjs — SHADOW claim-verification (v0, DARK by default behind SKALPEL_VERIFY_SHADOW=1).
3
+ //
4
+ // THE PROBLEM: your agent is both the executor AND the reporter of its own work, so when it says
5
+ // "done / tests pass / fixed / it works" nothing independent ever re-checks. skalpel closes that loop:
6
+ // when the agent's last turn CLAIMS success, we reconstruct the PROOF the agent itself ran (its most
7
+ // recent test/build/typecheck/lint command, pulled from the session's OWN prior Bash tool calls — never
8
+ // invented), RE-RUN it out-of-band, and record the real PASS/FAIL the agent cannot forge. The money
9
+ // metric is the MISMATCH rate: how often the agent claimed success but its own proof, re-run, FAILS.
10
+ //
11
+ // SHADOW: this LOGS ONLY. It never injects into the model, never blocks or slows a turn, never writes
12
+ // or deploys anything. The per-turn hook launches it DETACHED (see skalpel-hook.mjs) so the user's turn
13
+ // is never delayed by the (up to 60s) re-run.
14
+ //
15
+ // HARD RED LINES (enforced by construction below):
16
+ // 1. ALLOWLIST ONLY. Only test/build/typecheck/lint binaries re-run (npm/pnpm/yarn test|build|run,
17
+ // npx tsc|jest|vitest|eslint|biome, pytest, cargo test|build|check|clippy, go test|build|vet,
18
+ // tsc, jest, vitest, eslint, biome, ruff, mypy, pyright, python -m <allowlisted>). Anything else
19
+ // is REFUSED. The command never comes from prompt/claim CONTENT — only from the session's own
20
+ // prior Bash tool_use commands.
21
+ // 2. execFile, NEVER a shell. Args are passed as a literal ARGUMENT ARRAY; there is no shell to
22
+ // interpret metacharacters, so a crafted string can never be executed as a command. shell:false.
23
+ // 3. HARD TIMEOUT (~60s), bounded output, single-flight lock. FAIL-OPEN: any error → log-or-skip,
24
+ // never throw, never affect the hook.
25
+ // 4. LOCAL ONLY. Imports only node: core; opens no socket; logs to ~/.skalpel/verify-shadow.log.
26
+ // 5. NO FABRICATED NUMBERS. pass_fail is the real process exit code; evidence is the real tail output.
27
+ import {
28
+ appendFileSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ writeFileSync,
32
+ renameSync,
33
+ statSync,
34
+ rmSync,
35
+ } from "node:fs";
36
+ import { homedir } from "node:os";
37
+ import { join } from "node:path";
38
+ import path from "node:path";
39
+ import { execFile } from "node:child_process";
40
+ import { fileURLToPath } from "node:url";
41
+ import { realpathSync } from "node:fs";
42
+ import { tailLines } from "./transcript.mjs";
43
+
44
+ const DIR = join(homedir(), ".skalpel");
45
+ export const VERIFY_LOG_PATH = join(DIR, "verify-shadow.log");
46
+ const LAST_PATH = join(DIR, "verify-last.json");
47
+ const LOCK_PATH = join(DIR, "verify-shadow.lock");
48
+
49
+ // Re-run budget + read caps. The re-run is out-of-band (detached), so it is NOT on the hook deadline —
50
+ // but it still gets a hard ceiling so a hung test process can never live forever.
51
+ const RERUN_TIMEOUT_MS = 60_000;
52
+ const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
53
+ // The proof command lives near the end of the transcript (the agent runs tests, then claims done). A
54
+ // generous tail is fine here (off the hot path); still bounded so a multi-GB transcript can't OOM us.
55
+ const TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
56
+ const LOCK_STALE_MS = 90_000;
57
+
58
+ // ======================= (1) completion-claim detection =======================
59
+ // A deliberately broad lexicon: this is an INSTRUMENT, and the MISMATCH metric is computed only among
60
+ // turns where a proof actually re-ran, so a loose claim match cannot fabricate a "lie" — it can only
61
+ // trigger an honest re-run that PASSES. Recall over precision here is the right trade.
62
+ const CLAIM_RE = new RegExp(
63
+ [
64
+ "\\b(all\\s+)?tests?\\s+(are\\s+)?(now\\s+)?(pass|passing|passed|passes|green)\\b",
65
+ "\\ball\\s+green\\b",
66
+ "\\bbuild\\s+(passes|passed|succeeds|succeeded|is\\s+green|works|clean)\\b",
67
+ "\\bbuilds?\\s+(successfully|clean(ly)?)\\b",
68
+ "\\btypechecks?\\s+(pass(es|ed)?|clean(ly)?)\\b",
69
+ "\\b(it|that|this|everything)\\s+should\\s+work\\s+now\\b",
70
+ "\\bworks\\s+now\\b",
71
+ "\\b(this|that)\\s+should\\s+(fix|resolve|do\\s+it)\\b",
72
+ "\\b(i('|’)ve|i\\s+have)\\s+(fixed|implemented|resolved|completed|added|finished)\\b",
73
+ "\\b(now\\s+)?(fixed|implemented|resolved|completed|merged|verified)\\b",
74
+ "\\b(all\\s+)?done\\b",
75
+ "\\ball\\s+set\\b",
76
+ "\\bgood\\s+to\\s+go\\b",
77
+ "\\bready\\s+(to\\s+(go|merge|ship|review)|now)\\b",
78
+ "\\blgtm\\b",
79
+ "\\bpasses?\\s+(now|cleanly)\\b",
80
+ ].join("|"),
81
+ "i",
82
+ );
83
+
84
+ // detectCompletionClaim(text) -> { claim:bool, text:<the claim line, <=120c> }
85
+ export function detectCompletionClaim(text) {
86
+ const s = typeof text === "string" ? text : "";
87
+ if (!s.trim()) return { claim: false, text: "" };
88
+ if (!CLAIM_RE.test(s)) return { claim: false, text: "" };
89
+ // Prefer the specific line that carries the claim (nicer log evidence than the whole turn).
90
+ let claimLine = s;
91
+ for (const line of s.split(/\r?\n/)) {
92
+ if (CLAIM_RE.test(line)) {
93
+ claimLine = line.trim();
94
+ break;
95
+ }
96
+ }
97
+ return { claim: true, text: claimLine.replace(/\s+/g, " ").trim().slice(0, 120) };
98
+ }
99
+
100
+ // ======================= allowlist (safety-critical) =======================
101
+ // Strip a leading path + a Windows .exe/.cmd suffix so `/usr/local/bin/npx` and `npm.cmd` match by base.
102
+ function cmdBase(cmd) {
103
+ return String(cmd || "")
104
+ .split(/[/\\]/)
105
+ .pop()
106
+ .replace(/\.(exe|cmd|bat|ps1)$/i, "");
107
+ }
108
+
109
+ const NODE_PM = new Set(["npm", "pnpm", "yarn"]);
110
+ const JS_TOOLS = new Set(["jest", "vitest", "tsc", "eslint", "biome"]);
111
+ const PY_TOOLS = new Set(["pytest", "ruff", "mypy", "pyright"]);
112
+ const NPX_ALLOWED = new Set(["tsc", "jest", "vitest", "eslint", "biome", "playwright"]);
113
+ const CARGO_SUBS = new Set(["test", "build", "check", "clippy", "nextest"]);
114
+ const GO_SUBS = new Set(["test", "build", "vet"]);
115
+ const PY_M_MODULES = new Set(["pytest", "mypy", "ruff", "unittest", "pyright"]);
116
+ // Allowed npm/pnpm/yarn `run <script>` (and pnpm/yarn shorthand) — test/build/typecheck/lint families only.
117
+ const PM_RUN_SCRIPTS =
118
+ /^(test|build|typecheck|type-check|tsc|lint|check|check:[\w:-]+|ci|unit|e2e|vitest|jest)$/;
119
+ const PM_VALUE_FLAGS = new Set([
120
+ "--filter",
121
+ "-C",
122
+ "--prefix",
123
+ "--dir",
124
+ "-w",
125
+ "--workspace",
126
+ "--workspace-root",
127
+ ]);
128
+
129
+ function pmPositionals(args) {
130
+ const pos = [];
131
+ for (let i = 0; i < args.length; i++) {
132
+ const a = args[i];
133
+ if (a.startsWith("-")) {
134
+ if (PM_VALUE_FLAGS.has(a) && !a.includes("=")) i++; // its value is not a positional
135
+ continue;
136
+ }
137
+ pos.push(a);
138
+ }
139
+ return pos;
140
+ }
141
+
142
+ function validatePM(base, args) {
143
+ const pos = pmPositionals(args);
144
+ if (!pos.length) return false;
145
+ const p0 = pos[0];
146
+ if (base === "npm") {
147
+ if (p0 === "test" || p0 === "t") return true;
148
+ if (p0 === "run" || p0 === "run-script") return !!pos[1] && PM_RUN_SCRIPTS.test(pos[1]);
149
+ return false; // npm has no bare-script shorthand
150
+ }
151
+ // pnpm / yarn
152
+ if (p0 === "test" || p0 === "build") return true;
153
+ if (p0 === "run") return !!pos[1] && PM_RUN_SCRIPTS.test(pos[1]);
154
+ return PM_RUN_SCRIPTS.test(p0); // `pnpm typecheck` / `yarn lint` shorthand
155
+ }
156
+
157
+ function validateNPX(args) {
158
+ // SHARP-2: `-p/--package` fetch + INSTALL an arbitrary package (its postinstall script = code
159
+ // execution) and `-c/--call` execute an arbitrary string. Neither may appear in a proof re-run —
160
+ // refuse the whole invocation if present (in any `-p x`, `-p=x`, or `--package=x` form). Only a
161
+ // plain `npx <allowed-tool> [value-less flags]` is a proof.
162
+ for (const a of args) {
163
+ if (/^(-p|--package|-c|--call)(=|$)/.test(a)) return false;
164
+ }
165
+ for (let i = 0; i < args.length; i++) {
166
+ const a = args[i];
167
+ if (a.startsWith("-")) continue; // remaining flags are value-less/safe
168
+ if (/[/\\]/.test(a)) return false; // SHARP-1: the tool npx runs must also be a bare name
169
+ return NPX_ALLOWED.has(cmdBase(a)); // first positional is the tool npx runs
170
+ }
171
+ return false;
172
+ }
173
+
174
+ function firstSubcommand(args) {
175
+ for (const a of args) {
176
+ if (a.startsWith("-") || a.startsWith("+")) continue; // skip flags + cargo toolchain (+nightly)
177
+ return a;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ function validatePythonM(args) {
183
+ // SHARP-4 (CRITICAL): `python -c '<code>'` runs INLINE ARBITRARY CODE — and `python -c '…' -m pytest`
184
+ // slips past a naive `-m` check because python takes the FIRST run-option (-c wins, -m becomes argv).
185
+ // Refuse any -c, and require -m to be the first run-target (nothing but flags may precede it).
186
+ if (args.includes("-c")) return false;
187
+ const mi = args.indexOf("-m");
188
+ if (mi < 0) return false;
189
+ for (let i = 0; i < mi; i++) if (!args[i].startsWith("-")) return false; // no script/path before -m
190
+ return PY_M_MODULES.has(cmdBase(args[mi + 1] || ""));
191
+ }
192
+
193
+ // SHARP-4: reject flags that point an allowed tool at a manifest/config/tree the attacker chose OUTSIDE
194
+ // the re-run cwd (an absolute path or a `..` escape). Those files are executed as code by the tool
195
+ // (package.json scripts, Cargo build.rs, eslint/jest/vitest config JS). In-tree RELATIVE paths stay
196
+ // allowed; the residual "re-run the session's OWN in-tree project code" is inherent + accepted (dark).
197
+ const TREE_REDIRECT_FLAG = /^(--config|--manifest-path|--prefix|--project|--dir|-C|-c|-p)$/;
198
+ function isPathEscape(v) {
199
+ return (
200
+ typeof v === "string" &&
201
+ v !== "" &&
202
+ (path.isAbsolute(expandHome(v)) || v.split(/[/\\]/).includes(".."))
203
+ );
204
+ }
205
+ function argsRedirectOutOfTree(args) {
206
+ for (let i = 0; i < args.length; i++) {
207
+ const a = args[i];
208
+ const eq = a.startsWith("-") ? a.indexOf("=") : -1;
209
+ if (eq > 0) {
210
+ if (TREE_REDIRECT_FLAG.test(a.slice(0, eq)) && isPathEscape(a.slice(eq + 1))) return true;
211
+ } else if (TREE_REDIRECT_FLAG.test(a) && isPathEscape(args[i + 1] || "")) {
212
+ return true;
213
+ }
214
+ }
215
+ return false;
216
+ }
217
+
218
+ // isAllowedProof(cmd, args) — the single gate every re-run must clear. Returns true ONLY for a
219
+ // test/build/typecheck/lint invocation. Everything else is refused.
220
+ export function isAllowedProof(cmd, args) {
221
+ const raw = String(cmd || "");
222
+ // SHARP-1: only a BARE, PATH-resolved command name may re-run. A path-qualified cmd
223
+ // (./npm, /tmp/evil/jest, node_modules/.bin/vitest) can resolve to a binary planted in an
224
+ // attacker-influenced cwd while still matching an allowed BASENAME — a basename check is not
225
+ // enough. Refuse anything containing a path separator so only the real PATH tool executes.
226
+ if (!raw || /[/\\]/.test(raw)) return false;
227
+ const base = cmdBase(raw);
228
+ const a = Array.isArray(args) ? args : [];
229
+ if (argsRedirectOutOfTree(a)) return false; // SHARP-4: no out-of-tree manifest/config/prefix redirect
230
+ if (NODE_PM.has(base)) return validatePM(base, a);
231
+ if (base === "npx") return validateNPX(a);
232
+ if (JS_TOOLS.has(base) || PY_TOOLS.has(base)) return true; // the binary itself is a proof tool
233
+ if (base === "cargo") return CARGO_SUBS.has(firstSubcommand(a));
234
+ if (base === "go") {
235
+ // SHARP-4 (HIGH): -exec/-toolexec/-vettool run an ARBITRARY external program during test/build/vet.
236
+ if (a.some((x) => /^--?(exec|toolexec|vettool)(=|$)/.test(x))) return false;
237
+ return GO_SUBS.has(firstSubcommand(a));
238
+ }
239
+ if (base === "python" || /^python3(\.\d+)?$/.test(base)) return validatePythonM(a);
240
+ return false;
241
+ }
242
+
243
+ // ======================= (2) proof reconstruction =======================
244
+ // Quote-aware tokenizer that ALSO splits on unquoted shell operators (&&, ||, ;, |, &, newline). Used
245
+ // only to LOCATE an allowlisted invocation inside a real shell command — never to execute. Redirections
246
+ // are left as ordinary tokens and stripped in cleanSegment. Returns an array of segments (token arrays).
247
+ function splitSegments(command) {
248
+ const segments = [];
249
+ let seg = [];
250
+ let cur = "";
251
+ let has = false;
252
+ let q = null; // "'" or '"'
253
+ const endTok = () => {
254
+ if (has) {
255
+ seg.push(cur);
256
+ cur = "";
257
+ has = false;
258
+ }
259
+ };
260
+ const endSeg = () => {
261
+ endTok();
262
+ if (seg.length) segments.push(seg);
263
+ seg = [];
264
+ };
265
+ const src = String(command);
266
+ for (let i = 0; i < src.length; i++) {
267
+ const ch = src[i];
268
+ if (q) {
269
+ if (ch === q) q = null;
270
+ else if (ch === "\\" && q === '"' && i + 1 < src.length) cur += src[++i];
271
+ else cur += ch;
272
+ has = true;
273
+ continue;
274
+ }
275
+ if (ch === "'" || ch === '"') {
276
+ q = ch;
277
+ has = true;
278
+ continue;
279
+ }
280
+ if (ch === "\\" && i + 1 < src.length) {
281
+ cur += src[++i];
282
+ has = true;
283
+ continue;
284
+ }
285
+ if (ch === "\n" || ch === ";") {
286
+ endSeg();
287
+ continue;
288
+ }
289
+ if (ch === "&") {
290
+ if (src[i + 1] === "&") i++;
291
+ endSeg();
292
+ continue;
293
+ }
294
+ if (ch === "|") {
295
+ if (src[i + 1] === "|") i++;
296
+ endSeg();
297
+ continue;
298
+ }
299
+ if (/\s/.test(ch)) {
300
+ endTok();
301
+ continue;
302
+ }
303
+ cur += ch;
304
+ has = true;
305
+ }
306
+ endSeg();
307
+ return segments;
308
+ }
309
+
310
+ // Strip leading `VAR=value` env-assignments and redirection tokens (`2>&1`, `>out`, `>` `file`, …).
311
+ function cleanSegment(tokens) {
312
+ let i = 0;
313
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++; // env prefix
314
+ const rest = tokens.slice(i);
315
+ const out = [];
316
+ for (let j = 0; j < rest.length; j++) {
317
+ const t = rest[j];
318
+ if (/^(\d+)?[<>]{1,2}$/.test(t)) {
319
+ j++; // bare redirection operator — also drop its target filename
320
+ continue;
321
+ }
322
+ if (/^(\d+)?[<>]/.test(t)) continue; // self-contained redirection: 2>&1, 2>/dev/null, >out.txt
323
+ if (t === "&") continue;
324
+ out.push(t);
325
+ }
326
+ return out;
327
+ }
328
+
329
+ // Expand only $HOME / ${HOME} / a leading ~ (safe, no shell). Other $VARs are left literal → if the dir
330
+ // doesn't exist the re-run fails open (logged), never executes anything.
331
+ function expandHome(p) {
332
+ let s = String(p);
333
+ s = s.replace(/\$\{HOME\}|\$HOME/g, homedir());
334
+ if (s === "~" || s.startsWith("~/")) s = homedir() + s.slice(1);
335
+ return s;
336
+ }
337
+
338
+ function resolveCd(baseCwd, dir) {
339
+ const d = expandHome(dir);
340
+ return path.isAbsolute(d) ? path.normalize(d) : path.resolve(baseCwd || process.cwd(), d);
341
+ }
342
+
343
+ // parseCommandForProof(command, baseCwd) -> { cmd, args, cwd } | null
344
+ // Walks the segments of ONE shell command; honors a leading `cd <dir>` (sets the re-run cwd) and returns
345
+ // the first allowlisted invocation found. Args are the cleaned literal tokens (no shell interpretation).
346
+ export function parseCommandForProof(command, baseCwd) {
347
+ if (!command || typeof command !== "string") return null;
348
+ let cwd = baseCwd || process.cwd();
349
+ for (const rawTokens of splitSegments(command)) {
350
+ const toks = cleanSegment(rawTokens);
351
+ if (!toks.length) continue;
352
+ if (toks[0] === "cd" && toks[1]) {
353
+ cwd = resolveCd(cwd, toks[1]);
354
+ continue; // the actual proof command is a later segment
355
+ }
356
+ const cmd = toks[0];
357
+ const args = toks.slice(1, 65).map((t) => String(t).slice(0, 4096)); // bound arg count + length
358
+ if (isAllowedProof(cmd, args)) return { cmd, args, cwd };
359
+ }
360
+ return null;
361
+ }
362
+
363
+ // Pull the Bash command strings from one Claude transcript entry (assistant message with tool_use Bash).
364
+ // Carries the tool_use `id` so a later tool_result can be matched back to it (SHARP-3 denial check).
365
+ function bashCommandsFromEntry(entry) {
366
+ const out = [];
367
+ const c = entry?.message?.content;
368
+ if (!Array.isArray(c)) return out;
369
+ for (const b of c) {
370
+ if (b && b.type === "tool_use" && b.name === "Bash" && typeof b.input?.command === "string") {
371
+ out.push({ command: b.input.command, cwd: entry.cwd || null, id: b.id || null });
372
+ }
373
+ }
374
+ return out;
375
+ }
376
+
377
+ // SHARP-3: a tool_result whose text says the user DENIED / interrupted the tool — the command never
378
+ // actually executed, so we must not re-run it out-of-band. Deliberately narrow to Claude Code's real
379
+ // rejection/interrupt sentinels. NOTE: a command that RAN and exited non-zero is NOT a denial — its
380
+ // tool_result carries the real test output, and those failures are exactly the mismatches we DO re-run.
381
+ // ANCHORED to the START of the result: a Claude Code denial/interrupt result BEGINS with one of these
382
+ // sentinels. Anchoring is critical — an unanchored match would fire on ordinary test OUTPUT that merely
383
+ // CONTAINS "user denied"/"cancelled by user" (e.g. Skalpel's own auth tests), silently discarding a real
384
+ // failing-proof mismatch (the money case) and letting an adversarial agent print such a line to dodge us.
385
+ const DENIAL_RE =
386
+ /^\s*(\[request interrupted by user|the user doesn'?t want to (proceed|take this action)|tool use was rejected|operation (was )?(interrupted|aborted|cancell?ed) by (the )?user)/i;
387
+
388
+ function resultTextOf(block) {
389
+ const c = block?.content;
390
+ if (typeof c === "string") return c;
391
+ if (Array.isArray(c)) return c.map((x) => (typeof x === "string" ? x : x?.text || "")).join(" ");
392
+ return "";
393
+ }
394
+
395
+ // Set of tool_use ids whose result was a user denial/interrupt (never executed) — skipped as proofs.
396
+ function deniedToolUseIds(entries) {
397
+ const denied = new Set();
398
+ for (const e of entries) {
399
+ const c = e?.message?.content;
400
+ if (!Array.isArray(c)) continue;
401
+ for (const b of c) {
402
+ if (b && b.type === "tool_result" && b.tool_use_id && DENIAL_RE.test(resultTextOf(b))) {
403
+ denied.add(b.tool_use_id);
404
+ }
405
+ }
406
+ }
407
+ return denied;
408
+ }
409
+
410
+ // reconstructProof(entries, cwd) -> { cmd, args, cwd } | null
411
+ // The MOST RECENT test/build/typecheck/lint command the session actually ran. Scans newest→oldest; the
412
+ // entry's own recorded cwd is the base (a `cd` inside the command still overrides it). SHARP-3: a
413
+ // command the user DENIED (never ran) is skipped — we only re-run what the session actually executed.
414
+ export function reconstructProof(entries, cwd) {
415
+ const list = Array.isArray(entries) ? entries : [];
416
+ const denied = deniedToolUseIds(list);
417
+ for (let i = list.length - 1; i >= 0; i--) {
418
+ const cmds = bashCommandsFromEntry(list[i]);
419
+ for (let k = cmds.length - 1; k >= 0; k--) {
420
+ // SHARP-3: skip a user-DENIED command. If a tool_use has an id, skip iff that id was denied.
421
+ // If it has NO id we can't link it to a result — so if the transcript contains ANY denial we
422
+ // conservatively skip (can't prove this wasn't the denied one). Real Claude Code tool_uses
423
+ // always carry an id, so this only bites pathological/synthetic transcripts.
424
+ const id = cmds[k].id;
425
+ if (id ? denied.has(id) : denied.size > 0) continue;
426
+ const proof = parseCommandForProof(cmds[k].command, cmds[k].cwd || cwd);
427
+ if (proof) return proof;
428
+ }
429
+ }
430
+ return null;
431
+ }
432
+
433
+ // Classify a re-run into PASS / GENUINE_FAIL / HARNESS_ERROR. This is what keeps the money metric HONEST:
434
+ // only a GENUINE_FAIL (the test/build itself ran and reported failure) may count as "the agent lied".
435
+ // A HARNESS_ERROR (timeout, missing binary/script, wrong cwd — the PROOF machinery broke, not the code)
436
+ // must NEVER be shown as a lie. Real-data measurement showed 6/6 raw "mismatches" were harness noise
437
+ // (a 3-min lint timeout, a missing lint script, a wrong-cwd eslint) — counting those would cry wolf and
438
+ // burn the whole point of an honesty product.
439
+ function classifyOutcome(err, evidence) {
440
+ if (!err) return "PASS";
441
+ if (err.killed || err.signal === "SIGTERM") return "HARNESS_ERROR"; // hit the timeout
442
+ if (err.code === "ENOENT") return "HARNESS_ERROR"; // binary/cwd does not exist
443
+ const exit = typeof err.code === "number" ? err.code : null;
444
+ if (exit === 127 || exit === 126) return "HARNESS_ERROR"; // command not found / not executable
445
+ if (
446
+ /\bno such file or directory\b|\bENOENT\b|\bcommand not found\b|missing script|no [a-z:-]{1,24} script|npm err!.*missing script/i.test(
447
+ String(evidence || ""),
448
+ )
449
+ )
450
+ return "HARNESS_ERROR"; // the proof harness itself is broken, not the work
451
+ return "GENUINE_FAIL"; // a real non-zero exit from the test/build/lint itself
452
+ }
453
+
454
+ // ======================= (3) re-run the proof =======================
455
+ // rerunProof({cmd,args,cwd}) -> Promise<{ pass, outcome, evidence, refused? }>
456
+ // outcome ∈ {PASS, GENUINE_FAIL, HARNESS_ERROR, REFUSED}. execFile with an ARGUMENT ARRAY and NO shell.
457
+ // Re-validates the allowlist (defense in depth). Never throws.
458
+ export function rerunProof({ cmd, args, cwd }) {
459
+ const a = Array.isArray(args) ? args : [];
460
+ if (!isAllowedProof(cmd, a)) {
461
+ return Promise.resolve({
462
+ pass: false,
463
+ outcome: "REFUSED",
464
+ refused: true,
465
+ evidence: "refused: not on allowlist",
466
+ });
467
+ }
468
+ return new Promise((resolve) => {
469
+ let done = false;
470
+ const finish = (r) => {
471
+ if (done) return;
472
+ done = true;
473
+ resolve(r);
474
+ };
475
+ try {
476
+ execFile(
477
+ cmd,
478
+ a,
479
+ {
480
+ cwd: cwd || process.cwd(),
481
+ timeout: RERUN_TIMEOUT_MS,
482
+ maxBuffer: MAX_OUTPUT_BYTES,
483
+ windowsHide: true,
484
+ shell: false, // RED LINE: never a shell
485
+ env: process.env,
486
+ },
487
+ (err, stdout, stderr) => {
488
+ const tail = (String(stdout || "") + String(stderr || "")).replace(/\s+$/, "");
489
+ let evidence = tail.slice(-200) || (err ? String(err.message).slice(-200) : "");
490
+ if (!err) return finish({ pass: true, outcome: "PASS", evidence });
491
+ if (err.killed || err.signal === "SIGTERM")
492
+ evidence = `timeout after ${RERUN_TIMEOUT_MS}ms; ${evidence}`.slice(-200);
493
+ const outcome = classifyOutcome(err, evidence);
494
+ return finish({
495
+ pass: false,
496
+ outcome,
497
+ evidence: evidence || String(err.message).slice(-200),
498
+ });
499
+ },
500
+ );
501
+ } catch (e) {
502
+ finish({
503
+ pass: false,
504
+ outcome: "HARNESS_ERROR",
505
+ evidence: `spawn error: ${String(e && e.message).slice(-180)}`,
506
+ });
507
+ }
508
+ });
509
+ }
510
+
511
+ // ======================= (4) wire it: shadow-log a claim turn =======================
512
+ function readEntries(transcriptPath) {
513
+ try {
514
+ const lines = tailLines(transcriptPath, TRANSCRIPT_TAIL_BYTES);
515
+ const entries = [];
516
+ for (const line of lines) {
517
+ try {
518
+ entries.push(JSON.parse(line));
519
+ } catch {
520
+ /* partial/garbage line — skip */
521
+ }
522
+ }
523
+ return entries;
524
+ } catch {
525
+ return [];
526
+ }
527
+ }
528
+
529
+ // The agent's LAST assistant prose (the claim lives here). Concatenates text blocks of the final
530
+ // assistant turn found scanning newest→oldest.
531
+ function lastAssistantText(entries) {
532
+ for (let i = entries.length - 1; i >= 0; i--) {
533
+ const e = entries[i];
534
+ if (e?.type !== "assistant" && e?.message?.role !== "assistant") continue;
535
+ const c = e?.message?.content;
536
+ let text = "";
537
+ if (typeof c === "string") text = c;
538
+ else if (Array.isArray(c))
539
+ text = c
540
+ .filter((b) => b && b.type === "text")
541
+ .map((b) => b.text || "")
542
+ .join(" ");
543
+ text = text.trim();
544
+ if (text) return text;
545
+ }
546
+ return "";
547
+ }
548
+
549
+ function djb2(s) {
550
+ let h = 5381;
551
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) & 0xffffffff;
552
+ return (h >>> 0).toString(36);
553
+ }
554
+
555
+ // Dedup: don't re-run the same (session, claim) twice — guards double hook fires and repeated prompts
556
+ // that still see the same prior assistant turn. Best-effort; a miss just means one extra honest re-run.
557
+ function alreadyVerified(sig) {
558
+ try {
559
+ return JSON.parse(readFileSync(LAST_PATH, "utf8"))?.sig === sig;
560
+ } catch {
561
+ return false;
562
+ }
563
+ }
564
+ function markVerified(sig) {
565
+ try {
566
+ mkdirSync(DIR, { recursive: true });
567
+ const tmp = `${LAST_PATH}.tmp`;
568
+ writeFileSync(tmp, JSON.stringify({ sig, ts: Date.now() }));
569
+ renameSync(tmp, LAST_PATH);
570
+ } catch {
571
+ /* dedup is a bonus */
572
+ }
573
+ }
574
+
575
+ // Single-flight: only one shadow re-run at a time (a burst of prompts must not spawn parallel test runs).
576
+ function acquireLock() {
577
+ try {
578
+ mkdirSync(DIR, { recursive: true });
579
+ try {
580
+ const st = statSync(LOCK_PATH);
581
+ if (Date.now() - st.mtimeMs < LOCK_STALE_MS) return false; // fresh lock held
582
+ } catch {
583
+ /* no lock yet */
584
+ }
585
+ writeFileSync(LOCK_PATH, String(process.pid));
586
+ return true;
587
+ } catch {
588
+ return false;
589
+ }
590
+ }
591
+ function releaseLock() {
592
+ try {
593
+ rmSync(LOCK_PATH, { force: true });
594
+ } catch {
595
+ /* best-effort — a stale lock self-expires after LOCK_STALE_MS */
596
+ }
597
+ }
598
+
599
+ const LOG_MAX_BYTES = 1024 * 1024;
600
+ const LOG_KEEP_LINES = 500;
601
+ function appendShadowLog(row) {
602
+ try {
603
+ mkdirSync(DIR, { recursive: true });
604
+ try {
605
+ if (statSync(VERIFY_LOG_PATH).size > LOG_MAX_BYTES) {
606
+ const kept = readFileSync(VERIFY_LOG_PATH, "utf8")
607
+ .trim()
608
+ .split("\n")
609
+ .filter(Boolean)
610
+ .slice(-LOG_KEEP_LINES);
611
+ const tmp = `${VERIFY_LOG_PATH}.tmp`;
612
+ writeFileSync(tmp, kept.join("\n") + "\n");
613
+ renameSync(tmp, VERIFY_LOG_PATH);
614
+ }
615
+ } catch {
616
+ /* no file yet or trim failed — still append */
617
+ }
618
+ appendFileSync(VERIFY_LOG_PATH, JSON.stringify(row) + "\n");
619
+ } catch {
620
+ /* never throw from the shadow path */
621
+ }
622
+ }
623
+
624
+ // recordVerifyShadow({transcriptPath, session, cwd}) — the wired entry. On (claim + reconstructable
625
+ // proof) it re-runs the proof and appends one shadow row. LOG ONLY. Never injects, never throws.
626
+ export async function recordVerifyShadow({ transcriptPath, session, cwd }) {
627
+ try {
628
+ if (!transcriptPath) return;
629
+ const entries = readEntries(transcriptPath);
630
+ if (!entries.length) return;
631
+ const { claim, text } = detectCompletionClaim(lastAssistantText(entries));
632
+ if (!claim) return; // no claim this turn → nothing to verify (keeps the log claim-only)
633
+
634
+ const sig = `${session || ""}|${djb2(text)}`;
635
+ if (alreadyVerified(sig)) return;
636
+
637
+ const proof = reconstructProof(entries, cwd);
638
+ // No re-runnable proof existed — still record the claim so the analyzer can compute the
639
+ // proof-reconstruction success rate (claims WITH a proof / all claims).
640
+ if (!proof) {
641
+ markVerified(sig);
642
+ appendShadowLog({
643
+ ts: new Date().toISOString(),
644
+ session: session || null,
645
+ claim_text: text,
646
+ proof_command: null,
647
+ pass_fail: null,
648
+ mismatch: false,
649
+ evidence: "",
650
+ });
651
+ return;
652
+ }
653
+
654
+ // A proof exists → re-run it, but only one at a time.
655
+ if (!acquireLock()) return; // another shadow run is in flight; skip (fail-open, no pile-up)
656
+ let result;
657
+ try {
658
+ result = await rerunProof(proof);
659
+ } finally {
660
+ releaseLock();
661
+ }
662
+ markVerified(sig);
663
+ const outcome = result.outcome || (result.pass ? "PASS" : "GENUINE_FAIL");
664
+ // pass_fail is PASS/FAIL for a real run; HARNESS_ERROR is neither (the proof machinery broke, not
665
+ // the work) so it is NOT counted in the mismatch metric — only a GENUINE_FAIL is "the agent lied".
666
+ const pass_fail =
667
+ outcome === "PASS" ? "PASS" : outcome === "GENUINE_FAIL" ? "FAIL" : "HARNESS_ERROR";
668
+ appendShadowLog({
669
+ ts: new Date().toISOString(),
670
+ session: session || null,
671
+ claim_text: text,
672
+ proof_command: [proof.cmd, ...proof.args].join(" ").slice(0, 200),
673
+ pass_fail,
674
+ outcome,
675
+ mismatch: claim && outcome === "GENUINE_FAIL", // THE money metric: claimed done, own test REALLY failed
676
+ evidence: String(result.evidence || "").slice(0, 200),
677
+ });
678
+ } catch {
679
+ /* SHADOW: any failure is swallowed — this must never affect the hook or the turn */
680
+ }
681
+ }
682
+
683
+ // ======================= analyzer: `skalpel __verify-report` =======================
684
+ // Reads verify-shadow.log (+ metrics.ndjson for the total-turn denominator) and prints the three
685
+ // instrument numbers. Returns the report string (also used by tests).
686
+ export function renderVerifyReport() {
687
+ let rows = [];
688
+ try {
689
+ rows = readFileSync(VERIFY_LOG_PATH, "utf8")
690
+ .trim()
691
+ .split("\n")
692
+ .filter(Boolean)
693
+ .map((l) => {
694
+ try {
695
+ return JSON.parse(l);
696
+ } catch {
697
+ return null;
698
+ }
699
+ })
700
+ .filter(Boolean);
701
+ } catch {
702
+ return `no claim-verification shadow data yet (${VERIFY_LOG_PATH})\n\nEnable the shadow with SKALPEL_VERIFY_SHADOW=1, then keep coding — every turn your agent claims "done / tests pass" gets its own proof re-run and logged here.`;
703
+ }
704
+
705
+ const claims = rows.length;
706
+ const withProof = rows.filter((r) => r.proof_command).length;
707
+ // Genuine re-runs only (PASS or a real test FAIL). HARNESS_ERROR rows (timeout, missing script, wrong
708
+ // cwd) are EXCLUDED from the mismatch denominator — the proof machinery broke, not the agent's work.
709
+ const reran = rows.filter((r) => r.pass_fail === "PASS" || r.pass_fail === "FAIL").length;
710
+ const harnessErrors = rows.filter(
711
+ (r) => r.pass_fail === "HARNESS_ERROR" || r.outcome === "HARNESS_ERROR",
712
+ ).length;
713
+ const mismatches = rows.filter((r) => r.mismatch === true).length;
714
+
715
+ // Denominator for claim fire-rate: total prompt turns the hook saw (metrics.ndjson prompt events).
716
+ let promptTurns = 0;
717
+ try {
718
+ for (const l of readFileSync(join(DIR, "metrics.ndjson"), "utf8").trim().split("\n")) {
719
+ try {
720
+ if (JSON.parse(l)?.event === "prompt") promptTurns++;
721
+ } catch {
722
+ /* skip */
723
+ }
724
+ }
725
+ } catch {
726
+ /* no metrics — claim fire-rate denominator unknown */
727
+ }
728
+
729
+ const pct = (n, d) => (d > 0 ? `${((100 * n) / d).toFixed(1)}%` : "n/a");
730
+ const L = [];
731
+ L.push("");
732
+ L.push("skalpel — claim-verification SHADOW (does the agent's own proof back up its claim?)");
733
+ L.push("─".repeat(72));
734
+ L.push(` claim turns logged ${claims}`);
735
+ L.push(
736
+ ` (1) claim fire-rate ${pct(claims, promptTurns)} ${claims}/${promptTurns || "?"} prompt turns claimed success`,
737
+ );
738
+ L.push(
739
+ ` (2) proof-reconstruction rate ${pct(withProof, claims)} ${withProof}/${claims} claims had a re-runnable proof`,
740
+ );
741
+ L.push(
742
+ ` (3) MISMATCH rate ${pct(mismatches, reran)} ${mismatches}/${reran} re-run proofs GENUINELY failed after a success claim`,
743
+ );
744
+ L.push(
745
+ ` (excluded: ${harnessErrors} harness error(s) — timeout/missing-script/wrong-cwd, not counted as a lie)`,
746
+ );
747
+ L.push("─".repeat(72));
748
+ L.push(
749
+ ` ${mismatches} time(s) the agent claimed done but its own proof, re-run, did NOT pass.`,
750
+ );
751
+ // Show the most recent few mismatches concretely (the receipt).
752
+ const recentMismatch = rows
753
+ .filter((r) => r.mismatch)
754
+ .slice(-3)
755
+ .reverse();
756
+ if (recentMismatch.length) {
757
+ L.push("");
758
+ L.push(" recent mismatches:");
759
+ for (const r of recentMismatch) {
760
+ L.push(` • claim: ${String(r.claim_text || "").slice(0, 68)}`);
761
+ L.push(` proof: ${String(r.proof_command || "").slice(0, 68)} → FAIL`);
762
+ if (r.evidence) L.push(` ${String(r.evidence).slice(0, 68)}`);
763
+ }
764
+ }
765
+ L.push("");
766
+ L.push(
767
+ " SHADOW mode: logged locally only — never injected into the model, never blocks a turn.",
768
+ );
769
+ L.push("");
770
+ return L.join("\n");
771
+ }
772
+
773
+ // CLI: `node verify-shadow.mjs --run` → detached worker launched by the per-turn hook.
774
+ // `node verify-shadow.mjs --report`→ print the instrument (also reachable via `skalpel __verify-report`).
775
+ // Robust main detection (mirrors skalpel-setup.mjs): compare REAL paths, not a hand-built file:// URL —
776
+ // the staged path lives under the home dir, which on macOS/Windows can contain a space that would break
777
+ // `new URL("file://"+path)` and wrongly disable the CLI. Importing this module (tests) never trips it.
778
+ const isMain = (() => {
779
+ try {
780
+ return (
781
+ Boolean(process.argv[1]) &&
782
+ realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
783
+ );
784
+ } catch {
785
+ return false;
786
+ }
787
+ })();
788
+ if (isMain) {
789
+ const arg = process.argv[2];
790
+ if (arg === "--report") {
791
+ process.stdout.write(renderVerifyReport() + "\n");
792
+ process.exit(0);
793
+ } else if (arg === "--run") {
794
+ recordVerifyShadow({
795
+ transcriptPath: process.env.SKALPEL_VERIFY_TRANSCRIPT || null,
796
+ session: process.env.SKALPEL_VERIFY_SESSION || null,
797
+ cwd: process.env.SKALPEL_VERIFY_CWD || process.cwd(),
798
+ })
799
+ .then(() => process.exit(0))
800
+ .catch(() => process.exit(0));
801
+ } else {
802
+ process.exit(0);
803
+ }
804
+ }