opencode-workflow-guard 1.1.4 → 1.2.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.
@@ -1,2321 +1,224 @@
1
1
  /**
2
2
  * Workflow Guard Plugin for OpenCode (hooks-only - no prompt rules)
3
3
  *
4
- * Deterministic enforcement via the `tool.execute.before` plugin hook
5
- * (https://opencode.ai/docs/plugins/). Throwing from the hook blocks the
6
- * tool call outright.
4
+ * Deterministic enforcement via the tool.execute.before plugin hook.
5
+ * Throwing from the hook blocks the tool call outright.
7
6
  *
8
- * 1. Task gate: file-editing tools (edit/write/apply_patch) are blocked
9
- * until the session's NATIVE todo list (the built-in `todowrite` tool,
10
- * https://opencode.ai/docs/tools/#todowrite) has at least one active
11
- * item (pending/in_progress). Once every item is completed/cancelled,
12
- * edits block again - forcing a fresh breakdown per request. Subagent
13
- * sessions (todowrite is denied for them by default) inherit the todo
14
- * list of their parent session.
15
- * 2. Git pushes to main/master (incl. refspecs like HEAD:main or :main)
16
- * are hard-blocked.
17
- * 3. PR creation (gh) requires a changelog - either a CHANGELOG update in
18
- * the branch's diff or a "Changelog:" section in the PR body.
19
- * 4. Live-system guard: destructive commands targeting infrastructure,
20
- * databases, or remote APIs are blocked unless the USER overrides with
21
- * WORKFLOW_GUARD_ALLOW_LIVE=1 in the environment before launch. There
22
- * is deliberately no in-command override - the agent cannot grant it
23
- * to itself.
24
- * 5. MCP mutation guard: GitHub / Azure DevOps MCP tools with mutation
25
- * verbs in their names are blocked (read-only verbs pass).
26
- * 6. Settings tamper guard: the agent cannot edit opencode.json /
27
- * permission config / the guard's own plugin files - via shell OR via
28
- * the file-editing tools - or run `opencode auth` to weaken its gates.
29
- * 7. Branch guard: on main/master, file mutations and history-changing
30
- * git commands are blocked until a feature branch is created.
31
- * 8. Workspace boundary guard: file mutations cannot escape the workspace
32
- * root - via edit tools, patches, or shell redirection/copy commands.
33
- * 9. Script laundering guard: content written via edit/write/apply_patch
34
- * is scanned for destructive commands, so `write deploy.sh` followed by
35
- * `bash deploy.sh` cannot smuggle blocked operations past the shell
36
- * guards.
37
- *
38
- * Known limits (defense in depth, not a sandbox): these guards match on
39
- * command/file strings. An agent that base64-encodes a payload or invokes
40
- * an interpreter directly (`python3 -c ...`) can evade pattern matching.
41
- * Pair this plugin with OS-level isolation (branch protection, containers)
42
- * for hard guarantees.
43
- *
44
- * Install: copy this file into <project>/.opencode/plugins/ or
45
- * ~/.config/opencode/plugins/ - files there are auto-loaded at startup.
46
- * Requires opencode >= 1.18 (todo endpoint GET /session/:id/todo).
47
- */
48
-
49
- import { spawnSync, spawn } from "node:child_process";
50
- import { mkdirSync, realpathSync, readFileSync, writeFileSync, appendFileSync, existsSync, symlinkSync, rmSync, lstatSync } from "node:fs";
51
- import { homedir } from "node:os";
52
- import { basename, join, relative, resolve, sep } from "node:path";
53
- import { tool, type Plugin, type PluginModule } from "@opencode-ai/plugin";
54
-
55
- // OpenCode built-in tools (https://opencode.ai/docs/tools/): bash is the
56
- // shell tool. Legacy/alias names are kept for compatibility.
57
- const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
58
-
59
- const PROTECTED_BRANCHES = new Set(["main", "master"]);
60
-
61
- let workspaceRoot = process.cwd();
62
- let workspaceRootReal = workspaceRoot;
63
- try {
64
- workspaceRootReal = realpathSync(workspaceRoot);
65
- } catch {}
66
-
67
- function asRecord(value: unknown): Record<string, unknown> | undefined {
68
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
69
- return undefined;
70
- }
71
- return value as Record<string, unknown>;
72
- }
73
-
74
- /** Pull the command string(s) out of a shell tool input. */
75
- function extractCommands(input: unknown): string[] {
76
- if (typeof input === "string") {
77
- return [input];
78
- }
79
- const record = asRecord(input);
80
- if (!record) {
81
- return [];
82
- }
83
- const commands: string[] = [];
84
- if (typeof record.command === "string") commands.push(record.command);
85
- if (Array.isArray(record.commands)) {
86
- for (const c of record.commands) {
87
- if (typeof c === "string") commands.push(c);
88
- }
89
- }
90
- return commands;
91
- }
92
-
93
- /** Normalize a command for matching: collapse whitespace. */
94
- function normalize(cmd: string): string {
95
- return cmd.replace(/\s+/g, " ");
96
- }
97
-
98
- function shellWords(command: string): string[] {
99
- return (command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((word) =>
100
- word.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, "$1$2"),
101
- );
102
- }
103
-
104
- function splitShellSegments(command: string): string[] {
105
- const segments: string[] = [];
106
- let current = "";
107
- let quote: "'" | '"' | undefined;
108
- let escaped = false;
109
- for (const char of command) {
110
- if (escaped) {
111
- current += char;
112
- escaped = false;
113
- continue;
114
- }
115
- if (char === "\\" && quote !== "'") {
116
- current += char;
117
- escaped = true;
118
- continue;
119
- }
120
- if (char === "'" || char === '"') {
121
- if (!quote) quote = char;
122
- else if (quote === char) quote = undefined;
123
- current += char;
124
- continue;
125
- }
126
- if (!quote && /[\n|;&]/.test(char)) {
127
- if (current.trim()) segments.push(current.trim());
128
- current = "";
129
- continue;
130
- }
131
- current += char;
132
- }
133
- if (current.trim()) segments.push(current.trim());
134
- return segments;
135
- }
136
-
137
- /** Remove common command wrappers while preserving the underlying invocation. */
138
- function unwrapShellWords(command: string): string[] {
139
- const words = shellWords(command.trim());
140
- let i = 0;
141
- while (i < words.length) {
142
- if (words[i] === "command") {
143
- i++;
144
- continue;
145
- }
146
- if (words[i] === "sudo") {
147
- i++;
148
- const valueOptions = new Set(["-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-C", "--close-from", "-R", "--chroot", "-D", "--chdir"]);
149
- while (i < words.length && words[i]!.startsWith("-")) {
150
- const option = words[i++]!;
151
- if (valueOptions.has(option) && i < words.length) i++;
152
- }
153
- continue;
154
- }
155
- if (words[i] === "env") {
156
- i++;
157
- const valueOptions = new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]);
158
- while (i < words.length) {
159
- const word = words[i]!;
160
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
161
- i++;
162
- continue;
163
- }
164
- if (!word.startsWith("-")) break;
165
- i++;
166
- if (valueOptions.has(word) && i < words.length) i++;
167
- }
168
- continue;
169
- }
170
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) {
171
- i++;
172
- continue;
173
- }
174
- break;
175
- }
176
- return words.slice(i);
177
- }
178
-
179
- function unwrapShellCommand(command: string): string {
180
- return unwrapShellWords(command).join(" ");
181
- }
182
-
183
- // Matches a direct push of main|master: "git push origin main",
184
- // "git push main", "git push origin local:main" (refspec), "git push
185
- // origin :main" (branch deletion), "git push origin +HEAD:master" (forced
186
- // refspec). The right-hand side of a refspec must be exactly main/master -
187
- // feature/main-fix or HEAD:main-backup are fine.
188
- const PUSH_TO_MAIN_RE =
189
- /\bgit\s+push\b[^|;&]*(?:^|\s)\+?[\w./-]*:(?:main|master)(?![\w./-])|\bgit\s+push\b[^|;&]*(?:\s|\/)(?:main|master)(?![\w./-])/;
190
- const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?changelog\s*(?::|$)/im;
191
-
192
- function hasPrCreateInvocation(command: string): boolean {
193
- return splitShellSegments(command)
194
- .some((seg) => {
195
- const trimmed = unwrapShellCommand(seg);
196
- return (
197
- /^gh\b[^|;&]*\bpr\s+create\b/.test(trimmed) ||
198
- /^az\b[^|;&]*\brepos\s+pr\s+create\b/.test(trimmed)
199
- );
200
- });
201
- }
202
-
203
- // ── Task gate (native session todos) ─────────────────────────────────────────
204
- // opencode has a built-in per-session todo system: the `todowrite` tool
205
- // stores the list server-side (documented endpoint GET /session/:id/todo,
206
- // served through the SDK client plugins receive as ctx.client). The task
207
- // gate requires ACTIVE work in that list before any file edit.
208
-
209
- // OpenCode file-mutation tools: `edit`, `write`, `apply_patch` (the `edit`
210
- // permission covers all three per the permissions docs; the tools docs
211
- // explicitly note apply_patch is reported as "apply_patch", not "patch").
212
- const EDIT_TOOL_NAMES = new Set(["edit", "write", "patch", "apply_patch"]);
213
-
214
- // Subagent sessions spawn with `todowrite` denied (documented: "This tool
215
- // is disabled for subagents by default"), so a subagent's own list is
216
- // usually empty - the gate then falls back to the parent session's list
217
- // (sessions expose parentID) so delegated work stays gated on the
218
- // orchestrator's breakdown.
219
-
220
- const ACTIVE_TODO_STATUSES = new Set(["pending", "in_progress"]);
221
-
222
- export interface TodoItem {
223
- content?: unknown;
224
- status?: unknown;
225
- }
226
-
227
- // Minimal structural type for the SDK client (documented endpoints
228
- // GET /session/:id/todo, GET /session/:id, POST /tui/show-toast). Kept structural -
229
- // not the generated SDK types - so the plugin tolerates SDK minor-version drift.
230
- export interface TodoSdkClient {
231
- session?: {
232
- todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
233
- get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown } }>;
234
- };
235
- tui?: {
236
- showToast?: (opts: { body: { title?: string; message: string; variant?: string } }) => Promise<unknown>;
237
- };
238
- }
239
-
240
- let sdkClient: TodoSdkClient | undefined;
241
-
242
- /** Provide the SDK client (the plugin passes ctx.client; tests inject a fake). */
243
- export function setSdkClient(client: unknown): void {
244
- sdkClient = client as TodoSdkClient | undefined;
245
- }
246
-
247
- async function fetchSessionTodos(sessionID: string): Promise<TodoItem[] | undefined> {
248
- const session = sdkClient?.session;
249
- const todo = session?.todo;
250
- if (typeof todo !== "function") return undefined;
251
- try {
252
- const result = await todo.call(session, { path: { id: sessionID } });
253
- const data = (result as { data?: unknown } | undefined)?.data;
254
- return Array.isArray(data) ? (data as TodoItem[]) : undefined;
255
- } catch {
256
- return undefined;
257
- }
258
- }
259
-
260
- async function fetchParentSessionID(sessionID: string): Promise<string | undefined> {
261
- const session = sdkClient?.session;
262
- const get = session?.get;
263
- if (typeof get !== "function") return undefined;
264
- try {
265
- const result = await get.call(session, { path: { id: sessionID } });
266
- const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
267
- return typeof parent === "string" && parent ? parent : undefined;
268
- } catch {
269
- return undefined;
270
- }
271
- }
272
-
273
- /**
274
- * Todos governing a session: its own list, or - when it has none (typically
275
- * subagents, which cannot todowrite) - the nearest ancestor session's list.
276
- * Returns undefined when the list cannot be determined (client missing or
277
- * fetch failed); the gate then fails open instead of bricking the agent.
278
- */
279
- async function effectiveTodos(
280
- sessionID: string | undefined,
281
- ): Promise<TodoItem[] | undefined> {
282
- if (!sessionID) return undefined;
283
- const seen = new Set<string>();
284
- let current: string | undefined = sessionID;
285
- while (current && !seen.has(current)) {
286
- seen.add(current);
287
- const todos = await fetchSessionTodos(current);
288
- if (todos === undefined) return undefined;
289
- if (todos.length > 0) return todos;
290
- current = await fetchParentSessionID(current);
291
- }
292
- return [];
293
- }
294
-
295
- async function effectiveTodoOwnerSessionID(
296
- sessionID: string | undefined,
297
- ): Promise<string | undefined> {
298
- if (!sessionID) return undefined;
299
- const seen = new Set<string>();
300
- let current: string | undefined = sessionID;
301
- while (current && !seen.has(current)) {
302
- seen.add(current);
303
- const todos = await fetchSessionTodos(current);
304
- if (todos === undefined) return sessionID;
305
- if (todos.length > 0) return current;
306
- current = await fetchParentSessionID(current);
307
- }
308
- return sessionID;
309
- }
310
-
311
- function hasActiveTodo(todos: TodoItem[]): boolean {
312
- return todos.some((todo) => ACTIVE_TODO_STATUSES.has(String(todo.status ?? "")));
313
- }
314
-
315
- /**
316
- * Validates todo discipline rules:
317
- * 1. Task lifecycle: active tasks cannot silently vanish without being marked
318
- * 'completed' or 'cancelled'.
319
- */
320
- export function validateTodoLifecycle(
321
- newTodos: TodoItem[],
322
- existingTodos: TodoItem[] | undefined,
323
- ): string | undefined {
324
- // Rule: No silent task deletion while active work remains
325
- if (existingTodos && existingTodos.length > 0) {
326
- const activeExisting = existingTodos.filter((t) => {
327
- const s = String(t.status ?? "");
328
- return s === "pending" || s === "in_progress";
329
- });
330
- if (activeExisting.length > 0) {
331
- const newContentCounts = new Map<string, number>();
332
- for (const todo of newTodos) {
333
- const content = String(todo.content ?? "").trim();
334
- newContentCounts.set(content, (newContentCounts.get(content) ?? 0) + 1);
335
- }
336
- const missing = activeExisting.find((todo) => {
337
- const content = String(todo.content ?? "").trim();
338
- const remaining = newContentCounts.get(content) ?? 0;
339
- if (remaining === 0) return true;
340
- newContentCounts.set(content, remaining - 1);
341
- return false;
342
- });
343
- if (missing) {
344
- return (
345
- `Blocked todowrite: active task '${String(missing.content ?? "")}' was removed ` +
346
- "without being marked completed or cancelled. Tasks cannot silently disappear."
347
- );
348
- }
349
- }
350
- }
351
-
352
- return undefined;
353
- }
354
-
355
- // ── Workspace boundary guard ─────────────────────────────────────────────────
356
-
357
- function isPathOutsideWorkspace(targetPath: string, root: string): boolean {
358
- if (!targetPath) return false;
359
- const resolved = resolve(root, targetPath);
360
- const normalizedRoot = root.endsWith("/") ? root : root + "/";
361
- if (resolved !== root && !resolved.startsWith(normalizedRoot)) {
362
- return true;
363
- }
364
- // Verify symlink resolution against workspaceRootReal
365
- try {
366
- const real = realpathSync(resolved);
367
- const realRoot = workspaceRootReal.endsWith("/") ? workspaceRootReal : workspaceRootReal + "/";
368
- if (real !== workspaceRootReal && !real.startsWith(realRoot)) {
369
- return true;
370
- }
371
- } catch {
372
- // Target does not exist yet (e.g. creating new file). Check closest existing ancestor directory.
373
- let curr = resolved;
374
- while (curr && curr !== "/" && curr !== ".") {
375
- const parent = resolve(curr, "..");
376
- if (parent === curr) break;
377
- curr = parent;
378
- try {
379
- const realParent = realpathSync(curr);
380
- const realRoot = workspaceRootReal.endsWith("/") ? workspaceRootReal : workspaceRootReal + "/";
381
- if (realParent !== workspaceRootReal && !realParent.startsWith(realRoot)) {
382
- return true;
383
- }
384
- break;
385
- } catch {}
386
- }
387
- }
388
- return false;
389
- }
390
-
391
- function extractPatchPaths(patchText: string): string[] {
392
- const paths: string[] = [];
393
- const markerRe =
394
- /^\*\*\*\s+(?:Add File|Update File|Delete File|Move to|Move from):\s*(\S+)/gm;
395
- let match: RegExpExecArray | null;
396
- while ((match = markerRe.exec(patchText)) !== null) {
397
- if (match[1]) paths.push(match[1]);
398
- }
399
- const diffRe = /^(?:---|\+\+\+)\s+(?:[ab]\/)?(\S+)/gm;
400
- while ((match = diffRe.exec(patchText)) !== null) {
401
- if (match[1] && match[1] !== "/dev/null") paths.push(match[1]);
402
- }
403
- return paths;
404
- }
405
-
406
- // ── Shell file-mutation heuristic ────────────────────────────────────────────
407
- // The edit/write/apply_patch gates (todo, branch, boundary) are meaningless
408
- // if the same mutations can happen through the shell. These heuristics catch
409
- // the common file-writing shell idioms so the same gates apply. This is a
410
- // heuristic, not a shell parser: exotic redirects can evade it, which is why
411
- // content scanning of edit payloads (script-laundering guard) also exists.
412
-
413
- interface ShellMutation {
414
- kind: "redirect" | "command";
415
- target?: string;
416
- what: string;
417
- }
418
-
419
- /** Extract file paths mutated by a single shell command segment. */
420
- function shellMutationIn(segment: string): ShellMutation | undefined {
421
- // Redirection to a file: > file, >> file, 2> file, | tee file
422
- const redirectMatch = segment.match(
423
- /(?:^|\s|>)(?:[0-9]*>>?)\s*["']?([^\s>&|;"']+)/,
424
- );
425
- if (redirectMatch?.[1]) {
426
- if (/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(redirectMatch[1])) {
427
- return undefined;
428
- }
429
- return {
430
- kind: "redirect",
431
- target: redirectMatch[1],
432
- what: `file redirect to '${redirectMatch[1]}'`,
433
- };
434
- }
435
- const teeMatch = segment.match(/\btee\s+(?:-a\s+)?["']?([^\s;&|"']+)/);
436
- if (teeMatch?.[1]) {
437
- return {
438
- kind: "command",
439
- target: teeMatch[1],
440
- what: `tee to '${teeMatch[1]}'`,
441
- };
442
- }
443
- // In-place editing: sed -i file …
444
- const sedMatch = segment.match(/\bsed\s+(?:-(?:[a-zA-Z]*i[a-zA-Z]*|i\S*)\s+)+[^\s;&|]+/);
445
- if (sedMatch) {
446
- // first non-flag token after the -i flags is the script; the file is
447
- // the LAST token (sed -i 's/x/y/' file)
448
- const tokens = sedMatch[0]!.split(/\s+/);
449
- const target = tokens[tokens.length - 1];
450
- return { kind: "command", target, what: `sed -i on '${target}'` };
451
- }
452
- const transfer = filesystemTransferInfo(segment);
453
- if (transfer?.destination) {
454
- return {
455
- kind: "command",
456
- target: transfer.destination,
457
- what: `copy/move/link to '${transfer.destination}'`,
458
- };
459
- }
460
- // Common single-target filesystem mutations. Flags are tolerated; the
461
- // final operand is the target for these forms.
462
- const fsMutationMatch = segment.match(
463
- /\b(?:touch|mkdir|rm|unlink|rmdir|ln)\b[^|;&]*\s+["']?([^\s;&|"']+)["']?\s*$/,
464
- );
465
- if (fsMutationMatch?.[1] && !fsMutationMatch[1].startsWith("-")) {
466
- return {
467
- kind: "command",
468
- target: fsMutationMatch[1],
469
- what: `filesystem mutation of '${fsMutationMatch[1]}'`,
470
- };
471
- }
472
- // Copy/move into the workspace: cp/mv/rsync … dest
473
- const copyMatch = segment.match(
474
- /\b(?:cp|mv|rsync|install|cpio|scp|wget|curl)\b[^|;&]*?(-o\s+)?(["']?)([^\s;&|"']+)\2?\s*$/,
475
- );
476
- if (copyMatch?.[3] && /\b(?:cp|mv|rsync|install)\b/.test(segment)) {
477
- return {
478
- kind: "command",
479
- target: copyMatch[3],
480
- what: `copy/move to '${copyMatch[3]}'`,
481
- };
482
- }
483
- // git apply / git am (mutates working tree via patch stdin/file)
484
- if (/\bgit\s+(?:apply|am)\b/.test(segment)) {
485
- return { kind: "command", what: "git apply/am (patch via shell)" };
486
- }
487
- return undefined;
488
- }
489
-
490
- function simpleFilesystemMutations(segment: string): ShellMutation[] {
491
- const words = shellWords(unwrapShellCommand(segment));
492
- const command = words[0];
493
- if (!command || !new Set(["touch", "mkdir", "rm", "unlink", "rmdir"]).has(command)) {
494
- return [];
495
- }
496
- return words
497
- .slice(1)
498
- .filter((word) => !word.startsWith("-"))
499
- .map((target) => ({
500
- kind: "command" as const,
501
- target,
502
- what: `filesystem mutation of '${target}'`,
503
- }));
504
- }
505
-
506
- function filesystemTransferInfo(
507
- segment: string,
508
- ): { sources: string[]; destination?: string } | undefined {
509
- const words = shellWords(unwrapShellCommand(segment));
510
- if (!words[0] || !new Set(["cp", "mv", "ln"]).has(words[0])) return undefined;
511
- const operands: string[] = [];
512
- let targetDirectory: string | undefined;
513
- for (let i = 1; i < words.length; i++) {
514
- const word = words[i]!;
515
- if (word === "-t" || word === "--target-directory") {
516
- targetDirectory = words[++i];
517
- continue;
518
- }
519
- if (word.startsWith("--target-directory=")) {
520
- targetDirectory = word.slice("--target-directory=".length);
521
- continue;
522
- }
523
- if (word.startsWith("-")) continue;
524
- operands.push(word);
525
- }
526
- if (targetDirectory) return { sources: operands, destination: targetDirectory };
527
- return {
528
- sources: operands.slice(0, -1),
529
- destination: operands.at(-1),
530
- };
531
- }
532
-
533
- function secretSourceInFilesystemCommand(segment: string): string | undefined {
534
- const transfer = filesystemTransferInfo(segment);
535
- if (!transfer) return undefined;
536
- for (const source of transfer.sources) {
537
- if (isSecretPath(source)) return source;
538
- }
539
- return undefined;
540
- }
541
-
542
- /**
543
- * Mutations via the shell are subject to the same gates as direct edits:
544
- * todo activity, branch protection (targets inside the workspace) and the
545
- * workspace boundary (targets must not escape). Returns a block reason.
546
- */
547
- async function guardShellMutation(
548
- command: string,
549
- sessionID: string | undefined,
550
- ): Promise<string | undefined> {
551
- let hasMutation = false;
552
- for (const segment of command.split(/[\n|;&]+/)) {
553
- const secretSource = secretSourceInFilesystemCommand(segment);
554
- if (secretSource) {
555
- return `Blocked: shell command would copy, move, or link sensitive file '${secretSource}' under a non-secret name.`;
556
- }
557
- // mv mutates its SOURCES too (they are removed from their origin), so
558
- // sources must respect the same boundaries as mutation targets: no
559
- // moving files in from outside the workspace, no moving protected
560
- // (settings/plugin) files to innocuous names.
561
- const transfer = filesystemTransferInfo(segment);
562
- if (transfer && shellWords(unwrapShellCommand(segment))[0] === "mv") {
563
- for (const source of transfer.sources) {
564
- if (isProtectedPath(source)) {
565
- return PROTECTED_PATH_REASON;
566
- }
567
- if (isPathOutsideWorkspace(source, workspaceRoot)) {
568
- return `Blocked: mv would remove source '${source}' from outside the workspace root (${workspaceRoot}). File mutations must stay within the workspace.`;
569
- }
570
- }
571
- }
572
- const simpleMutations = simpleFilesystemMutations(segment);
573
- const fallbackMutation = shellMutationIn(segment.trim());
574
- const mutations = simpleMutations.length > 0
575
- ? simpleMutations
576
- : fallbackMutation
577
- ? [fallbackMutation]
578
- : [];
579
- for (const mutation of mutations) {
580
- hasMutation = true;
581
- const secret = secretIn(segment);
582
- if (secret) {
583
- return `Blocked: shell file mutation payload appears to contain a ${secret}. Secrets must not be written to disk from agent commands.`;
584
- }
585
- const target = mutation.target ?? "";
586
- // git apply/am has no explicit filename target - treat as workspace
587
- // mutation: subject to todo + branch gates, boundary implicit.
588
- if (target && isProtectedPath(target)) {
589
- return PROTECTED_PATH_REASON;
590
- }
591
- if (target && isPathOutsideWorkspace(target, workspaceRoot)) {
592
- // The workspace boundary has no override: a write outside the
593
- // workspace is out of bounds even with WORKFLOW_GUARD_ALLOW_LIVE
594
- // (that override covers live-system commands, not the boundary).
595
- return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
596
- }
597
- if (onProtectedBranch(workspaceRoot)) {
598
- return branchGuardReason();
599
- }
600
- const todos = await effectiveTodos(sessionID);
601
- if (todos !== undefined && !hasActiveTodo(todos)) {
602
- return (
603
- "Blocked: shell file mutation with no active todo item. " +
604
- "Break the request down with todowrite first, then apply " +
605
- "changes (the same gates apply to shell redirects, tee, " +
606
- "sed -i, cp/mv and git apply as to the edit tools)."
607
- );
608
- }
609
- }
610
- }
611
- if (hasMutation) {
612
- recordMutation(await effectiveTodoOwnerSessionID(sessionID));
613
- }
614
- return undefined;
615
- }
616
-
617
- // ── Branch guard ─────────────────────────────────────────────────────────────
618
-
619
- // Git global options that change which repository/work-tree a command acts
620
- // on. These must be parsed (not regexed around) so that e.g.
621
- // `git -C /other/repo commit` is gated on /other/repo's branch, not ours.
622
- const GIT_DIR_OPTION_TAKES_VALUE = new Set([
623
- "-C",
624
- "--git-dir",
625
- "--work-tree",
626
- "-c",
627
- "--config-env",
628
- "--namespace",
629
- ]);
630
- const GIT_DIR_OPTION_PREFIXED =
631
- /^(--git-dir=|--work-tree=|--namespace=|-c\S|--config-env=)/;
632
- const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
633
- "--version",
634
- "--help",
635
- "--no-pager",
636
- "-p",
637
- "--paginate",
638
- "--bare",
639
- "--literal-pathspecs",
640
- "--glob-pathspecs",
641
- "--noglob-pathspecs",
642
- "--icase-pathspecs",
643
- "--no-optional-locks",
644
- "--exec-path",
645
- ]);
646
-
647
- interface GitInvocation {
648
- repoDir: string;
649
- rest: string;
650
- }
651
-
652
- /**
653
- * Parse the leading global options off a `git …` command segment, returning
654
- * the effective repository directory (-C/--git-dir change it; --work-tree
655
- * alone does not) and the remainder (subcommand + args). Used so that
656
- * global flags cannot smuggle a guarded subcommand past the regexes.
657
- */
658
- function parseGitInvocation(command: string): GitInvocation | undefined {
659
- const tokens = unwrapShellWords(command);
660
- if (tokens[0] !== "git") return undefined;
661
- let i = 1;
662
- let repoDir = workspaceRoot;
663
- let sawDirOption = false;
664
- while (i < tokens.length) {
665
- const tok = tokens[i]!;
666
- if (GIT_DIR_OPTION_TAKES_VALUE.has(tok)) {
667
- const value = tokens[i + 1];
668
- if (value === undefined) return { repoDir, rest: "" };
669
- if (tok === "-C" || tok === "--git-dir") {
670
- repoDir = resolve(workspaceRoot, value);
671
- sawDirOption = true;
672
- }
673
- i += 2;
674
- continue;
675
- }
676
- if (GIT_DIR_OPTION_PREFIXED.test(tok)) {
677
- if (tok.startsWith("--git-dir=")) {
678
- repoDir = resolve(workspaceRoot, tok.slice("--git-dir=".length));
679
- sawDirOption = true;
680
- }
681
- i += 1;
682
- continue;
683
- }
684
- // Bare global flags that take no value.
685
- if (GIT_GLOBAL_BOOLEAN_OPTIONS.has(tok)) {
686
- i += 1;
687
- continue;
688
- }
689
- break;
690
- }
691
- if (!sawDirOption) repoDir = workspaceRoot;
692
- return { repoDir, rest: tokens.slice(i).join(" ") };
693
- }
694
-
695
- /**
696
- * Rewrite a command so every `git [global-opts] <sub>` becomes plain
697
- * `git <sub>`, letting the guard regexes match the subcommand regardless of
698
- * global flags. Non-git segments pass through unchanged.
699
- */
700
- function normalizeGitCommands(command: string): string {
701
- return splitShellSegments(command)
702
- .map((segment) => {
703
- const parsed = parseGitInvocation(segment);
704
- return parsed ? `git ${parsed.rest}` : segment;
705
- })
706
- .join(" ; ");
707
- }
708
-
709
- const GIT_WRITE_RE =
710
- /\bgit\s+(commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
711
-
712
- function currentGitBranch(root: string): string | undefined {
713
- const result = spawnSync("git", ["branch", "--show-current"], {
714
- cwd: root,
715
- encoding: "utf8",
716
- timeout: 10_000,
717
- });
718
- if (result.status === 0 && result.stdout.trim()) {
719
- return result.stdout.trim();
720
- }
721
- // Fallback for unborn branches / odd runtimes: read .git/HEAD directly.
722
- try {
723
- const head = readFileSync(resolve(root, ".git", "HEAD"), "utf8").trim();
724
- const match = head.match(/^ref:\s+refs\/heads\/(\S+)/);
725
- if (match) return match[1];
726
- } catch {
727
- // Not a repo (or worktree without .git dir) - no gate.
728
- }
729
- if (result.status === 0) return ""; // detached HEAD - treat as unprotected
730
- return undefined;
731
- }
732
-
733
- // ── Project-level configuration (.opencode/workflow-guard.json) ───────────────
734
-
735
- export interface ProjectConfig {
736
- protectedBranches?: string[];
737
- verifyCommand?: string;
738
- requireReview?: boolean;
739
- requireDocumentation?: boolean;
740
- }
741
-
742
- let cachedProjectConfig: ProjectConfig | undefined;
743
-
744
- export function loadProjectConfig(root: string): ProjectConfig {
745
- const candidates = [
746
- join(root, ".opencode", "workflow-guard.json"),
747
- join(root, ".opencode", "workflow-guard.jsonc"),
748
- join(root, "workflow-guard.json"),
749
- join(root, "workflow-guard.jsonc"),
750
- ];
751
- for (const candidate of candidates) {
752
- try {
753
- const raw = readFileSync(candidate, "utf8");
754
- return JSON.parse(raw);
755
- } catch {}
756
- }
757
- return {};
758
- }
759
-
760
- export function reloadProjectConfig(root: string): void {
761
- cachedProjectConfig = loadProjectConfig(root);
762
- }
763
-
764
- export function isReviewRequired(root: string): boolean {
765
- if (process.env.WORKFLOW_GUARD_REQUIRE_REVIEW === "1") return true;
766
- const cfg = cachedProjectConfig ?? loadProjectConfig(root);
767
- return cfg.requireReview === true;
768
- }
769
-
770
- /**
771
- * The full protected-branch set for this repository: the built-in
772
- * main/master set plus any branches configured in
773
- * `.opencode/workflow-guard.json` (`protectedBranches`). Shared by the
774
- * branch guard, the push guard and the native worktree tools so every
775
- * path enforces the same set.
776
- */
777
- export function getProtectedBranches(root = workspaceRoot): Set<string> {
778
- const cfg = cachedProjectConfig ?? loadProjectConfig(root);
779
- const customBranches = Array.isArray(cfg.protectedBranches)
780
- ? cfg.protectedBranches
781
- : [];
782
- return new Set([...PROTECTED_BRANCHES, ...customBranches]);
783
- }
784
-
785
- /** True when `branch` is protected for the repository at `root`. */
786
- export function isProtectedBranchName(branch: string, root = workspaceRoot): boolean {
787
- if (!branch) return false;
788
- return getProtectedBranches(root).has(branch);
789
- }
790
-
791
- /** Regex-escape a literal string for embedding in a RegExp. */
792
- function escapeRegExp(text: string): string {
793
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
794
- }
795
-
796
- /**
797
- * Returns the protected branch a `git push` command targets as its
798
- * destination, if any. Covers bare refspecs (`git push origin <branch>`)
799
- * and colon forms (`git push origin HEAD:<branch>`, `:<branch>` deletion,
800
- * `+<branch>` forced), for every protected branch - built-in and
801
- * configured - of the repository at `root`.
802
- */
803
- function pushedProtectedBranchIn(pushCommand: string, root: string): string | undefined {
804
- for (const branch of getProtectedBranches(root)) {
805
- const escaped = escapeRegExp(branch);
806
- const re = new RegExp(
807
- `(?:^|\\s)\\+?[\\w./-]*:${escaped}(?![\\w./-])` +
808
- `|(?:\\s|\\/)${escaped}(?![\\w./-])`,
809
- );
810
- if (re.test(pushCommand)) return branch;
811
- }
812
- return undefined;
813
- }
814
-
815
- function onProtectedBranch(root: string): boolean {
816
- return isProtectedBranchName(currentGitBranch(root) ?? "", root);
817
- }
818
-
819
- function branchGuardReason(): string {
820
- return (
821
- "Blocked: the workspace is on a protected branch. " +
822
- "Create a feature branch first - e.g. " +
823
- "`git switch -c feat/description` - and make all changes there, " +
824
- "then open a PR. Direct changes on protected branches are not allowed."
825
- );
826
- }
827
-
828
- // ── Merged / Closed PR branch check (GitHub & Azure DevOps) ──────────────────
829
-
830
- export function isBranchAlreadyMergedOrClosed(
831
- root: string,
832
- branch: string,
833
- ): { merged: boolean; reason?: string } {
834
- if (!branch || PROTECTED_BRANCHES.has(branch)) {
835
- return { merged: false };
836
- }
837
-
838
- // 1. Check local / remote git ancestors: is branch already fully merged into main/master?
839
- for (const base of ["origin/HEAD", "origin/main", "origin/master", "main", "master"]) {
840
- const check = spawnSync("git", ["merge-base", "--is-ancestor", branch, base], {
841
- cwd: root,
842
- encoding: "utf8",
843
- timeout: 5_000,
844
- });
845
- if (check.status === 0) {
846
- const unmerged = spawnSync("git", ["rev-list", `${base}..${branch}`, "--count"], {
847
- cwd: root,
848
- encoding: "utf8",
849
- timeout: 5_000,
850
- });
851
- if (unmerged.status === 0 && unmerged.stdout.trim() === "0") {
852
- return {
853
- merged: true,
854
- reason: `Branch '${branch}' is already merged into '${base}'. Create a fresh feature branch for new changes.`,
855
- };
856
- }
857
- }
858
- }
859
-
860
- // 2. Check GitHub PR state if gh CLI is present
861
- try {
862
- const ghRes = spawnSync(
863
- "gh",
864
- ["pr", "list", "--head", branch, "--state", "all", "--json", "number,state,title", "--limit", "1"],
865
- { cwd: root, encoding: "utf8", timeout: 8_000 },
866
- );
867
- if (ghRes.status === 0 && ghRes.stdout.trim()) {
868
- const prs = JSON.parse(ghRes.stdout.trim());
869
- if (Array.isArray(prs) && prs.length > 0) {
870
- const pr = prs[0];
871
- if (pr && (pr.state === "MERGED" || pr.state === "CLOSED")) {
872
- return {
873
- merged: true,
874
- reason: `Branch '${branch}' is associated with an already ${pr.state.toLowerCase()} GitHub PR (#${pr.number}: ${pr.title ?? ""}). Create a fresh feature branch for new changes.`,
875
- };
876
- }
877
- }
878
- }
879
- } catch {}
880
-
881
- // 3. Check Azure DevOps PR state if az CLI is present
882
- try {
883
- const azRes = spawnSync(
884
- "az",
885
- ["repos", "pr", "list", "--source-branch", branch, "--status", "all", "--query", "[0].{id:pullRequestId, status:status, title:title}", "-o", "json"],
886
- { cwd: root, encoding: "utf8", timeout: 8_000 },
887
- );
888
- if (azRes.status === 0 && azRes.stdout.trim()) {
889
- const pr = JSON.parse(azRes.stdout.trim());
890
- if (pr && typeof pr === "object" && (pr.status === "completed" || pr.status === "abandoned")) {
891
- return {
892
- merged: true,
893
- reason: `Branch '${branch}' is associated with an already ${pr.status} Azure DevOps PR (#${pr.id}: ${pr.title ?? ""}). Create a fresh feature branch for new changes.`,
894
- };
895
- }
896
- }
897
- } catch {}
898
-
899
- return { merged: false };
900
- }
901
-
902
- // ── Merge conflict pre-flight check ──────────────────────────────────────────
903
-
904
- export function checkMergeConflicts(root: string): {
905
- hasConflicts: boolean;
906
- baseBranch?: string;
907
- reason?: string;
908
- } {
909
- const candidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
910
- for (const base of candidates) {
911
- const mergeBaseRes = spawnSync("git", ["merge-base", "HEAD", base], {
912
- cwd: root,
913
- encoding: "utf8",
914
- timeout: 5_000,
915
- });
916
- if (mergeBaseRes.status !== 0 || !mergeBaseRes.stdout.trim()) continue;
917
- const mergeBase = mergeBaseRes.stdout.trim();
918
-
919
- const treeRes = spawnSync("git", ["merge-tree", mergeBase, "HEAD", base], {
920
- cwd: root,
921
- encoding: "utf8",
922
- timeout: 10_000,
923
- });
924
- if (treeRes.status === 0 && treeRes.stdout.includes("<<<<<<<")) {
925
- return {
926
- hasConflicts: true,
927
- baseBranch: base,
928
- reason: `Branch has merge conflicts with base branch '${base}'. Rebase or merge '${base}' to resolve all conflicts before opening a PR or handing off.`,
929
- };
930
- }
931
- }
932
- return { hasConflicts: false };
933
- }
934
-
935
- // ── Base branch freshness pre-flight check ───────────────────────────────────
936
-
937
- const GIT_BRANCH_CREATE_RE =
938
- /\bgit\s+(?:checkout\s+-b|switch\s+(?:-c|--create))\b/;
939
-
940
- export function checkBranchBaseIsUpToDate(root: string): {
941
- isBehind: boolean;
942
- count?: number;
943
- baseRef?: string;
944
- reason?: string;
945
- } {
946
- for (const base of ["origin/HEAD", "origin/main", "origin/master"]) {
947
- const res = spawnSync("git", ["rev-list", `HEAD..${base}`, "--count"], {
948
- cwd: root,
949
- encoding: "utf8",
950
- timeout: 5_000,
951
- });
952
- if (res.status === 0 && res.stdout.trim()) {
953
- const count = parseInt(res.stdout.trim(), 10);
954
- if (!isNaN(count) && count > 0) {
955
- return {
956
- isBehind: true,
957
- count,
958
- baseRef: base,
959
- reason: `Local base branch is ${count} commit(s) behind remote (${base}). Run 'git pull' or 'git fetch' on main before creating a fresh feature branch to prevent upstream conflicts.`,
960
- };
961
- }
962
- }
963
- }
964
- return { isBehind: false };
965
- }
966
-
967
- // ── Live-system guard ────────────────────────────────────────────────────────
968
- // The ONLY override is the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable,
969
- // which the user must set before launching the agent. There is deliberately
970
- // no in-command marker: an override the agent can append to its own command
971
- // (or is told about in an error message) is an override the agent WILL use.
972
-
973
- interface LivePattern {
974
- re: RegExp;
975
- what: string;
976
- }
977
-
978
- const LIVE_MUTATION_PATTERNS: LivePattern[] = [
979
- // Only DESTRUCTIVE operations are blocked. Create/update/apply/set-style
980
- // commands are allowed - they're normal work and reviewable in diffs.
981
- // Filesystem destruction: block rm -rf on system paths, allow workspace cleanup
982
- { re: /\brm\s+(?:-[a-zA-Z]*[rRfF][a-zA-Z]*\s+)*-[a-zA-Z]*[rRfF][a-zA-Z]*\s+(?:\/|~|\*)/, what: "recursive/forced deletion of system/home paths" },
983
- { re: /\b(?:sudo\s+)?rm\s+-(?:[a-zA-Z]*[rRfF][a-zA-Z]*\s+){1,2}(?:\/|~)/, what: "forced deletion of system/home paths" },
984
- { re: /\bgit\s+clean\s+(?:-[a-zA-Z]*[fdx][a-zA-Z]*)(?:\s|$)/, what: "git clean (untracked file deletion)" },
985
- { re: /\bgit\s+push\b[^|;&]*\s\+(?:[\w./-]*:)?/, what: "force push via + refspec" },
986
- // Infrastructure / orchestration
987
- { re: /\bkubectl\s+(delete|drain|cordon)\b/, what: "destructive kubectl command" },
988
- { re: /\bkubectl\s+rollout\s+(undo|restart)\b/, what: "destructive kubectl rollout" },
989
- { re: /\bhelm\s+(uninstall|rollback|delete)\b/, what: "helm release removal/rollback" },
990
- { re: /\b(?:terraform|tofu)\s+destroy\b/, what: "terraform/tofu destroy" },
991
- { re: /\bpulumi\s+destroy\b/, what: "pulumi destroy" },
992
- // Containers
993
- { re: /\bdocker\s+(?:container\s+)?(?:rm|prune)\b/, what: "docker container/image removal" },
994
- { re: /\bdocker\s+(?:system|image|volume|network)\s+prune\b/, what: "docker prune" },
995
- { re: /\bdocker\s+volume\s+rm\b/, what: "docker volume removal" },
996
- // Cloud CLI deletions
997
- { re: /\baz\s+\S+\s+(?:delete|purge)\b/, what: "Azure resource deletion" },
998
- { re: /\baz\s+(?:devops|repos|pipelines|boards|artifacts)\s+[\w-]*\s*(?:delete|abandon)\b/, what: "Azure DevOps deletion" },
999
- { re: /\baws\s+\S+\s+(?:delete|terminate)-?\w*\b/, what: "AWS resource deletion" },
1000
- { re: /\bgcloud\s+\S+\s+(?:delete|abandon)\b/, what: "GCP resource deletion" },
1001
- // Hosted repo / PR destruction via CLI
1002
- { re: /\bgh\s+(?:repo|issue|pr|release|secret|variable)\s+(?:delete|close)\b/, what: "gh destructive command" },
1003
- // Database destruction via CLI clients (insert/update/create are allowed)
1004
- { re: /\b(?:psql|mysql|mariadb|mongosh|mongo|redis-cli|sqlite3)\b[^|;&]*\b(?:drop|delete|truncate|flushall|flushdb)\b/i, what: "destructive database command" },
1005
- { re: /\b(?:npx|pnpm\s+exec|yarn)\s+prisma\s+migrate\s+reset\b/, what: "prisma migrate reset (database wipe)" },
1006
- { re: /\bprisma\s+migrate\s+reset\b/, what: "prisma migrate reset (database wipe)" },
1007
- // Destructive remote HTTP calls (DELETE only; POST/PUT/PATCH are normal API work)
1008
- { re: /\bcurl\b(?=[^|;&]*(?:(?<!\S)(?:-X|--request)\s*=?\s*DELETE))(?=[^|;&]*https?:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]))/, what: "remote HTTP DELETE" },
1009
- { re: /\b(?:curl|wget)\b[^;&]*\|\s*(?:bash|sh|zsh)\b/, what: "remote download piped directly to a shell" },
1010
- // Destructive git operations
1011
- { re: /\bgit\s+push\b[^|;&]*(?:--force\b|--force-with-lease\b|\s-f\b)/, what: "force push" },
1012
- ];
1013
-
1014
- function liveMutationIn(command: string): string | undefined {
1015
- for (const { re, what } of LIVE_MUTATION_PATTERNS) {
1016
- if (re.test(command)) {
1017
- return what;
1018
- }
1019
- }
1020
- return undefined;
1021
- }
1022
-
1023
- // ── Secret content scan ──────────────────────────────────────────────────────
1024
- // Common credential shapes that must never end up committed to a repo. This
1025
- // list is intentionally conservative - it aims to catch obvious accidents
1026
- // (env-file contents, keys copied into source) rather than every secret on
1027
- // earth. False positives return an actionable error.
1028
-
1029
- interface SecretPattern {
1030
- re: RegExp;
1031
- what: string;
1032
- }
1033
-
1034
- const SECRET_PATTERNS: SecretPattern[] = [
1035
- { re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/, what: "private key material" },
1036
- { re: /\bAKIA[0-9A-Z]{16}\b/, what: "AWS access key ID" },
1037
- { re: /\bASIA[0-9A-Z]{16}\b/, what: "AWS temporary session credential" },
1038
- { re: /\bghp_[A-Za-z0-9]{36}\b/, what: "GitHub personal access token" },
1039
- { re: /\bgithub_pat_[A-Za-z0-9_]{82}\b/, what: "GitHub fine-grained PAT" },
1040
- { re: /\bgho_[A-Za-z0-9]{36}\b/, what: "GitHub OAuth token" },
1041
- { re: /\bghu_[A-Za-z0-9]{36}\b/, what: "GitHub user-to-server token" },
1042
- { re: /\bghs_[A-Za-z0-9]{36}\b/, what: "GitHub server-to-server token" },
1043
- { re: /\bghr_[A-Za-z0-9]{36}\b/, what: "GitHub refresh token" },
1044
- { re: /\bsk-[A-Za-z0-9]{20,}\b/, what: "OpenAI-style API key" },
1045
- { re: /\bog-[A-Za-z0-9]{20,}\b/, what: "OpenCode/legacy API key" },
1046
- { re: /\bAIza[0-9A-Za-z_-]{35}\b/, what: "Google API key" },
1047
- { re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, what: "Slack token (xox family)" },
1048
- // Explicit env-style assignments are a common accident in .env files.
1049
- { re: /(?:^|\s)(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)\s*=\s*\S+/, what: "AWS credential assignment" },
1050
- { re: /(?:^|\s)(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENAI_KEY)\s*=\s*\S+/, what: "LLM API key assignment" },
1051
- ];
1052
-
1053
- function secretIn(content: string): string | undefined {
1054
- for (const { re, what } of SECRET_PATTERNS) {
1055
- if (re.test(content)) return what;
1056
- }
1057
- return undefined;
1058
- }
1059
-
1060
- /**
1061
- * Extract executable-looking content from file-edit tool input (write/edit
1062
- * payloads, apply_patch texts). Scanned for destructive patterns so that
1063
- * writing a script and executing it cannot launder a blocked command.
1064
- */
1065
- function extractEditContent(input: unknown): string[] {
1066
- const record = asRecord(input);
1067
- if (!record) return [];
1068
- const contents: string[] = [];
1069
- for (const key of ["content", "newString", "patchText", "patch", "diff"]) {
1070
- if (typeof record[key] === "string") contents.push(record[key] as string);
1071
- }
1072
- // apply_patch in some runtimes is an array of file ops
1073
- if (Array.isArray(record.changes)) {
1074
- for (const change of record.changes) {
1075
- const r = asRecord(change);
1076
- if (r && typeof r.content === "string") contents.push(r.content);
1077
- }
1078
- }
1079
- return contents;
1080
- }
1081
-
1082
- // ── MCP mutation tool guard ──────────────────────────────────────────────────
1083
- // MCP tools bypass the shell entirely, so they need their own name-based
1084
- // matching. OpenCode exposes MCP tools with names like `mcp__<server>__<tool>`;
1085
- // some runtimes use plain `<server>_<tool>` names instead, so both forms are
1086
- // matched. Read-only tool names (get/list/search/show/query/describe/…) are
1087
- // always allowed; mutating names are blocked unless explicitly allowed.
1088
-
1089
- const MCP_MUTATION_VERB_RE =
1090
- /(?:^|_)(?:create|update|delete|remove|merge|push|close|edit|set|fork|trigger|cancel|rerun|add|assign|approve|complete|abandon)(?:_|$)/;
1091
- const MCP_READ_ONLY_RE =
1092
- /(?:^|_)(?:get|list|search|show|query|describe|find|read|status|diff|log)(?:_|$)/;
1093
-
1094
- // Guarded servers. Matched as whole tokens after splitting on
1095
- // non-alphanumerics, so `azure-devops` → {azure, devops}, `gh` → {gh}, etc.
1096
- const GUARDED_MCP_SERVERS = new Set([
1097
- "github",
1098
- "gh",
1099
- "azure",
1100
- "azmcp",
1101
- "ado",
1102
- "devops",
1103
- "azuredevops",
1104
- ]);
1105
-
1106
- function mcpMutationTool(toolName: string): string | undefined {
1107
- if (!MCP_MUTATION_VERB_RE.test(toolName) || MCP_READ_ONLY_RE.test(toolName)) {
1108
- return undefined;
1109
- }
1110
- const tokens = toolName.toLowerCase().split(/[^a-z0-9]+/);
1111
- if (tokens.some((t) => GUARDED_MCP_SERVERS.has(t))) {
1112
- return "GitHub/Azure DevOps";
1113
- }
1114
- return undefined;
1115
- }
1116
-
1117
- // ── OpenCode settings tamper guard ───────────────────────────────────────────
1118
- // The model must not be able to weaken its own permission gates by editing
1119
- // opencode.json / permission config, tampering with the guard's own plugin
1120
- // files, or managing auth from the agent. This is enforced in BOTH the
1121
- // shell guard (below) and the file-edit tools (via isProtectedPath), because
1122
- // an agent that can `edit` a config file directly doesn't need the shell.
1123
-
1124
- /**
1125
- * True when a filesystem path targets OpenCode's own configuration, plugin,
1126
- * or auth state - anything the agent could modify to weaken the guard.
1127
- * Checked against the path resolved from the workspace root.
1128
- */
1129
- export function isProtectedPath(targetPath: string): boolean {
1130
- if (!targetPath) return false;
1131
- const resolved = resolve(workspaceRoot, targetPath);
1132
- const matches = (path: string): boolean => {
1133
- const base = basename(path);
1134
- const lower = path.toLowerCase();
1135
- return (
1136
- // opencode.json / opencode.jsonc anywhere (project config)
1137
- /^opencode\.jsonc?$/i.test(base) ||
1138
- // workflow-guard.json / workflow-guard.jsonc anywhere
1139
- /^workflow-guard\.jsonc?$/i.test(base) ||
1140
- // the .opencode directory itself and anything under it (project
1141
- // plugins, agents) - including the exact directory, not just paths
1142
- // nested inside it
1143
- lower === ".opencode" ||
1144
- lower.endsWith("/.opencode") ||
1145
- lower.includes(`${"/"}.opencode/`) ||
1146
- // anything under ~/.config/opencode (global config, plugins, ui)
1147
- lower.includes("/.config/opencode/") ||
1148
- lower.includes("/.config/opencode.json")
1149
- );
1150
- };
1151
- if (matches(resolved)) return true;
1152
- try {
1153
- return matches(realpathSync(resolved));
1154
- } catch {
1155
- let ancestor = resolve(resolved, "..");
1156
- while (ancestor !== resolve(ancestor, "..")) {
1157
- try {
1158
- const realAncestor = realpathSync(ancestor);
1159
- return matches(resolve(realAncestor, relative(ancestor, resolved)));
1160
- } catch {
1161
- ancestor = resolve(ancestor, "..");
1162
- }
1163
- }
1164
- return false;
1165
- }
1166
- }
1167
-
1168
- // ── Sensitive file READ guard (.env*, keys, credentials, kubeconfig) ─────────
1169
- // Reading live secrets into model context leaks credentials. Read-only fixtures
1170
- // (.env.example, .env.sample, .env.template) are safe and permitted.
1171
-
1172
- const SAFE_ENV_FIXTURE_RE =
1173
- /\.env\.(example|sample|template|dist|schema)(\.[\w-]+)*$/i;
1174
-
1175
- export function isSecretPath(targetPath: string): boolean {
1176
- if (!targetPath) return false;
1177
- const resolved = resolve(workspaceRoot, targetPath);
1178
- const matches = (path: string): boolean => {
1179
- const base = basename(path).toLowerCase();
1180
- const full = path.toLowerCase();
1181
-
1182
- // Safe fixtures
1183
- if (SAFE_ENV_FIXTURE_RE.test(base)) {
1184
- return false;
1185
- }
1186
-
1187
- // .env files (e.g. .env, .env.local, .env.prod, .env.secret)
1188
- if (/^\.env(?:\.|$)/i.test(base)) {
1189
- return true;
1190
- }
1191
-
1192
- // SSH & TLS key material
1193
- if (
1194
- /^(id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:\.|$)/i.test(base) ||
1195
- /\.(pem|key|pkcs12|pfx|p12)$/i.test(base)
1196
- ) {
1197
- return true;
1198
- }
1199
-
1200
- // Cloud / cluster / service account credentials
1201
- if (
1202
- /kubeconfig/i.test(base) ||
1203
- full.includes("/.kube/config") ||
1204
- /^(service[-_]?account|client[-_]?secret).*\.json$/i.test(base) ||
1205
- /credentials\.json$/i.test(base) ||
1206
- full.includes("/.aws/credentials") ||
1207
- full.includes("/.docker/config.json") ||
1208
- base === ".netrc" ||
1209
- base === ".git-credentials"
1210
- ) {
1211
- return true;
1212
- }
1213
-
1214
- return false;
1215
- };
1216
- if (matches(resolved)) return true;
1217
- try {
1218
- return matches(realpathSync(resolved));
1219
- } catch {
1220
- return false;
1221
- }
1222
- }
1223
-
1224
- /**
1225
- * Returns true specifically for environment variable files (.env, .env.local, etc.)
1226
- * that can safely present their schema (masked keys) to agents without exposing secret values.
1227
- */
1228
- export function isEnvFilePath(targetPath: string): boolean {
1229
- if (!targetPath) return false;
1230
- const base = basename(targetPath).toLowerCase();
1231
- if (SAFE_ENV_FIXTURE_RE.test(base)) return false;
1232
- return /^\.env(?:\.|$)/i.test(base);
1233
- }
1234
-
1235
- /**
1236
- * Parses a raw .env file and returns a sanitized schema mask where variable names
1237
- * and comment structures are preserved, but sensitive values are redacted to '********'.
1238
- */
1239
- export function generateMaskedEnvSchema(content: string): string {
1240
- const lines = content.split("\n");
1241
- return lines
1242
- .map((line) => {
1243
- const trimmed = line.trim();
1244
- if (!trimmed || trimmed.startsWith("#")) return line;
1245
- const eqIdx = line.indexOf("=");
1246
- if (eqIdx === -1) return line;
1247
- const key = line.slice(0, eqIdx);
1248
- return `${key}=********`;
1249
- })
1250
- .join("\n");
1251
- }
1252
-
1253
- const SECRET_READ_COMMAND_RE =
1254
- /(?:^|\s)(?:cat|head|tail|less|more|grep|awk|sed|od|hexdump|strings|base64|xxd|nl|sort|uniq|view|nano|vim?)\s+[^|;&]*?(?:["']?)([\w\/.~-]*\.(?:pem|key|pfx|p12)|[\w\/.~-]*\.env(?:\.[\w-]+)*|[\w\/.~-]*id_(?:rsa|dsa|ecdsa|ed25519)[\w.-]*|[\w\/.~-]*kubeconfig[\w.-]*|[\w\/.~-]*(?:service[-_]?account|credentials|client[-_]?secret)[\w.-]*\.json)(?:["']?)/i;
1255
- const SIMPLE_FILE_READ_COMMAND_RE =
1256
- /(?:^|\s)(?:cat|head|tail|less|more|od|hexdump|strings|base64|xxd|nl|view|nano|vim?)\s+(?:-\S+\s+)*["']?([^\s|;&"']+)/i;
1257
-
1258
- function secretFileReadIn(segment: string): string | undefined {
1259
- const match = segment.match(SECRET_READ_COMMAND_RE);
1260
- if (match?.[1] && isSecretPath(match[1])) {
1261
- return match[1];
1262
- }
1263
- const simpleMatch = segment.match(SIMPLE_FILE_READ_COMMAND_RE);
1264
- if (simpleMatch?.[1] && isSecretPath(simpleMatch[1])) return simpleMatch[1];
1265
- return undefined;
1266
- }
1267
-
1268
- // ── Interpreter inline evasion scanner ───────────────────────────────────────
1269
- // Extracts inline script payloads (python -c, node -e, perl -e, ruby -e,
1270
- // powershell -enc, base64 | sh) so the live-mutation and settings-tamper
1271
- // scanners can inspect the actual script content.
1272
-
1273
- export function extractInterpreterPayload(segment: string): string[] {
1274
- const payloads: string[] = [];
1275
- const inlineMatch = segment.match(
1276
- /\b(?:python3?|node|perl|ruby|osascript)\s+(?:-[a-zA-Z]*[ce]\s+)(?:"([^"]*)"|'([^']*)')/i,
1277
- );
1278
- if (inlineMatch?.[1] || inlineMatch?.[2]) {
1279
- payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
1280
- }
1281
- const psMatch = segment.match(
1282
- /\b(?:powershell|pwsh)\s+(?:-[a-zA-Z]*enc[a-zA-Z]*\s+)([A-Za-z0-9+/=]+)/i,
1283
- );
1284
- if (psMatch?.[1]) {
1285
- try {
1286
- const buf = Buffer.from(psMatch[1], "base64");
1287
- payloads.push(buf.toString("utf8"), buf.toString("utf16le"));
1288
- } catch {}
1289
- }
1290
- const b64PipeMatch = segment.match(
1291
- /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,
1292
- );
1293
- if (b64PipeMatch?.[1]) {
1294
- try {
1295
- const buf = Buffer.from(b64PipeMatch[1], "base64");
1296
- payloads.push(buf.toString("utf8"), buf.toString("utf16le"));
1297
- } catch {}
1298
- }
1299
- return payloads;
1300
- }
1301
-
1302
- /**
1303
- * Strip shell quote-concatenation and escape sequences that exist only to
1304
- * break up a literal string: open''code.json -> opencode.json,
1305
- * open""code -> opencode, open\c\ode -> opencode. Applied to each segment
1306
- * before pattern matching so evasion requires more than quoting.
1307
- */
1308
- function normalizeShellEvasion(text: string): string {
1309
- return text
1310
- .replace(/'([^']*)'/g, "$1") // open''code -> opencode (quoted content kept)
1311
- .replace(/"([^"]*)"/g, "$1")
1312
- .replace(/\\(.)/g, "$1"); // open\c\ode -> opencode
1313
- }
1314
-
1315
- /**
1316
- * Collapse shell glob wildcards on the final path component so that
1317
- * `opencode.jso?` or `opencode.*` still match the protected name.
1318
- */
1319
- function normalizeGlobPathEvasion(text: string): string {
1320
- return text
1321
- .replace(/opencode\.jso[?]/gi, "opencode.json")
1322
- .replace(/opencode\.[?*]/gi, "opencode.json");
1323
- }
1324
-
1325
- // Tamper patterns are evaluated per command segment (split on newlines, |, ;,
1326
- // &) so that tokens appearing on unrelated lines of a multi-line command can't
1327
- // combine into a false match. Patterns that target config PATHS require a
1328
- // write verb or redirect, so merely READING a config file (cat, less, grep)
1329
- // is allowed - only modification attempts are blocked.
1330
- const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
1331
- // Shell writes to opencode config paths - write verb/redirect required.
1332
- /(?:^|\s)(?:sed\s+-i|tee|mv|cp|rm|chmod|chown|ln|install|truncate|dd)\s+[^|;&]*?(?:[\w\/.~-]*opencode\.jsonc?|[\w\/.~-]*\.config\/opencode|[\w\/.~-]*\.opencode\/)/i,
1333
- />\s*["']?[\w\/.~-]*(?:opencode\.jsonc?|\.config\/opencode|\.opencode\/)/i,
1334
- // Editing the guard plugin itself or its TUI companion (any file,
1335
- // not just config) - self-protection.
1336
- /(?:^|\s)(?:sed\s+-i|tee|mv|cp|rm|chmod|chown|truncate|dd)\s+[^|;&]*?[\w\/.~-]*\.config\/opencode\/(?:plugins|ui)\//i,
1337
- />\s*["']?[\w\/.~-]*\.config\/opencode\/(?:plugins|ui)\//i,
1338
- // CLI config/auth commands - matched as command verbs, not bare words.
1339
- /\bopencode\s+(?:auth|config|permission)\b/i,
1340
- // Blanket auto-approval flags on the CLI.
1341
- /\bopencode\s+(?:run\s+)?--auto\b/i,
1342
- ];
1343
-
1344
- const PROTECTED_PATH_REASON =
1345
- "Blocked: modifying OpenCode config / permissions / auth / the guard " +
1346
- "plugin itself is not allowed from the agent. The user must change " +
1347
- "these manually in opencode.json or the UI.";
1348
-
1349
- function isSettingsTamper(command: string): boolean {
1350
- // Evaluate each segment independently so regexes cannot span lines or
1351
- // shell separators. Normalize evasion before matching.
1352
- const segments = command.split(/[\n|;&]+/).map((s) =>
1353
- normalizeGlobPathEvasion(normalizeShellEvasion(s)),
1354
- );
1355
- return segments.some((segment) =>
1356
- SETTINGS_TAMPER_PATTERNS.some((re) => re.test(segment)),
1357
- );
1358
- }
1359
-
1360
- // ── PR changelog check ───────────────────────────────────────────────────────
1361
- // Accepts either a CHANGELOG file modification or a .changeset/*.md file.
1362
-
1363
- const CHANGELOG_OR_CHANGESET_RE = /(?:changelog|\.changeset\/[^/]+\.md$)/i;
1364
-
1365
- function branchHasChangelogChange(root: string): boolean {
1366
- try {
1367
- const baseCandidates = ["origin/HEAD", "origin/main", "origin/master"];
1368
- for (const base of baseCandidates) {
1369
- const mergeBase = spawnSync(
1370
- "git",
1371
- ["merge-base", "HEAD", base],
1372
- { cwd: root, encoding: "utf8", timeout: 10_000 },
1373
- );
1374
- if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
1375
- const diff = spawnSync(
1376
- "git",
1377
- ["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`],
1378
- { cwd: root, encoding: "utf8", timeout: 10_000 },
1379
- );
1380
- if (diff.status !== 0) continue;
1381
- if (diff.stdout.split("\n").some((f) => CHANGELOG_OR_CHANGESET_RE.test(f))) {
1382
- return true;
1383
- }
1384
- }
1385
- // Last resort: diff against HEAD~1 (single-commit branches).
1386
- const last = spawnSync("git", ["diff", "--name-only", "HEAD~1"], {
1387
- cwd: root,
1388
- encoding: "utf8",
1389
- timeout: 10_000,
1390
- });
1391
- return (
1392
- last.status === 0 &&
1393
- last.stdout.split("\n").some((f) => CHANGELOG_OR_CHANGESET_RE.test(f))
1394
- );
1395
- } catch {
1396
- return false;
1397
- }
1398
- }
1399
-
1400
- // ── Documentation review & synchronization check (Policy 21) ─────────────────
1401
-
1402
- /**
1403
- * True for user-facing documentation files: README.md (root or package
1404
- * level) and anything under a docs/ directory. Deliberately excludes
1405
- * arbitrary markdown (e.g. .changeset/*.md, CHANGELOG.md) so the
1406
- * documentation gate cannot be satisfied by a changeset fragment.
1407
- */
1408
- function isDocumentationFile(filePath: string): boolean {
1409
- const lower = filePath.toLowerCase();
1410
- if (lower === "readme.md" || lower.endsWith("/readme.md")) return true;
1411
- if (lower === "docs" || lower.startsWith("docs/") || lower.includes("/docs/")) return true;
1412
- return false;
1413
- }
1414
-
1415
- export function branchHasDocumentationChange(root: string): boolean {
1416
- try {
1417
- const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
1418
- for (const base of baseCandidates) {
1419
- const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], {
1420
- cwd: root,
1421
- encoding: "utf8",
1422
- timeout: 5_000,
1423
- });
1424
- if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
1425
- const diff = spawnSync(
1426
- "git",
1427
- ["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`],
1428
- { cwd: root, encoding: "utf8", timeout: 8_000 },
1429
- );
1430
- if (diff.status !== 0) continue;
1431
- const files = diff.stdout.split("\n").filter(Boolean);
1432
- if (files.some((f) => isDocumentationFile(f))) {
1433
- return true;
1434
- }
1435
- }
1436
- const last = spawnSync("git", ["diff", "--name-only", "HEAD~1"], {
1437
- cwd: root,
1438
- encoding: "utf8",
1439
- timeout: 5_000,
1440
- });
1441
- if (last.status === 0) {
1442
- const files = last.stdout.split("\n").filter(Boolean);
1443
- return files.some((f) => isDocumentationFile(f));
1444
- }
1445
- return false;
1446
- } catch {
1447
- return false;
1448
- }
1449
- }
1450
-
1451
- // ── Package supply-chain & dependency hygiene guard (Policy 23) ──────────────
1452
- // AI agents frequently run dangerous package manager flags (e.g. npm audit fix --force,
1453
- // npm install --ignore-scripts, pip install --no-verify, global unversioned installs)
1454
- // that introduce breaking changes, downgrade packages insecurely, or execute arbitrary code.
1455
-
1456
- const PACKAGE_HYGIENE_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
1457
- {
1458
- regex: /\bnpm\s+audit\s+fix\s+--force\b/i,
1459
- name: "destructive npm audit fix --force",
1460
- advice: "Run 'npm audit' or update dependencies individually in package.json. '--force' introduces breaking major version downgrades.",
1461
- },
1462
- {
1463
- regex: /\b(?:npm|pnpm|yarn|bun)\s+(?:install|i|add)\s+-[a-zA-Z]*g\b/i,
1464
- name: "global package installation",
1465
- advice: "Install packages locally as devDependencies (--save-dev) or execute with npx/bunx rather than polluting global machine state.",
1466
- },
1467
- {
1468
- regex: /\bpip\s+install\s+(?:--upgrade\s+)?--force-reinstall\b/i,
1469
- name: "destructive pip force reinstall",
1470
- advice: "Specify exact versions in requirements.txt or pyproject.toml instead of force reinstalling.",
1471
- },
1472
- {
1473
- regex: /\b(?:npm|yarn|pnpm|bun)\s+publish\b/i,
1474
- name: "direct package publish from agent subshell",
1475
- advice: "Package publishing must be managed by automated release workflows or explicit user release commands.",
1476
- },
1477
- ];
1478
-
1479
- export function checkPackageHygiene(command: string): { isViolating: boolean; name?: string; advice?: string } {
1480
- for (const { regex, name, advice } of PACKAGE_HYGIENE_PATTERNS) {
1481
- if (regex.test(command)) {
1482
- return { isViolating: true, name, advice };
1483
- }
1484
- }
1485
- return { isViolating: false };
1486
- }
1487
- // AI agents running in non-interactive terminal subshells will hang indefinitely
1488
- // if an interactive prompt (e.g. vim, nano, less, top, sudo, npm init without -y,
1489
- // apt-get without -y, git rebase -i) waits for TTY input.
1490
-
1491
- const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
1492
- {
1493
- regex: /\b(?:nano|vim?|emacs|pico|joe|micro)\b/i,
1494
- name: "interactive text editor",
1495
- advice: "Use the edit, write, or apply_patch tools instead of interactive terminal editors.",
1496
- },
1497
- {
1498
- regex: /\b(?:less|more|most)\b/i,
1499
- name: "terminal pager",
1500
- advice: "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.",
1501
- },
1502
- {
1503
- regex: /\b(?:top|htop|btop|atop|glances)\b/i,
1504
- name: "interactive process monitor",
1505
- advice: "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.",
1506
- },
1507
- {
1508
- regex: /\bsudo\b/i,
1509
- name: "sudo with password prompt",
1510
- advice: "Avoid sudo in agent sessions; run commands directly or request user execution.",
1511
- },
1512
- {
1513
- regex: /\bgit\s+rebase\s+-[a-zA-Z]*i/i,
1514
- name: "git interactive rebase",
1515
- advice: "Use non-interactive git commands or explicit cherry-pick/merge sequences.",
1516
- },
1517
- {
1518
- regex: /\bgit\s+add\s+-[a-zA-Z]*p/i,
1519
- name: "git interactive patch",
1520
- advice: "Use git add <file> or git apply rather than interactive patch selection.",
1521
- },
1522
- {
1523
- regex: /\bnpm\s+init\b(?!\s+(?:-y|--yes|--force))\b/i,
1524
- name: "npm init without non-interactive flag",
1525
- advice: "Add -y or --yes flag: npm init -y",
1526
- },
1527
- {
1528
- regex: /\b(?:apt|apt-get)\s+(?:install|remove|purge|upgrade|dist-upgrade)\b(?!\s+(?:-y|--yes|--assume-yes))\b/i,
1529
- name: "apt command without non-interactive flag",
1530
- advice: "Add -y flag to avoid interactive confirmation prompts (e.g. apt-get install -y <pkg>).",
1531
- },
1532
- {
1533
- regex: /\b(?:yum|dnf)\s+(?:install|remove|upgrade)\b(?!\s+-y)\b/i,
1534
- name: "yum/dnf command without non-interactive flag",
1535
- advice: "Add -y flag to avoid interactive confirmation prompts (e.g. dnf install -y <pkg>).",
1536
- },
1537
- ];
1538
-
1539
- export function checkInteractiveTtyCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
1540
- for (const { regex, name, advice } of INTERACTIVE_COMMAND_PATTERNS) {
1541
- if (regex.test(command)) {
1542
- return { isInteractive: true, name, advice };
1543
- }
1544
- }
1545
- return { isInteractive: false };
1546
- }
1547
-
1548
- /**
1549
- * Dispatches a native OS desktop notification (macOS osascript or Linux notify-send)
1550
- * on important guard events (policy blocks, verification completion).
1551
- * Non-blocking, fails gracefully.
1552
- */
1553
- export function sendDesktopNotification(title: string, message: string): void {
1554
- if (process.env.WORKFLOW_GUARD_NOTIFY === "0") return;
1555
- const safeTitle = title.replace(/"/g, '\\"');
1556
- const safeMsg = message.slice(0, 150).replace(/"/g, '\\"');
1557
-
1558
- try {
1559
- if (process.platform === "darwin") {
1560
- const child = spawn(
1561
- "osascript",
1562
- ["-e", `display notification "${safeMsg}" with title "${safeTitle}"`],
1563
- { stdio: "ignore", detached: true },
1564
- );
1565
- child.on("error", () => {});
1566
- child.unref();
1567
- } else if (process.platform === "linux") {
1568
- const child = spawn(
1569
- "notify-send",
1570
- [safeTitle, safeMsg],
1571
- { stdio: "ignore", detached: true },
1572
- );
1573
- child.on("error", () => {});
1574
- child.unref();
1575
- }
1576
- } catch {}
1577
- }
1578
-
1579
- export function getWorktreeStorageDir(root = workspaceRoot): string {
1580
- const base = process.env.WORKFLOW_GUARD_WORKTREE_DIR ??
1581
- join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "worktrees");
1582
- const repoName = basename(root);
1583
- return join(base, repoName);
1584
- }
1585
-
1586
- /**
1587
- * Returns a copy of process.env with git context variables removed so spawned
1588
- * git commands resolve the repository from their cwd alone. Without this, a
1589
- * caller nested inside a git hook (which exports GIT_INDEX_FILE and friends)
1590
- * would make `git worktree add` fail: the internal checkout of the new
1591
- * worktree resolves the inherited relative GIT_INDEX_FILE against the new
1592
- * worktree root, where `.git` is a file, not a directory.
1593
- */
1594
- export function getCleanGitEnv(): Record<string, string> {
1595
- const env: Record<string, string> = { ...(process.env as Record<string, string>) };
1596
- for (const key of [
1597
- "GIT_INDEX_FILE",
1598
- "GIT_DIR",
1599
- "GIT_WORK_TREE",
1600
- "GIT_COMMON_DIR",
1601
- "GIT_OBJECT_DIRECTORY",
1602
- "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1603
- "GIT_PREFIX",
1604
- ]) {
1605
- delete env[key];
1606
- }
1607
- return env;
1608
- }
1609
-
1610
- /**
1611
- * Validates a branch name using git itself (`git check-ref-format
1612
- * --branch`), with fast local pre-filters for names that must never be
1613
- * passed as arguments at all (leading dash would be parsed as an option).
1614
- */
1615
- export function isValidBranchName(name: string): boolean {
1616
- if (!name || name.length > 255) return false;
1617
- if (name.startsWith("-") || name.startsWith(".") || name.endsWith(".") || name.endsWith(".lock")) return false;
1618
- if (name.includes("..") || name.includes("@{") || name.includes("//")) return false;
1619
- const res = spawnSync("git", ["check-ref-format", "--branch", name], {
1620
- encoding: "utf8",
1621
- timeout: 5_000,
1622
- env: getCleanGitEnv(),
1623
- });
1624
- return res.status === 0;
1625
- }
1626
-
1627
- /** True when `refs/heads/<branch>` exists in the repository at `root`. */
1628
- function localBranchExists(branch: string, root: string): boolean {
1629
- const res = spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
1630
- cwd: root,
1631
- encoding: "utf8",
1632
- timeout: 5_000,
1633
- env: getCleanGitEnv(),
1634
- });
1635
- return res.status === 0;
1636
- }
1637
-
1638
- export function createGitWorktree(
1639
- branch: string,
1640
- baseBranch = "HEAD",
1641
- root = workspaceRoot,
1642
- ): { success: boolean; worktreePath?: string; error?: string } {
1643
- if (!branch || !isValidBranchName(branch)) {
1644
- return { success: false, error: `Invalid branch name '${branch}' for worktree creation.` };
1645
- }
1646
- if (isProtectedBranchName(branch, root)) {
1647
- return { success: false, error: `Cannot create worktree on protected branch '${branch}'.` };
1648
- }
1649
-
1650
- const storageBase = getWorktreeStorageDir(root);
1651
- const targetPath = join(storageBase, branch.replace(/[/\\:]/g, "-"));
1652
-
1653
- try {
1654
- mkdirSync(storageBase, { recursive: true });
1655
-
1656
- const exists = localBranchExists(branch, root);
1657
-
1658
- const gitArgs = exists
1659
- ? ["worktree", "add", targetPath, branch]
1660
- : ["worktree", "add", "-b", branch, targetPath, baseBranch];
1661
-
1662
- const addRes = spawnSync("git", gitArgs, {
1663
- cwd: root,
1664
- env: getCleanGitEnv(),
1665
- encoding: "utf8",
1666
- timeout: 15_000,
1667
- });
1668
-
1669
- if (addRes.status !== 0) {
1670
- return { success: false, error: addRes.stderr.trim() || `git worktree add failed` };
1671
- }
1672
-
1673
- const nodeModulesSrc = join(root, "node_modules");
1674
- const nodeModulesDest = join(targetPath, "node_modules");
1675
- if (existsSync(nodeModulesSrc) && !existsSync(nodeModulesDest)) {
1676
- try {
1677
- symlinkSync(nodeModulesSrc, nodeModulesDest, "dir");
1678
- } catch {}
1679
- }
1680
-
1681
- return { success: true, worktreePath: targetPath };
1682
- } catch (e: any) {
1683
- return { success: false, error: e?.message ?? String(e) };
1684
- }
1685
- }
1686
-
1687
- /**
1688
- * Lists the worktree paths registered for the repository at `root`
1689
- * (primary worktree first), or undefined when git cannot be consulted.
1690
- */
1691
- function registeredWorktreePaths(root: string): string[] | undefined {
1692
- const res = spawnSync("git", ["worktree", "list", "--porcelain"], {
1693
- cwd: root,
1694
- encoding: "utf8",
1695
- timeout: 10_000,
1696
- env: getCleanGitEnv(),
1697
- });
1698
- if (res.status !== 0) return undefined;
1699
- return res.stdout
1700
- .split("\n")
1701
- .filter((line) => line.startsWith("worktree "))
1702
- .map((line) => line.slice("worktree ".length))
1703
- .filter((path) => path.length > 0);
1704
- }
1705
-
1706
- export function cleanupGitWorktree(
1707
- worktreePath: string,
1708
- root = workspaceRoot,
1709
- ): { success: boolean; error?: string } {
1710
- try {
1711
- if (!existsSync(worktreePath)) {
1712
- return { success: false, error: `Worktree path '${worktreePath}' does not exist.` };
1713
- }
1714
-
1715
- // Ownership validation: the path must live under the configured
1716
- // worktree storage directory AND be a registered worktree of this
1717
- // repository. This refuses arbitrary directories, other repos'
1718
- // worktrees, and the primary working tree - cleanup must never be
1719
- // an unrestricted delete primitive.
1720
- const resolved = resolve(worktreePath);
1721
- const storageBase = resolve(getWorktreeStorageDir(root));
1722
- if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
1723
- return {
1724
- success: false,
1725
- error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
1726
- };
1727
- }
1728
- const registered = registeredWorktreePaths(root);
1729
- if (!registered) {
1730
- return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
1731
- }
1732
- const resolvedRegistered = registered.map((path) => resolve(path));
1733
- if (!resolvedRegistered.includes(resolved)) {
1734
- return {
1735
- success: false,
1736
- error: `Refusing to clean up '${worktreePath}': not a registered worktree of this repository.`,
1737
- };
1738
- }
1739
- if (resolvedRegistered[0] === resolved) {
1740
- return { success: false, error: "Refusing to remove the primary working tree." };
1741
- }
1742
-
1743
- // Snapshot: stage and commit remaining changes. If the snapshot
1744
- // cannot be established (hook failure, missing identity, lock
1745
- // error, timeout), abort - never destroy uncommitted work.
1746
- const addArgs = ["add", "-A", "--", "."];
1747
- try {
1748
- // Exclude the plugin-created node_modules symlink so repos that
1749
- // do not ignore it never commit a machine-local link.
1750
- if (lstatSync(join(resolved, "node_modules")).isSymbolicLink()) {
1751
- addArgs.push(":(exclude)node_modules");
1752
- }
1753
- } catch {}
1754
- const addRes = spawnSync("git", addArgs, {
1755
- cwd: worktreePath,
1756
- env: getCleanGitEnv(),
1757
- encoding: "utf8",
1758
- timeout: 5_000,
1759
- });
1760
- if (addRes.status !== 0) {
1761
- return {
1762
- success: false,
1763
- error: `Snapshot failed (git add: ${(addRes.stderr ?? "").trim()}) - worktree left intact.`,
1764
- };
1765
- }
1766
- const commitRes = spawnSync(
1767
- "git",
1768
- ["commit", "-m", "chore(worktree): auto-snapshot before cleanup", "--allow-empty"],
1769
- { cwd: worktreePath, env: getCleanGitEnv(), encoding: "utf8", timeout: 5_000 },
1770
- );
1771
- if (commitRes.status !== 0) {
1772
- return {
1773
- success: false,
1774
- error: `Snapshot failed (git commit: ${(commitRes.stderr ?? "").trim().split("\n").pop()?.trim() ?? "unknown error"}) - worktree left intact.`,
1775
- };
1776
- }
1777
-
1778
- const res = spawnSync("git", ["worktree", "remove", "--force", worktreePath], {
1779
- cwd: root,
1780
- env: getCleanGitEnv(),
1781
- encoding: "utf8",
1782
- timeout: 10_000,
1783
- });
1784
- if (res.status !== 0) {
1785
- // A failed git removal is an error - never authorization for a
1786
- // raw recursive delete of the directory.
1787
- return { success: false, error: res.stderr.trim() || "git worktree remove failed." };
1788
- }
1789
-
1790
- return { success: true };
1791
- } catch (e: any) {
1792
- return { success: false, error: e?.message ?? String(e) };
1793
- }
1794
- }
1795
-
1796
- export function isDocumentationRequired(root: string): boolean {
1797
- if (process.env.WORKFLOW_GUARD_REQUIRE_DOCS === "1") return true;
1798
- const cfg = cachedProjectConfig ?? loadProjectConfig(root);
1799
- return cfg.requireDocumentation === true;
1800
- }
1801
-
1802
- function prBodyIncludesChangelog(command: string): boolean {
1803
- // Handle inline --body / --description / -d / -b "..." with a Changelog section.
1804
- const bodyMatch = command.match(
1805
- /(?:--body|--description|-d|-b)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
1806
- );
1807
- const body = bodyMatch?.[1] ?? bodyMatch?.[2] ?? bodyMatch?.[3] ?? "";
1808
- if (CHANGELOG_SECTION_RE.test(body)) {
1809
- return true;
1810
- }
1811
- // Handle --body-file <path> / --description-file <path> / -F <path>: read the referenced file.
1812
- const bodyFileMatch = command.match(
1813
- /(?:--body-file|--description-file|-F)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
1814
- );
1815
- const bodyFile =
1816
- bodyFileMatch?.[1] ?? bodyFileMatch?.[2] ?? bodyFileMatch?.[3];
1817
- if (bodyFile) {
1818
- try {
1819
- return CHANGELOG_SECTION_RE.test(
1820
- readFileSync(resolve(workspaceRoot, bodyFile), "utf8"),
1821
- );
1822
- } catch {
1823
- return false;
1824
- }
1825
- }
1826
- return false;
1827
- }
1828
-
1829
- // ── Secondary Agent Review Spoke ─────────────────────────────────────────────
1830
- // Supports multi-agent accountability and code review quality gates before PR
1831
- // or final delivery. Ties into the `code-review-and-quality` and
1832
- // `doubt-driven-development` agent skills with a structured evaluation rubric.
1833
-
1834
- let lastReview:
1835
- | {
1836
- passed: boolean;
1837
- reviewer: string;
1838
- summary: string;
1839
- timestamp: number;
1840
- targetSessionID?: string;
1841
- workspace?: string;
1842
- }
1843
- | undefined;
1844
- const sessionReviews = new Map<string, NonNullable<typeof lastReview>>();
1845
-
1846
- export function recordReviewResult(
1847
- reviewer: string,
1848
- summary: string,
1849
- passed: boolean,
1850
- targetSessionID?: string,
1851
- workspace?: string,
1852
- ): void {
1853
- lastReview = {
1854
- reviewer,
1855
- summary: summary.slice(-4000),
1856
- passed,
1857
- timestamp: Date.now(),
1858
- targetSessionID,
1859
- workspace,
1860
- };
1861
- if (targetSessionID) sessionReviews.set(targetSessionID, lastReview);
1862
- }
1863
-
1864
- export function getLastReviewResult(): typeof lastReview {
1865
- return lastReview;
1866
- }
1867
-
1868
- export function resetReviewState(): void {
1869
- lastReview = undefined;
1870
- sessionReviews.clear();
1871
- }
1872
-
1873
- /**
1874
- * Builds a structured, adversarial review rubric prompt for a secondary review agent
1875
- * or subagent (combining `code-review-and-quality` and `doubt-driven-development`).
1876
- */
1877
- export function buildReviewRubric(diffText: string, taskPrompt?: string): string {
1878
- return [
1879
- "# Secondary Review Agent Quality Gate",
1880
- "",
1881
- "Evaluate this code change independently with fresh context across these 5 core axes:",
1882
- "",
1883
- "### 1. Test Integrity & Truthfulness (CRITICAL)",
1884
- "- Are test assertions testing real behavioral outcomes rather than trivial passes (e.g. `expect(true).toBe(true)`)?",
1885
- "- Were existing tests disabled, bypassed, or weakened?",
1886
- "- Are edge cases and error paths covered?",
1887
- "",
1888
- "### 2. Task Completeness & Intent Alignment",
1889
- "- Does the implementation genuinely satisfy the user request without shortcut stubs (`// TODO`, `throw new Error('not implemented')`)?",
1890
- "- Does it introduce regressions in surrounding code?",
1891
- "",
1892
- "### 3. Code Cleanliness & Hygiene",
1893
- "- Is there any orphaned dead code, commented-out code blocks, or temporary debugging logs?",
1894
- "- Is the logic straightforward and free of unnecessary cognitive complexity?",
1895
- "",
1896
- "### 4. Security & Safety Boundaries",
1897
- "- Are there any hardcoded secrets, unprotected tokens, or unvalidated user inputs?",
1898
- "- Does the code respect workspace confinement and safe environment practices?",
1899
- "",
1900
- "### 5. Platform & Architecture Fit (GitHub & Azure DevOps)",
1901
- "- Does the change fit established repository patterns and CI/CD pipelines?",
1902
- "",
1903
- taskPrompt ? `### User Request / Context:\n${taskPrompt}\n` : "",
1904
- "### Code Diff Under Review:",
1905
- "```diff",
1906
- diffText.slice(0, 30_000),
1907
- "```",
1908
- "",
1909
- "Provide your verdict: `[APPROVE]` or `[REQUEST_CHANGES]` with concise, actionable findings.",
1910
- ].join("\n");
1911
- }
1912
-
1913
- // ── Core guard (exported for testing) ────────────────────────────────────────
1914
- // Returns a block reason string, or undefined when the call is allowed.
1915
-
1916
- // ── Post-edit verification gate ─────────────────────────────────────────────
1917
- // The guard blocks edits before they happen, and validates verification evidence
1918
- // before final completion. When the agent attempts to mark all tasks completed/cancelled,
1919
- // the guard ensures fresh verification has passed since the most recent mutation.
1920
-
1921
- let lastMutationTimestamp = 0;
1922
- let mutationCount = 0;
1923
- const sessionMutationTimestamps = new Map<string, number>();
1924
- const sessionMutationCounts = new Map<string, number>();
1925
- let lastVerify: {
1926
- passed: boolean;
1927
- command: string;
1928
- output: string;
1929
- timestamp: number;
1930
- durationMs?: number;
1931
- commitHash?: string;
1932
- gitStatus?: string;
1933
- workspaceRoot?: string;
1934
- } | undefined;
1935
- const sessionVerifyResults = new Map<string, NonNullable<typeof lastVerify>>();
1936
-
1937
- export function recordMutation(sessionID?: string): void {
1938
- lastMutationTimestamp = Date.now();
1939
- mutationCount++;
1940
- if (sessionID) {
1941
- sessionMutationTimestamps.set(sessionID, lastMutationTimestamp);
1942
- sessionMutationCounts.set(sessionID, (sessionMutationCounts.get(sessionID) ?? 0) + 1);
1943
- sessionVerifyResults.delete(sessionID);
1944
- }
1945
- if (sessionID) sessionReviews.delete(sessionID);
1946
- if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID) {
1947
- lastReview = undefined;
1948
- }
1949
- }
1950
-
1951
- export function getMutationCount(sessionID?: string): number {
1952
- if (sessionID && sessionMutationCounts.has(sessionID)) {
1953
- return sessionMutationCounts.get(sessionID) ?? 0;
1954
- }
1955
- return mutationCount;
1956
- }
1957
-
1958
- export function getLastMutationTimestamp(): number {
1959
- return lastMutationTimestamp;
1960
- }
1961
-
1962
- export function getLastVerifyResult(): typeof lastVerify {
1963
- return lastVerify;
1964
- }
1965
-
1966
- export function resetVerifyState(): void {
1967
- lastMutationTimestamp = 0;
1968
- mutationCount = 0;
1969
- lastVerify = undefined;
1970
- sessionMutationTimestamps.clear();
1971
- sessionMutationCounts.clear();
1972
- sessionVerifyResults.clear();
1973
- }
1974
-
1975
- // Sensitive environment variables scrubbed from agent shells by the
1976
- // shell.env hook. Prefix matches (AWS_*, KUBE*) and exact names are both
1977
- // listed; the exec-check RE handles the prefix family.
1978
- const SENSITIVE_ENV_KEYS = [
1979
- "GITHUB_TOKEN",
1980
- "GH_TOKEN",
1981
- "OPENAI_API_KEY",
1982
- "ANTHROPIC_API_KEY",
1983
- "OPENAI_KEY",
1984
- "KUBECONFIG",
1985
- "NPM_TOKEN",
1986
- "DOCKER_AUTH",
1987
- "GOOGLE_APPLICATION_CREDENTIALS",
1988
- "GCLOUD_AUTH",
1989
- "AZURE_CREDENTIALS",
1990
- "SLACK_TOKEN",
1991
- ];
1992
- const SENSITIVE_ENV_RE = /^(AWS_|KUBE|OPENAI|ANTHROPIC|GH_|GITHUB_|GOOGLE_|GCP_|AZURE_|SLACK_|NPM_|DOCKER_|KUBECONFIG)/;
1993
-
1994
- export function getCleanEnv(): Record<string, string> {
1995
- const env: Record<string, string> = { ...(process.env as Record<string, string>) };
1996
- for (const key of SENSITIVE_ENV_KEYS) {
1997
- delete env[key];
1998
- }
1999
- for (const key of Object.keys(env)) {
2000
- if (SENSITIVE_ENV_RE.test(key)) {
2001
- delete env[key];
2002
- }
2003
- }
2004
- return env;
2005
- }
2006
-
2007
- /**
2008
- * Truncates and snips verification stdout/stderr for token efficiency.
2009
- * When a test suite or compiler passes, verbose log lines are trimmed to a concise summary.
2010
- * When it fails, failure context, error markers, and stack traces are prioritized.
7
+ * Orchestrator: imports policy modules from ./policies/ and engine services
8
+ * from ./lib/, and re-exports the public helper surface for tests.
2011
9
  */
