opencode-workflow-guard 1.19.0 → 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.
@@ -9,6 +9,8 @@ import {
9
9
  import {
10
10
  unwrapShellWords,
11
11
  splitShellSegments,
12
+ shellPrefixAssignments,
13
+ resolveGitRepoSelector,
12
14
  } from "../lib/shell.ts";
13
15
 
14
16
  export const PROTECTED_BRANCHES = new Set(["main", "master"]);
@@ -45,6 +47,10 @@ const GIT_DIR_OPTION_TAKES_VALUE = new Set([
45
47
  ]);
46
48
  const GIT_DIR_OPTION_PREFIXED =
47
49
  /^(--git-dir=|--work-tree=|--namespace=|-c\S|--config-env=)/;
50
+ // Git also accepts the attached short forms (`-C/path`, `-C~`). An unparsed
51
+ // `-C` leaves the repository at the assumed base, which judges the write in the
52
+ // wrong checkout, so the attached form is read as a claim like the spaced one.
53
+ const GIT_DIR_SHORT_ATTACHED = /^-C(.+)$/;
48
54
  const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
49
55
  "--version",
50
56
  "--help",
@@ -60,44 +66,88 @@ const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
60
66
  "--exec-path",
61
67
  ]);
62
68
 
63
- const GIT_ENV_DIR_RE =
64
- /(?:^|\s)(?:env\s+(?:-\S+\s+)*)?(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR)=(?:"([^"]*)"|'([^']*)'|(\S+))/;
69
+ // Repository selection from the environment. It is read from the assignments
70
+ // that precede the command word, never from the rest of the command text. A
71
+ // bare `GIT_DIR=` used to be matched anywhere in the string, so an unquoted
72
+ // commit message such as `git commit -m GIT_DIR=/repo` claimed an explicit
73
+ // repository. That was harmless while the value only picked a base directory,
74
+ // but `explicitRepoDir` is now a fact the permission seat trusts, so the claim
75
+ // must be real. A `-c` config override is deliberately not a repository
76
+ // selector: `GIT_DIR` is an environment variable, not a git config key.
77
+ const GIT_REPO_ENV_ASSIGNMENT = /^(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR)=(.*)$/s;
65
78
 
