pi-crew 0.9.67 → 0.9.68

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/CHANGELOG.md CHANGED
@@ -2,6 +2,54 @@
2
2
 
3
3
  > **Note:** `atomic-write-v2.ts` / `AtomicWriter` mentioned in historical entries below was consolidated into `atomic-write.ts` as of v0.9.42. This changelog is preserved as historical record — the migration was completed (the v2 class was never adopted; v1 won on simplicity + symlink-safety + link+unlink atomicity). See `docs/migration/atomic-write-v2-migration.md` for the decision rationale.
4
4
 
5
+ ## [0.9.68] — RLM fixes after deep-review verification (2026-08-12)
6
+
7
+ Fixes from `docs/rlm-deep-review-2026-08-12.md` (verified) +
8
+ `docs/rlm-fixes-implementation-plan.md`. Each code part loop-reviewed (3
9
+ parallel reviewers: security/correctness/tests, read-only) before merge here.
10
+
11
+ ### Fixes
12
+ - **P1 — global shadow poisoning** (`src/runtime/scratchpad/guest.ts`):
13
+ `PROTECTED_GLOBALS` + `resetProtectedGlobals()` register live Node globals
14
+ into the namespace/`INTERNAL_BINDINGS`. Closes both poisoning paths: restore
15
+ (re-install overwrites revived shadow) and in-session (reset at cell start —
16
+ shadow stays local to the cell that created it). Silent corruption (e.g.
17
+ `const process='x'` making `process.env` undefined) is gone. 8 regression
18
+ tests in `test/unit/runtime/scratchpad/guest-global-shadow.test.ts`.
19
+ - **P2 — scratchpad adoption lever** (`src/agents/agent-config.ts`): new
20
+ `PI_CREW_SCRATCHPAD_DEMOTE_BASH=1` flag. When on + a role has scratchpad
21
+ enabled, `resolveToolPolicy` removes `bash` (allowlist filter + denylist add)
22
+ so the model reaches for the `sh()` binding instead — the documented root
23
+ cause of 0 adoption. Default off (zero behavior change); scoped to
24
+ scratchpad-armed roles (executor/verifier/test-engineer) via
25
+ `isScratchpadEnabledForRole` (S-6 read-only gate + F6 kill-switch honored).
26
+ 10 tests in `test/unit/scratchpad-demote-bash.test.ts`.
27
+ - **P4 — host_request protocol doc** (`src/runtime/scratchpad/protocol.ts`):
28
+ the `host_request` type is now documented as reserved for the future host
29
+ bridge (§5.2F), not yet wired (no handler).
30
+ - **P5 — version drift** (`package.json`): 4 pi devDeps `^0.83.0` → `^0.84.0`
31
+ (installed runtime is 0.84.1).
32
+ - **P6 — scratchpad stack-trace sourcemap** (`src/runtime/scratchpad/transform.ts`
33
+ + `guest.ts`): `transformCell` now returns a `lineMap` (body line → source
34
+ line) built from esbuild `sourcemap:'inline'` + a hand-rolled VLQ decoder
35
+ (no dependency) + import pre-rewrite tracking + splice line counting. Guest
36
+ `remapStackLines` remaps the V8 `<anonymous>:N` frame back to the cell's
37
+ original source line. Only real ` at ` frames are remapped (a crafted
38
+ error message containing `<anonymous>:N:C)` is left byte-identical). Known
39
+ cosmetic limitation: multi-line type annotations map to the esbuild collapse
40
+ point. 10 tests in `test/unit/runtime/scratchpad/scratchpad-sourcemap.test.ts`.
41
+
42
+ ### Removed
43
+ - **P3 — dead HMAC crypto deleted.** `src/runtime/scratchpad/snapshot-hmac.ts`
44
+ (167 lines, 11 tests) + its test file removed. The threat it mitigated
45
+ (v8.deserialize tamper) is double-conditional (0 scratchpad adoption +
46
+ same-uid store both hold today); wiring (ROADMAP R1-4) was premature and
47
+ blocked on 3 unresolved design questions. ADR Superseded; design retained
48
+ for clean re-add if conditions ever hold. ROADMAP R1-4 / R2-2 marked removed.
49
+
50
+ ### Deferred (needs design)
51
+ - *None remaining.* (P6 was the last deferred item; it shipped — see Fixes.)
52
+
5
53
  ## [0.9.67] — RLM/scratchpad adoption batch (I1–I7) (2026-08-11)
6
54
 
7
55
  First shippable slice of `improvement-plan-2026-08-11.md` — the scratchpad was