2012
- export function snipVerifyOutput(output: string, passed: boolean, maxLines = 40): string {
2013
- if (!output) return "(no output)";
2014
- const lines = output.trim().split("\n");
2015
- if (lines.length <= maxLines) return output.trim();
2016
-
2017
- if (passed) {
2018
- const head = lines.slice(0, 5).join("\n");
2019
- const tail = lines.slice(-15).join("\n");
2020
- return `${head}\n... [${lines.length - 20} lines omitted - all tests passing] ...\n${tail}`;
2021
- }
2022
-
2023
- // On failure, preserve stack traces, FAIL/Error keywords, and tail
2024
- const errorLines: string[] = [];
2025
- for (const line of lines) {
2026
- if (/(?:fail|error|exception|panic|assert|reject|expected|received|err:)/i.test(line)) {
2027
- errorLines.push(line);
2028
- }
2029
- }
2030
-
2031
- const head = lines.slice(0, 5).join("\n");
2032
- const tail = lines.slice(-20).join("\n");
2033
- const errorSummary = errorLines.slice(-15).join("\n");
2034
-
2035
- return [
2036
- head,
2037
- `\n--- [Verification Failed: ${lines.length} total lines captured] ---`,
2038
- errorSummary ? `Key Failures:\n${errorSummary}` : "",
2039
- `Output Tail:\n${tail}`,
2040
- ]
2041
- .filter(Boolean)
2042
- .join("\n");
2043
- }
2044
-
2045
- export function getCurrentGitCommitHash(root: string): string | undefined {
2046
- try {
2047
- const res = spawnSync("git", ["rev-parse", "HEAD"], {
2048
- cwd: root,
2049
- encoding: "utf8",
2050
- timeout: 5_000,
2051
- });
2052
- if (res.status === 0 && res.stdout.trim()) {
2053
- return res.stdout.trim();
2054
- }
2055
- } catch {}
2056
- return undefined;
2057
- }
2058
-
2059
- export function getGitStatusSummary(root: string): string | undefined {
2060
- try {
2061
- const res = spawnSync("git", ["status", "--porcelain"], {
2062
- cwd: root,
2063
- encoding: "utf8",
2064
- timeout: 5_000,
2065
- });
2066
- if (res.status === 0) {
2067
- return res.stdout.trim();
2068
- }
2069
- } catch {}
2070
- return undefined;
2071
- }
2072
10
 