66
- export function parseGitInvocation(command: string): GitInvocation | undefined {
67
- const root = getWorkspaceRoot();
79
+ /**
80
+ * The repository an environment assignment selects, or undefined.
81
+ *
82
+ * Only assignments that the shell unwrapper actually consumed before the
83
+ * command word count. A value that merely appears inside an argument cannot be
84
+ * mistaken for one.
85
+ */
86
+ function repoEnvironmentOverride(command: string): string | undefined {
87
+ for (const assignment of shellPrefixAssignments(command)) {
88
+ const match = GIT_REPO_ENV_ASSIGNMENT.exec(assignment);
89
+ if (match?.[1]) return match[1];
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /**
95
+ * Parse one git invocation.
96
+ *
97
+ * `baseDir` is the directory the command actually runs in: a shell call's
98
+ * `workdir`, resolved against the call's workspace by the caller. It defaults
99
+ * to the ambient workspace root for callers that only classify command text.
100
+ * Without it, `git push` inside a linked worktree was resolved against the
101
+ * session-bound checkout, so every branch-state check judged the wrong tree.
102
+ *
103
+ * `explicitRepoDir` records whether the command text named the repository
104
+ * itself. A caller that cannot see `workdir` uses that flag to tell a fact
105
+ * from an assumption.
106
+ */
107
+ export function parseGitInvocation(command: string, baseDir?: string): GitInvocation | undefined {
108
+ const root = baseDir ?? getWorkspaceRoot();
68
109
  const tokens = unwrapShellWords(command);
69
110
  if (tokens[0] !== "git") return undefined;
70
111
  let i = 1;
71
112
  let repoDir = root;
72
113
  let sawDirOption = false;
73
114
 
74
- const envDirMatch = command.match(GIT_ENV_DIR_RE);
75
- if (envDirMatch) {
76
- const envVal = envDirMatch[1] ?? envDirMatch[2] ?? envDirMatch[3];
77
- if (envVal) {
78
- repoDir = resolve(root, envVal);
79
- sawDirOption = true;
80
- }
115
+ const envDirValue = repoEnvironmentOverride(command);
116
+ if (envDirValue) {
117
+ repoDir = resolveGitRepoSelector(root, envDirValue);
118
+ sawDirOption = true;
81
119
  }
82
120
 
83
121
  while (i < tokens.length) {
84
122
  const tok = tokens[i]!;
85
123
  if (GIT_DIR_OPTION_TAKES_VALUE.has(tok)) {
86
124
  const value = tokens[i + 1];
87
- if (value === undefined) return { repoDir, rest: "" };
88
- if (tok === "-C" || tok === "--git-dir" || tok === "--work-tree") {
89
- repoDir = resolve(root, value);
125
+ const namesRepository = tok === "-C" || tok === "--git-dir" || tok === "--work-tree";
126
+ // A trailing `-C` with no value names no repository, but the flag is
127
+ // still present in the text. Report it as a claim so both seats agree
128
+ // the command is unclassifiable rather than one judging it and the
129
+ // other deferring it. `-c` and `--config-env` name no repository.
130
+ if (value === undefined) return { repoDir, rest: "", explicitRepoDir: sawDirOption || namesRepository };
131
+ if (namesRepository) {
132
+ repoDir = resolveGitRepoSelector(root, value);
90
133
  sawDirOption = true;
91
134
  }
92
135
  i += 2;
93
136
  continue;
94
137
  }
138
+ const attached = GIT_DIR_SHORT_ATTACHED.exec(tok);
139
+ if (attached) {
140
+ repoDir = resolveGitRepoSelector(root, attached[1]!);
141
+ sawDirOption = true;
142
+ i += 1;
143
+ continue;
144
+ }
95
145
  if (GIT_DIR_OPTION_PREFIXED.test(tok)) {
96
146
  if (tok.startsWith("--git-dir=")) {
97
- repoDir = resolve(root, tok.slice("--git-dir=".length));
147
+ repoDir = resolveGitRepoSelector(root, tok.slice("--git-dir=".length));
98
148
  sawDirOption = true;
99
149
  } else if (tok.startsWith("--work-tree=")) {
100
- repoDir = resolve(root, tok.slice("--work-tree=".length));
150
+ repoDir = resolveGitRepoSelector(root, tok.slice("--work-tree=".length));
101
151
  sawDirOption = true;
102
152
  }
103
153
  i += 1;
@@ -109,8 +159,7 @@ export function parseGitInvocation(command: string): GitInvocation | undefined {
109
159
  }
110
160
  break;
111
161
  }
112
- if (!sawDirOption) repoDir = root;
113
- return { repoDir, rest: tokens.slice(i).join(" ") };
162
+ return { repoDir, rest: tokens.slice(i).join(" "), explicitRepoDir: sawDirOption };
114
163
  }
115
164
 
116
165
  export function normalizeGitCommands(command: string): string {
@@ -125,6 +174,17 @@ export function normalizeGitCommands(command: string): string {
125
174
  export const GIT_WRITE_RE =
126
175
  /\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)(?:[^|;&]*\s)?(?:-d\b|--delete\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
127
176
 
177
+ /**
178
+ * True when `text` invokes git in a way that changes a tree's refs or content.
179
+ *
180
+ * One predicate for every caller: the shell segment walk, the nested payload
181
+ * check, and the foreign-workdir gate. Keeping copies is how the payload check
182
+ * drifted wider than the segment check once already.
183
+ */
184
+ export function isGitTreeWriteText(text: string): boolean {
185
+ return GIT_WRITE_RE.test(text) || GIT_BRANCH_CREATE_RE.test(text) || /\bgit\s+push\b/.test(text);
186
+ }
187
+
128
188
  export function currentGitBranch(root: string): string | undefined {
129
189
  const result = spawnSync("git", ["branch", "--show-current"], {
130
190
  cwd: root,
@@ -304,14 +364,76 @@ export function isBranchAlreadyMergedOrClosed(
304
364
  return { merged: false };
305
365
  }
306
366
 
307
- export function checkMergeConflicts(root: string): {
367
+ /**
368
+ * The source branch a PR-creation command names, if any.
369
+ *
370
+ * `gh` names it with `--head`/`-H`, and Azure DevOps with `--source-branch`.
371
+ * Fork forms carry an `owner:` prefix; a ref cannot contain `:`, so the last
372
+ * segment is the local branch name the caller looks up. Callers fall back to
373
+ * the invoking checkout when the branch is not checked out anywhere locally.
374
+ */
375
+ export function prSourceBranch(command: string): string | undefined {
376
+ const words = unwrapShellWords(command);
377
+ let source: string | undefined;
378
+ for (let i = 0; i < words.length; i++) {
379
+ const word = words[i]!;
380
+ let raw: string | undefined;
381
+ if (word === "--head" || word === "-H" || word === "--source-branch") {
382
+ raw = words[i + 1];
383
+ } else if (/^--(?:head|source-branch)=/.test(word)) {
384
+ raw = word.slice(word.indexOf("=") + 1);
385
+ } else if (/^-H\S/.test(word)) {
386
+ raw = word.slice(2);
387
+ }
388
+ if (raw === undefined) continue;
389
+ const branch = raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw;
390
+ if (branch) source = branch;
391
+ }
392
+ return source;
393
+ }
394
+
395
+ /**
396
+ * Which checkout of this repository has `branch` checked out, if any.
397
+ *
398
+ * A PR is evaluated against the tree the branch actually lives in, not against
399
+ * whatever the caller's checkout happens to hold. Linked worktrees are the
400
+ * normal case: a session may sit in the primary checkout while the reviewed
401
+ * branch is checked out in an isolated worktree.
402
+ */
403
+ export function findWorktreeForBranch(root: string, branch: string): string | undefined {
404
+ const target = branch.replace(/^refs\/heads\//, "").trim();
405
+ if (!target) return undefined;
406
+ const res = spawnSync("git", ["worktree", "list", "--porcelain"], {
407
+ cwd: root,
408
+ encoding: "utf8",
409
+ timeout: 10_000,
410
+ });
411
+ if (res.status !== 0) return undefined;
412
+ let current: string | undefined;
413
+ for (const line of res.stdout.split("\n")) {
414
+ if (line.startsWith("worktree ")) {
415
+ current = line.slice("worktree ".length).trim();
416
+ continue;
417
+ }
418
+ if (line.startsWith("branch ") && current) {
419
+ if (line.slice("branch ".length).trim().replace(/^refs\/heads\//, "") === target) return current;
420
+ }
421
+ }
422
+ return undefined;
423
+ }
424
+
425
+ export function checkMergeConflicts(root: string, ref?: string): {
308
426
  hasConflicts: boolean;
309
427
  baseBranch?: string;
310
428
  reason?: string;
311
429
  } {
430
+ // A branch that is not checked out anywhere still has to be judged; the
431
+ // caller passes its ref, and HEAD stands in only when no ref was named.
432
+ const subject = typeof ref === "string" && ref.trim() ? ref.trim() : "HEAD";
433
+ const subjectLabel = subject === "HEAD" ? "" : `Branch '${subject}'`;
312
434
  const candidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
313
435
  for (const base of candidates) {
314
- const mergeBaseRes = spawnSync("git", ["merge-base", "HEAD", base], {
436
+ const mergeBaseRes = spawnSync("git", ["merge-base", subject, base], {
315
437
  cwd: root,
316
438
  encoding: "utf8",
317
439
  timeout: 5_000,
@@ -319,7 +441,7 @@ export function checkMergeConflicts(root: string): {
319
441
  if (mergeBaseRes.status !== 0 || !mergeBaseRes.stdout.trim()) continue;
320
442
  const mergeBase = mergeBaseRes.stdout.trim();
321
443
 
322
- const treeRes = spawnSync("git", ["merge-tree", mergeBase, "HEAD", base], {
444
+ const treeRes = spawnSync("git", ["merge-tree", mergeBase, subject, base], {
323
445
  cwd: root,
324
446
  encoding: "utf8",
325
447
  timeout: 10_000,
@@ -328,7 +450,7 @@ export function checkMergeConflicts(root: string): {
328
450
  return {
329
451
  hasConflicts: true,
330
452
  baseBranch: base,
331
- reason: `Branch has merge conflicts with base branch '${base}'. Rebase or merge '${base}' to resolve all conflicts before opening a PR or handing off.`,
453
+ reason: `${subjectLabel || "Branch"} has merge conflicts with base branch '${base}'. Rebase or merge '${base}' to resolve all conflicts before opening a PR or handing off.`,
332
454
  };
333
455
  }
334
456
  }
@@ -1,25 +1,102 @@
1
1
  import { isSecretPath } from "./secrets.ts";
2
2
  import { isPathOutsideWorkspace } from "./boundary.ts";
3
+ import { heredocBodyIsCode, heredocRanges, maskDataHeredocBodies } from "../lib/shell.ts";
4
+
5
+ // `eval` runs its argument as shell text, so its payload is scanned like an
6
+ // inline interpreter body. It is the one launcher with no option word, so the
7
+ // quoted body follows the command directly.
8
+ const LAUNCHER_RE = /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce])\s*|\beval\s+/i;
9
+
10
+ /**
11
+ * The quoted shell word that follows an inline interpreter launcher.
12
+ *
13
+ * A shell word is one argument however many quoted spans it concatenates, so
14
+ * `'bash -c '\''cd /other && rm -rf x'\'''` is a single payload. Stopping at
15
+ * the first closing quote truncated that word to `bash -c `, and the nested
16
+ * command escaped the payload scan entirely.
17
+ */
18
+ function inlinePayloadWord(segment: string, start: number): string | undefined {
19
+ let out = "";
20
+ let i = start;
21
+ while (i < segment.length) {
22
+ const char = segment[i]!;
23
+ if (char === "'") {
24
+ i += 1;
25
+ let closed = false;
26
+ while (i < segment.length) {
27
+ const inner = segment[i]!;
28
+ // `'\''` is the idiom for a literal quote inside a single-quoted
29
+ // span: the span closes, an escaped quote follows, a new span opens.
30
+ if (inner === "'" && segment[i + 1] === "\\" && segment[i + 2] === "'" && segment[i + 3] === "'") {
31
+ out += "'";
32
+ i += 4;
33
+ continue;
34
+ }
35
+ if (inner === "'") {
36
+ closed = true;
37
+ i += 1;
38
+ break;
39
+ }
40
+ out += inner;
41
+ i += 1;
42
+ }
43
+ if (!closed) return undefined;
44
+ continue;
45
+ }
46
+ if (char === '"') {
47
+ i += 1;
48
+ let closed = false;
49
+ while (i < segment.length) {
50
+ const inner = segment[i]!;
51
+ if (inner === "\\" && segment[i + 1] !== undefined) {
52
+ out += segment[i + 1];
53
+ i += 2;
54
+ continue;
55
+ }
56
+ if (inner === '"') {
57
+ closed = true;
58
+ i += 1;
59
+ break;
60
+ }
61
+ out += inner;
62
+ i += 1;
63
+ }
64
+ if (!closed) return undefined;
65
+ continue;
66
+ }
67
+ // Unquoted text ends the word at whitespace or a shell operator.
68
+ if (/\s/.test(char) || "|&;()<>".includes(char)) break;
69
+ if (char === "\\" && segment[i + 1] !== undefined) {
70
+ out += segment[i + 1];
71
+ i += 2;
72
+ continue;
73
+ }
74
+ out += char;
75
+ i += 1;
76
+ }
77
+ return out === "" ? undefined : out;
78
+ }
3
79
 
4
80
  export function extractInterpreterPayload(segment: string): string[] {
5
81
  const payloads: string[] = [];
6
- const inlineMatch = segment.match(
7
- /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce])\s*(?:"([^"]*)"|'([^']*)')/i,
8
- );
9
- if (inlineMatch?.[1] || inlineMatch?.[2]) {
10
- payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
82
+ // A heredoc body is prose unless the command reading it executes it, so the
83
+ // launcher scan runs on the masked text instead of reading documentation as
84
+ // code (`bash -c 'cd /other && rm -rf x'` quoted inside a `cat` body).
85
+ const masked = maskDataHeredocBodies(segment);
86
+ const launcher = LAUNCHER_RE.exec(masked);
87
+ if (launcher) {
88
+ const payload = inlinePayloadWord(segment, (launcher.index ?? 0) + launcher[0].length);
89
+ if (payload !== undefined) payloads.push(payload);
11
90
  }
12
- const heredocHeader = segment.match(
13
- /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\b[^\n]*<<(-?)\s*(?:'([^'\n]+)'|"([^"\n]+)"|([^\s'";|&()<>\n]+))[^\n]*\n/i,
14
- );
15
- if (heredocHeader) {
16
- const delimiter = heredocHeader[2] ?? heredocHeader[3] ?? heredocHeader[4];
17
- const bodyStart = (heredocHeader.index ?? 0) + heredocHeader[0].length;
18
- const lines = segment.slice(bodyStart).split("\n");
19
- const terminator = lines.findIndex((line) => (heredocHeader[1] ? line.replace(/^\t+/, "") : line) === delimiter);
20
- if (terminator >= 0) payloads.push(lines.slice(0, terminator).join("\n"));
91
+ // A heredoc body is code when the command that reads it executes it. An
92
+ // unterminated body still runs, so it is scanned to end of input. The
93
+ // terminator's own newline is not part of the body.
94
+ for (const range of heredocRanges(segment)) {
95
+ if (!heredocBodyIsCode(segment, range.start)) continue;
96
+ const body = segment.slice(range.bodyStart, range.bodyEnd).replace(/\n$/, "");
97
+ if (body !== "") payloads.push(body);
21
98
  }
22
- const psMatch = segment.match(
99
+ const psMatch = masked.match(
23
100
  /\b(?:powershell|pwsh)\s+(?:-[a-zA-Z]*enc[a-zA-Z]*\s+)([A-Za-z0-9+/=]+)/i,
24
101
  );
25
102
  if (psMatch?.[1]) {
@@ -28,7 +105,7 @@ export function extractInterpreterPayload(segment: string): string[] {
28
105
  payloads.push(buf.toString("utf8"), buf.toString("utf16le"));
29
106
  } catch {}
30
107
  }
31
- const b64PipeMatch = segment.match(
108
+ const b64PipeMatch = masked.match(
32
109
  /echo\s+["']?([A-Za-z0-9+/=]{4,})["']?\s*\|\s*base64\s+(?:-[a-zA-Z]*d[a-zA-Z]*|--decode)\s*\|\s*(?:bash|sh|zsh)/i,
33
110
  );
34
111
  if (b64PipeMatch?.[1]) {
@@ -17,7 +17,7 @@ import { join } from "node:path";
17
17
  import { loadedPluginVersion } from "./lib/version.ts";
18
18
  import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "./policies/post-edit-validation.ts";
19
19
  import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
20
- import { extractCommands, splitShellSegments } from "./lib/shell.ts";
20
+ import { extractCommands, planShellSegments } from "./lib/shell.ts";
21
21
  import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "./policies/boundary.ts";
22
22
  import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
23
23
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
@@ -101,6 +101,8 @@ import {
101
101
  setSessionWorkspace,
102
102
  getSessionWorkspace,
103
103
  getSessionBoundWorkspace,
104
+ rebindSessionWorkspaceFromHost,
105
+ forgetSessionWorkspace,
104
106
  registerSessionWorkspaceFromToolCall,
105
107
  resolveInheritedWorkspace,
106
108
  resolveSessionBoundWorkspace,
@@ -148,6 +150,8 @@ export {
148
150
  setSessionWorkspace,
149
151
  getSessionWorkspace,
150
152
  getSessionBoundWorkspace,
153
+ rebindSessionWorkspaceFromHost,
154
+ forgetSessionWorkspace,
151
155
  registerSessionWorkspaceFromToolCall,
152
156
  resolveInheritedWorkspace,
153
157
  resolveSessionBoundWorkspace,
@@ -251,6 +255,7 @@ export {
251
255
  getGitWorktreeFingerprint,
252
256
  getTrackedWorktreeFingerprint,
253
257
  };
258
+ export type { VerifyRun } from "./lib/verify.ts";
254
259
 
255
260
  // ── Secondary review rubric ──────────────────────────────────────────────────
256
261
  import { buildReviewRubric } from "./lib/review.ts";
@@ -309,6 +314,9 @@ import {
309
314
  isBranchAlreadyMergedOrClosed,
310
315
  checkMergeConflicts,
311
316
  checkBranchBaseIsUpToDate,
317
+ findWorktreeForBranch,
318
+ parseGitInvocation,
319
+ prSourceBranch,
312
320
  } from "./policies/git.ts";
313
321
 
314
322
  export {
@@ -316,6 +324,9 @@ export {
316
324
  isBranchAlreadyMergedOrClosed,
317
325
  checkMergeConflicts,
318
326
  checkBranchBaseIsUpToDate,
327
+ findWorktreeForBranch,
328
+ parseGitInvocation,
329
+ prSourceBranch,
319
330
  };
320
331
 
321
332
  import {
@@ -596,13 +607,13 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
596
607
  const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
597
608
  const snapshots: FileSnapshot[] = [];
598
609
  for (const command of extractCommands(args)) {
599
- for (const segment of splitShellSegments(command)) {
610
+ for (const { segment, root } of planShellSegments(command, shellCwd)) {
600
611
  for (const mutation of redirectMutationsIn(segment)) {
601
612
  const target = mutation.target ?? "";
602
613
  if (!target || target === "/dev/null") continue;
603
614
  const expanded = expandShellTargetPath(target);
604
615
  if (!expanded) continue;
605
- const resolvedTarget = resolve(shellCwd, expanded);
616
+ const resolvedTarget = resolve(root ?? shellCwd, expanded);
606
617
  if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
607
618
  if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
608
619
  snapshots.push(snapshotFile(resolvedTarget));
@@ -943,6 +954,18 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
943
954
  await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID, settleTitle));
944
955
  }
945
956
  }
957
+ if (event?.type === "session.updated") {
958
+ // `session_move` relocates a session between checkouts, and the
959
+ // host re-emits `session.updated` for the moved session. Its
960
+ // `directory` is the authoritative post-move location; without
961
+ // this rebind the session kept judging the tree it left. The
962
+ // rebind only takes a genuine move, so a title or cost update on
963
+ // a session bound to a linked worktree cannot drag it back.
964
+ const info = (event.properties as { info?: { id?: unknown; directory?: unknown } })?.info;
965
+ if (typeof info?.id === "string" && typeof info.directory === "string") {
966
+ rebindSessionWorkspaceFromHost(info.id, info.directory);
967
+ }
968
+ }
946
969
  if (event?.type === "message.updated") {
947
970
  const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown; error?: { name?: unknown } } })?.info;
948
971
  if (info?.role === "user" && typeof info.sessionID === "string") {
@@ -972,6 +995,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
972
995
  toolLifecycle.takeRecoveryRun(sessionID);
973
996
  clearLiveDocsFloor(sessionID);
974
997
  await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
998
+ forgetSessionWorkspace(sessionID);
975
999
  }
976
1000
  }
977
1001
  if (
package/test/test-e2e.mts CHANGED
@@ -453,10 +453,13 @@ if (isOpenCodeV2) {
453
453
  headlessAccountability?.seat?.deny?.decision?.policy === "secrets" &&
454
454
  headlessAccountability?.seat?.deny?.decision?.code === "secret_read",
455
455
  );
456
+ // A bare `git push` names no repository. Its checkout comes from the tool's
457
+ // workdir, which the seat cannot see, so the seat defers it as
458
+ // cwd-dependent and `execute.before` enforces with the resolved workdir.
456
459
  check(
457
460
  "V2 permission seat abstains on an undecidable push instead of denying it",
458
461
  headlessAccountability?.seat?.push?.effect === "abstain" &&
459
- headlessAccountability?.seat?.push?.decision?.code === "remote_state_unchecked",
462
+ headlessAccountability?.seat?.push?.decision?.code === "repository_unidentified",
460
463
  );
461
464
  }
462
465