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.
@@ -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 { command: "pi", args };
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
- /^(?:echo|false|printenv|printf|true)(?:\s|$)/,
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
- function validateAllowedGateCommands(normalized: string, original: string): void {
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
- validateGateCommand(command);
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
- const guarded = resolveWorktreeGuardDecision(input.dependencyWorktreeProbes, memoHit);
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
- incMetric(
892
- input.metricRegistry,
893
- "crew.verification.verifier_gate_worktree_guard_total",
894
- "W2: PASS-skip refusals — dependency worktree work the gate's evaluated tree does not contain",
895
- { result: "spawn_forced" },
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
- const guarded = resolveWorktreeGuardDecision(input.dependencyWorktreeProbes, outcome);
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.verifier_gate_worktree_guard_total",
1021
- "W2: PASS-skip refusals — dependency worktree work the gate's evaluated tree does not contain",
1022
- { result: "spawn_forced" },
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;