2073
- function detectVerifyCommand(root: string): string | undefined {
2074
- if (process.env.WORKFLOW_GUARD_VERIFY !== undefined) {
2075
- const cmd = process.env.WORKFLOW_GUARD_VERIFY.trim();
2076
- return cmd || undefined; // empty string = "disabled"
2077
- }
2078
- const cfg = cachedProjectConfig ?? loadProjectConfig(root);
2079
- if (typeof cfg.verifyCommand === "string") {
2080
- const cmd = cfg.verifyCommand.trim();
2081
- return cmd || undefined;
2082
- }
2083
- try {
2084
- const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
2085
- scripts?: Record<string, string>;
2086
- };
2087
- if (pkg.scripts?.test) return "npm test";
2088
- } catch {}
2089
- return undefined;
2090
- }
11
+ import { tool, type Plugin, type PluginModule } from "@opencode-ai/plugin";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+
15
+ // ── Types ────────────────────────────────────────────────────────────────────
16
+ export type {
17
+ TodoItem,
18
+ TodoSdkClient,
19
+ ProjectConfig,
20
+ VerifyResult,
21
+ ReviewResult,
22
+ AuditEntry,
23
+ GitInvocation,
24
+ ShellMutation,
25
+ } from "./lib/types.ts";
26
+
27
+ // ── State & runtime context ──────────────────────────────────────────────────
28
+ import {
29
+ setWorkspaceRoot,
30
+ getWorkspaceRoot,
31
+ setSdkClient,
32
+ getSdkClient,
33
+ runWithRuntimeState,
34
+ lastMutationTimestamp,
35
+ getLastMutationTimestamp,
36
+ getMutationCount,
37
+ lastVerify,
38
+ getLastVerifyResult,
39
+ recordMutation,
40
+ recordVerifyResult,
41
+ resetVerifyState,
42
+ sessionMutationTimestamps,
43
+ sessionVerifyResults,
44
+ loadProjectConfig,
45
+ reloadProjectConfig,
46
+ isReviewRequired,
47
+ isDocumentationRequired,
48
+ recordReviewResult,
49
+ getLastReviewResult,
50
+ resetReviewState,
51
+ sessionReviews,
52
+ } from "./lib/state.ts";
53
+
54
+ export {
55
+ setWorkspaceRoot,
56
+ getWorkspaceRoot,
57
+ setSdkClient,
58
+ getLastMutationTimestamp,
59
+ getMutationCount,
60
+ getLastVerifyResult,
61
+ recordMutation,
62
+ recordVerifyResult,
63
+ resetVerifyState,
64
+ loadProjectConfig,
65
+ reloadProjectConfig,
66
+ isReviewRequired,
67
+ isDocumentationRequired,
68
+ recordReviewResult,
69
+ getLastReviewResult,
70
+ resetReviewState,
71
+ };
2091
72
 
