opencode-workflow-guard 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3305 @@
1
+ /**
2
+ * Workflow Guard Plugin for OpenCode (hooks-only - no prompt rules)
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.
7
+ *
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.
2011
+ */
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
+
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
+ }
2091
+
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";
2099
+
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
+ }
2119
+
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
+ }
2138
+
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);
2149
+
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
+ }
2168
+
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");
2176
+
2177
+ export function getAuditFilePath(): string {
2178
+ return AUDIT_FILE;
2179
+ }
2180
+
2181
+ export function getVerifyCacheFilePath(): string {
2182
+ return VERIFY_CACHE_FILE;
2183
+ }
2184
+
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
+ }
2195
+
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
+ }
2210
+
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
+ }
2236
+
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
+ }
2255
+
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
+ }
2275
+
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
+ }
2284
+
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
+ }
2302
+
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
+ }
2312
+
2313
+ 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
+ try {
2318
+ (sdkClient as any)?.app?.log?.({
2319
+ body: {
2320
+ service: "workflow-guard",
2321
+ level: "warn",
2322
+ message,
2323
+ },
2324
+ });
2325
+ } catch {}
2326
+ }
2327
+
2328
+ async function guardToolCallImpl(
2329
+ toolName: string,
2330
+ input: unknown,
2331
+ context?: { sessionID?: string },
2332
+ ): Promise<string | undefined> {
2333
+ // ── Policy 17: Secret file READ guard (.env*, keys, kubeconfig, credentials) ──
2334
+ if (toolName === "read") {
2335
+ const record = asRecord(input);
2336
+ const target =
2337
+ typeof record?.filePath === "string"
2338
+ ? record.filePath
2339
+ : typeof record?.path === "string"
2340
+ ? record.path
2341
+ : typeof input === "string"
2342
+ ? input
2343
+ : "";
2344
+ if (target && isSecretPath(target)) {
2345
+ logBlock(`[workflow-guard] blocked read: secret file ${target}`);
2346
+ if (isEnvFilePath(target)) {
2347
+ let schemaHint = "";
2348
+ try {
2349
+ const resolvedTarget = resolve(workspaceRoot, target);
2350
+ if (existsSync(resolvedTarget)) {
2351
+ const raw = readFileSync(resolvedTarget, "utf8");
2352
+ const masked = generateMaskedEnvSchema(raw);
2353
+ schemaHint = `\n\nSafe variable schema mask:\n\`\`\`\n${masked.slice(0, 800)}\n\`\`\``;
2354
+ }
2355
+ } catch {}
2356
+ return (
2357
+ `Blocked: reading sensitive credential file '${target}' directly is not permitted. ` +
2358
+ `Use environment variables or reference safe templates.${schemaHint}`
2359
+ );
2360
+ }
2361
+ return (
2362
+ `Blocked: reading sensitive credential/secret file '${target}' is not permitted. ` +
2363
+ "Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
2364
+ );
2365
+ }
2366
+ return undefined;
2367
+ }
2368
+
2369
+ // ── Policy 1: todowrite lifecycle & focus validation ──
2370
+ if (toolName === "todowrite") {
2371
+ const record = asRecord(input);
2372
+ const rawTodos = record?.todos;
2373
+ if (Array.isArray(rawTodos)) {
2374
+ const newTodos = rawTodos as TodoItem[];
2375
+ const existingTodos = context?.sessionID
2376
+ ? await fetchSessionTodos(context.sessionID)
2377
+ : undefined;
2378
+ const err = validateTodoLifecycle(newTodos, existingTodos);
2379
+ if (err) {
2380
+ logBlock(`[workflow-guard] ${err}`);
2381
+ return err;
2382
+ }
2383
+
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
+ const allDone =
2389
+ newTodos.length > 0 &&
2390
+ newTodos.every((t) => {
2391
+ const s = String(t.status ?? "");
2392
+ return s === "completed" || s === "cancelled";
2393
+ });
2394
+ if (allDone) {
2395
+ // Conflict-free mergeability gate before task completion/handoff
2396
+ const conflictCheck = checkMergeConflicts(workspaceRoot);
2397
+ if (conflictCheck.hasConflicts) {
2398
+ const reason = `Blocked todowrite: ${conflictCheck.reason}`;
2399
+ logBlock(`[workflow-guard] ${reason}`);
2400
+ return reason;
2401
+ }
2402
+
2403
+ const command = detectVerifyCommand(workspaceRoot);
2404
+ if (command) {
2405
+ const sessionID = context?.sessionID;
2406
+ let verifyResult = sessionID
2407
+ ? sessionVerifyResults.get(sessionID)
2408
+ : lastVerify;
2409
+
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;
2423
+ }
2424
+ }
2425
+
2426
+ const mutationTimestamp = sessionID
2427
+ ? (sessionMutationTimestamps.get(sessionID) ?? 0)
2428
+ : lastMutationTimestamp;
2429
+
2430
+ const currentCommit = getCurrentGitCommitHash(workspaceRoot);
2431
+ const currentGitStatus = getGitStatusSummary(workspaceRoot);
2432
+
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
+ const gitStateMatches =
2439
+ !verifyResult?.commitHash ||
2440
+ (verifyResult.commitHash === currentCommit &&
2441
+ verifyResult.gitStatus === currentGitStatus);
2442
+
2443
+ const isFresh =
2444
+ verifyResult !== undefined &&
2445
+ verifyResult.passed &&
2446
+ verifyResult.command === command &&
2447
+ verifyResult.timestamp >= mutationTimestamp &&
2448
+ gitStateMatches;
2449
+
2450
+ if (!isFresh) {
2451
+ const result = await runVerify(command, workspaceRoot);
2452
+ recordVerifyResult(command, result, sessionID, workspaceRoot);
2453
+ if (!result.passed) {
2454
+ const tail = result.output.slice(-500);
2455
+ const reason =
2456
+ `Blocked todowrite: all tasks marked done but verification is failing ` +
2457
+ `(${command}). Fix the failure before finishing; ` +
2458
+ `output tail: ${tail}`;
2459
+ logBlock(`[workflow-guard] ${reason}`);
2460
+ return reason;
2461
+ }
2462
+ }
2463
+ }
2464
+ }
2465
+ }
2466
+ return undefined;
2467
+ }
2468
+
2469
+ // ── Policies 1, 6, 7, 8 & 9: edit/write/patch gates ──
2470
+ if (EDIT_TOOL_NAMES.has(toolName)) {
2471
+ const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
2472
+ const record = asRecord(input);
2473
+ const target =
2474
+ typeof record?.filePath === "string"
2475
+ ? record.filePath
2476
+ : typeof record?.path === "string"
2477
+ ? record.path
2478
+ : typeof input === "string"
2479
+ ? input
2480
+ : "";
2481
+
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
+ if (target && isProtectedPath(target)) {
2485
+ logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
2486
+ return PROTECTED_PATH_REASON;
2487
+ }
2488
+ if (target && isSecretPath(target) && !allowLive) {
2489
+ logBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
2490
+ return (
2491
+ `Blocked: modifying secret file '${target}' directly is not permitted. ` +
2492
+ "Store credentials in environment variables or safe secret stores instead."
2493
+ );
2494
+ }
2495
+ if (toolName === "apply_patch") {
2496
+ const patchText =
2497
+ typeof record?.patchText === "string" ? record.patchText : "";
2498
+ for (const patchPath of extractPatchPaths(patchText)) {
2499
+ if (isProtectedPath(patchPath)) {
2500
+ logBlock(
2501
+ `[workflow-guard] blocked apply_patch: protected path ${patchPath}`,
2502
+ );
2503
+ return PROTECTED_PATH_REASON;
2504
+ }
2505
+ if (isSecretPath(patchPath) && !allowLive) {
2506
+ logBlock(
2507
+ `[workflow-guard] blocked apply_patch: secret file path ${patchPath}`,
2508
+ );
2509
+ return `Blocked: modifying secret file '${patchPath}' directly is not permitted.`;
2510
+ }
2511
+ if (isPathOutsideWorkspace(patchPath, workspaceRoot)) {
2512
+ logBlock(
2513
+ `[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`,
2514
+ );
2515
+ return `Blocked: patch targets file '${patchPath}' outside workspace root (${workspaceRoot}).`;
2516
+ }
2517
+ }
2518
+ }
2519
+
2520
+ // Workspace boundary check (path traversal guard)
2521
+ if (target && isPathOutsideWorkspace(target, workspaceRoot)) {
2522
+ logBlock(
2523
+ `[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`,
2524
+ );
2525
+ return `Blocked: file path '${target}' escapes workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
2526
+ }
2527
+
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
+ for (const content of extractEditContent(input)) {
2532
+ const secret = secretIn(content);
2533
+ if (secret) {
2534
+ logBlock(
2535
+ `[workflow-guard] blocked ${toolName}: payload contains ${secret}`,
2536
+ );
2537
+ return (
2538
+ `Blocked: payload appears to contain a ${secret}. ` +
2539
+ "Secrets must not be committed to the repository. Store them " +
2540
+ "in a secret manager or environment file excluded from git, " +
2541
+ "and reference them by name instead."
2542
+ );
2543
+ }
2544
+ }
2545
+
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
+ if (!allowLive) {
2552
+ for (const content of extractEditContent(input)) {
2553
+ const lines = content.split("\n");
2554
+ for (const line of lines) {
2555
+ // Skip comment lines (bash, js, python, etc.)
2556
+ const trimmed = line.trim();
2557
+ if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
2558
+ continue;
2559
+ }
2560
+ const normalizedContent = normalizeGitCommands(line);
2561
+ const what = liveMutationIn(normalizedContent);
2562
+ if (what) {
2563
+ logBlock(
2564
+ `[workflow-guard] blocked ${toolName}: payload contains ${what}`,
2565
+ );
2566
+ return (
2567
+ `Blocked: the file you are writing contains a ${what}. ` +
2568
+ "Script files are not a way to smuggle destructive commands " +
2569
+ "past the shell guard. Only the user can allow live mutations " +
2570
+ "(WORKFLOW_GUARD_ALLOW_LIVE=1)."
2571
+ );
2572
+ }
2573
+ }
2574
+ }
2575
+ for (const content of extractEditContent(input)) {
2576
+ if (isSettingsTamper(content)) {
2577
+ logBlock(
2578
+ `[workflow-guard] blocked ${toolName}: payload tampers with settings`,
2579
+ );
2580
+ return PROTECTED_PATH_REASON;
2581
+ }
2582
+ }
2583
+ }
2584
+
2585
+ if (onProtectedBranch(workspaceRoot)) {
2586
+ logBlock(
2587
+ `[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(workspaceRoot)}`,
2588
+ );
2589
+ return branchGuardReason();
2590
+ }
2591
+ const todos = await effectiveTodos(context?.sessionID);
2592
+ if (todos !== undefined && !hasActiveTodo(todos)) {
2593
+ logBlock(
2594
+ `[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`,
2595
+ );
2596
+ return (
2597
+ "Blocked: no active todo item. First break the request down " +
2598
+ "with the todowrite tool (create items with status 'pending' " +
2599
+ "or 'in_progress'), then work them top to bottom, marking " +
2600
+ "each completed via todowrite as you finish it. When every " +
2601
+ "item is completed, create a fresh todo list before " +
2602
+ "starting new work."
2603
+ );
2604
+ }
2605
+ recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
2606
+ return undefined;
2607
+ }
2608
+
2609
+ const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
2610
+
2611
+ // ── 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
+ if (!allowLive) {
2614
+ const mcpWhat = mcpMutationTool(toolName);
2615
+ if (mcpWhat) {
2616
+ logBlock(
2617
+ `[workflow-guard] blocked MCP tool ${toolName} (${mcpWhat} mutation)`,
2618
+ );
2619
+ return (
2620
+ `Blocked: ${toolName} mutates ${mcpWhat} - a live ` +
2621
+ "system. Changes must be made in code unless the user " +
2622
+ "explicitly allows live changes. Only the user can override " +
2623
+ "this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment " +
2624
+ "variable set before launching the agent."
2625
+ );
2626
+ }
2627
+ }
2628
+
2629
+ if (!SHELL_TOOL_NAMES.has(toolName)) {
2630
+ return undefined;
2631
+ }
2632
+
2633
+ const commands = extractCommands(input);
2634
+ for (const raw of commands) {
2635
+ const command = normalize(raw);
2636
+
2637
+ // ── Policy 22: Non-interactive shell & TTY hang guard ───────
2638
+ const ttyCheck = checkInteractiveTtyCommand(command);
2639
+ if (ttyCheck.isInteractive) {
2640
+ logBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
2641
+ return (
2642
+ `Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ` +
2643
+ `${ttyCheck.advice}`
2644
+ );
2645
+ }
2646
+
2647
+ // ── Policy 23: Package supply-chain & dependency hygiene guard ────
2648
+ const packageCheck = checkPackageHygiene(command);
2649
+ if (packageCheck.isViolating && !allowLive) {
2650
+ logBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
2651
+ return (
2652
+ `Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ` +
2653
+ `${packageCheck.advice}`
2654
+ );
2655
+ }
2656
+
2657
+ // Rewrite `git [global-opts] <sub>` to `git <sub>` so global flags
2658
+ // cannot smuggle a guarded subcommand past the regexes.
2659
+ const normalizedCommand = normalizeGitCommands(command);
2660
+ const gitInvocations = splitShellSegments(command)
2661
+ .map((segment) => parseGitInvocation(segment.trim()))
2662
+ .filter((invocation): invocation is GitInvocation => invocation !== undefined);
2663
+ const effectiveRoot = gitInvocations[0]?.repoDir ?? workspaceRoot;
2664
+
2665
+ // Workspace boundary check for external git repo targets
2666
+ for (const invocation of gitInvocations) {
2667
+ const normalizedInvocation = `git ${invocation.rest}`;
2668
+ if (
2669
+ isPathOutsideWorkspace(invocation.repoDir, workspaceRoot) &&
2670
+ (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))
2671
+ ) {
2672
+ 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.`;
2674
+ }
2675
+ }
2676
+
2677
+ // ── Policy 7: changes only on feature branches ───────────
2678
+ for (const invocation of gitInvocations) {
2679
+ if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
2680
+ logBlock(
2681
+ `[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`,
2682
+ );
2683
+ return branchGuardReason();
2684
+ }
2685
+ }
2686
+
2687
+ // Check if creating a fresh branch while local base is behind remote
2688
+ if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
2689
+ const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
2690
+ if (behindCheck.isBehind) {
2691
+ logBlock(
2692
+ `[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`,
2693
+ );
2694
+ return `Blocked: ${behindCheck.reason}`;
2695
+ }
2696
+ }
2697
+
2698
+ // ── Policy 6: block self-modification of approval gates ──
2699
+ if (isSettingsTamper(command)) {
2700
+ logBlock(
2701
+ `[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`,
2702
+ );
2703
+ return PROTECTED_PATH_REASON;
2704
+ }
2705
+
2706
+ // ── Policy 17: Secret file reads via shell (cat .env, grep id_rsa, etc.) ──
2707
+ if (!allowLive) {
2708
+ for (const segment of command.split(/[\n|;&]+/)) {
2709
+ const secretFile = secretFileReadIn(segment.trim());
2710
+ if (secretFile) {
2711
+ logBlock(
2712
+ `[workflow-guard] blocked shell read of secret file: ${secretFile}`,
2713
+ );
2714
+ return (
2715
+ `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. ` +
2716
+ "Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
2717
+ );
2718
+ }
2719
+ }
2720
+ }
2721
+
2722
+ // ── Policy 18: Interpreter inline evasion scanner ────────
2723
+ if (!allowLive) {
2724
+ for (const payload of extractInterpreterPayload(command)) {
2725
+ const normPayload = normalizeGitCommands(normalize(payload));
2726
+ const liveCheck = liveMutationIn(normPayload);
2727
+ if (liveCheck) {
2728
+ logBlock(
2729
+ `[workflow-guard] blocked interpreter payload containing ${liveCheck}`,
2730
+ );
2731
+ return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`;
2732
+ }
2733
+ if (isSettingsTamper(payload)) {
2734
+ logBlock(
2735
+ `[workflow-guard] blocked interpreter payload with settings tamper`,
2736
+ );
2737
+ return PROTECTED_PATH_REASON;
2738
+ }
2739
+ }
2740
+ }
2741
+
2742
+ // ── Policies 1, 7 & 8: shell file mutations get the same gates as edits ──
2743
+ const shellMutationReason = await guardShellMutation(
2744
+ command,
2745
+ context?.sessionID,
2746
+ );
2747
+ if (shellMutationReason) {
2748
+ logBlock(
2749
+ `[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`,
2750
+ );
2751
+ return shellMutationReason;
2752
+ }
2753
+
2754
+ // ── Policy 4: live-system mutations (env var is the ONLY override) ──
2755
+ if (!allowLive) {
2756
+ const what = liveMutationIn(normalizedCommand) ?? liveMutationIn(command);
2757
+ if (what) {
2758
+ logBlock(
2759
+ `[workflow-guard] blocked ${what}: ${command.slice(0, 120)}`,
2760
+ );
2761
+ return (
2762
+ `Blocked: ${what} targets a live system. Changes must be ` +
2763
+ "made in code (IaC, migrations, source) unless the user " +
2764
+ "explicitly allows live changes. Only the user can override " +
2765
+ "this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment " +
2766
+ "variable set before launching the agent."
2767
+ );
2768
+ }
2769
+ }
2770
+
2771
+ // ── Policy 2: block git push to main/master ──────────────────
2772
+ if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
2773
+ logBlock(
2774
+ `[workflow-guard] blocked push to main/master: ${command}`,
2775
+ );
2776
+ return (
2777
+ "Blocked: direct pushes to main/master are not allowed. " +
2778
+ "Create a feature branch and open a PR instead."
2779
+ );
2780
+ }
2781
+ // Policy 2 (cont.): configured protected branches receive the same
2782
+ // destination-side push protection as main/master.
2783
+ for (const invocation of gitInvocations) {
2784
+ const pushText = `git ${invocation.rest}`;
2785
+ if (!/\bgit\s+push\b/.test(pushText)) continue;
2786
+ const pushedBranch = pushedProtectedBranchIn(pushText, invocation.repoDir);
2787
+ if (pushedBranch) {
2788
+ logBlock(
2789
+ `[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`,
2790
+ );
2791
+ return (
2792
+ `Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. ` +
2793
+ "Create a feature branch and open a PR instead."
2794
+ );
2795
+ }
2796
+ }
2797
+ // ── Policy 20: block push from protected or already-merged branches ──
2798
+ for (const invocation of gitInvocations) {
2799
+ if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
2800
+ if (onProtectedBranch(invocation.repoDir)) {
2801
+ logBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
2802
+ return branchGuardReason();
2803
+ }
2804
+ const branch = currentGitBranch(invocation.repoDir);
2805
+ if (branch) {
2806
+ const mergedStatus = isBranchAlreadyMergedOrClosed(
2807
+ invocation.repoDir,
2808
+ branch,
2809
+ );
2810
+ if (mergedStatus.merged) {
2811
+ logBlock(
2812
+ `[workflow-guard] blocked push to merged/closed branch: ${branch}`,
2813
+ );
2814
+ return `Blocked: ${mergedStatus.reason}`;
2815
+ }
2816
+ }
2817
+ }
2818
+
2819
+ // ── Policy 3: PRs must include a changelog (GitHub & Azure DevOps) ──
2820
+ if (hasPrCreateInvocation(raw)) {
2821
+ const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
2822
+ const prTool = isAz ? "az repos pr create" : "gh pr create";
2823
+
2824
+ // Policy 19: Conflict-free mergeability gate before opening PR
2825
+ const conflictCheck = checkMergeConflicts(workspaceRoot);
2826
+ if (conflictCheck.hasConflicts) {
2827
+ logBlock(`[workflow-guard] blocked ${prTool}: merge conflicts detected`);
2828
+ return `Blocked: ${conflictCheck.reason}`;
2829
+ }
2830
+
2831
+ // Policy 20: Check if branch is already merged/closed
2832
+ const branch = currentGitBranch(workspaceRoot);
2833
+ if (branch) {
2834
+ const mergedStatus = isBranchAlreadyMergedOrClosed(
2835
+ workspaceRoot,
2836
+ branch,
2837
+ );
2838
+ if (mergedStatus.merged) {
2839
+ logBlock(
2840
+ `[workflow-guard] blocked ${prTool}: branch already merged/closed`,
2841
+ );
2842
+ return `Blocked: ${mergedStatus.reason}`;
2843
+ }
2844
+ }
2845
+
2846
+ if (isReviewRequired(workspaceRoot)) {
2847
+ const review = context?.sessionID
2848
+ ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult())
2849
+ : getLastReviewResult();
2850
+ const reviewMatchesContext =
2851
+ review?.passed === true &&
2852
+ (!review.workspace || review.workspace === workspaceRoot) &&
2853
+ (!review.targetSessionID || review.targetSessionID === context?.sessionID);
2854
+ if (!reviewMatchesContext) {
2855
+ logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
2856
+ return (
2857
+ `Blocked: PR creation requires a passing review approval. ` +
2858
+ "Invoke a secondary review subagent to record an approval using the record_review tool first."
2859
+ );
2860
+ }
2861
+ }
2862
+
2863
+ // Policy 21: Documentation review & update check
2864
+ if (isDocumentationRequired(workspaceRoot)) {
2865
+ const hasDocChange = branchHasDocumentationChange(workspaceRoot);
2866
+ if (!hasDocChange) {
2867
+ logBlock(`[workflow-guard] blocked ${prTool}: documentation update required`);
2868
+ return (
2869
+ `Blocked: PR requires documentation updates (Policy 21). ` +
2870
+ "Update README.md or relevant documentation in docs/ before opening a PR."
2871
+ );
2872
+ }
2873
+ }
2874
+
2875
+ const branchChangelog = branchHasChangelogChange(workspaceRoot);
2876
+ const prSegments = splitShellSegments(raw)
2877
+ .filter((segment) => hasPrCreateInvocation(segment));
2878
+ const hasChangelog =
2879
+ branchChangelog ||
2880
+ prSegments.every((segment) => prBodyIncludesChangelog(segment));
2881
+ 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
+ );
2888
+ return (
2889
+ `Blocked: PR must include a changelog. Either update a ` +
2890
+ `CHANGELOG file in this branch's diff, or include a ` +
2891
+ `'Changelog:' section in the PR description (${descFlag}).`
2892
+ );
2893
+ }
2894
+ }
2895
+
2896
+ const hasGitMutation = gitInvocations.some((invocation) => {
2897
+ const normalizedInvocation = `git ${invocation.rest}`;
2898
+ return (
2899
+ GIT_WRITE_RE.test(normalizedInvocation) ||
2900
+ /\bgit\s+(?:switch|checkout)\b/.test(normalizedInvocation)
2901
+ );
2902
+ });
2903
+ if (hasGitMutation) {
2904
+ recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
2905
+ }
2906
+ }
2907
+
2908
+ return undefined;
2909
+ }
2910
+
2911
+ /**
2912
+ * Public guard entry point. Audits every decision (block or allow) to a
2913
+ * durable JSONL file before returning.
2914
+ */
2915
+ export async function guardToolCall(
2916
+ toolName: string,
2917
+ input: unknown,
2918
+ context?: { sessionID?: string },
2919
+ ): Promise<string | undefined> {
2920
+ const reason = await guardToolCallImpl(toolName, input, context);
2921
+ const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
2922
+ const isMutation = EDIT_TOOL_NAMES.has(toolName);
2923
+ const record = asRecord(input);
2924
+ const targetPath =
2925
+ typeof record?.filePath === "string"
2926
+ ? record.filePath
2927
+ : typeof record?.path === "string"
2928
+ ? record.path
2929
+ : undefined;
2930
+ logDecision(toolName, input, context, reason, {
2931
+ mutation: isMutation,
2932
+ targetPath,
2933
+ allowLive,
2934
+ verification: lastVerify
2935
+ ? {
2936
+ command: lastVerify.command,
2937
+ passed: lastVerify.passed,
2938
+ fresh: lastVerify.timestamp >= lastMutationTimestamp,
2939
+ durationMs: lastVerify.durationMs,
2940
+ }
2941
+ : undefined,
2942
+ });
2943
+ return reason;
2944
+ }
2945
+
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> {
2957
+ try {
2958
+ 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
+ } catch {}
2967
+ }
2968
+
2969
+ export const WorkflowGuard: Plugin = async (ctx) => {
2970
+ // Honor worktree if present (e.g. opencode worktrees or devcontainers)
2971
+ // so worktree plugins cannot punch through boundary gates.
2972
+ const effectiveRoot = ctx.worktree || ctx.directory || process.cwd();
2973
+ setWorkspaceRoot(effectiveRoot);
2974
+ setSdkClient(ctx.client);
2975
+ reloadProjectConfig(effectiveRoot);
2976
+
2977
+ try {
2978
+ await (ctx.client as any)?.app?.log?.({
2979
+ body: {
2980
+ service: "workflow-guard",
2981
+ level: "info",
2982
+ message: `Workflow Guard plugin initialized for ${effectiveRoot}`,
2983
+ },
2984
+ });
2985
+ } catch {}
2986
+
2987
+ return {
2988
+ // Custom inspection and review tools registered in OpenCode
2989
+ tool: {
2990
+ guard_status: tool({
2991
+ description:
2992
+ "Inspect active guardrails, current branch protection, and verification/review status.",
2993
+ args: {},
2994
+ execute: async () => {
2995
+ const branch = currentGitBranch(workspaceRoot) ?? "unknown";
2996
+ const isProtected = onProtectedBranch(workspaceRoot);
2997
+ const lastV = getLastVerifyResult();
2998
+ const lastR = getLastReviewResult();
2999
+ const lastMut = getLastMutationTimestamp();
3000
+ const cfg = loadProjectConfig(workspaceRoot);
3001
+ return JSON.stringify(
3002
+ {
3003
+ workspaceRoot,
3004
+ branch,
3005
+ onProtectedBranch: isProtected,
3006
+ lastMutationTimestamp: lastMut,
3007
+ lastVerify: lastV
3008
+ ? {
3009
+ command: lastV.command,
3010
+ passed: lastV.passed,
3011
+ fresh: lastV.timestamp >= lastMut,
3012
+ }
3013
+ : null,
3014
+ lastReview: lastR
3015
+ ? {
3016
+ reviewer: lastR.reviewer,
3017
+ passed: lastR.passed,
3018
+ summary: lastR.summary,
3019
+ }
3020
+ : null,
3021
+ projectConfig: {
3022
+ protectedBranches: cfg.protectedBranches ?? ["main", "master"],
3023
+ verifyCommand: detectVerifyCommand(workspaceRoot) ?? null,
3024
+ requireReview: isReviewRequired(workspaceRoot),
3025
+ requireDocumentation: isDocumentationRequired(workspaceRoot),
3026
+ },
3027
+ },
3028
+ null,
3029
+ 2,
3030
+ );
3031
+ },
3032
+ }),
3033
+ guard_audit: tool({
3034
+ description:
3035
+ "View recent audit entries recorded by opencode-workflow-guard.",
3036
+ args: {
3037
+ limit: tool.schema
3038
+ .number()
3039
+ .optional()
3040
+ .describe("Maximum entries to return (default 10)"),
3041
+ },
3042
+ execute: async (args) => {
3043
+ const limit =
3044
+ typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10;
3045
+ return JSON.stringify(getRecentAuditEntries(limit), null, 2);
3046
+ },
3047
+ }),
3048
+ guard_why: tool({
3049
+ description:
3050
+ "Simulate and explain whether a specific tool call or command would be blocked by guardrails.",
3051
+ args: {
3052
+ tool: tool.schema
3053
+ .string()
3054
+ .describe("Tool name (e.g. bash, edit, write, read, apply_patch)"),
3055
+ input: tool.schema
3056
+ .record(tool.schema.string(), tool.schema.any())
3057
+ .optional()
3058
+ .describe("Tool input arguments"),
3059
+ },
3060
+ execute: async (args) => {
3061
+ const reason = await guardToolCallImpl(args.tool, args.input ?? {});
3062
+ return reason
3063
+ ? `BLOCKED: ${reason}`
3064
+ : "ALLOWED: Satisfies all current guardrails.";
3065
+ },
3066
+ }),
3067
+ record_review: tool({
3068
+ description:
3069
+ "Record a secondary reviewer agent's approval or critique of the current changes.",
3070
+ args: {
3071
+ reviewer: tool.schema
3072
+ .string()
3073
+ .describe("Identifier/name of the reviewer subagent"),
3074
+ summary: tool.schema
3075
+ .string()
3076
+ .describe("Review findings summary across the 5 core review axes"),
3077
+ passed: tool.schema
3078
+ .boolean()
3079
+ .describe("True if change is approved, false if changes requested"),
3080
+ },
3081
+ execute: async (args, toolContext) => {
3082
+ const parentSessionID = await fetchParentSessionID(toolContext.sessionID);
3083
+ if (!parentSessionID) {
3084
+ return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session.";
3085
+ }
3086
+ recordReviewResult(
3087
+ args.reviewer,
3088
+ args.summary,
3089
+ args.passed,
3090
+ parentSessionID,
3091
+ toolContext.worktree || toolContext.directory,
3092
+ );
3093
+ return args.passed
3094
+ ? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}.`
3095
+ : `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
3096
+ },
3097
+ }),
3098
+ guard_worktree_create: tool({
3099
+ description:
3100
+ "Create an isolated git worktree directory for concurrent subagent execution.",
3101
+ args: {
3102
+ branch: tool.schema
3103
+ .string()
3104
+ .describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"),
3105
+ baseBranch: tool.schema
3106
+ .string()
3107
+ .optional()
3108
+ .describe("Base branch to branch off of (defaults to HEAD)"),
3109
+ },
3110
+ execute: async (args, toolContext) => {
3111
+ const todos = await effectiveTodos(toolContext.sessionID);
3112
+ if (todos !== undefined && !hasActiveTodo(todos)) {
3113
+ return (
3114
+ "[workflow-guard] Blocked: worktree creation with no active todo item. " +
3115
+ "Break the request down with todowrite first, then create worktrees."
3116
+ );
3117
+ }
3118
+ const effectiveRoot = toolContext.worktree || toolContext.directory || workspaceRoot;
3119
+ const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", effectiveRoot);
3120
+ if (!res.success) {
3121
+ return `[workflow-guard] Failed to create worktree: ${res.error}`;
3122
+ }
3123
+ recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID);
3124
+ return (
3125
+ `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\n` +
3126
+ `Run subagent tasks or pass worktree directory context to isolate file mutations.`
3127
+ );
3128
+ },
3129
+ }),
3130
+ guard_worktree_cleanup: tool({
3131
+ description:
3132
+ "Commit a final snapshot and remove an isolated git worktree directory.",
3133
+ args: {
3134
+ worktreePath: tool.schema
3135
+ .string()
3136
+ .describe("Path of the worktree directory to clean up"),
3137
+ },
3138
+ execute: async (args, toolContext) => {
3139
+ const todos = await effectiveTodos(toolContext.sessionID);
3140
+ if (todos !== undefined && !hasActiveTodo(todos)) {
3141
+ return (
3142
+ "[workflow-guard] Blocked: worktree cleanup with no active todo item. " +
3143
+ "Break the request down with todowrite first, then clean up worktrees."
3144
+ );
3145
+ }
3146
+ const effectiveRoot = toolContext.worktree || toolContext.directory || workspaceRoot;
3147
+ const res = cleanupGitWorktree(args.worktreePath, effectiveRoot);
3148
+ if (!res.success) {
3149
+ return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
3150
+ }
3151
+ recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID);
3152
+ return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
3153
+ },
3154
+ }),
3155
+ },
3156
+
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
+ "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,
3168
+ });
3169
+ if (reason !== undefined) {
3170
+ await showBlockToast(reason);
3171
+ throw new Error(`[workflow-guard] ${reason}`);
3172
+ }
3173
+ },
3174
+
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
+ "experimental.session.compacting": async (input, output) => {
3183
+ try {
3184
+ const sessionID = (input as { sessionID?: string })?.sessionID;
3185
+ const parentID = sessionID ? await fetchParentSessionID(sessionID) : undefined;
3186
+ const todos = await effectiveTodos(sessionID);
3187
+ const active = todos?.filter((t) => {
3188
+ const s = String(t.status ?? "");
3189
+ return s === "pending" || s === "in_progress";
3190
+ });
3191
+ const branch = currentGitBranch(workspaceRoot) ?? "unknown";
3192
+ const isProtected = onProtectedBranch(workspaceRoot);
3193
+ const lastV = sessionID ? sessionVerifyResults.get(sessionID) ?? lastVerify : lastVerify;
3194
+ const lastR = getLastReviewResult();
3195
+ const mutationCountVal = getMutationCount(sessionID);
3196
+
3197
+ const contextBlocks: string[] = [];
3198
+
3199
+ if (active && active.length > 0) {
3200
+ const lines = active.map(
3201
+ (t) =>
3202
+ `- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "")}`,
3203
+ );
3204
+ const attribution = parentID
3205
+ ? ` (Subagent session: ${sessionID}, Parent: ${parentID})`
3206
+ : sessionID
3207
+ ? ` (Session: ${sessionID})`
3208
+ : "";
3209
+ contextBlocks.push(
3210
+ `## Active Tasks${attribution}\n` +
3211
+ lines.join("\n") +
3212
+ "\nComplete tasks efficiently — mark finished items as completed and address remaining ones.",
3213
+ );
3214
+ }
3215
+
3216
+ // Inject operational guard state to keep the model grounded after compaction
3217
+ const stateLines: string[] = [
3218
+ `## Operational Guard State`,
3219
+ `- Git Branch: ${branch}${isProtected ? " (PROTECTED BRANCH - edits/commits require feature branch)" : " (feature branch - edits allowed)"}`,
3220
+ `- Uncommitted Mutations: ${mutationCountVal} recorded in current session`,
3221
+ ];
3222
+ if (lastV) {
3223
+ stateLines.push(
3224
+ `- Test Verification: ${lastV.passed ? "PASSED" : "FAILED"} (${lastV.command})${lastV.commitHash ? ` at commit ${lastV.commitHash.slice(0, 7)}` : ""}`,
3225
+ );
3226
+ }
3227
+ if (lastR) {
3228
+ stateLines.push(
3229
+ `- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
3230
+ );
3231
+ }
3232
+ contextBlocks.push(stateLines.join("\n"));
3233
+
3234
+ if (Array.isArray(output?.context)) {
3235
+ output.context.push(contextBlocks.join("\n\n"));
3236
+ }
3237
+ } catch {}
3238
+ },
3239
+
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
+ "shell.env": async (input, output) => {
3247
+ try {
3248
+ const env = (output as { env?: Record<string, string> }).env;
3249
+ if (env && typeof env === "object") {
3250
+ const scrubbed: string[] = [];
3251
+ for (const key of SENSITIVE_ENV_KEYS) {
3252
+ if (key in env && env[key] !== "") {
3253
+ env[key] = "";
3254
+ scrubbed.push(key);
3255
+ }
3256
+ }
3257
+ for (const key of Object.keys(env)) {
3258
+ if (SENSITIVE_ENV_RE.test(key) && env[key] !== "") {
3259
+ env[key] = "";
3260
+ scrubbed.push(key);
3261
+ }
3262
+ }
3263
+ if (scrubbed.length > 0) {
3264
+ try {
3265
+ await (sdkClient as any)?.app?.log?.({
3266
+ body: {
3267
+ service: "workflow-guard",
3268
+ level: "warn",
3269
+ message: `Scrubbed sensitive env vars: ${scrubbed.join(", ")}. Auth failures may be due to this.`,
3270
+ },
3271
+ });
3272
+ } catch {}
3273
+ }
3274
+ }
3275
+ } catch {}
3276
+ },
3277
+
3278
+ // Command & permission event audit trail: journal user commands,
3279
+ // permission requests, and session creations.
3280
+ event: async ({
3281
+ event,
3282
+ }: { event: { type?: string; properties?: unknown } }) => {
3283
+ if (
3284
+ event?.type === "command.executed" ||
3285
+ event?.type === "permission.updated" ||
3286
+ event?.type === "permission.replied" ||
3287
+ event?.type === "session.created"
3288
+ ) {
3289
+ audit({
3290
+ ts: new Date().toISOString(),
3291
+ sessionID: (event.properties as { sessionID?: string })?.sessionID,
3292
+ tool: event.type ?? "event",
3293
+ decision: "allow",
3294
+ input: summarizeInput(event.properties),
3295
+ });
3296
+ }
3297
+ },
3298
+ };
3299
+ };
3300
+
3301
+ // Default export MUST be a V1 PluginModule record.
3302
+ export default {
3303
+ id: "workflow-guard",
3304
+ server: WorkflowGuard,
3305
+ } satisfies PluginModule;