@webpresso/agent-kit 3.3.0 → 3.3.2

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.
Files changed (62) hide show
  1. package/catalog/agent/rules/pre-implementation.md +30 -8
  2. package/catalog/agent/skills/ultragoal/SKILL.md +5 -18
  3. package/dist/esm/blueprint/core/validation/task-blocks.js +7 -1
  4. package/dist/esm/blueprint/markdown/task-heading.d.ts +21 -0
  5. package/dist/esm/blueprint/markdown/task-heading.js +25 -0
  6. package/dist/esm/blueprint/trust/command-runner.d.ts +40 -0
  7. package/dist/esm/blueprint/trust/command-runner.js +265 -41
  8. package/dist/esm/build/cli-mcp-parity.js +5 -0
  9. package/dist/esm/ci/native-session-memory-cache.d.ts +2 -1
  10. package/dist/esm/ci/native-session-memory-cache.js +2 -1
  11. package/dist/esm/ci/release-progress.d.ts +37 -0
  12. package/dist/esm/ci/release-progress.js +139 -0
  13. package/dist/esm/cli/cli.d.ts +1 -1
  14. package/dist/esm/cli/cli.js +9 -0
  15. package/dist/esm/cli/commands/blueprint/abandon.d.ts +21 -0
  16. package/dist/esm/cli/commands/blueprint/abandon.js +178 -0
  17. package/dist/esm/cli/commands/blueprint/mutations.js +2 -3
  18. package/dist/esm/cli/commands/blueprint/router-dispatch.js +21 -0
  19. package/dist/esm/cli/commands/blueprint/router-output.js +1 -0
  20. package/dist/esm/cli/commands/blueprint/router.d.ts +2 -0
  21. package/dist/esm/cli/commands/blueprint/router.js +5 -2
  22. package/dist/esm/cli/commands/init/convergence-apply.d.ts +28 -5
  23. package/dist/esm/cli/commands/init/convergence-apply.js +75 -13
  24. package/dist/esm/cli/commands/opencode-probe.d.ts +11 -0
  25. package/dist/esm/cli/commands/opencode-probe.js +1 -1
  26. package/dist/esm/cli/commands/quality-runner.js +6 -2
  27. package/dist/esm/cli/commands/release-progress.d.ts +53 -0
  28. package/dist/esm/cli/commands/release-progress.js +155 -0
  29. package/dist/esm/cli/commands/review.js +74 -11
  30. package/dist/esm/cli/commands/typecheck.js +1 -1
  31. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +8 -0
  32. package/dist/esm/cli/commands/worktree/router-dispatch.js +25 -1
  33. package/dist/esm/cli/utils.d.ts +8 -1
  34. package/dist/esm/cli/utils.js +12 -2
  35. package/dist/esm/hooks/pretool-guard/validators/plan-frontmatter.js +3 -2
  36. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +2 -1
  37. package/dist/esm/mcp/blueprint/handlers/task-advance.js +2 -12
  38. package/dist/esm/review/delivery-verifier.d.ts +33 -2
  39. package/dist/esm/review/delivery-verifier.js +130 -24
  40. package/dist/esm/review/execution/review-checkout.d.ts +7 -0
  41. package/dist/esm/review/execution/review-checkout.js +416 -27
  42. package/dist/esm/review/execution/sandbox/adapter.d.ts +37 -0
  43. package/dist/esm/review/execution/sandbox/adapter.js +39 -0
  44. package/dist/esm/review/execution/sandbox/env.d.ts +12 -0
  45. package/dist/esm/review/execution/sandbox/env.js +37 -0
  46. package/dist/esm/review/execution/sandbox/index.d.ts +5 -0
  47. package/dist/esm/review/execution/sandbox/index.js +4 -0
  48. package/dist/esm/review/execution/sandbox/policy.d.ts +19 -0
  49. package/dist/esm/review/execution/sandbox/policy.js +95 -0
  50. package/dist/esm/review/execution/sandbox/probe.d.ts +20 -0
  51. package/dist/esm/review/execution/sandbox/probe.js +97 -0
  52. package/dist/esm/review/execution/sandbox/shell-quote.d.ts +14 -0
  53. package/dist/esm/review/execution/sandbox/shell-quote.js +18 -0
  54. package/dist/esm/review/execution/sandbox/tmpdir.d.ts +9 -0
  55. package/dist/esm/review/execution/sandbox/tmpdir.js +36 -0
  56. package/dist/esm/review/execution/sandbox/types.d.ts +31 -0
  57. package/dist/esm/review/execution/sandbox/types.js +6 -0
  58. package/dist/esm/review/execution/supervisor.js +44 -5
  59. package/dist/esm/review/execution/types.d.ts +2 -0
  60. package/dist/esm/worktrees/owner-dirt.d.ts +40 -0
  61. package/dist/esm/worktrees/owner-dirt.js +104 -0
  62. package/package.json +13 -12