2092
- export async function runVerify(
2093
- command: string,
2094
- root: string,
2095
- timeoutMs = 30_000,
2096
- ): Promise<{ passed: boolean; output: string; durationMs: number }> {
2097
- const start = Date.now();
2098
- const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
73
+ // ── Shared shell/env utilities ───────────────────────────────────────────────
74
+ import {
75
+ asRecord,
76
+ extractCommands,
77
+ normalize,
78
+ splitShellSegments,
79
+ getCleanEnv,
80
+ showBlockToast,
81
+ SENSITIVE_ENV_KEYS,
82
+ SENSITIVE_ENV_RE,
83
+ } from "./lib/utils.ts";
84
+
85
+ export { getCleanEnv };
86
+
87
+ // ── Audit & durable verification cache ───────────────────────────────────────
88
+ import {
89
+ getAuditFilePath,
90
+ getVerifyCacheFilePath,
91
+ persistVerifyCache,
92
+ loadVerifyCache,
93
+ getRecentAuditEntries,
94
+ audit,
95
+ logDecision,
96
+ summarizeInput,
97
+ } from "./lib/audit.ts";
98
+
99
+ export { getAuditFilePath, getVerifyCacheFilePath, persistVerifyCache, loadVerifyCache, getRecentAuditEntries };
100
+
101
+ // ── Verification engine ──────────────────────────────────────────────────────
102
+ import {
103
+ detectVerifyCommand,
104
+ runVerify,
105
+ snipVerifyOutput,
106
+ getCurrentGitCommitHash,
107
+ getGitStatusSummary,
108
+ } from "./lib/verify.ts";
109
+
110
+ export {
111
+ detectVerifyCommand,
112
+ runVerify,
113
+ snipVerifyOutput,
114
+ getCurrentGitCommitHash,
115
+ getGitStatusSummary,
116
+ };
2099
117
 
2100
- // Guard against privileged execution of destructive commands or settings tampering in verify scripts
2101
- if (!allowLive) {
2102
- const normalized = normalizeGitCommands(normalize(command));
2103
- const liveCheck = liveMutationIn(normalized);
2104
- if (liveCheck) {
2105
- return {
2106
- passed: false,
2107
- output: `Verification command blocked: contains live destructive command (${liveCheck})`,
2108
- durationMs: 0,
2109
- };
2110
- }
2111
- if (isSettingsTamper(command)) {
2112
- return {
2113
- passed: false,
2114
- output: "Verification command blocked: contains settings tamper command",
2115
- durationMs: 0,
2116
- };
2117
- }
2118
- }
118
+ // ── Secondary review rubric ──────────────────────────────────────────────────
119
+ import { buildReviewRubric } from "./lib/review.ts";
120
+ export { buildReviewRubric };
121
+
122
+ // ── Git worktree lifecycle ───────────────────────────────────────────────────
123
+ import {
124
+ getWorktreeStorageDir,
125
+ getCleanGitEnv,
126
+ isValidBranchName,
127
+ createGitWorktree,
128
+ cleanupGitWorktree,
129
+ } from "./lib/worktree.ts";
130
+
131
+ export {
132
+ getWorktreeStorageDir,
133
+ getCleanGitEnv,
134
+ isValidBranchName,
135
+ createGitWorktree,
136
+ cleanupGitWorktree,
137
+ };
2119
138
 
2120
- return new Promise((resolve) => {
2121
- let output = "";
2122
- let timer: NodeJS.Timeout | undefined;
2123
- let child: ReturnType<typeof spawn>;
2124
- try {
2125
- child = spawn(command, {
2126
- cwd: root,
2127
- shell: true,
2128
- env: getCleanEnv(),
2129
- stdio: ["ignore", "pipe", "pipe"],
2130
- });
2131
- } catch (err: any) {
2132
- return resolve({
2133
- passed: false,
2134
- output: `(spawn failed: ${err?.message ?? "unknown error"})`,
2135
- durationMs: Date.now() - start,
2136
- });
2137
- }
139
+ // ── Policy modules ───────────────────────────────────────────────────────────
140
+ import {
141
+ EDIT_TOOL_NAMES,
142
+ validateTodoLifecycle,
143
+ fetchSessionTodos,
144
+ fetchParentSessionID,
145
+ effectiveTodos,
146
+ effectiveTodoOwnerSessionID,
147
+ hasActiveTodo,
148
+ } from "./policies/todo.ts";
149
+
150
+ export { validateTodoLifecycle };
151
+
152
+ import {
153
+ PUSH_TO_MAIN_RE,
154
+ GIT_WRITE_RE,
155
+ GIT_BRANCH_CREATE_RE,
156
+ currentGitBranch,
157
+ onProtectedBranch,
158
+ isProtectedBranchName,
159
+ pushedProtectedBranchIn,
160
+ branchGuardReason,
161
+ parseGitInvocation,
162
+ normalizeGitCommands,
163
+ isBranchAlreadyMergedOrClosed,
164
+ checkMergeConflicts,
165
+ checkBranchBaseIsUpToDate,
166
+ } from "./policies/git.ts";
167
+
168
+ export {
169
+ isProtectedBranchName,
170
+ isBranchAlreadyMergedOrClosed,
171
+ checkMergeConflicts,
172
+ checkBranchBaseIsUpToDate,
173
+ };
2138
174
 
2139
- timer = setTimeout(() => {
2140
- try {
2141
- child.kill("SIGKILL");
2142
- } catch {}
2143
- resolve({
2144
- passed: false,
2145
- output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
2146
- durationMs: Date.now() - start,
2147
- });
2148
- }, timeoutMs);
175
+ import {
176
+ hasPrCreateInvocation,
177
+ branchHasChangelogChange,
178
+ prBodyIncludesChangelog,
179
+ } from "./policies/changelog.ts";
2149
180
 
2150
- child.stdout?.on("data", (d) => {
2151
- output += d.toString();
2152
- if (output.length > 50_000) output = output.slice(-50_000);
2153
- });
2154
- child.stderr?.on("data", (d) => {
2155
- output += d.toString();
2156
- if (output.length > 50_000) output = output.slice(-50_000);
2157
- });
2158
- child.on("close", (code) => {
2159
- if (timer) clearTimeout(timer);
2160
- resolve({ passed: code === 0, output, durationMs: Date.now() - start });
2161
- });
2162
- child.on("error", (err) => {
2163
- if (timer) clearTimeout(timer);
2164
- resolve({ passed: false, output: `(spawn failed: ${err.message})`, durationMs: Date.now() - start });
2165
- });
2166
- });
2167
- }
181
+ import { liveMutationIn, extractEditContent } from "./policies/destructive.ts";
2168
182
 
