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 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 root (cwd), but ONLY
700
- // when that root is a real project (never the home catch-all,
701
- // and never Cursor's rootless bubbles) — otherwise unknowable.
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
- function resolveFileIdentity(rawPath, abs, root) {
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
- const base = decodeRoot(root); // "" when root is null (Cursor) or unknown
709
- if (!base || base === HOME) return null; // no real project root to anchor a relative path against
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.root);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.36",
3
+ "version": "4.0.37",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
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
- // A just-finished background build leads (one-shot: surface "your graph's ready" the instant the next
774
- // prompt fires, so the user doesn't have to restart a session to see it), then the one-time auth-expiry
775
- // note, then ackNote (feedback confirmation), the reset interrupt (most acute), and the server steer.
776
- // Any can fire alone.
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
- let cwd = baseCwd || process.cwd();
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
- if (isAllowedProof(cmd, args)) return { cmd, args, cwd };
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
  }