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.
@@ -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,15 +17,15 @@ 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";
24
24
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
25
- import { guardToolCallImpl, isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
25
+ import { isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
26
26
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
27
27
  import { evaluateDogfoodGate } from "./lib/dogfood.ts";
28
- import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
28
+ import type { TodoSdkClient } from "./lib/types.ts";
29
29
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
30
30
  export { extractReviewFollowups, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
31
31
  export { evaluateDogfoodGate } from "./lib/dogfood.ts";
@@ -59,7 +59,6 @@ import {
59
59
  setSdkClient,
60
60
  getSdkClient,
61
61
  runWithRuntimeState,
62
- lastMutationTimestamp,
63
62
  getLastMutationTimestamp,
64
63
  getMutationCount,
65
64
  getSessionMutationCount,
@@ -101,6 +100,8 @@ import {
101
100
  setSessionWorkspace,
102
101
  getSessionWorkspace,
103
102
  getSessionBoundWorkspace,
103
+ rebindSessionWorkspaceFromHost,
104
+ forgetSessionWorkspace,
104
105
  registerSessionWorkspaceFromToolCall,
105
106
  resolveInheritedWorkspace,
106
107
  resolveSessionBoundWorkspace,
@@ -148,6 +149,8 @@ export {
148
149
  setSessionWorkspace,
149
150
  getSessionWorkspace,
150
151
  getSessionBoundWorkspace,
152
+ rebindSessionWorkspaceFromHost,
153
+ forgetSessionWorkspace,
151
154
  registerSessionWorkspaceFromToolCall,
152
155
  resolveInheritedWorkspace,
153
156
  resolveSessionBoundWorkspace,
@@ -159,7 +162,6 @@ export {
159
162
  // ── Shared shell/env utilities ───────────────────────────────────────────────
160
163
  import {
161
164
  asRecord,
162
- extractRecordTargetPath,
163
165
  getCleanEnv,
164
166
  showBlockToast,
165
167
  isSensitiveEnvKey,
@@ -208,13 +210,19 @@ import {
208
210
  persistReviewCache,
209
211
  loadReviewCache,
210
212
  audit,
211
- logDecision,
212
213
  summarizeInput,
213
214
  } from "./lib/audit.ts";
214
215
 
215
216
  export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getDogfoodCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, persistDogfoodCache, loadDogfoodCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
216
217
  export { summarizeInput };
217
218
 
219
+ // The guard entry surface lives in a leaf module so the V2 entrypoint can
220
+ // import it without pulling in this V1 file (and its SDK subtree) through a
221
+ // cycle. Re-exported here because the test harness imports this public surface.
222
+ export { guardToolCall, guardToolDecision, buildCompactionContext } from "./lib/guard-api.ts";
223
+ import { guardToolCall, buildCompactionContext } from "./lib/guard-api.ts";
224
+ import { TODOWRITE_SUFFIX, TODOWRITE_MARKER, EDIT_SUFFIX, EDIT_MARKER, SUBAGENT_SUFFIX, SUBAGENT_MARKER, appendToolDescriptionSuffix } from "./lib/tool-descriptions.ts";
225
+
218
226
  export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
219
227
  const directory = platform === "darwin"
220
228
  ? "/Library/Application Support/opencode"
@@ -251,6 +259,7 @@ export {
251
259
  getGitWorktreeFingerprint,
252
260
  getTrackedWorktreeFingerprint,
253
261
  };
262
+ export type { VerifyRun } from "./lib/verify.ts";
254
263
 
255
264
  // ── Secondary review rubric ──────────────────────────────────────────────────
256
265
  import { buildReviewRubric } from "./lib/review.ts";
@@ -309,6 +318,9 @@ import {
309
318
  isBranchAlreadyMergedOrClosed,
310
319
  checkMergeConflicts,
311
320
  checkBranchBaseIsUpToDate,
321
+ findWorktreeForBranch,
322
+ parseGitInvocation,
323
+ prSourceBranch,
312
324
  } from "./policies/git.ts";
313
325
 
314
326
  export {
@@ -316,6 +328,9 @@ export {
316
328
  isBranchAlreadyMergedOrClosed,
317
329
  checkMergeConflicts,
318
330
  checkBranchBaseIsUpToDate,
331
+ findWorktreeForBranch,
332
+ parseGitInvocation,
333
+ prSourceBranch,
319
334
  };
320
335
 
321
336
  import {
@@ -387,69 +402,10 @@ function logWarn(client: unknown, message: string): void {
387
402
  } catch {}
388
403
  }
389
404
 
390
- /**
391
- * Public guard entry point. Audits every decision (block or allow) to a
392
- * durable JSONL file before returning.
393
- */
394
- export async function guardToolDecision(
395
- toolName: string,
396
- input: unknown,
397
- context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
398
- ): Promise<PolicyDecision> {
399
- const customRoot = context?.worktree || context?.directory;
400
- const runImpl = () => guardToolCallImpl(toolName, input, context);
401
- return customRoot
402
- ? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
403
- : await runImpl();
404
- }
405
-
406
- export async function guardToolCall(
407
- toolName: string,
408
- input: unknown,
409
- context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
410
- ): Promise<string | undefined> {
411
- const decision = await guardToolDecision(toolName, input, context);
412
- const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
413
- const isMutation = EDIT_TOOL_NAMES.has(toolName);
414
- const targetPath = extractRecordTargetPath(input);
415
- logDecision(toolName, input, context, decision, {
416
- mutation: isMutation,
417
- targetPath,
418
- allowLive,
419
- verification: lastVerify
420
- ? {
421
- command: lastVerify.command,
422
- passed: lastVerify.passed,
423
- fresh: lastVerify.timestamp >= lastMutationTimestamp,
424
- durationMs: lastVerify.durationMs,
425
- }
426
- : undefined,
427
- });
428
- return decision.status === "allowed" ? undefined : decision.message;
429
- }
430
-
431
405
  async function emitBlockFeedback(message: string): Promise<void> {
432
406
  await showBlockToast(message);
433
407
  }
434
408
 
435
- export function buildCompactionContext(operationalState: string, priorityBlocks: string[], maxChars = 8_000): string {
436
- if (!Number.isFinite(maxChars) || maxChars < 0 || !Number.isInteger(maxChars)) {
437
- throw new RangeError("maxChars must be a non-negative finite integer");
438
- }
439
- const truncate = (value: string, limit: number): string => {
440
- let end = Math.min(value.length, limit);
441
- if (end > 0 && end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
442
- return value.slice(0, end);
443
- };
444
- let context = truncate(operationalState, maxChars);
445
- for (const block of priorityBlocks) {
446
- const remaining = maxChars - context.length - 2;
447
- if (remaining <= 0) break;
448
- context += `\n\n${truncate(block, remaining)}`;
449
- }
450
- return context;
451
- }
452
-
453
409
  export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
454
410
  // Honor worktree if present (e.g. opencode worktrees or devcontainers)
455
411
  // so worktree plugins cannot punch through boundary gates. When the host
@@ -596,13 +552,13 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
596
552
  const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
597
553
  const snapshots: FileSnapshot[] = [];
598
554
  for (const command of extractCommands(args)) {
599
- for (const segment of splitShellSegments(command)) {
555
+ for (const { segment, root } of planShellSegments(command, shellCwd)) {
600
556
  for (const mutation of redirectMutationsIn(segment)) {
601
557
  const target = mutation.target ?? "";
602
558
  if (!target || target === "/dev/null") continue;
603
559
  const expanded = expandShellTargetPath(target);
604
560
  if (!expanded) continue;
605
- const resolvedTarget = resolve(shellCwd, expanded);
561
+ const resolvedTarget = resolve(root ?? shellCwd, expanded);
606
562
  if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
607
563
  if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
608
564
  snapshots.push(snapshotFile(resolvedTarget));
@@ -828,28 +784,21 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
828
784
  // Similarly, mutating tools reflect their active-todo, feature-branch, and prior-read requirements.
829
785
  // Subagent orchestration tools reflect worktree isolation and review expectations.
830
786
  "tool.definition": async (input, output) => {
831
- if (input.toolID === "todowrite") {
787
+ const append = (suffix: string, marker: string) => {
832
788
  const description = typeof output.description === "string" ? output.description : "";
833
- if (description.includes("verification evidence")) return;
834
- output.description =
835
- description +
836
- "\n\nWorkflow Guard lifecycle: use todowrite to plan before working - it is the only view of progress the operator has, and every file mutation is gated on it. Each call replaces the complete task list: preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled, and do not omit active tasks when adding new work. A task may be marked completed only after its own work is recorded: the guard blocks a completion that has no file mutation in that task's active window, and one mutation cannot vouch for several completions. If a task is genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text; every such use is journaled. Use `cancelled` for work that was deliberately dropped. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
789
+ const next = appendToolDescriptionSuffix(description, suffix, marker);
790
+ if (next !== undefined) output.description = next;
791
+ };
792
+ if (input.toolID === "todowrite") {
793
+ append(TODOWRITE_SUFFIX, TODOWRITE_MARKER);
837
794
  return;
838
795
  }
839
796
  if (EDIT_TOOL_NAMES.has(input.toolID)) {
840
- const description = typeof output.description === "string" ? output.description : "";
841
- if (description.includes("Workflow Guard requirement")) return;
842
- output.description =
843
- description +
844
- "\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
797
+ append(EDIT_SUFFIX, EDIT_MARKER);
845
798
  return;
846
799
  }
847
800
  if (input.toolID === "task") {
848
- const description = typeof output.description === "string" ? output.description : "";
849
- if (description.includes("Workflow Guard subagent guidance")) return;
850
- output.description =
851
- description +
852
- "\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
801
+ append(SUBAGENT_SUFFIX, SUBAGENT_MARKER);
853
802
  return;
854
803
  }
855
804
  },
@@ -943,6 +892,18 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
943
892
  await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID, settleTitle));
944
893
  }
945
894
  }
895
+ if (event?.type === "session.updated") {
896
+ // `session_move` relocates a session between checkouts, and the
897
+ // host re-emits `session.updated` for the moved session. Its
898
+ // `directory` is the authoritative post-move location; without
899
+ // this rebind the session kept judging the tree it left. The
900
+ // rebind only takes a genuine move, so a title or cost update on
901
+ // a session bound to a linked worktree cannot drag it back.
902
+ const info = (event.properties as { info?: { id?: unknown; directory?: unknown } })?.info;
903
+ if (typeof info?.id === "string" && typeof info.directory === "string") {
904
+ rebindSessionWorkspaceFromHost(info.id, info.directory);
905
+ }
906
+ }
946
907
  if (event?.type === "message.updated") {
947
908
  const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown; error?: { name?: unknown } } })?.info;
948
909
  if (info?.role === "user" && typeof info.sessionID === "string") {
@@ -972,6 +933,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
972
933
  toolLifecycle.takeRecoveryRun(sessionID);
973
934
  clearLiveDocsFloor(sessionID);
974
935
  await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
936
+ forgetSessionWorkspace(sessionID);
975
937
  }
976
938
  }
977
939
  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