2169
- const AUDIT_DIR = join(
2170
- process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"),
2171
- "opencode",
2172
- "workflow-guard",
2173
- );
2174
- const AUDIT_FILE = join(AUDIT_DIR, "workflow-guard.jsonl");
2175
- const VERIFY_CACHE_FILE = join(AUDIT_DIR, "last-verify.json");
183
+ import { mcpMutationTool } from "./policies/mcp.ts";
2176
184
 
2177
- export function getAuditFilePath(): string {
2178
- return AUDIT_FILE;
2179
- }
185
+ import {
186
+ PROTECTED_PATH_REASON,
187
+ isProtectedPath,
188
+ isSettingsTamper,
189
+ } from "./policies/tamper.ts";
2180
190
 
2181
- export function getVerifyCacheFilePath(): string {
2182
- return VERIFY_CACHE_FILE;
2183
- }
191
+ export { isProtectedPath };
2184
192
 
2185
- /**
2186
- * Persists passing verification evidence to disk so session restarts
2187
- * or multi-agent handoffs retain valid verification state.
2188
- */
2189
- export function persistVerifyCache(verifyData: NonNullable<typeof lastVerify>): void {
2190
- try {
2191
- mkdirSync(AUDIT_DIR, { recursive: true });
2192
- writeFileSync(VERIFY_CACHE_FILE, JSON.stringify(verifyData, null, 2), "utf8");
2193
- } catch {}
2194
- }
193
+ import {
194
+ isPathOutsideWorkspace,
195
+ extractPatchPaths,
196
+ guardShellMutation,
197
+ } from "./policies/boundary.ts";
2195
198
 
