@webpresso/agent-kit 3.3.1 → 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.
- package/catalog/agent/rules/pre-implementation.md +15 -8
- package/catalog/agent/skills/ultragoal/SKILL.md +5 -18
- package/dist/esm/blueprint/core/validation/task-blocks.js +7 -1
- package/dist/esm/blueprint/markdown/task-heading.d.ts +21 -0
- package/dist/esm/blueprint/markdown/task-heading.js +25 -0
- package/dist/esm/blueprint/trust/command-runner.d.ts +40 -0
- package/dist/esm/blueprint/trust/command-runner.js +265 -41
- package/dist/esm/build/cli-mcp-parity.js +5 -0
- package/dist/esm/ci/native-session-memory-cache.d.ts +2 -1
- package/dist/esm/ci/native-session-memory-cache.js +2 -1
- package/dist/esm/ci/release-progress.d.ts +37 -0
- package/dist/esm/ci/release-progress.js +139 -0
- package/dist/esm/cli/cli.d.ts +1 -1
- package/dist/esm/cli/cli.js +9 -0
- package/dist/esm/cli/commands/blueprint/mutations.js +2 -3
- package/dist/esm/cli/commands/release-progress.d.ts +53 -0
- package/dist/esm/cli/commands/release-progress.js +155 -0
- package/dist/esm/cli/commands/review.js +74 -11
- package/dist/esm/cli/utils.d.ts +8 -1
- package/dist/esm/cli/utils.js +12 -2
- package/dist/esm/hooks/pretool-guard/validators/plan-frontmatter.js +3 -2
- package/dist/esm/mcp/blueprint/handlers/document-mutations.js +2 -1
- package/dist/esm/mcp/blueprint/handlers/task-advance.js +2 -12
- package/dist/esm/review/delivery-verifier.d.ts +33 -2
- package/dist/esm/review/delivery-verifier.js +130 -24
- package/dist/esm/review/execution/review-checkout.d.ts +1 -0
- package/dist/esm/review/execution/review-checkout.js +386 -23
- package/dist/esm/review/execution/sandbox/adapter.d.ts +37 -0
- package/dist/esm/review/execution/sandbox/adapter.js +39 -0
- package/dist/esm/review/execution/sandbox/env.d.ts +12 -0
- package/dist/esm/review/execution/sandbox/env.js +37 -0
- package/dist/esm/review/execution/sandbox/index.d.ts +5 -0
- package/dist/esm/review/execution/sandbox/index.js +4 -0
- package/dist/esm/review/execution/sandbox/policy.d.ts +19 -0
- package/dist/esm/review/execution/sandbox/policy.js +95 -0
- package/dist/esm/review/execution/sandbox/probe.d.ts +20 -0
- package/dist/esm/review/execution/sandbox/probe.js +97 -0
- package/dist/esm/review/execution/sandbox/shell-quote.d.ts +14 -0
- package/dist/esm/review/execution/sandbox/shell-quote.js +18 -0
- package/dist/esm/review/execution/sandbox/tmpdir.d.ts +9 -0
- package/dist/esm/review/execution/sandbox/tmpdir.js +36 -0
- package/dist/esm/review/execution/sandbox/types.d.ts +31 -0
- package/dist/esm/review/execution/sandbox/types.js +6 -0
- package/dist/esm/review/execution/supervisor.js +44 -5
- package/dist/esm/review/execution/types.d.ts +2 -0
- 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,
|
|
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
|
|
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,12 +115,52 @@ 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
|
-
|
|
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
|
|
@@ -150,7 +193,17 @@ function unquotePorcelainPath(pathPart) {
|
|
|
150
193
|
});
|
|
151
194
|
}
|
|
152
195
|
function status(cwd, ignoredPaths = new Set()) {
|
|
153
|
-
|
|
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
|
+
]);
|
|
154
207
|
return output
|
|
155
208
|
.split(/\r?\n/u)
|
|
156
209
|
.filter((line) => line.length > 0)
|
|
@@ -181,7 +234,12 @@ function neutralizeSymlinks(directory) {
|
|
|
181
234
|
}
|
|
182
235
|
export function createReviewTargetCheckout(repositoryRoot, targetSha) {
|
|
183
236
|
let callerFingerprint = worktreeFingerprint(repositoryRoot);
|
|
184
|
-
|
|
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-")));
|
|
185
243
|
try {
|
|
186
244
|
const archivePath = join(cwd, ".wp-review-target.tar");
|
|
187
245
|
execFileSync("git", ["archive", "--format=tar", `--output=${archivePath}`, targetSha], {
|
|
@@ -209,7 +267,7 @@ export function createReviewTargetCheckout(repositoryRoot, targetSha) {
|
|
|
209
267
|
git(cwd, ["update-ref", "HEAD", baselineHead]);
|
|
210
268
|
if (status(cwd).length > 0)
|
|
211
269
|
throw new Error("review target checkout was not clean at start");
|
|
212
|
-
|
|
270
|
+
projectCallerDependencies(repositoryRoot, cwd);
|
|
213
271
|
let cleaned = false;
|
|
214
272
|
return {
|
|
215
273
|
cwd,
|
|
@@ -218,7 +276,7 @@ export function createReviewTargetCheckout(repositoryRoot, targetSha) {
|
|
|
218
276
|
},
|
|
219
277
|
changes: () => {
|
|
220
278
|
try {
|
|
221
|
-
const changes = [...status(cwd
|
|
279
|
+
const changes = [...status(cwd)];
|
|
222
280
|
if (git(cwd, ["rev-parse", "HEAD"]).trim() !== baselineHead) {
|
|
223
281
|
changes.push("review checkout HEAD changed");
|
|
224
282
|
}
|
|
@@ -245,27 +303,332 @@ export function createReviewTargetCheckout(repositoryRoot, targetSha) {
|
|
|
245
303
|
}
|
|
246
304
|
}
|
|
247
305
|
function projectCallerDependencies(repositoryRoot, checkoutRoot) {
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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-"));
|
|
252
364
|
try {
|
|
253
|
-
|
|
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);
|
|
254
370
|
}
|
|
255
|
-
|
|
256
|
-
|
|
371
|
+
finally {
|
|
372
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
257
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) {
|
|
258
380
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
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;
|
|
261
594
|
}
|
|
262
595
|
catch {
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
}
|
|
265
631
|
}
|
|
266
|
-
symlinkSync(callerPath, targetPath, "dir");
|
|
267
|
-
projected.add(dependencyPath);
|
|
268
|
-
return projected;
|
|
269
632
|
}
|
|
270
633
|
/** Test-only surface for porcelain path parsing. */
|
|
271
634
|
export const reviewCheckoutInternals = {
|
|
@@ -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";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GateSandboxPolicy } from "#review/execution/sandbox/types.js";
|
|
2
|
+
export type SrtFilesystemConfig = {
|
|
3
|
+
readonly allowWrite: readonly string[];
|
|
4
|
+
readonly denyWrite: readonly string[];
|
|
5
|
+
readonly denyRead: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
export type SrtGateConfig = {
|
|
8
|
+
readonly network: {
|
|
9
|
+
readonly allowedDomains: readonly string[];
|
|
10
|
+
readonly deniedDomains: readonly string[];
|
|
11
|
+
};
|
|
12
|
+
readonly filesystem: SrtFilesystemConfig;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Builds the srt filesystem/network policy for one gate: writes confined to the
|
|
16
|
+
* checkout + per-run tmp + the shared per-process scratch dir; the checkout
|
|
17
|
+
* `.git` and srt's default-writable set denied; network fully denied.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildGateSandboxConfig(policy: GateSandboxPolicy): SrtGateConfig;
|