pi-crew 0.11.53 → 0.11.55
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 +152 -0
- package/dist/index.mjs +523 -523
- package/package.json +1 -1
- package/src/config/env-allowlist-registry.ts +4 -0
- package/src/config/env-vars.ts +4 -0
- package/src/extension/team-tool/parallel-dispatch.ts +5 -0
- package/src/extension/team-tool/respond.ts +65 -2
- package/src/extension/team-tool/run.ts +8 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +27 -3
- package/src/runtime/child-pi/child-pi-spawn.ts +25 -1
- package/src/runtime/child-pi/child-pi-timers.ts +25 -0
- package/src/runtime/child-pi/child-pi.ts +9 -0
- package/src/runtime/foreground-control.ts +164 -116
- package/src/runtime/goal-workflow/goal-loop-runner.ts +6 -0
- package/src/runtime/pi-spawn.ts +112 -2
- package/src/runtime/task-runner/child-executor.ts +7 -0
- package/src/runtime/task-runner.ts +17 -0
- package/src/runtime/verification/verification-gates.ts +103 -7
- package/src/runtime/verification/verify-gate.ts +209 -21
- package/src/state/atomic-write.ts +59 -0
- package/src/state/contracts.ts +7 -0
- package/src/state/coordination/mailbox.ts +52 -6
- package/src/state/event-log/event-log.ts +108 -27
- package/src/state/event-log/event-reconstructor.ts +5 -0
- package/src/state/stores/state-store.ts +8 -0
- package/src/state/types.ts +16 -0
- package/src/worktree/worktree-manager.ts +12 -0
package/src/runtime/pi-spawn.ts
CHANGED
|
@@ -80,6 +80,24 @@ function isWithinAllowedPrefixes(resolvedPath: string): boolean {
|
|
|
80
80
|
logInternalError("pi-spawn.allowlist-prefixes.local-bin", error, undefined, "debug");
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// S-4 (Wave G): Pi-managed install layout (the official Pi installer).
|
|
84
|
+
// The installer ships a launcher at ~/.pi/agent/bin/pi (verified live:
|
|
85
|
+
// `which pi` → ~/.pi/agent/bin/pi on a Pi-installer host) and execs the
|
|
86
|
+
// release tree under ~/.pi/agent/install/releases/<ver>/node_modules/.
|
|
87
|
+
// Neither is covered by the npm-style prefixes above, so PATH validation
|
|
88
|
+
// would reject the primary non-npm install layout without these entries.
|
|
89
|
+
// Both are user-owned single-user dirs — same trust class as
|
|
90
|
+
// ~/.npm-global/bin above. Deliberately NOT ~/.pi/agent as a whole so the
|
|
91
|
+
// extensions/ and cache/ subdirs never become auto-executable prefixes.
|
|
92
|
+
try {
|
|
93
|
+
const piAgentDir = path.join(os.homedir(), ".pi", "agent");
|
|
94
|
+
allowedPrefixes.push(path.join(piAgentDir, "bin").toLowerCase());
|
|
95
|
+
allowedPrefixes.push(path.join(piAgentDir, "install").toLowerCase());
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// R17-B3: probe failure → prefix simply not allowed.
|
|
98
|
+
logInternalError("pi-spawn.allowlist-prefixes.pi-managed", error, undefined, "debug");
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
// Canonicalize prefixes (macOS: /var/folders/... realpath → /private/var/folders/...).
|
|
84
102
|
// validateExplicitBin() compares fs.realpathSync(resolved) against this list —
|
|
85
103
|
// without the canonical forms, any prefix reached through a symlink (macOS
|
|
@@ -312,6 +330,98 @@ function validateExplicitBin(explicit: string): string | undefined {
|
|
|
312
330
|
return resolved;
|
|
313
331
|
}
|
|
314
332
|
|
|
333
|
+
/**
|
|
334
|
+
* which(1)-equivalent resolution of a bare command name against PATH.
|
|
335
|
+
* Returns the first hit (absolute path), or undefined when PATH has no match.
|
|
336
|
+
* On win32 the PATHEXT extensions are tried in order — spawn itself does NOT
|
|
337
|
+
* do PATHEXT resolution (see the Issue #33 note on resolveNpmGlobalRoot).
|
|
338
|
+
* @internal — exported for unit tests (S-4).
|
|
339
|
+
*/
|
|
340
|
+
export function resolveCommandOnPath(command: string): string | undefined {
|
|
341
|
+
const rawPath = process.env.PATH;
|
|
342
|
+
if (!rawPath) return undefined;
|
|
343
|
+
const extensions =
|
|
344
|
+
process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => ext.length > 0) : [""];
|
|
345
|
+
for (const dir of rawPath.split(path.delimiter)) {
|
|
346
|
+
if (dir.length === 0) continue;
|
|
347
|
+
for (const ext of extensions) {
|
|
348
|
+
const candidate = path.resolve(dir, `${command}${ext}`);
|
|
349
|
+
try {
|
|
350
|
+
// statSync (symlink-following) rejects missing entries and
|
|
351
|
+
// directories named `pi`; accessSync(X_OK) enforces executability
|
|
352
|
+
// exactly like which(1) on posix.
|
|
353
|
+
if (!fs.statSync(candidate).isFile()) continue;
|
|
354
|
+
if (process.platform !== "win32") fs.accessSync(candidate, fs.constants.X_OK);
|
|
355
|
+
return candidate;
|
|
356
|
+
} catch {
|
|
357
|
+
// Not present / not a file / not executable — keep scanning PATH.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* S-4 (Wave G): validate a PATH-resolved bare-"pi" hit with the same
|
|
366
|
+
* predicate as PI_TEAMS_PI_BIN (validateExplicitBin). Throws on an
|
|
367
|
+
* out-of-prefix hit — or an out-of-prefix symlink target — so the worker is
|
|
368
|
+
* skipped loudly with a diagnostic instead of silently spawning whatever
|
|
369
|
+
* the inherited PATH served (the pre-S-4 bare-"pi" fallback validated
|
|
370
|
+
* nothing). Returns the validated hit.
|
|
371
|
+
* Hardening (2026-10-31, LOW ruling): a FOUND, prefix-valid hit whose
|
|
372
|
+
* realpath probe then fails for a NON-prefix reason (transient EACCES /
|
|
373
|
+
* TOCTOU / symlink loop) ALSO throws — returning undefined here made the
|
|
374
|
+
* caller degrade to the unvalidated bare-"pi" command, the exact S-4
|
|
375
|
+
* primitive this guard exists to close. A found-and-prefix-valid candidate
|
|
376
|
+
* must never degrade to the unvalidated fallback.
|
|
377
|
+
* @internal — exported for unit tests.
|
|
378
|
+
*/
|
|
379
|
+
export function validatePathResolvedPi(hit: string): string | undefined {
|
|
380
|
+
if (!isWithinAllowedPrefixes(hit)) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`PATH-resolved 'pi' at '${hit}' is outside allowed prefixes. ` +
|
|
383
|
+
`Allowed: npm global bin, project node_modules/.bin, APPDATA/npm, ~/.npm-global/bin, ~/.local/bin, ~/.pi/agent (Pi-managed install), or process execPath directory. ` +
|
|
384
|
+
`Refusing to spawn an unvalidated PATH hit — install pi via npm -g or the Pi installer, pin PI_TEAMS_PI_BIN inside an allowed prefix, or extend the allowlist with NPM_CONFIG_PREFIX.`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const real = fs.realpathSync(hit);
|
|
389
|
+
if (!isWithinAllowedPrefixes(real)) {
|
|
390
|
+
throw new Error(`PATH-resolved 'pi' at '${hit}' symlink target '${real}' is outside allowed prefixes.`);
|
|
391
|
+
}
|
|
392
|
+
} catch (e) {
|
|
393
|
+
if (e instanceof Error && e.message.includes("allowed prefixes")) throw e;
|
|
394
|
+
// S-4 hardening: loud skip-with-diagnostic, NEVER a silent degrade to
|
|
395
|
+
// bare "pi" (spawn would re-resolve the name against the inherited PATH
|
|
396
|
+
// with zero validation). The raw cause rides in the message.
|
|
397
|
+
throw new Error(
|
|
398
|
+
`PATH-resolved 'pi' at '${hit}' passed the prefix allowlist but could not be realpath-validated: ${e instanceof Error ? e.message : String(e)}. ` +
|
|
399
|
+
`Refusing to degrade to the unvalidated bare-'pi' fallback — treat this as a skip-and-diagnose (transient EACCES/TOCTOU or a symlink loop).`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
return hit;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* S-4 (Wave G): the bare-"pi" fallback. resolvePiCliScript() missed, and the
|
|
407
|
+
* legacy behavior handed spawn("pi") to inherited-PATH resolution with NO
|
|
408
|
+
* validation. Now: resolve the hit ourselves and validate it with the same
|
|
409
|
+
* allowlist as PI_TEAMS_PI_BIN — an unvalidated/hijacked hit throws (worker
|
|
410
|
+
* skipped with a clear diagnostic, same UX as an invalid PI_TEAMS_PI_BIN),
|
|
411
|
+
* and a validated hit is spawned by absolute path so the child cannot
|
|
412
|
+
* re-resolve a different binary under a mutated env. On win32 a validated
|
|
413
|
+
* hit only proves PATH sanity — spawn cannot exec PATHEXT targets (pi.cmd)
|
|
414
|
+
* without a shell — so the legacy bare "pi" command is kept as before.
|
|
415
|
+
* @internal — exported for unit tests.
|
|
416
|
+
*/
|
|
417
|
+
export function resolvePathFallbackCommand(args: string[]): PiSpawnCommand {
|
|
418
|
+
const hit = resolveCommandOnPath("pi");
|
|
419
|
+
if (!hit) return { command: "pi", args };
|
|
420
|
+
const validated = validatePathResolvedPi(hit);
|
|
421
|
+
if (validated && process.platform !== "win32") return { command: validated, args };
|
|
422
|
+
return { command: "pi", args };
|
|
423
|
+
}
|
|
424
|
+
|
|
315
425
|
export function getPiSpawnCommand(args: string[]): PiSpawnCommand {
|
|
316
426
|
const explicit = getCrewEnv("PI_TEAMS_PI_BIN")?.trim();
|
|
317
427
|
if (explicit) {
|
|
@@ -332,8 +442,8 @@ export function getPiSpawnCommand(args: string[]): PiSpawnCommand {
|
|
|
332
442
|
}
|
|
333
443
|
// Linux/macOS: also resolve the full path so child processes can find 'pi' even if
|
|
334
444
|
// PATH is minimal (e.g. in detached background-runner processes). Fall back to "pi"
|
|
335
|
-
// only if resolution fails.
|
|
445
|
+
// only if resolution fails — S-4: the fallback itself now validates the PATH hit.
|
|
336
446
|
const script = resolvePiCliScript();
|
|
337
447
|
if (script) return { command: process.execPath, args: [script, ...args] };
|
|
338
|
-
return
|
|
448
|
+
return resolvePathFallbackCommand(args);
|
|
339
449
|
}
|
|
@@ -868,6 +868,13 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
868
868
|
agentId: task.id,
|
|
869
869
|
artifactsRoot: manifest.artifactsRoot,
|
|
870
870
|
eventsPath: manifest.eventsPath, // I5: scratchpad metric events from the worker
|
|
871
|
+
// FINDING B (2026-10-31): thread the run's DISPATCHER (leader session)
|
|
872
|
+
// PUBLIC verify key into the worker's PI_CREW_MAILBOX_VERIFY_KEYS env —
|
|
873
|
+
// async runs execute here in the detached runner (different keypair),
|
|
874
|
+
// and without this the leader's respond/steering rows fail the
|
|
875
|
+
// worker's sig verify and drop as noise. Absent on pre-FINDING-B
|
|
876
|
+
// manifests (spawn keeps the singular spawner key only).
|
|
877
|
+
dispatcherVerifyKey: manifest.dispatcherVerifyKey,
|
|
871
878
|
attempt: i,
|
|
872
879
|
steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
|
|
873
880
|
onSpawn: (pid) => {
|
|
@@ -196,6 +196,23 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
196
196
|
})),
|
|
197
197
|
}
|
|
198
198
|
: {}),
|
|
199
|
+
// S-1 (shared-cwd-untracked-harness, HIGH, 2026-10-31): host-owned
|
|
200
|
+
// task records at gate time — the gate refuses the deterministic
|
|
201
|
+
// skip when a WRITE-role task EXECUTED in the gate's own cwd
|
|
202
|
+
// (gitignored node_modules shims sit outside every pinned binding,
|
|
203
|
+
// so a shared-cwd executor's tampered harness could grade its own
|
|
204
|
+
// gate). Host in-memory records ONLY — the gate must never read the
|
|
205
|
+
// worker-mutable tasks.json itself. The verifier's own record is
|
|
206
|
+
// excluded: the gate runs BEFORE its spawn.
|
|
207
|
+
taskRecords: ctx.tasks
|
|
208
|
+
.filter((t) => t.id !== ctx.task.id)
|
|
209
|
+
.map((t) => ({
|
|
210
|
+
taskId: t.id,
|
|
211
|
+
role: t.role,
|
|
212
|
+
status: t.status,
|
|
213
|
+
cwd: t.cwd,
|
|
214
|
+
worktreePath: t.worktree?.path,
|
|
215
|
+
})),
|
|
199
216
|
signal: input.signal,
|
|
200
217
|
classifier: {
|
|
201
218
|
enabled: resolveClassifierEnabled(input.runtimeConfig?.classifierEnabled),
|
|
@@ -197,18 +197,112 @@ const ALLOWED_GATE_COMMAND_PATTERNS: readonly RegExp[] = [
|
|
|
197
197
|
/^(?:gradle|\.\/gradlew)\s+(?:build|check|test)(?:\s|$)/,
|
|
198
198
|
/^(?:mvn|\.\/mvnw)\s+(?:package|test|verify)(?:\s|$)/,
|
|
199
199
|
/^make\s+(?:build|check|lint|test)(?:\s|$)/,
|
|
200
|
-
|
|
200
|
+
// S-3 (deep review 2026-10-31): `printenv` removed — no built-in gate needs
|
|
201
|
+
// it, and it turns any allowlisted gate into an environment-dump read
|
|
202
|
+
// primitive whose output is captured into world-readable artifacts
|
|
203
|
+
// (sanitize survivors + PRESERVE_ENV vars included).
|
|
204
|
+
/^(?:echo|false|printf|true)(?:\s|$)/,
|
|
205
|
+
// S-3: reader commands stay allowlisted ONLY as cwd-scoped evidence
|
|
206
|
+
// extractors — their path arguments are additionally restricted by
|
|
207
|
+
// validateGateReaderPathArgs below (no absolute or ~-prefixed paths).
|
|
201
208
|
/^(?:grep|head|rg|tail)(?:\s|$)/,
|
|
202
209
|
];
|
|
203
210
|
|
|
204
211
|
const NODE_EVAL_MODE_PATTERN = /(?:^|\s)["']?(?:(?:-e|-p)(?!-)|--(?:eval|print)(?:=|\s|["']|$))/;
|
|
205
212
|
|
|
206
|
-
|
|
213
|
+
/** S-3 (deep review 2026-10-31): grep/head/rg/tail are allowlisted as
|
|
214
|
+
* cwd-scoped evidence extractors. A path argument resolving outside the gate
|
|
215
|
+
* cwd — absolute (`/etc/passwd`), home-relative (`~/notes`), Windows absolute
|
|
216
|
+
* (`C:/Windows`, `\\\\host\\share`), or `..`-relative escaping it — is an
|
|
217
|
+
* out-of-workspace read primitive, so it is rejected outright. Fail-closed:
|
|
218
|
+
* simpler than allowlisting the cwd's absolute prefix, which quoting and `~`
|
|
219
|
+
* expansion would defeat. Option flags (`-c`, `-rn`, …) and cwd-relative
|
|
220
|
+
* paths (`src/`, `dist/index.mjs`) pass. */
|
|
221
|
+
const GATE_READER_COMMAND_PATTERN = /^(?:grep|head|rg|tail)(?:\s|$)/;
|
|
222
|
+
|
|
223
|
+
/** S-3 hardening (2026-10-31, LOW ruling): `rg --pre <cmd>` runs an arbitrary
|
|
224
|
+
* preprocessor executable on every file the reader touches — an execution
|
|
225
|
+
* primitive hiding inside a reader allowlist entry. Denied in both the
|
|
226
|
+
* attached (`--pre=x`) and detached (`--pre x`) spellings. */
|
|
227
|
+
const GATE_READER_PREPROCESSOR_PATTERN = /(?:^|\s)--pre(?:\s|=|$)/;
|
|
228
|
+
|
|
229
|
+
/** S-3 hardening: Windows absolute forms — drive letter (`C:/…`, `C:\\…`) and
|
|
230
|
+
* UNC (`\\\\host\\share`). */
|
|
231
|
+
const WINDOWS_ABSOLUTE_ARG_PATTERN = /^[A-Za-z]:[\\/]/;
|
|
232
|
+
|
|
233
|
+
/** S-3 hardening: lexical path containment — `child` inside `parent`? Exact
|
|
234
|
+
* segment boundary on the `..` escape; path.relative never returns a bare
|
|
235
|
+
* absolute path on the same tree. */
|
|
236
|
+
function isPathWithin(parent: string, child: string): boolean {
|
|
237
|
+
const rel = path.relative(parent, child);
|
|
238
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function validateGateReaderPathArgs(pipelineCommand: string, original: string, gateCwd?: string): void {
|
|
242
|
+
if (GATE_READER_PREPROCESSOR_PATTERN.test(pipelineCommand)) {
|
|
243
|
+
throw new Error(`Security: verification gate command rejected (reader --pre runs an arbitrary preprocessor): ${original}`);
|
|
244
|
+
}
|
|
245
|
+
for (const rawArg of pipelineCommand.split(/\s+/).slice(1)) {
|
|
246
|
+
// Strip at most one leading and one trailing quote char: `sh -c`
|
|
247
|
+
// expands quotes before path resolution, so `"/etc/passwd"` (and an
|
|
248
|
+
// unbalanced prefix like `"~/a` from a whitespace-split quoted path)
|
|
249
|
+
// must not slip through.
|
|
250
|
+
let arg = rawArg;
|
|
251
|
+
if (arg.startsWith('"') || arg.startsWith("'")) arg = arg.slice(1);
|
|
252
|
+
if (arg.endsWith('"') || arg.endsWith("'")) arg = arg.slice(0, -1);
|
|
253
|
+
if (arg.length === 0) continue;
|
|
254
|
+
if (arg.startsWith("/") || arg.startsWith("~")) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Security: verification gate command rejected (reader path argument escapes the gate cwd — absolute and ~-prefixed paths are not allowed): ${original}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
// Windows absolute forms — on POSIX a `C:/x` arg is a cwd-relative
|
|
260
|
+
// filename to US, but sh/spawn on win32 resolves it absolutely.
|
|
261
|
+
if (WINDOWS_ABSOLUTE_ARG_PATTERN.test(arg) || arg.startsWith("\\\\")) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Security: verification gate command rejected (reader path argument is a Windows absolute path — must stay cwd-relative): ${original}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (arg.startsWith("-")) {
|
|
267
|
+
// Flags are not paths we can resolve — but an ATTACHED value carrying a
|
|
268
|
+
// path separator (`-f/etc/x`, `--pre=/usr/bin/id`) smuggles an absolute
|
|
269
|
+
// path past the positional checks above, so reject any separator in a
|
|
270
|
+
// flag token (reader flags are pattern/count selectors — no slashes).
|
|
271
|
+
if (/[\\/]/.test(arg)) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Security: verification gate command rejected (reader flag carries a path in its attached value): ${original}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
// Positional argument (pattern or path): when the gate cwd is known,
|
|
279
|
+
// resolve against it and enforce containment (covers `a/../../etc/x`
|
|
280
|
+
// escapes that do not START with `..`); without a cwd (test helper), fall
|
|
281
|
+
// back to a lexical leading-`..` segment check.
|
|
282
|
+
if (gateCwd !== undefined) {
|
|
283
|
+
const resolved = path.resolve(gateCwd, arg);
|
|
284
|
+
if (!isPathWithin(gateCwd, resolved)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Security: verification gate command rejected (reader path argument resolves outside the gate cwd): ${original}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
} else if (arg === ".." || arg.startsWith(`..${path.sep}`) || arg.startsWith("../")) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Security: verification gate command rejected (reader path argument escapes the gate cwd — ..-relative paths are not allowed): ${original}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function validateAllowedGateCommands(normalized: string, original: string, gateCwd?: string): void {
|
|
207
298
|
const pipelineCommands = normalized.split("|").map((part) => part.trim());
|
|
208
299
|
for (const pipelineCommand of pipelineCommands) {
|
|
209
300
|
if (/^node(?:\s|$)/.test(pipelineCommand) && NODE_EVAL_MODE_PATTERN.test(pipelineCommand)) {
|
|
210
301
|
throw new Error(`Security: verification gate command rejected (Node eval/print mode is not allowed): ${original}`);
|
|
211
302
|
}
|
|
303
|
+
if (GATE_READER_COMMAND_PATTERN.test(pipelineCommand)) {
|
|
304
|
+
validateGateReaderPathArgs(pipelineCommand, original, gateCwd);
|
|
305
|
+
}
|
|
212
306
|
if (!pipelineCommand || !ALLOWED_GATE_COMMAND_PATTERNS.some((pattern) => pattern.test(pipelineCommand))) {
|
|
213
307
|
throw new Error(`Security: verification gate command rejected (command is not allowlisted): ${original}`);
|
|
214
308
|
}
|
|
@@ -222,11 +316,11 @@ function validateAllowedGateCommands(normalized: string, original: string): void
|
|
|
222
316
|
* and stderr fd duplication (`2>&1`).
|
|
223
317
|
*/
|
|
224
318
|
/** @internal — exported for injection-guard unit testing (Round 25). */
|
|
225
|
-
export function __test__validateGateCommand(command: string): void {
|
|
226
|
-
validateGateCommand(command);
|
|
319
|
+
export function __test__validateGateCommand(command: string, gateCwd?: string): void {
|
|
320
|
+
validateGateCommand(command, gateCwd);
|
|
227
321
|
}
|
|
228
322
|
|
|
229
|
-
function validateGateCommand(command: string): void {
|
|
323
|
+
function validateGateCommand(command: string, gateCwd?: string): void {
|
|
230
324
|
// Round 25 (VULN-3): check the ORIGINAL command for raw newlines BEFORE
|
|
231
325
|
// normalization. The regex below runs on the NORMALIZED command (which
|
|
232
326
|
// collapses \s+ incl. newlines to a single space), so a newline would be
|
|
@@ -246,7 +340,7 @@ function validateGateCommand(command: string): void {
|
|
|
246
340
|
if (DANGEROUS_SHELL_PATTERNS.test(normalized)) {
|
|
247
341
|
throw new Error(`Security: verification gate command rejected (dangerous shell pattern): ${command}`);
|
|
248
342
|
}
|
|
249
|
-
validateAllowedGateCommands(normalized, command);
|
|
343
|
+
validateAllowedGateCommands(normalized, command, gateCwd);
|
|
250
344
|
}
|
|
251
345
|
|
|
252
346
|
/** U13 (upgrade spec 2026-10-09): exported so the deterministic verify_gate
|
|
@@ -259,7 +353,9 @@ export async function executeCommand(
|
|
|
259
353
|
timeoutMs: number = 120000,
|
|
260
354
|
): Promise<{ exitCode: number | null; output: string; durationMs: number }> {
|
|
261
355
|
// SECURITY: Validate command before shell execution to prevent injection.
|
|
262
|
-
|
|
356
|
+
// The execution cwd IS the gate cwd for reader-path containment — thread it
|
|
357
|
+
// down so `..`-relative escapes resolve against where the command runs.
|
|
358
|
+
validateGateCommand(command, cwd);
|
|
263
359
|
|
|
264
360
|
const start = Date.now();
|
|
265
361
|
let output = "";
|
|
@@ -79,6 +79,14 @@
|
|
|
79
79
|
* (worktree mode — the gate's pristine tree structurally excludes that
|
|
80
80
|
* work), the skip is refused and the LLM verifier spawns with the guard
|
|
81
81
|
* note (dependencyWorktreeProbes → reason "unverified_worktree_work").
|
|
82
|
+
* - S-1 (2026-10-31, HIGH): in SHARED-CWD mode the bindings still cannot
|
|
83
|
+
* see GITIGNORED content in the gate cwd itself — the gate runs its checks
|
|
84
|
+
* in the same directory a WRITE-role executor just wrote to, and a
|
|
85
|
+
* tampered gitignored harness (node_modules shims) grades its own gate.
|
|
86
|
+
* When host-owned task records show a write-role task EXECUTED in the
|
|
87
|
+
* gate cwd, the skip is refused and the LLM verifier spawns (taskRecords
|
|
88
|
+
* → reason "shared-cwd-untracked-harness") — fresh AND memoized PASS
|
|
89
|
+
* verdicts, mirroring the W2 guard.
|
|
82
90
|
*
|
|
83
91
|
* SAFETY: a gate that cannot run (disabled by env, no checks resolved,
|
|
84
92
|
* executor failure) degrades to "spawn the LLM verifier as today" — the
|
|
@@ -94,9 +102,14 @@
|
|
|
94
102
|
*/
|
|
95
103
|
import { execFileSync } from "node:child_process";
|
|
96
104
|
import * as fs from "node:fs";
|
|
105
|
+
import * as path from "node:path";
|
|
97
106
|
import { getCrewEnvBool, getCrewEnvInt } from "../../config/env-vars.ts";
|
|
98
107
|
import type { MetricRegistry } from "../../observability/metric-registry.ts";
|
|
99
108
|
import { assertWorktreePointerTrusted, buildGitSafeEnv, hardenGitArgs } from "../../worktree/git-hardening.ts";
|
|
109
|
+
// Static edge is graph-free: role-permission.ts is already in the index.bundle
|
|
110
|
+
// value-import graph (task-runner/pre-execution.ts imports it), so this adds no
|
|
111
|
+
// bundle-graph file (check-lazy-imports budget) — same rationale as below.
|
|
112
|
+
import { permissionForRole } from "../role-permission.ts";
|
|
100
113
|
import type { VerifierPreGateResult } from "./classifier-pre-gate.ts";
|
|
101
114
|
import { executeCommand } from "./verification-gates.ts";
|
|
102
115
|
// Static edge is graph-free: verification-integrity.ts is already in the
|
|
@@ -732,6 +745,129 @@ function resolveWorktreeGuardDecision(
|
|
|
732
745
|
};
|
|
733
746
|
}
|
|
734
747
|
|
|
748
|
+
// ─── S-1 (shared-cwd untracked harness, HIGH, 2026-10-31): task-record guard ──
|
|
749
|
+
|
|
750
|
+
/** S-1: host-owned view of ONE task record at gate time (a structural subset
|
|
751
|
+
* of TeamTaskState — the state layer is not imported here). Callers pass the
|
|
752
|
+
* records for every task in the run EXCEPT the verifier itself (the gate
|
|
753
|
+
* runs BEFORE the verifier's own spawn — including it would fire the guard
|
|
754
|
+
* on every shared-cwd run). */
|
|
755
|
+
export interface VerifierGateTaskRecord {
|
|
756
|
+
taskId: string;
|
|
757
|
+
/** Role as the HOST recorded it at dispatch. Write-capability comes from
|
|
758
|
+
* the role-permission WRITE_ROLES allowlist — the same list that gates
|
|
759
|
+
* worker tool access, so "a role that can write files" is exactly "a
|
|
760
|
+
* role that can plant a gitignored harness shim". */
|
|
761
|
+
role: string;
|
|
762
|
+
/** Lifecycle status at gate time. "queued" never started and "skipped"
|
|
763
|
+
* never ran — every other status (incl. "cancelled": an aborted worker
|
|
764
|
+
* may still have written) counts as EXECUTED, fail-closed. */
|
|
765
|
+
status: string;
|
|
766
|
+
/** The cwd the task executes in. */
|
|
767
|
+
cwd: string;
|
|
768
|
+
/** Present when the task ran in its own worktree (worktree mode) — its
|
|
769
|
+
* writes live outside the gate cwd and the W2 probe path owns them. */
|
|
770
|
+
worktreePath?: string;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** S-1: statuses proving a worker actually ran (see VerifierGateTaskRecord.status). */
|
|
774
|
+
const EXECUTED_TASK_STATUSES: ReadonlySet<string> = new Set(["running", "waiting", "completed", "failed", "cancelled", "needs_attention"]);
|
|
775
|
+
|
|
776
|
+
/** S-1: same-directory compare — exact string first (host records derive both
|
|
777
|
+
* sides from the same manifest cwd string), realpath fallback for textual
|
|
778
|
+
* variants (symlinks / Windows \\?\\ prefixes). Unresolvable ⇒ NOT equal: a
|
|
779
|
+
* record whose cwd cannot be resolved carries no evidence it ran here.
|
|
780
|
+
* Guard hardening (2026-10-31, M-ruling): a record cwd that is a realpath'd
|
|
781
|
+
* ANCESTOR of the gate cwd also counts — worker writes are NOT OS-scoped to
|
|
782
|
+
* the task cwd, so a WRITE-role task running at a monorepo root (task
|
|
783
|
+
* cwd=/ws) wrote into the gate's cwd (/ws/pi-crew) all the same. */
|
|
784
|
+
function taskRecordSharesGateCwd(recordCwd: string, gateCwd: string): boolean {
|
|
785
|
+
if (recordCwd === gateCwd) return true;
|
|
786
|
+
try {
|
|
787
|
+
const realRecord = fs.realpathSync.native(recordCwd);
|
|
788
|
+
const realGate = fs.realpathSync.native(gateCwd);
|
|
789
|
+
if (realRecord === realGate) return true;
|
|
790
|
+
// Ancestor containment: is realGate strictly inside realRecord? (Symlink
|
|
791
|
+
// loops / unresolvable sides already fell to the catch below — the
|
|
792
|
+
// existing fail-closed fallback pattern.)
|
|
793
|
+
const rel = path.relative(realRecord, realGate);
|
|
794
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
795
|
+
} catch {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** S-1 hardening (2026-10-31, security HIGH follow-up): the tool-granting
|
|
801
|
+
* layer normalizes underscore→hyphen role keys before its permission lookup
|
|
802
|
+
* (agents/agent-config.ts normalizeRoleKey + permissionForRole, F10), so a
|
|
803
|
+
* dispatched `test_engineer` / `cold_verifier` / `chain_executor` role is
|
|
804
|
+
* WRITE-capable at the worker — but the guard's un-normalized lookup here
|
|
805
|
+
* default-denied it to read_only, leaving the guard inert for exactly the
|
|
806
|
+
* roles it exists to catch (the S-1 exploit preserved). Mirrored locally
|
|
807
|
+
* (3 lines) instead of imported: agent-config.ts is not in this module's
|
|
808
|
+
* bundle-graph budget (see the check-lazy-imports precedent comments above).
|
|
809
|
+
* TODO(role-key-normalization): un-normalized permissionForRole copies also
|
|
810
|
+
* exist at task-runner/pre-execution.ts:248, spawn-policy.ts:184 and
|
|
811
|
+
* task-runner/prompt-builder.ts:29 — centralize normalizeRoleKey next to
|
|
812
|
+
* permissionForRole (role-permission.ts) and consume it from every site. */
|
|
813
|
+
function normalizeRoleKeyForGuard(role: string): string {
|
|
814
|
+
return role.includes("_") ? role.replaceAll("_", "-") : role;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** S-1: task ids whose execution wrote into the gate cwd through paths no
|
|
818
|
+
* pinned binding can see: write-capable role + EXECUTED + no worktree of
|
|
819
|
+
* its own + same cwd (or an ancestor of it). Tracked writes are already
|
|
820
|
+
* covered by the W2 tree binding; this closes the GITIGNORED remainder
|
|
821
|
+
* (node_modules shims) that every binding deliberately excludes. */
|
|
822
|
+
function findSharedCwdUntrackedHarnessWriters(records: readonly VerifierGateTaskRecord[] | undefined, gateCwd: string): string[] {
|
|
823
|
+
if (!records || records.length === 0) return [];
|
|
824
|
+
const writers: string[] = [];
|
|
825
|
+
for (const record of records) {
|
|
826
|
+
if (record.worktreePath) continue;
|
|
827
|
+
if (!EXECUTED_TASK_STATUSES.has(record.status)) continue;
|
|
828
|
+
if (permissionForRole(normalizeRoleKeyForGuard(record.role)) !== "workspace_write") continue;
|
|
829
|
+
if (!taskRecordSharesGateCwd(record.cwd, gateCwd)) continue;
|
|
830
|
+
writers.push(record.taskId);
|
|
831
|
+
}
|
|
832
|
+
return writers;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* S-1 guard (shared-cwd-untracked-harness): in shared-cwd mode the gate runs
|
|
837
|
+
* its checks in the SAME directory a WRITE-role executor just wrote to
|
|
838
|
+
* (worktree-manager returns { cwd: task.cwd } for non-worktree runs), and the
|
|
839
|
+
* dispatch-pinned bindings cover TRACKED content only — gitignored harness
|
|
840
|
+
* files (node_modules shims) sit outside every binding, so a tampered harness
|
|
841
|
+
* could grade its own gate and skip the LLM verifier. When the host's task
|
|
842
|
+
* records show any write-role task EXECUTED in the gate cwd, the
|
|
843
|
+
* deterministic skip is refused and the LLM verifier spawns with the guard
|
|
844
|
+
* note (reason "shared-cwd-untracked-harness") — fresh AND memoized PASS
|
|
845
|
+
* verdicts, exactly like the W2 worktree guard. Non-skip decisions pass
|
|
846
|
+
* through untouched. Worktree mode is unaffected (records carrying a
|
|
847
|
+
* worktreePath are skipped here; their uncommitted work is the W2 probe's
|
|
848
|
+
* job, and a clean dependency worktree keeps the skip).
|
|
849
|
+
*
|
|
850
|
+
* Residual (documented, same class as the W2 probe source): the records are
|
|
851
|
+
* the host's best view, re-read from tasks.json — a worker that forges its
|
|
852
|
+
* own record (role/status) in that file evades this guard; state-store
|
|
853
|
+
* integrity is a separate lane, not the gate's.
|
|
854
|
+
*/
|
|
855
|
+
function applySharedCwdHarnessGuard(
|
|
856
|
+
records: readonly VerifierGateTaskRecord[] | undefined,
|
|
857
|
+
gateCwd: string,
|
|
858
|
+
previous: { outcome: VerifyGateOutcome; decision: VerifierSpawnDecision; forcedSpawn: boolean },
|
|
859
|
+
): { outcome: VerifyGateOutcome; decision: VerifierSpawnDecision; forcedSpawn: boolean } {
|
|
860
|
+
if (!previous.decision.skipVerifier || !records || records.length === 0) return previous;
|
|
861
|
+
const writers = findSharedCwdUntrackedHarnessWriters(records, gateCwd);
|
|
862
|
+
if (writers.length === 0) return previous;
|
|
863
|
+
const note = `shared-cwd-harness: write-role task(s) ${writers.join(", ")} executed in the gate's own cwd — gitignored harness files (e.g. node_modules shims) there sit outside every dispatch-pinned binding, so this PASS cannot certify the harness it ran; refusing the deterministic skip`;
|
|
864
|
+
return {
|
|
865
|
+
outcome: { ...previous.outcome, evidenceLines: [...previous.outcome.evidenceLines, note] },
|
|
866
|
+
decision: { skipVerifier: false, reason: "shared-cwd-untracked-harness", attachGateContext: true },
|
|
867
|
+
forcedSpawn: true,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
735
871
|
export interface VerifierGatePreGateInput {
|
|
736
872
|
cwd: string;
|
|
737
873
|
/** Explicit checks take precedence over every other source (tool path /
|
|
@@ -770,6 +906,16 @@ export interface VerifierGatePreGateInput {
|
|
|
770
906
|
* that structurally excludes that work) and the LLM verifier spawns with
|
|
771
907
|
* the guard note (reason "unverified_worktree_work"). */
|
|
772
908
|
dependencyWorktreeProbes?: VerifierGateWorkProbe[];
|
|
909
|
+
/** S-1 (shared-cwd-untracked-harness, HIGH, 2026-10-31): host-owned records
|
|
910
|
+
* for every task in the run EXCEPT the verifier itself. When any record
|
|
911
|
+
* shows a write-capable role (role-permission WRITE_ROLES) that EXECUTED
|
|
912
|
+
* in the gate cwd with no worktree of its own (shared-cwd mode), a gate
|
|
913
|
+
* PASS — fresh OR memoized — refuses the skipVerifier decision (gitignored
|
|
914
|
+
* harness files in the gate cwd sit outside every dispatch-pinned binding)
|
|
915
|
+
* and the LLM verifier spawns with the guard note (reason
|
|
916
|
+
* "shared-cwd-untracked-harness"). Absent/empty ⇒ guard inert (pre-S-1
|
|
917
|
+
* behavior). */
|
|
918
|
+
taskRecords?: VerifierGateTaskRecord[];
|
|
773
919
|
}
|
|
774
920
|
|
|
775
921
|
export interface VerifierGatePreGateResult {
|
|
@@ -877,7 +1023,14 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
|
|
|
877
1023
|
// W2: the guard runs on memo hits too — a recorded PASS for a pristine
|
|
878
1024
|
// tree must not skip the verifier when the verified dependency work
|
|
879
1025
|
// lives outside this tree (the original finding-3 memo-replay PoC).
|
|
880
|
-
|
|
1026
|
+
// S-1: the shared-cwd harness guard chains after it — a memoized PASS
|
|
1027
|
+
// must not skip either when a write-role task executed in the gate cwd
|
|
1028
|
+
// (gitignored harness tampering is invisible to the memo's tree key).
|
|
1029
|
+
const guarded = applySharedCwdHarnessGuard(
|
|
1030
|
+
input.taskRecords,
|
|
1031
|
+
input.cwd,
|
|
1032
|
+
resolveWorktreeGuardDecision(input.dependencyWorktreeProbes, memoHit),
|
|
1033
|
+
);
|
|
881
1034
|
incMetric(input.metricRegistry, "crew.verification.verify_gate_runs_total", "Deterministic verify_gate executions by verdict", {
|
|
882
1035
|
verdict: guarded.outcome.verdict,
|
|
883
1036
|
});
|
|
@@ -888,12 +1041,21 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
|
|
|
888
1041
|
{},
|
|
889
1042
|
);
|
|
890
1043
|
if (guarded.forcedSpawn) {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1044
|
+
if (guarded.decision.reason === "shared-cwd-untracked-harness") {
|
|
1045
|
+
incMetric(
|
|
1046
|
+
input.metricRegistry,
|
|
1047
|
+
"crew.verification.verifier_gate_shared_cwd_guard_total",
|
|
1048
|
+
"S-1: PASS-skip refusals — write-role execution in the gate's shared cwd (gitignored harness outside pinned bindings)",
|
|
1049
|
+
{ result: "spawn_forced" },
|
|
1050
|
+
);
|
|
1051
|
+
} else {
|
|
1052
|
+
incMetric(
|
|
1053
|
+
input.metricRegistry,
|
|
1054
|
+
"crew.verification.verifier_gate_worktree_guard_total",
|
|
1055
|
+
"W2: PASS-skip refusals — dependency worktree work the gate's evaluated tree does not contain",
|
|
1056
|
+
{ result: "spawn_forced" },
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
897
1059
|
}
|
|
898
1060
|
if (guarded.decision.skipVerifier) {
|
|
899
1061
|
incMetric(
|
|
@@ -999,27 +1161,53 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
|
|
|
999
1161
|
// `input.classifier` stays on the interface (deprecated) because the dispatch
|
|
1000
1162
|
// call site in task-runner.ts passes it; removing the field is a follow-up
|
|
1001
1163
|
// that must land with that call site.
|
|
1002
|
-
// ── W10-2: record the green verdict for same-tree memoization (AFTER the
|
|
1003
|
-
// post-bookend — a drift-downgraded INCONCLUSIVE must never memoize).
|
|
1004
|
-
if (outcome.verdict === "PASS" && recordVerifyGateMemo(input.cwd, checks, outcome)) {
|
|
1005
|
-
incMetric(
|
|
1006
|
-
input.metricRegistry,
|
|
1007
|
-
"crew.verification.verify_gate_memo_records_total",
|
|
1008
|
-
"W10-2 PASS verdicts recorded for same-tree memoization",
|
|
1009
|
-
{},
|
|
1010
|
-
);
|
|
1011
|
-
}
|
|
1012
1164
|
const preGate: VerifierPreGateResult | undefined = undefined;
|
|
1013
1165
|
// W2: the guard runs on fresh verdicts too — a green gate in the verifier's
|
|
1014
1166
|
// own pristine worktree must not skip the verifier when the dependency work
|
|
1015
1167
|
// it certifies lives in other, uncommitted worktrees.
|
|
1016
|
-
|
|
1168
|
+
// S-1: the shared-cwd harness guard chains after it — a fresh PASS in a cwd a
|
|
1169
|
+
// write-role task executed in must not skip either (gitignored harness
|
|
1170
|
+
// tampering sits outside every binding this gate can pin).
|
|
1171
|
+
const guarded = applySharedCwdHarnessGuard(
|
|
1172
|
+
input.taskRecords,
|
|
1173
|
+
input.cwd,
|
|
1174
|
+
resolveWorktreeGuardDecision(input.dependencyWorktreeProbes, outcome),
|
|
1175
|
+
);
|
|
1017
1176
|
if (guarded.forcedSpawn) {
|
|
1177
|
+
if (guarded.decision.reason === "shared-cwd-untracked-harness") {
|
|
1178
|
+
incMetric(
|
|
1179
|
+
input.metricRegistry,
|
|
1180
|
+
"crew.verification.verifier_gate_shared_cwd_guard_total",
|
|
1181
|
+
"S-1: PASS-skip refusals — write-role execution in the gate's shared cwd (gitignored harness outside pinned bindings)",
|
|
1182
|
+
{ result: "spawn_forced" },
|
|
1183
|
+
);
|
|
1184
|
+
} else {
|
|
1185
|
+
incMetric(
|
|
1186
|
+
input.metricRegistry,
|
|
1187
|
+
"crew.verification.verifier_gate_worktree_guard_total",
|
|
1188
|
+
"W2: PASS-skip refusals — dependency worktree work the gate's evaluated tree does not contain",
|
|
1189
|
+
{ result: "spawn_forced" },
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
// ── W10-2: record the green verdict for same-tree memoization (AFTER the
|
|
1194
|
+
// post-bookend — a drift-downgraded INCONCLUSIVE must never memoize; AFTER
|
|
1195
|
+
// the guards — M-ruling 2026-10-31: a PASS refused by the shared-cwd
|
|
1196
|
+
// harness guard was produced under a harness the gate cannot certify, so
|
|
1197
|
+
// it must never memoize and replay as a skip in a later run whose
|
|
1198
|
+
// taskRecords happen to be absent/empty. The W2 refusal does NOT suppress
|
|
1199
|
+
// the record — its memo-hit path re-checks the probes on every replay,
|
|
1200
|
+
// and the certified tree itself stays sound.)
|
|
1201
|
+
if (
|
|
1202
|
+
outcome.verdict === "PASS" &&
|
|
1203
|
+
!(guarded.forcedSpawn && guarded.decision.reason === "shared-cwd-untracked-harness") &&
|
|
1204
|
+
recordVerifyGateMemo(input.cwd, checks, outcome)
|
|
1205
|
+
) {
|
|
1018
1206
|
incMetric(
|
|
1019
1207
|
input.metricRegistry,
|
|
1020
|
-
"crew.verification.
|
|
1021
|
-
"
|
|
1022
|
-
{
|
|
1208
|
+
"crew.verification.verify_gate_memo_records_total",
|
|
1209
|
+
"W10-2 PASS verdicts recorded for same-tree memoization",
|
|
1210
|
+
{},
|
|
1023
1211
|
);
|
|
1024
1212
|
}
|
|
1025
1213
|
outcome = guarded.outcome;
|