opencode-workflow-guard 1.5.1 → 1.7.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.
- package/README.md +15 -6
- package/bin/opencode-workflow-guard.mjs +59 -0
- package/package.json +7 -2
- package/src/lib/audit.ts +82 -7
- package/src/lib/checkpoint.ts +308 -0
- package/src/lib/custom-tools.ts +198 -0
- package/src/lib/guard-dispatcher.ts +478 -0
- package/src/lib/project-config.ts +68 -0
- package/src/lib/project-memory.ts +46 -3
- package/src/lib/shell.ts +399 -0
- package/src/lib/state.ts +23 -102
- package/src/lib/tool-lifecycle.ts +63 -0
- package/src/lib/tool-outcomes.ts +64 -0
- package/src/lib/types.ts +36 -0
- package/src/lib/utils.ts +13 -380
- package/src/lib/verify.ts +69 -8
- package/src/policies/boundary.ts +3 -3
- package/src/policies/changelog.ts +2 -2
- package/src/policies/continuation.ts +5 -0
- package/src/policies/destructive.ts +2 -1
- package/src/policies/file-claims.ts +56 -0
- package/src/policies/git.ts +1 -1
- package/src/policies/post-edit-validation.ts +76 -0
- package/src/policies/secrets.ts +1 -1
- package/src/policies/stale-write.ts +70 -0
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +25 -15
- package/src/workflow-guard-ui.ts +75 -0
- package/src/workflow-guard.ts +171 -1118
package/src/workflow-guard.ts
CHANGED
|
@@ -8,12 +8,19 @@
|
|
|
8
8
|
* from ./lib/, and re-exports the public helper surface for tests.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import type { LearningEvidenceKind } from "./lib/types.ts";
|
|
13
|
-
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { type Plugin, type PluginModule } from "@opencode-ai/plugin";
|
|
14
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
13
|
import { resolve } from "node:path";
|
|
16
14
|
import { join } from "node:path";
|
|
15
|
+
import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
|
|
16
|
+
import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
|
|
17
|
+
import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
18
|
+
import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
19
|
+
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
20
|
+
import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
21
|
+
import { createCustomTools } from "./lib/custom-tools.ts";
|
|
22
|
+
export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
23
|
+
export { extractReviewFollowups } from "./lib/custom-tools.ts";
|
|
17
24
|
|
|
18
25
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
19
26
|
export type {
|
|
@@ -40,6 +47,7 @@ import {
|
|
|
40
47
|
lastMutationTimestamp,
|
|
41
48
|
getLastMutationTimestamp,
|
|
42
49
|
getMutationCount,
|
|
50
|
+
getSessionMutationCount,
|
|
43
51
|
lastVerify,
|
|
44
52
|
getLastVerifyResult,
|
|
45
53
|
recordMutation,
|
|
@@ -47,19 +55,19 @@ import {
|
|
|
47
55
|
resetVerifyState,
|
|
48
56
|
sessionMutationTimestamps,
|
|
49
57
|
sessionVerifyResults,
|
|
50
|
-
|
|
51
|
-
reloadProjectConfig,
|
|
52
|
-
stripJsonComments,
|
|
58
|
+
getProjectConfig,
|
|
53
59
|
isReviewRequired,
|
|
54
60
|
isDocumentationRequired,
|
|
55
61
|
getSubagentMutationBudget,
|
|
56
62
|
isLearningEnabled,
|
|
63
|
+
isProjectMemoryEnabled,
|
|
57
64
|
getLearningInterventionBudget,
|
|
58
65
|
recordReviewResult,
|
|
59
66
|
getLastReviewResult,
|
|
60
67
|
resetReviewState,
|
|
61
68
|
sessionReviews,
|
|
62
69
|
} from "./lib/state.ts";
|
|
70
|
+
import { loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
|
|
63
71
|
|
|
64
72
|
export {
|
|
65
73
|
setWorkspaceRoot,
|
|
@@ -86,27 +94,16 @@ export {
|
|
|
86
94
|
|
|
87
95
|
// ── Shared shell/env utilities ───────────────────────────────────────────────
|
|
88
96
|
import {
|
|
89
|
-
|
|
90
|
-
extractCommands,
|
|
91
|
-
normalize,
|
|
92
|
-
shellWrappersChangeCwd,
|
|
93
|
-
splitShellSegments,
|
|
94
|
-
unwrapShellCommand,
|
|
97
|
+
extractRecordTargetPath,
|
|
95
98
|
getCleanEnv,
|
|
96
99
|
showBlockToast,
|
|
97
|
-
|
|
98
|
-
SENSITIVE_ENV_RE,
|
|
100
|
+
isSensitiveEnvKey,
|
|
99
101
|
} from "./lib/utils.ts";
|
|
102
|
+
import { dynamicShellSyntaxIn } from "./lib/shell.ts";
|
|
100
103
|
|
|
101
|
-
export { getCleanEnv };
|
|
104
|
+
export { getCleanEnv, dynamicShellSyntaxIn };
|
|
102
105
|
|
|
103
106
|
// ── Adaptive learning engine ─────────────────────────────────────────────────
|
|
104
|
-
import {
|
|
105
|
-
loadLearnerProfile,
|
|
106
|
-
recordLearningEvidence,
|
|
107
|
-
selectLearningOpportunity,
|
|
108
|
-
updateLearnerProfile,
|
|
109
|
-
} from "./lib/learning.ts";
|
|
110
107
|
export {
|
|
111
108
|
createLearnerProfile,
|
|
112
109
|
getLearnerProfilePath,
|
|
@@ -127,20 +124,13 @@ export {
|
|
|
127
124
|
recordProjectMemory,
|
|
128
125
|
searchProjectMemory,
|
|
129
126
|
getRecentProjectMemory,
|
|
127
|
+
recordReviewFollowup,
|
|
128
|
+
listReviewFollowups,
|
|
129
|
+
resolveReviewFollowup,
|
|
130
130
|
exportProjectKnowledge,
|
|
131
131
|
importProjectKnowledge,
|
|
132
132
|
} from "./lib/project-memory.ts";
|
|
133
|
-
import {
|
|
134
|
-
exportProjectKnowledge,
|
|
135
|
-
ensureProjectMemoryExcluded,
|
|
136
|
-
getProjectMemoryIdentity,
|
|
137
|
-
getRecentProjectMemory,
|
|
138
|
-
importProjectKnowledge,
|
|
139
|
-
isProjectMemoryFresh,
|
|
140
|
-
openProjectMemory,
|
|
141
|
-
recordProjectMemory,
|
|
142
|
-
searchProjectMemory,
|
|
143
|
-
} from "./lib/project-memory.ts";
|
|
133
|
+
import { ensureProjectMemoryExcluded, getProjectMemoryIdentity, getRecentProjectMemory, importProjectKnowledge, isProjectMemoryFresh, listReviewFollowups, openProjectMemory } from "./lib/project-memory.ts";
|
|
144
134
|
|
|
145
135
|
// ── Audit & durable verification cache ───────────────────────────────────────
|
|
146
136
|
import {
|
|
@@ -148,13 +138,25 @@ import {
|
|
|
148
138
|
getVerifyCacheFilePath,
|
|
149
139
|
persistVerifyCache,
|
|
150
140
|
loadVerifyCache,
|
|
151
|
-
getRecentAuditEntries,
|
|
152
141
|
audit,
|
|
153
142
|
logDecision,
|
|
154
143
|
summarizeInput,
|
|
155
144
|
} from "./lib/audit.ts";
|
|
156
145
|
|
|
157
|
-
export { getAuditFilePath, getVerifyCacheFilePath, persistVerifyCache, loadVerifyCache, getRecentAuditEntries };
|
|
146
|
+
export { getAuditFilePath, getVerifyCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
|
|
147
|
+
export { summarizeInput };
|
|
148
|
+
|
|
149
|
+
export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
|
|
150
|
+
const directory = platform === "darwin"
|
|
151
|
+
? "/Library/Application Support/opencode"
|
|
152
|
+
: platform === "win32"
|
|
153
|
+
? env.ProgramData ? join(env.ProgramData, "opencode") : undefined
|
|
154
|
+
: platform === "linux" ? "/etc/opencode" : undefined;
|
|
155
|
+
const detected = directory !== undefined && ["opencode.json", "opencode.jsonc"].some((name) => existsSync(join(directory, name)));
|
|
156
|
+
return directory
|
|
157
|
+
? `managed config ${detected ? "detected" : "not detected"} at ${directory}; plugin provenance is not verified by the OpenCode V1 API`
|
|
158
|
+
: "managed config location is unknown on this platform; plugin provenance is not verified by the OpenCode V1 API";
|
|
159
|
+
}
|
|
158
160
|
|
|
159
161
|
// ── Verification engine ──────────────────────────────────────────────────────
|
|
160
162
|
import {
|
|
@@ -202,35 +204,33 @@ import {
|
|
|
202
204
|
validateTodoLifecycle,
|
|
203
205
|
fetchSessionTodos,
|
|
204
206
|
fetchParentSessionID,
|
|
207
|
+
fetchParentSession,
|
|
208
|
+
subagentMutationBudgetReason,
|
|
205
209
|
effectiveTodos,
|
|
206
|
-
effectiveTodoOwnerSessionID,
|
|
207
|
-
hasActiveTodo,
|
|
208
210
|
} from "./policies/todo.ts";
|
|
209
211
|
import {
|
|
210
212
|
clearContinuationState,
|
|
211
213
|
continueUnfinishedSession,
|
|
214
|
+
isGeneratedContinuationMessage,
|
|
212
215
|
recordUserMessage,
|
|
213
216
|
} from "./policies/continuation.ts";
|
|
217
|
+
import {
|
|
218
|
+
createRecoveryCheckpoint,
|
|
219
|
+
finalizeRecoveryCheckpoint,
|
|
220
|
+
nextRecoveryRun,
|
|
221
|
+
} from "./lib/checkpoint.ts";
|
|
214
222
|
import { discoverPlanningSources } from "./policies/planning.ts";
|
|
215
223
|
export { discoverPlanningSources };
|
|
216
224
|
|
|
217
225
|
export { validateTodoLifecycle };
|
|
218
226
|
|
|
219
227
|
import {
|
|
220
|
-
PUSH_TO_MAIN_RE,
|
|
221
|
-
GIT_WRITE_RE,
|
|
222
|
-
GIT_BRANCH_CREATE_RE,
|
|
223
228
|
currentGitBranch,
|
|
224
229
|
onProtectedBranch,
|
|
225
230
|
isProtectedBranchName,
|
|
226
|
-
pushedProtectedBranchIn,
|
|
227
|
-
branchGuardReason,
|
|
228
|
-
parseGitInvocation,
|
|
229
|
-
normalizeGitCommands,
|
|
230
231
|
isBranchAlreadyMergedOrClosed,
|
|
231
232
|
checkMergeConflicts,
|
|
232
233
|
checkBranchBaseIsUpToDate,
|
|
233
|
-
hasUnsafeGitAlias,
|
|
234
234
|
} from "./policies/git.ts";
|
|
235
235
|
|
|
236
236
|
export {
|
|
@@ -241,34 +241,18 @@ export {
|
|
|
241
241
|
};
|
|
242
242
|
|
|
243
243
|
import {
|
|
244
|
-
hasPrCreateInvocation,
|
|
245
|
-
branchHasChangelogChange,
|
|
246
|
-
prBodyIncludesChangelog,
|
|
247
244
|
checkLockfileSync,
|
|
248
245
|
} from "./policies/changelog.ts";
|
|
249
246
|
|
|
250
247
|
export { checkLockfileSync };
|
|
251
248
|
|
|
252
|
-
import { liveMutationIn, extractEditContent } from "./policies/destructive.ts";
|
|
253
|
-
|
|
254
|
-
import { mcpMutationTool } from "./policies/mcp.ts";
|
|
255
|
-
|
|
256
249
|
import {
|
|
257
|
-
PROTECTED_PATH_REASON,
|
|
258
250
|
isProtectedPath,
|
|
259
|
-
isSettingsTamper,
|
|
260
251
|
} from "./policies/tamper.ts";
|
|
261
252
|
|
|
262
253
|
export { isProtectedPath };
|
|
263
254
|
|
|
264
|
-
import {
|
|
265
|
-
isPathOutsideWorkspace,
|
|
266
|
-
extractPatchPaths,
|
|
267
|
-
guardShellMutation,
|
|
268
|
-
detectShellMutation,
|
|
269
|
-
} from "./policies/boundary.ts";
|
|
270
|
-
|
|
271
|
-
import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMaskedEnvSchema } from "./policies/secrets.ts";
|
|
255
|
+
import { isSecretPath, secretIn, isEnvFilePath, generateMaskedEnvSchema } from "./policies/secrets.ts";
|
|
272
256
|
|
|
273
257
|
export { isSecretPath, isEnvFilePath, generateMaskedEnvSchema };
|
|
274
258
|
|
|
@@ -276,7 +260,6 @@ import {
|
|
|
276
260
|
extractInterpreterPayload,
|
|
277
261
|
secretPathInPayload,
|
|
278
262
|
outsideWritePathInPayload,
|
|
279
|
-
writePathsInPayload,
|
|
280
263
|
} from "./policies/interpreter.ts";
|
|
281
264
|
export { extractInterpreterPayload, secretPathInPayload, outsideWritePathInPayload };
|
|
282
265
|
|
|
@@ -296,20 +279,6 @@ export { checkCompletionClaims };
|
|
|
296
279
|
|
|
297
280
|
export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification, escapeAppleScriptString };
|
|
298
281
|
|
|
299
|
-
const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
|
|
300
|
-
|
|
301
|
-
function logBlock(message: string): void {
|
|
302
|
-
try {
|
|
303
|
-
void (getSdkClient() as any)?.app?.log?.({
|
|
304
|
-
body: {
|
|
305
|
-
service: "workflow-guard",
|
|
306
|
-
level: "warn",
|
|
307
|
-
message,
|
|
308
|
-
},
|
|
309
|
-
});
|
|
310
|
-
} catch {}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
282
|
function logObservation(client: unknown, message: string): void {
|
|
314
283
|
try {
|
|
315
284
|
void (client as any)?.app?.log?.({
|
|
@@ -318,656 +287,6 @@ function logObservation(client: unknown, message: string): void {
|
|
|
318
287
|
} catch {}
|
|
319
288
|
}
|
|
320
289
|
|
|
321
|
-
const READ_ONLY_ROLES = new Set([
|
|
322
|
-
"reviewer",
|
|
323
|
-
"planner",
|
|
324
|
-
"advisor",
|
|
325
|
-
"critic",
|
|
326
|
-
"explorer",
|
|
327
|
-
"scout",
|
|
328
|
-
"evaluator",
|
|
329
|
-
]);
|
|
330
|
-
|
|
331
|
-
export function isReadOnlyRole(agent?: string): boolean {
|
|
332
|
-
if (!agent) return false;
|
|
333
|
-
const lower = agent.toLowerCase().trim();
|
|
334
|
-
return READ_ONLY_ROLES.has(lower) || Array.from(READ_ONLY_ROLES).some((r) => lower.includes(r));
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async function guardToolCallImpl(
|
|
338
|
-
toolName: string,
|
|
339
|
-
input: unknown,
|
|
340
|
-
context?: { sessionID?: string; worktree?: string; directory?: string; agent?: string },
|
|
341
|
-
): Promise<string | undefined> {
|
|
342
|
-
const currentRoot = getWorkspaceRoot();
|
|
343
|
-
|
|
344
|
-
// ── Role confinement: subagents in read-only roles cannot mutate files ──
|
|
345
|
-
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
346
|
-
if (EDIT_TOOL_NAMES.has(toolName) || toolName.startsWith("guard_worktree_")) {
|
|
347
|
-
const reason = `Blocked: subagent with read-only role '${context.agent}' cannot perform file mutations or lifecycle changes.`;
|
|
348
|
-
logBlock(`[workflow-guard] ${reason}`);
|
|
349
|
-
return reason;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// ── Policy 17: secret file read block via read tool ──
|
|
354
|
-
if (toolName === "read" || toolName === "read_file") {
|
|
355
|
-
const record = asRecord(input);
|
|
356
|
-
const target =
|
|
357
|
-
typeof record?.filePath === "string"
|
|
358
|
-
? record.filePath
|
|
359
|
-
: typeof record?.path === "string"
|
|
360
|
-
? record.path
|
|
361
|
-
: typeof input === "string"
|
|
362
|
-
? input
|
|
363
|
-
: "";
|
|
364
|
-
if (target && isSecretPath(target)) {
|
|
365
|
-
logBlock(`[workflow-guard] blocked read: secret file ${target}`);
|
|
366
|
-
if (isEnvFilePath(target)) {
|
|
367
|
-
let schemaHint = "";
|
|
368
|
-
try {
|
|
369
|
-
const resolvedTarget = resolve(currentRoot, target);
|
|
370
|
-
if (existsSync(resolvedTarget)) {
|
|
371
|
-
const raw = readFileSync(resolvedTarget, "utf8");
|
|
372
|
-
const masked = generateMaskedEnvSchema(raw);
|
|
373
|
-
schemaHint = `\n\nSafe variable schema mask:\n\`\`\`\n${masked.slice(0, 800)}\n\`\`\``;
|
|
374
|
-
}
|
|
375
|
-
} catch {}
|
|
376
|
-
return (
|
|
377
|
-
`Blocked: reading sensitive credential file '${target}' directly is not permitted. ` +
|
|
378
|
-
`Use environment variables or reference safe templates.${schemaHint}`
|
|
379
|
-
);
|
|
380
|
-
}
|
|
381
|
-
return (
|
|
382
|
-
`Blocked: reading sensitive credential/secret file '${target}' is not permitted. ` +
|
|
383
|
-
"Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
return undefined;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// ── Policy 1: todowrite lifecycle validation & finalization gates ──
|
|
390
|
-
if (toolName === "todowrite") {
|
|
391
|
-
const record = asRecord(input);
|
|
392
|
-
const rawTodos = record?.todos;
|
|
393
|
-
if (Array.isArray(rawTodos)) {
|
|
394
|
-
const newTodos = rawTodos as any[];
|
|
395
|
-
const existingTodos = context?.sessionID
|
|
396
|
-
? await fetchSessionTodos(context.sessionID)
|
|
397
|
-
: undefined;
|
|
398
|
-
const err = validateTodoLifecycle(newTodos, existingTodos);
|
|
399
|
-
if (err) {
|
|
400
|
-
logBlock(`[workflow-guard] ${err}`);
|
|
401
|
-
return err;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const allDone =
|
|
405
|
-
newTodos.length > 0 &&
|
|
406
|
-
newTodos.every((t) => {
|
|
407
|
-
const s = String(t.status ?? "");
|
|
408
|
-
return s === "completed" || s === "cancelled";
|
|
409
|
-
});
|
|
410
|
-
if (allDone) {
|
|
411
|
-
// Policy 19: conflict-free mergeability gate before task completion/handoff
|
|
412
|
-
const conflictCheck = checkMergeConflicts(currentRoot);
|
|
413
|
-
if (conflictCheck.hasConflicts) {
|
|
414
|
-
const reason = `Blocked todowrite: ${conflictCheck.reason}`;
|
|
415
|
-
logBlock(`[workflow-guard] ${reason}`);
|
|
416
|
-
return reason;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Policy 10: evidence-based verification
|
|
420
|
-
const command = detectVerifyCommand(currentRoot);
|
|
421
|
-
if (command) {
|
|
422
|
-
const sessionID = context?.sessionID;
|
|
423
|
-
let verifyResult = sessionID
|
|
424
|
-
? sessionVerifyResults.get(sessionID)
|
|
425
|
-
: lastVerify;
|
|
426
|
-
|
|
427
|
-
// Recover durable evidence from disk when in-memory state is missing.
|
|
428
|
-
// Durable evidence is bound to the workspace that produced it: without
|
|
429
|
-
// this check a passing run from a different project (identical verify
|
|
430
|
-
// command, non-git state) could satisfy finalization here.
|
|
431
|
-
if (!verifyResult) {
|
|
432
|
-
const diskCached = loadVerifyCache();
|
|
433
|
-
if (
|
|
434
|
-
diskCached &&
|
|
435
|
-
diskCached.passed &&
|
|
436
|
-
diskCached.command === command &&
|
|
437
|
-
diskCached.workspaceRoot === resolve(currentRoot)
|
|
438
|
-
) {
|
|
439
|
-
verifyResult = diskCached;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const mutationTimestamp = sessionID
|
|
444
|
-
? (sessionMutationTimestamps.get(sessionID) ?? 0)
|
|
445
|
-
: lastMutationTimestamp;
|
|
446
|
-
|
|
447
|
-
const currentCommit = getCurrentGitCommitHash(currentRoot);
|
|
448
|
-
const currentGitStatus = getGitStatusSummary(currentRoot);
|
|
449
|
-
|
|
450
|
-
const gitStateMatches =
|
|
451
|
-
!verifyResult?.commitHash ||
|
|
452
|
-
(verifyResult.commitHash === currentCommit &&
|
|
453
|
-
verifyResult.gitStatus === currentGitStatus);
|
|
454
|
-
|
|
455
|
-
const isFresh =
|
|
456
|
-
verifyResult !== undefined &&
|
|
457
|
-
verifyResult.passed &&
|
|
458
|
-
verifyResult.command === command &&
|
|
459
|
-
verifyResult.timestamp >= mutationTimestamp &&
|
|
460
|
-
gitStateMatches;
|
|
461
|
-
|
|
462
|
-
if (!isFresh) {
|
|
463
|
-
const result = await runVerify(command, currentRoot);
|
|
464
|
-
recordVerifyResult(command, result, sessionID, currentRoot);
|
|
465
|
-
if (!result.passed) {
|
|
466
|
-
const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
|
|
467
|
-
const reason =
|
|
468
|
-
`Blocked todowrite: all tasks marked done but verification is failing ` +
|
|
469
|
-
`(${command}). Fix the failure before finishing; ` +
|
|
470
|
-
`output tail: ${tail}`;
|
|
471
|
-
logBlock(`[workflow-guard] ${reason}`);
|
|
472
|
-
return reason;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
return undefined;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// ── Policies 1, 6, 7, 8 & 9: edit/write/patch gates ──
|
|
482
|
-
if (EDIT_TOOL_NAMES.has(toolName)) {
|
|
483
|
-
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
484
|
-
const record = asRecord(input);
|
|
485
|
-
const target =
|
|
486
|
-
typeof record?.filePath === "string"
|
|
487
|
-
? record.filePath
|
|
488
|
-
: typeof record?.path === "string"
|
|
489
|
-
? record.path
|
|
490
|
-
: typeof input === "string"
|
|
491
|
-
? input
|
|
492
|
-
: "";
|
|
493
|
-
|
|
494
|
-
if (target && isProtectedPath(target)) {
|
|
495
|
-
logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
|
|
496
|
-
return PROTECTED_PATH_REASON;
|
|
497
|
-
}
|
|
498
|
-
if (target && isSecretPath(target)) {
|
|
499
|
-
logBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
|
|
500
|
-
return (
|
|
501
|
-
`Blocked: modifying secret file '${target}' directly is not permitted. ` +
|
|
502
|
-
"Store credentials in environment variables or safe secret stores instead."
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
if (toolName === "apply_patch") {
|
|
506
|
-
const patchText =
|
|
507
|
-
typeof record?.patchText === "string" ? record.patchText : "";
|
|
508
|
-
for (const patchPath of extractPatchPaths(patchText)) {
|
|
509
|
-
if (isProtectedPath(patchPath)) {
|
|
510
|
-
logBlock(
|
|
511
|
-
`[workflow-guard] blocked apply_patch: protected path ${patchPath}`,
|
|
512
|
-
);
|
|
513
|
-
return PROTECTED_PATH_REASON;
|
|
514
|
-
}
|
|
515
|
-
if (isSecretPath(patchPath)) {
|
|
516
|
-
logBlock(
|
|
517
|
-
`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`,
|
|
518
|
-
);
|
|
519
|
-
return `Blocked: modifying secret file '${patchPath}' directly is not permitted.`;
|
|
520
|
-
}
|
|
521
|
-
if (isPathOutsideWorkspace(patchPath, currentRoot)) {
|
|
522
|
-
logBlock(
|
|
523
|
-
`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`,
|
|
524
|
-
);
|
|
525
|
-
return `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
if (target && isPathOutsideWorkspace(target, currentRoot)) {
|
|
531
|
-
logBlock(
|
|
532
|
-
`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`,
|
|
533
|
-
);
|
|
534
|
-
return `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
for (const content of extractEditContent(input)) {
|
|
538
|
-
const secret = secretIn(content);
|
|
539
|
-
if (secret) {
|
|
540
|
-
logBlock(
|
|
541
|
-
`[workflow-guard] blocked ${toolName}: payload contains ${secret}`,
|
|
542
|
-
);
|
|
543
|
-
return (
|
|
544
|
-
`Blocked: payload appears to contain a ${secret}. ` +
|
|
545
|
-
"Secrets must not be committed to the repository. Store them " +
|
|
546
|
-
"in a secret manager or environment file excluded from git, " +
|
|
547
|
-
"and reference them by name instead."
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
if (!allowLive) {
|
|
553
|
-
for (const content of extractEditContent(input)) {
|
|
554
|
-
const lines = content.split("\n");
|
|
555
|
-
for (const line of lines) {
|
|
556
|
-
const trimmed = line.trim();
|
|
557
|
-
if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
const normalizedContent = normalizeGitCommands(line);
|
|
561
|
-
const what = liveMutationIn(normalizedContent);
|
|
562
|
-
if (what) {
|
|
563
|
-
logBlock(
|
|
564
|
-
`[workflow-guard] blocked ${toolName}: payload contains ${what}`,
|
|
565
|
-
);
|
|
566
|
-
return (
|
|
567
|
-
`Blocked: the file you are writing contains a ${what}. ` +
|
|
568
|
-
"Script files are not a way to smuggle destructive commands " +
|
|
569
|
-
"past the shell guard. Only the user can allow live mutations " +
|
|
570
|
-
"(WORKFLOW_GUARD_ALLOW_LIVE=1)."
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
for (const content of extractEditContent(input)) {
|
|
577
|
-
if (isSettingsTamper(content)) return PROTECTED_PATH_REASON;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
if (onProtectedBranch(currentRoot)) {
|
|
581
|
-
logBlock(
|
|
582
|
-
`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`,
|
|
583
|
-
);
|
|
584
|
-
return branchGuardReason();
|
|
585
|
-
}
|
|
586
|
-
const todos = await effectiveTodos(context?.sessionID);
|
|
587
|
-
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
588
|
-
logBlock(
|
|
589
|
-
`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`,
|
|
590
|
-
);
|
|
591
|
-
return (
|
|
592
|
-
"Blocked: no active todo item. First break the request down " +
|
|
593
|
-
"with the todowrite tool (create items with status 'pending' " +
|
|
594
|
-
"or 'in_progress'), then work them through, marking " +
|
|
595
|
-
"each completed via todowrite as you finish it. When every " +
|
|
596
|
-
"item is completed, create a fresh todo list before " +
|
|
597
|
-
"starting new work."
|
|
598
|
-
);
|
|
599
|
-
}
|
|
600
|
-
if (context?.sessionID) {
|
|
601
|
-
const parentID = await fetchParentSessionID(context.sessionID);
|
|
602
|
-
if (parentID) {
|
|
603
|
-
const count = getMutationCount(context.sessionID);
|
|
604
|
-
const budget = getSubagentMutationBudget(currentRoot);
|
|
605
|
-
if (count >= budget) {
|
|
606
|
-
const reason = `Blocked: subagent session '${context.sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
|
|
607
|
-
logBlock(`[workflow-guard] ${reason}`);
|
|
608
|
-
return reason;
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
613
|
-
return undefined;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
617
|
-
|
|
618
|
-
// ── Policy 5: MCP tools that mutate GitHub / Azure / DevOps ──
|
|
619
|
-
if (!allowLive) {
|
|
620
|
-
const mcpWhat = mcpMutationTool(toolName);
|
|
621
|
-
if (mcpWhat) {
|
|
622
|
-
logBlock(
|
|
623
|
-
`[workflow-guard] blocked MCP tool ${toolName} (${mcpWhat} mutation)`,
|
|
624
|
-
);
|
|
625
|
-
return (
|
|
626
|
-
`Blocked: ${toolName} mutates ${mcpWhat} - a live ` +
|
|
627
|
-
"system. Changes must be made in code unless the user " +
|
|
628
|
-
"explicitly allows live changes. Only the user can override " +
|
|
629
|
-
"this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment " +
|
|
630
|
-
"variable set before launching the agent."
|
|
631
|
-
);
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
if (!SHELL_TOOL_NAMES.has(toolName)) {
|
|
636
|
-
return undefined;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
const commands = extractCommands(input);
|
|
640
|
-
for (const raw of commands) {
|
|
641
|
-
const command = normalize(raw);
|
|
642
|
-
|
|
643
|
-
// ── Policy 22: non-interactive shell & TTY hang guard ──
|
|
644
|
-
const ttyCheck = checkInteractiveTtyCommand(command);
|
|
645
|
-
if (ttyCheck.isInteractive) {
|
|
646
|
-
logBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
|
|
647
|
-
return (
|
|
648
|
-
`Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ` +
|
|
649
|
-
`${ttyCheck.advice}`
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// ── Policy 23: package supply-chain & dependency hygiene guard ──
|
|
654
|
-
const packageCheck = checkPackageHygiene(command);
|
|
655
|
-
if (packageCheck.isViolating && !allowLive) {
|
|
656
|
-
logBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
|
|
657
|
-
return (
|
|
658
|
-
`Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ` +
|
|
659
|
-
`${packageCheck.advice}`
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const normalizedCommand = normalizeGitCommands(command);
|
|
664
|
-
if (hasUnsafeGitAlias(command)) {
|
|
665
|
-
return "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.";
|
|
666
|
-
}
|
|
667
|
-
const gitInvocations = splitShellSegments(command)
|
|
668
|
-
.map((segment) => parseGitInvocation(segment.trim()))
|
|
669
|
-
.filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
|
|
670
|
-
const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
|
|
671
|
-
|
|
672
|
-
// Workspace boundary check for external git repo targets
|
|
673
|
-
for (const invocation of gitInvocations) {
|
|
674
|
-
const normalizedInvocation = `git ${invocation.rest}`;
|
|
675
|
-
// The workspace boundary has no allow-live override (same invariant as
|
|
676
|
-
// file redirects/mv): an external repository write is out of bounds even
|
|
677
|
-
// with WORKFLOW_GUARD_ALLOW_LIVE=1.
|
|
678
|
-
if (
|
|
679
|
-
isPathOutsideWorkspace(invocation.repoDir, currentRoot) &&
|
|
680
|
-
(GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))
|
|
681
|
-
) {
|
|
682
|
-
logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
|
|
683
|
-
return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// ── Policy 7: changes only on feature branches ──
|
|
688
|
-
for (const invocation of gitInvocations) {
|
|
689
|
-
if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
|
|
690
|
-
logBlock(
|
|
691
|
-
`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`,
|
|
692
|
-
);
|
|
693
|
-
return branchGuardReason();
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// Policy 20: base freshness when creating a fresh branch
|
|
698
|
-
if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
|
|
699
|
-
const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
|
|
700
|
-
if (behindCheck.isBehind) {
|
|
701
|
-
logBlock(
|
|
702
|
-
`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`,
|
|
703
|
-
);
|
|
704
|
-
return `Blocked: ${behindCheck.reason}`;
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// ── Policy 6: block self-modification of approval gates ──
|
|
709
|
-
if (isSettingsTamper(command)) {
|
|
710
|
-
logBlock(
|
|
711
|
-
`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`,
|
|
712
|
-
);
|
|
713
|
-
return PROTECTED_PATH_REASON;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// ── Policy 17: secret file reads via shell ──
|
|
717
|
-
for (const segment of command.split(/[\n|;&]+/)) {
|
|
718
|
-
const secretFile = secretFileReadIn(segment.trim());
|
|
719
|
-
if (secretFile) {
|
|
720
|
-
logBlock(
|
|
721
|
-
`[workflow-guard] blocked shell read of secret file: ${secretFile}`,
|
|
722
|
-
);
|
|
723
|
-
return (
|
|
724
|
-
`Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. ` +
|
|
725
|
-
"Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
|
|
726
|
-
);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// ── Policy 18: interpreter inline evasion scanner ──
|
|
731
|
-
for (const payload of extractInterpreterPayload(command)) {
|
|
732
|
-
// Workspace boundary check inside inline interpreter payloads has no
|
|
733
|
-
// allow-live override (same invariant as file redirects).
|
|
734
|
-
const outsidePath = outsideWritePathInPayload(payload, currentRoot);
|
|
735
|
-
if (outsidePath) {
|
|
736
|
-
logBlock(
|
|
737
|
-
`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`,
|
|
738
|
-
);
|
|
739
|
-
return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
740
|
-
}
|
|
741
|
-
const writePaths = writePathsInPayload(payload);
|
|
742
|
-
if (writePaths.length > 0) {
|
|
743
|
-
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
744
|
-
return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations.`;
|
|
745
|
-
}
|
|
746
|
-
for (const path of writePaths) {
|
|
747
|
-
if (isProtectedPath(path)) return PROTECTED_PATH_REASON;
|
|
748
|
-
}
|
|
749
|
-
if (onProtectedBranch(currentRoot)) return branchGuardReason();
|
|
750
|
-
const todos = await effectiveTodos(context?.sessionID);
|
|
751
|
-
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
752
|
-
return "Blocked: inline interpreter file mutation with no active todo item.";
|
|
753
|
-
}
|
|
754
|
-
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (!allowLive) {
|
|
758
|
-
const normPayload = normalizeGitCommands(normalize(payload));
|
|
759
|
-
const liveCheck = liveMutationIn(normPayload);
|
|
760
|
-
if (liveCheck) {
|
|
761
|
-
logBlock(
|
|
762
|
-
`[workflow-guard] blocked interpreter payload containing ${liveCheck}`,
|
|
763
|
-
);
|
|
764
|
-
return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`;
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
if (isSettingsTamper(payload)) return PROTECTED_PATH_REASON;
|
|
768
|
-
const secretPath = secretPathInPayload(payload);
|
|
769
|
-
if (secretPath) return `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// ── Policies 1, 7 & 8: shell file mutations get the same gates as edits ──
|
|
773
|
-
const shellMutationReason = await guardShellMutation(
|
|
774
|
-
command,
|
|
775
|
-
context?.sessionID,
|
|
776
|
-
);
|
|
777
|
-
if (shellMutationReason) {
|
|
778
|
-
logBlock(
|
|
779
|
-
`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`,
|
|
780
|
-
);
|
|
781
|
-
return shellMutationReason;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// ── Policy 4: live-system mutations (env var is the ONLY override) ──
|
|
785
|
-
if (!allowLive) {
|
|
786
|
-
const what = liveMutationIn(normalizedCommand) ?? liveMutationIn(command);
|
|
787
|
-
if (what) {
|
|
788
|
-
logBlock(
|
|
789
|
-
`[workflow-guard] blocked ${what}: ${command.slice(0, 120)}`,
|
|
790
|
-
);
|
|
791
|
-
return (
|
|
792
|
-
`Blocked: ${what} targets a live system. Changes must be ` +
|
|
793
|
-
"made in code (IaC, migrations, source) unless the user " +
|
|
794
|
-
"explicitly allows live changes. Only the user can override " +
|
|
795
|
-
"this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment " +
|
|
796
|
-
"variable set before launching the agent."
|
|
797
|
-
);
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// ── Policy 2: block git push to main/master ──
|
|
802
|
-
if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
|
|
803
|
-
logBlock(
|
|
804
|
-
`[workflow-guard] blocked push to main/master: ${command}`,
|
|
805
|
-
);
|
|
806
|
-
return (
|
|
807
|
-
"Blocked: direct pushes to main/master are not allowed. " +
|
|
808
|
-
"Create a feature branch and open a PR instead."
|
|
809
|
-
);
|
|
810
|
-
}
|
|
811
|
-
// Policy 2 (cont.): configured protected branches receive the same
|
|
812
|
-
// destination-side push protection as main/master.
|
|
813
|
-
for (const invocation of gitInvocations) {
|
|
814
|
-
const pushText = `git ${invocation.rest}`;
|
|
815
|
-
if (!/\bgit\s+push\b/.test(pushText)) continue;
|
|
816
|
-
const pushedBranch = pushedProtectedBranchIn(pushText, invocation.repoDir);
|
|
817
|
-
if (pushedBranch) {
|
|
818
|
-
logBlock(
|
|
819
|
-
`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`,
|
|
820
|
-
);
|
|
821
|
-
return (
|
|
822
|
-
`Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. ` +
|
|
823
|
-
"Create a feature branch and open a PR instead."
|
|
824
|
-
);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// ── Policy 20: block push from protected or already-merged branches ──
|
|
829
|
-
for (const invocation of gitInvocations) {
|
|
830
|
-
if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
|
|
831
|
-
if (onProtectedBranch(invocation.repoDir)) {
|
|
832
|
-
logBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
|
|
833
|
-
return branchGuardReason();
|
|
834
|
-
}
|
|
835
|
-
const branch = currentGitBranch(invocation.repoDir);
|
|
836
|
-
if (branch) {
|
|
837
|
-
const mergedStatus = isBranchAlreadyMergedOrClosed(
|
|
838
|
-
invocation.repoDir,
|
|
839
|
-
branch,
|
|
840
|
-
);
|
|
841
|
-
if (mergedStatus.merged) {
|
|
842
|
-
logBlock(
|
|
843
|
-
`[workflow-guard] blocked push to merged/closed branch: ${branch}`,
|
|
844
|
-
);
|
|
845
|
-
return `Blocked: ${mergedStatus.reason}`;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// ── Policy 3: PRs must include a changelog (GitHub & Azure DevOps) ──
|
|
851
|
-
if (hasPrCreateInvocation(raw)) {
|
|
852
|
-
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
853
|
-
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
854
|
-
const descFlag = isAz ? "--description" : "--body";
|
|
855
|
-
const preflightFailures: string[] = [];
|
|
856
|
-
|
|
857
|
-
// Policy 19: conflict-free mergeability gate before opening PR
|
|
858
|
-
const conflictCheck = checkMergeConflicts(currentRoot);
|
|
859
|
-
if (conflictCheck.hasConflicts) {
|
|
860
|
-
preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// Policy 20: branch already merged/closed
|
|
864
|
-
const branch = currentGitBranch(currentRoot);
|
|
865
|
-
if (branch) {
|
|
866
|
-
const mergedStatus = isBranchAlreadyMergedOrClosed(currentRoot, branch);
|
|
867
|
-
if (mergedStatus.merged) {
|
|
868
|
-
preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
if (isReviewRequired(currentRoot)) {
|
|
873
|
-
const review = context?.sessionID
|
|
874
|
-
? (sessionReviews.get(context.sessionID) ?? getLastReviewResult())
|
|
875
|
-
: getLastReviewResult();
|
|
876
|
-
const reviewMatchesContext =
|
|
877
|
-
review?.passed === true &&
|
|
878
|
-
review.workspace === resolve(currentRoot) &&
|
|
879
|
-
(!review.targetSessionID || review.targetSessionID === context?.sessionID) &&
|
|
880
|
-
review.commitHash === getCurrentGitCommitHash(currentRoot) &&
|
|
881
|
-
review.gitStatus === getGitStatusSummary(currentRoot) &&
|
|
882
|
-
review.worktreeFingerprint === getGitWorktreeFingerprint(currentRoot);
|
|
883
|
-
if (!reviewMatchesContext) {
|
|
884
|
-
preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// Policy 21: documentation review & update check
|
|
889
|
-
if (isDocumentationRequired(currentRoot)) {
|
|
890
|
-
const hasDocChange = branchHasDocumentationChange(currentRoot);
|
|
891
|
-
if (!hasDocChange) {
|
|
892
|
-
preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
const branchChangelog = branchHasChangelogChange(currentRoot);
|
|
897
|
-
let shellCwdKnown = true;
|
|
898
|
-
const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
|
|
899
|
-
for (const segment of splitShellSegments(raw)) {
|
|
900
|
-
const unwrapped = unwrapShellCommand(segment);
|
|
901
|
-
if (/(?:^|\s)(?:(?:builtin|command)\s+)?(?:cd|pushd|popd)(?:\s|$)/.test(unwrapped)) shellCwdKnown = false;
|
|
902
|
-
if (hasPrCreateInvocation(segment)) {
|
|
903
|
-
const wrapperChangesCwd = shellWrappersChangeCwd(segment);
|
|
904
|
-
prSegments.push({ segment, invocationRoot: shellCwdKnown && !wrapperChangesCwd ? currentRoot : null });
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
const hasChangelog =
|
|
908
|
-
branchChangelog ||
|
|
909
|
-
prSegments.every(({ segment, invocationRoot }) => prBodyIncludesChangelog(segment, invocationRoot));
|
|
910
|
-
if (!hasChangelog) {
|
|
911
|
-
preflightFailures.push(`Changelog is required; update a CHANGELOG file or include a 'Changelog:' section in the PR description (${descFlag}).`);
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// Lockfile synchronization pre-flight gate
|
|
915
|
-
const lockCheck = checkLockfileSync(currentRoot);
|
|
916
|
-
if (lockCheck.isOutOfSync) {
|
|
917
|
-
preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
if (preflightFailures.length > 0) {
|
|
921
|
-
const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
|
|
922
|
-
logBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
|
|
923
|
-
return reason;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
const hasGitMutation = gitInvocations.some((invocation) => {
|
|
928
|
-
const normalizedInvocation = `git ${invocation.rest}`;
|
|
929
|
-
return (
|
|
930
|
-
GIT_WRITE_RE.test(normalizedInvocation) ||
|
|
931
|
-
/\bgit\s+(?:switch|checkout)\b/.test(normalizedInvocation)
|
|
932
|
-
);
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
936
|
-
const mutation = detectShellMutation(command);
|
|
937
|
-
if (mutation) {
|
|
938
|
-
logBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
|
|
939
|
-
return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`;
|
|
940
|
-
}
|
|
941
|
-
if (hasGitMutation) {
|
|
942
|
-
logBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
|
|
943
|
-
return `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`;
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
const shellMut = detectShellMutation(command);
|
|
948
|
-
if (hasGitMutation || shellMut) {
|
|
949
|
-
if (context?.sessionID) {
|
|
950
|
-
const parentID = await fetchParentSessionID(context.sessionID);
|
|
951
|
-
if (parentID) {
|
|
952
|
-
const count = getMutationCount(context.sessionID);
|
|
953
|
-
const budget = getSubagentMutationBudget(currentRoot);
|
|
954
|
-
if (count >= budget) {
|
|
955
|
-
const reason = `Blocked: subagent session '${context.sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
|
|
956
|
-
logBlock(`[workflow-guard] ${reason}`);
|
|
957
|
-
return reason;
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
if (hasGitMutation) {
|
|
964
|
-
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
return undefined;
|
|
969
|
-
}
|
|
970
|
-
|
|
971
290
|
/**
|
|
972
291
|
* Public guard entry point. Audits every decision (block or allow) to a
|
|
973
292
|
* durable JSONL file before returning.
|
|
@@ -975,7 +294,7 @@ async function guardToolCallImpl(
|
|
|
975
294
|
export async function guardToolCall(
|
|
976
295
|
toolName: string,
|
|
977
296
|
input: unknown,
|
|
978
|
-
context?: { sessionID?: string; worktree?: string; directory?: string; agent?: string },
|
|
297
|
+
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
979
298
|
): Promise<string | undefined> {
|
|
980
299
|
const customRoot = context?.worktree || context?.directory;
|
|
981
300
|
const runImpl = () => guardToolCallImpl(toolName, input, context);
|
|
@@ -984,13 +303,7 @@ export async function guardToolCall(
|
|
|
984
303
|
: await runImpl();
|
|
985
304
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
986
305
|
const isMutation = EDIT_TOOL_NAMES.has(toolName);
|
|
987
|
-
const
|
|
988
|
-
const targetPath =
|
|
989
|
-
typeof record?.filePath === "string"
|
|
990
|
-
? record.filePath
|
|
991
|
-
: typeof record?.path === "string"
|
|
992
|
-
? record.path
|
|
993
|
-
: undefined;
|
|
306
|
+
const targetPath = extractRecordTargetPath(input);
|
|
994
307
|
logDecision(toolName, input, context, reason, {
|
|
995
308
|
mutation: isMutation,
|
|
996
309
|
targetPath,
|
|
@@ -1027,10 +340,16 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1027
340
|
setSdkClient(ctx.client);
|
|
1028
341
|
reloadProjectConfig(effectiveRoot);
|
|
1029
342
|
const learningEnabled = isLearningEnabled(effectiveRoot);
|
|
343
|
+
const projectMemoryEnabled = isProjectMemoryEnabled(effectiveRoot);
|
|
1030
344
|
const learningInterventions = new Map<string, number>();
|
|
1031
345
|
const portableMemoryPath = join(effectiveRoot, ".opencode", "memory", "project-memory.jsonl");
|
|
346
|
+
let followupStore: ReturnType<typeof openProjectMemory> | undefined;
|
|
1032
347
|
let projectMemory: ReturnType<typeof openProjectMemory> | undefined;
|
|
1033
348
|
try {
|
|
349
|
+
followupStore = openProjectMemory(getProjectMemoryIdentity(effectiveRoot));
|
|
350
|
+
} catch {}
|
|
351
|
+
try {
|
|
352
|
+
if (!projectMemoryEnabled) throw new Error("Project memory disabled");
|
|
1034
353
|
projectMemory = openProjectMemory(getProjectMemoryIdentity(effectiveRoot));
|
|
1035
354
|
ensureProjectMemoryExcluded(effectiveRoot);
|
|
1036
355
|
importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined);
|
|
@@ -1044,379 +363,16 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1044
363
|
body: {
|
|
1045
364
|
service: "workflow-guard",
|
|
1046
365
|
level: "info",
|
|
1047
|
-
message: `Workflow Guard plugin initialized for ${effectiveRoot}`,
|
|
366
|
+
message: `Workflow Guard plugin initialized for ${effectiveRoot}; ${managedConfigDiagnostic()}`,
|
|
1048
367
|
},
|
|
1049
368
|
});
|
|
1050
369
|
} catch {}
|
|
1051
370
|
|
|
371
|
+
const toolLifecycle = new ToolInvocationLifecycle();
|
|
372
|
+
const toolOutcomes = new ToolOutcomeTracker();
|
|
373
|
+
|
|
1052
374
|
return {
|
|
1053
|
-
tool: {
|
|
1054
|
-
guard_next_tasks: tool({
|
|
1055
|
-
description:
|
|
1056
|
-
"Load durable repository task context when deciding what to work on next. Prefers TODO.md; if absent, discovers conventional roadmap, plan, tasks, backlog, and docs/plans Markdown files.",
|
|
1057
|
-
args: {},
|
|
1058
|
-
execute: async () => {
|
|
1059
|
-
const sources = discoverPlanningSources(effectiveRoot);
|
|
1060
|
-
return sources.length > 0
|
|
1061
|
-
? JSON.stringify({ sources }, null, 2)
|
|
1062
|
-
: JSON.stringify({ sources: [], message: "No TODO, roadmap, plan, tasks, or backlog Markdown files found." });
|
|
1063
|
-
},
|
|
1064
|
-
}),
|
|
1065
|
-
project_memory_search: tool({
|
|
1066
|
-
description: "Search current durable knowledge for this project. Returns concise typed records with provenance; superseded records are excluded.",
|
|
1067
|
-
args: { query: tool.schema.string() },
|
|
1068
|
-
execute: async (args) => projectMemory ? JSON.stringify(args.query.length <= 500 ? searchProjectMemory(projectMemory, args.query, 8) : [], null, 2) : "[workflow-guard] Project memory unavailable; core guard enforcement remains active.",
|
|
1069
|
-
}),
|
|
1070
|
-
project_memory_record: tool({
|
|
1071
|
-
description: "Record a durable project fact, decision, constraint, or lesson when it will matter in future sessions. Do not record transient tool output, secrets, or speculative hypotheses.",
|
|
1072
|
-
args: {
|
|
1073
|
-
kind: tool.schema.string().describe("fact, decision, constraint, or lesson"),
|
|
1074
|
-
content: tool.schema.string(),
|
|
1075
|
-
paths: tool.schema.array(tool.schema.string()),
|
|
1076
|
-
supersedes: tool.schema.string().optional(),
|
|
1077
|
-
},
|
|
1078
|
-
execute: async (args, toolContext) => {
|
|
1079
|
-
if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
|
|
1080
|
-
if (!(["fact", "decision", "constraint", "lesson"] as string[]).includes(args.kind)) return "[workflow-guard] Project memory rejected: invalid kind.";
|
|
1081
|
-
const detectedSecret = secretIn(args.content);
|
|
1082
|
-
if (detectedSecret) return `[workflow-guard] Project memory rejected: possible secret detected (${detectedSecret}).`;
|
|
1083
|
-
const record = recordProjectMemory(projectMemory, {
|
|
1084
|
-
kind: args.kind as "fact" | "decision" | "constraint" | "lesson",
|
|
1085
|
-
content: args.content,
|
|
1086
|
-
source: "agent",
|
|
1087
|
-
sessionID: toolContext.sessionID,
|
|
1088
|
-
commit: getCurrentGitCommitHash(effectiveRoot),
|
|
1089
|
-
paths: args.paths,
|
|
1090
|
-
supersedes: args.supersedes || undefined,
|
|
1091
|
-
});
|
|
1092
|
-
return JSON.stringify(record);
|
|
1093
|
-
},
|
|
1094
|
-
}),
|
|
1095
|
-
project_memory_export: tool({
|
|
1096
|
-
description: "Explicitly promote selected durable project-memory records to the human-readable repo-local .opencode/memory/project-memory.jsonl file. Nothing is exported automatically.",
|
|
1097
|
-
args: { ids: tool.schema.array(tool.schema.string()) },
|
|
1098
|
-
execute: async (args) => {
|
|
1099
|
-
if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
|
|
1100
|
-
if (args.ids.length > 100 || args.ids.some((id) => id.length > 200)) return "[workflow-guard] Project memory export rejected: invalid record IDs.";
|
|
1101
|
-
const count = exportProjectKnowledge(projectMemory, args.ids, portableMemoryPath, (content) => secretIn(content) !== undefined);
|
|
1102
|
-
return `[workflow-guard] Exported ${count} project-memory record(s) to .opencode/memory/project-memory.jsonl.`;
|
|
1103
|
-
},
|
|
1104
|
-
}),
|
|
1105
|
-
project_memory_import: tool({
|
|
1106
|
-
description: "Import the fixed repo-local .opencode/memory/project-memory.jsonl file into this project's local working-memory index.",
|
|
1107
|
-
args: {},
|
|
1108
|
-
execute: async () => projectMemory ? `[workflow-guard] Imported ${importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined)} new project-memory record(s).` : "[workflow-guard] Project memory unavailable; core guard enforcement remains active.",
|
|
1109
|
-
}),
|
|
1110
|
-
...(learningEnabled ? {
|
|
1111
|
-
learning_profile: tool({
|
|
1112
|
-
description: "Inspect the local evidence-based learner profile so teaching can build on demonstrated knowledge without assuming unobserved concepts are gaps.",
|
|
1113
|
-
args: {},
|
|
1114
|
-
execute: async () => JSON.stringify(loadLearnerProfile(), null, 2),
|
|
1115
|
-
}),
|
|
1116
|
-
learning_checkpoint: tool({
|
|
1117
|
-
description: "At a high-value design decision, debugging moment, or important new concept, rank candidate Socratic learning opportunities. If one is selected, ask one concise question before continuing the work; do not turn routine syntax into a lesson.",
|
|
1118
|
-
args: {
|
|
1119
|
-
opportunities: tool.schema.array(tool.schema.object({
|
|
1120
|
-
type: tool.schema.string().describe("design, debugging, or new-concept"),
|
|
1121
|
-
concept: tool.schema.string().describe("Transferable engineering concept"),
|
|
1122
|
-
relevance: tool.schema.number().describe("Current-task relevance from 0 to 1"),
|
|
1123
|
-
consequence: tool.schema.number().describe("Decision consequence from 0 to 1"),
|
|
1124
|
-
})),
|
|
1125
|
-
},
|
|
1126
|
-
execute: async (args, toolContext) => {
|
|
1127
|
-
const valid = args.opportunities.slice(0, 20).filter((candidate): candidate is typeof candidate & { type: "design" | "debugging" | "new-concept" } =>
|
|
1128
|
-
(candidate.type === "design" || candidate.type === "debugging" || candidate.type === "new-concept") &&
|
|
1129
|
-
candidate.concept.length > 0 && candidate.concept.length <= 100 &&
|
|
1130
|
-
candidate.relevance >= 0 && candidate.relevance <= 1 && candidate.consequence >= 0 && candidate.consequence <= 1,
|
|
1131
|
-
);
|
|
1132
|
-
const used = learningInterventions.get(toolContext.sessionID) ?? 0;
|
|
1133
|
-
const selected = selectLearningOpportunity(loadLearnerProfile(), valid, {
|
|
1134
|
-
interventionsThisSession: used,
|
|
1135
|
-
maxInterventionsPerSession: getLearningInterventionBudget(effectiveRoot),
|
|
1136
|
-
});
|
|
1137
|
-
if (!selected) return JSON.stringify({ intervene: false, reason: "No opportunity selected or session learning budget reached." });
|
|
1138
|
-
learningInterventions.set(toolContext.sessionID, used + 1);
|
|
1139
|
-
return JSON.stringify({ intervene: true, opportunity: selected, guidance: "Ask one question, use the answer as task context, briefly reconcile if useful, then continue building." });
|
|
1140
|
-
},
|
|
1141
|
-
}),
|
|
1142
|
-
learning_record: tool({
|
|
1143
|
-
description: "Record concise evidence from a real Socratic interaction after observing the learner's reasoning. Record what was demonstrated, not a grade or an inferred deficit.",
|
|
1144
|
-
args: {
|
|
1145
|
-
concept: tool.schema.string(),
|
|
1146
|
-
kind: tool.schema.string().describe("exposed, developing, demonstrated, independent, critique, or needs-reinforcement"),
|
|
1147
|
-
summary: tool.schema.string().describe("Short factual description of observed reasoning"),
|
|
1148
|
-
},
|
|
1149
|
-
execute: async (args, toolContext) => {
|
|
1150
|
-
const validKinds = new Set(["exposed", "developing", "demonstrated", "independent", "critique", "needs-reinforcement"]);
|
|
1151
|
-
if (!validKinds.has(args.kind)) return "[workflow-guard] Learning evidence rejected: invalid evidence kind.";
|
|
1152
|
-
if (args.concept.length < 1 || args.concept.length > 100 || args.summary.length < 1 || args.summary.length > 1000) {
|
|
1153
|
-
return "[workflow-guard] Learning evidence rejected: concept must be 1-100 characters and summary 1-1000 characters.";
|
|
1154
|
-
}
|
|
1155
|
-
try {
|
|
1156
|
-
updateLearnerProfile((profile) => {
|
|
1157
|
-
if (!profile.concepts[args.concept] && Object.keys(profile.concepts).length >= 500) {
|
|
1158
|
-
throw new Error("concept-limit");
|
|
1159
|
-
}
|
|
1160
|
-
recordLearningEvidence(profile, {
|
|
1161
|
-
concept: args.concept,
|
|
1162
|
-
kind: args.kind as LearningEvidenceKind,
|
|
1163
|
-
summary: args.summary,
|
|
1164
|
-
timestamp: Date.now(),
|
|
1165
|
-
sessionID: toolContext.sessionID,
|
|
1166
|
-
project: effectiveRoot,
|
|
1167
|
-
});
|
|
1168
|
-
});
|
|
1169
|
-
} catch (error) {
|
|
1170
|
-
if ((error as Error).message === "concept-limit") {
|
|
1171
|
-
return "[workflow-guard] Learning evidence rejected: learner profile concept limit reached.";
|
|
1172
|
-
}
|
|
1173
|
-
throw error;
|
|
1174
|
-
}
|
|
1175
|
-
return `[workflow-guard] Learning evidence recorded for ${args.concept}.`;
|
|
1176
|
-
},
|
|
1177
|
-
}),
|
|
1178
|
-
} : {}),
|
|
1179
|
-
guard_status: tool({
|
|
1180
|
-
description:
|
|
1181
|
-
"Inspect active guardrails, current branch protection, and verification/review status.",
|
|
1182
|
-
args: {},
|
|
1183
|
-
execute: async () => {
|
|
1184
|
-
const root = getWorkspaceRoot();
|
|
1185
|
-
const branch = currentGitBranch(root) ?? "unknown";
|
|
1186
|
-
const isProtected = onProtectedBranch(root);
|
|
1187
|
-
const lastV = getLastVerifyResult();
|
|
1188
|
-
const lastR = getLastReviewResult();
|
|
1189
|
-
const lastMut = getLastMutationTimestamp();
|
|
1190
|
-
const cfg = loadProjectConfig(root);
|
|
1191
|
-
return JSON.stringify(
|
|
1192
|
-
{
|
|
1193
|
-
workspaceRoot: root,
|
|
1194
|
-
branch,
|
|
1195
|
-
onProtectedBranch: isProtected,
|
|
1196
|
-
lastMutationTimestamp: lastMut,
|
|
1197
|
-
mutationCount: getMutationCount(),
|
|
1198
|
-
lastVerify: lastV
|
|
1199
|
-
? {
|
|
1200
|
-
command: lastV.command,
|
|
1201
|
-
passed: lastV.passed,
|
|
1202
|
-
fresh: lastV.timestamp >= lastMut,
|
|
1203
|
-
commitHash: lastV.commitHash,
|
|
1204
|
-
}
|
|
1205
|
-
: null,
|
|
1206
|
-
lastReview: lastR
|
|
1207
|
-
? {
|
|
1208
|
-
reviewer: lastR.reviewer,
|
|
1209
|
-
passed: lastR.passed,
|
|
1210
|
-
summary: lastR.summary,
|
|
1211
|
-
}
|
|
1212
|
-
: null,
|
|
1213
|
-
projectConfig: {
|
|
1214
|
-
protectedBranches: cfg.protectedBranches ?? ["main", "master"],
|
|
1215
|
-
verifyCommand: detectVerifyCommand(root) ?? null,
|
|
1216
|
-
requireReview: isReviewRequired(root),
|
|
1217
|
-
requireDocumentation: isDocumentationRequired(root),
|
|
1218
|
-
},
|
|
1219
|
-
},
|
|
1220
|
-
null,
|
|
1221
|
-
2,
|
|
1222
|
-
);
|
|
1223
|
-
},
|
|
1224
|
-
}),
|
|
1225
|
-
guard_audit: tool({
|
|
1226
|
-
description:
|
|
1227
|
-
"View recent audit entries recorded by opencode-workflow-guard.",
|
|
1228
|
-
args: {
|
|
1229
|
-
limit: tool.schema
|
|
1230
|
-
.number()
|
|
1231
|
-
.optional()
|
|
1232
|
-
.describe("Maximum entries to return (default 10)"),
|
|
1233
|
-
},
|
|
1234
|
-
execute: async (args) => {
|
|
1235
|
-
const limit =
|
|
1236
|
-
typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10;
|
|
1237
|
-
return JSON.stringify(getRecentAuditEntries(limit), null, 2);
|
|
1238
|
-
},
|
|
1239
|
-
}),
|
|
1240
|
-
guard_why: tool({
|
|
1241
|
-
description:
|
|
1242
|
-
"Simulate and explain whether a specific tool call or command would be blocked by guardrails.",
|
|
1243
|
-
args: {
|
|
1244
|
-
tool: tool.schema
|
|
1245
|
-
.string()
|
|
1246
|
-
.describe("Tool name (e.g. bash, edit, write, read, apply_patch)"),
|
|
1247
|
-
input: tool.schema
|
|
1248
|
-
.record(tool.schema.string(), tool.schema.any())
|
|
1249
|
-
.optional()
|
|
1250
|
-
.describe("Tool input arguments"),
|
|
1251
|
-
},
|
|
1252
|
-
execute: async (args) => {
|
|
1253
|
-
const reason = await guardToolCallImpl(args.tool, args.input ?? {});
|
|
1254
|
-
return reason
|
|
1255
|
-
? `BLOCKED: ${reason}`
|
|
1256
|
-
: "ALLOWED: Satisfies all current guardrails.";
|
|
1257
|
-
},
|
|
1258
|
-
}),
|
|
1259
|
-
record_review: tool({
|
|
1260
|
-
description:
|
|
1261
|
-
"Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric.",
|
|
1262
|
-
args: {
|
|
1263
|
-
reviewer: tool.schema
|
|
1264
|
-
.string()
|
|
1265
|
-
.describe("Identifier/name of the reviewer subagent"),
|
|
1266
|
-
summary: tool.schema
|
|
1267
|
-
.string()
|
|
1268
|
-
.describe("Review findings summary across the 5 core review axes"),
|
|
1269
|
-
passed: tool.schema
|
|
1270
|
-
.boolean()
|
|
1271
|
-
.describe("True if change is approved, false if changes requested"),
|
|
1272
|
-
},
|
|
1273
|
-
execute: async (args, toolContext) => {
|
|
1274
|
-
const parentSessionID = await fetchParentSessionID(toolContext.sessionID);
|
|
1275
|
-
if (!parentSessionID) {
|
|
1276
|
-
return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session.";
|
|
1277
|
-
}
|
|
1278
|
-
// The rubric is the contract: a recorded review must demonstrate it
|
|
1279
|
-
// was evaluated against the 5 axes, not just assert a verdict.
|
|
1280
|
-
const axesRefs = [
|
|
1281
|
-
"test integrity",
|
|
1282
|
-
"task completeness",
|
|
1283
|
-
"cleanliness",
|
|
1284
|
-
"security",
|
|
1285
|
-
"platform",
|
|
1286
|
-
];
|
|
1287
|
-
const summaryLower = args.summary.toLowerCase();
|
|
1288
|
-
const referenced = axesRefs.filter((axis) => summaryLower.includes(axis));
|
|
1289
|
-
if (referenced.length < 3) {
|
|
1290
|
-
return (
|
|
1291
|
-
`[workflow-guard] Review rejected: summary must reference the review axes ` +
|
|
1292
|
-
`(found ${referenced.length}/5). Call guard_review_rubric to get the rubric, ` +
|
|
1293
|
-
`evaluate each axis, and include findings per axis in the summary.`
|
|
1294
|
-
);
|
|
1295
|
-
}
|
|
1296
|
-
if (args.passed) {
|
|
1297
|
-
if (/(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) {
|
|
1298
|
-
return (
|
|
1299
|
-
"[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. " +
|
|
1300
|
-
"Resolve all P0/P1 issues before approving or record review with passed=false."
|
|
1301
|
-
);
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
recordReviewResult(
|
|
1305
|
-
args.reviewer,
|
|
1306
|
-
args.summary,
|
|
1307
|
-
args.passed,
|
|
1308
|
-
parentSessionID,
|
|
1309
|
-
toolContext.worktree || toolContext.directory,
|
|
1310
|
-
);
|
|
1311
|
-
return args.passed
|
|
1312
|
-
? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}.`
|
|
1313
|
-
: `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
|
|
1314
|
-
},
|
|
1315
|
-
}),
|
|
1316
|
-
guard_review_rubric: tool({
|
|
1317
|
-
description:
|
|
1318
|
-
"Get the secondary-review rubric for the current branch diff. The orchestrator calls this, spawns a reviewer subagent with the rubric as the prompt, then the reviewer records its verdict via record_review.",
|
|
1319
|
-
args: {
|
|
1320
|
-
base: tool.schema
|
|
1321
|
-
.string()
|
|
1322
|
-
.optional()
|
|
1323
|
-
.describe("Base ref to diff against (default: origin/main, origin/master, main)"),
|
|
1324
|
-
},
|
|
1325
|
-
execute: async (args) => {
|
|
1326
|
-
const sanitizedBase =
|
|
1327
|
-
typeof args.base === "string" &&
|
|
1328
|
-
!args.base.startsWith("-") &&
|
|
1329
|
-
!/[\s;'"\0]/.test(args.base)
|
|
1330
|
-
? args.base
|
|
1331
|
-
: undefined;
|
|
1332
|
-
const bases = sanitizedBase
|
|
1333
|
-
? [sanitizedBase]
|
|
1334
|
-
: ["origin/main", "origin/master", "main", "master"];
|
|
1335
|
-
let diffText = "";
|
|
1336
|
-
for (const base of bases) {
|
|
1337
|
-
const res = spawnSync(
|
|
1338
|
-
"git",
|
|
1339
|
-
["diff", "--", `${base}...HEAD`],
|
|
1340
|
-
{ cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 },
|
|
1341
|
-
);
|
|
1342
|
-
if (res.status === 0 && res.stdout.trim()) {
|
|
1343
|
-
diffText = res.stdout;
|
|
1344
|
-
break;
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
if (!diffText) {
|
|
1348
|
-
const last = spawnSync("git", ["diff", "--", "HEAD~1"], {
|
|
1349
|
-
cwd: getWorkspaceRoot(),
|
|
1350
|
-
encoding: "utf8",
|
|
1351
|
-
timeout: 10_000,
|
|
1352
|
-
});
|
|
1353
|
-
diffText = last.status === 0 ? last.stdout : "(no diff available)";
|
|
1354
|
-
}
|
|
1355
|
-
return buildReviewRubric(diffText);
|
|
1356
|
-
},
|
|
1357
|
-
}),
|
|
1358
|
-
guard_worktree_create: tool({
|
|
1359
|
-
description:
|
|
1360
|
-
"Create an isolated git worktree directory for concurrent subagent execution.",
|
|
1361
|
-
args: {
|
|
1362
|
-
branch: tool.schema
|
|
1363
|
-
.string()
|
|
1364
|
-
.describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"),
|
|
1365
|
-
baseBranch: tool.schema
|
|
1366
|
-
.string()
|
|
1367
|
-
.optional()
|
|
1368
|
-
.describe("Base branch to branch off of (defaults to HEAD)"),
|
|
1369
|
-
},
|
|
1370
|
-
execute: async (args, toolContext) => {
|
|
1371
|
-
const todos = await effectiveTodos(toolContext.sessionID);
|
|
1372
|
-
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
1373
|
-
return (
|
|
1374
|
-
"[workflow-guard] Blocked: worktree creation with no active todo item. " +
|
|
1375
|
-
"Break the request down with todowrite first, then create worktrees."
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot;
|
|
1379
|
-
const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot);
|
|
1380
|
-
if (!res.success) {
|
|
1381
|
-
return `[workflow-guard] Failed to create worktree: ${res.error}`;
|
|
1382
|
-
}
|
|
1383
|
-
recordMutation(
|
|
1384
|
-
(await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID,
|
|
1385
|
-
);
|
|
1386
|
-
return (
|
|
1387
|
-
`[workflow-guard] Worktree created successfully at: ${res.worktreePath}\n` +
|
|
1388
|
-
`Run subagent tasks or pass worktree directory context to isolate file mutations.`
|
|
1389
|
-
);
|
|
1390
|
-
},
|
|
1391
|
-
}),
|
|
1392
|
-
guard_worktree_cleanup: tool({
|
|
1393
|
-
description:
|
|
1394
|
-
"Commit a final snapshot and remove an isolated git worktree directory.",
|
|
1395
|
-
args: {
|
|
1396
|
-
worktreePath: tool.schema
|
|
1397
|
-
.string()
|
|
1398
|
-
.describe("Path of the worktree directory to clean up"),
|
|
1399
|
-
},
|
|
1400
|
-
execute: async (args, toolContext) => {
|
|
1401
|
-
const todos = await effectiveTodos(toolContext.sessionID);
|
|
1402
|
-
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
1403
|
-
return (
|
|
1404
|
-
"[workflow-guard] Blocked: worktree cleanup with no active todo item. " +
|
|
1405
|
-
"Break the request down with todowrite first, then clean up worktrees."
|
|
1406
|
-
);
|
|
1407
|
-
}
|
|
1408
|
-
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot;
|
|
1409
|
-
const res = cleanupGitWorktree(args.worktreePath, toolRoot);
|
|
1410
|
-
if (!res.success) {
|
|
1411
|
-
return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
|
|
1412
|
-
}
|
|
1413
|
-
recordMutation(
|
|
1414
|
-
(await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID,
|
|
1415
|
-
);
|
|
1416
|
-
return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
|
|
1417
|
-
},
|
|
1418
|
-
}),
|
|
1419
|
-
},
|
|
375
|
+
tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions }),
|
|
1420
376
|
|
|
1421
377
|
"tool.execute.before": async (input, output) => {
|
|
1422
378
|
const toolWorktree =
|
|
@@ -1434,6 +390,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1434
390
|
(input as { args?: unknown }).args;
|
|
1435
391
|
const reason = await guardToolCall(input.tool, args, {
|
|
1436
392
|
sessionID: input.sessionID,
|
|
393
|
+
callID: input.callID,
|
|
1437
394
|
worktree: toolWorktree,
|
|
1438
395
|
agent: toolAgent,
|
|
1439
396
|
});
|
|
@@ -1441,6 +398,43 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1441
398
|
await emitBlockFeedback(reason);
|
|
1442
399
|
throw new Error(`[workflow-guard] ${reason}`);
|
|
1443
400
|
}
|
|
401
|
+
toolLifecycle.start(input.sessionID, input.callID);
|
|
402
|
+
if (input.tool === "read") {
|
|
403
|
+
const target = editTargets(args, toolWorktree)[0];
|
|
404
|
+
if (target) {
|
|
405
|
+
const observation = beginReadObservation(target);
|
|
406
|
+
if (observation) toolLifecycle.setReadObservation(input.sessionID, input.callID, observation);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (EDIT_TOOL_NAMES.has(input.tool)) {
|
|
410
|
+
const snapshots = editTargets(args, toolWorktree).map(snapshotFile);
|
|
411
|
+
if (snapshots.length) toolLifecycle.setPostEditSnapshots(input.sessionID, input.callID, toolWorktree, snapshots);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
"tool.execute.after": async (input, output) => {
|
|
417
|
+
const startedAt = toolLifecycle.finish(input.sessionID, input.callID);
|
|
418
|
+
audit({
|
|
419
|
+
ts: new Date().toISOString(),
|
|
420
|
+
sessionID: input.sessionID,
|
|
421
|
+
callID: input.callID,
|
|
422
|
+
tool: input.tool,
|
|
423
|
+
decision: "allow",
|
|
424
|
+
phase: "outcome",
|
|
425
|
+
durationMs: startedAt === undefined ? undefined : Date.now() - startedAt,
|
|
426
|
+
});
|
|
427
|
+
releaseFileClaims(input.sessionID, input.callID);
|
|
428
|
+
if (input.tool === "read") {
|
|
429
|
+
const observation = toolLifecycle.takeReadObservation(input.sessionID, input.callID);
|
|
430
|
+
if (observation) recordSuccessfulRead(observation, input.sessionID);
|
|
431
|
+
}
|
|
432
|
+
const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
|
|
433
|
+
if (!pending) return;
|
|
434
|
+
await runWithRuntimeState(pending.root, ctx.client, async () => {
|
|
435
|
+
const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
|
|
436
|
+
const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
|
|
437
|
+
if (report) output.output = `${output.output}\n\n${report}`;
|
|
1444
438
|
});
|
|
1445
439
|
},
|
|
1446
440
|
|
|
@@ -1454,20 +448,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1454
448
|
return s === "pending" || s === "in_progress";
|
|
1455
449
|
});
|
|
1456
450
|
const branch = currentGitBranch(effectiveRoot) ?? "unknown";
|
|
1457
|
-
const isProtected =
|
|
451
|
+
const isProtected = branch !== "unknown" && isProtectedBranchName(branch, effectiveRoot);
|
|
452
|
+
const sessionMutationCount = sessionID ? getSessionMutationCount(sessionID) : 0;
|
|
1458
453
|
const lastV = sessionID
|
|
1459
|
-
? (sessionVerifyResults.get(sessionID) ??
|
|
454
|
+
? (sessionVerifyResults.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionVerifyResults.get(parentID) : undefined))
|
|
1460
455
|
: lastVerify;
|
|
1461
|
-
const lastR =
|
|
1462
|
-
|
|
456
|
+
const lastR = sessionID
|
|
457
|
+
? (sessionReviews.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionReviews.get(parentID) : undefined))
|
|
458
|
+
: getLastReviewResult();
|
|
459
|
+
const mutationCountVal = sessionID ? sessionMutationCount : getMutationCount();
|
|
1463
460
|
|
|
1464
461
|
const contextBlocks: string[] = [];
|
|
1465
462
|
|
|
1466
463
|
if (active && active.length > 0) {
|
|
1467
|
-
const lines = active.map(
|
|
464
|
+
const lines = active.slice(0, 20).map(
|
|
1468
465
|
(t) =>
|
|
1469
|
-
`- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "")}`,
|
|
466
|
+
`- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "").slice(0, 300)}`,
|
|
1470
467
|
);
|
|
468
|
+
if (active.length > 20) lines.push(`- ... ${active.length - 20} more active task(s) omitted`);
|
|
1471
469
|
const attribution = parentID
|
|
1472
470
|
? ` (Subagent session: ${sessionID}, Parent: ${parentID})`
|
|
1473
471
|
: sessionID
|
|
@@ -1495,10 +493,18 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1495
493
|
`- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
|
|
1496
494
|
);
|
|
1497
495
|
}
|
|
496
|
+
let openFollowups: ReturnType<typeof listReviewFollowups> = [];
|
|
497
|
+
try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
|
|
498
|
+
if (openFollowups.length > 0) {
|
|
499
|
+
stateLines.push(`- Open Review Follow-ups: ${openFollowups.length} local P2/P3 item(s)`);
|
|
500
|
+
contextBlocks.push(`## Review Follow-ups\n${openFollowups.map((item) => `- [${item.severity}:${item.id.slice(0, 8)}] ${item.summary.slice(0, 300)}`).join("\n")}\nTreat these as durable technical debt: address relevant items when practical and resolve them explicitly after verification.`);
|
|
501
|
+
}
|
|
1498
502
|
contextBlocks.push(stateLines.join("\n"));
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
503
|
+
let projectKnowledge: ReturnType<typeof getRecentProjectMemory> = [];
|
|
504
|
+
try {
|
|
505
|
+
projectKnowledge = (projectMemory ? getRecentProjectMemory(projectMemory, 8) : [])
|
|
506
|
+
.filter((memory) => memory.source !== "portable" && isProjectMemoryFresh(memory, effectiveRoot));
|
|
507
|
+
} catch {}
|
|
1502
508
|
if (projectKnowledge.length > 0) {
|
|
1503
509
|
const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);
|
|
1504
510
|
contextBlocks.push(`## Project Memory\n${lines.join("\n")}\nTreat these as historical project knowledge; verify against current repository state when relevant files have changed.`);
|
|
@@ -1515,14 +521,8 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1515
521
|
const env = (output as { env?: Record<string, string> }).env;
|
|
1516
522
|
if (env && typeof env === "object") {
|
|
1517
523
|
const scrubbed: string[] = [];
|
|
1518
|
-
for (const key of SENSITIVE_ENV_KEYS) {
|
|
1519
|
-
if (key in env && env[key] !== "") {
|
|
1520
|
-
env[key] = "";
|
|
1521
|
-
scrubbed.push(key);
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
524
|
for (const key of Object.keys(env)) {
|
|
1525
|
-
if (
|
|
525
|
+
if (isSensitiveEnvKey(key) && env[key] !== "") {
|
|
1526
526
|
env[key] = "";
|
|
1527
527
|
scrubbed.push(key);
|
|
1528
528
|
}
|
|
@@ -1591,12 +591,61 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1591
591
|
"\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
|
|
1592
592
|
},
|
|
1593
593
|
|
|
594
|
+
"chat.message": async (input) => {
|
|
595
|
+
if (getProjectConfig(effectiveRoot).recoveryCheckpoints !== true) return;
|
|
596
|
+
if (isGeneratedContinuationMessage(input.sessionID, input.messageID)) return;
|
|
597
|
+
const parent = await runWithRuntimeState(effectiveRoot, ctx.client, () => fetchParentSession(input.sessionID));
|
|
598
|
+
if (!parent.ok || parent.parentID) return;
|
|
599
|
+
const run = nextRecoveryRun(effectiveRoot, input.sessionID);
|
|
600
|
+
const checkpoint = createRecoveryCheckpoint(effectiveRoot, input.sessionID, run);
|
|
601
|
+
if (checkpoint) toolLifecycle.setRecoveryRun(input.sessionID, run);
|
|
602
|
+
},
|
|
603
|
+
|
|
1594
604
|
event: async ({
|
|
1595
605
|
event,
|
|
1596
606
|
}: { event: { type?: string; properties?: unknown } }) => {
|
|
607
|
+
if (event?.type === "message.part.updated") {
|
|
608
|
+
const outcome = toolOutcomes.record((event.properties as { part?: ToolOutcomePart })?.part ?? {});
|
|
609
|
+
if (outcome) {
|
|
610
|
+
audit({
|
|
611
|
+
ts: new Date().toISOString(),
|
|
612
|
+
sessionID: outcome.sessionID,
|
|
613
|
+
callID: outcome.callID,
|
|
614
|
+
tool: outcome.tool,
|
|
615
|
+
decision: "allow",
|
|
616
|
+
phase: "outcome",
|
|
617
|
+
durationMs: outcome.durationMs,
|
|
618
|
+
reason: outcome.status,
|
|
619
|
+
});
|
|
620
|
+
if (outcome.status === "error" && outcome.repeatedFailureCount === 3) {
|
|
621
|
+
audit({
|
|
622
|
+
ts: new Date().toISOString(),
|
|
623
|
+
sessionID: outcome.sessionID,
|
|
624
|
+
callID: outcome.callID,
|
|
625
|
+
tool: outcome.tool,
|
|
626
|
+
decision: "allow",
|
|
627
|
+
phase: "event",
|
|
628
|
+
reason: "repeated-equivalent-failure:3",
|
|
629
|
+
});
|
|
630
|
+
logObservation(ctx.client, `[workflow-guard] ${outcome.tool} failed equivalently 3 times in this session; change approach or inspect the underlying failure before retrying.`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (event?.type === "session.deleted") {
|
|
635
|
+
const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
|
|
636
|
+
if (typeof sessionID === "string") toolOutcomes.clearSession(sessionID);
|
|
637
|
+
}
|
|
1597
638
|
if (event?.type === "session.idle") {
|
|
1598
639
|
const sessionID = (event.properties as { sessionID?: unknown })?.sessionID;
|
|
1599
640
|
if (typeof sessionID === "string") {
|
|
641
|
+
toolOutcomes.clearSession(sessionID);
|
|
642
|
+
releaseFileClaims(sessionID);
|
|
643
|
+
clearReadFingerprints(sessionID);
|
|
644
|
+
toolLifecycle.clearSession(sessionID);
|
|
645
|
+
const recoveryRun = toolLifecycle.takeRecoveryRun(sessionID);
|
|
646
|
+
if (recoveryRun !== undefined) {
|
|
647
|
+
finalizeRecoveryCheckpoint(effectiveRoot, sessionID, recoveryRun);
|
|
648
|
+
}
|
|
1600
649
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID));
|
|
1601
650
|
}
|
|
1602
651
|
}
|
|
@@ -1611,6 +660,10 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1611
660
|
if (event?.type === "session.deleted") {
|
|
1612
661
|
const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
|
|
1613
662
|
if (typeof sessionID === "string") {
|
|
663
|
+
releaseFileClaims(sessionID);
|
|
664
|
+
clearReadFingerprints(sessionID);
|
|
665
|
+
toolLifecycle.clearSession(sessionID);
|
|
666
|
+
toolLifecycle.takeRecoveryRun(sessionID);
|
|
1614
667
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
|
|
1615
668
|
}
|
|
1616
669
|
}
|