package/dist/index.mjs CHANGED
@@ -11755,10 +11755,19 @@ function uniqueToolMerge(...lists) {
11755
11755
  function resolveToolPolicy(agent, role) {
11756
11756
  const roleConfig = role ? getToolConfig(role) : {};
11757
11757
  const explicitTools = agent.source === "builtin" ? roleConfig.tools ?? agent.tools : agent.tools ?? roleConfig.tools;
11758
- const tools = agent.loadMode === "lean" && agent.defaultTools?.length ? uniqueToolMerge(explicitTools, agent.defaultTools) : explicitTools;
11759
- const excludeTools = uniqueToolMerge(roleConfig.excludeTools, agent.disallowedTools);
11758
+ let tools = agent.loadMode === "lean" && agent.defaultTools?.length ? uniqueToolMerge(explicitTools, agent.defaultTools) : explicitTools;
11759
+ let excludeTools = uniqueToolMerge(roleConfig.excludeTools, agent.disallowedTools);
11760
+ if (shouldDemoteBashForScratchpad(role, agent)) {
11761
+ tools = tools ? tools.filter((t2) => t2 !== "bash") : tools;
11762
+ excludeTools = uniqueToolMerge(excludeTools, ["bash"]);
11763
+ }
11760
11764
  return { tools, excludeTools };
11761
11765
  }
11766
+ function shouldDemoteBashForScratchpad(role, agent) {
11767
+ if (process.env.PI_CREW_SCRATCHPAD_DEMOTE_BASH !== "1") return false;
11768
+ if (!role) return false;
11769
+ return isScratchpadEnabledForRole(role, { scratchpad: agent.scratchpad });
11770
+ }
11762
11771
  var BUILTIN_TOOL_NAMES;
11763
11772
  var init_agent_config = __esm({
11764
11773
  "src/agents/agent-config.ts"() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.67",
3
+ "version": "0.9.68",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -67,7 +67,8 @@
67
67
  ],
68
68
  "scripts": {
69
69
  "check": "npm run ci",
70
- "ci": "npm run typecheck && npm run lint && npm run format:check && npm run check:conflict-markers && npm run check:decision-drift && npm run check:event-types && npm run check:lazy-imports && npm run check:bundle-staleness && npm run build:bundle && npm run check:bundle-size && npm run test:bundle && npm test && npm pack --dry-run",
70
+ "ci": "npm run check:lockfile-sync && npm run typecheck && npm run lint && npm run format:check && npm run check:conflict-markers && npm run check:decision-drift && npm run check:event-types && npm run check:lazy-imports && npm run check:bundle-staleness && npm run build:bundle && npm run check:bundle-size && npm run test:bundle && npm test && npm pack --dry-run",
71
+ "check:lockfile-sync": "node scripts/check-lockfile-sync.mjs",
71
72
  "check:lazy-imports": "node scripts/check-lazy-imports.mjs",
72
73
  "check:bundle-staleness": "node scripts/check-bundle-staleness.mjs",
73
74
  "check:bundle-size": "node scripts/check-bundle-size.mjs",
@@ -136,10 +137,10 @@
136
137
  },
137
138
  "devDependencies": {
138
139
  "@biomejs/biome": "^2.5.3",
139
- "@earendil-works/pi-agent-core": "^0.83.0",
140
- "@earendil-works/pi-ai": "^0.83.0",
141
- "@earendil-works/pi-coding-agent": "^0.83.0",
142
- "@earendil-works/pi-tui": "^0.83.0",
140
+ "@earendil-works/pi-agent-core": "^0.84.0",
141
+ "@earendil-works/pi-ai": "^0.84.0",
142
+ "@earendil-works/pi-coding-agent": "^0.84.0",
143
+ "@earendil-works/pi-tui": "^0.84.0",
143
144
  "@types/node": "^25.9.5",
144
145
  "tsx": "^4.23.0",
145
146
  "typescript": "^7.0.2"
@@ -1,5 +1,5 @@
1
1
  import type { RoleToolConfig } from "../config/role-tools.ts";
2
- import { getToolConfig } from "../config/role-tools.ts";
2
+ import { getToolConfig, isScratchpadEnabledForRole } from "../config/role-tools.ts";
3
3
 
4
4
  /**
5
5
  * F1 (v0.7.9): canonical built-in tool name list. Used by `parseToolsField`
@@ -162,24 +162,45 @@ export function resolveToolPolicy(agent: AgentConfig, role?: string): ResolvedTo
162
162
  const roleConfig = role ? getToolConfig(role) : {};
163
163
  // allowlist: source-aware precedence (see doc above).
164
164
  const explicitTools = agent.source === "builtin" ? (roleConfig.tools ?? agent.tools) : (agent.tools ?? roleConfig.tools);
165
- // L5: when the agent opts into `loadMode: "lean"` AND provides a
166
- // non-empty `defaultTools` list, merge that list into the resolved
167
- // allowlist. The merge is additive (union, dedup, order-preserving) so
168
- // the existing source-aware precedence is preserved and a lean agent
169
- // gets a focused-but-non-empty tool set. Signal flow:
170
- // agent YAML frontmatter (loadMode, defaultTools)
171
- // → parsed into AgentConfig
172
- // → resolveToolPolicy(agent, role) here
173
- // → policy.tools returned to buildPiWorkerArgs
174
- // → `args.push("--tools", policy.tools.join(","))` in pi-args.ts
175
- // → child pi process sees the merged allowlist
176
- const tools =
165
+ let tools =
177
166
  agent.loadMode === "lean" && agent.defaultTools?.length ? uniqueToolMerge(explicitTools, agent.defaultTools) : explicitTools;
178
167
  // denylist: additive merge of role excludeTools + agent disallowedTools.
179
- const excludeTools = uniqueToolMerge(roleConfig.excludeTools, agent.disallowedTools);
168
+ let excludeTools = uniqueToolMerge(roleConfig.excludeTools, agent.disallowedTools);
169
+ // P2 (scratchpad adoption lever, rlm-deep-review-2026-08-12.md §5.1A):
170
+ // when scratchpad is armed for this role AND the operator opted in via
171
+ // PI_CREW_SCRATCHPAD_DEMOTE_BASH=1, remove `bash` from the tool surface so
172
+ // the model reaches for `sh()` inside scratchpad cells (structured value
173
+ // reuse) instead of `bash` (which always wins by default — the documented
174
+ // root cause of 0 scratchpad adoption). Gated behind a flag because it
175
+ // changes the tool surface the model sees (behavioral risk). The model
176
+ // keeps read/edit/write/ls/grep/find; shell ops must go via `sh()`.
177
+ if (shouldDemoteBashForScratchpad(role, agent)) {
178
+ tools = tools ? tools.filter((t) => t !== "bash") : tools;
179
+ excludeTools = uniqueToolMerge(excludeTools, ["bash"]);
180
+ }
180
181
  return { tools, excludeTools };
181
182
  }
182
183
 
184
+ /**
185
+ * P2: should `bash` be demoted (removed) for a scratchpad-armed role?
186
+ *
187
+ * True only when BOTH hold:
188
+ * (a) the role has scratchpad enabled (so the model still has a way to run
189
+ * shell commands — via the `sh()` binding inside scratchpad cells);
190
+ * (b) the operator opted in via `PI_CREW_SCRATCHPAD_DEMOTE_BASH=1`.
191
+ *
192
+ * Default off → zero behavior change (existing adoption stays at 0). On → the
193
+ * lever to break 0-adoption without full tool collapse. This is read-only
194
+ * config logic; the actual tool-surface change happens in `resolveToolPolicy`
195
+ * above and flows to BOTH spawn paths (child-pi `--tools`/`--exclude-tools` and
196
+ * live-session filterActiveTools) via the unified policy.
197
+ */
198
+ function shouldDemoteBashForScratchpad(role: string | undefined, agent: AgentConfig): boolean {
199
+ if (process.env.PI_CREW_SCRATCHPAD_DEMOTE_BASH !== "1") return false;
200
+ if (!role) return false;
201
+ return isScratchpadEnabledForRole(role, { scratchpad: agent.scratchpad });
202
+ }
203
+
183
204
  /**
184
205
  * Build agent session options including role-based tool restrictions.
185
206
  * @param agent - The agent configuration
@@ -124,11 +124,16 @@ attempt N+1 worker (scratchpad-lifecycle.ts)
124
124
  token), so this does not cross the existing boundary. An HMAC over the payload
125
125
  is a Phase 2.5/3 hardening if artifacts ever land in a shared location.
126
126
 
127
- > ⚠️ **NOT WIRED (2026-08-11):** `src/runtime/scratchpad/snapshot-hmac.ts` is an
128
- > unconnected helper — zero production call sites, 11 green unit tests in
129
- > isolation. Wire-or-delete decision in `docs/improvement-plan-2026-08-11.md`
130
- > §4 J1. Until then the note above (v8.deserialize unauthenticated) is exactly
131
- > how the code behaves.
127
+ > **REMOVED (2026-08-12):** `src/runtime/scratchpad/snapshot-hmac.ts` and its
128
+ > test were deleted. Decision (P3 of `docs/rlm-fixes-implementation-plan.md`):
129
+ > the HMAC threat is double-conditional — it only materializes when scratchpad
130
+ > has adoption (>0 cells; today: 0/83 runs) AND snapshots move to a
131
+ > shared/networked store (today: same-uid dev-machine). Hardening a
132
+ > 0-adoption surface that is itself at risk of removal (decision gate §3.3)
133
+ > is premature. The design is fully recorded in ADR
134
+ > `docs/decisions/2026-08-10-scratchpad-snapshot-hmac.md` (Superseded), so it
135
+ > can be re-added cleanly if/when both conditions hold. Until then the note
136
+ > below (v8.deserialize unauthenticated) remains exactly how the code behaves.
132
137
  - **Secret-at-rest under a benign key name** persists as base64 for the run's
133
138
  retention (structural redaction is key-name based, best-effort). A sanitized-
134
139
  namespace policy is a Phase 2.5 concern.
@@ -188,6 +188,49 @@ console.trace = consoleErr;
188
188
 
189
189
  const INTERNAL_BINDINGS = new Map<string, unknown>();
190
190
 
191
+ /**
192
+ * Protected Node globals — must never be persistently shadowed by a cell.
193
+ *
194
+ * Because the scope proxy's `get` trap checks the namespace BEFORE globalThis,
195
+ * a cell writing `const process = 'poisoned'` (transformed to a namespace
196
+ * assignment) would otherwise poison EVERY later cell in the same engine
197
+ * (the namespace is a module-level singleton) AND survive snapshot→restore
198
+ * (a serializable shadow revives). Result: silent corruption — e.g.
199
+ * `typeof process.env` becomes 'undefined' with no error.
200
+ *
201
+ * We register the LIVE global for each protected name in INTERNAL_BINDINGS +
202
+ * the namespace, so (a) restore's re-install overwrites any revived shadow,
203
+ * and (b) each cell starts with the real global. Within-cell shadowing still
204
+ * works (the cell's own write wins for that cell only); it just cannot leak
205
+ * to other cells or across restarts. Verified: probe reproduced the bug
206
+ * pre-fix; see test/unit/runtime/scratchpad/guest-global-shadow.test.ts.
207
+ */
208
+ const PROTECTED_GLOBALS: ReadonlyArray<string> = [
209
+ "process",
210
+ "Buffer",
211
+ "console",
212
+ "setTimeout",
213
+ "clearTimeout",
214
+ "setInterval",
215
+ "clearInterval",
216
+ "setImmediate",
217
+ "clearImmediate",
218
+ "queueMicrotask",
219
+ "structuredClone",
220
+ "AbortController",
221
+ "globalThis",
222
+ ];
223
+
224
+ function resetProtectedGlobals(): void {
225
+ const g = globalThis as Record<string, unknown>;
226
+ for (const name of PROTECTED_GLOBALS) {
227
+ const live = g[name];
228
+ if (live === undefined) continue;
229
+ INTERNAL_BINDINGS.set(name, live);
230
+ namespace[name] = live;
231
+ }
232
+ }
233
+
191
234
  /** Pattern-12 nullish guard: refuse undefined/null BEFORE spawning. */
192
235
  function assertShellArgNotNullish(name: string, value: unknown): void {
193
236
  if (value === undefined || value === null) {
@@ -235,6 +278,9 @@ function installBootstrapBindings(): void {
235
278
  // serialization, and so cells can call `sh(...)` directly.
236
279
  INTERNAL_BINDINGS.set("sh", sh);
237
280
  namespace.sh = sh;
281
+ // Overwrite any protected-global shadow (e.g. a revived `process='x'` from
282
+ // restore) with the live global — fixes the global shadow poisoning bug.
283
+ resetProtectedGlobals();
238
284
  }
239
285
 
240
286
  installBootstrapBindings();
@@ -246,14 +292,69 @@ const AsyncFunction = (async () => {}).constructor as new (...args: string[]) =>
246
292
 
247
293
  const liveCells = new Map<string, CellContext>();
248
294
 
295
+ // ── P6: remap transformed error-stack lines back to the cell's source ───────
296
+ // V8 reports a thrown line as `N = bodyLine + 2` (1-based) — the AsyncFunction
297
+ // wrapper adds 2 prefix lines (the anonymous function line + the `with (...)`
298
+ // line) before the cell body. `lineMap` (from transformCell) maps body lines
299
+ // that RECEIVED A REPLACEMENT back to the source line the model wrote. Lines
300
+ // between replacements (unreplaced spans — e.g. a bare `throw`) are identity:
301
+ // sourceLine = entry.sourceLine + (bodyLine - entry.bodyLine), using the
302
+ // nearest replacement entry at or before the body line.
303
+ //
304
+ // SECURITY/robustness (P6 review finding): only lines that look like a real
305
+ // V8 frame (` at ... <anonymous>:N:C)`) are remapped. The `Error: <message>`
306
+ // line and any cell text containing `<anonymous>:N:C)` (e.g. an error message
307
+ // that echoes code) must be left byte-identical — a crafted message must never
308
+ // get its numbers rewritten.
309
+ function remapStackLines(stack: string, lineMap: { sourceLine: number; bodyLine: number }[]): string[] {
310
+ const lines = stack.split("\n");
311
+ const sorted = [...lineMap].sort((a, b) => a.bodyLine - b.bodyLine);
312
+ return lines.map((line) => {
313
+ // Real V8 frame only: ` at <anything> <anonymous>:N:C)` — anchored to
314
+ // the leading frame prefix and a parenthesized trailing position. The
315
+ // message line (`Error: ...`) does not start with ` at `, so it is
316
+ // never matched; a crafted message containing `<anonymous>:N:C)` is
317
+ // likewise safe because it lacks the frame prefix.
318
+ const m = line.match(/^\s+at .*<anonymous>:(\d+):(\d+)\)/);
319
+ if (!m) return line;
320
+ const reported = Number(m[1]);
321
+ const bodyLine = reported - 2; // wrapper prefix offset (verified by probe)
322
+ // nearest replacement entry at or before bodyLine (lower bound)
323
+ let lo = 0;
324
+ let hi = sorted.length - 1;
325
+ let entry: { sourceLine: number; bodyLine: number } | undefined;
326
+ while (lo <= hi) {
327
+ const mid = (lo + hi) >> 1;
328
+ if (sorted[mid].bodyLine <= bodyLine) {
329
+ entry = sorted[mid];
330
+ lo = mid + 1;
331
+ } else {
332
+ hi = mid - 1;
333
+ }
334
+ }
335
+ if (!entry) return line; // before any replacement — leave as-is
336
+ const sourceLine = entry.sourceLine + (bodyLine - entry.bodyLine);
337
+ const col = m[2];
338
+ return line.replace(/<anonymous>:\d+:\d+/, `<anonymous>:${sourceLine}:${col}`);
339
+ });
340
+ }
341
+
249
342
  async function runCell(cellId: string, code: string): Promise<void> {
250
343
  const ctx = makeCellContext(cellId);
251
344
  activeCell = ctx;
252
345
  liveCells.set(cellId, ctx);
346
+ // Reset protected globals so a prior cell's shadow (e.g. `const process='x'`)
347
+ // cannot poison this cell — shadowing stays local to the cell that did it
348
+ // (the namespace is a module-level singleton shared across cells).
349
+ resetProtectedGlobals();
253
350
 
254
351
  let done: GuestToHostMessage;
352
+ // P6: lineMap from transformCell is needed in BOTH try (to build the
353
+ // wrapper) and catch (to remap the error stack), so hoist it here.
354
+ let lineMap: { sourceLine: number; bodyLine: number }[] = [];
255
355
  try {
256
- const { body } = transformCell(code, { ctxName: CTX_NAME });
356
+ const { body, lineMap: lm } = transformCell(code, { ctxName: CTX_NAME });
357
+ lineMap = lm;
257
358
  // Sloppy-mode wrapper so `with` is legal (AsyncFunction bodies are always
258
359
  // sloppy, even inside a strict ESM strip-types module); async for
259
360
  // top-level await.
@@ -271,7 +372,7 @@ async function runCell(cellId: string, code: string): Promise<void> {
271
372
  type: "done",
272
373
  cellId,
273
374
  status: ctx.aborted ? "aborted" : "error",
274
- error: { name: err.name, message: err.message, stack: (err.stack ?? "").split("\n") },
375
+ error: { name: err.name, message: err.message, stack: remapStackLines(err.stack ?? "", lineMap) },
275
376
  };
276
377
  } finally {
277
378
  if (activeCell === ctx) activeCell = undefined;
@@ -45,7 +45,19 @@ export interface GuestToHost {
45
45
  error?: { name: string; message: string; stack: string[] };
46
46
  };
47
47
  pong: { type: "pong"; id: string };
48
- host_request: { type: "host_request"; id: string; requestType: string; payload: Record<string, unknown> };
48
+ host_request: {
49
+ // RESERVED for future host bridge (rlm-deep-review-2026-08-12.md §5.2F /
50
+ // J2): guest-side cells would request host services (tools.read,
51
+ // tools.grep) so data enters the namespace WITHOUT crossing the
52
+ // transcript — pi-rlm's core token-saving value proposition. Declared
53
+ // here so the protocol type is stable, but NOT YET WIRED: engine.ts and
54
+ // guest.ts have no host_request dispatcher/handler. Blocked on scratchpad
55
+ // adoption > 0 (do not add a host execution surface nobody calls).
56
+ type: "host_request";
57
+ id: string;
58
+ requestType: string;
59
+ payload: Record<string, unknown>;
60
+ };
49
61
  snapshot_result: {
50
62
  type: "snapshot_result";
51
63
  id: string;
@@ -31,6 +31,15 @@ export interface TransformedCell {
31
31
  body: string;
32
32
  /** Top-level names this cell binds into the namespace. */
33
33
  declaredNames: string[];
34
+ /**
35
+ * P6: line-based position map — for each body line (1-based), the
36
+ * corresponding SOURCE line (1-based) the model wrote. Used to remap the
37
+ * V8-reported error line (relative to `body`) back to the cell's original
38
+ * line so stack traces point at the source the user wrote, not the
39
+ * transformed body. Built WITHOUT a source-map dependency by tracking
40
+ * replacement newline shifts against the source (acorn `locations`).
41
+ */
42
+ lineMap: { sourceLine: number; bodyLine: number }[];
34
43
  }
35
44
 
36
45
  export interface TransformOptions {
@@ -41,8 +50,16 @@ export interface TransformOptions {
41
50
  // esbuild's transform strips types but never drops side-effect-free trailing
42
51
  // expressions (no DCE in transform mode) — the exact thing we capture as the
43
52
  // cell result.
44
- function stripTypes(code: string): string {
45
- return transformSync(code, { loader: "ts" }).code;
53
+ // P6: `sourcemap: 'inline'` so we can map the stripped `js` lines back to the
54
+ // `rewritten` (pre-strip) lines — esbuild does NOT preserve line numbers
55
+ // across type stripping (probe: 7 input lines → 5 output lines). The inline
56
+ // sourcemap is stripped from the output (it would otherwise leak into the cell
57
+ // body as a trailing comment); we only keep the decoded line map.
58
+ function stripTypes(code: string): { code: string; lineMap: number[] } {
59
+ const out = transformSync(code, { loader: "ts", sourcemap: "inline" });
60
+ const lineMap = esbuildLineMap(out.code);
61
+ const codeWithoutMap = out.code.replace(/\/\/# sourceMappingURL=data:application\/json;base64,[A-Za-z0-9+/=]+\s*$/, "");
62
+ return { code: codeWithoutMap, lineMap };
46
63
  }
47
64
 
48
65
  // ── top-level import extraction ──────────────────────────────────────────────
@@ -271,14 +288,147 @@ function variableReplacement(decl: VariableDeclaration, source: string): string
271
288
  return statements.join(" ");
272
289
  }
273
290
 
291
+ // ── P6: line-based position map ─────────────────────────────────────────────
292
+ // V8 reports a cell error's line as 1-based relative to the TRANSFORMED `body`.
293
+ // The model wrote `code`; between them sit 3 transforms:
294
+ // 1. import pre-rewrite (acorn) — collapses multi-line imports to 1 line
295
+ // 2. esbuild strip-types — collapses type annotations/signatures
296
+ // 3. declaration → assignment + trailing-expr capture — may add/remove lines
297
+ // We build a map { sourceLine (1-based, in `code`) → bodyLine (1-based) } by
298
+ // tracking line deltas across each stage with acorn `locations` + newline
299
+ // counts. No source-map dependency (probe: esbuild strip-types does NOT
300
+ // preserve lines — 7→5 — so a naive single-layer sourcemap would be wrong).
301
+
302
+ function countNewlines(s: string): number {
303
+ let n = 0;
304
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
305
+ return n;
306
+ }
307
+
308
+ /** 1-based line of `pos` (char offset) in `text`. */
309
+ function lineAt(text: string, pos: number): number {
310
+ let line = 1;
311
+ for (let i = 0; i < pos && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
312
+ return line;
313
+ }
314
+
315
+ // ── P6: esbuild inline sourcemap → line map (js → rewritten) ───────────────
316
+ // esbuild `sourcemap: 'inline'` emits a base64 VLQ sourcemap whose `mappings`
317
+ // field encodes, per generated line, segments mapping back to source
318
+ // positions. We only need LINE fidelity (column is overkill for the bug), so
319
+ // we decode each generated line's first segment's source line. VLQ is the
320
+ // standard base64-variable-length encoding (no dependency needed).
321
+
322
+ const BASE64: string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
323
+ const VLQ: Map<string, number> = new Map([...BASE64].map((c, i) => [c, i]));
324
+
325
+ function decodeVlqSegment(segment: string): number[] {
326
+ const values: number[] = [];
327
+ let shift = 0;
328
+ let value = 0;
329
+ for (let i = 0; i < segment.length; i++) {
330
+ const digit = VLQ.get(segment[i]);
331
+ if (digit === undefined) break;
332
+ value |= (digit & 31) << shift;
333
+ if (digit & 32) {
334
+ shift += 5;
335
+ } else {
336
+ values.push(value & 1 ? -(value >>> 1) : value >>> 1);
337
+ value = 0;
338
+ shift = 0;
339
+ }
340
+ }
341
+ return values;
342
+ }
343
+
344
+ /**
345
+ * Decode an esbuild inline sourcemap's `mappings` into an array where
346
+ * `lineMapGenerated[generatedLine-1]` = the source LINE (1-based) that
347
+ * generated line originates from (first segment per generated line; falls back
348
+ * to the previous mapped line for continuation lines).
349
+ */
350
+ function esbuildLineMap(codeWithInlineMap: string): number[] {
351
+ const m = codeWithInlineMap.match(/\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/);
352
+ if (!m) return [];
353
+ let sm: { mappings?: string };
354
+ try {
355
+ sm = JSON.parse(Buffer.from(m[1], "base64").toString("utf8")) as { mappings?: string };
356
+ } catch {
357
+ return [];
358
+ }
359
+ if (!sm.mappings) return [];
360
+ const result: number[] = [];
361
+ let srcLine = 1;
362
+ let lastSrcLine = 1;
363
+ for (const genLine of sm.mappings.split(";")) {
364
+ const segs = genLine.split(",").filter(Boolean);
365
+ if (segs.length > 0) {
366
+ const vals = decodeVlqSegment(segs[0]);
367
+ // values: [genColDelta, srcIdxDelta, srcLineDelta, srcColDelta, ...]
368
+ if (vals.length >= 3) {
369
+ srcLine += vals[2];
370
+ lastSrcLine = srcLine;
371
+ }
372
+ }
373
+ result.push(lastSrcLine);
374
+ }
375
+ return result;
376
+ }
377
+
378
+ /**
379
+ * Build a `code`-line → `js`-line map across the import pre-rewrite.
380
+ * For each import replacement, its `code` span [codeStart, codeEnd] collapses
381
+ * to `js` lines [jsStart, jsEnd] (usually 1). Lines outside any replacement
382
+ * are identity-shifted by the cumulative delta.
383
+ */
384
+ function buildImportLineMap(
385
+ code: string,
386
+ rewritten: string,
387
+ importSpans: { codeStart: number; codeEnd: number; jsStart: number; jsEnd: number }[],
388
+ ): (src: number) => number {
389
+ if (importSpans.length === 0) return (src) => src;
390
+ // P6 (final): map a `rewritten` (post-import) LINE to the original `code`
391
+ // line. `rewritten` = code with each import statement replaced by its
392
+ // (usually 1-line) dynamic-import fragment. We walk both strings tracking
393
+ // newline counts: between imports the line numbers advance identically;
394
+ // inside an import span, all its code lines map to the replacement's first
395
+ // rewritten line (collapsed).
396
+ const rewrittenLines: string[] = rewritten.split("\n");
397
+ const codeLines: string[] = code.split("\n");
398
+ const map = new Map<number, number>(); // rewrittenLine → codeLine (1-based)
399
+ let codeIdx = 1;
400
+ let rewIdx = 1;
401
+ for (const span of importSpans) {
402
+ // Advance both to the span start, emitting identity mapping for the
403
+ // untouched lines between the previous span end and this span start.
404
+ const spanStartCodeLine = lineAt(code, span.codeStart);
405
+ const spanStartRewLine = lineAt(rewritten, span.jsStart);
406
+ for (; codeIdx < spanStartCodeLine && rewIdx < spanStartRewLine; codeIdx++, rewIdx++) {
407
+ map.set(rewIdx, codeIdx);
408
+ }
409
+ // Lines inside the import span (in code) collapse to the replacement's
410
+ // single starting rewritten line.
411
+ const spanEndCodeLine = lineAt(code, span.codeEnd);
412
+ for (; codeIdx <= spanEndCodeLine; codeIdx++) {
413
+ map.set(spanStartRewLine, spanStartCodeLine);
414
+ }
415
+ rewIdx = spanStartRewLine + 1;
416
+ }
417
+ // Tail: identity from wherever we stopped.
418
+ for (; rewIdx <= rewrittenLines.length; rewIdx++, codeIdx++) {
419
+ map.set(rewIdx, Math.min(codeIdx, codeLines.length));
420
+ }
421
+ return (rewLine: number) => {
422
+ const mapped = map.get(rewLine);
423
+ return mapped ?? Math.min(rewLine, codeLines.length);
424
+ };
425
+ }
426
+
274
427
  export function transformCell(code: string, options: TransformOptions = {}): TransformedCell {
275
428
  const ctxName = options.ctxName ?? "__ctx";
276
-
277
- // Top-level imports are rewritten into awaited dynamic imports BEFORE type
278
- // stripping: esbuild elides unused imports, and the reference behaviour
279
- // binds every imported name into the namespace so it persists across cells.
280
429
  let importDeclared: string[] = [];
281
430
  let rewritten = code;
431
+ const importSpans: { codeStart: number; codeEnd: number; jsStart: number; jsEnd: number }[] = [];
282
432
  try {
283
433
  const tokens = lexTopLevel(code);
284
434
  const imports = findImportStatements(tokens);
@@ -286,11 +436,21 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
286
436
  importDeclared = [];
287
437
  const pieces: string[] = [];
288
438
  let cursor = 0;
439
+ let jsCursor = 0;
289
440
  for (const stmt of imports) {
290
- pieces.push(rewritten.slice(cursor, stmt.start));
441
+ const pre = rewritten.slice(cursor, stmt.start);
442
+ pieces.push(pre);
443
+ jsCursor += pre.length;
291
444
  const rewrite = rewriteImport(code, stmt, tokens);
292
445
  importDeclared.push(...rewrite.declaredNames);
293
446
  pieces.push(rewrite.replacement);
447
+ importSpans.push({
448
+ codeStart: stmt.start,
449
+ codeEnd: stmt.end,
450
+ jsStart: jsCursor,
451
+ jsEnd: jsCursor + rewrite.replacement.length - 1,
452
+ });
453
+ jsCursor += rewrite.replacement.length;
294
454
  cursor = stmt.end;
295
455
  }
296
456
  pieces.push(rewritten.slice(cursor));
@@ -301,8 +461,15 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
301
461
  // the pre-rewrite; type stripping below surfaces the error.
302
462
  }
303
463
 
304
- const js = stripTypes(rewritten);
305
- const program: Program = parse(js, { ecmaVersion: "latest", sourceType: "module", allowAwaitOutsideFunction: true });
464
+ const { code: js, lineMap: esbLineMap } = stripTypes(rewritten);
465
+ const rewToCode = buildImportLineMap(code, rewritten, importSpans);
466
+ const program: Program = parse(js, {
467
+ ecmaVersion: "latest",
468
+ sourceType: "module",
469
+ allowAwaitOutsideFunction: true,
470
+ // P6: locations needed to know each top-level node's source lines.
471
+ locations: true,
472
+ });
306
473
 
307
474
  const declaredNames: string[] = [];
308
475
  const replacements: { start: number; end: number; text: string }[] = [];
@@ -353,11 +520,38 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
353
520
  replacements.sort((a, b) => a.start - b.start);
354
521
  let body = "";
355
522
  let cursor = 0;
523
+ // P6: build bodyLine → sourceLine map while splicing. `body` is what V8
524
+ // reports error lines against; `js` is the esbuild-stripped source; the map
525
+ // composes: bodyLine → jsLine (via newline deltas) → rewrittenLine (via
526
+ // esbuild inline sourcemap) → codeLine (via import pre-rewrite).
527
+ const lineMap: { sourceLine: number; bodyLine: number }[] = [];
528
+ let bodyLine = 1;
529
+ let jsLine = 1;
530
+ // jsLineOfBodyStart maps the js line at each body boundary; we track the
531
+ // cumulative js-line delta as body lines are emitted.
356
532
  for (const replacement of replacements) {
357
- body += js.slice(cursor, replacement.start) + replacement.text;
533
+ const pre = js.slice(cursor, replacement.start);
534
+ body += pre;
535
+ bodyLine += countNewlines(pre);
536
+ jsLine += countNewlines(pre);
537
+ // The replacement's first body line maps from the js line at its start.
538
+ const repJsStartLine = lineAt(js, replacement.start);
539
+ // Source line = compose: js → rewritten (esbuild) → code (imports).
540
+ const rewLine = esbLineMap.length > 0 ? esbLineMap[Math.min(repJsStartLine - 1, esbLineMap.length - 1)] : repJsStartLine;
541
+ const srcLine = rewToCode(rewLine);
542
+ lineMap.push({ sourceLine: srcLine, bodyLine: bodyLine });
543
+ body += replacement.text;
544
+ bodyLine += countNewlines(replacement.text);
545
+ // js line after the replacement: replacements are in js coordinate, so
546
+ // jsLine advances by the replacement's text newlines too.
547
+ jsLine += countNewlines(replacement.text);
358
548
  cursor = replacement.end;
359
549
  }
360
- body += js.slice(cursor);
550
+ const tail = js.slice(cursor);
551
+ body += tail;
552
+ bodyLine += countNewlines(tail);
553
+ // Any body line not covered by a replacement maps identity.
554
+ lineMap.sort((a, b) => a.bodyLine - b.bodyLine);
361
555
 
362
- return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])] };
556
+ return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])], lineMap };
363
557
  }
@@ -1,167 +0,0 @@
1
- /**
2
- * Snapshot HMAC helper — opt-in integrity for scratchpad snapshots.
3
- *
4
- * ⚠️ NOT WIRED — see ADR 2026-08-10, Phase 2. This module is NOT connected to
5
- * the snapshot write/read path (`guest.ts` restore uses plain `v8.deserialize`,
6
- * `snapshotNamespace` writes unsigned payloads). Zero production call sites.
7
- * The 11 unit tests here are green but exercise the helper in isolation.
8
- * Wire-or-delete decision: docs/improvement-plan-2026-08-11.md §4 J1.
9
- *
10
- * Closes the E.2 gap declared in docs/improvement-plan-2026-08-09.md and
11
- * src/runtime/scratchpad/README.md:120 ("v8.deserialize of restore content
12
- * is unauthenticated (no HMAC)"). The threat model — a same-uid attacker
13
- * plants a crafted V8 blob at the snapshot path to run deserialize gadgets
14
- * in the guest — does not cross the existing same-uid boundary, but HMAC
15
- * hardening is required before snapshots ever land in a shared/networked
16
- * store.
17
- *
18
- * Migration window (see docs/decisions/2026-08-10-scratchpad-snapshot-hmac.md):
19
- *
20
- * Phase 1 (this module): HMAC sign-on-write + verify-on-read are OPT-IN
21
- * via PI_CREW_SNAPSHOT_HMAC_KEY. When the key is unset, behaviour is
22
- * unchanged (snapshots remain unsigned). When the key is set, writes
23
- * attach a signature and reads verify it; an unsigned/failed snapshot
24
- * is ACCEPTED with a warning so existing snapshots remain readable.
25
- * Phase 2 (after one release): unsigned snapshots are REJECTED when the
26
- * key is set (configurable via PI_CREW_SNAPSHOT_HMAC_STRICT=1).
27
- * Phase 3 (after snapshots move to a shared store): the key becomes
28
- * required and unsigned snapshots are always rejected.
29
- *
30
- * Wire-up into the actual write/read paths is intentionally deferred to a
31
- * follow-up that audits the snapshot envelope format (V8 base64 vs raw
32
- * bytes) and the writeArtifact redaction interaction. See ADR for the plan.
33
- */
34
- import { createHmac, timingSafeEqual } from "node:crypto";
35
-
36
- /**
37
- * Env var holding the HMAC secret. When unset, HMAC is disabled (Phase 0
38
- * behaviour — snapshots unsigned). When set, sign-on-write and
39
- * verify-on-read are enabled.
40
- */
41
- export const SNAPSHOT_HMAC_KEY_ENV = "PI_CREW_SNAPSHOT_HMAC_KEY";
42
-
43
- /**
44
- * Opt-in strict mode (Phase 2): when the key is set AND this is "1",
45
- * unsigned or signature-mismatched snapshots are REJECTED on read instead
46
- * of accepted with a warning.
47
- */
48
- export const SNAPSHOT_HMAC_STRICT_ENV = "PI_CREW_SNAPSHOT_HMAC_STRICT";
49
-
50
- /**
51
- * Header prefix for an inline signature. When snapshots are signed, the
52
- * signature is prepended to the blob as `PI_CREW_SIG=<hex>\n` so a single
53
- * read yields both the signature and the payload. (Sidecar files would
54
- * require writeArtifact coordination that the envelope format does not
55
- * currently support.)
56
- */
57
- export const SNAPSHOT_SIG_PREFIX = "PI_CREW_SIG=";
58
-
59
- export function getSnapshotHmacKey(env: NodeJS.ProcessEnv = process.env): Buffer | undefined {
60
- const raw = env[SNAPSHOT_HMAC_KEY_ENV];
61
- if (!raw) return undefined;
62
- // Accept hex-encoded keys directly; otherwise encode the string as utf8.
63
- // A key shorter than 32 bytes is rejected to prevent trivial brute-force.
64
- const buf = /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0 ? Buffer.from(raw, "hex") : Buffer.from(raw, "utf8");
65
- if (buf.length < 32) {
66
- throw new Error(
67
- `${SNAPSHOT_HMAC_KEY_ENV} must be at least 32 bytes (got ${buf.length}); use a longer key or generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`,
68
- );
69
- }
70
- return buf;
71
- }
72
-
73
- export function isSnapshotHmacStrict(env: NodeJS.ProcessEnv = process.env): boolean {
74
- return env[SNAPSHOT_HMAC_STRICT_ENV] === "1" || env[SNAPSHOT_HMAC_STRICT_ENV] === "true";
75
- }
76
-
77
- /**
78
- * Compute the HMAC-SHA256 signature of `content` under `key`. Returns a
79
- * lowercase hex string.
80
- */
81
- export function signSnapshot(content: Buffer | string, key: Buffer): string {
82
- return createHmac("sha256", key).update(content).digest("hex");
83
- }
84
-
85
- /**
86
- * Constant-time signature comparison. Both signatures must be lowercase
87
- * hex of the same length; anything else returns false without throwing.
88
- */
89
- export function snapshotSignatureMatches(content: Buffer | string, signature: string, key: Buffer): boolean {
90
- const expected = signSnapshot(content, key);
91
- const a = Buffer.from(expected);
92
- const b = Buffer.from(signature);
93
- if (a.length !== b.length) return false;
94
- return timingSafeEqual(a, b);
95
- }
96
-
97
- /**
98
- * Outcome of {@link verifySnapshotPayload}. Callers decide how to react
99
- * based on the strict-mode flag.
100
- */
101
- export type SnapshotVerifyOutcome =
102
- | { kind: "unsigned"; strict: boolean }
103
- | { kind: "verified" }
104
- | { kind: "mismatch"; strict: boolean }
105
- | { kind: "hmac-disabled" };
106
-
107
- /**
108
- * Verify a snapshot payload that may or may not carry an inline signature.
109
- *
110
- * @param payload The raw bytes read from disk (possibly including the
111
- * `PI_CREW_SIG=<hex>\n` prefix).
112
- * @param key The HMAC key, or `undefined` when HMAC is disabled.
113
- * @param strict When true, unsigned/mismatched payloads are reported as
114
- * rejectable. When false (the Phase 1 default), the caller is expected
115
- * to accept the payload with a warning.
116
- */
117
- export function verifySnapshotPayload(payload: Buffer, key: Buffer | undefined, strict: boolean): SnapshotVerifyOutcome {
118
- if (!key) return { kind: "hmac-disabled" };
119
- const prefixStr = SNAPSHOT_SIG_PREFIX;
120
- if (payload.length < prefixStr.length + 1 || payload.subarray(0, prefixStr.length).toString("utf8") !== prefixStr) {
121
- return { kind: "unsigned", strict };
122
- }
123
- const newlineIdx = payload.indexOf(0x0a, prefixStr.length);
124
- if (newlineIdx < 0) return { kind: "unsigned", strict };
125
- const sigHex = payload.subarray(prefixStr.length, newlineIdx).toString("utf8");
126
- const body = payload.subarray(newlineIdx + 1);
127
- return snapshotSignatureMatches(body, sigHex, key) ? { kind: "verified" } : { kind: "mismatch", strict };
128
- }
129
-
130
- /**
131
- * Strip the inline signature prefix and return the bare payload. Returns
132
- * the original buffer when no prefix is present. Used by read paths that
133
- * have already called {@link verifySnapshotPayload} and decided to accept.
134
- */
135
- export function stripSnapshotSignature(payload: Buffer): Buffer {
136
- if (payload.length < SNAPSHOT_SIG_PREFIX.length) return payload;
137
- if (payload.subarray(0, SNAPSHOT_SIG_PREFIX.length).toString("utf8") !== SNAPSHOT_SIG_PREFIX) return payload;
138
- const newlineIdx = payload.indexOf(0x0a, SNAPSHOT_SIG_PREFIX.length);
139
- if (newlineIdx < 0) return payload;
140
- return payload.subarray(newlineIdx + 1);
141
- }
142
-
143
- /**
144
- * Attach an inline signature to a payload. Returns a new buffer shaped as
145
- * `PI_CREW_SIG=<hex>\n<payload>`. Used by write paths that have HMAC
146
- * enabled.
147
- */
148
- export function attachSnapshotSignature(payload: Buffer, key: Buffer): Buffer {
149
- const sig = signSnapshot(payload, key);
150
- return Buffer.concat([Buffer.from(`${SNAPSHOT_SIG_PREFIX}${sig}\n`, "utf8"), payload]);
151
- }
152
-
153
- /**
154
- * Should the caller reject the snapshot based on the verify outcome?
155
- * Centralises the strict-vs-migration decision so callers stay simple.
156
- */
157
- export function shouldRejectSnapshot(outcome: SnapshotVerifyOutcome): boolean {
158
- switch (outcome.kind) {
159
- case "hmac-disabled":
160
- return false;
161
- case "verified":
162
- return false;
163
- case "unsigned":
164
- case "mismatch":
165
- return outcome.strict;
166
- }
167
- }