skalpel 4.0.40 → 4.0.42
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/insights.mjs +184 -3
- package/install.mjs +96 -28
- package/mutation-check.mjs +158 -0
- package/package.json +1 -1
- package/postinstall.mjs +33 -6
- 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/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/install.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
mkdirSync,
|
|
9
9
|
existsSync,
|
|
10
10
|
copyFileSync,
|
|
11
|
+
renameSync,
|
|
11
12
|
rmSync,
|
|
12
13
|
readdirSync,
|
|
13
14
|
} from "node:fs";
|
|
@@ -42,40 +43,95 @@ const SESSION_FILE = join(HOOKS_DIR, "skalpel-hook-session.mjs");
|
|
|
42
43
|
const SESSION_END_FILE = join(HOOKS_DIR, "skalpel-hook-session-end.mjs");
|
|
43
44
|
const STATUSLINE_FILE = join(HOOKS_DIR, "skalpel-statusline.mjs");
|
|
44
45
|
|
|
45
|
-
//
|
|
46
|
-
// wired
|
|
46
|
+
// The COMPLETE import closure that must live in ~/.skalpel/hooks: the CLI entrypoints Claude Code /
|
|
47
|
+
// Codex are wired to, plus every module those entrypoints (transitively) import. A hook wired to an
|
|
48
|
+
// absolute path whose imported module is absent crashes on EVERY invocation with
|
|
49
|
+
// `SyntaxError: ... does not provide an export named ...` / ERR_MODULE_NOT_FOUND — the exact
|
|
50
|
+
// 0.3.12–0.3.16 bug shape. Keep this list a superset of the closure of skalpel-hook.mjs /
|
|
51
|
+
// skalpel-hook-session.mjs / skalpel-hook-session-end.mjs / skalpel-statusline.mjs (see the trailing
|
|
52
|
+
// note on each line for who imports it). Order is irrelevant now — staging is atomic (all files land
|
|
53
|
+
// in a temp dir; the temp dir is only swapped into place after every copy succeeds).
|
|
54
|
+
const RUNTIME_FILES = [
|
|
55
|
+
"insights.mjs", // hooks + statusline + reveal + verify-shadow import ./insights.mjs
|
|
56
|
+
"reveal.mjs", // both prompt/session hooks import ./reveal.mjs
|
|
57
|
+
"transcript.mjs", // skalpel-hook + skalpel-statusline + verify-shadow import ./transcript.mjs (tailLines)
|
|
58
|
+
"skalpel-hook.mjs", // UserPromptSubmit entrypoint (wired)
|
|
59
|
+
"skalpel-hook-session.mjs", // SessionStart entrypoint (wired)
|
|
60
|
+
"incremental-ingest.mjs", // skalpel-hook-session-end.mjs imports ./incremental-ingest.mjs
|
|
61
|
+
"skalpel-hook-session-end.mjs", // SessionEnd entrypoint (wired)
|
|
62
|
+
"skalpel-statusline.mjs", // statusLine entrypoint (wired)
|
|
63
|
+
"anchor-shadow.mjs", // verify-shadow.mjs (staged worker) imports ./anchor-shadow.mjs — probe primitives
|
|
64
|
+
"verify-shadow.mjs", // claim-verification SHADOW worker (spawned by skalpel-hook.mjs)
|
|
65
|
+
"auth.mjs", // hooks import ./auth.mjs
|
|
66
|
+
"metrics.mjs", // hooks import ./metrics.mjs
|
|
67
|
+
"stats.mjs", // `node ~/.skalpel/hooks/stats.mjs` — local read-only accumulator
|
|
68
|
+
"autopsy.mjs", // `node ~/.skalpel/hooks/autopsy.mjs` — local read-only receipt
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// Stage the hook runtime into ~/.skalpel/hooks so the wired absolute path is always valid, ATOMICALLY:
|
|
72
|
+
// copy the whole closure into a fresh temp dir and only swap it into place after EVERY copy succeeds.
|
|
73
|
+
// This is the fix for the partial-upgrade fail-open: the old in-place overwrite copied the new
|
|
74
|
+
// skalpel-hook.mjs BEFORE its auth.mjs/metrics.mjs, so a mid-copy I/O failure on an UPGRADE (hooks
|
|
75
|
+
// already wired to the stable path) left the NEW hook next to the OLD auth at the live path — every
|
|
76
|
+
// turn then crashed with a missing-export SyntaxError, and the all-or-nothing guard below only
|
|
77
|
+
// refused to WIRE, never rolled back the already-clobbered files. With atomic staging a failed copy
|
|
78
|
+
// leaves the EXISTING hooks dir 100% intact.
|
|
47
79
|
function stageHookRuntime() {
|
|
48
80
|
// Login and setup repair paths invoke the already-staged copy. Avoid copying a file onto itself.
|
|
49
81
|
if (PKG_DIR === HOOKS_DIR) return;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
const SKALPEL_DIR = dirname(HOOKS_DIR); // ~/.skalpel — staging/backup are siblings of hooks so every
|
|
83
|
+
const stagingDir = join(SKALPEL_DIR, `hooks.staging-${process.pid}`); // rename below is same-filesystem
|
|
84
|
+
const backupDir = join(SKALPEL_DIR, `hooks.bak-${process.pid}`); // (atomic) and never crosses devices.
|
|
85
|
+
|
|
86
|
+
// 1) Copy the WHOLE closure into a fresh temp dir. Scoped to THIS pid so a concurrent installer's
|
|
87
|
+
// staging-<otherpid> is never disturbed; drop any leftover from a crashed prior run of this pid.
|
|
88
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
89
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
90
|
+
try {
|
|
91
|
+
for (const name of RUNTIME_FILES) {
|
|
92
|
+
copyFileSync(join(PKG_DIR, name), join(stagingDir, name));
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// A copy failed part-way (incomplete tarball / disk error). The LIVE hooks dir was never touched —
|
|
96
|
+
// clean up the partial staging dir and rethrow so the caller refuses to wire.
|
|
97
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2) All copies succeeded — swap staging into place. rename can't overwrite a non-empty dir, so move
|
|
102
|
+
// the old dir aside first, move the new one in, then delete the old. Both renames are same-device
|
|
103
|
+
// (siblings under ~/.skalpel), so each is atomic. On ANY failure — including the old dir being
|
|
104
|
+
// locked (Windows) so it can't even be moved aside — clean up the staging dir, and if we already
|
|
105
|
+
// moved the old dir aside, roll it back so the wired path is never left without a hooks dir.
|
|
106
|
+
rmSync(backupDir, { recursive: true, force: true }); // stale bak from a crashed prior same-pid run
|
|
107
|
+
const hadLive = existsSync(HOOKS_DIR);
|
|
108
|
+
try {
|
|
109
|
+
if (hadLive) renameSync(HOOKS_DIR, backupDir);
|
|
110
|
+
renameSync(stagingDir, HOOKS_DIR);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (hadLive && !existsSync(HOOKS_DIR) && existsSync(backupDir)) {
|
|
113
|
+
try {
|
|
114
|
+
renameSync(backupDir, HOOKS_DIR); // restore the previous working install
|
|
115
|
+
} catch {
|
|
116
|
+
/* rollback best-effort; the original is still recoverable at backupDir */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
// Swap done. Dropping the old dir is best-effort: a failure here only leaves a stale hooks.bak-<pid>,
|
|
123
|
+
// never a broken wired path.
|
|
124
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
69
125
|
}
|
|
70
126
|
if (!uninstall && !process.env.SKALPEL_HOOK_BIN) {
|
|
71
127
|
try {
|
|
72
128
|
stageHookRuntime();
|
|
73
129
|
} catch (e) {
|
|
74
|
-
// Staging is all-or-nothing. A missing source file means the tarball is incomplete (a
|
|
75
|
-
// installer copies was left out of package.json "files")
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
130
|
+
// Staging is atomic and all-or-nothing. A missing source file means the tarball is incomplete (a
|
|
131
|
+
// file this installer copies was left out of package.json "files"); any copy error aborts staging
|
|
132
|
+
// with the EXISTING hooks dir left fully intact (never a half-updated dir at the wired path).
|
|
133
|
+
// Wiring on top of a failed stage would risk hooks that crash on every prompt — so refuse to wire
|
|
134
|
+
// instead; a loud failure beats a silent no-op.
|
|
79
135
|
console.error(`skalpel: could not stage hook runtime: ${e.message}`);
|
|
80
136
|
console.error("skalpel: hooks were NOT wired.");
|
|
81
137
|
process.exit(1);
|
|
@@ -222,7 +278,12 @@ function claude() {
|
|
|
222
278
|
} else if (statuslineOnly) {
|
|
223
279
|
return "preserved"; // a custom status line wins; do not rewrite unrelated Claude settings
|
|
224
280
|
}
|
|
225
|
-
|
|
281
|
+
// Only write when we actually added, refreshed, or removed one of OUR entries (mirrors codexToml's
|
|
282
|
+
// `if (hadToml) write` guard). Never CREATE a settings.json — nor re-serialize/churn an existing one
|
|
283
|
+
// — when there was nothing to do: an `uninstall` on a machine that never had our hooks leaves the
|
|
284
|
+
// file exactly as it was (or absent). Install always changes something here, so this only suppresses
|
|
285
|
+
// the no-op write.
|
|
286
|
+
if (installed || refreshed || removed) writeJson(CLAUDE, d);
|
|
226
287
|
if (uninstall) return removed ? "removed" : "absent";
|
|
227
288
|
return installed ? "installed" : refreshed ? "refreshed" : "absent";
|
|
228
289
|
}
|
|
@@ -378,8 +439,15 @@ function codexJson() {
|
|
|
378
439
|
installed += 1;
|
|
379
440
|
}
|
|
380
441
|
}
|
|
442
|
+
if (uninstall) {
|
|
443
|
+
// Never CREATE ~/.codex/hooks.json during an uninstall — only rewrite it when we actually stripped
|
|
444
|
+
// one of our blocks from an existing file (mirrors codexToml's hadToml guard). A machine whose
|
|
445
|
+
// ~/.codex has no hooks.json (or none of ours) has nothing to clean up, so writing here would drop
|
|
446
|
+
// a stray file behind an operation that's supposed to leave the machine clean.
|
|
447
|
+
if (removed) writeJson(CODEX, d);
|
|
448
|
+
return removed ? "removed" : "absent";
|
|
449
|
+
}
|
|
381
450
|
writeJson(CODEX, d);
|
|
382
|
-
if (uninstall) return removed ? "removed" : "absent";
|
|
383
451
|
return installed ? "installed" : refreshed ? "refreshed" : "absent";
|
|
384
452
|
}
|
|
385
453
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mutation-check.mjs — mutation testing for the CRITICAL LOGIC: detectCompletionClaim (incl. the negation
|
|
3
|
+
// guard), classifyOutcome, classifyConfirm, plus the false-accusation safeguards shipUrlPass (URL
|
|
4
|
+
// reachability) and hasMutatingFlag (non-idempotent re-run guard). For each mutation we flip ONE key
|
|
5
|
+
// predicate/branch in verify-shadow.mjs and re-run the pure-function suite (verify-core.test.mjs) against
|
|
6
|
+
// the mutated copy. A mutation the suite still PASSES is a SURVIVOR (a test gap). Gate: survivors must be
|
|
7
|
+
// 0. Run: `node mutation-check.mjs`.
|
|
8
|
+
import { cpSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const TARGET = "verify-shadow.mjs";
|
|
16
|
+
const SUITE = "verify-core.test.mjs";
|
|
17
|
+
|
|
18
|
+
// Each mutation: a UNIQUE substring in verify-shadow.mjs and the flipped version. Every one MUST be caught
|
|
19
|
+
// by at least one assertion in verify-core.test.mjs.
|
|
20
|
+
const MUTATIONS = [
|
|
21
|
+
// ---- detectCompletionClaim (incl. the negation/hedge guard, Fix 2) ----
|
|
22
|
+
{
|
|
23
|
+
fn: "detectCompletionClaim",
|
|
24
|
+
find: 'if (!CLAIM_RE.test(s)) return { claim: false, text: "" };',
|
|
25
|
+
replace: 'if (CLAIM_RE.test(s)) return { claim: false, text: "" };',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
fn: "detectCompletionClaim",
|
|
29
|
+
find: "if (!l || !CLAIM_RE.test(l)) continue;",
|
|
30
|
+
replace: "if (!l || CLAIM_RE.test(l)) continue;",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
fn: "detectCompletionClaim",
|
|
34
|
+
find: "if (NEGATION_RE.test(l)) continue; // negated/hedged → not a completion claim",
|
|
35
|
+
replace: "if (!NEGATION_RE.test(l)) continue; // negated/hedged → not a completion claim",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
fn: "detectCompletionClaim",
|
|
39
|
+
find: 'if (NEGATION_RE.test(s)) return { claim: false, text: "" };',
|
|
40
|
+
replace: 'if (!NEGATION_RE.test(s)) return { claim: false, text: "" };',
|
|
41
|
+
},
|
|
42
|
+
// ---- shipUrlPass (Fix 1: URL false-red safeguard) ----
|
|
43
|
+
{
|
|
44
|
+
fn: "shipUrlPass",
|
|
45
|
+
find: "if (urlResponded(ev1)) return true;",
|
|
46
|
+
replace: "if (urlResponded(ev1)) return false;",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
fn: "shipUrlPass",
|
|
50
|
+
find: " if (urlResponded(ev2)) return true;\n return false;",
|
|
51
|
+
replace: " if (urlResponded(ev2)) return true;\n return true;",
|
|
52
|
+
},
|
|
53
|
+
// ---- hasMutatingFlag (Fix 3: non-idempotent re-run guard) ----
|
|
54
|
+
{
|
|
55
|
+
fn: "hasMutatingFlag",
|
|
56
|
+
find: "if (a.some((x) => MUTATING_FLAG.test(String(x)))) return true;",
|
|
57
|
+
replace: "if (a.some((x) => MUTATING_FLAG.test(String(x)))) return false;",
|
|
58
|
+
},
|
|
59
|
+
// ---- classifyOutcome ----
|
|
60
|
+
{
|
|
61
|
+
fn: "classifyOutcome",
|
|
62
|
+
find: 'if (!err) return "PASS";',
|
|
63
|
+
replace: 'if (err) return "PASS";',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
fn: "classifyOutcome",
|
|
67
|
+
find: 'if (err.killed || err.signal === "SIGTERM") return "HARNESS_ERROR"; // hit the timeout',
|
|
68
|
+
replace: 'if (err.killed && err.signal === "SIGTERM") return "PASS"; // hit the timeout',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
fn: "classifyOutcome",
|
|
72
|
+
find: 'if (exit === 127 || exit === 126) return "HARNESS_ERROR"; // command not found / not executable',
|
|
73
|
+
replace:
|
|
74
|
+
'if (exit === 127 && exit === 126) return "HARNESS_ERROR"; // command not found / not executable',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
fn: "classifyOutcome",
|
|
78
|
+
find: 'return "GENUINE_FAIL"; // a real non-zero exit from the test/build/lint itself',
|
|
79
|
+
replace: 'return "HARNESS_ERROR"; // a real non-zero exit from the test/build/lint itself',
|
|
80
|
+
},
|
|
81
|
+
// ---- classifyConfirm (the flake-confirm state machine) ----
|
|
82
|
+
{
|
|
83
|
+
fn: "classifyConfirm",
|
|
84
|
+
find: 'const needsConfirm = firstOutcome === "GENUINE_FAIL";',
|
|
85
|
+
replace: 'const needsConfirm = firstOutcome !== "GENUINE_FAIL";',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
fn: "classifyConfirm",
|
|
89
|
+
find: 'const confirmedFail = needsConfirm && confirmOutcome === "GENUINE_FAIL";',
|
|
90
|
+
replace: 'const confirmedFail = needsConfirm || confirmOutcome === "GENUINE_FAIL";',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
fn: "classifyConfirm",
|
|
94
|
+
find: 'const confirmedFail = needsConfirm && confirmOutcome === "GENUINE_FAIL";',
|
|
95
|
+
replace: 'const confirmedFail = needsConfirm && confirmOutcome !== "GENUINE_FAIL";',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
fn: "classifyConfirm",
|
|
99
|
+
find: "const unconfirmedFail = needsConfirm && !confirmedFail;",
|
|
100
|
+
replace: "const unconfirmedFail = needsConfirm && confirmedFail;",
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const workRoot = mkdtempSync(join(tmpdir(), "skalpel-mutants-"));
|
|
105
|
+
cpSync(HERE, join(workRoot, "src"), {
|
|
106
|
+
recursive: true,
|
|
107
|
+
filter: (s) => !s.includes("node_modules"),
|
|
108
|
+
});
|
|
109
|
+
const dir = join(workRoot, "src");
|
|
110
|
+
const targetPath = join(dir, TARGET);
|
|
111
|
+
const pristine = readFileSync(targetPath, "utf8");
|
|
112
|
+
|
|
113
|
+
function runSuite() {
|
|
114
|
+
const r = spawnSync("node", ["--test", SUITE], { cwd: dir, encoding: "utf8" });
|
|
115
|
+
return r.status; // 0 = suite passed (mutant SURVIVED); non-zero = suite failed (mutant KILLED)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Baseline: the pristine copy must pass, else the harness itself is broken.
|
|
119
|
+
if (runSuite() !== 0) {
|
|
120
|
+
console.error(
|
|
121
|
+
"BASELINE FAIL: verify-core.test.mjs does not pass on the pristine copy — aborting.",
|
|
122
|
+
);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let survivors = 0;
|
|
127
|
+
let missing = 0;
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const m of MUTATIONS) {
|
|
130
|
+
const count = pristine.split(m.find).length - 1;
|
|
131
|
+
if (count !== 1) {
|
|
132
|
+
missing++;
|
|
133
|
+
results.push(` ✗ MUTATION TARGET NOT UNIQUE (${count}×) [${m.fn}] ${m.find.slice(0, 50)}…`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
writeFileSync(targetPath, pristine.replace(m.find, m.replace));
|
|
137
|
+
const killed = runSuite() !== 0;
|
|
138
|
+
writeFileSync(targetPath, pristine); // restore
|
|
139
|
+
if (killed) {
|
|
140
|
+
results.push(` ✓ killed [${m.fn}] ${m.find.slice(0, 52)}…`);
|
|
141
|
+
} else {
|
|
142
|
+
survivors++;
|
|
143
|
+
results.push(` ☠ SURVIVED [${m.fn}] ${m.find.slice(0, 52)}…`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
148
|
+
console.log(results.join("\n"));
|
|
149
|
+
console.log(
|
|
150
|
+
`\nmutants: ${MUTATIONS.length} killed: ${MUTATIONS.length - survivors - missing} survivors: ${survivors} target-errors: ${missing}`,
|
|
151
|
+
);
|
|
152
|
+
if (survivors === 0 && missing === 0) {
|
|
153
|
+
console.log(
|
|
154
|
+
"MUTATION GATE: survivors = 0 on the critical + false-accusation-safeguard functions ✓",
|
|
155
|
+
);
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
process.exit(1);
|
package/package.json
CHANGED
package/postinstall.mjs
CHANGED
|
@@ -48,12 +48,37 @@ function retireLegacyDaemon() {
|
|
|
48
48
|
|
|
49
49
|
function stageStatusline() {
|
|
50
50
|
try {
|
|
51
|
-
// --statusline-only: stage the read-only indicator, do NOT wire prompt/session hooks yet.
|
|
52
|
-
|
|
51
|
+
// --statusline-only: stage the read-only indicator, do NOT wire prompt/session hooks yet. NOTE:
|
|
52
|
+
// this still runs the FULL hook-runtime staging inside install.mjs — so if staging genuinely fails
|
|
53
|
+
// (an incomplete package, a mid-copy I/O error) install.mjs exits non-zero. We MUST read that
|
|
54
|
+
// status: an unattended `npm install/update -g` upgrade that hits a staging failure would otherwise
|
|
55
|
+
// leave a broken/stale runtime with postinstall exiting 0 — a failure masquerading as success.
|
|
56
|
+
const r = spawnSync(process.execPath, [join(DIR, "install.mjs"), "--statusline-only"], {
|
|
53
57
|
stdio: "inherit",
|
|
54
58
|
});
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
if (r.error || r.status !== 0) {
|
|
60
|
+
// Surface it LOUDLY (stderr), but keep the fail-open posture: package.json wraps postinstall in
|
|
61
|
+
// `|| true`, so npm install itself is never bricked — the user still gets the package and can
|
|
62
|
+
// repair with `skalpel setup`. A first install where hooks aren't wired yet does NOT reach here:
|
|
63
|
+
// install.mjs only exits non-zero on a real staging failure, not on a clean statusline-only stage.
|
|
64
|
+
const detail = r.error
|
|
65
|
+
? r.error.message
|
|
66
|
+
: r.signal
|
|
67
|
+
? `killed by ${r.signal}`
|
|
68
|
+
: `exit code ${r.status}`;
|
|
69
|
+
process.stderr.write(
|
|
70
|
+
`\n skalpel: WARNING — hook staging failed (${detail}). Hooks/status line may be missing or ` +
|
|
71
|
+
`stale. Run \x1b[1mskalpel setup\x1b[22m to repair.\n\n`,
|
|
72
|
+
);
|
|
73
|
+
process.exitCode = 1; // record the failure; package.json's `|| true` keeps npm install green
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// spawn itself failed (couldn't launch node at all) — still surface, still fail-open.
|
|
79
|
+
process.stderr.write(`\n skalpel: WARNING — could not run the installer (${e.message}).\n\n`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return false;
|
|
57
82
|
}
|
|
58
83
|
}
|
|
59
84
|
|
|
@@ -75,11 +100,13 @@ function fixSudoOwnership() {
|
|
|
75
100
|
}
|
|
76
101
|
|
|
77
102
|
retireLegacyDaemon();
|
|
78
|
-
stageStatusline();
|
|
103
|
+
const stagingOk = stageStatusline();
|
|
79
104
|
fixSudoOwnership();
|
|
80
105
|
|
|
81
106
|
// A quiet nudge — the real onboarding (Google sign-in → build graph → insights) runs on `skalpel`.
|
|
82
|
-
|
|
107
|
+
// Suppressed when staging failed: the warning above is the honest signal, and a cheerful
|
|
108
|
+
// "installed" line under it would read as success.
|
|
109
|
+
if (stagingOk && process.stdout.isTTY && !process.env.CI) {
|
|
83
110
|
process.stdout.write(
|
|
84
111
|
"\n skalpel installed. Run \x1b[1mskalpel\x1b[22m to sign in and build your graph.\n\n",
|
|
85
112
|
);
|
package/skalpel-hook.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { homedir } from "node:os";
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { identity } from "./auth.mjs";
|
|
15
15
|
import { record } from "./metrics.mjs";
|
|
16
|
-
import { recordInsight, cleanText,
|
|
16
|
+
import { recordInsight, cleanText, verifySpawnArmed } from "./insights.mjs";
|
|
17
17
|
import { graphReadyNote, verifyCatchNote } from "./reveal.mjs";
|
|
18
18
|
import { tailLines } from "./transcript.mjs";
|
|
19
19
|
|
|
@@ -510,13 +510,11 @@ async function main() {
|
|
|
510
510
|
// recent test/build/typecheck/lint command and RE-RUNs it out-of-band to log the real PASS/FAIL the
|
|
511
511
|
// agent can't forge. LOG ONLY — it writes nothing to this hook's stdout and never blocks the turn (the
|
|
512
512
|
// re-run, up to 60s, lives in a separate unref'd process). Wrapped so it can never affect the injection.
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
// the
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
payload.transcript_path
|
|
519
|
-
) {
|
|
513
|
+
// ARMING is resolved by the ONE shared resolver (verifySpawnArmed): the consented `verify:"on"` in
|
|
514
|
+
// client.json OR a dev env override (SKALPEL_VERIFY_SHADOW=1 / SKALPEL_VERIFY_REVEAL). When arming
|
|
515
|
+
// resolves OFF (the default for every existing install — env unset + config off/absent) this branch is
|
|
516
|
+
// skipped and the hook's stdout is byte-identical to before; the spawn is detached with stdio ignored.
|
|
517
|
+
if (verifySpawnArmed(process.env) && payload.transcript_path) {
|
|
520
518
|
try {
|
|
521
519
|
const { spawn } = await import("node:child_process");
|
|
522
520
|
const { fileURLToPath } = await import("node:url");
|