opencode-workflow-guard 1.8.7 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/accountability-evolution-plan.md +106 -0
- package/docs/assets/workflow-guard-tui-badge.png +0 -0
- package/docs/installation.md +163 -0
- package/docs/managed-deployment.md +15 -0
- package/docs/policies.md +211 -0
- package/docs/testing.md +56 -0
- package/docs/troubleshooting.md +84 -0
- package/package.json +4 -1
- package/src/lib/audit.ts +6 -4
- package/src/lib/custom-tools.ts +32 -11
- package/src/lib/evidence.ts +54 -0
- package/src/lib/guard-dispatcher.ts +123 -109
- package/src/lib/project-config.ts +14 -6
- package/src/lib/state.ts +56 -5
- package/src/lib/types.ts +35 -0
- package/src/policies/boundary.ts +2 -1
- package/src/policies/completion.ts +10 -2
- package/src/policies/continuation.ts +45 -11
- package/src/policies/file-claims.ts +12 -5
- package/src/policies/git.ts +2 -0
- package/src/workflow-guard-ui.ts +3 -1
- package/src/workflow-guard.ts +26 -7
- package/test/run-test.mjs +13 -0
- package/test/test-e2e.mts +288 -0
- package/test/test.mts +3479 -0
- package/tsconfig.json +16 -0
|
@@ -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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
171
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
return reason;
|
|
264
|
+
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
265
|
+
return block("todo", "mutation_budget", reason);
|
|
258
266
|
}
|
|
259
267
|
}
|
|
260
|
-
if (context?.sessionID
|
|
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
|
-
|
|
266
|
-
return staleReason;
|
|
273
|
+
logPolicyBlock(`[workflow-guard] ${staleReason}`);
|
|
274
|
+
return block("stale-write", "stale_write", staleReason);
|
|
267
275
|
}
|
|
268
276
|
}
|
|
269
277
|
}
|
|
270
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
+
if (context?.simulate) remotePrStateUnchecked = true;
|
|
415
|
+
const mergedStatus = isBranchAlreadyMergedOrClosed(invocation.repoDir, branch, !context?.simulate);
|
|
404
416
|
if (mergedStatus.merged) {
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
71
|
+
projectConfigCache.set(projectRootKey(root), loadProjectConfig(root));
|
|
64
72
|
}
|
|
65
73
|
|
|
66
|
-
export function getCachedProjectConfig(): ProjectConfig | undefined {
|
|
67
|
-
return
|
|
74
|
+
export function getCachedProjectConfig(root: string): ProjectConfig | undefined {
|
|
75
|
+
return projectConfigCache.get(projectRootKey(root));
|
|
68
76
|
}
|