2196
- /**
2197
- * Loads durable verification evidence from disk if present and fresh.
2198
- */
2199
- export function loadVerifyCache(): typeof lastVerify {
2200
- try {
2201
- if (!existsSync(VERIFY_CACHE_FILE)) return undefined;
2202
- const raw = readFileSync(VERIFY_CACHE_FILE, "utf8");
2203
- const data = JSON.parse(raw);
2204
- if (data && typeof data.command === "string" && typeof data.timestamp === "number") {
2205
- return data;
2206
- }
2207
- } catch {}
2208
- return undefined;
2209
- }
199
+ import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMaskedEnvSchema } from "./policies/secrets.ts";
2210
200
 
2211
- export function recordVerifyResult(
2212
- command: string,
2213
- result: { passed: boolean; output: string; durationMs?: number },
2214
- sessionID?: string,
2215
- root = workspaceRoot,
2216
- ): void {
2217
- const snippedOutput = snipVerifyOutput(result.output, result.passed);
2218
- const commitHash = getCurrentGitCommitHash(root);
2219
- const gitStatus = getGitStatusSummary(root);
2220
-
2221
- lastVerify = {
2222
- command,
2223
- passed: result.passed,
2224
- output: snippedOutput,
2225
- timestamp: Date.now(),
2226
- durationMs: result.durationMs,
2227
- commitHash,
2228
- workspaceRoot: resolve(root),
2229
- gitStatus,
2230
- };
2231
- if (sessionID && lastVerify) sessionVerifyResults.set(sessionID, lastVerify);
2232
- if (lastVerify.passed) {
2233
- persistVerifyCache(lastVerify);
2234
- }
2235
- }
201
+ export { isSecretPath, isEnvFilePath, generateMaskedEnvSchema };
2236
202
 
