skalpel 4.0.45 → 4.0.47

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.
@@ -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
+ }