skalpel 4.0.36 → 4.0.37
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/autopsy.mjs +14 -8
- package/package.json +1 -1
- package/reveal.mjs +82 -1
- package/skalpel-hook.mjs +9 -6
- package/verify-shadow.mjs +14 -2
package/autopsy.mjs
CHANGED
|
@@ -275,6 +275,7 @@ function parseClaudeSession(file, rootDir) {
|
|
|
275
275
|
turns.push({
|
|
276
276
|
session_id: sessionId,
|
|
277
277
|
root: rootDir,
|
|
278
|
+
rootRaw: typeof o.cwd === "string" && o.cwd ? o.cwd : null, // real cwd for same-file identity
|
|
278
279
|
role,
|
|
279
280
|
turn_idx: idx++,
|
|
280
281
|
ts: o.timestamp || null,
|
|
@@ -365,6 +366,7 @@ function parseCodexSession(file) {
|
|
|
365
366
|
const turns = raw.map((t, idx) => ({
|
|
366
367
|
session_id: sessionId,
|
|
367
368
|
root: rootDir,
|
|
369
|
+
rootRaw: cwd || null, // real cwd from session_meta.payload.cwd for same-file identity
|
|
368
370
|
role: t.role,
|
|
369
371
|
turn_idx: idx,
|
|
370
372
|
ts: t.ts,
|
|
@@ -696,18 +698,22 @@ function projTail(root) {
|
|
|
696
698
|
// SAME ACTUAL FILE — not a shared basename. Returns a normalized absolute path, or null when the
|
|
697
699
|
// mention cannot be pinned to a real file:
|
|
698
700
|
// • absolute path in the text → that path IS the identity (works across every vendor).
|
|
699
|
-
// • relative path WITH a directory → resolved against the session's project
|
|
700
|
-
// when that
|
|
701
|
-
// and never Cursor's rootless bubbles) —
|
|
701
|
+
// • relative path WITH a directory → resolved against the session's RAW project cwd (rootRaw), but
|
|
702
|
+
// ONLY when that cwd is known and is a real project (never the
|
|
703
|
+
// home catch-all, and never Cursor's rootless bubbles) —
|
|
704
|
+
// otherwise unknowable.
|
|
702
705
|
// • bare basename (no directory) → null. A basename alone can never prove same-file.
|
|
703
|
-
|
|
706
|
+
// NOTE: identity MUST anchor on the raw, undecoded cwd — never decodeRoot(), whose lossy `-`→`/`
|
|
707
|
+
// round-trip can fabricate a path that is not on disk (a hyphen in a real dir name becomes a
|
|
708
|
+
// separator) or false-join two distinct projects whose decoded forms collide. decodeRoot stays for
|
|
709
|
+
// DISPLAY labels only.
|
|
710
|
+
function resolveFileIdentity(rawPath, abs, rootRaw) {
|
|
704
711
|
if (!rawPath) return null;
|
|
705
712
|
if (abs) return path.posix.normalize("/" + rawPath.replace(/^\/+/, ""));
|
|
706
713
|
if (!rawPath.includes("/")) return null; // bare basename — not a same-file proof
|
|
707
714
|
if (/^\.\.?\//.test(rawPath)) return null; // "./x" or "../x": ambiguous relative to an unknown subdir
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
return path.posix.normalize(base.replace(/\/+$/, "") + "/" + rawPath);
|
|
715
|
+
if (!rootRaw || rootRaw === HOME) return null; // cwd unknown (Cursor/Aider) or home catch-all — unknowable
|
|
716
|
+
return path.posix.normalize(rootRaw.replace(/\/+$/, "") + "/" + rawPath);
|
|
711
717
|
}
|
|
712
718
|
// Detect any email (incl. TLDs truncated by the 200-char span slice, e.g. "name@host.c" or "name@host").
|
|
713
719
|
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9][A-Za-z0-9.-]*/g;
|
|
@@ -1073,7 +1079,7 @@ async function main() {
|
|
|
1073
1079
|
if (errOnly && !kind.startsWith("err")) continue;
|
|
1074
1080
|
// ---- same-file identity index (message text only; tool output is skipped above) ----
|
|
1075
1081
|
if (kind === "sym:file") {
|
|
1076
|
-
const identity = resolveFileIdentity(ex.rawPath, ex.abs, turn.
|
|
1082
|
+
const identity = resolveFileIdentity(ex.rawPath, ex.abs, turn.rootRaw);
|
|
1077
1083
|
if (identity) {
|
|
1078
1084
|
if (!filePathMap.has(identity))
|
|
1079
1085
|
filePathMap.set(identity, { basename: anchor, byTool: new Map() });
|
package/package.json
CHANGED
package/reveal.mjs
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// Tone: this is the first time the user is seeing what skalpel learned about them, so the note directs
|
|
9
9
|
// the model to TEACH them their own pattern in plain, warm English — never the internal notation, never
|
|
10
10
|
// a stats dump.
|
|
11
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
+
import { REVEAL_PATH, revealEnabled, cleanText } from "./insights.mjs";
|
|
14
15
|
|
|
15
16
|
export function graphReadyNote() {
|
|
16
17
|
const p = join(homedir(), ".skalpel", "insights-ready.json");
|
|
@@ -78,3 +79,83 @@ export function graphReadyNote() {
|
|
|
78
79
|
`actually asked. Keep the whole thing short and human.]`
|
|
79
80
|
);
|
|
80
81
|
}
|
|
82
|
+
|
|
83
|
+
// ---- verify catch: the AGENT-facing half of the opt-in "aha catch" (SKALPEL_VERIFY_REVEAL) ----------
|
|
84
|
+
//
|
|
85
|
+
// verify-shadow.mjs re-runs the agent's OWN cited proof out of band; on a GENUINE_FAIL mismatch (it
|
|
86
|
+
// claimed "done", its own test/build REALLY failed) it writes ~/.skalpel/verify-reveal.json. The
|
|
87
|
+
// statusline renders that catch as an AMBIENT line the USER can miss within its TTL. This surfaces the
|
|
88
|
+
// SAME catch to the AGENT deterministically, in the NEXT turn's additionalContext, so the model corrects
|
|
89
|
+
// itself instead of standing by a false "done" — the mechanical nudge, not a line someone has to notice.
|
|
90
|
+
//
|
|
91
|
+
// The notified marker: the reveal file records the ts of the failing re-run; we remember the last ts we
|
|
92
|
+
// told the agent about so each distinct catch fires EXACTLY ONCE. It therefore persists across turns until
|
|
93
|
+
// it is injected once OR verify-shadow retracts the reveal on a later PASS — never a 90s TTL that lapses
|
|
94
|
+
// unseen. Scoped to the SAME session that produced the claim, so a stale cross-session catch never fires.
|
|
95
|
+
const NOTIFIED_PATH = join(homedir(), ".skalpel", "verify-reveal-notified.json");
|
|
96
|
+
|
|
97
|
+
// verifyCatchNote(env, sessionId) -> the bracketed model directive, or null.
|
|
98
|
+
// HONESTY RED LINES (mirrors verify-shadow's reveal): every word traces to the REAL reveal record —
|
|
99
|
+
// the real claim text, the real reconstructed proof command, the real captured failure tail, and a
|
|
100
|
+
// failing COUNT only when the runner actually printed one (r.fail_count is null otherwise). NO fabricated
|
|
101
|
+
// numbers. HARNESS_ERROR (timeout / missing script / wrong cwd) never reaches the reveal file, so this
|
|
102
|
+
// never cries wolf. GATED on SKALPEL_VERIFY_REVEAL: OFF (default) → returns null BEFORE touching disk, so
|
|
103
|
+
// the hook's additionalContext stays byte-identical. Fail-open: any error → null.
|
|
104
|
+
export function verifyCatchNote(env, sessionId) {
|
|
105
|
+
try {
|
|
106
|
+
if (!revealEnabled(env)) return null; // opt-in; default OFF → byte-identical, no disk touch
|
|
107
|
+
let r;
|
|
108
|
+
try {
|
|
109
|
+
r = JSON.parse(readFileSync(REVEAL_PATH, "utf8"));
|
|
110
|
+
} catch {
|
|
111
|
+
return null; // no catch pending
|
|
112
|
+
}
|
|
113
|
+
if (!r || typeof r !== "object" || !r.ts) return null;
|
|
114
|
+
// Only tell the AGENT about a catch from ITS OWN session — never a stale cross-session one.
|
|
115
|
+
const sid = sessionId ? String(sessionId) : "";
|
|
116
|
+
if (!sid || !r.session || String(r.session) !== sid) return null;
|
|
117
|
+
// Fire at most once per distinct catch (keyed by the failing re-run's ts).
|
|
118
|
+
try {
|
|
119
|
+
if (JSON.parse(readFileSync(NOTIFIED_PATH, "utf8"))?.ts === r.ts) return null;
|
|
120
|
+
} catch {
|
|
121
|
+
/* no marker yet — first time we've seen this catch */
|
|
122
|
+
}
|
|
123
|
+
// Record that we've now notified the agent about THIS catch BEFORE returning the note, so it fires
|
|
124
|
+
// once. If we can't persist the marker, DROP the note (same one-shot discipline as graphReadyNote /
|
|
125
|
+
// the auth-expiry note) — better to miss it once than re-inject every turn.
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
|
|
128
|
+
const tmp = `${NOTIFIED_PATH}.tmp`;
|
|
129
|
+
writeFileSync(tmp, JSON.stringify({ ts: r.ts }));
|
|
130
|
+
renameSync(tmp, NOTIFIED_PATH);
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// Build the directive from REAL fields only (cleanText strips any control chars; slices bound length).
|
|
135
|
+
const proof =
|
|
136
|
+
cleanText(String(r.proof_command || ""))
|
|
137
|
+
.trim()
|
|
138
|
+
.slice(0, 120) || "its own proof";
|
|
139
|
+
const claim =
|
|
140
|
+
cleanText(String(r.claim_text || ""))
|
|
141
|
+
.trim()
|
|
142
|
+
.slice(0, 120) || "it's done";
|
|
143
|
+
const failN =
|
|
144
|
+
Number.isFinite(r.fail_count) && r.fail_count > 0 ? ` (${r.fail_count} failing)` : "";
|
|
145
|
+
const evidence = cleanText(String(r.evidence || ""))
|
|
146
|
+
.trim()
|
|
147
|
+
.slice(0, 160);
|
|
148
|
+
return (
|
|
149
|
+
`[skalpel — a claim-check just RE-RAN the proof you cited and it FAILED. After your last ` +
|
|
150
|
+
`"${claim}"-type message, skalpel re-ran \`${proof}\` out of band and it did NOT pass${failN}. ` +
|
|
151
|
+
`This is YOUR OWN test/build re-run independently — not a new opinion, not a flaky guess. Do NOT ` +
|
|
152
|
+
`tell the user the work is done. Open with ONE short line — "🔬 skalpel · I re-ran \`${proof}\` ` +
|
|
153
|
+
`and it still fails${failN} — not done yet" — then actually diagnose and fix the failure before ` +
|
|
154
|
+
`you claim completion again.` +
|
|
155
|
+
(evidence ? ` The real failing output tail was: ${evidence}` : "") +
|
|
156
|
+
`]`
|
|
157
|
+
);
|
|
158
|
+
} catch {
|
|
159
|
+
return null; // fail-open: the catch note is a bonus, never break the hook
|
|
160
|
+
}
|
|
161
|
+
}
|
package/skalpel-hook.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { join } from "node:path";
|
|
|
14
14
|
import { identity } from "./auth.mjs";
|
|
15
15
|
import { record } from "./metrics.mjs";
|
|
16
16
|
import { recordInsight, cleanText, revealEnabled } from "./insights.mjs";
|
|
17
|
-
import { graphReadyNote } from "./reveal.mjs";
|
|
17
|
+
import { graphReadyNote, verifyCatchNote } from "./reveal.mjs";
|
|
18
18
|
import { tailLines } from "./transcript.mjs";
|
|
19
19
|
|
|
20
20
|
// Tail budget for the two transcript scans below (last-prompt + cascade). Both only ever look at the
|
|
@@ -770,12 +770,15 @@ async function main() {
|
|
|
770
770
|
session_id: sessionId,
|
|
771
771
|
});
|
|
772
772
|
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
773
|
+
// The AGENT-facing "aha catch" (opt-in, SKALPEL_VERIFY_REVEAL) LEADS: if verify-shadow's detached
|
|
774
|
+
// re-run caught the agent claiming "done" while its OWN proof GENUINELY failed, tell the agent so it
|
|
775
|
+
// corrects itself instead of a user having to notice an ambient statusline line. Fires once per catch,
|
|
776
|
+
// scoped to this session; returns null (byte-identical) when the flag is off. Then a just-finished
|
|
777
|
+
// background build ("your graph's ready", one-shot), the one-time auth-expiry note, ackNote (feedback
|
|
778
|
+
// confirmation), the reset interrupt (most acute), and the server steer. Any can fire alone.
|
|
779
|
+
const verifyNote = verifyCatchNote(process.env, sessionId);
|
|
777
780
|
const readyNote = graphReadyNote();
|
|
778
|
-
const additionalContext = [readyNote, authNote, ackNote, resetNote, injection]
|
|
781
|
+
const additionalContext = [verifyNote, readyNote, authNote, ackNote, resetNote, injection]
|
|
779
782
|
.filter(Boolean)
|
|
780
783
|
.join("\n\n");
|
|
781
784
|
if (additionalContext) {
|
package/verify-shadow.mjs
CHANGED
|
@@ -384,12 +384,23 @@ function resolveCd(baseCwd, dir) {
|
|
|
384
384
|
return path.isAbsolute(d) ? path.normalize(d) : path.resolve(baseCwd || process.cwd(), d);
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
// SHARP-4: a leading `cd <dir>` must not move the re-run OUTSIDE the session's base cwd. An out-of-tree
|
|
388
|
+
// cwd (absolute or `..` escape) would execute an attacker-planted package.json script / Cargo build.rs /
|
|
389
|
+
// eslint config as code — exactly the out-of-band execution the flag check (argsRedirectOutOfTree) blocks.
|
|
390
|
+
// Mirrors isPathEscape's guarantee for the resolved cwd itself. base is never null here → no resolve throw.
|
|
391
|
+
function cwdEscapesBase(cwd, base) {
|
|
392
|
+
const c = path.resolve(cwd);
|
|
393
|
+
const b = path.resolve(base);
|
|
394
|
+
return c !== b && !c.startsWith(b + path.sep);
|
|
395
|
+
}
|
|
396
|
+
|
|
387
397
|
// parseCommandForProof(command, baseCwd) -> { cmd, args, cwd } | null
|
|
388
398
|
// Walks the segments of ONE shell command; honors a leading `cd <dir>` (sets the re-run cwd) and returns
|
|
389
399
|
// the first allowlisted invocation found. Args are the cleaned literal tokens (no shell interpretation).
|
|
390
400
|
export function parseCommandForProof(command, baseCwd) {
|
|
391
401
|
if (!command || typeof command !== "string") return null;
|
|
392
|
-
|
|
402
|
+
const base = baseCwd || process.cwd();
|
|
403
|
+
let cwd = base;
|
|
393
404
|
for (const rawTokens of splitSegments(command)) {
|
|
394
405
|
const toks = cleanSegment(rawTokens);
|
|
395
406
|
if (!toks.length) continue;
|
|
@@ -399,7 +410,8 @@ export function parseCommandForProof(command, baseCwd) {
|
|
|
399
410
|
}
|
|
400
411
|
const cmd = toks[0];
|
|
401
412
|
const args = toks.slice(1, 65).map((t) => String(t).slice(0, 4096)); // bound arg count + length
|
|
402
|
-
|
|
413
|
+
// SHARP-4: refuse an out-of-tree cwd (from a `cd` escape) just as we refuse out-of-tree path flags.
|
|
414
|
+
if (isAllowedProof(cmd, args)) return cwdEscapesBase(cwd, base) ? null : { cmd, args, cwd };
|
|
403
415
|
}
|
|
404
416
|
return null;
|
|
405
417
|
}
|