skalpel 4.0.46 → 4.0.48

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/catches.mjs CHANGED
@@ -39,6 +39,10 @@ import {
39
39
  classifyOutcome,
40
40
  extractFailCount,
41
41
  } from "./verify-shadow.mjs";
42
+ // ONE normalizer, never two: the Codex rollout → Claude-shape translation lives in codex-normalize.mjs and
43
+ // is shared verbatim with the LIVE verify-shadow worker, so the retroactive scan and the live catch can
44
+ // never drift. (This module keeps only the Claude-native loader below.)
45
+ import { loadCodexEntries } from "./codex-normalize.mjs";
42
46
  import { recordInsight } from "./insights.mjs";
43
47
 
44
48
  const HOME = homedir();
@@ -113,76 +117,10 @@ export function loadClaudeEntries(file) {
113
117
  return entries;
114
118
  }
115
119
 
116
- // Codex output wrapper always carries "Process exited with code N" — the recorded exit status, which is
117
- // exactly the ground truth we need (no re-run). Returns { exitCode:number|null, body:string }.
118
- function parseCodexOutput(output) {
119
- const s = typeof output === "string" ? output : "";
120
- const m = s.match(/Process exited with code\s+(-?\d+)/i);
121
- const exitCode = m ? parseInt(m[1], 10) : null;
122
- // Prefer the text AFTER "Output:" as the evidence body (drops the Command/Chunk/Wall-time header).
123
- const oi = s.indexOf("\nOutput:\n");
124
- const body = oi >= 0 ? s.slice(oi + "\nOutput:\n".length) : s;
125
- return { exitCode, body };
126
- }
127
-
128
- // Normalize one Codex rollout file into Claude-shaped entries. exec_command → a Bash tool_use; its
129
- // function_call_output → a tool_result carrying the recorded exit code; assistant messages → text.
130
- export function loadCodexEntries(file) {
131
- const entries = [];
132
- for (const line of tailLines(file, SESSION_TAIL_BYTES)) {
133
- let e;
134
- try {
135
- e = JSON.parse(line);
136
- } catch {
137
- continue;
138
- }
139
- if (e.type !== "response_item") continue;
140
- const p = e.payload || {};
141
- if (p.type === "message") {
142
- const role = p.role === "assistant" ? "assistant" : "user";
143
- const text = Array.isArray(p.content)
144
- ? p.content.map((b) => (b && typeof b.text === "string" ? b.text : "")).join("")
145
- : "";
146
- if (text.trim())
147
- entries.push({ type: role, message: { role, content: [{ type: "text", text }] } });
148
- } else if (p.type === "function_call" && p.name === "exec_command" && p.call_id) {
149
- let cmd = "";
150
- let workdir = null;
151
- try {
152
- const a = JSON.parse(p.arguments || "{}");
153
- cmd = typeof a.cmd === "string" ? a.cmd : Array.isArray(a.cmd) ? a.cmd.join(" ") : "";
154
- workdir = typeof a.workdir === "string" ? a.workdir : null;
155
- } catch {
156
- /* unparseable args — leave cmd empty (no proof) */
157
- }
158
- if (cmd)
159
- entries.push({
160
- cwd: workdir,
161
- message: {
162
- role: "assistant",
163
- content: [{ type: "tool_use", name: "Bash", id: p.call_id, input: { command: cmd } }],
164
- },
165
- });
166
- } else if (p.type === "function_call_output" && p.call_id) {
167
- const { exitCode, body } = parseCodexOutput(p.output);
168
- entries.push({
169
- message: {
170
- role: "user",
171
- content: [
172
- {
173
- type: "tool_result",
174
- tool_use_id: p.call_id,
175
- is_error: typeof exitCode === "number" ? exitCode !== 0 : false,
176
- exit_code: exitCode,
177
- content: body,
178
- },
179
- ],
180
- },
181
- });
182
- }
183
- }
184
- return entries;
185
- }
120
+ // Codex rollout normalization (loadCodexEntries / parseCodexOutput) now lives in ./codex-normalize.mjs and
121
+ // is imported above ONE normalizer shared with the live verify-shadow worker (deleted here to avoid a
122
+ // second, drifting copy). The retroactive scan still reads the RECORDED exit code those entries carry
123
+ // (exit_code / is_error) instead of re-running.
186
124
 
187
125
  // ---------- (3) index the recorded proof results (no re-run) ----------