2237
- export function getRecentAuditEntries(limit = 10): AuditEntry[] {
2238
- try {
2239
- if (!existsSync(AUDIT_FILE)) return [];
2240
- const lines = readFileSync(AUDIT_FILE, "utf8").trim().split("\n");
2241
- const entries: AuditEntry[] = [];
2242
- for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
2243
- const line = lines[i]?.trim();
2244
- if (line) {
2245
- try {
2246
- entries.push(JSON.parse(line));
2247
- } catch {}
2248
- }
2249
- }
2250
- return entries;
2251
- } catch {
2252
- return [];
2253
- }
2254
- }
203
+ import { extractInterpreterPayload } from "./policies/interpreter.ts";
204
+ export { extractInterpreterPayload };
2255
205
 
2256
- export interface AuditEntry {
2257
- ts: string;
2258
- sessionID?: string;
2259
- tool: string;
2260
- decision: "allow" | "block";
2261
- reason?: string;
2262
- input?: unknown;
2263
- evidence?: {
2264
- mutation?: boolean;
2265
- targetPath?: string;
2266
- verification?: {
2267
- command: string;
2268
- passed: boolean;
2269
- fresh: boolean;
2270
- durationMs?: number;
2271
- };
2272
- allowLive?: boolean;
2273
- };
2274
- }
206
+ import { branchHasDocumentationChange } from "./policies/docs.ts";
207
+ export { branchHasDocumentationChange };
2275
208
 
2276
- function audit(entry: AuditEntry): void {
2277
- try {
2278
- mkdirSync(AUDIT_DIR, { recursive: true });
2279
- appendFileSync(AUDIT_FILE, JSON.stringify(entry) + "\n", "utf8");
2280
- } catch {
2281
- // Logging must never break the guard.
2282
- }
2283
- }
209
+ import {
210
+ checkInteractiveTtyCommand,
211
+ checkPackageHygiene,
212
+ sendDesktopNotification,
213
+ } from "./policies/shell-safety.ts";
2284
214
 
2285
- function logDecision(
2286
- tool: string,
2287
- input: unknown,
2288
- context: { sessionID?: string } | undefined,
2289
- reason: string | undefined,
2290
- evidence?: AuditEntry["evidence"],
2291
- ): void {
2292
- audit({
2293
- ts: new Date().toISOString(),
2294
- sessionID: context?.sessionID,
2295
- tool,
2296
- decision: reason ? "block" : "allow",
2297
- reason,
2298
- input: summarizeInput(input),
2299
- evidence,
2300
- });
2301
- }
215
+ export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification };
2302
216
 
2303
- function summarizeInput(input: unknown): unknown {
2304
- const record = asRecord(input);
2305
- if (!record) return typeof input === "string" ? input.slice(0, 200) : input;
2306
- if (typeof record.command === "string") return { command: record.command.slice(0, 200) };
2307
- if (typeof record.filePath === "string") return { filePath: record.filePath };
2308
- if (typeof record.path === "string") return { path: record.path };
2309
- if (typeof record.patchText === "string") return { patchText: record.patchText.slice(0, 200) };
2310
- return { keys: Object.keys(record) };
2311
- }
217
+ const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
2312
218
 
