opencode-workflow-guard 1.5.0 → 1.6.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,471 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { loadVerifyCache } from "./audit.ts";
4
+ import {
5
+ getLastReviewResult,
6
+ getSdkClient,
7
+ getWorkspaceRoot,
8
+ isDocumentationRequired,
9
+ isReviewRequired,
10
+ lastMutationTimestamp,
11
+ lastVerify,
12
+ recordMutation,
13
+ recordVerifyResult,
14
+ sessionMutationTimestamps,
15
+ sessionReviews,
16
+ sessionVerifyResults,
17
+ } from "./state.ts";
18
+ import {
19
+ dynamicShellSyntaxIn,
20
+ extractCommands,
21
+ normalize,
22
+ shellWrappersChangeCwd,
23
+ splitShellSegments,
24
+ unwrapShellCommand,
25
+ } from "./shell.ts";
26
+ import { asRecord, extractTargetPath } from "./utils.ts";
27
+ import {
28
+ detectVerifyCommand,
29
+ getCurrentGitCommitHash,
30
+ getGitStatusSummary,
31
+ getGitWorktreeFingerprint,
32
+ runVerify,
33
+ snipVerifyOutput,
34
+ } from "./verify.ts";
35
+ import { detectShellMutation, extractPatchPaths, guardShellMutation, isPathOutsideWorkspace } from "../policies/boundary.ts";
36
+ import { branchHasChangelogChange, checkLockfileSync, hasPrCreateInvocation, prBodyIncludesChangelog } from "../policies/changelog.ts";
37
+ import { extractEditContent, liveMutationIn } from "../policies/destructive.ts";
38
+ import { branchHasDocumentationChange } from "../policies/docs.ts";
39
+ import { claimFiles } from "../policies/file-claims.ts";
40
+ import {
41
+ GIT_BRANCH_CREATE_RE,
42
+ GIT_WRITE_RE,
43
+ PUSH_TO_MAIN_RE,
44
+ branchGuardReason,
45
+ checkBranchBaseIsUpToDate,
46
+ checkMergeConflicts,
47
+ currentGitBranch,
48
+ hasUnsafeGitAlias,
49
+ isBranchAlreadyMergedOrClosed,
50
+ normalizeGitCommands,
51
+ onProtectedBranch,
52
+ parseGitInvocation,
53
+ pushedProtectedBranchIn,
54
+ } from "../policies/git.ts";
55
+ import { extractInterpreterPayload, outsideWritePathInPayload, secretPathInPayload, writePathsInPayload } from "../policies/interpreter.ts";
56
+ import { mcpMutationTool } from "../policies/mcp.ts";
57
+ import { editTargets } from "../policies/post-edit-validation.ts";
58
+ import { generateMaskedEnvSchema, isEnvFilePath, isSecretPath, secretFileReadIn, secretIn } from "../policies/secrets.ts";
59
+ import { checkInteractiveTtyCommand, checkPackageHygiene } from "../policies/shell-safety.ts";
60
+ import { staleWriteReason } from "../policies/stale-write.ts";
61
+ import { PROTECTED_PATH_REASON, isProtectedPath, isSettingsTamper } from "../policies/tamper.ts";
62
+ import {
63
+ EDIT_TOOL_NAMES,
64
+ effectiveTodoOwnerSessionID,
65
+ effectiveTodos,
66
+ fetchSessionTodos,
67
+ hasActiveTodo,
68
+ subagentMutationBudgetReason,
69
+ validateTodoLifecycle,
70
+ } from "../policies/todo.ts";
71
+
72
+ const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
73
+
74
+ const READ_ONLY_ROLES = new Set([
75
+ "reviewer",
76
+ "planner",
77
+ "advisor",
78
+ "critic",
79
+ "explorer",
80
+ "scout",
81
+ "evaluator",
82
+ ]);
83
+
84
+ export function isReadOnlyRole(agent?: string): boolean {
85
+ if (!agent) return false;
86
+ const lower = agent.toLowerCase().trim();
87
+ return READ_ONLY_ROLES.has(lower) || Array.from(READ_ONLY_ROLES).some((r) => lower.includes(r));
88
+ }
89
+
90
+ function logBlock(message: string): void {
91
+ try {
92
+ void (getSdkClient() as any)?.app?.log?.({
93
+ body: {
94
+ service: "workflow-guard",
95
+ level: "warn",
96
+ message,
97
+ },
98
+ });
99
+ } catch {}
100
+ }
101
+
102
+ export async function guardToolCallImpl(
103
+ toolName: string,
104
+ input: unknown,
105
+ context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
106
+ ): Promise<string | undefined> {
107
+ const currentRoot = getWorkspaceRoot();
108
+
109
+ if (context?.agent && isReadOnlyRole(context.agent)) {
110
+ if (EDIT_TOOL_NAMES.has(toolName) || toolName.startsWith("guard_worktree_")) {
111
+ const reason = `Blocked: subagent with read-only role '${context.agent}' cannot perform file mutations or lifecycle changes.`;
112
+ logBlock(`[workflow-guard] ${reason}`);
113
+ return reason;
114
+ }
115
+ }
116
+
117
+ if (toolName === "read" || toolName === "read_file") {
118
+ const target = extractTargetPath(input) ?? "";
119
+ if (target && isSecretPath(target)) {
120
+ logBlock(`[workflow-guard] blocked read: secret file ${target}`);
121
+ if (isEnvFilePath(target)) {
122
+ let schemaHint = "";
123
+ try {
124
+ const resolvedTarget = resolve(currentRoot, target);
125
+ if (existsSync(resolvedTarget)) {
126
+ const raw = readFileSync(resolvedTarget, "utf8");
127
+ const masked = generateMaskedEnvSchema(raw);
128
+ schemaHint = `\n\nSafe variable schema mask:\n\`\`\`\n${masked.slice(0, 800)}\n\`\`\``;
129
+ }
130
+ } catch {}
131
+ return `Blocked: reading sensitive credential file '${target}' directly is not permitted. Use environment variables or reference safe templates.${schemaHint}`;
132
+ }
133
+ return `Blocked: reading sensitive credential/secret file '${target}' is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`;
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ if (toolName === "todowrite") {
139
+ const record = asRecord(input);
140
+ const rawTodos = record?.todos;
141
+ if (Array.isArray(rawTodos)) {
142
+ const newTodos = rawTodos as any[];
143
+ const existingTodos = context?.sessionID ? await fetchSessionTodos(context.sessionID) : undefined;
144
+ const err = validateTodoLifecycle(newTodos, existingTodos);
145
+ if (err) {
146
+ logBlock(`[workflow-guard] ${err}`);
147
+ return err;
148
+ }
149
+ const allDone = newTodos.length > 0 && newTodos.every((t) => {
150
+ const s = String(t.status ?? "");
151
+ return s === "completed" || s === "cancelled";
152
+ });
153
+ if (allDone) {
154
+ const conflictCheck = checkMergeConflicts(currentRoot);
155
+ if (conflictCheck.hasConflicts) {
156
+ const reason = `Blocked todowrite: ${conflictCheck.reason}`;
157
+ logBlock(`[workflow-guard] ${reason}`);
158
+ return reason;
159
+ }
160
+ const command = detectVerifyCommand(currentRoot);
161
+ if (command) {
162
+ const sessionID = context?.sessionID;
163
+ let verifyResult = sessionID ? sessionVerifyResults.get(sessionID) : lastVerify;
164
+ if (!verifyResult) {
165
+ const diskCached = loadVerifyCache();
166
+ if (diskCached && diskCached.passed && diskCached.command === command && diskCached.workspaceRoot === resolve(currentRoot)) verifyResult = diskCached;
167
+ }
168
+ const mutationTimestamp = sessionID ? (sessionMutationTimestamps.get(sessionID) ?? 0) : lastMutationTimestamp;
169
+ const currentCommit = getCurrentGitCommitHash(currentRoot);
170
+ const currentGitStatus = getGitStatusSummary(currentRoot);
171
+ const gitStateMatches = !verifyResult?.commitHash || (verifyResult.commitHash === currentCommit && verifyResult.gitStatus === currentGitStatus);
172
+ const isFresh = verifyResult !== undefined && verifyResult.passed && verifyResult.command === command && verifyResult.timestamp >= mutationTimestamp && gitStateMatches;
173
+ if (!isFresh) {
174
+ const result = await runVerify(command, currentRoot);
175
+ recordVerifyResult(command, result, sessionID, currentRoot);
176
+ if (!result.passed) {
177
+ const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
178
+ const reason = `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
179
+ logBlock(`[workflow-guard] ${reason}`);
180
+ return reason;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
186
+ return undefined;
187
+ }
188
+
189
+ if (EDIT_TOOL_NAMES.has(toolName)) {
190
+ const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
191
+ const record = asRecord(input);
192
+ const target = extractTargetPath(input) ?? "";
193
+ if (target && isProtectedPath(target)) {
194
+ logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
195
+ return PROTECTED_PATH_REASON;
196
+ }
197
+ if (target && isSecretPath(target)) {
198
+ logBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
199
+ return `Blocked: modifying secret file '${target}' directly is not permitted. Store credentials in environment variables or safe secret stores instead.`;
200
+ }
201
+ if (toolName === "apply_patch") {
202
+ const patchText = typeof record?.patchText === "string" ? record.patchText : "";
203
+ for (const patchPath of extractPatchPaths(patchText)) {
204
+ if (isProtectedPath(patchPath)) {
205
+ logBlock(`[workflow-guard] blocked apply_patch: protected path ${patchPath}`);
206
+ return PROTECTED_PATH_REASON;
207
+ }
208
+ if (isSecretPath(patchPath)) {
209
+ logBlock(`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`);
210
+ return `Blocked: modifying secret file '${patchPath}' directly is not permitted.`;
211
+ }
212
+ if (isPathOutsideWorkspace(patchPath, currentRoot)) {
213
+ logBlock(`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`);
214
+ return `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`;
215
+ }
216
+ }
217
+ }
218
+ if (target && isPathOutsideWorkspace(target, currentRoot)) {
219
+ logBlock(`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`);
220
+ return `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`;
221
+ }
222
+ for (const content of extractEditContent(input)) {
223
+ const secret = secretIn(content);
224
+ if (secret) {
225
+ logBlock(`[workflow-guard] blocked ${toolName}: payload contains ${secret}`);
226
+ return `Blocked: payload appears to contain a ${secret}. Secrets must not be committed to the repository. Store them in a secret manager or environment file excluded from git, and reference them by name instead.`;
227
+ }
228
+ }
229
+ if (!allowLive) {
230
+ for (const content of extractEditContent(input)) {
231
+ for (const line of content.split("\n")) {
232
+ const trimmed = line.trim();
233
+ if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) continue;
234
+ const what = liveMutationIn(normalizeGitCommands(line));
235
+ if (what) {
236
+ logBlock(`[workflow-guard] blocked ${toolName}: payload contains ${what}`);
237
+ return `Blocked: the file you are writing contains a ${what}. Script files are not a way to smuggle destructive commands past the shell guard. Only the user can allow live mutations (WORKFLOW_GUARD_ALLOW_LIVE=1).`;
238
+ }
239
+ }
240
+ }
241
+ }
242
+ for (const content of extractEditContent(input)) if (isSettingsTamper(content)) return PROTECTED_PATH_REASON;
243
+ if (onProtectedBranch(currentRoot)) {
244
+ logBlock(`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`);
245
+ return branchGuardReason();
246
+ }
247
+ const todos = await effectiveTodos(context?.sessionID);
248
+ if (todos !== undefined && !hasActiveTodo(todos)) {
249
+ logBlock(`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`);
250
+ return "Blocked: no active todo item. First break the request down with the todowrite tool (create items with status 'pending' or 'in_progress'), then work them through, marking each completed via todowrite as you finish it. When every item is completed, create a fresh todo list before starting new work.";
251
+ }
252
+ if (context?.sessionID) {
253
+ const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
254
+ if (reason) {
255
+ logBlock(`[workflow-guard] ${reason}`);
256
+ return reason;
257
+ }
258
+ }
259
+ if (context?.sessionID && context.callID) {
260
+ if (toolName === "edit" || toolName === "write") {
261
+ for (const path of editTargets(input, currentRoot)) {
262
+ const staleReason = staleWriteReason(path, context.sessionID);
263
+ if (staleReason) {
264
+ logBlock(`[workflow-guard] ${staleReason}`);
265
+ return staleReason;
266
+ }
267
+ }
268
+ }
269
+ const claimReason = claimFiles(editTargets(input, currentRoot), context.sessionID, context.callID);
270
+ if (claimReason) {
271
+ logBlock(`[workflow-guard] ${claimReason}`);
272
+ return claimReason;
273
+ }
274
+ }
275
+ recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
276
+ return undefined;
277
+ }
278
+
279
+ const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
280
+ if (!allowLive) {
281
+ const mcpWhat = mcpMutationTool(toolName);
282
+ if (mcpWhat) {
283
+ logBlock(`[workflow-guard] blocked MCP tool ${toolName} (${mcpWhat} mutation)`);
284
+ return `Blocked: ${toolName} mutates ${mcpWhat} - a live system. Changes must be made in code unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent.`;
285
+ }
286
+ }
287
+ if (!SHELL_TOOL_NAMES.has(toolName)) return undefined;
288
+
289
+ for (const raw of extractCommands(input)) {
290
+ const dynamicSyntax = dynamicShellSyntaxIn(raw);
291
+ if (dynamicSyntax) return `Blocked: command contains ${dynamicSyntax} that cannot be safely classified without executing shell expansion.`;
292
+ const command = normalize(raw);
293
+ const ttyCheck = checkInteractiveTtyCommand(command);
294
+ if (ttyCheck.isInteractive) {
295
+ logBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
296
+ return `Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ${ttyCheck.advice}`;
297
+ }
298
+ const packageCheck = checkPackageHygiene(command);
299
+ if (packageCheck.isViolating && !allowLive) {
300
+ logBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
301
+ return `Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ${packageCheck.advice}`;
302
+ }
303
+ const normalizedCommand = normalizeGitCommands(command);
304
+ if (hasUnsafeGitAlias(command)) return "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.";
305
+ const gitInvocations = splitShellSegments(command).map((segment) => parseGitInvocation(segment.trim())).filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
306
+ const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
307
+ for (const invocation of gitInvocations) {
308
+ const normalizedInvocation = `git ${invocation.rest}`;
309
+ if (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))) {
310
+ logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
311
+ return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
312
+ }
313
+ }
314
+ for (const invocation of gitInvocations) {
315
+ if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
316
+ logBlock(`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`);
317
+ return branchGuardReason();
318
+ }
319
+ }
320
+ if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
321
+ const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
322
+ if (behindCheck.isBehind) {
323
+ logBlock(`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`);
324
+ return `Blocked: ${behindCheck.reason}`;
325
+ }
326
+ }
327
+ if (isSettingsTamper(command)) {
328
+ logBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
329
+ return PROTECTED_PATH_REASON;
330
+ }
331
+ for (const segment of command.split(/[\n|;&]+/)) {
332
+ const secretFile = secretFileReadIn(segment.trim());
333
+ if (secretFile) {
334
+ logBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
335
+ return `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`;
336
+ }
337
+ }
338
+ for (const payload of extractInterpreterPayload(command)) {
339
+ const outsidePath = outsideWritePathInPayload(payload, currentRoot);
340
+ if (outsidePath) {
341
+ logBlock(`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`);
342
+ return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
343
+ }
344
+ const writePaths = writePathsInPayload(payload);
345
+ if (writePaths.length > 0) {
346
+ if (context?.agent && isReadOnlyRole(context.agent)) return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations.`;
347
+ for (const path of writePaths) if (isProtectedPath(path)) return PROTECTED_PATH_REASON;
348
+ if (onProtectedBranch(currentRoot)) return branchGuardReason();
349
+ const todos = await effectiveTodos(context?.sessionID);
350
+ if (todos !== undefined && !hasActiveTodo(todos)) return "Blocked: inline interpreter file mutation with no active todo item.";
351
+ recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
352
+ }
353
+ if (!allowLive) {
354
+ const liveCheck = liveMutationIn(normalizeGitCommands(normalize(payload)));
355
+ if (liveCheck) {
356
+ logBlock(`[workflow-guard] blocked interpreter payload containing ${liveCheck}`);
357
+ return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`;
358
+ }
359
+ }
360
+ if (isSettingsTamper(payload)) return PROTECTED_PATH_REASON;
361
+ const secretPath = secretPathInPayload(payload);
362
+ if (secretPath) return `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`;
363
+ }
364
+ const shellMutationReason = await guardShellMutation(command, context?.sessionID);
365
+ if (shellMutationReason) {
366
+ logBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
367
+ return shellMutationReason;
368
+ }
369
+ if (!allowLive) {
370
+ const what = liveMutationIn(normalizedCommand) ?? liveMutationIn(command);
371
+ if (what) {
372
+ logBlock(`[workflow-guard] blocked ${what}: ${command.slice(0, 120)}`);
373
+ return `Blocked: ${what} targets a live system. Changes must be made in code (IaC, migrations, source) unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent.`;
374
+ }
375
+ }
376
+ if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
377
+ logBlock(`[workflow-guard] blocked push to main/master: ${command}`);
378
+ return "Blocked: direct pushes to main/master are not allowed. Create a feature branch and open a PR instead.";
379
+ }
380
+ for (const invocation of gitInvocations) {
381
+ const pushText = `git ${invocation.rest}`;
382
+ if (!/\bgit\s+push\b/.test(pushText)) continue;
383
+ const pushedBranch = pushedProtectedBranchIn(pushText, invocation.repoDir);
384
+ if (pushedBranch) {
385
+ logBlock(`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`);
386
+ return `Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. Create a feature branch and open a PR instead.`;
387
+ }
388
+ }
389
+ for (const invocation of gitInvocations) {
390
+ if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
391
+ if (onProtectedBranch(invocation.repoDir)) {
392
+ logBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
393
+ return branchGuardReason();
394
+ }
395
+ const branch = currentGitBranch(invocation.repoDir);
396
+ if (branch) {
397
+ const mergedStatus = isBranchAlreadyMergedOrClosed(invocation.repoDir, branch);
398
+ if (mergedStatus.merged) {
399
+ logBlock(`[workflow-guard] blocked push to merged/closed branch: ${branch}`);
400
+ return `Blocked: ${mergedStatus.reason}`;
401
+ }
402
+ }
403
+ }
404
+ if (hasPrCreateInvocation(raw)) {
405
+ const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
406
+ const prTool = isAz ? "az repos pr create" : "gh pr create";
407
+ const descFlag = isAz ? "--description" : "--body";
408
+ const preflightFailures: string[] = [];
409
+ const conflictCheck = checkMergeConflicts(currentRoot);
410
+ if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
411
+ const branch = currentGitBranch(currentRoot);
412
+ if (branch) {
413
+ const mergedStatus = isBranchAlreadyMergedOrClosed(currentRoot, branch);
414
+ if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
415
+ }
416
+ if (isReviewRequired(currentRoot)) {
417
+ const review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
418
+ const reviewMatchesContext = review?.passed === true && review.workspace === resolve(currentRoot) && (!review.targetSessionID || review.targetSessionID === context?.sessionID) && review.commitHash === getCurrentGitCommitHash(currentRoot) && review.gitStatus === getGitStatusSummary(currentRoot) && review.worktreeFingerprint === getGitWorktreeFingerprint(currentRoot);
419
+ if (!reviewMatchesContext) preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
420
+ }
421
+ if (isDocumentationRequired(currentRoot) && !branchHasDocumentationChange(currentRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
422
+ const branchChangelog = branchHasChangelogChange(currentRoot);
423
+ let shellCwdKnown = true;
424
+ const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
425
+ for (const segment of splitShellSegments(raw)) {
426
+ const unwrapped = unwrapShellCommand(segment);
427
+ if (/(?:^|\s)(?:(?:builtin|command)\s+)?(?:cd|pushd|popd)(?:\s|$)/.test(unwrapped)) shellCwdKnown = false;
428
+ if (hasPrCreateInvocation(segment)) {
429
+ const wrapperChangesCwd = shellWrappersChangeCwd(segment);
430
+ prSegments.push({ segment, invocationRoot: shellCwdKnown && !wrapperChangesCwd ? currentRoot : null });
431
+ }
432
+ }
433
+ const hasChangelog = branchChangelog || prSegments.every(({ segment, invocationRoot }) => prBodyIncludesChangelog(segment, invocationRoot));
434
+ if (!hasChangelog) preflightFailures.push(`Release information is required; update a CHANGELOG/changeset file or include a Summary, Changes, Release notes, or Changelog section in the PR description (${descFlag}).`);
435
+ const lockCheck = checkLockfileSync(currentRoot);
436
+ if (lockCheck.isOutOfSync) preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
437
+ if (preflightFailures.length > 0) {
438
+ const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
439
+ logBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
440
+ return reason;
441
+ }
442
+ }
443
+ const hasGitMutation = gitInvocations.some((invocation) => {
444
+ const normalizedInvocation = `git ${invocation.rest}`;
445
+ return GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+(?:switch|checkout)\b/.test(normalizedInvocation);
446
+ });
447
+ if (context?.agent && isReadOnlyRole(context.agent)) {
448
+ const mutation = detectShellMutation(command);
449
+ if (mutation) {
450
+ logBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
451
+ return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`;
452
+ }
453
+ if (hasGitMutation) {
454
+ logBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
455
+ return `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`;
456
+ }
457
+ }
458
+ const shellMut = detectShellMutation(command);
459
+ if (hasGitMutation || shellMut) {
460
+ if (context?.sessionID) {
461
+ const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
462
+ if (reason) {
463
+ logBlock(`[workflow-guard] ${reason}`);
464
+ return reason;
465
+ }
466
+ }
467
+ }
468
+ if (hasGitMutation) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
469
+ }
470
+ return undefined;
471
+ }
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ProjectConfig } from "./types.ts";
4
+
5
+ let cachedProjectConfig: ProjectConfig | undefined;
6
+
7
+ export function projectConfigCandidates(root: string): string[] {
8
+ return [
9
+ join(root, ".opencode", "workflow-guard.json"),
10
+ join(root, ".opencode", "workflow-guard.jsonc"),
11
+ join(root, "workflow-guard.json"),
12
+ join(root, "workflow-guard.jsonc"),
13
+ ];
14
+ }
15
+
16
+ export function projectConfigPath(root: string): string {
17
+ const candidates = projectConfigCandidates(root);
18
+ return candidates.find(existsSync) ?? candidates[0]!;
19
+ }
20
+
21
+ /** Strip JSONC comments and trailing commas without altering string literals. */
22
+ export function stripJsonComments(jsonc: string): string {
23
+ let insideString = false;
24
+ let stringQuote = "";
25
+ let escaped = false;
26
+ let output = "";
27
+ let i = 0;
28
+ while (i < jsonc.length) {
29
+ const char = jsonc[i]!;
30
+ const next = jsonc[i + 1];
31
+ if (escaped) { output += char; escaped = false; i++; continue; }
32
+ if (char === "\\") { output += char; escaped = true; i++; continue; }
33
+ if (char === '"' || char === "'") {
34
+ if (!insideString) { insideString = true; stringQuote = char; }
35
+ else if (stringQuote === char) insideString = false;
36
+ output += char; i++; continue;
37
+ }
38
+ if (!insideString && char === "/" && next === "/") {
39
+ i += 2;
40
+ while (i < jsonc.length && jsonc[i] !== "\n" && jsonc[i] !== "\r") i++;
41
+ continue;
42
+ }
43
+ if (!insideString && char === "/" && next === "*") {
44
+ i += 2;
45
+ while (i < jsonc.length && !(jsonc[i] === "*" && jsonc[i + 1] === "/")) i++;
46
+ i += 2; continue;
47
+ }
48
+ output += char; i++;
49
+ }
50
+ return output.replace(/,(\s*[}\]])/g, "$1");
51
+ }
52
+
53
+ export function loadProjectConfig(root: string): ProjectConfig {
54
+ for (const candidate of projectConfigCandidates(root)) {
55
+ try {
56
+ return JSON.parse(stripJsonComments(readFileSync(candidate, "utf8")));
57
+ } catch {}
58
+ }
59
+ return {};
60
+ }
61
+
62
+ export function reloadProjectConfig(root: string): void {
63
+ cachedProjectConfig = loadProjectConfig(root);
64
+ }
65
+
66
+ export function getCachedProjectConfig(): ProjectConfig | undefined {
67
+ return cachedProjectConfig;
68
+ }
@@ -3,7 +3,7 @@ import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFil
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { DatabaseSync } from "node:sqlite";
6
- import type { ProjectMemoryInput, ProjectMemoryKind, ProjectMemoryRecord, ProjectMemorySource } from "./types.ts";
6
+ import type { ProjectMemoryInput, ProjectMemoryKind, ProjectMemoryRecord, ProjectMemorySource, ReviewFollowup, ReviewFollowupInput } from "./types.ts";
7
7
 
8
8
  const KINDS = new Set<ProjectMemoryKind>(["fact", "decision", "constraint", "lesson"]);
9
9
  const SOURCES = new Set<ProjectMemorySource>(["user", "file", "git", "tool", "agent", "portable"]);
@@ -58,10 +58,53 @@ export function openProjectMemory(projectId: string, directory = getProjectMemor
58
58
  paths TEXT NOT NULL, supersedes TEXT, status TEXT NOT NULL DEFAULT 'current'
59
59
  );
60
60
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content);
61
+ CREATE TABLE IF NOT EXISTS review_followups (
62
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, severity TEXT NOT NULL, summary TEXT NOT NULL,
63
+ reviewer TEXT NOT NULL, created_at INTEGER NOT NULL, resolved_at INTEGER, session_id TEXT,
64
+ commit_sha TEXT, paths TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open'
65
+ );
61
66
  `);
62
67
  return { db, projectId, close: () => db.close() };
63
68
  }
64
69
 
70
+ function followupRowToRecord(row: Record<string, unknown>): ReviewFollowup {
71
+ return {
72
+ id: String(row.id), projectId: String(row.project_id), severity: String(row.severity) as ReviewFollowup["severity"],
73
+ summary: String(row.summary), reviewer: String(row.reviewer), createdAt: Number(row.created_at),
74
+ resolvedAt: row.resolved_at ? Number(row.resolved_at) : undefined, sessionID: row.session_id ? String(row.session_id) : undefined,
75
+ commit: row.commit_sha ? String(row.commit_sha) : undefined, paths: JSON.parse(String(row.paths)) as string[],
76
+ status: String(row.status) as ReviewFollowup["status"],
77
+ };
78
+ }
79
+
80
+ export function recordReviewFollowup(store: ProjectMemoryStore, input: ReviewFollowupInput, id: string = randomUUID()): ReviewFollowup {
81
+ if (input.severity !== "P2" && input.severity !== "P3") throw new Error("Review follow-up severity must be P2 or P3.");
82
+ if (!input.summary.trim() || input.summary.length > 4000) throw new Error("Review follow-up summary must be 1-4000 characters.");
83
+ if (!input.reviewer.trim() || input.reviewer.length > 200) throw new Error("Invalid review follow-up reviewer.");
84
+ if ((input.paths?.length ?? 0) > 20 || input.paths?.some((path) => !path || path.length > 500 || isAbsolute(path) || path.split(/[\\/]/).includes("..") || /[\0\r\n]/.test(path))) throw new Error("Invalid review follow-up paths.");
85
+ if (input.sessionID && input.sessionID.length > 200) throw new Error("Invalid review follow-up session ID.");
86
+ if (input.commit && !/^[0-9a-f]{7,64}$/i.test(input.commit)) throw new Error("Invalid review follow-up commit.");
87
+ const createdAt = Date.now();
88
+ store.db.prepare("INSERT INTO review_followups VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, 'open')").run(
89
+ id, store.projectId, input.severity, input.summary.trim(), input.reviewer.trim(), createdAt,
90
+ input.sessionID ?? null, input.commit ?? null, JSON.stringify(input.paths ?? []),
91
+ );
92
+ return { id, projectId: store.projectId, ...input, summary: input.summary.trim(), reviewer: input.reviewer.trim(), paths: input.paths ?? [], createdAt, status: "open" };
93
+ }
94
+
95
+ export function listReviewFollowups(store: ProjectMemoryStore, status: "open" | "resolved" = "open", limit = 50): ReviewFollowup[] {
96
+ const rows = store.db.prepare("SELECT * FROM review_followups WHERE project_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?")
97
+ .all(store.projectId, status, Math.max(1, Math.min(limit, 200))) as Record<string, unknown>[];
98
+ return rows.map(followupRowToRecord);
99
+ }
100
+
101
+ export function resolveReviewFollowup(store: ProjectMemoryStore, id: string): boolean {
102
+ if (!id || id.length > 200 || /[\0\r\n]/.test(id)) return false;
103
+ const result = store.db.prepare("UPDATE review_followups SET status = 'resolved', resolved_at = ? WHERE id = ? AND project_id = ? AND status = 'open'")
104
+ .run(Date.now(), id, store.projectId);
105
+ return Number(result.changes) === 1;
106
+ }
107
+
65
108
  function validateInput(input: ProjectMemoryInput): void {
66
109
  if (!KINDS.has(input.kind) || !SOURCES.has(input.source)) throw new Error("Invalid project memory kind or source.");
67
110
  if (!input.content.trim() || input.content.length > 4000) throw new Error("Project memory content must be 1-4000 characters.");