188
126
  function resultTextOf(block) {
@@ -226,14 +164,17 @@ function indexResults(entries) {
226
164
  // An UNANCHORED full-body scan would misread an unrelated "exit code N" the command itself printed — a
227
165
  // grep/cat of source, a pasted CI log like "##[error]Process completed with exit code 123." — and cry wolf
228
166
  // on a genuinely-passing proof. Anchoring to the first line reads the real outcome and nothing else.
229
- function exitCodeFromText(text) {
167
+ // EXPORTED (additive; logic byte-unchanged) so the OFFLINE efficacy eval (`skalpel eval`,
168
+ // efficacy-eval.mjs) reuses the EXACT recorded-result → verdict path this scanner uses — one verdict
169
+ // source, so the eval's confirmed-lie set can never drift from what `skalpel catches` reports.
170
+ export function exitCodeFromText(text) {
230
171
  const firstLine = String(text || "").split("\n", 1)[0];
231
172
  const m = firstLine.match(/^\s*exit(?:ed)?(?:\s+with)?\s+(?:code|status)\s+(\d{1,3})\b/i);
232
173
  if (!m) return null;
233
174
  const n = Number.parseInt(m[1], 10);
234
175
  return Number.isFinite(n) ? n : null;
235
176
  }
236
- function classifyRecorded({ isError, exitCode, text }) {
177
+ export function classifyRecorded({ isError, exitCode, text }) {
237
178
  const ev = String(text || "");
238
179
  // Prefer the structured exit code (Codex); else recover a real code from the text (Claude). This makes
239
180
  // the numeric-exit-code branch reachable for Claude, so 127/126 route to HARNESS_ERROR (no cry-wolf) and
@@ -318,7 +259,10 @@ export function runScan() {
318
259
  for (const s of sessions) {
319
260
  let entries;
320
261
  try {
321
- entries = s.tool === "codex" ? loadCodexEntries(s.file) : loadClaudeEntries(s.file);
262
+ entries =
263
+ s.tool === "codex"
264
+ ? loadCodexEntries(s.file, SESSION_TAIL_BYTES)
265
+ : loadClaudeEntries(s.file);
322
266
  } catch {
323
267
  continue; // unreadable file — skip (read-only, fail-open)
324
268
  }
@@ -0,0 +1,216 @@
1
+ // codex-normalize.mjs — the ONE Codex-rollout → Claude-transcript normalizer, shared by the retroactive
2
+ // scanner (catches.mjs) AND the LIVE claim-verification worker (verify-shadow.mjs). Codex records a session
3
+ // as a JSONL "rollout" of `response_item` events; Claude records `message.content[]` blocks. verify-shadow's
4
+ // whole engine — claim detection, proof reconstruction, allowlist, classifyOutcome, flake-confirm — speaks
5
+ // the Claude shape. Rather than teach every one of those functions a second dialect, we translate the
6
+ // rollout INTO the Claude shape ONCE, here, and everything downstream is unchanged. A single normalizer
7
+ // means the live catch and the retroactive catch can never drift.
8
+ //
9
+ // RED LINES (inherited, enforced by construction):
10
+ // • READ-ONLY + fail-open. This only PARSES bytes already on disk. Unparseable args / unknown schema /
11
+ // missing fields → the record is dropped (no proof), never a throw. A crafted rollout can at worst make
12
+ // us find NOTHING to verify — never make us execute something we shouldn't (the allowlist + execFile
13
+ // arg-array in verify-shadow still gate every re-run).
14
+ // • The recorded exit code Codex already wrote ("Process exited with code N") is carried onto the
15
+ // normalized tool_result (exit_code / is_error). The live worker uses it as CORROBORATING evidence:
16
+ // a live re-run FAIL is only ever surfaced when Codex's OWN recorded run of that same command also
17
+ // failed (recorded-exit-code agreement) — so a Codex catch is strictly MORE precision-gated than a
18
+ // Claude one, and an environment/flake divergence can never cry wolf.
19
+ import { readdirSync, statSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import path from "node:path";
22
+ import { tailLines } from "./transcript.mjs";
23
+
24
+ // Default per-file tail. Codex rollouts can be multi-GB; a bounded tail keeps normalization constant-time
25
+ // and OOM-proof. Callers pass their own budget (the retroactive scan is generous, the hot live path tighter).
26
+ const DEFAULT_TAIL_BYTES = 4 * 1024 * 1024;
27
+
28
+ // ---------- detection ----------
29
+ // A Codex rollout is identified by WHERE it lives (under ~/.codex — or $CODEX_HOME) OR by its canonical
30
+ // filename (`rollout-<iso>-<uuid>.jsonl`). Path-based so we never have to read a byte to route. Fail-open:
31
+ // anything ambiguous returns false and the caller treats it as a Claude transcript (the safe default).
32
+ export function isCodexRollout(p) {
33
+ if (!p || typeof p !== "string") return false;
34
+ try {
35
+ const base = p.split(/[/\\]/).pop() || "";
36
+ if (/^rollout-.*\.jsonl$/.test(base)) return true;
37
+ const norm = p.replace(/\\/g, "/");
38
+ if (/(^|\/)\.codex(\/|$)/.test(norm)) return true;
39
+ const codexHome = process.env.CODEX_HOME;
40
+ if (codexHome && norm.startsWith(codexHome.replace(/\\/g, "/"))) return true;
41
+ return false;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ // The Codex session roots to scan when we must resolve a session ourselves (the hook did not hand us a
48
+ // usable rollout path — see verify-shadow #6). $CODEX_HOME wins; otherwise ~/.codex.
49
+ function codexRoots() {
50
+ const home = process.env.CODEX_HOME || path.join(homedir(), ".codex");
51
+ return [path.join(home, "sessions"), path.join(home, "archived_sessions")];
52
+ }
53
+
54
+ // Bounded newest-first walk for `rollout-*.jsonl`. Used only as the LAST resort when no usable rollout path
55
+ // was provided for a Codex turn. Read-only, fail-open. depth-bounded so a symlink loop can't spin forever.
56
+ function walkRollouts(dir, out, depth = 0) {
57
+ if (depth > 8) return; // Codex nests sessions/<yyyy>/<mm>/<dd>/ — 8 is plenty and bounds a symlink loop
58
+ let entries;
59
+ try {
60
+ entries = readdirSync(dir, { withFileTypes: true });
61
+ } catch {
62
+ return;
63
+ }
64
+ for (const e of entries) {
65
+ const p = path.join(dir, e.name);
66
+ try {
67
+ if (e.isDirectory()) walkRollouts(p, out, depth + 1);
68
+ else if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) out.push(p);
69
+ } catch {
70
+ /* one bad entry never aborts the walk */
71
+ }
72
+ }
73
+ }
74
+
75
+ // newestCodexRollout() → absolute path of the most-recently-modified rollout, or null. Fail-open.
76
+ export function newestCodexRollout() {
77
+ try {
78
+ const files = [];
79
+ for (const d of codexRoots()) walkRollouts(d, files);
80
+ let best = null;
81
+ let bestMtime = -Infinity;
82
+ for (const f of files) {
83
+ try {
84
+ const m = statSync(f).mtimeMs;
85
+ if (m > bestMtime) {
86
+ bestMtime = m;
87
+ best = f;
88
+ }
89
+ } catch {
90
+ /* vanished mid-scan — skip */
91
+ }
92
+ }
93
+ return best;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // ---------- output parsing ----------
100
+ // A Codex exec output wrapper always carries "Process exited with code N" — the recorded exit status, which
101
+ // is exactly the ground truth we need (no re-run to learn it). Returns { exitCode:number|null, body:string }.
102
+ // A sandbox/harness failure ("exec_command failed: CreateProcess …") has no such line → exitCode null →
103
+ // treated downstream as "no failure signal recorded" (never invented as a failure).
104
+ export function parseCodexOutput(output) {
105
+ const s = typeof output === "string" ? output : "";
106
+ // Codex records its exit status as a HEADER line, BEFORE the "\nOutput:\n" body (verified against real
107
+ // ~/.codex rollouts — the status line always precedes the Output body and reads):
108
+ // [Command: …\n]Chunk ID: …\nWall time: … seconds\nProcess exited with code N\nOriginal token count: …\nOutput:\n<body>
109
+ // The command's OWN stdout/stderr (the body, AFTER "Output:") — OR the echoed "Command:" line — can also
110
+ // contain a "Process exited with code M" substring (a nested runner/CI-supervisor logging a child's exit, a
111
+ // grep of this very phrase). Neither is Codex's status. Reading the FIRST match can grab the Command echo;
112
+ // reading the LAST over the WHOLE string can grab the body. So we bind to Codex's OWN status line: scan the
113
+ // HEADER region only (everything before "\nOutput:\n") and take the LAST match there — the real status line
114
+ // always follows the Command echo, and the body is excluded outright. This is the recorded exit code the
115
+ // agreement gate corroborates against; it can no longer be spoofed by command output — closing the
116
+ // false-catch path. A thinner wrapper with no "\nOutput:\n" degrades to a last-match over the whole string;
117
+ // no match at all → null (suppressed downstream, never invented as a failure).
118
+ const oi = s.indexOf("\nOutput:\n");
119
+ const header = oi >= 0 ? s.slice(0, oi) : s;
120
+ let exitCode = null;
121
+ const re = /Process exited with code\s+(-?\d+)/gi;
122
+ let m;
123
+ while ((m = re.exec(header)) !== null) exitCode = parseInt(m[1], 10);
124
+ // Prefer the text AFTER "Output:" as the evidence body (drops the Command/Chunk/Wall-time header).
125
+ const body = oi >= 0 ? s.slice(oi + "\nOutput:\n".length) : s;
126
+ return { exitCode, body };
127
+ }
128
+
129
+ // ---------- normalization ----------
130
+ // Normalize one Codex rollout file into Claude-shaped entries. exec_command → a Bash tool_use; its
131
+ // function_call_output → a tool_result carrying the recorded exit code; assistant/user messages → text.
132
+ // maxBytes bounds the tail read (see DEFAULT_TAIL_BYTES). Never throws.
133
+ export function loadCodexEntries(file, maxBytes = DEFAULT_TAIL_BYTES) {
134
+ const entries = [];
135
+ for (const line of tailLines(file, maxBytes)) {
136
+ let e;
137
+ try {
138
+ e = JSON.parse(line);
139
+ } catch {
140
+ continue;
141
+ }
142
+ if (e.type !== "response_item") continue;
143
+ const p = e.payload || {};
144
+ if (p.type === "message") {
145
+ const role = p.role === "assistant" ? "assistant" : "user";
146
+ const text = Array.isArray(p.content)
147
+ ? p.content.map((b) => (b && typeof b.text === "string" ? b.text : "")).join("")
148
+ : "";
149
+ if (text.trim())
150
+ entries.push({ type: role, message: { role, content: [{ type: "text", text }] } });
151
+ } else if (p.type === "function_call" && p.name === "exec_command" && p.call_id) {
152
+ let cmd = "";
153
+ let workdir = null;
154
+ try {
155
+ const a = JSON.parse(p.arguments || "{}");
156
+ // A STRING cmd is a shell command verify-shadow's tokenizer parses safely. An ARRAY cmd is argv;
157
+ // joining it back into a string is LOSSY (quoting is lost — `["sh","-c","a b"]` → `sh -c a b`),
158
+ // so we DROP it (leave cmd empty → no proof) rather than reconstruct a command that differs from
159
+ // what actually ran. Fail-open on argv-join quoting doubt (RED LINE #4).
160
+ cmd = typeof a.cmd === "string" ? a.cmd : "";
161
+ workdir = typeof a.workdir === "string" ? a.workdir : null;
162
+ } catch {
163
+ /* unparseable args — leave cmd empty (no proof) */
164
+ }
165
+ if (cmd)
166
+ entries.push({
167
+ cwd: workdir,
168
+ message: {
169
+ role: "assistant",
170
+ content: [{ type: "tool_use", name: "Bash", id: p.call_id, input: { command: cmd } }],
171
+ },
172
+ });
173
+ } else if (p.type === "function_call_output" && p.call_id) {
174
+ const { exitCode, body } = parseCodexOutput(p.output);
175
+ entries.push({
176
+ message: {
177
+ role: "user",
178
+ content: [
179
+ {
180
+ type: "tool_result",
181
+ tool_use_id: p.call_id,
182
+ is_error: typeof exitCode === "number" ? exitCode !== 0 : false,
183
+ exit_code: exitCode,
184
+ content: body,
185
+ },
186
+ ],
187
+ },
188
+ });
189
+ }
190
+ }
191
+ return entries;
192
+ }
193
+
194
+ // ---------- recorded-exit lookup (the Codex precision lever) ----------
195
+ // The recorded exit code Codex wrote for the tool_use `id` (call_id) of a reconstructed proof. This is the
196
+ // CORROBORATING evidence the live worker demands before it surfaces a Codex catch: a live re-run FAIL is
197
+ // only revealed when Codex's OWN recorded run of that exact command ALSO failed. Returns
198
+ // { exitCode:number|null, isError:bool, found:bool }. Scans newest→oldest so the most recent result wins.
199
+ export function recordedExitFor(entries, toolUseId) {
200
+ if (!toolUseId || !Array.isArray(entries))
201
+ return { exitCode: null, isError: false, found: false };
202
+ for (let i = entries.length - 1; i >= 0; i--) {
203
+ const c = entries[i]?.message?.content;
204
+ if (!Array.isArray(c)) continue;
205
+ for (const b of c) {
206
+ if (b && b.type === "tool_result" && b.tool_use_id === toolUseId) {
207
+ return {
208
+ exitCode: typeof b.exit_code === "number" ? b.exit_code : null,
209
+ isError: b.is_error === true,
210
+ found: true,
211
+ };
212
+ }
213
+ }
214
+ }
215
+ return { exitCode: null, isError: false, found: false };
216
+ }