2313
219
  function logBlock(message: string): void {
2314
- // In OpenCode TUI, writing to process.stderr / console.error writes directly
2315
- // to the terminal screen and pollutes the interactive chat prompt input box.
2316
- // We route blocked action logs to the OpenCode app logger instead.
2317
220
  try {
2318
- (sdkClient as any)?.app?.log?.({
221
+ void (getSdkClient() as any)?.app?.log?.({
2319
222
  body: {
2320
223
  service: "workflow-guard",
2321
224
  level: "warn",
@@ -2330,8 +233,10 @@ async function guardToolCallImpl(
2330
233
  input: unknown,
2331
234
  context?: { sessionID?: string },
2332
235
  ): Promise<string | undefined> {
2333
- // ── Policy 17: Secret file READ guard (.env*, keys, kubeconfig, credentials) ──
2334
- if (toolName === "read") {
236
+ const currentRoot = getWorkspaceRoot();
237
+
238
+ // ── Policy 17: secret file read block via read tool ──
239
+ if (toolName === "read" || toolName === "read_file") {
2335
240
  const record = asRecord(input);
2336
241
  const target =
2337
242
  typeof record?.filePath === "string"
@@ -2346,7 +251,7 @@ async function guardToolCallImpl(
2346
251
  if (isEnvFilePath(target)) {
2347
252
  let schemaHint = "";
2348
253
  try {
2349
- const resolvedTarget = resolve(workspaceRoot, target);
254
+ const resolvedTarget = resolve(currentRoot, target);
2350
255
  if (existsSync(resolvedTarget)) {
2351
256
  const raw = readFileSync(resolvedTarget, "utf8");
2352
257
  const masked = generateMaskedEnvSchema(raw);
@@ -2366,12 +271,12 @@ async function guardToolCallImpl(
2366
271
  return undefined;
2367
272
  }
2368
273
 
2369
- // ── Policy 1: todowrite lifecycle & focus validation ──
274
+ // ── Policy 1: todowrite lifecycle validation & finalization gates ──
2370
275
  if (toolName === "todowrite") {
2371
276
  const record = asRecord(input);
2372
277
  const rawTodos = record?.todos;
2373
278
  if (Array.isArray(rawTodos)) {
2374
- const newTodos = rawTodos as TodoItem[];
279
+ const newTodos = rawTodos as any[];
2375
280
  const existingTodos = context?.sessionID
2376
281
  ? await fetchSessionTodos(context.sessionID)
2377
282
  : undefined;
@@ -2381,10 +286,6 @@ async function guardToolCallImpl(
2381
286
  return err;
2382
287
  }
2383
288
 
2384
- // Final-completion gate (Policy 10): when EVERY task is being marked
2385
- // done, run verification on-demand if a verify command is configured.
2386
- // This replaces the old background-per-edit approach with a single
2387
- // verification run at finalization time.
2388
289
  const allDone =
2389
290
  newTodos.length > 0 &&
2390
291
  newTodos.every((t) => {
@@ -2392,49 +293,45 @@ async function guardToolCallImpl(
2392
293
  return s === "completed" || s === "cancelled";
2393
294
  });
2394
295
  if (allDone) {
2395
- // Conflict-free mergeability gate before task completion/handoff
2396
- const conflictCheck = checkMergeConflicts(workspaceRoot);
296
+ // Policy 19: conflict-free mergeability gate before task completion/handoff
297
+ const conflictCheck = checkMergeConflicts(currentRoot);
2397
298
  if (conflictCheck.hasConflicts) {
2398
299
  const reason = `Blocked todowrite: ${conflictCheck.reason}`;
2399
300
  logBlock(`[workflow-guard] ${reason}`);
2400
301
  return reason;
2401
302
  }
2402
303
 
2403
- const command = detectVerifyCommand(workspaceRoot);
304
+ // Policy 10: evidence-based verification
305
+ const command = detectVerifyCommand(currentRoot);
2404
306
  if (command) {
2405
307
  const sessionID = context?.sessionID;
2406
308
  let verifyResult = sessionID
2407
309
  ? sessionVerifyResults.get(sessionID)
2408
310
  : lastVerify;
2409
311
 
2410
- // If in-memory cache is missing, attempt to recover from durable disk cache.
2411
- // Durable evidence is bound to the workspace that produced it: without
2412
- // this check a passing run from a different project (identical verify
2413
- // command, non-git state) could satisfy finalization here.
2414
- if (!verifyResult) {
2415
- const diskCached = loadVerifyCache();
2416
- if (
2417
- diskCached &&
2418
- diskCached.passed &&
2419
- diskCached.command === command &&
2420
- diskCached.workspaceRoot === resolve(workspaceRoot)
2421
- ) {
2422
- verifyResult = diskCached;
312
+ // Recover durable evidence from disk when in-memory state is missing.
313
+ // Durable evidence is bound to the workspace that produced it: without
314
+ // this check a passing run from a different project (identical verify
315
+ // command, non-git state) could satisfy finalization here.
316
+ if (!verifyResult) {
317
+ const diskCached = loadVerifyCache();
318
+ if (
319
+ diskCached &&
320
+ diskCached.passed &&
321
+ diskCached.command === command &&
322
+ diskCached.workspaceRoot === resolve(currentRoot)
323
+ ) {
324
+ verifyResult = diskCached;
325
+ }
2423
326
  }
2424
- }
2425
327
 
2426
328
  const mutationTimestamp = sessionID
2427
329
  ? (sessionMutationTimestamps.get(sessionID) ?? 0)
2428
330
  : lastMutationTimestamp;
2429
331
 
2430
- const currentCommit = getCurrentGitCommitHash(workspaceRoot);
2431
- const currentGitStatus = getGitStatusSummary(workspaceRoot);
332
+ const currentCommit = getCurrentGitCommitHash(currentRoot);
333
+ const currentGitStatus = getGitStatusSummary(currentRoot);
2432
334
 
2433
- // Verification freshness requires:
2434
- // 1. Result passed
2435
- // 2. Matching verify command
2436
- // 3. Timestamp is equal to or newer than the latest mutation
2437
- // 4. Git commit and git status matches the state when verified (if git repo)
2438
335
  const gitStateMatches =
2439
336
  !verifyResult?.commitHash ||
2440
337
  (verifyResult.commitHash === currentCommit &&
@@ -2448,10 +345,10 @@ async function guardToolCallImpl(
2448
345
  gitStateMatches;
2449
346
 
2450
347
  if (!isFresh) {
2451
- const result = await runVerify(command, workspaceRoot);
2452
- recordVerifyResult(command, result, sessionID, workspaceRoot);
348
+ const result = await runVerify(command, currentRoot);
349
+ recordVerifyResult(command, result, sessionID, currentRoot);
2453
350
  if (!result.passed) {
2454
- const tail = result.output.slice(-500);
351
+ const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
2455
352
  const reason =
2456
353
  `Blocked todowrite: all tasks marked done but verification is failing ` +
2457
354
  `(${command}). Fix the failure before finishing; ` +
@@ -2479,8 +376,6 @@ async function guardToolCallImpl(
2479
376
  ? input
2480
377
  : "";
2481
378
 
2482
- // Settings tamper via edit tools (P0.2): editing opencode config or
2483
- // the guard's own plugin files is blocked even via the edit tools.
2484
379
  if (target && isProtectedPath(target)) {
2485
380
  logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
2486
381
  return PROTECTED_PATH_REASON;
@@ -2508,26 +403,22 @@ async function guardToolCallImpl(
2508
403
  );
2509
404
  return `Blocked: modifying secret file '${patchPath}' directly is not permitted.`;
2510
405
  }
2511
- if (isPathOutsideWorkspace(patchPath, workspaceRoot)) {
406
+ if (isPathOutsideWorkspace(patchPath, currentRoot)) {
2512
407
  logBlock(
2513
408
  `[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`,
2514
409
  );
2515
- return `Blocked: patch targets file '${patchPath}' outside workspace root (${workspaceRoot}).`;
410
+ return `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`;
2516
411
  }
2517
412
  }
2518
413
  }
2519
414
 
2520
- // Workspace boundary check (path traversal guard)
2521
- if (target && isPathOutsideWorkspace(target, workspaceRoot)) {
415
+ if (target && isPathOutsideWorkspace(target, currentRoot)) {
2522
416
  logBlock(
2523
417
  `[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`,
2524
418
  );
2525
- return `Blocked: file path '${target}' escapes workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
419
+ return `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`;
2526
420
  }
2527
421
 
2528
- // Secret-content scan (Policy 12): common credential material must
2529
- // never be written into the repo. The agent often has access to env
2530
- // vars the user does NOT want committed; block at the write step.
2531
422
  for (const content of extractEditContent(input)) {
2532
423
  const secret = secretIn(content);
2533
424
  if (secret) {
@@ -2543,16 +434,10 @@ async function guardToolCallImpl(
2543
434
  }
2544
435
  }
2545
436
 
2546
- // Script-laundering guard (P0.5): file payloads that contain
2547
- // destructive commands or tamper instructions are blocked, so an
2548
- // agent cannot smuggle a blocked command into a script file and run
2549
- // it through the shell. We scan line-by-line and skip comment lines
2550
- // to reduce false positives on documentation.
2551
437
  if (!allowLive) {
2552
438
  for (const content of extractEditContent(input)) {
2553
439
  const lines = content.split("\n");
2554
440
  for (const line of lines) {
2555
- // Skip comment lines (bash, js, python, etc.)
2556
441
  const trimmed = line.trim();
2557
442
  if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
2558
443
  continue;
@@ -2582,9 +467,9 @@ async function guardToolCallImpl(
2582
467
  }
2583
468
  }
2584
469
 
2585
- if (onProtectedBranch(workspaceRoot)) {
470
+ if (onProtectedBranch(currentRoot)) {
2586
471
  logBlock(
2587
- `[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(workspaceRoot)}`,
472
+ `[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`,
2588
473
  );
2589
474
  return branchGuardReason();
2590
475
  }
@@ -2596,7 +481,7 @@ async function guardToolCallImpl(
2596
481
  return (
2597
482
  "Blocked: no active todo item. First break the request down " +
2598
483
  "with the todowrite tool (create items with status 'pending' " +
2599
- "or 'in_progress'), then work them top to bottom, marking " +
484
+ "or 'in_progress'), then work them through, marking " +
2600
485
  "each completed via todowrite as you finish it. When every " +
2601
486
  "item is completed, create a fresh todo list before " +
2602
487
  "starting new work."
@@ -2609,7 +494,6 @@ async function guardToolCallImpl(
2609
494
  const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
2610
495
 
2611
496
  // ── Policy 5: MCP tools that mutate GitHub / Azure / DevOps ──
2612
- // MCP calls carry no command string, so the only override is the env var.
2613
497
  if (!allowLive) {
2614
498
  const mcpWhat = mcpMutationTool(toolName);
2615
499
  if (mcpWhat) {
@@ -2634,7 +518,7 @@ async function guardToolCallImpl(
2634
518
  for (const raw of commands) {
2635
519
  const command = normalize(raw);
2636
520
 
2637
- // ── Policy 22: Non-interactive shell & TTY hang guard ───────
521
+ // ── Policy 22: non-interactive shell & TTY hang guard ──
2638
522
  const ttyCheck = checkInteractiveTtyCommand(command);
2639
523
  if (ttyCheck.isInteractive) {
2640
524
  logBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
@@ -2644,7 +528,7 @@ async function guardToolCallImpl(
2644
528
  );
2645
529
  }
2646
530
 
2647
- // ── Policy 23: Package supply-chain & dependency hygiene guard ────
531
+ // ── Policy 23: package supply-chain & dependency hygiene guard ──
2648
532
  const packageCheck = checkPackageHygiene(command);
2649
533
  if (packageCheck.isViolating && !allowLive) {
2650
534
  logBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
@@ -2654,27 +538,26 @@ async function guardToolCallImpl(
2654
538
  );
2655
539
  }
2656
540
 
2657
- // Rewrite `git [global-opts] <sub>` to `git <sub>` so global flags
2658
- // cannot smuggle a guarded subcommand past the regexes.
2659
541
  const normalizedCommand = normalizeGitCommands(command);
2660
542
  const gitInvocations = splitShellSegments(command)
2661
543
  .map((segment) => parseGitInvocation(segment.trim()))
2662
- .filter((invocation): invocation is GitInvocation => invocation !== undefined);
2663
- const effectiveRoot = gitInvocations[0]?.repoDir ?? workspaceRoot;
544
+ .filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
545
+ const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
2664
546
 
2665
547
  // Workspace boundary check for external git repo targets
2666
548
  for (const invocation of gitInvocations) {
2667
549
  const normalizedInvocation = `git ${invocation.rest}`;
2668
550
  if (
2669
- isPathOutsideWorkspace(invocation.repoDir, workspaceRoot) &&
551
+ isPathOutsideWorkspace(invocation.repoDir, currentRoot) &&
552
+ !allowLive &&
2670
553
  (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))
2671
554
  ) {
2672
555
  logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
2673
- return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
556
+ return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
2674
557
  }
2675
558
  }
2676
559
 
2677
- // ── Policy 7: changes only on feature branches ───────────
560
+ // ── Policy 7: changes only on feature branches ──
2678
561
  for (const invocation of gitInvocations) {
2679
562
  if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
2680
563
  logBlock(
@@ -2684,7 +567,7 @@ async function guardToolCallImpl(
2684
567
  }
2685
568
  }
2686
569
 
2687
- // Check if creating a fresh branch while local base is behind remote
570
+ // Policy 20: base freshness when creating a fresh branch
2688
571
  if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
2689
572
  const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
2690
573
  if (behindCheck.isBehind) {
@@ -2703,7 +586,7 @@ async function guardToolCallImpl(
2703
586
  return PROTECTED_PATH_REASON;
2704
587
  }
2705
588
 
2706
- // ── Policy 17: Secret file reads via shell (cat .env, grep id_rsa, etc.) ──
589
+ // ── Policy 17: secret file reads via shell ──
2707
590
  if (!allowLive) {
2708
591
  for (const segment of command.split(/[\n|;&]+/)) {
2709
592
  const secretFile = secretFileReadIn(segment.trim());
@@ -2719,7 +602,7 @@ async function guardToolCallImpl(
2719
602
  }
2720
603
  }
2721
604
 
2722
- // ── Policy 18: Interpreter inline evasion scanner ────────
605
+ // ── Policy 18: interpreter inline evasion scanner ──
2723
606
  if (!allowLive) {
2724
607
  for (const payload of extractInterpreterPayload(command)) {
2725
608
  const normPayload = normalizeGitCommands(normalize(payload));
@@ -2768,7 +651,7 @@ async function guardToolCallImpl(
2768
651
  }
2769
652
  }
2770
653
 
2771
- // ── Policy 2: block git push to main/master ──────────────────
654
+ // ── Policy 2: block git push to main/master ──
2772
655
  if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
2773
656
  logBlock(
2774
657
  `[workflow-guard] blocked push to main/master: ${command}`,
@@ -2794,6 +677,7 @@ async function guardToolCallImpl(
2794
677
  );
2795
678
  }
2796
679
  }
680
+
2797
681
  // ── Policy 20: block push from protected or already-merged branches ──
2798
682
  for (const invocation of gitInvocations) {
2799
683
  if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
@@ -2820,21 +704,19 @@ async function guardToolCallImpl(
2820
704
  if (hasPrCreateInvocation(raw)) {
2821
705
  const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
2822
706
  const prTool = isAz ? "az repos pr create" : "gh pr create";
707
+ const descFlag = isAz ? "--description" : "--body";
2823
708
 
2824
- // Policy 19: Conflict-free mergeability gate before opening PR
2825
- const conflictCheck = checkMergeConflicts(workspaceRoot);
709
+ // Policy 19: conflict-free mergeability gate before opening PR
710
+ const conflictCheck = checkMergeConflicts(currentRoot);
2826
711
  if (conflictCheck.hasConflicts) {
2827
712
  logBlock(`[workflow-guard] blocked ${prTool}: merge conflicts detected`);
2828
713
  return `Blocked: ${conflictCheck.reason}`;
2829
714
  }
2830
715
 
2831
- // Policy 20: Check if branch is already merged/closed
2832
- const branch = currentGitBranch(workspaceRoot);
716
+ // Policy 20: branch already merged/closed
717
+ const branch = currentGitBranch(currentRoot);
2833
718
  if (branch) {
2834
- const mergedStatus = isBranchAlreadyMergedOrClosed(
2835
- workspaceRoot,
2836
- branch,
2837
- );
719
+ const mergedStatus = isBranchAlreadyMergedOrClosed(currentRoot, branch);
2838
720
  if (mergedStatus.merged) {
2839
721
  logBlock(
2840
722
  `[workflow-guard] blocked ${prTool}: branch already merged/closed`,
@@ -2843,13 +725,13 @@ async function guardToolCallImpl(
2843
725
  }
2844
726
  }
2845
727
 
2846
- if (isReviewRequired(workspaceRoot)) {
728
+ if (isReviewRequired(currentRoot)) {
2847
729
  const review = context?.sessionID
2848
730
  ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult())
2849
731
  : getLastReviewResult();
2850
732
  const reviewMatchesContext =
2851
733
  review?.passed === true &&
2852
- (!review.workspace || review.workspace === workspaceRoot) &&
734
+ (!review.workspace || review.workspace === currentRoot) &&
2853
735
  (!review.targetSessionID || review.targetSessionID === context?.sessionID);
2854
736
  if (!reviewMatchesContext) {
2855
737
  logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
@@ -2860,9 +742,9 @@ async function guardToolCallImpl(
2860
742
  }
2861
743
  }
2862
744
 
2863
- // Policy 21: Documentation review & update check
2864
- if (isDocumentationRequired(workspaceRoot)) {
2865
- const hasDocChange = branchHasDocumentationChange(workspaceRoot);
745
+ // Policy 21: documentation review & update check
746
+ if (isDocumentationRequired(currentRoot)) {
747
+ const hasDocChange = branchHasDocumentationChange(currentRoot);
2866
748
  if (!hasDocChange) {
2867
749
  logBlock(`[workflow-guard] blocked ${prTool}: documentation update required`);
2868
750
  return (
@@ -2872,19 +754,14 @@ async function guardToolCallImpl(
2872
754
  }
2873
755
  }
2874
756
 
2875
- const branchChangelog = branchHasChangelogChange(workspaceRoot);
757
+ const branchChangelog = branchHasChangelogChange(currentRoot);
2876
758
  const prSegments = splitShellSegments(raw)
2877
759
  .filter((segment) => hasPrCreateInvocation(segment));
2878
760
  const hasChangelog =
2879
761
  branchChangelog ||
2880
762
  prSegments.every((segment) => prBodyIncludesChangelog(segment));
2881
763
  if (!hasChangelog) {
2882
- const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
2883
- const prTool = isAz ? "az repos pr create" : "gh pr create";
2884
- const descFlag = isAz ? "--description" : "--body";
2885
- logBlock(
2886
- `[workflow-guard] blocked ${prTool}: no changelog found`,
2887
- );
764
+ logBlock(`[workflow-guard] blocked ${prTool}: no changelog found`);
2888
765
  return (
2889
766
  `Blocked: PR must include a changelog. Either update a ` +
2890
767
  `CHANGELOG file in this branch's diff, or include a ` +
@@ -2943,27 +820,11 @@ export async function guardToolCall(
2943
820
  return reason;
2944
821
  }
2945
822
 
2946
- /** Set the workspace root (used by the plugin and tests). */
2947
- export function setWorkspaceRoot(root: string): void {
2948
- workspaceRoot = root;
2949
- try {
2950
- workspaceRootReal = realpathSync(root);
2951
- } catch {
2952
- workspaceRootReal = root;
2953
- }
2954
- }
2955
-
2956
- async function showBlockToast(message: string): Promise<void> {
823
+ async function emitBlockFeedback(message: string): Promise<void> {
2957
824
  try {
2958
825
  sendDesktopNotification("Workflow Guard Blocked", message);
2959
- await sdkClient?.tui?.showToast?.({
2960
- body: {
2961
- title: "Workflow Guard Blocked",
2962
- message: message.slice(0, 180),
2963
- variant: "warning",
2964
- },
2965
- });
2966
826
  } catch {}
827
+ await showBlockToast(message);
2967
828
  }
2968
829
 
2969
830
  export const WorkflowGuard: Plugin = async (ctx) => {
@@ -2985,30 +846,32 @@ export const WorkflowGuard: Plugin = async (ctx) => {
2985
846
  } catch {}
2986
847
 
2987
848
  return {
2988
- // Custom inspection and review tools registered in OpenCode
2989
849
  tool: {
2990
850
  guard_status: tool({
2991
851
  description:
2992
852
  "Inspect active guardrails, current branch protection, and verification/review status.",
2993
853
  args: {},
2994
854
  execute: async () => {
2995
- const branch = currentGitBranch(workspaceRoot) ?? "unknown";
2996
- const isProtected = onProtectedBranch(workspaceRoot);
855
+ const root = getWorkspaceRoot();
856
+ const branch = currentGitBranch(root) ?? "unknown";
857
+ const isProtected = onProtectedBranch(root);
2997
858
  const lastV = getLastVerifyResult();
2998
859
  const lastR = getLastReviewResult();
2999
860
  const lastMut = getLastMutationTimestamp();
3000
- const cfg = loadProjectConfig(workspaceRoot);
861
+ const cfg = loadProjectConfig(root);
3001
862
  return JSON.stringify(
3002
863
  {
3003
- workspaceRoot,
864
+ workspaceRoot: root,
3004
865
  branch,
3005
866
  onProtectedBranch: isProtected,
3006
867
  lastMutationTimestamp: lastMut,
868
+ mutationCount: getMutationCount(),
3007
869
  lastVerify: lastV
3008
870
  ? {
3009
871
  command: lastV.command,
3010
872
  passed: lastV.passed,
3011
873
  fresh: lastV.timestamp >= lastMut,
874
+ commitHash: lastV.commitHash,
3012
875
  }
3013
876
  : null,
3014
877
  lastReview: lastR
@@ -3020,9 +883,9 @@ export const WorkflowGuard: Plugin = async (ctx) => {
3020
883
  : null,
3021
884
  projectConfig: {
3022
885
  protectedBranches: cfg.protectedBranches ?? ["main", "master"],
3023
- verifyCommand: detectVerifyCommand(workspaceRoot) ?? null,
3024
- requireReview: isReviewRequired(workspaceRoot),
3025
- requireDocumentation: isDocumentationRequired(workspaceRoot),
886
+ verifyCommand: detectVerifyCommand(root) ?? null,
887
+ requireReview: isReviewRequired(root),
888
+ requireDocumentation: isDocumentationRequired(root),
3026
889
  },
3027
890
  },
3028
891
  null,
@@ -3115,12 +978,14 @@ export const WorkflowGuard: Plugin = async (ctx) => {
3115
978
  "Break the request down with todowrite first, then create worktrees."
3116
979
  );
3117
980
  }
3118
- const effectiveRoot = toolContext.worktree || toolContext.directory || workspaceRoot;
3119
- const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", effectiveRoot);
981
+ const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot;
982
+ const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot);
3120
983
  if (!res.success) {
3121
984
  return `[workflow-guard] Failed to create worktree: ${res.error}`;
3122
985
  }
3123
- recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID);
986
+ recordMutation(
987
+ (await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID,
988
+ );
3124
989
  return (
3125
990
  `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\n` +
3126
991
  `Run subagent tasks or pass worktree directory context to isolate file mutations.`
@@ -3143,42 +1008,34 @@ export const WorkflowGuard: Plugin = async (ctx) => {
3143
1008
  "Break the request down with todowrite first, then clean up worktrees."
3144
1009
  );
3145
1010
  }
3146
- const effectiveRoot = toolContext.worktree || toolContext.directory || workspaceRoot;
3147
- const res = cleanupGitWorktree(args.worktreePath, effectiveRoot);
1011
+ const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot;
1012
+ const res = cleanupGitWorktree(args.worktreePath, toolRoot);
3148
1013
  if (!res.success) {
3149
1014
  return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
3150
1015
  }
3151
- recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID);
1016
+ recordMutation(
1017
+ (await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID,
1018
+ );
3152
1019
  return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
3153
1020
  },
3154
1021
  }),
3155
1022
  },
3156
1023
 
3157
- // OpenCode passes the tool args as the SECOND hook parameter
3158
- // (`output.args` - documented in the tools docs, e.g. apply_patch
3159
- // "uses output.args.patchText"; `input` only carries
3160
- // { tool, sessionID, callID }). The fallback covers hypothetical
3161
- // runtimes that put args on the input.
3162
1024
  "tool.execute.before": async (input, output) => {
3163
- const args =
3164
- (output as { args?: unknown } | undefined)?.args ??
3165
- (input as { args?: unknown }).args;
3166
- const reason = await guardToolCall(input.tool, args, {
3167
- sessionID: input.sessionID,
1025
+ return runWithRuntimeState(effectiveRoot, ctx.client, async () => {
1026
+ const args =
1027
+ (output as { args?: unknown } | undefined)?.args ??
1028
+ (input as { args?: unknown }).args;
1029
+ const reason = await guardToolCall(input.tool, args, {
1030
+ sessionID: input.sessionID,
1031
+ });
1032
+ if (reason !== undefined) {
1033
+ await emitBlockFeedback(reason);
1034
+ throw new Error(`[workflow-guard] ${reason}`);
1035
+ }
3168
1036
  });
3169
- if (reason !== undefined) {
3170
- await showBlockToast(reason);
3171
- throw new Error(`[workflow-guard] ${reason}`);
3172
- }
3173
1037
  },
3174
1038
 
3175
- // Post-edit verification runs when the agent attempts to finalize
3176
- // the todo list (all tasks completed) - enforced in the
3177
- // tool.execute.before todowrite branch above. This avoids running
3178
- // tests on intermediate broken states and reduces overhead.
3179
-
3180
- // Focus preservation across context compaction (documented in
3181
- // https://opencode.ai/docs/plugins/#compaction-hooks).
3182
1039
  "experimental.session.compacting": async (input, output) => {
3183
1040
  try {
3184
1041
  const sessionID = (input as { sessionID?: string })?.sessionID;
@@ -3188,9 +1045,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
3188
1045
  const s = String(t.status ?? "");
3189
1046
  return s === "pending" || s === "in_progress";
3190
1047
  });
3191
- const branch = currentGitBranch(workspaceRoot) ?? "unknown";
3192
- const isProtected = onProtectedBranch(workspaceRoot);
3193
- const lastV = sessionID ? sessionVerifyResults.get(sessionID) ?? lastVerify : lastVerify;
1048
+ const branch = currentGitBranch(effectiveRoot) ?? "unknown";
1049
+ const isProtected = onProtectedBranch(effectiveRoot);
1050
+ const lastV = sessionID
1051
+ ? (sessionVerifyResults.get(sessionID) ?? lastVerify)
1052
+ : lastVerify;
3194
1053
  const lastR = getLastReviewResult();
3195
1054
  const mutationCountVal = getMutationCount(sessionID);
3196
1055
 
@@ -3208,12 +1067,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
3208
1067
  : "";
3209
1068
  contextBlocks.push(
3210
1069
  `## Active Tasks${attribution}\n` +
3211
- lines.join("\n") +
3212
- "\nComplete tasks efficiently — mark finished items as completed and address remaining ones.",
1070
+ lines.join("\n") +
1071
+ "\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
3213
1072
  );
3214
1073
  }
3215
1074
 
3216
- // Inject operational guard state to keep the model grounded after compaction
3217
1075
  const stateLines: string[] = [
3218
1076
  `## Operational Guard State`,
3219
1077
  `- Git Branch: ${branch}${isProtected ? " (PROTECTED BRANCH - edits/commits require feature branch)" : " (feature branch - edits allowed)"}`,
@@ -3237,12 +1095,6 @@ export const WorkflowGuard: Plugin = async (ctx) => {
3237
1095
  } catch {}
3238
1096
  },
3239
1097
 
3240
- // Shell env scrub: sensitive credentials must not leak into agent
3241
- // shells by default. The hook only documents overwrite, so the
3242
- // sensitive entries are emptied; the agent cannot carry live
3243
- // credentials unless the user explicitly allows it elsewhere.
3244
- // When a scrubbed variable is present, we log a warning so the agent
3245
- // knows auth failures may be due to scrubbing, not their code.
3246
1098
  "shell.env": async (input, output) => {
3247
1099
  try {
3248
1100
  const env = (output as { env?: Record<string, string> }).env;
@@ -3262,7 +1114,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
3262
1114
  }
3263
1115
  if (scrubbed.length > 0) {
3264
1116
  try {
3265
- await (sdkClient as any)?.app?.log?.({
1117
+ await (getSdkClient() as any)?.app?.log?.({
3266
1118
  body: {
3267
1119
  service: "workflow-guard",
3268
1120
  level: "warn",
@@ -3275,8 +1127,21 @@ export const WorkflowGuard: Plugin = async (ctx) => {
3275
1127
  } catch {}
3276
1128
  },
3277
1129
 
3278
- // Command & permission event audit trail: journal user commands,
3279
- // permission requests, and session creations.
1130
+ "permission.ask": async (input, output) => {
1131
+ audit({
1132
+ ts: new Date().toISOString(),
1133
+ sessionID: input.sessionID,
1134
+ tool: "permission.ask",
1135
+ decision: output.status === "deny" ? "block" : "allow",
1136
+ input: {
1137
+ permissionID: input.id,
1138
+ type: input.type,
1139
+ pattern: input.pattern,
1140
+ status: output.status,
1141
+ },
1142
+ });
1143
+ },
1144
+
3280
1145
  event: async ({
3281
1146
  event,
3282
1147
  }: { event: { type?: string; properties?: unknown } }) => {
@@ -3290,7 +1155,13 @@ export const WorkflowGuard: Plugin = async (ctx) => {
3290
1155
  ts: new Date().toISOString(),
3291
1156
  sessionID: (event.properties as { sessionID?: string })?.sessionID,
3292
1157
  tool: event.type ?? "event",
3293
- decision: "allow",
1158
+ decision:
1159
+ event.type === "permission.replied" &&
1160
+ /reject|deny/i.test(
1161
+ String((event.properties as { response?: unknown })?.response ?? ""),
1162
+ )
1163
+ ? "block"
1164
+ : "allow",
3294
1165
  input: summarizeInput(event.properties),
3295
1166
  });
3296
1167
  }