@@ -1,8 +1,9 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { lstatSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { basename, join } from "node:path";
5
+ import { basename, isAbsolute, join, sep } from "node:path";
6
+ import { parse as parseYaml } from "yaml";
6
7
  function git(cwd, args, input) {
7
8
  return execFileSync("git", [...args], {
8
9
  cwd,
@@ -86,8 +87,10 @@ function worktreeFingerprint(cwd, ignoredPaths = new Set()) {
86
87
  // derived/dependency root, because such output can reach neither the caller
87
88
  // nor the reviewed source (the checkout is a disposable tmpdir). Everything
88
89
  // else stays strict: a tracked mutation (` M `/` D `), a new untracked source
89
- // file (`?? `), and any NON-derived gitignored write (e.g. a stray `.env`) all
90
- // still surface.
90
+ // file (`?? `), and any NON-derived gitignored write (e.g. a stray `.env`,
91
+ // `bin/runtime/`) all still surface. Under this branch's node_modules
92
+ // reconstruction the dependency tree is a real, contained directory rather than
93
+ // a symlink, which is why `node_modules` stays in the exempt set at any depth.
91
94
  const NON_SOURCE_IGNORED_SEGMENTS = new Set(["dist", ".tshy", "node_modules"]);
92
95
  function isNonSourceIgnoredPath(relativePath) {
93
96
  return relativePath.split("/").some((segment) => NON_SOURCE_IGNORED_SEGMENTS.has(segment));
@@ -112,23 +115,95 @@ function isNormalizedPackageJsonModification(cwd, line) {
112
115
  try {
113
116
  const working = readFileSync(join(cwd, relativePath), "utf8");
114
117
  const tracked = git(cwd, ["show", `HEAD:${relativePath}`]);
115
- return working.replace(/\s+$/u, "") === tracked.replace(/\s+$/u, "");
118
+ if (working.replace(/\s+$/u, "") === tracked.replace(/\s+$/u, ""))
119
+ return true;
120
+ // B2 neutralizeTargetPackageManager rewrites root package.json by deleting
121
+ // only the untrusted `packageManager` field. That intentional security
122
+ // rewrite must not fail delivery verification as contamination.
123
+ return isPackageManagerOnlyRemoval(tracked, working);
116
124
  }
117
125
  catch {
118
126
  return false;
119
127
  }
120
128
  }
129
+ function isPackageManagerOnlyRemoval(tracked, working) {
130
+ try {
131
+ const trackedJson = JSON.parse(tracked);
132
+ const workingJson = JSON.parse(working);
133
+ const trackedNormalized = stripUntrustedPackageManagerFields(trackedJson);
134
+ const workingNormalized = stripUntrustedPackageManagerFields(workingJson);
135
+ // Working must equal tracked after removing the untrusted fields from both,
136
+ // and at least one untrusted field must have been present on tracked.
137
+ if (JSON.stringify(trackedNormalized.value) !== JSON.stringify(workingNormalized.value)) {
138
+ return false;
139
+ }
140
+ return trackedNormalized.stripped;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
146
+ function stripUntrustedPackageManagerFields(input) {
147
+ let stripped = false;
148
+ const value = { ...input };
149
+ if ("packageManager" in value) {
150
+ delete value.packageManager;
151
+ stripped = true;
152
+ }
153
+ const devEngines = value["devEngines"];
154
+ if (devEngines !== null && typeof devEngines === "object" && !Array.isArray(devEngines)) {
155
+ const engines = { ...devEngines };
156
+ if ("packageManager" in engines) {
157
+ delete engines.packageManager;
158
+ stripped = true;
159
+ value["devEngines"] = engines;
160
+ }
161
+ }
162
+ return { value, stripped };
163
+ }
121
164
  // Single source of truth for turning a `git status --porcelain=v1` line into
122
165
  // its relative path, shared by status()'s filter predicate and
123
166
  // worktreeFingerprint()'s dirty-path hashing so both stay aligned by
124
- // construction. Does not special-case the rename arrow (`old -> new`); a
125
- // rename line's raw tail is used as-is, matching this function's prior
126
- // (pre-refactor) inline behavior.
167
+ // construction.
168
+ //
169
+ // Rename/copy lines use non-null porcelain form `XY ORIG -> DEST` (or quoted
170
+ // paths). The destination is the live path whose bytes must be fingerprinted;
171
+ // using the raw `ORIG -> DEST` tail makes lstat miss both sides and silently
172
+ // drops content from the hash (regression vs paired-record `-z` parsing).
127
173
  function statusEntryPath(line) {
128
- return line.slice(3).replace(/\/$/u, "");
174
+ const tail = line.slice(3).replace(/\/$/u, "");
175
+ const renameSep = " -> ";
176
+ const sep = tail.lastIndexOf(renameSep);
177
+ const pathPart = sep === -1 ? tail : tail.slice(sep + renameSep.length);
178
+ return unquotePorcelainPath(pathPart);
179
+ }
180
+ /** Strip git porcelain double-quotes and reverse C-style escapes for a path. */
181
+ function unquotePorcelainPath(pathPart) {
182
+ if (!pathPart.startsWith('"') || !pathPart.endsWith('"') || pathPart.length < 2) {
183
+ return pathPart;
184
+ }
185
+ return pathPart.slice(1, -1).replace(/\\([\\"ntr])/gu, (_m, ch) => {
186
+ if (ch === "n")
187
+ return "\n";
188
+ if (ch === "t")
189
+ return "\t";
190
+ if (ch === "r")
191
+ return "\r";
192
+ return ch;
193
+ });
129
194
  }
130
195
  function status(cwd, ignoredPaths = new Set()) {
131
- const output = git(cwd, ["status", "--porcelain=v1", "--untracked-files=all", "--ignored"]);
196
+ // `--ignored=matching` reports an ignored DIRECTORY as a single entry instead
197
+ // of recursing into it. This is both correct for the depth-aware exemption
198
+ // (the dir entry carries the derived/dependency segment) and essential now that
199
+ // node_modules is a reconstructed real tree: `-uall --ignored` (traditional)
200
+ // would enumerate every file under node_modules and overflow the pipe buffer.
201
+ const output = git(cwd, [
202
+ "status",
203
+ "--porcelain=v1",
204
+ "--untracked-files=all",
205
+ "--ignored=matching",
206
+ ]);
132
207
  return output
133
208
  .split(/\r?\n/u)
134
209
  .filter((line) => line.length > 0)
@@ -159,7 +234,12 @@ function neutralizeSymlinks(directory) {
159
234
  }
160
235
  export function createReviewTargetCheckout(repositoryRoot, targetSha) {
161
236
  let callerFingerprint = worktreeFingerprint(repositoryRoot);
162
- const cwd = mkdtempSync(join(tmpdir(), "wp-review-target-"));
237
+ // Canonicalize the checkout root. On macOS `tmpdir()` is `/var/folders/...`, a
238
+ // symlink to `/private/var/folders/...`; a non-canonical root makes pnpm's
239
+ // `file:` directory-dependency resolution mismatch (`/var` vs `/private/var`)
240
+ // and wrongly reject a valid lockfile with ERR_PNPM_OUTDATED_LOCKFILE, and it
241
+ // would also weaken path-containment comparisons. Realpath once, up front.
242
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "wp-review-target-")));
163
243
  try {
164
244
  const archivePath = join(cwd, ".wp-review-target.tar");
165
245
  execFileSync("git", ["archive", "--format=tar", `--output=${archivePath}`, targetSha], {
@@ -187,7 +267,7 @@ export function createReviewTargetCheckout(repositoryRoot, targetSha) {
187
267
  git(cwd, ["update-ref", "HEAD", baselineHead]);
188
268
  if (status(cwd).length > 0)
189
269
  throw new Error("review target checkout was not clean at start");
190
- const injectedPaths = projectCallerDependencies(repositoryRoot, cwd);
270
+ projectCallerDependencies(repositoryRoot, cwd);
191
271
  let cleaned = false;
192
272
  return {
193
273
  cwd,
@@ -196,7 +276,7 @@ export function createReviewTargetCheckout(repositoryRoot, targetSha) {
196
276
  },
197
277
  changes: () => {
198
278
  try {
199
- const changes = [...status(cwd, injectedPaths)];
279
+ const changes = [...status(cwd)];
200
280
  if (git(cwd, ["rev-parse", "HEAD"]).trim() !== baselineHead) {
201
281
  changes.push("review checkout HEAD changed");
202
282
  }
@@ -223,25 +303,334 @@ export function createReviewTargetCheckout(repositoryRoot, targetSha) {
223
303
  }
224
304
  }
225
305
  function projectCallerDependencies(repositoryRoot, checkoutRoot) {
226
- const projected = new Set();
227
- const dependencyPath = "node_modules";
228
- const callerPath = join(repositoryRoot, dependencyPath);
229
- const targetPath = join(checkoutRoot, dependencyPath);
306
+ const targetPath = join(checkoutRoot, "node_modules");
307
+ // Respect a target that ships its own (tracked) node_modules — never clobber it.
308
+ if (existsSync(targetPath))
309
+ return;
310
+ // Reconstruct only when there is a manifest + lockfile to reconstruct from and
311
+ // a warm caller store to source packages from. Without any of these there is
312
+ // nothing to (or no safe way to) rebuild, so skip and let the gate commands
313
+ // surface any genuinely missing dependency loudly.
314
+ if (!existsSync(join(checkoutRoot, "package.json")) ||
315
+ !existsSync(join(checkoutRoot, "pnpm-lock.yaml")) ||
316
+ !existsSync(join(repositoryRoot, "node_modules"))) {
317
+ return;
318
+ }
319
+ // SECURITY (S1): a hostile target `pnpm-workspace.yaml` can point pnpm's
320
+ // workspace discovery at an EXTERNAL project (e.g. `../victim`), so the
321
+ // unsandboxed install would create that project's node_modules OUTSIDE the
322
+ // checkout, which the post-install root containment assert never examines.
323
+ // Symlinked escapes are already neutralized on the archived tree
324
+ // (neutralizeSymlinks), so the residual vector is lexical: fail closed on any
325
+ // workspace pattern that is absolute or traverses out with `..`.
326
+ assertWorkspacePatternsContained(checkoutRoot);
327
+ // Reconstruct a REAL, WRITABLE, ISOLATED node_modules inside the disposable
328
+ // checkout with pnpm, sourcing packages from the warm global store via
329
+ // copy-on-write clones (falling back to a copy where reflink is unsupported).
330
+ // Unlike a symlink to the caller's node_modules, this gives the sandboxed gate
331
+ // commands a node_modules they can WRITE into (vite/vitest/tsc caches such as
332
+ // `.vite`, `.vite-temp`, `.cache`) that is confined to the disposable checkout
333
+ // and cannot reach the caller's dependencies or the shared content-addressable
334
+ // store. This is the industry pattern (Nix/Vercel/Cloudflare all reconstruct
335
+ // deps inside the isolate); the reference agent sandboxes (Codex, Claude Code)
336
+ // likewise treat the workspace node_modules as writable-and-isolated.
337
+ //
338
+ // SECURITY (trust boundary): this materialization runs UNSANDBOXED — it
339
+ // precedes the OS sandbox that confines gate execution. So it must itself be
340
+ // hardened against a hostile target, whose `.npmrc` / `pnpm-workspace.yaml`
341
+ // can set pnpm's writable-location settings and redirect this parent-process
342
+ // install to WRITE outside the disposable checkout. `--ignore-scripts` +
343
+ // `--ignore-pnpmfile` block code execution; `--offline` + `--frozen-lockfile`
344
+ // keep it a pure lockfile materialization; `clone-or-copy` never hardlinks so
345
+ // the shared store stays pristine. Crucially, EVERY writable pnpm location is
346
+ // derived from a TRUSTED path and pinned on the CLI, which overrides both the
347
+ // project `.npmrc` and env vars — so a hostile config cannot redirect a write:
348
+ // - store-dir -> the caller's warm content store (read source; resolved in
349
+ // the trusted caller context, never from the target)
350
+ // - modules-dir / virtual-store-dir -> inside the checkout
351
+ // - lockfile-dir -> the checkout (frozen won't rewrite it)
352
+ // - cache/state/global -> a per-run scratch OUTSIDE the checkout (trusted,
353
+ // and therefore never counted as checkout contamination)
354
+ // Without a resolvable trusted store we cannot install without risking a
355
+ // target-redirected store, so we FAIL CLOSED (skip reconstruction) rather than
356
+ // run an unpinnable install.
357
+ const trustedStore = trustedPnpmStorePath(repositoryRoot);
358
+ if (trustedStore === undefined)
359
+ return;
360
+ assertTargetNpmrcSafe(checkoutRoot);
361
+ neutralizeTargetPackageManager(checkoutRoot);
362
+ const trustedPnpm = resolveTrustedPnpmBinary(repositoryRoot);
363
+ const scratch = mkdtempSync(join(tmpdir(), "wp-review-pnpm-"));
230
364
  try {
231
- lstatSync(callerPath);
365
+ runReconstructionInstall(checkoutRoot, trustedStore, scratch, trustedPnpm);
366
+ // Defense-in-depth: verify the reconstructed dependency tree actually lives
367
+ // inside the checkout. The pinned layout should guarantee this, but a
368
+ // containment breach must fail closed rather than silently escape.
369
+ assertDependencyContainment(checkoutRoot);
232
370
  }
233
- catch {
234
- return projected;
371
+ finally {
372
+ rmSync(scratch, { recursive: true, force: true });
235
373
  }
374
+ }
375
+ // Resolve `pnpm` once in the trusted caller context. Never rely on PATH lookup
376
+ // with cwd=checkoutRoot: corepack's PATH shim reads the *target* package.json
377
+ // `packageManager` field and can download+exec a hostile package-manager build
378
+ // (B2). An absolute binary path bypasses that.
379
+ function resolveTrustedPnpmBinary(repositoryRoot) {
236
380
  try {
237
- lstatSync(targetPath);
238
- return projected;
381
+ const resolved = execFileSync("which", ["pnpm"], {
382
+ cwd: repositoryRoot,
383
+ encoding: "utf8",
384
+ stdio: ["ignore", "pipe", "pipe"],
385
+ timeout: 10_000,
386
+ env: sanitizedPnpmEnv(),
387
+ }).trim();
388
+ if (resolved.length === 0 || !existsSync(resolved)) {
389
+ throw new Error("pnpm binary path is empty or missing");
390
+ }
391
+ return resolved;
392
+ }
393
+ catch (error) {
394
+ throw new Error(`cannot resolve trusted pnpm binary: ${error instanceof Error ? error.message : String(error)}`);
395
+ }
396
+ }
397
+ // Target package.json `packageManager` is untrusted under reconstruction. Even
398
+ // with an absolute pnpm path, strip it so ambient corepack cannot re-enter via
399
+ // nested tooling. Fail closed if package.json is unparseable after archive.
400
+ function neutralizeTargetPackageManager(checkoutRoot) {
401
+ const packageJsonPath = join(checkoutRoot, "package.json");
402
+ if (!existsSync(packageJsonPath))
403
+ return;
404
+ let parsed;
405
+ try {
406
+ parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
407
+ }
408
+ catch (error) {
409
+ throw new Error(`review checkout package.json is unparseable: ${error instanceof Error ? error.message : String(error)}`);
410
+ }
411
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
412
+ return;
413
+ const record = parsed;
414
+ let rewritten = false;
415
+ if ("packageManager" in record) {
416
+ delete record.packageManager;
417
+ rewritten = true;
418
+ }
419
+ const devEngines = record["devEngines"];
420
+ if (devEngines !== null && typeof devEngines === "object" && !Array.isArray(devEngines)) {
421
+ const engines = devEngines;
422
+ if ("packageManager" in engines) {
423
+ delete engines.packageManager;
424
+ rewritten = true;
425
+ }
426
+ }
427
+ if (!rewritten)
428
+ return;
429
+ writeFileSync(packageJsonPath, `${JSON.stringify(record, null, 2)}` + "\n", "utf8");
430
+ }
431
+ // Refuse target `.npmrc` keys that can redirect writes, fetch runtimes, or
432
+ // otherwise escape the pinned reconstruction contract (B2 coverage).
433
+ // Fold kebab/snake/camel to a single letter form so `useNodeVersion`,
434
+ // `use-node-version`, and `use_node_version` all match.
435
+ function normalizePnpmConfigKey(key) {
436
+ return key.toLowerCase().replace(/[-_]/gu, "");
437
+ }
438
+ const FORBIDDEN_PNPM_CONFIG_KEYS = new Set([
439
+ "store-dir",
440
+ "modules-dir",
441
+ "virtual-store-dir",
442
+ "cache-dir",
443
+ "state-dir",
444
+ "global-dir",
445
+ "prefix",
446
+ "use-node-version",
447
+ "node-mirror",
448
+ "node-version",
449
+ "package-import-method",
450
+ "global-virtual-store",
451
+ "enable-global-virtual-store",
452
+ "node-linker",
453
+ ].map(normalizePnpmConfigKey));
454
+ // Workspace settings that are NOT CLI-pinned and can force an unsandboxed
455
+ // runtime fetch/exec. CLI-pinned keys (store-dir, enable-global-virtual-store,
456
+ // etc.) remain allowed in workspace yaml because reconstruction overrides them.
457
+ const FORBIDDEN_WORKSPACE_SETTING_KEYS = new Set(["use-node-version", "node-mirror", "node-version", "global-dir", "prefix"].map(normalizePnpmConfigKey));
458
+ function assertTargetNpmrcSafe(checkoutRoot) {
459
+ const npmrcPath = join(checkoutRoot, ".npmrc");
460
+ if (!existsSync(npmrcPath))
461
+ return;
462
+ const text = readFileSync(npmrcPath, "utf8");
463
+ for (const rawLine of text.split("\n")) {
464
+ const line = rawLine.replace(/\r$/u, "").trim();
465
+ if (line.length === 0 || line.startsWith("#") || line.startsWith(";"))
466
+ continue;
467
+ const eq = line.indexOf("=");
468
+ const rawKey = (eq >= 0 ? line.slice(0, eq) : line).trim();
469
+ if (rawKey.length === 0)
470
+ continue;
471
+ const key = normalizePnpmConfigKey(rawKey);
472
+ if (FORBIDDEN_PNPM_CONFIG_KEYS.has(key) || key.startsWith("nodemirror")) {
473
+ throw new Error(`review checkout .npmrc sets forbidden key ${rawKey}; refusing reconstruction`);
474
+ }
475
+ }
476
+ }
477
+ // Run the hardened pnpm materialization, surfacing pnpm's own stdout/stderr on
478
+ // failure — pnpm reports errors like ERR_PNPM_OUTDATED_LOCKFILE on stdout, and
479
+ // an opaque "Command failed" would otherwise hide the actual cause.
480
+ function runReconstructionInstall(checkoutRoot, trustedStore, scratch, trustedPnpm) {
481
+ try {
482
+ execFileSync(trustedPnpm, [
483
+ "install",
484
+ // Offline: pure lockfile materialization, no network. If a package is
485
+ // missing from the warm store this fails closed (ERR_PNPM_NO_OFFLINE_TARBALL).
486
+ "--offline",
487
+ "--frozen-lockfile",
488
+ // NOTE: deliberately NOT --frozen-store. With enableGlobalVirtualStore
489
+ // content-addressable link layouts, --frozen-store reports
490
+ // ERR_PNPM_NO_OFFLINE_TARBALL for packages that offline materialization
491
+ // can still import (verified: same install without --frozen-store
492
+ // succeeds). --offline already forbids downloading; clone-or-copy keeps
493
+ // the shared store content immutable; store-dir is pinned to the
494
+ // trusted caller path. Residual risk is store metadata touch-ups during
495
+ // materialization — acceptable vs blocking every delivery review.
496
+ "--ignore-scripts",
497
+ "--ignore-pnpmfile",
498
+ "--config.node-linker=isolated",
499
+ "--config.modules-dir=node_modules",
500
+ "--config.virtual-store-dir=node_modules/.pnpm",
501
+ "--config.package-import-method=clone-or-copy",
502
+ // Force a self-contained virtual store INSIDE the checkout. A hostile
503
+ // target `pnpm-workspace.yaml` cannot re-enable the global virtual store
504
+ // (which links into the shared, writable host store) because CLI config
505
+ // overrides workspace config.
506
+ "--config.enable-global-virtual-store=false",
507
+ // Pin the writable locations a local install actually touches to a
508
+ // trusted path (see SECURITY note above). CLI `--config.*` overrides
509
+ // project `.npmrc` and env vars. (Global dirs are only written by
510
+ // `pnpm add -g`, never a local install, so they need no pin.)
511
+ // lockfile-dir is relative "." because cwd is already the checkout:
512
+ // absolute lockfile-dir paths make pnpm 11 mis-resolve importers
513
+ // (false OUTDATED_LOCKFILE against apps/desktop).
514
+ `--config.store-dir=${trustedStore}`,
515
+ "--config.lockfile-dir=.",
516
+ `--config.cache-dir=${join(scratch, "cache")}`,
517
+ `--config.state-dir=${join(scratch, "state")}`,
518
+ ], {
519
+ cwd: checkoutRoot,
520
+ stdio: ["ignore", "pipe", "pipe"],
521
+ // A sanitized env drops config-injecting `npm_config_*` / `pnpm_*` vars
522
+ // so no ambient (or later target-learned) env var can redirect a write;
523
+ // the CLI pins are authoritative regardless, this is defense-in-depth.
524
+ env: sanitizedPnpmEnv(),
525
+ // Bounded so a pathological target lockfile cannot stall the verifier
526
+ // before any per-gate timeout applies. `--offline` clone-or-copy of a
527
+ // full workspace is seconds in practice; 120s is a generous ceiling.
528
+ timeout: 120_000,
529
+ });
530
+ }
531
+ catch (error) {
532
+ const detail = error;
533
+ const output = `${String(detail.stdout ?? "")}${String(detail.stderr ?? "")}`.trim();
534
+ throw new Error(`dependency reconstruction failed${output ? `: ${output}` : ""}`, error instanceof Error ? { cause: error } : undefined);
535
+ }
536
+ }
537
+ // Fail closed if the target's pnpm-workspace.yaml points pnpm's workspace
538
+ // discovery outside the checkout (S1). Symlink escapes are already neutralized
539
+ // on the archived tree, so only a lexical escape (absolute path or `..`
540
+ // traversal) remains; an unparseable or list-shaped-wrong manifest is also
541
+ // refused rather than trusted.
542
+ function assertWorkspacePatternsContained(checkoutRoot) {
543
+ const workspaceFile = join(checkoutRoot, "pnpm-workspace.yaml");
544
+ if (!existsSync(workspaceFile))
545
+ return;
546
+ let parsed;
547
+ try {
548
+ parsed = parseYaml(readFileSync(workspaceFile, "utf8"));
549
+ }
550
+ catch (error) {
551
+ throw new Error(`review checkout pnpm-workspace.yaml is unparseable: ${error instanceof Error ? error.message : String(error)}`);
552
+ }
553
+ // Settings-only workspace files (no packages key) are legal; still scan for
554
+ // forbidden settings so the guard is not bypassable by omitting packages.
555
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
556
+ for (const rawKey of Object.keys(parsed)) {
557
+ if (rawKey === "packages")
558
+ continue;
559
+ const key = normalizePnpmConfigKey(rawKey);
560
+ if (FORBIDDEN_WORKSPACE_SETTING_KEYS.has(key) || key.startsWith("nodemirror")) {
561
+ throw new Error(`review checkout pnpm-workspace.yaml sets forbidden key ${rawKey}; refusing reconstruction`);
562
+ }
563
+ }
564
+ }
565
+ const packages = parsed?.packages;
566
+ if (packages === undefined || packages === null)
567
+ return;
568
+ if (!Array.isArray(packages)) {
569
+ throw new Error("review checkout pnpm-workspace.yaml `packages` is not a list; refusing reconstruction");
570
+ }
571
+ for (const pattern of packages) {
572
+ if (typeof pattern !== "string") {
573
+ throw new Error("review checkout pnpm-workspace.yaml `packages` has a non-string pattern; refusing reconstruction");
574
+ }
575
+ // A leading `!` negates (excludes) a pattern; strip it before the escape check.
576
+ const spec = pattern.startsWith("!") ? pattern.slice(1) : pattern;
577
+ if (isAbsolute(spec) || spec.split(/[\\/]/u).includes("..")) {
578
+ throw new Error(`review checkout pnpm-workspace.yaml workspace pattern escapes the checkout: ${pattern}`);
579
+ }
580
+ }
581
+ }
582
+ // Resolve the caller's warm pnpm content store in the TRUSTED caller context
583
+ // (never the target's). Used to pin `store-dir` so a hostile target `.npmrc`
584
+ // cannot redirect the offline install's store to an attacker-chosen path.
585
+ function trustedPnpmStorePath(repositoryRoot) {
586
+ try {
587
+ const resolved = execFileSync("pnpm", ["store", "path"], {
588
+ cwd: repositoryRoot,
589
+ encoding: "utf8",
590
+ stdio: ["ignore", "pipe", "pipe"],
591
+ timeout: 30_000,
592
+ }).trim();
593
+ return resolved.length > 0 ? resolved : undefined;
239
594
  }
240
595
  catch {
241
- // Missing in the target archive means the caller dependency cache may be
242
- // projected for local tooling without overwriting target-owned files.
596
+ return undefined;
597
+ }
598
+ }
599
+ // Env for the reconstruction install with pnpm/npm config-injecting variables
600
+ // stripped, so ambient config cannot redirect a writable location. The CLI
601
+ // `--config.*` pins already win over env; this is belt-and-suspenders.
602
+ function sanitizedPnpmEnv() {
603
+ const env = {};
604
+ for (const [key, value] of Object.entries(process.env)) {
605
+ if (value === undefined)
606
+ continue;
607
+ if (/^(npm_config_|pnpm_)/iu.test(key))
608
+ continue;
609
+ env[key] = value;
610
+ }
611
+ // Real corepack knobs (COREPACK_ENABLE is not a corepack variable).
612
+ env["COREPACK_ENABLE_NETWORK"] = "0";
613
+ env["COREPACK_ENABLE_PROJECT_SPEC"] = "0";
614
+ env["COREPACK_ENABLE_STRICT"] = "0";
615
+ env["COREPACK_ENABLE_DOWNLOAD_PROMPT"] = "0";
616
+ return env;
617
+ }
618
+ // Assert the reconstructed dependency tree is contained within the disposable
619
+ // checkout. Resolves symlinks so a redirected node_modules/virtual store that
620
+ // points at the caller (or anywhere outside checkoutRoot) fails closed.
621
+ export function assertDependencyContainment(checkoutRoot) {
622
+ const boundary = realpathSync(checkoutRoot);
623
+ for (const relative of ["node_modules", join("node_modules", ".pnpm")]) {
624
+ const target = join(checkoutRoot, relative);
625
+ if (!existsSync(target))
626
+ continue;
627
+ const resolved = realpathSync(target);
628
+ if (resolved !== boundary && !resolved.startsWith(boundary + sep)) {
629
+ throw new Error(`projected dependency path escapes the review checkout: ${relative} -> ${resolved}`);
630
+ }
243
631
  }
244
- symlinkSync(callerPath, targetPath, "dir");
245
- projected.add(dependencyPath);
246
- return projected;
247
632
  }
633
+ /** Test-only surface for porcelain path parsing. */
634
+ export const reviewCheckoutInternals = {
635
+ statusEntryPath,
636
+ };
@@ -0,0 +1,37 @@
1
+ import type { GateSandboxPolicy, SandboxedInvocation } from "#review/execution/sandbox/types.js";
2
+ export type SrtWrapResult = {
3
+ readonly argv: readonly string[];
4
+ readonly env: NodeJS.ProcessEnv;
5
+ };
6
+ export type SrtSandboxManager = {
7
+ wrapWithSandboxArgv(command: string, binShell: undefined, customConfig: unknown): Promise<SrtWrapResult>;
8
+ cleanupAfterCommand(): void | Promise<void>;
9
+ isSupportedPlatform(): boolean;
10
+ checkDependencies(): {
11
+ readonly errors: readonly string[];
12
+ readonly warnings: readonly string[];
13
+ };
14
+ };
15
+ export type SrtModule = {
16
+ readonly SandboxManager: SrtSandboxManager;
17
+ };
18
+ /**
19
+ * Dynamically imports srt so an unresolvable module (e.g. under the bun-compiled
20
+ * `wp` binary, where srt's prebuilt vendor assets are unreachable) degrades to a
21
+ * probe failure instead of crashing the CLI at load time.
22
+ */
23
+ export declare function loadSrt(): Promise<SrtModule>;
24
+ /**
25
+ * Wraps a resolved gate invocation for sandboxed execution using srt's
26
+ * no-initialize pattern: the full policy is passed as `customConfig`, so srt
27
+ * starts no proxies or lifecycle and the wrapped command is safe to run under a
28
+ * blocking spawn. The returned env is the caller-provided allowlist (srt injects
29
+ * its sandbox markers via argv, not env).
30
+ */
31
+ export declare function wrapGateForSandbox(invocation: {
32
+ readonly command: string;
33
+ readonly args: readonly string[];
34
+ readonly env: NodeJS.ProcessEnv;
35
+ }, policy: GateSandboxPolicy, srt?: SrtModule): Promise<SandboxedInvocation>;
36
+ /** Decrements srt's per-process active-sandbox counter (Linux mount cleanup). */
37
+ export declare function cleanupSandboxCommand(srt?: SrtModule): Promise<void>;
@@ -0,0 +1,39 @@
1
+ import { buildGateSandboxConfig } from "#review/execution/sandbox/policy.js";
2
+ import { shellQuoteArgv } from "#review/execution/sandbox/shell-quote.js";
3
+ import { withSandboxTmpdir } from "#review/execution/sandbox/tmpdir.js";
4
+ let srtModulePromise;
5
+ /**
6
+ * Dynamically imports srt so an unresolvable module (e.g. under the bun-compiled
7
+ * `wp` binary, where srt's prebuilt vendor assets are unreachable) degrades to a
8
+ * probe failure instead of crashing the CLI at load time.
9
+ */
10
+ export function loadSrt() {
11
+ srtModulePromise ??= import("@anthropic-ai/sandbox-runtime");
12
+ return srtModulePromise;
13
+ }
14
+ /**
15
+ * Wraps a resolved gate invocation for sandboxed execution using srt's
16
+ * no-initialize pattern: the full policy is passed as `customConfig`, so srt
17
+ * starts no proxies or lifecycle and the wrapped command is safe to run under a
18
+ * blocking spawn. The returned env is the caller-provided allowlist (srt injects
19
+ * its sandbox markers via argv, not env).
20
+ */
21
+ export async function wrapGateForSandbox(invocation, policy, srt) {
22
+ const { SandboxManager } = srt ?? (await loadSrt());
23
+ const config = buildGateSandboxConfig(policy);
24
+ const commandString = shellQuoteArgv([invocation.command, ...invocation.args]);
25
+ // Serialize only the wrap (srt reads CLAUDE_CODE_TMPDIR here to bake the
26
+ // child's per-run TMPDIR into the argv); the returned argv carries it, so the
27
+ // subsequent gate run needs no shared process state.
28
+ const wrapped = await withSandboxTmpdir(policy.runTmpDir, () => SandboxManager.wrapWithSandboxArgv(commandString, undefined, config));
29
+ const [command, ...args] = wrapped.argv;
30
+ if (command === undefined) {
31
+ throw new Error("sandbox wrap returned an empty argv");
32
+ }
33
+ return { command, args, env: invocation.env };
34
+ }
35
+ /** Decrements srt's per-process active-sandbox counter (Linux mount cleanup). */
36
+ export async function cleanupSandboxCommand(srt) {
37
+ const { SandboxManager } = srt ?? (await loadSrt());
38
+ await SandboxManager.cleanupAfterCommand();
39
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Builds the allowlist environment for a sandboxed gate spawn. This REPLACES
3
+ * the caller's `process.env` (the secret-leak fix): only non-sensitive,
4
+ * toolchain-required variables are copied. `PATH` is passed through here and
5
+ * the checkout `bin` dir is prepended by `resolveAllowedWpInvocation`. `HOME`
6
+ * and `TMPDIR` point into the disposable run dir so a gate that writes to
7
+ * either cannot reach the caller's real home or a shared host tmp.
8
+ *
9
+ * Secrets and injection vectors (`NODE_OPTIONS`, `BASH_ENV`, `LD_PRELOAD`,
10
+ * `DYLD_INSERT_LIBRARIES`, cloud tokens, …) are simply never copied.
11
+ */
12
+ export declare function buildSandboxedGateEnv(runTmpDir: string): NodeJS.ProcessEnv;
@@ -0,0 +1,37 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const ENV_PASSTHROUGH = ["LANG", "LC_ALL"];
4
+ /**
5
+ * Builds the allowlist environment for a sandboxed gate spawn. This REPLACES
6
+ * the caller's `process.env` (the secret-leak fix): only non-sensitive,
7
+ * toolchain-required variables are copied. `PATH` is passed through here and
8
+ * the checkout `bin` dir is prepended by `resolveAllowedWpInvocation`. `HOME`
9
+ * and `TMPDIR` point into the disposable run dir so a gate that writes to
10
+ * either cannot reach the caller's real home or a shared host tmp.
11
+ *
12
+ * Secrets and injection vectors (`NODE_OPTIONS`, `BASH_ENV`, `LD_PRELOAD`,
13
+ * `DYLD_INSERT_LIBRARIES`, cloud tokens, …) are simply never copied.
14
+ */
15
+ export function buildSandboxedGateEnv(runTmpDir) {
16
+ const home = join(runTmpDir, "home");
17
+ const temp = join(runTmpDir, "tmp");
18
+ mkdirSync(home, { recursive: true });
19
+ mkdirSync(temp, { recursive: true });
20
+ const env = {
21
+ HOME: home,
22
+ TMPDIR: temp,
23
+ TERM: "dumb",
24
+ CI: "1",
25
+ NO_COLOR: "1",
26
+ WP_FORCE_SOURCE: "1",
27
+ };
28
+ const path = process.env["PATH"];
29
+ if (path !== undefined)
30
+ env["PATH"] = path;
31
+ for (const key of ENV_PASSTHROUGH) {
32
+ const value = process.env[key];
33
+ if (value !== undefined)
34
+ env[key] = value;
35
+ }
36
+ return env;
37
+ }
@@ -0,0 +1,5 @@
1
+ export type { SandboxAvailability, GateSandboxPolicy, SandboxedInvocation, } from "#review/execution/sandbox/types.js";
2
+ export { resolveSandboxAvailability, resetSandboxAvailabilityCache, } from "#review/execution/sandbox/probe.js";
3
+ export { wrapGateForSandbox, cleanupSandboxCommand, loadSrt, } from "#review/execution/sandbox/adapter.js";
4
+ export { buildSandboxedGateEnv } from "#review/execution/sandbox/env.js";
5
+ export { buildGateSandboxConfig } from "#review/execution/sandbox/policy.js";
@@ -0,0 +1,4 @@
1
+ export { resolveSandboxAvailability, resetSandboxAvailabilityCache, } from "#review/execution/sandbox/probe.js";
2
+ export { wrapGateForSandbox, cleanupSandboxCommand, loadSrt, } from "#review/execution/sandbox/adapter.js";
3
+ export { buildSandboxedGateEnv } from "#review/execution/sandbox/env.js";
4
+ export { buildGateSandboxConfig } from "#review/execution/sandbox/policy.js";