opencode-workflow-guard 1.8.6 → 1.9.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.
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { loadVerifyCache } from "./audit.ts";
4
+ import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
5
+ import { projectRootKey } from "./project-config.ts";
4
6
  import {
5
7
  getLastReviewResult,
6
8
  getSdkClient,
@@ -24,6 +26,7 @@ import {
24
26
  unwrapShellCommand,
25
27
  } from "./shell.ts";
26
28
  import { asRecord, extractTargetPath } from "./utils.ts";
29
+ import type { PolicyDecision } from "./types.ts";
27
30
  import { getProjectMemoryIdentity } from "./project-memory.ts";
28
31
  import {
29
32
  detectVerifyCommand,
@@ -37,7 +40,7 @@ import { detectShellMutation, extractPatchPaths, guardShellMutation, isPathOutsi
37
40
  import { branchHasChangelogChange, checkLockfileSync, hasPrCreateInvocation, prBodyHasLiteralLineBreakEscapes, prBodyIncludesChangelog } from "../policies/changelog.ts";
38
41
  import { extractEditContent, liveMutationIn } from "../policies/destructive.ts";
39
42
  import { branchHasDocumentationChange } from "../policies/docs.ts";
40
- import { claimFiles } from "../policies/file-claims.ts";
43
+ import { claimFiles, fileClaimConflictReason } from "../policies/file-claims.ts";
41
44
  import {
42
45
  GIT_BRANCH_CREATE_RE,
43
46
  GIT_WRITE_RE,
@@ -88,7 +91,8 @@ export function isReadOnlyRole(agent?: string): boolean {
88
91
  return READ_ONLY_ROLES.has(lower) || Array.from(READ_ONLY_ROLES).some((r) => lower.includes(r));
89
92
  }
90
93
 
91
- function logBlock(message: string): void {
94
+ function logBlock(message: string, simulate = false): void {
95
+ if (simulate) return;
92
96
  try {
93
97
  void getSdkClient()?.app?.log?.({
94
98
  body: {
@@ -103,22 +107,27 @@ function logBlock(message: string): void {
103
107
  export async function guardToolCallImpl(
104
108
  toolName: string,
105
109
  input: unknown,
106
- context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
107
- ): Promise<string | undefined> {
110
+ context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string; simulate?: boolean },
111
+ ): Promise<PolicyDecision> {
112
+ const logPolicyBlock = (message: string) => logBlock(message, context?.simulate);
108
113
  const currentRoot = getWorkspaceRoot();
114
+ const allow = (): PolicyDecision => ({ status: "allowed", code: "allowed", message: "Allowed by current guardrails." });
115
+ const block = (policy: string, code: string, message: string): PolicyDecision => ({ status: "blocked", policy, code, message });
116
+ const needsApproval = (policy: string, code: string, message: string): PolicyDecision => ({ status: "needs_approval", policy, code, message });
117
+ let remotePrStateUnchecked = false;
109
118
 
110
119
  if (context?.agent && isReadOnlyRole(context.agent)) {
111
120
  if (EDIT_TOOL_NAMES.has(toolName) || toolName.startsWith("guard_worktree_")) {
112
121
  const reason = `Blocked: subagent with read-only role '${context.agent}' cannot perform file mutations or lifecycle changes.`;
113
- logBlock(`[workflow-guard] ${reason}`);
114
- return reason;
122
+ logPolicyBlock(`[workflow-guard] ${reason}`);
123
+ return block("subagent-role", "read_only_role", reason);
115
124
  }
116
125
  }
117
126
 
118
127
  if (toolName === "read" || toolName === "read_file") {
119
128
  const target = extractTargetPath(input) ?? "";
120
129
  if (target && isSecretPath(target)) {
121
- logBlock(`[workflow-guard] blocked read: secret file ${target}`);
130
+ logPolicyBlock(`[workflow-guard] blocked read: secret file ${target}`);
122
131
  if (isEnvFilePath(target)) {
123
132
  let schemaHint = "";
124
133
  try {
@@ -129,11 +138,11 @@ export async function guardToolCallImpl(
129
138
  schemaHint = `\n\nSafe variable schema mask:\n\`\`\`\n${masked.slice(0, 800)}\n\`\`\``;
130
139
  }
131
140
  } catch {}
132
- return `Blocked: reading sensitive credential file '${target}' directly is not permitted. Use environment variables or reference safe templates.${schemaHint}`;
141
+ return block("secrets", "secret_read", `Blocked: reading sensitive credential file '${target}' directly is not permitted. Use environment variables or reference safe templates.${schemaHint}`);
133
142
  }
134
- 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.`;
143
+ return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${target}' is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`);
135
144
  }
136
- return undefined;
145
+ return allow();
137
146
  }
138
147
 
139
148
  if (toolName === "todowrite") {
@@ -144,8 +153,8 @@ export async function guardToolCallImpl(
144
153
  const existingTodos = context?.sessionID ? await fetchSessionTodos(context.sessionID) : undefined;
145
154
  const err = validateTodoLifecycle(newTodos, existingTodos);
146
155
  if (err) {
147
- logBlock(`[workflow-guard] ${err}`);
148
- return err;
156
+ logPolicyBlock(`[workflow-guard] ${err}`);
157
+ return block("todo", "todo_lifecycle", err);
149
158
  }
150
159
  const allDone = newTodos.length > 0 && newTodos.every((t) => {
151
160
  const s = String(t.status ?? "");
@@ -155,8 +164,8 @@ export async function guardToolCallImpl(
155
164
  const conflictCheck = checkMergeConflicts(currentRoot);
156
165
  if (conflictCheck.hasConflicts) {
157
166
  const reason = `Blocked todowrite: ${conflictCheck.reason}`;
158
- logBlock(`[workflow-guard] ${reason}`);
159
- return reason;
167
+ logPolicyBlock(`[workflow-guard] ${reason}`);
168
+ return block("git", "merge_conflict", reason);
160
169
  }
161
170
  const command = detectVerifyCommand(currentRoot);
162
171
  if (command) {
@@ -164,27 +173,26 @@ export async function guardToolCallImpl(
164
173
  let verifyResult = sessionID ? sessionVerifyResults.get(sessionID) : lastVerify;
165
174
  if (!verifyResult) {
166
175
  const diskCached = loadVerifyCache();
167
- if (diskCached && diskCached.passed && diskCached.command === command && diskCached.workspaceRoot === resolve(currentRoot)) verifyResult = diskCached;
176
+ if (diskCached && diskCached.passed && diskCached.command === command && diskCached.workspaceRoot && projectRootKey(diskCached.workspaceRoot) === projectRootKey(currentRoot)) verifyResult = diskCached;
168
177
  }
169
178
  const mutationTimestamp = sessionID ? (sessionMutationTimestamps.get(sessionID) ?? 0) : lastMutationTimestamp;
170
- const currentCommit = getCurrentGitCommitHash(currentRoot);
171
- const currentGitStatus = getGitStatusSummary(currentRoot);
172
- const gitStateMatches = !verifyResult?.commitHash || (verifyResult.commitHash === currentCommit && verifyResult.gitStatus === currentGitStatus);
173
- const isFresh = verifyResult !== undefined && verifyResult.passed && verifyResult.command === command && verifyResult.timestamp >= mutationTimestamp && gitStateMatches;
179
+ const currentSubject = { workspace: projectRootKey(currentRoot), commitHash: getCurrentGitCommitHash(currentRoot), worktreeFingerprint: getGitWorktreeFingerprint(currentRoot), sessionID };
180
+ const isFresh = verifyResult !== undefined && verifyResult.passed && verifyResult.command === command && isEvidenceFresh(verificationEvidence(verifyResult, sessionID), currentSubject, mutationTimestamp);
174
181
  if (!isFresh) {
182
+ if (context?.simulate) return block("verification", "verification_required", `Blocked: finalization requires fresh passing verification (${command}).`);
175
183
  const result = await runVerify(command, currentRoot);
176
184
  recordVerifyResult(command, result, sessionID, currentRoot);
177
185
  if (!result.passed) {
178
186
  const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
179
187
  const reason = `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
180
- logBlock(`[workflow-guard] ${reason}`);
181
- return reason;
188
+ logPolicyBlock(`[workflow-guard] ${reason}`);
189
+ return block("verification", "verification_failed", reason);
182
190
  }
183
191
  }
184
192
  }
185
193
  }
186
194
  }
187
- return undefined;
195
+ return allow();
188
196
  }
189
197
 
190
198
  if (EDIT_TOOL_NAMES.has(toolName)) {
@@ -192,39 +200,39 @@ export async function guardToolCallImpl(
192
200
  const record = asRecord(input);
193
201
  const target = extractTargetPath(input) ?? "";
194
202
  if (target && isProtectedPath(target)) {
195
- logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
196
- return PROTECTED_PATH_REASON;
203
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
204
+ return block("tamper", "protected_path", PROTECTED_PATH_REASON);
197
205
  }
198
206
  if (target && isSecretPath(target)) {
199
- logBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
200
- return `Blocked: modifying secret file '${target}' directly is not permitted. Store credentials in environment variables or safe secret stores instead.`;
207
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
208
+ return block("secrets", "secret_write", `Blocked: modifying secret file '${target}' directly is not permitted. Store credentials in environment variables or safe secret stores instead.`);
201
209
  }
202
210
  if (toolName === "apply_patch") {
203
211
  const patchText = typeof record?.patchText === "string" ? record.patchText : "";
204
212
  for (const patchPath of extractPatchPaths(patchText)) {
205
213
  if (isProtectedPath(patchPath)) {
206
- logBlock(`[workflow-guard] blocked apply_patch: protected path ${patchPath}`);
207
- return PROTECTED_PATH_REASON;
214
+ logPolicyBlock(`[workflow-guard] blocked apply_patch: protected path ${patchPath}`);
215
+ return block("tamper", "protected_path", PROTECTED_PATH_REASON);
208
216
  }
209
217
  if (isSecretPath(patchPath)) {
210
- logBlock(`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`);
211
- return `Blocked: modifying secret file '${patchPath}' directly is not permitted.`;
218
+ logPolicyBlock(`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`);
219
+ return block("secrets", "secret_write", `Blocked: modifying secret file '${patchPath}' directly is not permitted.`);
212
220
  }
213
221
  if (isPathOutsideWorkspace(patchPath, currentRoot)) {
214
- logBlock(`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`);
215
- return `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`;
222
+ logPolicyBlock(`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`);
223
+ return block("boundary", "workspace_escape", `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`);
216
224
  }
217
225
  }
218
226
  }
219
227
  if (target && isPathOutsideWorkspace(target, currentRoot)) {
220
- logBlock(`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`);
221
- return `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`;
228
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`);
229
+ return block("boundary", "workspace_escape", `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`);
222
230
  }
223
231
  for (const content of extractEditContent(input)) {
224
232
  const secret = secretIn(content);
225
233
  if (secret) {
226
- logBlock(`[workflow-guard] blocked ${toolName}: payload contains ${secret}`);
227
- 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.`;
234
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: payload contains ${secret}`);
235
+ return block("secrets", "secret_payload", `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.`);
228
236
  }
229
237
  }
230
238
  if (!allowLive) {
@@ -234,58 +242,61 @@ export async function guardToolCallImpl(
234
242
  if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) continue;
235
243
  const what = liveMutationIn(normalizeGitCommands(line));
236
244
  if (what) {
237
- logBlock(`[workflow-guard] blocked ${toolName}: payload contains ${what}`);
238
- 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).`;
245
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: payload contains ${what}`);
246
+ return block("destructive", "live_mutation_payload", `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).`);
239
247
  }
240
248
  }
241
249
  }
242
250
  }
243
- for (const content of extractEditContent(input)) if (isSettingsTamper(content)) return PROTECTED_PATH_REASON;
251
+ for (const content of extractEditContent(input)) if (isSettingsTamper(content)) return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
244
252
  if (onProtectedBranch(currentRoot)) {
245
- logBlock(`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`);
246
- return branchGuardReason();
253
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`);
254
+ return block("git", "protected_branch", branchGuardReason());
247
255
  }
248
256
  const todos = await effectiveTodos(context?.sessionID);
249
257
  if (todos !== undefined && !hasActiveTodo(todos)) {
250
- logBlock(`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`);
251
- 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.";
258
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`);
259
+ return block("todo", "no_active_todo", "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.");
252
260
  }
253
261
  if (context?.sessionID) {
254
262
  const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
255
263
  if (reason) {
256
- logBlock(`[workflow-guard] ${reason}`);
257
- return reason;
264
+ logPolicyBlock(`[workflow-guard] ${reason}`);
265
+ return block("todo", "mutation_budget", reason);
258
266
  }
259
267
  }
260
- if (context?.sessionID && context.callID) {
268
+ if (context?.sessionID) {
261
269
  if (toolName === "edit" || toolName === "write") {
262
270
  for (const path of editTargets(input, currentRoot)) {
263
271
  const staleReason = staleWriteReason(path, context.sessionID);
264
272
  if (staleReason) {
265
- logBlock(`[workflow-guard] ${staleReason}`);
266
- return staleReason;
273
+ logPolicyBlock(`[workflow-guard] ${staleReason}`);
274
+ return block("stale-write", "stale_write", staleReason);
267
275
  }
268
276
  }
269
277
  }
270
- const claimReason = claimFiles(editTargets(input, currentRoot), context.sessionID, context.callID);
278
+ const targets = editTargets(input, currentRoot);
279
+ const claimReason = context.simulate
280
+ ? fileClaimConflictReason(targets, context.sessionID)
281
+ : context.callID ? claimFiles(targets, context.sessionID, context.callID) : undefined;
271
282
  if (claimReason) {
272
- logBlock(`[workflow-guard] ${claimReason}`);
273
- return claimReason;
283
+ logPolicyBlock(`[workflow-guard] ${claimReason}`);
284
+ return block("file-claims", "file_claim_conflict", claimReason);
274
285
  }
275
286
  }
276
- recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
277
- return undefined;
287
+ if (!context?.simulate) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
288
+ return allow();
278
289
  }
279
290
 
280
291
  const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
281
292
  if (!allowLive) {
282
293
  const mcpWhat = mcpMutationTool(toolName);
283
294
  if (mcpWhat) {
284
- logBlock(`[workflow-guard] blocked MCP tool ${toolName} (${mcpWhat} mutation)`);
285
- 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.`;
295
+ logPolicyBlock(`[workflow-guard] blocked MCP tool ${toolName} (${mcpWhat} mutation)`);
296
+ return block("mcp", "live_mcp_mutation", `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.`);
286
297
  }
287
298
  }
288
- if (!SHELL_TOOL_NAMES.has(toolName)) return undefined;
299
+ if (!SHELL_TOOL_NAMES.has(toolName)) return allow();
289
300
  const shellWorkdir = asRecord(input)?.workdir;
290
301
  const requestedShellRoot = typeof shellWorkdir === "string" ? resolve(currentRoot, shellWorkdir) : currentRoot;
291
302
  const shellRoot = !isPathOutsideWorkspace(requestedShellRoot, currentRoot) || getProjectMemoryIdentity(requestedShellRoot) === getProjectMemoryIdentity(currentRoot)
@@ -294,116 +305,117 @@ export async function guardToolCallImpl(
294
305
 
295
306
  for (const raw of extractCommands(input)) {
296
307
  const dynamicSyntax = dynamicShellSyntaxIn(raw);
297
- if (dynamicSyntax) return `Blocked: command contains ${dynamicSyntax} that cannot be safely classified without executing shell expansion.`;
308
+ if (dynamicSyntax) return block("shell-safety", "dynamic_shell_syntax", `Blocked: command contains ${dynamicSyntax} that cannot be safely classified without executing shell expansion.`);
298
309
  const command = normalize(raw);
299
310
  const ttyCheck = checkInteractiveTtyCommand(command);
300
311
  if (ttyCheck.isInteractive) {
301
- logBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
302
- return `Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ${ttyCheck.advice}`;
312
+ logPolicyBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
313
+ return block("shell-safety", "interactive_tty", `Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ${ttyCheck.advice}`);
303
314
  }
304
315
  const packageCheck = checkPackageHygiene(command);
305
316
  if (packageCheck.isViolating && !allowLive) {
306
- logBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
307
- return `Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ${packageCheck.advice}`;
317
+ logPolicyBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
318
+ return block("shell-safety", "package_hygiene", `Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ${packageCheck.advice}`);
308
319
  }
309
320
  const normalizedCommand = normalizeGitCommands(command);
310
- if (hasUnsafeGitAlias(command)) return "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.";
321
+ if (hasUnsafeGitAlias(command)) return block("git", "unsafe_git_alias", "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.");
311
322
  const gitInvocations = splitShellSegments(command).map((segment) => parseGitInvocation(segment.trim())).filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
312
323
  const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
313
324
  for (const invocation of gitInvocations) {
314
325
  const normalizedInvocation = `git ${invocation.rest}`;
315
326
  if (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))) {
316
- logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
317
- return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
327
+ logPolicyBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
328
+ return block("boundary", "workspace_escape", `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`);
318
329
  }
319
330
  }
320
331
  for (const invocation of gitInvocations) {
321
332
  if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
322
- logBlock(`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`);
323
- return branchGuardReason();
333
+ logPolicyBlock(`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`);
334
+ return block("git", "protected_branch", branchGuardReason());
324
335
  }
325
336
  }
326
337
  if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
327
338
  const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
328
339
  if (behindCheck.isBehind) {
329
- logBlock(`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`);
330
- return `Blocked: ${behindCheck.reason}`;
340
+ logPolicyBlock(`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`);
341
+ return block("git", "branch_base_behind", `Blocked: ${behindCheck.reason}`);
331
342
  }
332
343
  }
333
344
  if (isSettingsTamper(command)) {
334
- logBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
335
- return PROTECTED_PATH_REASON;
345
+ logPolicyBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
346
+ return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
336
347
  }
337
348
  for (const segment of command.split(/[\n|;&]+/)) {
338
349
  const secretFile = secretFileReadIn(segment.trim());
339
350
  if (secretFile) {
340
- logBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
341
- 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.`;
351
+ logPolicyBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
352
+ return block("secrets", "secret_read", `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.`);
342
353
  }
343
354
  }
344
355
  for (const payload of extractInterpreterPayload(raw)) {
345
356
  const outsidePath = outsideWritePathInPayload(payload, currentRoot);
346
357
  if (outsidePath) {
347
- logBlock(`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`);
348
- return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
358
+ logPolicyBlock(`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`);
359
+ return block("boundary", "workspace_escape", `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`);
349
360
  }
350
361
  const writePaths = writePathsInPayload(payload);
351
362
  if (writePaths.length > 0) {
352
- if (context?.agent && isReadOnlyRole(context.agent)) return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations.`;
353
- for (const path of writePaths) if (isProtectedPath(path)) return PROTECTED_PATH_REASON;
354
- if (onProtectedBranch(currentRoot)) return branchGuardReason();
363
+ if (context?.agent && isReadOnlyRole(context.agent)) return block("subagent-role", "read_only_role", `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations.`);
364
+ for (const path of writePaths) if (isProtectedPath(path)) return block("tamper", "protected_path", PROTECTED_PATH_REASON);
365
+ if (onProtectedBranch(currentRoot)) return block("git", "protected_branch", branchGuardReason());
355
366
  const todos = await effectiveTodos(context?.sessionID);
356
- if (todos !== undefined && !hasActiveTodo(todos)) return "Blocked: inline interpreter file mutation with no active todo item.";
357
- recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
367
+ if (todos !== undefined && !hasActiveTodo(todos)) return block("todo", "no_active_todo", "Blocked: inline interpreter file mutation with no active todo item.");
368
+ if (!context?.simulate) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
358
369
  }
359
370
  if (!allowLive) {
360
371
  const liveCheck = liveMutationIn(normalizeGitCommands(normalize(payload)));
361
372
  if (liveCheck) {
362
- logBlock(`[workflow-guard] blocked interpreter payload containing ${liveCheck}`);
363
- return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`;
373
+ logPolicyBlock(`[workflow-guard] blocked interpreter payload containing ${liveCheck}`);
374
+ return block("destructive", "live_mutation_payload", `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`);
364
375
  }
365
376
  }
366
- if (isSettingsTamper(payload)) return PROTECTED_PATH_REASON;
377
+ if (isSettingsTamper(payload)) return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
367
378
  const secretPath = secretPathInPayload(payload);
368
- if (secretPath) return `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`;
379
+ if (secretPath) return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`);
369
380
  }
370
- const shellMutationReason = await guardShellMutation(command, context?.sessionID);
381
+ const shellMutationReason = await guardShellMutation(command, context?.sessionID, !context?.simulate);
371
382
  if (shellMutationReason) {
372
- logBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
373
- return shellMutationReason;
383
+ logPolicyBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
384
+ return block("boundary", "shell_mutation", shellMutationReason);
374
385
  }
375
386
  if (!allowLive) {
376
387
  const what = liveMutationIn(normalizedCommand) ?? liveMutationIn(command);
377
388
  if (what) {
378
- logBlock(`[workflow-guard] blocked ${what}: ${command.slice(0, 120)}`);
379
- 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.`;
389
+ logPolicyBlock(`[workflow-guard] blocked ${what}: ${command.slice(0, 120)}`);
390
+ return block("destructive", "live_mutation", `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.`);
380
391
  }
381
392
  }
382
393
  if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
383
- logBlock(`[workflow-guard] blocked push to main/master: ${command}`);
384
- return "Blocked: direct pushes to main/master are not allowed. Create a feature branch and open a PR instead.";
394
+ logPolicyBlock(`[workflow-guard] blocked push to main/master: ${command}`);
395
+ return block("git", "protected_branch_push", "Blocked: direct pushes to main/master are not allowed. Create a feature branch and open a PR instead.");
385
396
  }
386
397
  for (const invocation of gitInvocations) {
387
398
  const pushText = `git ${invocation.rest}`;
388
399
  if (!/\bgit\s+push\b/.test(pushText)) continue;
389
400
  const pushedBranch = pushedProtectedBranchIn(pushText, invocation.repoDir);
390
401
  if (pushedBranch) {
391
- logBlock(`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`);
392
- return `Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. Create a feature branch and open a PR instead.`;
402
+ logPolicyBlock(`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`);
403
+ return block("git", "protected_branch_push", `Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. Create a feature branch and open a PR instead.`);
393
404
  }
394
405
  }
395
406
  for (const invocation of gitInvocations) {
396
407
  if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
397
408
  if (onProtectedBranch(invocation.repoDir)) {
398
- logBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
399
- return branchGuardReason();
409
+ logPolicyBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
410
+ return block("git", "protected_branch", branchGuardReason());
400
411
  }
401
412
  const branch = currentGitBranch(invocation.repoDir);
402
413
  if (branch) {
403
- const mergedStatus = isBranchAlreadyMergedOrClosed(invocation.repoDir, branch);
414
+ if (context?.simulate) remotePrStateUnchecked = true;
415
+ const mergedStatus = isBranchAlreadyMergedOrClosed(invocation.repoDir, branch, !context?.simulate);
404
416
  if (mergedStatus.merged) {
405
- logBlock(`[workflow-guard] blocked push to merged/closed branch: ${branch}`);
406
- return `Blocked: ${mergedStatus.reason}`;
417
+ logPolicyBlock(`[workflow-guard] blocked push to merged/closed branch: ${branch}`);
418
+ return block("git", "merged_branch", `Blocked: ${mergedStatus.reason}`);
407
419
  }
408
420
  }
409
421
  }
@@ -418,12 +430,13 @@ export async function guardToolCallImpl(
418
430
  if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
419
431
  const branch = currentGitBranch(prRoot);
420
432
  if (branch) {
421
- const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch);
433
+ if (context?.simulate) remotePrStateUnchecked = true;
434
+ const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch, !context?.simulate);
422
435
  if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
423
436
  }
424
437
  if (isReviewRequired(prRoot)) {
425
438
  const review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
426
- const reviewMatchesContext = review?.passed === true && review.workspace === resolve(prRoot) && (!review.targetSessionID || review.targetSessionID === context?.sessionID) && review.commitHash === getCurrentGitCommitHash(prRoot) && review.gitStatus === getGitStatusSummary(prRoot) && review.worktreeFingerprint === getGitWorktreeFingerprint(prRoot);
439
+ const reviewMatchesContext = review?.passed === true && isEvidenceFresh(reviewEvidence(review), { workspace: projectRootKey(prRoot), commitHash: getCurrentGitCommitHash(prRoot), worktreeFingerprint: getGitWorktreeFingerprint(prRoot), sessionID: review.targetSessionID }, 0) && (!review.targetSessionID || review.targetSessionID === context?.sessionID);
427
440
  if (!reviewMatchesContext) preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
428
441
  }
429
442
  if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
@@ -444,8 +457,8 @@ export async function guardToolCallImpl(
444
457
  if (lockCheck.isOutOfSync) preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
445
458
  if (preflightFailures.length > 0) {
446
459
  const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
447
- logBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
448
- return reason;
460
+ logPolicyBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
461
+ return block("pr-preflight", "pr_preflight_failed", reason,);
449
462
  }
450
463
  }
451
464
  const hasGitMutation = gitInvocations.some((invocation) => {
@@ -455,12 +468,12 @@ export async function guardToolCallImpl(
455
468
  if (context?.agent && isReadOnlyRole(context.agent)) {
456
469
  const mutation = detectShellMutation(command);
457
470
  if (mutation) {
458
- logBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
459
- return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`;
471
+ logPolicyBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
472
+ return block("subagent-role", "read_only_role", `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`);
460
473
  }
461
474
  if (hasGitMutation) {
462
- logBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
463
- return `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`;
475
+ logPolicyBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
476
+ return block("subagent-role", "read_only_role", `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`);
464
477
  }
465
478
  }
466
479
  const shellMut = detectShellMutation(command);
@@ -468,12 +481,13 @@ export async function guardToolCallImpl(
468
481
  if (context?.sessionID) {
469
482
  const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
470
483
  if (reason) {
471
- logBlock(`[workflow-guard] ${reason}`);
472
- return reason;
484
+ logPolicyBlock(`[workflow-guard] ${reason}`);
485
+ return block("todo", "mutation_budget", reason);
473
486
  }
474
487
  }
475
488
  }
476
- if (hasGitMutation) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
489
+ if (hasGitMutation && !context?.simulate) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
477
490
  }
478
- return undefined;
491
+ if (remotePrStateUnchecked) return needsApproval("git", "remote_state_unchecked", "Remote GitHub/Azure PR state was not queried during simulation; no deterministic local guardrail blocked this call, but final allow/block status requires real enforcement.");
492
+ return allow();
479
493
  }
@@ -1,8 +1,16 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
3
  import type { ProjectConfig } from "./types.ts";
4
4
 
5
- let cachedProjectConfig: ProjectConfig | undefined;
5
+ const projectConfigCache = new Map<string, ProjectConfig>();
6
+
7
+ export function projectRootKey(root: string): string {
8
+ try {
9
+ return realpathSync(root);
10
+ } catch {
11
+ return resolve(root);
12
+ }
13
+ }
6
14
 
7
15
  export function projectConfigCandidates(root: string): string[] {
8
16
  return [
@@ -60,9 +68,9 @@ export function loadProjectConfig(root: string): ProjectConfig {
60
68
  }
61
69
 
62
70
  export function reloadProjectConfig(root: string): void {
63
- cachedProjectConfig = loadProjectConfig(root);
71
+ projectConfigCache.set(projectRootKey(root), loadProjectConfig(root));
64
72
  }
65
73
 
66
- export function getCachedProjectConfig(): ProjectConfig | undefined {
67
- return cachedProjectConfig;
74
+ export function getCachedProjectConfig(root: string): ProjectConfig | undefined {
75
+ return projectConfigCache.get(projectRootKey(root));
68
76
  }