opencode-workflow-guard 1.19.0 → 1.19.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/test/test.mts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync, existsSync, mkdirSync, lstatSync, chmodSync, statSync, utimesSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import { tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
3
+ import { tmpdir, homedir } from "node:os";
4
+ import { join, resolve, dirname } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { DatabaseSync } from "node:sqlite";
7
7
  import type { PluginModule } from "@opencode-ai/plugin";
8
8
  import { getRalphMaxIterations, runWithRuntimeState } from "../src/lib/state.ts";
9
9
  import { isGeneratedContinuationMessage } from "../src/policies/continuation.ts";
10
+ import { clearContinuationState, continueUnfinishedSession, recordUserMessage } from "../src/policies/continuation.ts";
11
+ import { ToolInvocationLifecycle } from "../src/lib/tool-lifecycle.ts";
10
12
  import {
11
13
  guardToolCall,
12
14
  guardToolDecision,
@@ -89,6 +91,11 @@ import {
89
91
  isBranchAlreadyMergedOrClosed,
90
92
  checkMergeConflicts,
91
93
  checkBranchBaseIsUpToDate,
94
+ findWorktreeForBranch,
95
+ parseGitInvocation,
96
+ prSourceBranch,
97
+ rebindSessionWorkspaceFromHost,
98
+ forgetSessionWorkspace,
92
99
  checkLockfileSync,
93
100
  branchHasDocumentationChange,
94
101
  isDocumentationRequired,
@@ -2209,6 +2216,27 @@ check("GIT_DIR prefix to external repository is blocked for writes", blocked(awa
2209
2216
  check("env GIT_DIR prefix to external repository is blocked for writes", blocked(await shell(`env GIT_DIR=${externalRepo}/.git git commit -m test`)));
2210
2217
  check("GIT_DIR prefix push to external repository is blocked", blocked(await shell(`GIT_DIR=${externalRepo}/.git git push origin feat/external`)));
2211
2218
 
2219
+ // An indeterminate repository is outside the boundary, even when the session
2220
+ // itself sits in a git workspace. `findGitRoot` walks up to the nearest
2221
+ // existing ancestor, so `$OTHER_REPO` resolved to the workspace's own root and
2222
+ // passed the same-repo test that admits linked worktrees. The feature branch
2223
+ // keeps the protected-branch gate quiet so this pins the boundary decision.
2224
+ const varRepo = mkdtempSync(join(tmpdir(), "wg-var-repo-"));
2225
+ spawnSync("git", ["init", "-b", "feat/var"], { cwd: varRepo });
2226
+ setWorkspaceRoot(varRepo);
2227
+ const variableGit = await shell("git -C $OTHER_REPO commit -m x");
2228
+ check(
2229
+ "git -C with an unresolvable variable stays blocked inside a git workspace",
2230
+ blocked(variableGit) && String(variableGit).includes("unresolvable variable reference"),
2231
+ );
2232
+ const variableGitDir = await shell("GIT_DIR=$OTHER_REPO/.git git push origin feat/x");
2233
+ check(
2234
+ "GIT_DIR with an unresolvable variable stays blocked inside a git workspace",
2235
+ blocked(variableGitDir) && String(variableGitDir).includes("unresolvable variable reference"),
2236
+ );
2237
+ setWorkspaceRoot(root);
2238
+ rmSync(varRepo, { recursive: true, force: true });
2239
+
2212
2240
  rmSync(externalRepo, { recursive: true, force: true });
2213
2241
 
2214
2242
  // 5. Verification isolation and privilege checks
@@ -2408,6 +2436,21 @@ check(
2408
2436
  "verification timeout names a remedy the agent can actually apply",
2409
2437
  String(timeoutFinalRes).includes("verifyTimeoutMs") && String(timeoutFinalRes).includes("WORKFLOW_GUARD_VERIFY_TIMEOUT_MS"),
2410
2438
  );
2439
+ check(
2440
+ "verification timeout names the user as the actor for a limit the agent cannot raise",
2441
+ String(timeoutFinalRes).includes("Only the user can raise the limit") && String(timeoutFinalRes).includes(".jsonc"),
2442
+ );
2443
+ // Machine consumers (audit, evidence, the V2 seat) branch on the code, not the
2444
+ // prose, so pin the timeout's own code rather than only its message.
2445
+ const timeoutDecision = await guardToolDecision(
2446
+ "todowrite",
2447
+ { todos: [item("timeout work", "completed")] },
2448
+ { sessionID: "s-verify-timeout" },
2449
+ );
2450
+ check(
2451
+ "a verification timeout carries its own structured code",
2452
+ timeoutDecision.status === "blocked" && timeoutDecision.code === "verification_timed_out",
2453
+ );
2411
2454
  setWorkspaceRoot(verifyRepo);
2412
2455
  reloadProjectConfig(verifyRepo);
2413
2456
  if (prevTimeoutVerifyEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY = prevTimeoutVerifyEnv;
@@ -4349,6 +4392,529 @@ check(
4349
4392
  );
4350
4393
  check("variable start point behind a redirect still fails closed", blocked(await shell("git switch -c wg-var-redir $WG_REF 2>/dev/null")));
4351
4394
 
4395
+ // Worktree-aware git verdicts: a git command is judged against the checkout it
4396
+ // runs in, not against the session-bound one. The regression these pin: a
4397
+ // linked worktree on a feature branch was resolved to the primary checkout on
4398
+ // `main`, so its commits were blocked as protected-branch writes, and PR
4399
+ // preflight judged the wrong tree.
4400
+ console.log("- Worktree-aware git verdicts -");
4401
+ check(
4402
+ "parseGitInvocation resolves a bare git command against the supplied workdir",
4403
+ parseGitInvocation("git status", "/tmp/some-worktree")?.repoDir === "/tmp/some-worktree",
4404
+ );
4405
+ check(
4406
+ "parseGitInvocation marks an assumed base as not explicit",
4407
+ parseGitInvocation("git status", "/tmp/some-worktree")?.explicitRepoDir === false,
4408
+ );
4409
+ check(
4410
+ "parseGitInvocation treats -C as an explicit repository",
4411
+ parseGitInvocation("git -C /tmp/named status")?.repoDir === "/tmp/named" &&
4412
+ parseGitInvocation("git -C /tmp/named status")?.explicitRepoDir === true,
4413
+ );
4414
+ check("prSourceBranch reads gh --head", prSourceBranch("gh pr create --head feat/x --body b") === "feat/x");
4415
+ check("prSourceBranch reads the gh -H short form", prSourceBranch("gh pr create -H feat/x --body b") === "feat/x");
4416
+ check("prSourceBranch reads --head=value", prSourceBranch("gh pr create --head=feat/x --body b") === "feat/x");
4417
+ check("prSourceBranch reads the attached -Hvalue form", prSourceBranch("gh pr create -Hfeat/x --body b") === "feat/x");
4418
+ check("prSourceBranch reads az --source-branch", prSourceBranch("az repos pr create --source-branch feat/x --description b") === "feat/x");
4419
+ check("prSourceBranch strips a fork owner prefix", prSourceBranch("gh pr create --head someone:feature/y --body b") === "feature/y");
4420
+ check("prSourceBranch returns undefined when no branch is named", prSourceBranch("gh pr create --body b") === undefined);
4421
+ // A `--head`-looking token inside a quoted body is command data, not a branch.
4422
+ // The dispatcher used to unwrap the segment first, and the joined text lost the
4423
+ // quotes, so body prose was re-read as flags.
4424
+ check(
4425
+ "a --head token inside a quoted PR body is not read as the source branch",
4426
+ prSourceBranch(`gh pr create --title t --body 'Changelog: ok. Reviewer note: --head innocent'`) === undefined,
4427
+ );
4428
+ check(
4429
+ "a real --head still wins over a --head token inside the body",
4430
+ prSourceBranch(`gh pr create --head feat/x --body 'Changelog: ok, see --head nope'`) === "feat/x",
4431
+ );
4432
+ // A repository claim must be an actual assignment. A commit message that
4433
+ // happens to contain `GIT_DIR=` previously read as one, which was harmless
4434
+ // while the value only picked a base directory but is not harmless now that the
4435
+ // permission seat trusts `explicitRepoDir` to decide whether it may judge.
4436
+ check(
4437
+ "an unquoted GIT_DIR= inside a commit message is not a repository claim",
4438
+ parseGitInvocation("git commit -m GIT_DIR=/repo")?.explicitRepoDir === false,
4439
+ );
4440
+ check(
4441
+ "a quoted GIT_DIR= inside a commit message is not a repository claim",
4442
+ parseGitInvocation("git commit -m 'GIT_DIR=/repo'")?.explicitRepoDir === false,
4443
+ );
4444
+ check(
4445
+ "a real GIT_DIR= prefix is a repository claim",
4446
+ parseGitInvocation("GIT_DIR=/repo git commit")?.repoDir === "/repo" &&
4447
+ parseGitInvocation("GIT_DIR=/repo git commit")?.explicitRepoDir === true,
4448
+ );
4449
+ // `git -C ~/repo` reaches that repository through the shell's own expansion. A
4450
+ // literal `~` resolved against the workspace base put the write inside the
4451
+ // workspace, so a commit to a home-directory repository read as a local one.
4452
+ check(
4453
+ "a tilde repository selector expands to the home directory",
4454
+ parseGitInvocation("git -C ~/wg-probe-repo commit")?.repoDir === join(process.env.HOME || homedir(), "wg-probe-repo"),
4455
+ );
4456
+ check(
4457
+ "a tilde git-dir selector expands to the home directory",
4458
+ parseGitInvocation("git --git-dir=~/.git commit")?.repoDir === join(process.env.HOME || homedir(), ".git"),
4459
+ );
4460
+ check(
4461
+ "an unresolvable repository selector keeps its literal text instead of collapsing to the root",
4462
+ parseGitInvocation("git -C $OTHER_REPO/wg commit", "/tmp/wg-base")?.repoDir === join("/tmp/wg-base", "$OTHER_REPO", "wg"),
4463
+ );
4464
+ // `~user` names that user's home. Reading only the home base dropped the path
4465
+ // after the name, so `git -C ~csh/repo commit` was judged against the home
4466
+ // directory instead of the repository inside it.
4467
+ check(
4468
+ "a named-user repository selector keeps the path after the home",
4469
+ parseGitInvocation("git -C ~root/repo commit")?.repoDir === join(dirname(process.env.HOME || homedir()), "root", "repo"),
4470
+ );
4471
+ // Git also accepts the attached short form. Leaving `-C~/repo` unparsed kept
4472
+ // the assumed base, so the commit was judged in the session's own checkout.
4473
+ check(
4474
+ "an attached short -C selector names its repository",
4475
+ parseGitInvocation("git -C~/wg-probe-repo commit")?.repoDir === join(process.env.HOME || homedir(), "wg-probe-repo") &&
4476
+ parseGitInvocation("git -C~/wg-probe-repo commit")?.explicitRepoDir === true,
4477
+ );
4478
+ // A dangling `-C` names no repository, but the flag is still in the text. Both
4479
+ // seats must then agree the command is unclassifiable: the seat defers, and
4480
+ // execute.before decides. Reporting it as an assumed base let the seat judge a
4481
+ // command whose repository it could not know.
4482
+ check(
4483
+ "a dangling -C is reported as an explicit claim, not silent text truncation",
4484
+ parseGitInvocation("git -C")?.explicitRepoDir === true && parseGitInvocation("git -C")?.rest === "",
4485
+ );
4486
+ check(
4487
+ "a dangling --git-dir is reported as an explicit claim",
4488
+ parseGitInvocation("git --git-dir")?.explicitRepoDir === true,
4489
+ );
4490
+ check(
4491
+ "a dangling option that names no repository is not a claim",
4492
+ parseGitInvocation("git -c")?.explicitRepoDir === false,
4493
+ );
4494
+ check(
4495
+ "a wrapped GIT_DIR= prefix is still a repository claim",
4496
+ parseGitInvocation("timeout 30 env GIT_DIR=/repo git commit")?.repoDir === "/repo" &&
4497
+ parseGitInvocation("sudo -u x env GIT_DIR=/repo git commit")?.repoDir === "/repo",
4498
+ );
4499
+ check(
4500
+ "a bare git command wrapped in env/nohup/timeout is still parsed",
4501
+ parseGitInvocation("env git push origin feature/x")?.rest === "push origin feature/x" &&
4502
+ parseGitInvocation("timeout 60 git push origin feature/x")?.rest === "push origin feature/x" &&
4503
+ parseGitInvocation("nohup git -C /repo commit")?.repoDir === "/repo",
4504
+ );
4505
+
4506
+ const wtRepo = mkdtempSync(join(tmpdir(), "wg-workdir-repo-"));
4507
+ spawnSync("git", ["init", "-b", "main"], { cwd: wtRepo });
4508
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: wtRepo });
4509
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: wtRepo });
4510
+ writeFileSync(join(wtRepo, "shared.txt"), "base\n");
4511
+ spawnSync("git", ["add", "-A"], { cwd: wtRepo });
4512
+ spawnSync("git", ["commit", "-qm", "init"], { cwd: wtRepo });
4513
+ spawnSync("git", ["switch", "-c", "feat/wt-branch"], { cwd: wtRepo });
4514
+ writeFileSync(join(wtRepo, "shared.txt"), "branch content\n");
4515
+ spawnSync("git", ["commit", "-qam", "branch edit"], { cwd: wtRepo });
4516
+ spawnSync("git", ["switch", "main"], { cwd: wtRepo });
4517
+ writeFileSync(join(wtRepo, "shared.txt"), "main content\n");
4518
+ spawnSync("git", ["commit", "-qam", "main edit"], { cwd: wtRepo });
4519
+ const wtLinked = mkdtempSync(join(tmpdir(), "wg-workdir-linked-"));
4520
+ rmSync(wtLinked, { recursive: true, force: true });
4521
+ spawnSync("git", ["worktree", "add", wtLinked, "feat/wt-branch"], { cwd: wtRepo });
4522
+ check("findWorktreeForBranch resolves the checkout holding a branch", findWorktreeForBranch(wtRepo, "feat/wt-branch") === wtLinked);
4523
+ check("findWorktreeForBranch accepts a full ref name", findWorktreeForBranch(wtRepo, "refs/heads/feat/wt-branch") === wtLinked);
4524
+ check("findWorktreeForBranch returns undefined for an absent branch", findWorktreeForBranch(wtRepo, "feat/absent") === undefined);
4525
+ check(
4526
+ "checkMergeConflicts judges an explicitly named branch that is not checked out",
4527
+ checkMergeConflicts(wtRepo, "feat/wt-branch").hasConflicts &&
4528
+ (checkMergeConflicts(wtRepo, "feat/wt-branch").reason ?? "").includes("'feat/wt-branch'"),
4529
+ );
4530
+ // The protected-branch gate follows the workdir: the primary checkout is on
4531
+ // `main`, yet a commit run in the linked feature worktree is legitimate.
4532
+ const wtCommit = await call("bash", { command: "git commit -m x --allow-empty", workdir: wtLinked }, { sessionID: "s-wt-commit", worktree: wtRepo, directory: wtRepo });
4533
+ check("a commit in a linked feature worktree is not blocked as a protected-branch write", !blocked(wtCommit));
4534
+ const primaryCommit = await call("bash", { command: "git commit -m x --allow-empty", workdir: wtRepo }, { sessionID: "s-wt-commit-primary", worktree: wtRepo, directory: wtRepo });
4535
+ check("a commit in the primary checkout on main stays blocked", blocked(primaryCommit));
4536
+ // PR preflight follows --head to the branch's own worktree, so its merge
4537
+ // conflict is found even though the shell sits in the primary checkout.
4538
+ const prevWtReview = process.env.WORKFLOW_GUARD_REQUIRE_REVIEW;
4539
+ process.env.WORKFLOW_GUARD_REQUIRE_REVIEW = "0";
4540
+ try {
4541
+ const headPr = await call(
4542
+ "bash",
4543
+ { command: "gh pr create --head feat/wt-branch --title t --body 'Changelog: fixed'", workdir: wtRepo },
4544
+ { sessionID: "s-wt-pr-head", worktree: wtRepo, directory: wtRepo },
4545
+ );
4546
+ check(
4547
+ "PR preflight resolves --head to the linked worktree and names its conflict",
4548
+ blocked(headPr) && String(headPr).includes("'feat/wt-branch'") && String(headPr).includes("merge conflicts"),
4549
+ );
4550
+ const barePr = await call(
4551
+ "bash",
4552
+ { command: "gh pr create --title t --body 'Changelog: fixed'", workdir: wtRepo },
4553
+ { sessionID: "s-wt-pr-bare", worktree: wtRepo, directory: wtRepo },
4554
+ );
4555
+ check("a bare PR command still judges the shell's checkout", !blocked(barePr));
4556
+ if (blocked(barePr)) console.log(" blocked:", barePr);
4557
+ } finally {
4558
+ if (prevWtReview === undefined) delete process.env.WORKFLOW_GUARD_REQUIRE_REVIEW;
4559
+ else process.env.WORKFLOW_GUARD_REQUIRE_REVIEW = prevWtReview;
4560
+ }
4561
+ // A workdir outside the workspace is only legible when it is a linked
4562
+ // worktree of the SAME repository. A genuinely foreign repository cannot be
4563
+ // judged: the command runs there, so `rm -rf dist` or `> artifact.txt` would be
4564
+ // validated against the workspace root while changing another tree.
4565
+ const foreignWtRepo = mkdtempSync(join(tmpdir(), "wg-foreign-workdir-"));
4566
+ spawnSync("git", ["init", "-b", "main"], { cwd: foreignWtRepo });
4567
+ spawnSync("git", ["-c", "user.name=T", "-c", "user.email=t@e", "commit", "--allow-empty", "-m", "init"], { cwd: foreignWtRepo });
4568
+ todo("s-wt-foreign", item("probe foreign workdir", "in_progress"));
4569
+ const foreignWrite = await call("bash", { command: "echo x > artifact.txt", workdir: foreignWtRepo }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4570
+ check(
4571
+ "a relative shell mutation in a foreign workdir is blocked instead of judged against the workspace",
4572
+ blocked(foreignWrite) && String(foreignWrite).includes(foreignWtRepo),
4573
+ );
4574
+ const foreignGitWrite = await call("bash", { command: "git commit -m x --allow-empty", workdir: foreignWtRepo }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4575
+ check("a git write in a foreign workdir is blocked", blocked(foreignGitWrite));
4576
+ const foreignRead = await call("bash", { command: "ls -la", workdir: foreignWtRepo }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4577
+ check("a read-only command in a foreign workdir stays allowed", !blocked(foreignRead));
4578
+ const linkedWrite = await call("bash", { command: "echo x > .tmp-linked.txt", workdir: wtLinked }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4579
+ check("a relative shell mutation in a linked worktree of the same repository is allowed", !blocked(linkedWrite));
4580
+ const linkedAbsoluteWrite = await call("bash", { command: `echo x > ${join(wtRepo, "abs.txt")}`, workdir: wtLinked }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4581
+ check("an absolute target in the primary checkout stays allowed from a linked worktree", !blocked(linkedAbsoluteWrite));
4582
+ const linkedOutsideWrite = await call("bash", { command: `echo x > ${join(foreignWtRepo, "outside.txt")}`, workdir: wtLinked }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4583
+ check("an absolute target outside both checkouts is still blocked from a linked worktree", blocked(linkedOutsideWrite));
4584
+
4585
+ // An in-command `cd` moves the tree later segments run in, so the walk has to
4586
+ // follow it. Judging every segment against the shell's initial `workdir` let
4587
+ // `cd <foreign> && git commit` be checked as a commit in the session's own
4588
+ // checkout, which both allowed a foreign write and denied nothing.
4589
+ const cdForeignCommit = await call("bash", { command: `cd ${foreignWtRepo} && git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4590
+ check(
4591
+ "an in-command cd into a foreign repository is blocked for a git write",
4592
+ blocked(cdForeignCommit) && String(cdForeignCommit).includes(foreignWtRepo),
4593
+ );
4594
+ const cdForeignWrite = await call("bash", { command: `cd ${foreignWtRepo} && echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4595
+ check("an in-command cd into a foreign repository is blocked for a shell mutation", blocked(cdForeignWrite));
4596
+ const cdForeignRead = await call("bash", { command: `cd ${foreignWtRepo} && ls -la` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4597
+ check("an in-command cd into a foreign repository stays allowed for a read", !blocked(cdForeignRead));
4598
+ // A newline separates commands exactly like `;`. The dispatcher planned its
4599
+ // segments from the newline-flattened command, so a newline-separated `cd`
4600
+ // moved nothing and every later segment was judged against the shell's start
4601
+ // tree. `cd <foreign>` then `rm -rf x` therefore read as a workspace removal.
4602
+ for (const [label, command] of [
4603
+ ["a removal", `cd ${foreignWtRepo}\nrm -rf out.txt`],
4604
+ ["a git write", `cd ${foreignWtRepo}\ngit commit -m x --allow-empty`],
4605
+ ["a redirect", `cd ${foreignWtRepo}\necho x > artifact.txt`],
4606
+ ] as const) {
4607
+ const newlineCd = await call("bash", { command }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4608
+ check(`a newline-separated cd into a foreign repository is blocked for ${label}`, blocked(newlineCd) && String(newlineCd).includes(foreignWtRepo));
4609
+ }
4610
+ const newlineCdRead = await call("bash", { command: `cd ${foreignWtRepo}\nls -la` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4611
+ check("a newline-separated cd into a foreign repository stays allowed for a read", !blocked(newlineCdRead));
4612
+ // A heredoc feeding a shell runs its body as shell text, so the body's `cd`
4613
+ // moves the tree its later lines run in just like a `;`-separated command. The
4614
+ // body was never re-planned, so the removal looked like a workspace write.
4615
+ const heredocCd = await call("bash", { command: `bash <<'WGEOF'\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4616
+ check("a heredoc shell body that cds into a foreign repository is blocked", blocked(heredocCd) && String(heredocCd).includes(foreignWtRepo));
4617
+ const heredocGit = await call("bash", { command: `bash <<'WGEOF'\ncd ${foreignWtRepo}\ngit commit -m x --allow-empty\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4618
+ check("a heredoc shell body that cds into a foreign repository is blocked for a git write", blocked(heredocGit) && String(heredocGit).includes(foreignWtRepo));
4619
+ // Bash still runs a heredoc body it closes at end of input, so an unterminated
4620
+ // body is code too. Requiring a terminator line dropped the payload entirely.
4621
+ const heredocUnterminated = await call("bash", { command: `bash <<'WGEOF'\ncd ${foreignWtRepo}\nrm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4622
+ check("an unterminated heredoc shell body is scanned as code", blocked(heredocUnterminated) && String(heredocUnterminated).includes(foreignWtRepo));
4623
+ // `source` executes a body it reads from stdin, so its body is shell text even
4624
+ // though the launcher list names only interpreters.
4625
+ const heredocSource = await call("bash", { command: `source /dev/stdin <<'WGEOF'\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4626
+ check("a source heredoc body that cds into a foreign repository is blocked", blocked(heredocSource) && String(heredocSource).includes(foreignWtRepo));
4627
+ // The body feeds `cat`, but the pipe delivers it to a shell, so it runs.
4628
+ const heredocPiped = await call("bash", { command: `cat <<'WGEOF' | sh\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4629
+ check("a heredoc body piped into a shell is scanned as code", blocked(heredocPiped) && String(heredocPiped).includes(foreignWtRepo));
4630
+ // The pipe decides, and an unrecognized downstream command is not a filter. An
4631
+ // allowlist that named only common shells let `| ash` and `| make -f -` run a
4632
+ // body the scan had already masked as prose.
4633
+ for (const downstream of ["ash", "make -f -", "busybox sh", "xargs sh -c"]) {
4634
+ const pipeUnknown = await call("bash", { command: `cat <<'WGEOF' | ${downstream}\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4635
+ check(`a heredoc body piped into ${downstream} is scanned as code`, blocked(pipeUnknown) && String(pipeUnknown).includes(foreignWtRepo));
4636
+ }
4637
+ const pipeFilter = await call("bash", { command: `cat <<'WGEOF' | grep build\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4638
+ check("a heredoc body piped into a known filter stays data", allowed(pipeFilter));
4639
+ // A pipeline hands the body to every stage, so a filter followed by a shell
4640
+ // still executes it. Reading only the first stage masked the body as prose.
4641
+ for (const pipeline of ["grep x | sh", "cat | bash", "tee /dev/null | sh"]) {
4642
+ const pipeChain = await call("bash", { command: `cat <<'WGEOF' | ${pipeline}\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4643
+ check(`a heredoc body reaching a shell through ${pipeline} is scanned as code`, blocked(pipeChain) && String(pipeChain).includes(foreignWtRepo));
4644
+ }
4645
+ // `|&` also pipes, so a body it delivers to a shell runs exactly like a `|`
4646
+ // body. `||` runs the right side only on failure and delivers no body.
4647
+ for (const operator of ["|& sh", "|& ash"]) {
4648
+ const ampersandPipe = await call("bash", { command: `cat <<'WGEOF' ${operator}\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4649
+ check(`a heredoc body delivered through ${operator} is scanned as code`, blocked(ampersandPipe) && String(ampersandPipe).includes(foreignWtRepo));
4650
+ }
4651
+ // `|&` into a filter is still a filter, so the body stays prose.
4652
+ const ampersandFilter = await call("bash", { command: `cat <<'WGEOF' |& grep build\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4653
+ check("a heredoc body delivered through |& to a filter stays data", allowed(ampersandFilter));
4654
+ const logicalOrPipe = await call("bash", { command: `cat <<'WGEOF' || grep build\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4655
+ check("a heredoc body after || stays data because no pipe delivers it", allowed(logicalOrPipe));
4656
+ // A program that can run a command named in its own arguments is not a filter.
4657
+ // `sed`, `awk`, and `sort` each execute the body's text with a flag or program.
4658
+ for (const header of ["sed 'e'", "sed -n '1e echo hi'", `awk '{system($0)}'`, "sort --compress-program='sh'"]) {
4659
+ const execConsumer = await call("bash", { command: `${header} <<'WGEOF'\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4660
+ check(`a heredoc consumed by ${header} is scanned as code`, blocked(execConsumer) && String(execConsumer).includes(foreignWtRepo));
4661
+ }
4662
+ // A heredoc feeding `cat` is data. Its body is never executed, so documentation
4663
+ // that quotes a blocked command shape must stay writable. The shell-backed form
4664
+ // above is code; this one is prose.
4665
+ const heredocDoc = await call("bash", { command: `cat > notes.md <<'WGEOF'\nbash -c 'cd /other-repo && rm -rf x' is blocked.\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4666
+ check("a data heredoc quoting a blocked command shape is not treated as code", allowed(heredocDoc));
4667
+ const heredocDocProse = await call("bash", { command: `cat > notes.md <<'WGEOF'\nrm -rf build is blocked by policy.\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4668
+ check("a data heredoc body is not read as a shell mutation", allowed(heredocDocProse));
4669
+ // The mask has to find the heredoc in the raw text. A quoted `<<` opened a fake
4670
+ // body and swallowed the real payload that followed it.
4671
+ const heredocMasked = await call("bash", { command: `echo "<<DONE"\npython3 -c "open('${join(foreignWtRepo, "evil")}','w').write('x')"\nDONE` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4672
+ check("a quoted << does not mask a later interpreter payload", blocked(heredocMasked) && String(heredocMasked).includes(foreignWtRepo));
4673
+ // One header line can introduce two bodies. Only the first was ranged, so the
4674
+ // second body was read as top-level commands.
4675
+ const heredocPair = await call("bash", { command: `cat <<A <<B\nhello\nA\nrm -rf build is prose\nB` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4676
+ check("a second heredoc body on the same header line stays data", !blocked(heredocPair));
4677
+ const cdIndeterminate = await call("bash", { command: "cd $WG_UNKNOWN_TREE && git commit -m x" }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4678
+ check(
4679
+ "an indeterminate cd target followed by a write fails closed",
4680
+ blocked(cdIndeterminate) && String(cdIndeterminate).includes("cannot be determined"),
4681
+ );
4682
+ const cdWorkspaceSubdir = await call("bash", { command: "mkdir -p nested && cd nested && echo x > artifact.txt" }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4683
+ check(
4684
+ "an in-command cd into a workspace subdirectory is judged where it lands, not as a foreign tree",
4685
+ String(cdWorkspaceSubdir).includes("protected branch") && !String(cdWorkspaceSubdir).includes("outside workspace root"),
4686
+ );
4687
+ const cdLinkedSubdir = await call("bash", { command: "mkdir -p nested && cd nested && echo x > artifact.txt" }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4688
+ check("an in-command cd into a linked-worktree subdirectory is allowed", !blocked(cdLinkedSubdir));
4689
+ const cdLinkedCommit = await call("bash", { command: `cd ${wtLinked} && git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4690
+ check("an in-command cd into a linked worktree is not blocked as a foreign tree", !blocked(cdLinkedCommit));
4691
+ const wrapperCwdCommit = await call("bash", { command: `env -C ${foreignWtRepo} git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4692
+ check("a wrapper -C into a foreign repository is blocked for a git write", blocked(wrapperCwdCommit));
4693
+ // The nested payload is one shell word to the outer shell, so its own `cd` and
4694
+ // its own `cwd` option were invisible to the top-level walk.
4695
+ const nestedCd = await call("bash", { command: `bash -c 'cd ${foreignWtRepo} && git commit -m x --allow-empty'` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4696
+ check("a nested shell payload that cds into a foreign repository is blocked", blocked(nestedCd) && String(nestedCd).includes(foreignWtRepo));
4697
+ const nestedExecCwd = await call("bash", { command: `node -e "require('child_process').execSync('git commit -m x --allow-empty', {cwd:'${foreignWtRepo}'})"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4698
+ check("a nested interpreter command with a foreign cwd is blocked", blocked(nestedExecCwd) && String(nestedExecCwd).includes(foreignWtRepo));
4699
+ const nestedLocal = await call("bash", { command: `node -e "require('child_process').execSync('git status', {cwd:'${wtRepo}'})"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4700
+ check("a nested interpreter command in the workspace stays allowed", !blocked(nestedLocal));
4701
+ const nestedLinked = await call("bash", { command: `bash -c 'cd ${wtLinked} && git commit -m x --allow-empty'` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4702
+ check("a nested payload cd into a linked worktree is not blocked as foreign", !blocked(nestedLinked));
4703
+
4704
+ // A `cd` inside a grouping subshell is scoped to it. Treating it as moving the
4705
+ // outer shell made a later workspace mutation read as happening in the foreign
4706
+ // tree the subshell visited.
4707
+ const subshellCdThenWorkspaceWrite = await call("bash", { command: `( cd ${foreignWtRepo} && ls ) && echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4708
+ check(
4709
+ "a cd inside a grouping subshell does not move the tree of later segments",
4710
+ !String(subshellCdThenWorkspaceWrite).includes(foreignWtRepo) && !String(subshellCdThenWorkspaceWrite).includes("outside workspace root"),
4711
+ );
4712
+ const subshellCdThenWorkspaceCommit = await call("bash", { command: `( cd ${foreignWtRepo} && ls ) && git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4713
+ check("a subshell cd does not foreign-flag a later commit in the workspace", !String(subshellCdThenWorkspaceCommit).includes(foreignWtRepo));
4714
+ // The payload's declared root is only a working directory when a spawn-family
4715
+ // call or `process.chdir` names it. An unrelated `{cwd: ...}` key is not one.
4716
+ const payloadObjectKey = await call("bash", { command: `node -e "const o={cwd:'${foreignWtRepo}'}; require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4717
+ check("an unrelated object cwd key does not foreign-flag a payload write", !String(payloadObjectKey).includes(foreignWtRepo));
4718
+ const payloadUnrelatedChdir = await call("bash", { command: `node -e "foo.chdir('${foreignWtRepo}'); require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4719
+ check("chdir on an unrelated object does not foreign-flag a payload write", !String(payloadUnrelatedChdir).includes(foreignWtRepo));
4720
+ const payloadProcessChdir = await call("bash", { command: `node -e "process.chdir('${foreignWtRepo}'); require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4721
+ check("process.chdir into a foreign directory before a write is blocked", blocked(payloadProcessChdir) && String(payloadProcessChdir).includes(foreignWtRepo));
4722
+ const payloadUnresolvableCwd = await call("bash", { command: "node -e \"require('child_process').execSync('git commit -m x --allow-empty', {cwd: process.env.WG_OTHER});\"" }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4723
+ check(
4724
+ "a payload mutating with an unresolvable cwd fails closed",
4725
+ blocked(payloadUnresolvableCwd) && String(payloadUnresolvableCwd).includes("cannot be determined"),
4726
+ );
4727
+ // An indeterminate launcher tree must not be assumed to be the workspace root.
4728
+ // `cd $DIR && node -e "writeFileSync('x')"` used to read as a workspace write
4729
+ // because the payload walk substituted the workspace root for the unknown one.
4730
+ const payloadIndeterminateLauncher = await call("bash", { command: `cd $WG_UNKNOWN_WT && node -e "require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4731
+ check(
4732
+ "a payload mutating under an indeterminate launcher tree fails closed",
4733
+ blocked(payloadIndeterminateLauncher) && String(payloadIndeterminateLauncher).includes("cannot be determined"),
4734
+ );
4735
+ const payloadIndeterminateLauncherGit = await call("bash", { command: `cd $WG_UNKNOWN_WT && node -e "require('child_process').execSync('git commit -m x --allow-empty')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4736
+ check("a payload git write under an indeterminate launcher tree fails closed", blocked(payloadIndeterminateLauncherGit));
4737
+ const payloadIndeterminateLauncherRead = await call("bash", { command: `cd $WG_UNKNOWN_WT && node -e "console.log(1)"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4738
+ check("a read-only payload under an indeterminate launcher tree stays allowed", !blocked(payloadIndeterminateLauncherRead));
4739
+ // The spawn anchor must not read `regex.exec(str, {cwd})` as a spawned process.
4740
+ // Asserted in the linked worktree, where a write is allowed outright, so the
4741
+ // verdict cannot pass for the unrelated protected-branch reason.
4742
+ const payloadRegexExec = await call("bash", { command: `node -e "const r=/a/.exec('b',{cwd:'${foreignWtRepo}'}); require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4743
+ check(
4744
+ "a RegExp exec with a cwd key is not treated as a spawn working directory",
4745
+ !blocked(payloadRegexExec) && !String(payloadRegexExec).includes(foreignWtRepo),
4746
+ );
4747
+
4748
+ // A `pushd` inside a grouping subshell leaves its entry on the subshell's own
4749
+ // stack. bash discards that stack with the subshell, so a later outer `popd`
4750
+ // stops at "directory stack empty" and leaves the outer `cd` in force. A walk
4751
+ // that leaked the inner entry planned the outer removal back at the workspace.
4752
+ // The genuine leak shape puts a `cd` between the two, so the outer directory is
4753
+ // no longer the workspace and the removal lands in the foreign tree.
4754
+ const subshellPushdThenPopd = await call("bash", { command: `( pushd ${foreignWtRepo} >/dev/null ) ; cd ${foreignWtRepo} ; popd >/dev/null 2>&1 ; rm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4755
+ check(
4756
+ "a pushd inside a grouping subshell does not leak into a later outer popd",
4757
+ blocked(subshellPushdThenPopd) && String(subshellPushdThenPopd).includes(foreignWtRepo),
4758
+ );
4759
+ const subshellPushdThenPopdCommit = await call("bash", { command: `( pushd ${foreignWtRepo} >/dev/null ) ; cd ${foreignWtRepo} ; popd >/dev/null 2>&1 ; git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4760
+ check("a leaked subshell pushd does not let an outer git write read as the workspace", blocked(subshellPushdThenPopdCommit) && String(subshellPushdThenPopdCommit).includes(foreignWtRepo));
4761
+ // A `popd` that only rotates the stack is not a pop, so it fails closed.
4762
+ const popdRotate = await call("bash", { command: `pushd ${foreignWtRepo} >/dev/null 2>&1 ; popd +1 >/dev/null 2>&1 ; rm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4763
+ check("a popd stack rotation followed by a mutation fails closed", blocked(popdRotate) && String(popdRotate).includes("cannot be determined"));
4764
+
4765
+ // `pushd +N` and `pushd -N` rotate the stack and `-n` only pushes: neither
4766
+ // changes directory the way a plain `pushd <dir>` does, so the walk reports
4767
+ // them as unchecked and a mutation after one fails closed.
4768
+ const pushdRotate = await call("bash", { command: "pushd +1 >/dev/null 2>&1 ; rm -rf out.txt" }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4769
+ check("a pushd stack rotation followed by a mutation fails closed", blocked(pushdRotate) && String(pushdRotate).includes("cannot be determined"));
4770
+ const pushdNoChange = await call("bash", { command: `pushd -n ${foreignWtRepo} >/dev/null 2>&1 ; rm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4771
+ check("a pushd -n that only pushes the stack fails closed before a mutation", blocked(pushdNoChange));
4772
+ const pushdRotateRead = await call("bash", { command: "pushd +1 >/dev/null 2>&1 ; ls -la" }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4773
+ check("a pushd stack rotation followed by a read stays allowed", !blocked(pushdRotateRead));
4774
+
4775
+ // A control-flow body runs in the CURRENT shell, so a `cd` inside it moves the
4776
+ // directory of later segments. Which body runs depends on a condition this walk
4777
+ // cannot evaluate, so a construct whose bodies disagree about the directory
4778
+ // fails closed rather than letting the last body parsed win. Only `( … )`
4779
+ // scopes a directory.
4780
+ for (const [label, command] of [
4781
+ ["then", `if true; then cd ${foreignWtRepo}; fi; rm -rf out.txt`],
4782
+ ["else", `if true; then cd ${foreignWtRepo}; else cd .; fi; rm -rf out.txt`],
4783
+ ["do", `for i in 1; do cd ${foreignWtRepo}; done; rm -rf out.txt`],
4784
+ ["while", `while false; do :; done; cd ${foreignWtRepo}; rm -rf out.txt`],
4785
+ ["case", `case x in x) cd ${foreignWtRepo};; esac; rm -rf out.txt`],
4786
+ ["brace group", `{ cd ${foreignWtRepo} ; } ; rm -rf out.txt`],
4787
+ ] as const) {
4788
+ const flowMutation = await call("bash", { command }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4789
+ check(`a conditional cd inside a '${label}' body fails closed before a mutation`, blocked(flowMutation));
4790
+ }
4791
+ // A construct body that mutates without changing directory must stay allowed:
4792
+ // the condition does not affect the tree the mutation lands in.
4793
+ const conditionalNoCd = await call("bash", { command: `if [ -d .git ]; then echo hi; fi ; echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4794
+ check("a conditional body with no cd keeps the tree of later segments", !blocked(conditionalNoCd));
4795
+ const caseArmParen = await call("bash", { command: `( case x in x) cd ${foreignWtRepo} ;; esac ; mkdir -p mk )` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4796
+ check("a case pattern paren is not read as a grouping close", blocked(caseArmParen));
4797
+ // A `case` that follows a body opener (`then`, `else`, `do`, `elif`) still opens
4798
+ // an arm. Only a leading `case` was recognised, so the arm's `cd` went unseen and
4799
+ // the `esac` popped the enclosing frame instead.
4800
+ for (const [label, command] of [
4801
+ ["then", `if true; then case x in x) cd ${foreignWtRepo};; esac; fi; rm -rf out.txt`],
4802
+ ["else", `if true; then :; else case x in x) cd ${foreignWtRepo};; esac; fi; rm -rf out.txt`],
4803
+ ["elif", `if a; then :; elif b; then case x in x) cd ${foreignWtRepo};; esac; fi; rm -rf out.txt`],
4804
+ ["do", `for i in 1; do case x in x) cd ${foreignWtRepo};; esac; done; rm -rf out.txt`],
4805
+ ] as const) {
4806
+ const nestedCase = await call("bash", { command }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4807
+ check(`a case arm after '${label}' cannot hide its cd`, blocked(nestedCase));
4808
+ }
4809
+ // A case arm inside a grouping subshell is scoped like any other subshell cd, so
4810
+ // its pattern paren must not consume the subshell's own closing paren.
4811
+ const caseInSubshell = await call("bash", { command: `( case x in x) cd ${foreignWtRepo} ;; esac ) ; echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4812
+ check("a case arm inside a grouping subshell stays scoped to it", !blocked(caseInSubshell));
4813
+ // `eval` runs its body in the current shell, so a `cd` inside it moves the
4814
+ // directory of later segments. Both halves are covered: a payload that changes
4815
+ // directory and mutates, and a payload whose `cd` moves a later outer command.
4816
+ const evalCompound = await call("bash", { command: `eval "cd ${foreignWtRepo} && git commit -m x --allow-empty"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4817
+ check("an eval payload that changes directory and mutates is blocked", blocked(evalCompound));
4818
+ const evalMovesOuter = await call("bash", { command: `eval "cd ${foreignWtRepo}" ; rm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4819
+ check("an eval cd moves the tree of a later outer command", blocked(evalMovesOuter));
4820
+ const evalHarmless = await call("bash", { command: `eval "echo hi" ; echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4821
+ check("a harmless eval leaves the tree of later segments alone", !blocked(evalHarmless));
4822
+ // A leading assignment still reaches `eval`, so the `cd` inside it moves the
4823
+ // tree of the lines that follow.
4824
+ const evalAssignment = await call("bash", { command: `A=1 eval 'cd ${foreignWtRepo}'\nrm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4825
+ check("an assignment-prefixed eval moves the tree of later segments", blocked(evalAssignment) && String(evalAssignment).includes(foreignWtRepo));
4826
+ // `~user` never means a literal `~user` directory. Reading it as a relative
4827
+ // path put the walk inside the workspace while the shell reached that home.
4828
+ const tildeUser = await call("bash", { command: "cd ~root && rm -rf out.txt" }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
4829
+ check("a cd to a named user's home is not read as a workspace-relative path", blocked(tildeUser) && String(tildeUser).includes("outside workspace root"));
4830
+
4831
+ // A payload can launch another interpreter. Only the innermost quoted body was
4832
+ // extracted, so the outer launcher swallowed the inner one and its `cd` and
4833
+ // mutation were never re-planned.
4834
+ const doublyNestedPayload = await call("bash", { command: `bash -c "bash -c 'cd ${foreignWtRepo} && git commit -m x --allow-empty'"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4835
+ check("a doubly nested shell payload is re-planned until its mutation is judged", blocked(doublyNestedPayload) && String(doublyNestedPayload).includes(foreignWtRepo));
4836
+ // The `'\''` idiom is how a shell nests a single quote inside a single-quoted
4837
+ // span. Extraction stopped at the first closing quote, so the payload read as a
4838
+ // bare `bash -c ` and the nested mutation was never scanned.
4839
+ const quoteEscapePayload = await call("bash", { command: "bash -c 'bash -c '\\''cd " + foreignWtRepo + " && git commit -m x --allow-empty'\\'''" }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4840
+ check("a payload nested with the quote-escape idiom is scanned past the first quote", blocked(quoteEscapePayload) && String(quoteEscapePayload).includes(foreignWtRepo));
4841
+
4842
+ // `git -C <repo>` carries its repository in the flag, with no `cd` to follow.
4843
+ // The payload walk never applied the foreign-repository check to it.
4844
+ const payloadGitDashC = await call("bash", { command: `bash -c 'git -C ${foreignWtRepo} commit -m x --allow-empty'` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4845
+ check("a payload git -C into a foreign repository is blocked", blocked(payloadGitDashC) && String(payloadGitDashC).includes(foreignWtRepo));
4846
+ const payloadGitDashCLocal = await call("bash", { command: `bash -c 'git -C ${wtLinked} commit -m x --allow-empty'` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4847
+ check("a payload git -C into a linked worktree stays allowed", !blocked(payloadGitDashCLocal));
4848
+ // A spawn `cwd` after a call expression is still a spawn `cwd`. A flat `[^)]*`
4849
+ // up to the first closing paren matched nothing and left the tree unchecked.
4850
+ const spawnCwdAfterCall = await call("bash", { command: `node -e "require('child_process').execSync(String('git commit -m x --allow-empty'), {cwd:'${foreignWtRepo}'})"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
4851
+ check("a spawn cwd after a nested call is still read as the working directory", blocked(spawnCwdAfterCall) && String(spawnCwdAfterCall).includes(foreignWtRepo));
4852
+
4853
+ rmSync(join(wtLinked, "nested"), { recursive: true, force: true });
4854
+ rmSync(foreignWtRepo, { recursive: true, force: true });
4855
+ spawnSync("git", ["worktree", "remove", "--force", wtLinked], { cwd: wtRepo });
4856
+ rmSync(wtRepo, { recursive: true, force: true });
4857
+
4858
+ // Host session moves rebind the workspace: `session_move` relocates a session
4859
+ // between checkouts, and a stale binding made every later decision judge the
4860
+ // tree the session had left. A session bound to a LINKED WORKTREE by its own
4861
+ // tool-call workdir differs from the directory `session.updated` reports, so an
4862
+ // update alone must never move it back.
4863
+ const moveRepoA = mkdtempSync(join(tmpdir(), "wg-move-a-"));
4864
+ spawnSync("git", ["init", "-b", "main"], { cwd: moveRepoA });
4865
+ spawnSync("git", ["-c", "user.name=T", "-c", "user.email=t@e", "commit", "--allow-empty", "-m", "init"], { cwd: moveRepoA });
4866
+ const moveRepoB = mkdtempSync(join(tmpdir(), "wg-move-b-"));
4867
+ spawnSync("git", ["init", "-b", "main"], { cwd: moveRepoB });
4868
+ spawnSync("git", ["-c", "user.name=T", "-c", "user.email=t@e", "commit", "--allow-empty", "-m", "init"], { cwd: moveRepoB });
4869
+ setSessionWorkspace("s-host-move", moveRepoA);
4870
+ rebindSessionWorkspaceFromHost("s-host-move", moveRepoB, { moved: true });
4871
+ check("an announced host move rebinds the session workspace", getSessionBoundWorkspace("s-host-move") === resolve(moveRepoB));
4872
+ check("an announced host move is journaled as a granted widening", getRecentAuditEntries(20).some((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_session_move" && entry.sessionID === "s-host-move"));
4873
+ const beforeNoop = getRecentAuditEntries(200).filter((entry) => entry.reason === "workspace_rebind_session_move").length;
4874
+ rebindSessionWorkspaceFromHost("s-host-move", moveRepoB);
4875
+ const afterNoop = getRecentAuditEntries(200).filter((entry) => entry.reason === "workspace_rebind_session_move").length;
4876
+ check("a repeated move event to the same directory is a no-op", afterNoop === beforeNoop);
4877
+ // The regression: a session whose own tool-call workdir bound it to a LINKED
4878
+ // WORKTREE must survive a routine `session.updated` that names the primary
4879
+ // checkout of the same repository. Both resolve to the same repository, so the
4880
+ // update only records the baseline.
4881
+ const moveLinked = join(mkdtempSync(join(tmpdir(), "wg-move-linked-")), "wt");
4882
+ spawnSync("git", ["worktree", "add", "-b", "feat/host-move", moveLinked], { cwd: moveRepoA });
4883
+ setSessionWorkspace("s-host-linked", moveLinked);
4884
+ rebindSessionWorkspaceFromHost("s-host-linked", moveRepoA);
4885
+ check(
4886
+ "a routine update naming the primary checkout keeps a linked-worktree binding",
4887
+ getSessionBoundWorkspace("s-host-linked") === resolve(moveLinked),
4888
+ );
4889
+ // A directory that resolves to a DIFFERENT repository is a move even on the
4890
+ // first observation, which is how OpenCode 1.x sees one (it has no move event).
4891
+ rebindSessionWorkspaceFromHost("s-host-linked", moveRepoB);
4892
+ check("an update naming another repository rebinds", getSessionBoundWorkspace("s-host-linked") === resolve(moveRepoB));
4893
+ forgetSessionWorkspace("s-host-linked");
4894
+ spawnSync("git", ["worktree", "remove", "--force", moveLinked], { cwd: moveRepoA });
4895
+ // A later update that names a DIFFERENT directory than the last one is a move
4896
+ // even without the `session.moved` announcement.
4897
+ rebindSessionWorkspaceFromHost("s-host-move", moveRepoA);
4898
+ check("an update naming a new directory rebinds without an announcement", getSessionBoundWorkspace("s-host-move") === resolve(moveRepoA));
4899
+ rebindSessionWorkspaceFromHost(undefined, moveRepoA);
4900
+ rebindSessionWorkspaceFromHost("s-host-move", undefined);
4901
+ check("an incomplete move event never binds", getSessionBoundWorkspace("s-host-move") === resolve(moveRepoA));
4902
+ // An unresolved directory is not cached, so a later event for the same path
4903
+ // retries instead of being suppressed forever by a stale "already seen". A path
4904
+ // that resolves to another repository is then a move.
4905
+ const notYetRepo = mkdtempSync(join(tmpdir(), "wg-host-retry-"));
4906
+ setSessionWorkspace("s-host-retry", moveRepoA);
4907
+ rebindSessionWorkspaceFromHost("s-host-retry", notYetRepo);
4908
+ check("an unresolved host directory leaves the binding alone", getSessionBoundWorkspace("s-host-retry") === resolve(moveRepoA));
4909
+ spawnSync("git", ["init", "-b", "main"], { cwd: notYetRepo });
4910
+ rebindSessionWorkspaceFromHost("s-host-retry", notYetRepo);
4911
+ check("a retry for the now-resolvable directory rebinds when it is another repository", getSessionBoundWorkspace("s-host-retry") === resolve(notYetRepo));
4912
+ forgetSessionWorkspace("s-host-retry");
4913
+ check("deleting a session drops its binding", getSessionBoundWorkspace("s-host-retry") === undefined);
4914
+ rmSync(notYetRepo, { recursive: true, force: true });
4915
+ rmSync(moveRepoA, { recursive: true, force: true });
4916
+ rmSync(moveRepoB, { recursive: true, force: true });
4917
+
4352
4918
  // Lockfile synchronization tests
4353
4919
  const lockRepo = mkdtempSync(join(tmpdir(), "wg-lock-repo-"));
4354
4920
  spawnSync("git", ["init", "-b", "main"], { cwd: lockRepo });
@@ -6423,20 +6989,86 @@ try {
6423
6989
  { workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
6424
6990
  );
6425
6991
  check(
6426
- "seat abstains on a non-protected push whose remote PR state is unverified",
6427
- pushSeat.effect !== "deny" && pushSeat.effect === "abstain",
6992
+ "seat defers a cwd-dependent push instead of judging the session checkout",
6993
+ pushSeat.effect === "abstain" && pushSeat.decision?.code === "repository_unidentified",
6994
+ );
6995
+ // An explicit `-C` names the repository in the command text, so the seat is
6996
+ // back on facts and runs the core. That is the path that reports the
6997
+ // deliberately skipped remote PR probe.
6998
+ const explicitPushSeat = await evaluatePermissionSeat(
6999
+ { ...seatEvent([`git -C ${seatFeatureRepo} push origin feature/push-seat`]), sessionID: "s-seat-explicit" },
7000
+ { workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
6428
7001
  );
6429
7002
  check(
6430
- "seat keeps the undecidable decision for audit attribution",
6431
- pushSeat.decision?.status === "needs_approval" && pushSeat.decision?.code === "remote_state_unchecked",
7003
+ "seat still runs the core when the command names its repository",
7004
+ explicitPushSeat.effect === "abstain" && explicitPushSeat.decision?.code === "remote_state_unchecked",
6432
7005
  );
6433
- const protectedPushSeat = await evaluatePermissionSeat(
7006
+ // The bug this fixes: the session sat in the primary checkout while the
7007
+ // command targeted a linked worktree, and the seat denied on the primary
7008
+ // checkout's protected branch. A cwd-dependent push must never be judged
7009
+ // against a checkout the command does not name.
7010
+ const protectedCheckoutSeat = await evaluatePermissionSeat(
6434
7011
  { ...seatEvent(["git push origin feature/push-seat"]), sessionID: "s-seat-block" },
6435
7012
  { workspaceRoot: seatMainRepo, client: fakeClient as unknown },
6436
7013
  );
6437
7014
  check(
6438
- "seat still denies a real finding (push from a protected branch)",
6439
- protectedPushSeat.effect === "deny" && protectedPushSeat.decision?.status === "blocked" && /protected branch/i.test(String(protectedPushSeat.decision?.message)),
7015
+ "seat no longer denies a cwd-dependent push from a protected session checkout",
7016
+ protectedCheckoutSeat.effect === "abstain" && protectedCheckoutSeat.decision?.code === "repository_unidentified",
7017
+ );
7018
+ // A push whose DESTINATION is protected is a finding in every checkout, so
7019
+ // the workdir cannot change the verdict and the seat keeps its denial.
7020
+ const protectedDestSeat = await evaluatePermissionSeat(
7021
+ { ...seatEvent(["git push origin main"]), sessionID: "s-seat-dest" },
7022
+ { workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
7023
+ );
7024
+ check(
7025
+ "seat denies a push to a protected destination regardless of the working directory",
7026
+ protectedDestSeat.effect === "deny",
7027
+ );
7028
+ // A CONFIGURED protected branch is not that case: the seat recognizes the
7029
+ // built-in pair only, so the verdict still depends on the checkout and
7030
+ // defers to execute.before, which resolves the workdir and reads config.
7031
+ mkdirSync(join(seatFeatureRepo, ".opencode"), { recursive: true });
7032
+ writeFileSync(join(seatFeatureRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ protectedBranches: ["release/prod"] }));
7033
+ reloadProjectConfig(seatFeatureRepo);
7034
+ const configuredDestSeat = await evaluatePermissionSeat(
7035
+ { ...seatEvent(["git push origin release/prod"]), sessionID: "s-seat-configured-dest" },
7036
+ { workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
7037
+ );
7038
+ check(
7039
+ "seat defers a push to a configured protected branch instead of judging the session checkout",
7040
+ configuredDestSeat.effect === "abstain" && configuredDestSeat.decision?.code === "repository_unidentified",
7041
+ );
7042
+ rmSync(join(seatFeatureRepo, ".opencode"), { recursive: true, force: true });
7043
+ reloadProjectConfig(seatFeatureRepo);
7044
+ const explicitProtectedSeat = await evaluatePermissionSeat(
7045
+ { ...seatEvent([`git -C ${seatMainRepo} push origin feature/push-seat`]), sessionID: "s-seat-explicit-protected" },
7046
+ { workspaceRoot: seatMainRepo, client: fakeClient as unknown },
7047
+ );
7048
+ check(
7049
+ "seat denies a push from an explicitly named protected checkout",
7050
+ explicitProtectedSeat.effect === "deny" && explicitProtectedSeat.decision?.status === "blocked" && /protected branch/i.test(String(explicitProtectedSeat.decision?.message)),
7051
+ );
7052
+ // Naming a repository outside the session's workspace stays a boundary
7053
+ // finding, and the seat keeps denying it for that reason.
7054
+ const foreignRepoSeat = await evaluatePermissionSeat(
7055
+ { ...seatEvent([`git -C ${seatMainRepo} commit -m x`]), sessionID: "s-seat-foreign" },
7056
+ { workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
7057
+ );
7058
+ check(
7059
+ "seat denies an explicitly named repository outside the workspace",
7060
+ foreignRepoSeat.effect === "deny" && foreignRepoSeat.decision?.policy === "boundary",
7061
+ );
7062
+ // Deferring a checkout-derived verdict must never hide a finding about the
7063
+ // command text itself. A secret read in the same compound command is a real
7064
+ // block, no matter which checkout the push would have used.
7065
+ const compoundSeat = await evaluatePermissionSeat(
7066
+ { ...seatEvent(["cat .env && git push origin feature/push-seat"]), sessionID: "s-seat-compound" },
7067
+ { workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
7068
+ );
7069
+ check(
7070
+ "a genuine non-checkout finding in a compound command is still denied",
7071
+ compoundSeat.effect === "deny" && compoundSeat.decision?.policy === "secrets",
6440
7072
  );
6441
7073
  const askedSeat = await evaluatePermissionSeat(
6442
7074
  { ...seatEvent(["git push origin feature/push-seat"], "ask"), sessionID: "s-seat-ask" },
@@ -6448,9 +7080,9 @@ try {
6448
7080
  rmSync(seatMainRepo, { recursive: true, force: true });
6449
7081
  }
6450
7082
 
6451
- // The end-to-end seat: the same undecidable push through the registered hook
6452
- // must leave the configured effect and message untouched, and journal the
6453
- // deferral so the skipped remote probe stays legible.
7083
+ // The end-to-end seat: a deferred command through the registered hook must
7084
+ // leave the configured effect and message untouched, and journal the deferral
7085
+ // so a skipped decision stays legible.
6454
7086
  {
6455
7087
  resetPluginInstancesForTesting();
6456
7088
  const seatHookRepo = makeSeatRepo("feature/push-seat-hook");
@@ -6461,22 +7093,32 @@ try {
6461
7093
  const seatAskEvent = { ...permissionEvent("shell", ["git push origin feature/push-seat-hook"], "s-seat-hook-ask"), effect: "ask" as "allow" | "deny" | "ask" };
6462
7094
  await seatCtx.state.permissionHooks[0]?.(seatAskEvent);
6463
7095
  check(
6464
- "evaluate leaves a configured ask in place when the simulation abstains",
7096
+ "evaluate leaves a configured ask in place when the seat defers",
6465
7097
  seatAskEvent.effect === "ask" && seatAskEvent.message === undefined,
6466
7098
  );
6467
7099
  const seatPushEvent = permissionEvent("shell", ["git push origin feature/push-seat-hook"], "s-seat-hook");
6468
7100
  await seatCtx.state.permissionHooks[0]?.(seatPushEvent);
6469
7101
  check(
6470
- "evaluate does not deny a non-protected push it cannot decide",
7102
+ "evaluate does not deny a cwd-dependent push it cannot locate",
6471
7103
  seatPushEvent.effect === "allow" && seatPushEvent.message === undefined,
6472
7104
  );
6473
- const abstainAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.reason === "deferred_to_execute_before");
7105
+ const deferAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.reason === "deferred_to_execute_before");
6474
7106
  check(
6475
- "an abstention is journaled as deferred to execute.before, not as a plain allow",
6476
- abstainAudit !== undefined &&
6477
- abstainAudit.policyDecision?.status === "needs_approval" &&
6478
- abstainAudit.policyDecision?.code === "remote_state_unchecked" &&
6479
- (abstainAudit.input as any)?.deferred === "execute.before",
7107
+ "a cwd-dependent deferral is journaled with its own code, not as a plain allow",
7108
+ deferAudit !== undefined &&
7109
+ deferAudit.policyDecision?.status === "needs_approval" &&
7110
+ deferAudit.policyDecision?.code === "repository_unidentified" &&
7111
+ (deferAudit.input as any)?.deferred === "execute.before",
7112
+ );
7113
+ // The remote-probe deferral still exists for commands that name their
7114
+ // repository, because the seat's simulation skips that probe on purpose.
7115
+ const explicitSeatEvent = permissionEvent("shell", [`git -C ${seatHookRepo} push origin feature/push-seat-hook`], "s-seat-hook-explicit");
7116
+ await seatCtx.state.permissionHooks[0]?.(explicitSeatEvent);
7117
+ const remoteProbeAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.sessionID === "s-seat-hook-explicit");
7118
+ check(
7119
+ "a repository-naming command keeps the remote-probe deferral",
7120
+ explicitSeatEvent.effect === "allow" &&
7121
+ remoteProbeAudit?.policyDecision?.code === "remote_state_unchecked",
6480
7122
  );
6481
7123
  seatCleanup?.();
6482
7124
  if (prevSeatDataHome === undefined) delete process.env.XDG_DATA_HOME;
@@ -7006,5 +7648,265 @@ rmSync(liveBase, { recursive: true, force: true });
7006
7648
 
7007
7649
  rmSync(root, { recursive: true, force: true });
7008
7650
  if (prevLive !== undefined) process.env.WORKFLOW_GUARD_ALLOW_LIVE = prevLive;
7651
+
7652
+ // ── V2 delivered event handlers ──────────────────────────────────────────────
7653
+ // `ctx.event.subscribe()` is served from EventManifest.ServerDefinitions. A type
7654
+ // present in `Definitions` but absent from that manifest is never delivered,
7655
+ // even when a plugin handles it. Three handlers were built on such types, so
7656
+ // tool-outcome telemetry, the Policy 25 research floor, the MCP inventory
7657
+ // refresh, and the command audit were all silently dead on V2. These tests pin
7658
+ // the repaired paths and add a structural guard against the recurrence.
7659
+ console.log("- V2 delivered event handlers -");
7660
+
7661
+ const v2EventModule = await import("../src/lib/v2-plugin.ts");
7662
+ const { handleV2Event } = v2EventModule;
7663
+ const { EventManifest } = await import("@opencode/schema/event-manifest");
7664
+ const serverEventTypes = new Set<string>([...EventManifest.ServerDefinitions].map((entry) => entry.type as string));
7665
+
7666
+ // E5 (structural, highest value): every literal the plugin compares against
7667
+ // `event.type` must be a type the host actually delivers. This is what would
7668
+ // have caught the three dead handlers before they shipped.
7669
+ {
7670
+ const pluginSource = readFileSync("src/lib/v2-plugin.ts", "utf8");
7671
+ const literals = [...pluginSource.matchAll(/event\??\.type\s*(?:===|!==)\s*"([^"]+)"/g)].map((match) => match[1]);
7672
+ const unique = [...new Set(literals)];
7673
+ const undelivered = unique.filter((type) => !serverEventTypes.has(type));
7674
+ check("V2 event-liveness scan finds the plugin's event comparisons", unique.length >= 5);
7675
+ check("every event.type literal in v2-plugin.ts is in EventManifest.ServerDefinitions", undelivered.length === 0);
7676
+ if (undelivered.length > 0) console.log(` undelivered literal(s): ${undelivered.join(", ")}`);
7677
+ // The three types that made A1/A3/A4 dead must not come back as literals.
7678
+ check("the replay-only tool-content type is no longer handled", !unique.includes("session.message.content.updated"));
7679
+ check("the ephemeral mcp.tools.changed type is no longer handled", !unique.includes("mcp.tools.changed"));
7680
+ check("the replay-only command.executed type is no longer handled", !unique.includes("command.executed"));
7681
+ }
7682
+
7683
+ // E1: the deny contract. `execute.before` may reject, and that rejection is what
7684
+ // fails the call with the guard's reason. Source-only fact: the adapter wraps
7685
+ // only that hook in `Effect.tryPromise`. A real V2 instance is driven so the
7686
+ // assertion covers the shipped registration, not a re-creation of it.
7687
+ {
7688
+ resetPluginInstancesForTesting();
7689
+ resetToolCallClaimsForTesting();
7690
+ const denyRoot = mkdtempSync(join(tmpdir(), "wg-v2-deny-"));
7691
+ const v2Deny = makeV2Context(denyRoot);
7692
+ await (defaultExport.setup as any)(v2Deny.ctx);
7693
+ const beforeHook = v2Deny.state.beforeHooks[0];
7694
+ check("V2 registers exactly one execute.before hook", v2Deny.state.beforeHooks.length === 1);
7695
+ let rejected: unknown;
7696
+ let allowedReason: unknown = "sentinel";
7697
+ try {
7698
+ await beforeHook({ tool: "write", sessionID: "s-v2-deny", id: "call-deny", agent: "build", messageID: "m1", input: { filePath: join(denyRoot, "..", "escape.txt"), content: "x" } });
7699
+ } catch (error) {
7700
+ rejected = error;
7701
+ }
7702
+ check("a blocked execute.before rejects the call", rejected instanceof Error);
7703
+ check("the rejection carries the workflow-guard reason prefix", rejected instanceof Error && rejected.message.includes("[workflow-guard]"));
7704
+ check("the rejection carries an actionable policy reason", rejected instanceof Error && rejected.message.length > "[workflow-guard] ".length + 10);
7705
+ // A permitted call must stay silent: the hook resolves without throwing.
7706
+ try {
7707
+ allowedReason = await beforeHook({ tool: "read", sessionID: "s-v2-deny", id: "call-allow", agent: "build", messageID: "m2", input: { filePath: join(denyRoot, "ok.txt") } });
7708
+ } catch (error) {
7709
+ allowedReason = error;
7710
+ }
7711
+ check("a permitted execute.before resolves without throwing", !(allowedReason instanceof Error));
7712
+ rmSync(denyRoot, { recursive: true, force: true });
7713
+ }
7714
+
7715
+ // E2: tool-outcome telemetry (A1). The old subscription keyed on a replay-only
7716
+ // event, so nothing ever reached the tracker and the circuit breaker could not
7717
+ // fire. Recording now happens in `execute.after`, which does fire for a failed
7718
+ // call, so an error accrues failure debt and a success clears it.
7719
+ {
7720
+ resetPluginInstancesForTesting();
7721
+ resetToolCallClaimsForTesting();
7722
+ const outcomeRoot = mkdtempSync(join(tmpdir(), "wg-v2-outcome-"));
7723
+ const v2Outcome = makeV2Context(outcomeRoot);
7724
+ await (defaultExport.setup as any)(v2Outcome.ctx);
7725
+ const beforeHook = v2Outcome.state.beforeHooks[0];
7726
+ const afterHook = v2Outcome.state.afterHooks[0];
7727
+ const breaker = (error: unknown) => error instanceof Error && error.message.includes("[Workflow Guard Circuit Breaker");
7728
+ // One error is not enough: the suffix starts at a count of 2.
7729
+ await afterHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-a", agent: "build", messageID: "m1", input: {}, status: "error", error: new Error("boom") });
7730
+ let firstError: unknown;
7731
+ try {
7732
+ await beforeHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-b", agent: "build", messageID: "m2", input: { filePath: join(outcomeRoot, "..", "escape.txt"), content: "x" } });
7733
+ } catch (error) {
7734
+ firstError = error;
7735
+ }
7736
+ check("one failure stays below the circuit-breaker threshold", !breaker(firstError));
7737
+ // A second equivalent error crosses the threshold.
7738
+ await afterHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-c", agent: "build", messageID: "m3", input: {}, status: "error", error: new Error("boom") });
7739
+ let secondError: unknown;
7740
+ try {
7741
+ await beforeHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-d", agent: "build", messageID: "m4", input: { filePath: join(outcomeRoot, "..", "escape.txt"), content: "x" } });
7742
+ } catch (error) {
7743
+ secondError = error;
7744
+ }
7745
+ check("two equivalent tool errors raise the circuit-breaker path", breaker(secondError));
7746
+ // A completed call clears the debt, so the suffix disappears again.
7747
+ await afterHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-e", agent: "build", messageID: "m5", input: {}, status: "completed", result: { content: "ok" } });
7748
+ let clearedError: unknown;
7749
+ try {
7750
+ await beforeHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-f", agent: "build", messageID: "m6", input: { filePath: join(outcomeRoot, "..", "escape.txt"), content: "x" } });
7751
+ } catch (error) {
7752
+ clearedError = error;
7753
+ }
7754
+ check("the circuit-breaker suffix is gone after a success", !breaker(clearedError));
7755
+ check("the failure outcome is journaled for diagnosis", getRecentAuditEntries(50).some((entry) => entry.sessionID === "s-v2-outcome" && entry.phase === "outcome" && entry.reason === "error"));
7756
+ // Read the counter directly, so the debt assertions cannot pass on a call
7757
+ // that throws for an unrelated reason. Two errors accrued, one completion
7758
+ // cleared them; the audit rows are the durable witness for the same state.
7759
+ const outcomeAudit = getRecentAuditEntries(50).filter((entry) => entry.sessionID === "s-v2-outcome" && entry.phase === "outcome");
7760
+ check("two failures and one completion are each journaled", outcomeAudit.filter((entry) => entry.reason === "error").length === 2 && outcomeAudit.some((entry) => entry.reason === "completed"));
7761
+ // A partial event with no terminal status takes the fallback path. That path
7762
+ // must still journal its outcome row, which is the coverage the move onto
7763
+ // `execute.after` nearly dropped.
7764
+ await afterHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-g", agent: "build", messageID: "m7", input: {}, result: { content: "ok" } });
7765
+ check("a status-less after event still journals an outcome row", getRecentAuditEntries(50).some((entry) => entry.sessionID === "s-v2-outcome" && entry.callID === "call-g" && entry.phase === "outcome"));
7766
+ rmSync(outcomeRoot, { recursive: true, force: true });
7767
+ }
7768
+
7769
+ // E3: a user interrupt suspends continuation on V2 (A2). Only reason `user` is
7770
+ // user intent; the other three are host-lifecycle events and must not stop the
7771
+ // session's own unfinished work from continuing.
7772
+ {
7773
+ const makeState = (root: string, client: unknown) => ({
7774
+ effectiveRoot: root,
7775
+ client,
7776
+ toolLifecycle: new ToolInvocationLifecycle(),
7777
+ toolOutcomes: new ToolOutcomeTracker(),
7778
+ });
7779
+ const interrupts = [
7780
+ { reason: "user", suspends: true },
7781
+ { reason: "shutdown", suspends: false },
7782
+ { reason: "superseded", suspends: false },
7783
+ { reason: "inactivity", suspends: false },
7784
+ ];
7785
+ for (const { reason, suspends } of interrupts) {
7786
+ const sessionID = `s-v2-int-${reason}`;
7787
+ todo(sessionID, item("unfinished work", "pending"));
7788
+ // A continuation can only be observed if the client can actually prompt,
7789
+ // and continuation state is keyed on the client object, so each case
7790
+ // gets its own client and asserts on the delivered prompt rather than on
7791
+ // a false-negative return value.
7792
+ const prompted: string[] = [];
7793
+ const continuationClient = {
7794
+ session: {
7795
+ todo: async ({ path }: { path: { id: string } }) => ({ data: fakeTodos.get(path.id) ?? [] }),
7796
+ get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: fakeParents.get(path.id) } }),
7797
+ promptAsync: async ({ path }: { path: { id: string } }) => { prompted.push(path.id); },
7798
+ },
7799
+ };
7800
+ await handleV2Event({ type: "session.execution.interrupted", data: { sessionID, reason } }, makeState(root, continuationClient) as any);
7801
+ const continued = await runWithRuntimeState(root, continuationClient, () => continueUnfinishedSession(sessionID));
7802
+ check(`session.execution.interrupted reason "${reason}" ${suspends ? "suspends" : "does not suspend"} continuation`, continued === !suspends && prompted.length === (suspends ? 0 : 1));
7803
+ await runWithRuntimeState(root, continuationClient, () => clearContinuationState(sessionID));
7804
+ }
7805
+ // A genuine user message re-enables a suspended session, proven by an
7806
+ // actual continuation prompt: suspension must block it, user input must not.
7807
+ const resumedSession = "s-v2-int-resume";
7808
+ todo(resumedSession, item("unfinished work", "pending"));
7809
+ const resumedPrompts: string[] = [];
7810
+ const resumeClient = {
7811
+ session: {
7812
+ todo: async ({ path }: { path: { id: string } }) => ({ data: fakeTodos.get(path.id) ?? [] }),
7813
+ get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: fakeParents.get(path.id) } }),
7814
+ promptAsync: async ({ path }: { path: { id: string } }) => { resumedPrompts.push(path.id); },
7815
+ },
7816
+ };
7817
+ await handleV2Event({ type: "session.execution.interrupted", data: { sessionID: resumedSession, reason: "user" } }, makeState(root, resumeClient) as any);
7818
+ const suspendedResult = await runWithRuntimeState(root, resumeClient, () => continueUnfinishedSession(resumedSession));
7819
+ check("a suspended session sends no continuation prompt", suspendedResult === false && resumedPrompts.length === 0);
7820
+ // Continuation state is keyed on the client object, so the user-message
7821
+ // record must run against the same client the session was suspended under.
7822
+ await runWithRuntimeState(root, resumeClient, () => recordUserMessage(resumedSession));
7823
+ const afterUserInput = await runWithRuntimeState(root, resumeClient, () => continueUnfinishedSession(resumedSession));
7824
+ check("genuine user input re-enables a suspended continuation", afterUserInput === true && resumedPrompts.length === 1);
7825
+ await runWithRuntimeState(root, resumeClient, () => clearContinuationState(resumedSession));
7826
+ }
7827
+
7828
+ // E4: the MCP inventory refresh keys on a delivered event (A3), and widening is
7829
+ // additive only. A guarded server that connects mid-session must be guarded
7830
+ // without waiting for the next session, and a quieter roster must never unguard
7831
+ // a token that a static rule already covers.
7832
+ {
7833
+ const refreshCalls: string[] = [];
7834
+ const state = {
7835
+ effectiveRoot: root,
7836
+ client: fakeClient,
7837
+ toolLifecycle: new ToolInvocationLifecycle(),
7838
+ toolOutcomes: new ToolOutcomeTracker(),
7839
+ refreshMcpInventory: async () => { refreshCalls.push("refresh"); },
7840
+ };
7841
+ await handleV2Event({ type: "mcp.status.changed", data: { server: "github-enterprise" } }, state as any);
7842
+ check("a delivered mcp.status.changed triggers the inventory refresh", refreshCalls.length === 1);
7843
+ await handleV2Event({ type: "mcp.tools.changed", data: { server: "github-enterprise" } }, state as any);
7844
+ check("the undelivered mcp.tools.changed no longer triggers anything", refreshCalls.length === 1);
7845
+ // Widening is additive: a later roster without the server cannot remove it.
7846
+ resetGuardedMcpTokensForTesting();
7847
+ const added = recordHostMcpServers(["github-enterprise"]);
7848
+ const widened = [...guardedMcpServers()];
7849
+ check("a host-declared guarded server widens the token set", added.length > 0 && widened.some((token) => added.includes(token)));
7850
+ recordHostMcpServers([]);
7851
+ check("an emptied roster never narrows the guarded token set", [...guardedMcpServers()].length >= widened.length);
7852
+ resetGuardedMcpTokensForTesting();
7853
+ }
7854
+
7855
+ // E6: the idle trigger. The host keeps two event types for it and neither is
7856
+ // established as the one the server publishes: `session.status` with
7857
+ // `status.type === "idle"`, and the deprecated `session.idle`. Handling only
7858
+ // one silently disables automatic continuation if the other is what arrives.
7859
+ // The assertions are delivered continuation prompts, not return values.
7860
+ {
7861
+ const makeIdleState = (client: unknown) => ({
7862
+ effectiveRoot: root,
7863
+ client,
7864
+ toolLifecycle: new ToolInvocationLifecycle(),
7865
+ toolOutcomes: new ToolOutcomeTracker(),
7866
+ });
7867
+ const idleSession = "s-v2-idle-status";
7868
+ todo(idleSession, item("unfinished work", "pending"));
7869
+ const idlePrompts: string[] = [];
7870
+ const idleClient = {
7871
+ session: {
7872
+ todo: async ({ path }: { path: { id: string } }) => ({ data: fakeTodos.get(path.id) ?? [] }),
7873
+ get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: fakeParents.get(path.id) } }),
7874
+ promptAsync: async ({ path }: { path: { id: string } }) => { idlePrompts.push(path.id); },
7875
+ },
7876
+ };
7877
+ await handleV2Event({ type: "session.status", data: { sessionID: idleSession, status: { type: "busy" } } }, makeIdleState(idleClient) as any);
7878
+ check("a busy session.status does not continue the session", idlePrompts.length === 0);
7879
+ await handleV2Event({ type: "session.status", data: { sessionID: idleSession, status: { type: "idle" } } }, makeIdleState(idleClient) as any);
7880
+ check("an idle session.status continues the session", idlePrompts.length === 1);
7881
+ // The deprecated trigger must still work: a host that emits only this one
7882
+ // would otherwise lose auto-continuation entirely.
7883
+ await handleV2Event({ type: "session.idle", data: { sessionID: idleSession } }, makeIdleState(idleClient) as any);
7884
+ check("the deprecated session.idle still continues the session", idlePrompts.length === 2);
7885
+ await runWithRuntimeState(root, idleClient, () => clearContinuationState(idleSession));
7886
+
7887
+ // A host that emits both types for one transition must produce one prompt.
7888
+ // Each event is handled on its own call, so this pins the in-flight lock
7889
+ // rather than any per-call dedupe.
7890
+ const bothSession = "s-v2-idle-both";
7891
+ todo(bothSession, item("unfinished work", "pending"));
7892
+ let bothPrompts = 0;
7893
+ let releaseBoth!: () => void;
7894
+ const bothGate = new Promise<void>((resolve) => { releaseBoth = resolve; });
7895
+ const bothClient = {
7896
+ session: {
7897
+ todo: async ({ path }: { path: { id: string } }) => ({ data: fakeTodos.get(path.id) ?? [] }),
7898
+ get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: fakeParents.get(path.id) } }),
7899
+ promptAsync: async () => { bothPrompts++; await bothGate; },
7900
+ },
7901
+ };
7902
+ const firstTrigger = handleV2Event({ type: "session.status", data: { sessionID: bothSession, status: { type: "idle" } } }, makeIdleState(bothClient) as any);
7903
+ const secondTrigger = handleV2Event({ type: "session.idle", data: { sessionID: bothSession } }, makeIdleState(bothClient) as any);
7904
+ await new Promise((resolve) => setTimeout(resolve, 0));
7905
+ releaseBoth();
7906
+ await Promise.all([firstTrigger, secondTrigger]);
7907
+ check("both idle event types for one transition yield a single continuation prompt", bothPrompts === 1);
7908
+ await runWithRuntimeState(root, bothClient, () => clearContinuationState(bothSession));
7909
+ }
7910
+
7009
7911
  console.log(`\n${pass} passed, ${fail} failed`);
7010
7912
  process.exitCode = fail ? 1 : 0;