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 +18 -0
- package/anchor-shadow.mjs +20 -8
- package/autopsy.mjs +38 -8
- package/catches.mjs +40 -6
- package/incremental-ingest.mjs +14 -2
- package/insights.mjs +184 -3
- package/ledger.mjs +16 -3
- package/login.mjs +52 -12
- package/mutation-check.mjs +158 -0
- package/package.json +1 -1
- package/skalpel-hook.mjs +6 -8
- package/skalpel-setup.mjs +148 -0
- package/skalpel-statusline.mjs +16 -2
- package/verify-core.test.mjs +186 -0
- package/verify-integration.test.mjs +194 -0
- package/verify-safeguards.test.mjs +178 -0
- package/verify-shadow.mjs +287 -16
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
let
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
|
226
|
-
err = { code
|
|
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 }; //
|
|
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
|
-
|
|
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`);
|
package/incremental-ingest.mjs
CHANGED
|
@@ -215,11 +215,23 @@ async function sleep(ms) {
|
|
|
215
215
|
await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
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
|
|
110
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
}
|