opencode-workflow-guard 1.18.1 → 1.19.1

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.
@@ -0,0 +1,147 @@
1
+ import { dogfoodEvidence } from "./evidence.ts";
2
+ import { getDogfoodDeployMatrix, getDogfoodRecordsForWorkspace, isDogfoodRequired } from "./state.ts";
3
+ import { getCurrentGitCommitHash, getTrackedWorktreeFingerprint } from "./verify.ts";
4
+ import type { DogfoodResult } from "./types.ts";
5
+
6
+ export interface DogfoodGateResult {
7
+ required: boolean;
8
+ /** Matrix hosts still missing a fresh passing record. */
9
+ missingHosts: string[];
10
+ /** The record that satisfied the gate, when one did. */
11
+ matched?: DogfoodResult;
12
+ /** Every record counted toward the gate, one per host. */
13
+ matchedRecords?: DogfoodResult[];
14
+ /** Why the gate is unsatisfied, cause-attributed. */
15
+ reason?: string;
16
+ /** Evidence id of the matched record, for audit correlation. */
17
+ evidenceId?: string;
18
+ }
19
+
20
+ /**
21
+ * Why one record cannot count for this worktree, or undefined when it can.
22
+ *
23
+ * Each cause is named separately because they have different remedies. A
24
+ * missing fingerprint is an environment problem, a fingerprint mismatch is a
25
+ * stale run, and a commit mismatch is an unrecorded commit. Collapsing them
26
+ * into one message sends the agent looking for the wrong fix.
27
+ */
28
+ function stalenessReason(record: DogfoodResult, commitHash: string | undefined, fingerprint: string | undefined): string | undefined {
29
+ if (!fingerprint) {
30
+ return "Worktree fingerprint could not be computed (git unavailable or worktree unreadable), so dogfood evidence cannot be bound to this worktree; resolve worktree readability and re-run the dogfood scenario.";
31
+ }
32
+ if (!record.worktreeFingerprint) {
33
+ return `The recorded dogfood run ('${record.scenarioId}') has no worktree fingerprint binding (recorded against an unreadable worktree); re-run the dogfood scenario and record it with record_dogfood.`;
34
+ }
35
+ if (record.worktreeFingerprint !== fingerprint) {
36
+ return `The recorded dogfood run ('${record.scenarioId}' on '${record.host.label}') does not match the current worktree contents (tracked content changed after the run); re-run the dogfood scenario and record it with record_dogfood.`;
37
+ }
38
+ if (record.commitHash && commitHash && record.commitHash !== commitHash) {
39
+ return `The recorded dogfood run ('${record.scenarioId}' on '${record.host.label}') is bound to commit ${record.commitHash.slice(0, 12)} but this worktree is at commit ${commitHash.slice(0, 12)}; re-run the dogfood scenario at the current commit and record it with record_dogfood.`;
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * Judges the dogfood gate for one worktree.
46
+ *
47
+ * Shared by PR preflight and `guard_status` so the two can never disagree about
48
+ * whether the gate is satisfied. Freshness is content-scoped, matching review:
49
+ * a record covers the tracked tree it ran against, so untracked scratch
50
+ * cleanup cannot invalidate it while any tracked edit or new commit does.
51
+ */
52
+ export function evaluateDogfoodGate(root: string): DogfoodGateResult {
53
+ if (!isDogfoodRequired(root)) return { required: false, missingHosts: [] };
54
+ const matrix = getDogfoodDeployMatrix(root);
55
+ const records = getDogfoodRecordsForWorkspace(root);
56
+ const commitHash = getCurrentGitCommitHash(root);
57
+ const fingerprint = getTrackedWorktreeFingerprint(root);
58
+
59
+ if (records.length === 0) {
60
+ return {
61
+ required: true,
62
+ missingHosts: matrix.length > 0 ? matrix : ["this host"],
63
+ reason: "Passing dogfood evidence is required (requireDogfood); run the dogfood runner and record the result with record_dogfood.",
64
+ };
65
+ }
66
+
67
+ // Only the newest record per host can count. An older pass is superseded by
68
+ // a newer run of the same host, including a newer HOLD, so a stale pass
69
+ // cannot hide a regression the same host later found. A same-millisecond
70
+ // tie resolves to the HOLD regardless of arrival order: re-running is cheap,
71
+ // and trusting a stale pass is the failure that matters.
72
+ const newestPerHost = new Map<string, DogfoodResult>();
73
+ for (const record of records) {
74
+ const seen = newestPerHost.get(record.host.label);
75
+ if (!seen || record.timestamp > seen.timestamp) {
76
+ newestPerHost.set(record.host.label, record);
77
+ } else if (record.timestamp === seen.timestamp && seen.verdict === "pass" && record.verdict === "hold") {
78
+ newestPerHost.set(record.host.label, record);
79
+ }
80
+ }
81
+
82
+ const unmatched: string[] = [];
83
+ const held: DogfoodResult[] = [];
84
+ const stale: Array<{ record: DogfoodResult; reason: string }> = [];
85
+ const matchedRecords: DogfoodResult[] = [];
86
+ for (const [host, record] of newestPerHost) {
87
+ // A host outside the deploy matrix cannot block the gate, so its HOLD or
88
+ // staleness must not be reported as the reason: that would name the wrong
89
+ // remedy. Only in-scope hosts collect their cause.
90
+ const inScope = matrix.length === 0 || matrix.includes(host);
91
+ if (record.verdict !== "pass") {
92
+ if (inScope) {
93
+ held.push(record);
94
+ unmatched.push(host);
95
+ }
96
+ continue;
97
+ }
98
+ const reason = stalenessReason(record, commitHash, fingerprint);
99
+ if (reason) {
100
+ if (inScope) {
101
+ stale.push({ record, reason });
102
+ unmatched.push(host);
103
+ }
104
+ continue;
105
+ }
106
+ if (!inScope) continue;
107
+ matchedRecords.push(record);
108
+ }
109
+
110
+ if (matrix.length > 0) {
111
+ for (const host of matrix) if (!newestPerHost.has(host)) unmatched.push(host);
112
+ }
113
+
114
+ if (unmatched.length > 0) {
115
+ const missingHosts = [...new Set(unmatched)];
116
+ // A HOLD is more actionable than a matrix gap, so name it first.
117
+ if (held.length > 0) {
118
+ const first = held[0];
119
+ return {
120
+ required: true,
121
+ missingHosts,
122
+ reason: `The most recent dogfood run for host '${first.host.label}' ('${first.scenarioId}') returned HOLD${first.taxonomyClass ? ` with class '${first.taxonomyClass}'` : ""}; fix the finding and re-run before opening a PR.`,
123
+ };
124
+ }
125
+ if (stale.length > 0) {
126
+ return { required: true, missingHosts, reason: stale[0].reason };
127
+ }
128
+ const outsideMatrix = [...newestPerHost.keys()].filter((host) => !matrix.includes(host));
129
+ if (matrix.length > 0 && matchedRecords.length === 0 && outsideMatrix.length > 0) {
130
+ return {
131
+ required: true,
132
+ missingHosts,
133
+ reason: `Dogfood evidence was recorded for host(s) ${outsideMatrix.join(", ")}, which are not in the deploy matrix (${matrix.join(", ")}); run the dogfood scenario on each matrix host and record each result.`,
134
+ };
135
+ }
136
+ return {
137
+ required: true,
138
+ missingHosts,
139
+ reason: `Dogfood evidence is missing for host(s) ${missingHosts.join(", ")} in the deploy matrix (${matrix.join(", ")}); run the dogfood scenario on each and record each result with record_dogfood.`,
140
+ };
141
+ }
142
+
143
+ // Satisfied. Report the newest contributing record, and keep the per-host
144
+ // set so the caller can journal every host that was actually covered.
145
+ const newest = matchedRecords.reduce<DogfoodResult | undefined>((best, record) => !best || record.timestamp > best.timestamp ? record : best, undefined);
146
+ return { required: true, missingHosts: [], matched: newest, matchedRecords, evidenceId: newest ? dogfoodEvidence(newest).id : undefined };
147
+ }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ToolOutcome } from "./tool-outcomes.ts";
3
- import type { EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
3
+ import type { DogfoodResult, EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
4
4
  import { isSameGitRepo } from "./project-config.ts";
5
5
 
6
6
  function evidenceId(kind: EvidenceRecord["kind"], observedAt: number, subject: EvidenceRecord["subject"], source: Record<string, unknown>): string {
@@ -19,6 +19,24 @@ export function reviewEvidence(result: ReviewResult): EvidenceRecord {
19
19
  return { id: evidenceId("review", result.timestamp, subject, source), kind: "review", confidence: "attestation", observedAt: result.timestamp, subject, source };
20
20
  }
21
21
 
22
+ export function dogfoodEvidence(result: DogfoodResult): EvidenceRecord {
23
+ const subject = { workspace: result.workspace, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID: result.sessionID, actor: result.host.label };
24
+ // The digest pair is part of the source, so replaying a passing run onto a
25
+ // different scenario or taxonomy changes the evidence id.
26
+ const source = {
27
+ verdict: result.verdict,
28
+ scenarioId: result.scenarioId,
29
+ scenarioSha256: result.scenarioSha256,
30
+ taxonomySha256: result.taxonomySha256,
31
+ taxonomyClass: result.taxonomyClass,
32
+ checks: result.checks,
33
+ runCount: result.runCount,
34
+ host: result.host,
35
+ gitStatus: result.gitStatus,
36
+ };
37
+ return { id: evidenceId("dogfood", result.timestamp, subject, source), kind: "dogfood", confidence: "attestation", observedAt: result.timestamp, subject, source };
38
+ }
39
+
22
40
  export function toolOutcomeEvidence(outcome: ToolOutcome, observedAt: number, workspace?: string): EvidenceRecord {
23
41
  const subject = { workspace, sessionID: outcome.sessionID, callID: outcome.callID };
24
42
  const source = { tool: outcome.tool, status: outcome.status, durationMs: outcome.durationMs, repeatedFailureCount: outcome.repeatedFailureCount };
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import { audit, loadVerifyCache } from "./audit.ts";
4
4
  import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
5
5
  import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
6
+ import { evaluateDogfoodGate } from "./dogfood.ts";
6
7
  import {
7
8
  getLastReviewResultForWorkspace,
8
9
  getLiveControlPlaneRoots,
@@ -23,14 +24,16 @@ import {
23
24
  import {
24
25
  dynamicShellSyntaxIn,
25
26
  extractCommands,
27
+ isLeadingEvalSegment,
28
+ maskDataHeredocBodies,
26
29
  normalize,
30
+ planShellSegments,
27
31
  shellWrappersChangeCwd,
28
32
  splitShellSegments,
29
33
  unwrapShellCommand,
30
34
  } from "./shell.ts";
31
35
  import { asRecord, extractTargetPath } from "./utils.ts";
32
36
  import type { PolicyDecision } from "./types.ts";
33
- import { getProjectMemoryIdentity } from "./project-memory.ts";
34
37
  import {
35
38
  detectVerifyCommand,
36
39
  getCurrentGitCommitHash,
@@ -41,7 +44,7 @@ import {
41
44
  resolveVerifyTimeoutMs,
42
45
  snipVerifyOutput,
43
46
  } from "./verify.ts";
44
- import { detectShellMutation, extractPatchPaths, guardShellMutation, hasUnresolvableVariable, isEnvironmentScratchTarget, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR } from "../policies/boundary.ts";
47
+ import { detectShellMutation, extractPatchPaths, foreignSegmentMutationReason, guardShellMutation, hasUnresolvableVariable, isEnvironmentScratchTarget, isPathOutsideWorkspace, nestedPayloadMutationReason, OPENCODE_SCRATCH_DIR } from "../policies/boundary.ts";
45
48
  import { branchHasChangelogChange, checkLockfileSync, hasPrCreateInvocation, prBodyHasLiteralLineBreakEscapes, prBodyIncludesChangelog } from "../policies/changelog.ts";
46
49
  import { extractEditContent, liveMutationIn } from "../policies/destructive.ts";
47
50
  import { branchHasDocumentationChange } from "../policies/docs.ts";
@@ -55,11 +58,14 @@ import {
55
58
  checkBranchBaseIsUpToDate,
56
59
  checkMergeConflicts,
57
60
  currentGitBranch,
61
+ findWorktreeForBranch,
58
62
  hasUnsafeGitAlias,
59
63
  isBranchAlreadyMergedOrClosed,
64
+ isGitTreeWriteText,
60
65
  normalizeGitCommands,
61
66
  onProtectedBranch,
62
67
  parseGitInvocation,
68
+ prSourceBranch,
63
69
  pushedProtectedBranchIn,
64
70
  tagRefspecIn,
65
71
  } from "../policies/git.ts";
@@ -277,7 +283,7 @@ export async function guardToolCallImpl(
277
283
  // that would restate the misattribution.
278
284
  const tail = snipVerifyOutput(result.output, result.timedOut === true).slice(-500);
279
285
  const reason = result.timedOut
280
- ? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Raise the limit in the project .opencode/workflow-guard.json (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS, then complete the tasks again; output tail: ${tail}`
286
+ ? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Only the user can raise the limit, in the project .opencode/workflow-guard.json or .jsonc (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS set before the guard starts; ask them, then complete the tasks again. Output tail: ${tail}`
281
287
  : `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
282
288
  logPolicyBlock(`[workflow-guard] ${reason}`);
283
289
  return block("verification", result.timedOut ? "verification_timed_out" : "verification_failed", reason);
@@ -447,9 +453,13 @@ export async function guardToolCallImpl(
447
453
  if (!SHELL_TOOL_NAMES.has(toolName)) return allow();
448
454
  const shellWorkdir = asRecord(input)?.workdir;
449
455
  const requestedShellRoot = typeof shellWorkdir === "string" ? resolve(currentRoot, shellWorkdir) : currentRoot;
450
- const shellRoot = !isPathOutsideWorkspace(requestedShellRoot, currentRoot) || getProjectMemoryIdentity(requestedShellRoot) === getProjectMemoryIdentity(currentRoot)
451
- ? requestedShellRoot
452
- : currentRoot;
456
+ // A workdir outside the workspace is only usable when it is a linked
457
+ // worktree of the SAME repository: those commands mutate a tree of this
458
+ // workspace. A genuinely foreign repository cannot be judged here at all,
459
+ // because the command executes there while relative targets would be
460
+ // validated against the workspace root.
461
+ const foreignShellRoot = isPathOutsideWorkspace(requestedShellRoot, currentRoot) && !isSameGitRepo(requestedShellRoot, currentRoot);
462
+ const shellRoot = foreignShellRoot ? currentRoot : requestedShellRoot;
453
463
 
454
464
  for (const raw of extractCommands(input)) {
455
465
  const dynamicSyntax = dynamicShellSyntaxIn(raw);
@@ -470,11 +480,50 @@ export async function guardToolCallImpl(
470
480
  }
471
481
  const normalizedCommand = normalizeGitCommands(command);
472
482
  if (hasUnsafeGitAlias(command)) return block("git", "unsafe_git_alias", "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.");
473
- const gitInvocations = splitShellSegments(command).map((segment) => parseGitInvocation(segment.trim())).filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
483
+ // Each segment is judged in the directory it actually runs in. A `cd`
484
+ // earlier in the command moves the ones that follow, so parsing them all
485
+ // against the shell's initial `workdir` hid a mutation in another tree:
486
+ // `cd <other-repo> && git commit` was judged as a commit in the session's
487
+ // own checkout. Plan from `raw`: `normalize` folds newlines into spaces,
488
+ // which erases the separator that makes a newline-separated `cd` a real
489
+ // directory change.
490
+ const segmentPlan = planShellSegments(raw, shellRoot);
491
+ const gitInvocations = segmentPlan
492
+ .map(({ segment, root }) => parseGitInvocation(segment.trim(), root))
493
+ .filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
474
494
  const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
495
+ // The command executes in `requestedShellRoot`, not in the workspace, so
496
+ // every relative target in it would be validated against the wrong tree:
497
+ // `rm -rf dist` in a foreign workdir would be checked as
498
+ // `<workspace>/dist`. Reads elsewhere are fine, mutations are not.
499
+ const mutatesTree = (invocation: NonNullable<ReturnType<typeof parseGitInvocation>>): boolean =>
500
+ isGitTreeWriteText(`git ${invocation.rest}`);
501
+ if (foreignShellRoot && (detectShellMutation(command) || gitInvocations.some(mutatesTree))) {
502
+ logPolicyBlock(`[workflow-guard] blocked mutation in a workdir outside workspace: ${requestedShellRoot}`);
503
+ return block(
504
+ "boundary",
505
+ "workspace_escape",
506
+ `Blocked: the shell's workdir '${requestedShellRoot}' is outside workspace root (${currentRoot}) and is not a linked worktree of this repository, so relative targets cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`,
507
+ );
508
+ }
509
+ const foreignSegmentReason = foreignSegmentMutationReason(segmentPlan, currentRoot);
510
+ if (foreignSegmentReason) {
511
+ logPolicyBlock(`[workflow-guard] blocked mutation in a directory outside workspace: ${command.slice(0, 120)}`);
512
+ return block("boundary", "workspace_escape", foreignSegmentReason);
513
+ }
475
514
  for (const invocation of gitInvocations) {
476
515
  const normalizedInvocation = `git ${invocation.rest}`;
477
- if (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))) {
516
+ // A linked worktree of the SAME repository is not foreign state: the
517
+ // shell's workdir legitimately lives there when a session works in an
518
+ // isolated checkout, and judging those commands is the point of the
519
+ // workdir-aware parse above. A genuinely different repository stays
520
+ // confined, exactly as before.
521
+ // An indeterminate repository is foreign: `findGitRoot` walks up to
522
+ // the nearest existing ancestor, so `git -C $OTHER_REPO commit`
523
+ // resolved to the workspace's own root and read as the same repo.
524
+ const foreignRepo = hasUnresolvableVariable(invocation.repoDir)
525
+ || (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && !isSameGitRepo(invocation.repoDir, currentRoot));
526
+ if (foreignRepo && isGitTreeWriteText(normalizedInvocation)) {
478
527
  logPolicyBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
479
528
  return block(
480
529
  "boundary",
@@ -521,13 +570,25 @@ export async function guardToolCallImpl(
521
570
  logPolicyBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
522
571
  return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
523
572
  }
524
- for (const segment of command.split(/[\n|;&]+/)) {
573
+ // Mask from `raw`, not the normalized copy: `normalize` folds newlines
574
+ // into spaces, which erases the heredoc shape the mask needs to find.
575
+ for (const segment of maskDataHeredocBodies(raw).split(/[\n|;&]+/)) {
525
576
  const secretFile = secretFileReadIn(segment.trim());
526
577
  if (secretFile) {
527
578
  logPolicyBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
528
579
  return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`);
529
580
  }
530
581
  }
582
+ // A nested payload changes a tree this session may not judge when it
583
+ // changes directory itself, or names a foreign `cwd`. The outer shell
584
+ // sees one word, so the top-level walk above cannot see it. Plan from
585
+ // `raw` for the same reason the top-level walk does: a heredoc body's
586
+ // own lines are real commands to the shell that consumes it.
587
+ const nestedTreeReason = nestedPayloadMutationReason(extractInterpreterPayload, writePathsInPayload, raw, shellRoot, currentRoot);
588
+ if (nestedTreeReason) {
589
+ logPolicyBlock(`[workflow-guard] blocked nested interpreter mutation outside workspace: ${command.slice(0, 120)}`);
590
+ return block("boundary", "workspace_escape", nestedTreeReason);
591
+ }
531
592
  for (const payload of extractInterpreterPayload(raw)) {
532
593
  const outsidePath = outsideWritePathInPayload(payload, currentRoot);
533
594
  if (outsidePath) {
@@ -560,7 +621,13 @@ export async function guardToolCallImpl(
560
621
  const secretPath = secretPathInPayload(payload);
561
622
  if (secretPath) return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`);
562
623
  }
563
- const shellMutationReason = await guardShellMutation(command, context?.sessionID, !context?.simulate);
624
+ // `eval` runs its body in the current shell, so a `cd` inside it moves the
625
+ // directory of later segments. Another launcher's payload runs in a child
626
+ // process and cannot move this shell, so only `eval` is forwarded. The
627
+ // predicate lives in the shell walk so both call sites agree on which
628
+ // `eval` is a command.
629
+ const leadingEvalBody = (s: string): string | undefined => (isLeadingEvalSegment(s) ? extractInterpreterPayload(s)[0] : undefined);
630
+ const shellMutationReason = await guardShellMutation(raw, context?.sessionID, !context?.simulate, shellRoot, leadingEvalBody);
564
631
  if (shellMutationReason) {
565
632
  logPolicyBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
566
633
  return block("boundary", "shell_mutation", shellMutationReason);
@@ -604,15 +671,40 @@ export async function guardToolCallImpl(
604
671
  }
605
672
  }
606
673
  if (hasPrCreateInvocation(raw)) {
607
- const prRoot = shellRoot;
674
+ // The PR is about a BRANCH, not about whatever the shell's checkout
675
+ // happens to have checked out. Resolve the branch the command names
676
+ // (`gh --head`, `az --source-branch`) and then the checkout that
677
+ // holds it: a linked worktree is the normal case, because the
678
+ // orchestrator session may sit in the primary checkout while the
679
+ // reviewed branch lives in an isolated worktree. Every preflight
680
+ // check then judges that tree instead of the caller's.
681
+ const prSegment = splitShellSegments(raw).find((segment) => hasPrCreateInvocation(segment));
682
+ // Quote-aware on the raw segment: `unwrapShellCommand` joins the
683
+ // words back without their quotes, so re-parsing the joined text
684
+ // read a `--head`-looking token inside a quoted `--body` as the
685
+ // source branch.
686
+ const sourceBranch = prSegment ? prSourceBranch(prSegment) : undefined;
687
+ let prRoot = shellRoot;
688
+ let branch = currentGitBranch(shellRoot);
689
+ let missingSourceCheckout: string | undefined;
690
+ if (sourceBranch) {
691
+ const holder = findWorktreeForBranch(shellRoot, sourceBranch) ?? findWorktreeForBranch(currentRoot, sourceBranch);
692
+ prRoot = holder ?? shellRoot;
693
+ branch = sourceBranch;
694
+ // Review and merge evidence bind to the CONTENT of a checkout.
695
+ // Substituting the caller's checkout would let an approval of one
696
+ // tree satisfy a PR that publishes another, so a named branch no
697
+ // local checkout holds is refused instead.
698
+ if (!holder) missingSourceCheckout = sourceBranch;
699
+ }
608
700
  const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
609
701
  const prTool = isAz ? "az repos pr create" : "gh pr create";
610
702
  const descFlag = isAz ? "--description" : "--body";
611
703
  const preflightFailures: string[] = [];
704
+ if (missingSourceCheckout) preflightFailures.push(`Source branch '${missingSourceCheckout}' is not checked out in any worktree of this repository, so review and merge evidence cannot be bound to the content this PR would publish. Check that branch out (a linked worktree is fine) and re-run, or omit the source-branch flag to judge the current checkout.`);
612
705
  if (prBodyHasLiteralLineBreakEscapes(raw)) preflightFailures.push(`PR description contains literal \\n/\\r escapes that will render as text; use real newlines, ANSI-C quoting ($'...\\n...'), or a body/description file instead.`);
613
- const conflictCheck = checkMergeConflicts(prRoot);
706
+ const conflictCheck = checkMergeConflicts(prRoot, branch);
614
707
  if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
615
- const branch = currentGitBranch(prRoot);
616
708
  if (branch) {
617
709
  if (context?.simulate) remotePrStateUnchecked = true;
618
710
  const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch, !context?.simulate);
@@ -686,6 +778,35 @@ export async function guardToolCallImpl(
686
778
  }
687
779
  }
688
780
  if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
781
+ // Dogfood gate (pre-deployment evidence), off by default. The
782
+ // evaluation lives in lib/dogfood.ts so preflight and guard_status
783
+ // cannot disagree about whether the gate is satisfied.
784
+ const dogfoodGate = evaluateDogfoodGate(prRoot);
785
+ if (dogfoodGate.matched && !context?.simulate) {
786
+ audit({
787
+ ts: new Date().toISOString(),
788
+ sessionID: context?.sessionID,
789
+ tool: "pr-preflight.dogfood-binding",
790
+ decision: "allow",
791
+ phase: "event",
792
+ reason: "dogfood_evidence_matched",
793
+ evidence: {
794
+ evidenceId: dogfoodGate.evidenceId,
795
+ dogfoodScenarioId: dogfoodGate.matched.scenarioId,
796
+ dogfoodHost: dogfoodGate.matched.host.label,
797
+ // Journal every host the matrix required, not just the
798
+ // newest one. A single-host entry would understate a
799
+ // multi-host pass and mislead an auditor later.
800
+ dogfoodCoveredHosts: (dogfoodGate.matchedRecords ?? []).map((record) => record.host.label),
801
+ binding: {
802
+ workspace: projectRootKey(prRoot),
803
+ commitHash: getCurrentGitCommitHash(prRoot),
804
+ worktreeFingerprint: getTrackedWorktreeFingerprint(prRoot)?.slice(0, 16),
805
+ },
806
+ },
807
+ });
808
+ }
809
+ if (dogfoodGate.missingHosts.length > 0 && dogfoodGate.reason) preflightFailures.push(dogfoodGate.reason);
689
810
  const branchChangelog = branchHasChangelogChange(prRoot);
690
811
  let shellCwdKnown = true;
691
812
  const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
@@ -29,6 +29,9 @@
29
29
 
30
30
  import { guardToolCallImpl } from "./guard-dispatcher.ts";
31
31
  import { getSessionBoundWorkspace, runWithRuntimeState } from "./state.ts";
32
+ import { GIT_BRANCH_CREATE_RE, GIT_WRITE_RE, PUSH_TO_MAIN_RE, parseGitInvocation } from "../policies/git.ts";
33
+ import { hasPrCreateInvocation } from "../policies/changelog.ts";
34
+ import { splitShellSegments } from "./shell.ts";
32
35
  import { mcpMutationTool } from "../policies/mcp.ts";
33
36
  import type { PolicyDecision } from "./types.ts";
34
37
 
@@ -61,6 +64,55 @@ export interface PermissionSeatResult {
61
64
 
62
65
  const EDIT_ACTIONS = new Set(["edit", "write", "patch", "apply_patch"]);
63
66
 
67
+ /**
68
+ * Commands whose verdict depends on WHICH checkout they run in.
69
+ *
70
+ * A bare `git push`, `git commit`, or branch creation is judged against the
71
+ * repository the shell's working directory resolves to, and PR preflight is
72
+ * judged against the branch the PR publishes. This seat sees normalized
73
+ * `action`/`resources` only, never the tool's `workdir`, so for those commands
74
+ * the repository is an assumption rather than a fact: the session-bound
75
+ * checkout may be the primary tree while the command targets a linked
76
+ * worktree. Deciding on the assumption denied legitimate pushes and PRs, so
77
+ * the seat defers them to `execute.before`, which sees the raw arguments.
78
+ *
79
+ * Two cases stay decisive:
80
+ *
81
+ * - An explicit `-C`/`--git-dir`/`--work-tree`/`GIT_DIR` names the repository
82
+ * in the command text, so it is a fact the seat can judge.
83
+ * - A push whose destination is an explicitly named protected branch is a
84
+ * finding in every checkout, so no `workdir` can change the verdict.
85
+ */
86
+ function repositoryDependent(command: string): boolean {
87
+ for (const segment of splitShellSegments(command)) {
88
+ // PR creation names no repository at all: the checkout is cwd-derived,
89
+ // and a `--head`/`--source-branch` may live in another worktree.
90
+ if (hasPrCreateInvocation(segment)) return true;
91
+ const parsed = parseGitInvocation(segment);
92
+ if (!parsed) continue;
93
+ const rest = `git ${parsed.rest}`;
94
+ if (parsed.explicitRepoDir) continue;
95
+ if (PUSH_TO_MAIN_RE.test(rest)) continue;
96
+ const repositorySensitive =
97
+ /\bgit\s+push\b/.test(rest) ||
98
+ GIT_WRITE_RE.test(rest) ||
99
+ GIT_BRANCH_CREATE_RE.test(rest);
100
+ if (repositorySensitive) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ /**
106
+ * Policies whose verdict is derived from the selected checkout.
107
+ *
108
+ * A block from one of these may be an artifact of judging the wrong tree: the
109
+ * original defect was a push denied for the protected branch of the primary
110
+ * checkout while the command targeted a linked feature worktree. A block from
111
+ * any other policy (destructive, shell-safety, secrets, tamper, todo) is a
112
+ * finding about the command text itself, so no working directory can change it.
113
+ */
114
+ const CHECKOUT_DERIVED_POLICIES = new Set(["git", "pr-preflight"]);
115
+
64
116
  /**
65
117
  * Map one simulated policy decision onto the seat's effect.
66
118
  *
@@ -117,12 +169,38 @@ export async function evaluatePermissionSeat(
117
169
 
118
170
  const mapping = mappedToolFor(event.action);
119
171
  if (mapping) {
172
+ let deferred: PermissionSeatResult | undefined;
120
173
  for (const resource of resources) {
174
+ // Where the command runs is part of the decision for some commands,
175
+ // and this seat cannot see the tool's `workdir`. Both directions of a
176
+ // checkout-derived verdict are then unreliable: a block may be the
177
+ // protected branch of the wrong tree, and an allow may be a feature
178
+ // checkout that hides a push to a protected destination. Defer those
179
+ // to `execute.before`, which resolves the shell's workdir.
180
+ const cwdDependent = mapping.tool === "bash" && repositoryDependent(resource);
121
181
  const decision = await decide(mapping.tool, mapping.input(resource));
122
182
  const verdict = seatVerdict(decision, mapping.tool);
183
+ if (cwdDependent) {
184
+ // A block from any OTHER policy is a finding about the command
185
+ // text itself (`cat .env` or a destructive live command in the
186
+ // same compound command), so no working directory can change it
187
+ // and it keeps its denial. Everything else defers.
188
+ if (verdict?.effect === "deny" && !CHECKOUT_DERIVED_POLICIES.has(decision.policy ?? "")) return verdict;
189
+ deferred = {
190
+ effect: "abstain",
191
+ mappedTool: mapping.tool,
192
+ decision: {
193
+ status: "needs_approval",
194
+ policy: decision.policy ?? "git",
195
+ code: "repository_unidentified",
196
+ message: "[workflow-guard permission.evaluate] Deferred: this command's verdict depends on the directory it runs in, which the permission seat cannot see. `execute.before` resolves the shell's workdir and enforces there.",
197
+ },
198
+ };
199
+ continue;
200
+ }
123
201
  if (verdict) return verdict;
124
202
  }
125
- return { effect: "allow", mappedTool: mapping.tool };
203
+ return deferred ?? { effect: "allow", mappedTool: mapping.tool };
126
204
  }
127
205
 
128
206
  // `<server>_<tool>` MCP actions: only the guarded live-system servers are