skalpel 4.0.41 → 4.0.43

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/_test-home.mjs ADDED
@@ -0,0 +1,18 @@
1
+ // _test-home.mjs — TEST-ONLY isolation shim. Imported FIRST (before any client module) by every test
2
+ // that touches ~/.skalpel, so os.homedir() resolves to a throwaway temp dir and the suite can never read
3
+ // or clobber the developer's real ~/.skalpel. ESM evaluates imports depth-first in source order, so a test
4
+ // that does `import "./_test-home.mjs"` before `import "./insights.mjs"` guarantees HOME is redirected
5
+ // BEFORE insights.mjs binds its DIR = join(homedir(), ".skalpel") at module load. Not a test file itself.
6
+ import { mkdtempSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const home = mkdtempSync(join(tmpdir(), "skalpel-verify-test-"));
11
+ process.env.HOME = home;
12
+ process.env.USERPROFILE = home; // Windows
13
+ // Never let a stray dev env var arm/disarm the resolver mid-test — each test sets these explicitly.
14
+ delete process.env.SKALPEL_VERIFY_REVEAL;
15
+ delete process.env.SKALPEL_VERIFY_SHADOW;
16
+
17
+ export const TEST_HOME = home;
18
+ export const SKALPEL_DIR = join(home, ".skalpel");
package/anchor-shadow.mjs CHANGED
@@ -187,18 +187,30 @@ export function detectShipStatusSpiral(turns) {
187
187
  // (2) reconstructTarget — pull the concrete anchor from recent turns + repo context. Returns a STRUCTURED
188
188
  // target or null (null = "not confidently reconstructable" → the caller logs a miss and does NOT fire).
189
189
  // ---------------------------------------------------------------------------------------------------
190
+ // The context passed in (recentContextText) is joined OLDEST→NEWEST, so a plain non-global .exec() would
191
+ // return the STALEST match. A ship-status probe must target the claim the user is doubting NOW — so scan
192
+ // ALL matches globally and keep the LAST (most-recent) valid one, not the first (a stale PR/URL from many
193
+ // turns ago).
190
194
  function findPr(text) {
191
195
  // "#123", "PR 123", "PR#123", "pull/123", "pull request 123"
192
- const m = /(?:\bpr\s*#?\s*|\bpull(?:\s*request)?\s*#?\s*|\/pull\/|#)(\d{1,6})\b/i.exec(text);
193
- if (!m) return null;
194
- const n = Number(m[1]);
195
- return Number.isInteger(n) && n > 0 && n < 1e7 ? n : null;
196
+ const re = /(?:\bpr\s*#?\s*|\bpull(?:\s*request)?\s*#?\s*|\/pull\/|#)(\d{1,6})\b/gi;
197
+ let last = null;
198
+ let m;
199
+ while ((m = re.exec(text))) {
200
+ const n = Number(m[1]);
201
+ if (Number.isInteger(n) && n > 0 && n < 1e7) last = n;
202
+ }
203
+ return last;
196
204
  }
197
205
  function findUrl(text) {
198
- const m = /\bhttps?:\/\/[^\s'")<>`]+/i.exec(text);
199
- if (!m) return null;
200
- let url = m[0].replace(/[.,);]+$/, ""); // trim trailing punctuation from prose
201
- return isSafeUrl(url) ? url : null;
206
+ const re = /\bhttps?:\/\/[^\s'")<>`]+/gi;
207
+ let last = null;
208
+ let m;
209
+ while ((m = re.exec(text))) {
210
+ const url = m[0].replace(/[.,);]+$/, ""); // trim trailing punctuation from prose
211
+ if (isSafeUrl(url)) last = url;
212
+ }
213
+ return last;
202
214
  }
203
215
  function findSha(text) {
204
216
  // Prefer a sha that appears near commit-ish words; otherwise the longest lone hex token in [7,40].
package/autopsy.mjs CHANGED
@@ -49,6 +49,7 @@ import fs from "node:fs";
49
49
  import path from "node:path";
50
50
  import os from "node:os";
51
51
  import readline from "node:readline";
52
+ import { fileURLToPath } from "node:url";
52
53
 
53
54
  const HOME = os.homedir();
54
55
  const PROJECTS_DIR = path.join(HOME, ".claude", "projects");
@@ -212,10 +213,23 @@ function extractAnchors(text) {
212
213
  return out;
213
214
  }
214
215
 
215
- function span(text, at, len) {
216
+ // Captured transcript prose + tool output become quoted/shareable snippets via span() and vb(). Strip ESC
217
+ // + other C0 control chars so a hostile log line can't inject ANSI/CSI/OSC escapes (color, cursor moves,
218
+ // title/clipboard spoofing) into the receipt or a paste of it. redact() still runs on top for PII on the
219
+ // shareable surfaces; this is the terminal-safety layer beneath it (same defense as card.mjs `clean`).
220
+ export function stripCtrl(s) {
221
+ return (
222
+ String(s == null ? "" : s)
223
+ // eslint-disable-next-line no-control-regex
224
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // ANSI CSI sequences
225
+ // eslint-disable-next-line no-control-regex
226
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
227
+ ); // other control chars (incl. ESC/BEL) → space
228
+ }
229
+ export function span(text, at, len) {
216
230
  const a = Math.max(0, at - SPAN),
217
231
  b = Math.min(text.length, at + len + SPAN);
218
- return text.slice(a, b).replace(/\s+/g, " ").trim().slice(0, 200);
232
+ return stripCtrl(text.slice(a, b)).replace(/\s+/g, " ").trim().slice(0, 200);
219
233
  }
220
234
 
221
235
  // ~/.claude/projects encodes cwd as a dash-flattened path. Every vendor's project root is normalized to
@@ -1282,7 +1296,7 @@ async function main() {
1282
1296
  const full = t.text || t.toolErrText || "";
1283
1297
  const pos = full.toLowerCase().indexOf(rec.anchor.toLowerCase());
1284
1298
  return pos < 0
1285
- ? full.replace(/\s+/g, " ").trim().slice(0, 200)
1299
+ ? stripCtrl(full).replace(/\s+/g, " ").trim().slice(0, 200)
1286
1300
  : span(full, pos, rec.anchor.length);
1287
1301
  };
1288
1302
  cellC.push({
@@ -1618,8 +1632,24 @@ function renderFooter(P, presentVendors) {
1618
1632
  P();
1619
1633
  }
1620
1634
 
1621
- main().catch((e) => {
1622
- // fail closed and quiet: never crash a user's terminal, never invent output.
1623
- process.stderr.write(`skalpel autopsy: ${e && e.message ? e.message : e}\n`);
1624
- process.exit(0);
1625
- });
1635
+ // CLI guard: run the autopsy only when executed directly (`node autopsy.mjs`), never on import — so tests
1636
+ // and siblings can import helpers (span/stripCtrl) without triggering a full disk scan. autopsy is always
1637
+ // launched as a subprocess (`spawnSync node autopsy.mjs`), so direct-run behavior is unchanged. Mirrors the
1638
+ // isMain idiom in verify-shadow.mjs / ledger.mjs.
1639
+ const isMain = (() => {
1640
+ try {
1641
+ return (
1642
+ Boolean(process.argv[1]) &&
1643
+ fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))
1644
+ );
1645
+ } catch {
1646
+ return false;
1647
+ }
1648
+ })();
1649
+ if (isMain) {
1650
+ main().catch((e) => {
1651
+ // fail closed and quiet: never crash a user's terminal, never invent output.
1652
+ process.stderr.write(`skalpel autopsy: ${e && e.message ? e.message : e}\n`);
1653
+ process.exit(0);
1654
+ });
1655
+ }
package/catches.mjs CHANGED
@@ -215,17 +215,39 @@ function indexResults(entries) {
215
215
  // result (we never ran the process, so there is no live err). This keeps HARNESS_ERROR vs GENUINE_FAIL
216
216
  // identical to the live shadow: a timeout / command-not-found / missing-script is harness noise and is
217
217
  // NEVER counted as a lie.
218
+ // A real Claude/Anthropic tool_result block is {tool_use_id, content, is_error} — it carries NO structured
219
+ // exit_code field (only Codex rollouts, normalized in loadCodexEntries, synthesize one). So indexResults
220
+ // hands us exitCode=null for EVERY Claude result, which made the numeric-exit-code branch below dead for
221
+ // Claude: a 127/126 harness failure fell through to `{code:1}` and cried wolf as a GENUINE_FAIL, and a real
222
+ // non-zero exit Claude didn't flag via is_error was silently dropped as PASS. For a Claude proof the code
223
+ // lives in the recorded text — but read it ONLY from the LEADING line. Claude Code prepends the failing
224
+ // process's outcome as the first line of the Bash tool_result ("Exit code 1\n…", verified across real
225
+ // transcripts: 142/142 non-zero-exit results start with "Exit code N"; a PASSING command has no such line).
226
+ // An UNANCHORED full-body scan would misread an unrelated "exit code N" the command itself printed — a
227
+ // grep/cat of source, a pasted CI log like "##[error]Process completed with exit code 123." — and cry wolf
228
+ // on a genuinely-passing proof. Anchoring to the first line reads the real outcome and nothing else.
229
+ function exitCodeFromText(text) {
230
+ const firstLine = String(text || "").split("\n", 1)[0];
231
+ const m = firstLine.match(/^\s*exit(?:ed)?(?:\s+with)?\s+(?:code|status)\s+(\d{1,3})\b/i);
232
+ if (!m) return null;
233
+ const n = Number.parseInt(m[1], 10);
234
+ return Number.isFinite(n) ? n : null;
235
+ }
218
236
  function classifyRecorded({ isError, exitCode, text }) {
219
- if (exitCode === 0) return "PASS";
220
- if (exitCode == null && !isError) return "PASS"; // no failure signal recorded → treat as pass (never invent)
221
237
  const ev = String(text || "");
238
+ // Prefer the structured exit code (Codex); else recover a real code from the text (Claude). This makes
239
+ // the numeric-exit-code branch reachable for Claude, so 127/126 route to HARNESS_ERROR (no cry-wolf) and
240
+ // a textual non-zero exit is caught even when is_error wasn't set.
241
+ const code = typeof exitCode === "number" ? exitCode : exitCodeFromText(ev);
242
+ if (code === 0) return "PASS";
243
+ if (code == null && !isError) return "PASS"; // no failure signal at all → never invent a lie
222
244
  let err;
223
245
  if (/\btimed?\s*out\b|timeout after|\bSIGKILL\b|\bSIGTERM\b/i.test(ev)) {
224
246
  err = { killed: true, signal: "SIGTERM" }; // classifyOutcome → HARNESS_ERROR
225
- } else if (typeof exitCode === "number" && exitCode !== 0) {
226
- err = { code: exitCode }; // real recorded non-zero exit (127/126 → HARNESS_ERROR in classifyOutcome)
247
+ } else if (typeof code === "number" && code !== 0) {
248
+ err = { code }; // real recorded non-zero exit (127/126 → HARNESS_ERROR in classifyOutcome)
227
249
  } else {
228
- err = { code: 1 }; // Claude is_error with no numeric code → generic non-zero
250
+ err = { code: 1 }; // is_error flagged but no numeric code recorded → generic non-zero
229
251
  }
230
252
  return classifyOutcome(err, ev);
231
253
  }
@@ -315,8 +337,16 @@ export function runScan() {
315
337
  }
316
338
 
317
339
  // ---------- (6) render the honest report ----------
340
+ // claim_text / proof_command / evidence are UNTRUSTED captured strings (agent transcript + test output)
341
+ // rendered straight to a live terminal. Strip ESC + other C0 control chars FIRST (same defense as
342
+ // card.mjs / analytics.mjs `clean`) so a hostile transcript row can't inject ANSI/CSI/OSC escapes
343
+ // (color, cursor moves, title/clipboard spoofing) — then collapse whitespace and cap length.
318
344
  function clip(s, n) {
319
345
  const t = String(s || "")
346
+ // eslint-disable-next-line no-control-regex
347
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // ANSI CSI sequences
348
+ // eslint-disable-next-line no-control-regex
349
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ") // other control chars (incl. ESC/BEL) → space
320
350
  .replace(/\s+/g, " ")
321
351
  .trim();
322
352
  return t.length > n ? t.slice(0, n - 1) + "…" : t;
@@ -348,7 +378,11 @@ export function renderReport({ scanned, catches }) {
348
378
  return L.join("\n");
349
379
  }
350
380
 
351
- const cmds = [...new Set(catches.map((c) => c.proof_command.split(/\s+/).slice(0, 3).join(" ")))];
381
+ // clip() here too: the headline command phrase is built from the untrusted proof_command and rendered
382
+ // to the terminal, so it needs the same ESC/control-char stripping as the receipt bodies below.
383
+ const cmds = [
384
+ ...new Set(catches.map((c) => clip(c.proof_command, 64).split(/\s+/).slice(0, 3).join(" "))),
385
+ ];
352
386
  const cmdPhrase = cmds.length === 1 ? `\`${cmds[0]}\`` : "its own test/build";
353
387
  L.push("");
354
388
  L.push(` skalpel scanned ${N} of your recent sessions and found`);
@@ -215,11 +215,23 @@ async function sleep(ms) {
215
215
  await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
216
216
  }
217
217
 
218
- async function request(fetchFn, url, init, timeoutMs) {
218
+ // fetch() resolves as soon as the response HEADERS arrive; the BODY then streams separately. Clearing the
219
+ // abort timer the instant fetch() resolves (the old `finally`) left every body read — `res.json()` at the
220
+ // call sites — with NO deadline, so a slow or hung response body could stall the drain loop forever. Keep
221
+ // the timer armed and read the FULL body HERE, under the same timeout; hand callers a tiny response-like
222
+ // shim exposing only what they use (status, ok, json()). A stalled body now aborts and rejects instead of
223
+ // hanging. Callers only ever read status/ok and `await response.json().catch(...)`, so the shim suffices.
224
+ export async function request(fetchFn, url, init, timeoutMs) {
219
225
  const ctrl = new AbortController();
220
226
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
221
227
  try {
222
- return await fetchFn(url, { ...init, signal: ctrl.signal });
228
+ const res = await fetchFn(url, { ...init, signal: ctrl.signal });
229
+ const body = await res.text(); // fully consume the body while the timeout is still armed
230
+ return {
231
+ status: res.status,
232
+ ok: res.ok,
233
+ json: async () => JSON.parse(body), // async so callers' `.json().catch(...)` catches parse errors
234
+ };
223
235
  } finally {
224
236
  clearTimeout(timer);
225
237
  }
package/insights.mjs CHANGED
@@ -17,6 +17,9 @@ import { join } from "node:path";
17
17
 
18
18
  const DIR = join(homedir(), ".skalpel");
19
19
  export const INSIGHTS_PATH = join(DIR, "insights.ndjson");
20
+ // The persisted client config `skalpel setup` writes (api/user/verify). Read HERE too so the ONE arming
21
+ // resolver can consult the consented `verify` key without any writer/reader plumbing.
22
+ export const CLIENT_JSON_PATH = join(DIR, "client.json");
20
23
 
21
24
  // ---- verify-reveal: the opt-in "aha catch" surface — single source for the flag + the state-file path.
22
25
  // The reveal is WRITTEN by verify-shadow.mjs (the detached proof re-runner, when the agent claimed
@@ -30,10 +33,188 @@ export const REVEAL_PATH = join(DIR, "verify-reveal.json");
30
33
  // rare red catch land like a thunderclap — without a stream of honest verified stamps a lone red reads as
31
34
  // noise, not a verdict. Path single-sourced HERE alongside REVEAL_PATH so writer + reader never drift.
32
35
  export const VERDICT_PATH = join(DIR, "verify-verdict.json");
33
- // SKALPEL_VERIFY_REVEAL is OPT-IN and OFF BY DEFAULT. Unset / "" / "0" / "off" / "false" / "no" → DARK
34
- // (byte-identical to the shadow-only behavior). Only an explicit truthy value arms the visible reveal.
36
+ // ---- THE ONE ARMING RESOLVER (single source of truth) --------------------------------------------
37
+ // The "aha catch" is OPT-IN and OFF BY DEFAULT. Arming is resolved by ONE function everyone reads
38
+ // the hook spawn gate, the statusline reveal, verify-shadow's surfacing, and reveal.mjs's agent note —
39
+ // so writer, reader, and gate can never drift. PRECEDENCE:
40
+ // 1. ENV OVERRIDE (dev/CI): SKALPEL_VERIFY_REVEAL, explicit on OR off, always wins.
41
+ // 2. CONSENTED CONFIG: ~/.skalpel/client.json "verify" key ("on"/"off"), written by `skalpel setup`.
42
+ // 3. DEFAULT OFF — an absent config + unset env is DARK, byte-identical to the shadow-only baseline.
43
+ // Only an explicit consented "on" (or a dev env override) arms it; existing installs stay dark.
44
+ const ARM_TRUTHY = /^(1|true|on|yes)$/i;
45
+ const ARM_FALSY = /^(0|false|off|no)$/i;
46
+ // parseArmToken(v) → true (armed) | false (disarmed) | null (no opinion — unset/blank/unrecognized).
47
+ export function parseArmToken(v) {
48
+ const s = String(v ?? "").trim();
49
+ if (ARM_TRUTHY.test(s)) return true;
50
+ if (ARM_FALSY.test(s)) return false;
51
+ return null;
52
+ }
53
+ // verifyConfigArmed() → the persisted consent from client.json's "verify" key, or null if unset/unreadable.
54
+ // Fail-open: any read/parse error → null (no opinion → falls through to DEFAULT OFF). Never throws.
55
+ export function verifyConfigArmed() {
56
+ try {
57
+ return parseArmToken(JSON.parse(readFileSync(CLIENT_JSON_PATH, "utf8"))?.verify);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ // revealEnabled(env) — is the USER-VISIBLE reveal armed? env override > consented config > default off.
63
+ // SKALPEL_VERIFY_SHADOW is deliberately NOT consulted here: it arms only the DARK log-only shadow (no
64
+ // reveal); the spawn gate ORs it in separately (verifySpawnArmed). Wrapped so it can never throw.
35
65
  export function revealEnabled(env) {
36
- return /^(1|true|on|yes)$/i.test(String((env || {}).SKALPEL_VERIFY_REVEAL || "").trim());
66
+ try {
67
+ const envOverride = parseArmToken((env || {}).SKALPEL_VERIFY_REVEAL);
68
+ if (envOverride !== null) return envOverride; // 1) dev/CI env override wins
69
+ const cfg = verifyConfigArmed();
70
+ if (cfg !== null) return cfg; // 2) consented config
71
+ } catch {
72
+ /* fall through to default */
73
+ }
74
+ return false; // 3) DEFAULT OFF
75
+ }
76
+ // verifySpawnArmed(env) — should the per-turn hook SPAWN the detached shadow worker at all? True when the
77
+ // reveal is armed OR the dev log-only shadow (SKALPEL_VERIFY_SHADOW=1) is set. This is the single gate the
78
+ // hook reads; it stays byte-identical to the prior inline `SHADOW==="1" || revealEnabled(env)` when the
79
+ // config is absent, so no existing install starts spawning.
80
+ export function verifySpawnArmed(env) {
81
+ try {
82
+ if (String((env || {}).SKALPEL_VERIFY_SHADOW || "").trim() === "1") return true;
83
+ return revealEnabled(env);
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ // ---- FALSE-ACCUSATION SAFEGUARDS: adjudication ledger + derived auto-darken -----------------------
90
+ // A "was this right?" flight recorder. `skalpel yes|no` appends ONE row here keyed to the most-recent
91
+ // reveal; precision = right/(right+wrong) is derived from these rows, never a stored number that can lie.
92
+ export const ADJUDICATIONS_PATH = join(DIR, "verify-adjudications.ndjson");
93
+ // Every reveal actually SURFACED to the user is logged here (append-only) so `skalpel yes|no` can key an
94
+ // adjudication to the real reveal it saw, and the field-proof report can count real fires.
95
+ export const REVEALS_LOG_PATH = join(DIR, "verify-reveals.ndjson");
96
+ // Session-scoped suppression: a `skalpel no` writes the (session, proof) here so that proof never
97
+ // re-accuses for the rest of THAT session (matched by session id → a new session is never suppressed).
98
+ export const SUPPRESS_PATH = join(DIR, "verify-suppressed.ndjson");
99
+
100
+ // Auto-darken thresholds. If adjudicated precision < 90% over >= 10 adjudications, the LIVE red reveal
101
+ // self-darkens (derived from these rows every read — NOT a stored flag) and falls back to `skalpel catches`.
102
+ export const AUTO_DARKEN_MIN_ADJ = 10;
103
+ export const AUTO_DARKEN_MIN_PRECISION = 0.9;
104
+
105
+ // proofSig(s) — normalize a proof command for stable suppression matching (collapse whitespace, bound).
106
+ export function proofSig(s) {
107
+ return String(s ?? "")
108
+ .replace(/\s+/g, " ")
109
+ .trim()
110
+ .slice(0, 200);
111
+ }
112
+
113
+ function readNdjson(path, cap = 5000) {
114
+ try {
115
+ const rows = readFileSync(path, "utf8").trim().split("\n").filter(Boolean);
116
+ const out = [];
117
+ for (const l of rows.slice(-cap)) {
118
+ try {
119
+ out.push(JSON.parse(l));
120
+ } catch {
121
+ /* skip a partial/garbage line */
122
+ }
123
+ }
124
+ return out;
125
+ } catch {
126
+ return [];
127
+ }
128
+ }
129
+
130
+ // adjudicationStats() → { right, wrong, total, precision } from the REAL adjudication rows. precision is
131
+ // right/(right+wrong) or null when there are none. Only "yes"/"no" verdicts count. Never throws.
132
+ export function adjudicationStats() {
133
+ let right = 0;
134
+ let wrong = 0;
135
+ for (const r of readNdjson(ADJUDICATIONS_PATH)) {
136
+ if (r && r.verdict === "yes") right++;
137
+ else if (r && r.verdict === "no") wrong++;
138
+ }
139
+ const total = right + wrong;
140
+ return { right, wrong, total, precision: total > 0 ? right / total : null };
141
+ }
142
+
143
+ // autoDarkened() → is the live red reveal auto-paused? DERIVED from the adjudication rows every call (never
144
+ // a stored boolean): true only once there are >= AUTO_DARKEN_MIN_ADJ adjudications AND precision < 90%.
145
+ // Fail-open: any error → false (stay live — a broken read must never silently darken the catch).
146
+ export function autoDarkened() {
147
+ try {
148
+ const { total, precision } = adjudicationStats();
149
+ return (
150
+ total >= AUTO_DARKEN_MIN_ADJ && precision !== null && precision < AUTO_DARKEN_MIN_PRECISION
151
+ );
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function appendNdjson(path, row) {
158
+ try {
159
+ mkdirSync(DIR, { recursive: true });
160
+ appendFileSync(path, JSON.stringify(row) + "\n");
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ // appendAdjudication(row) — ONE flight-recorder row (verdict yes/no, keyed to a reveal). Never throws.
168
+ export function appendAdjudication(row) {
169
+ return appendNdjson(ADJUDICATIONS_PATH, { ts: new Date().toISOString(), ...row });
170
+ }
171
+
172
+ // appendRevealLog(row) — record a reveal that was actually SHOWN to the user (append-only, bounded).
173
+ export function appendRevealLog(row) {
174
+ return appendNdjson(REVEALS_LOG_PATH, { ts: new Date().toISOString(), ...row });
175
+ }
176
+
177
+ // lastReveal() — the most-recent reveal actually shown (the row `skalpel yes|no` adjudicates). Null if none.
178
+ export function lastReveal() {
179
+ const rows = readNdjson(REVEALS_LOG_PATH);
180
+ return rows.length ? rows[rows.length - 1] : null;
181
+ }
182
+
183
+ // suppressProof(session, proof) — after a `skalpel no`, stop this proof re-accusing for the rest of the
184
+ // session. Requires a real session id (a null-session suppression would over-suppress). Never throws.
185
+ export function suppressProof(session, proof) {
186
+ const sid = session ? String(session) : "";
187
+ const sig = proofSig(proof);
188
+ if (!sid || !sig) return false;
189
+ return appendNdjson(SUPPRESS_PATH, { ts: new Date().toISOString(), session: sid, proof: sig });
190
+ }
191
+
192
+ // isProofSuppressed(session, proof) — has this (session, proof) been marked "no" this session? Both sides
193
+ // must have a real session id and they must match, plus the normalized proof must match. Fail-open → false.
194
+ export function isProofSuppressed(session, proof) {
195
+ try {
196
+ const sid = session ? String(session) : "";
197
+ const sig = proofSig(proof);
198
+ if (!sid || !sig) return false;
199
+ for (const r of readNdjson(SUPPRESS_PATH)) {
200
+ if (r && String(r.session || "") === sid && proofSig(r.proof) === sig) return true;
201
+ }
202
+ return false;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ // verifyArmedAt() — the real ISO timestamp `verify` was last turned ON (recorded in client.json at consent
209
+ // time), as a Date, or null. Used only for the honest "armed N days" line — a recorded fact, not a metric.
210
+ export function verifyArmedAt() {
211
+ try {
212
+ const t = JSON.parse(readFileSync(CLIENT_JSON_PATH, "utf8"))?.verify_armed_at;
213
+ const d = t ? new Date(t) : null;
214
+ return d && Number.isFinite(d.getTime()) ? d : null;
215
+ } catch {
216
+ return null;
217
+ }
37
218
  }
38
219
 
39
220
  // Unlike metrics.ndjson (write-only telemetry, uncapped), this file is READ by a TUI every render —
package/ledger.mjs CHANGED
@@ -106,8 +106,10 @@ function firstGreater(sorted, x) {
106
106
  // even in a tampered log its own ts equals t0, which the strict `> t0` search already excludes — so the
107
107
  // self-skip was a no-op and omitting it changes nothing (proven by the equivalence fixtures).
108
108
  // opts.excludeNullSession — when true, a false row with a null session yields null (the statusline's
109
- // Finding-3 guard). ledger.mjs's own view keeps the historical behavior (null-session rows CAN pair with
110
- // other null-session rows) by leaving it false, so its results stay byte-identical to the old code.
109
+ // Finding-3 guard). Both live views (ledger.mjs AND the statusline) now pass it true: a NULL session
110
+ // carries no real identity, so `(r.session ?? null) === (falseRow.session ?? null)` would read null===null
111
+ // and pair a caught-false row with an UNRELATED null-session PASS of the same proof — a bogus, cross-
112
+ // attributed fix delta. Requiring a real session id to measure a fix is the honest floor.
111
113
  export function buildFixDeltas(rows, { excludeNullSession = false } = {}) {
112
114
  const index = new Map(); // (session ?? null) → Map<proof_command, ascending ts[]>
113
115
  for (const r of rows || []) {
@@ -180,7 +182,10 @@ export function buildLedger(rows) {
180
182
  const falseEntries = entries.filter((e) => e.verdict === "FALSE");
181
183
  // Attach the MEASURED fix delta where — and only where — a real later same-proof PASS exists. ONE index
182
184
  // pass (buildFixDeltas) then a binary-search per false row: O(n log n), not the old O(falseCount × rows).
183
- const fixDeltaFor = buildFixDeltas(rows || []);
185
+ // excludeNullSession: a caught-false row with a NULL session can't be tied to a fix (null carries no
186
+ // identity), so it never cross-attributes another null-session run's PASS — the same guard the statusline
187
+ // sibling applies. Count-only for such rows, never a fabricated delta.
188
+ const fixDeltaFor = buildFixDeltas(rows || [], { excludeNullSession: true });
184
189
  for (const e of falseEntries) {
185
190
  const d = fixDeltaFor(e._row);
186
191
  e.fix_delta_ms = d ? d.ms : null;
@@ -198,8 +203,16 @@ export function buildLedger(rows) {
198
203
  }
199
204
 
200
205
  // ---------- (4) render ----------
206
+ // claim_text / proof_command / evidence are UNTRUSTED captured strings (agent transcript + test output)
207
+ // rendered straight to a live terminal. Strip ESC + other C0 control chars FIRST (same defense as
208
+ // card.mjs / analytics.mjs `clean`) so a tampered/hostile log row can't inject ANSI/CSI/OSC escapes
209
+ // (color, cursor moves, title/clipboard spoofing) — then collapse whitespace and cap length.
201
210
  function clip(s, n) {
202
211
  const t = String(s || "")
212
+ // eslint-disable-next-line no-control-regex
213
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // ANSI CSI sequences
214
+ // eslint-disable-next-line no-control-regex
215
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ") // other control chars (incl. ESC/BEL) → space
203
216
  .replace(/\s+/g, " ")
204
217
  .trim();
205
218
  return t.length > n ? t.slice(0, n - 1) + "…" : t;
package/login.mjs CHANGED
@@ -4,11 +4,12 @@
4
4
  // the token → save it. The server holds the client secret and does the code exchange; we only ever
5
5
  // see the finished Google id_token. Branded onboarding reproduces the original prosumer `setup.mjs`
6
6
  // red block wordmark 1:1. Run: `node skalpel-login.mjs`.
7
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join, dirname } from "node:path";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { spawn } from "node:child_process";
12
+ import { fileURLToPath } from "node:url";
12
13
 
13
14
  const API = process.env.SKALPEL_API || "https://graph.skalpel.ai";
14
15
  const CLIENT_ID =
@@ -51,15 +52,38 @@ function authPath() {
51
52
  return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "skalpel", "auth.json");
52
53
  }
53
54
 
55
+ // Build the spawn recipe to open a URL in the default browser, safely, per-platform. Windows is the sharp
56
+ // edge: `start` is a cmd builtin (so it needs cmd.exe), and the OAuth URL is full of `&` param separators.
57
+ // The old path — spawn("start", [url], { shell: true }) — let cmd SPLIT the URL on its unquoted `&`, so
58
+ // sign-in silently broke (URL truncated at the first `&`) and any `&<token>` ran as a separate command
59
+ // (injection surface). Here we spawn cmd.exe with NO shell and windowsVerbatimArguments, quoting the URL
60
+ // ourselves so cmd treats the whole thing as one literal token. `'""'` is start's window title — it must be
61
+ // a LITERAL empty quote-pair (a zero-length JS string would contribute 0 chars to the verbatim command
62
+ // line, so start would consume the URL itself as the title and never open the browser). Any stray
63
+ // double-quote in the URL is stripped so it can't break out of its quotes (URLSearchParams never emits one
64
+ // anyway). macOS/Linux never use a shell, so their URL already rides as a single argv entry — unchanged.
65
+ export function browserOpenCommand(url, platform = process.platform) {
66
+ if (platform === "win32") {
67
+ const safe = String(url).replace(/"/g, "");
68
+ return {
69
+ cmd: "cmd.exe",
70
+ args: ["/c", "start", '""', `"${safe}"`],
71
+ opts: {
72
+ stdio: "ignore",
73
+ detached: true,
74
+ windowsHide: true,
75
+ windowsVerbatimArguments: true,
76
+ },
77
+ };
78
+ }
79
+ const cmd = platform === "darwin" ? "open" : "xdg-open";
80
+ return { cmd, args: [url], opts: { stdio: "ignore", detached: true } };
81
+ }
82
+
54
83
  function openBrowser(url) {
55
- const cmd =
56
- process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
57
84
  try {
58
- spawn(cmd, [url], {
59
- stdio: "ignore",
60
- detached: true,
61
- shell: process.platform === "win32",
62
- }).unref();
85
+ const { cmd, args, opts } = browserOpenCommand(url);
86
+ spawn(cmd, args, opts).unref();
63
87
  } catch {
64
88
  /* manual-paste fallback below */
65
89
  }
@@ -158,7 +182,23 @@ async function main() {
158
182
  process.exit(0);
159
183
  }
160
184
 
161
- main().catch((e) => {
162
- console.log(` ${R}✗${X} login error: ${D}${e?.message || String(e)}${X}\x1b[?25h`);
163
- process.exit(1);
164
- });
185
+ // CLI guard: run the sign-in flow only when executed directly (`node login.mjs`, how skalpel-setup.mjs
186
+ // always spawns it), never on import — so browserOpenCommand can be imported/tested without launching a
187
+ // real OAuth flow. Mirrors the isMain idiom in verify-shadow.mjs / ledger.mjs.
188
+ const isMain = (() => {
189
+ try {
190
+ return (
191
+ Boolean(process.argv[1]) &&
192
+ realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
193
+ );
194
+ } catch {
195
+ return false;
196
+ }
197
+ })();
198
+
199
+ if (isMain) {
200
+ main().catch((e) => {
201
+ console.log(` ${R}✗${X} login error: ${D}${e?.message || String(e)}${X}\x1b[?25h`);
202
+ process.exit(1);
203
+ });
204
+ }