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 +48 -0
- package/dist/index.mjs +11 -2
- package/package.json +7 -6
- package/src/agents/agent-config.ts +35 -14
- package/src/runtime/scratchpad/README.md +10 -5
- package/src/runtime/scratchpad/guest.ts +103 -2
- package/src/runtime/scratchpad/protocol.ts +13 -1
- package/src/runtime/scratchpad/transform.ts +206 -12
- package/src/runtime/scratchpad/snapshot-hmac.ts +0 -167
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
|
-
|
|
11759
|
-
|
|
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.
|
|
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.
|
|
140
|
-
"@earendil-works/pi-ai": "^0.
|
|
141
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
142
|
-
"@earendil-works/pi-tui": "^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
|
-
|
|
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
|
-
|
|
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
|
-
>
|
|
128
|
-
>
|
|
129
|
-
>
|
|
130
|
-
>
|
|
131
|
-
>
|
|
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 ?? "")
|
|
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: {
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|