opencode-workflow-guard 1.12.1 → 1.13.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/README.md +5 -2
- package/docs/installation.md +4 -3
- package/docs/policies.md +6 -2
- package/package.json +4 -2
- package/src/lib/guard-dispatcher.ts +6 -2
- package/src/lib/types.ts +1 -0
- package/src/lib/v2-plugin.ts +625 -0
- package/src/lib/verify.ts +11 -0
- package/src/policies/boundary.ts +16 -3
- package/src/policies/continuation.ts +20 -1
- package/src/policies/git.ts +42 -1
- package/src/policies/tamper.ts +17 -0
- package/src/workflow-guard-ui.ts +95 -14
- package/src/workflow-guard.ts +26 -5
- package/test/test-e2e.mts +129 -27
- package/test/test.mts +67 -1
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode V2 plugin implementation for Workflow Guard.
|
|
3
|
+
*
|
|
4
|
+
* Registers the same policies and tools as the V1 entrypoint in
|
|
5
|
+
* `workflow-guard.ts`, through the V2 `@opencode/plugin` API:
|
|
6
|
+
* - `ctx.tool.transform` for custom tools and tool-description enrichment
|
|
7
|
+
* - `ctx.tool.hook("execute.before"/"execute.after")` for deterministic enforcement
|
|
8
|
+
* - `ctx.session.hook("context"/"compaction"/"prompt")` for system guidance,
|
|
9
|
+
* compaction context, and recovery checkpoints
|
|
10
|
+
* - `ctx.permission.hook("evaluate")` for permission journaling
|
|
11
|
+
* - `ctx.shell.hook("create.before")` for env scrubbing
|
|
12
|
+
* - `ctx.event.subscribe()` for tool outcomes, session lifecycle, and claims-vs-evidence
|
|
13
|
+
*
|
|
14
|
+
* The V1 SDK client surface (`TodoSdkClient`) is satisfied by an adapter so
|
|
15
|
+
* `lib/` and `policies/` keep working unchanged.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Plugin } from "@opencode/plugin";
|
|
19
|
+
import { Message } from "@opencode/ai";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
import type { TodoItem, TodoSdkClient } from "./types.ts";
|
|
23
|
+
import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
|
|
24
|
+
import {
|
|
25
|
+
setWorkspaceRoot,
|
|
26
|
+
setSdkClient,
|
|
27
|
+
getSessionWorkspace,
|
|
28
|
+
setSessionWorkspace,
|
|
29
|
+
runWithRuntimeState,
|
|
30
|
+
getSessionMutationCount,
|
|
31
|
+
sessionVerifyResults,
|
|
32
|
+
sessionReviews,
|
|
33
|
+
isRecoveryCheckpointsEnabled,
|
|
34
|
+
isLearningEnabled,
|
|
35
|
+
isProjectMemoryEnabled,
|
|
36
|
+
getProjectConfig,
|
|
37
|
+
} from "./state.ts";
|
|
38
|
+
import {
|
|
39
|
+
openProjectMemory,
|
|
40
|
+
ensureProjectMemoryExcluded,
|
|
41
|
+
getProjectMemoryIdentity,
|
|
42
|
+
getRecentProjectMemory,
|
|
43
|
+
isProjectMemoryFreshAsync,
|
|
44
|
+
listReviewFollowups,
|
|
45
|
+
importProjectKnowledge,
|
|
46
|
+
type ProjectMemoryStore,
|
|
47
|
+
} from "./project-memory.ts";
|
|
48
|
+
import { secretIn } from "../policies/secrets.ts";
|
|
49
|
+
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
|
|
50
|
+
import { isReadOnlyRole } from "./guard-dispatcher.ts";
|
|
51
|
+
import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
|
|
52
|
+
import { ToolOutcomeTracker, type ToolOutcomePart } from "./tool-outcomes.ts";
|
|
53
|
+
import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
|
|
54
|
+
import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
|
|
55
|
+
import { checkCompletionClaims } from "../policies/completion.ts";
|
|
56
|
+
import { releaseFileClaims } from "../policies/file-claims.ts";
|
|
57
|
+
import { beginReadObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
|
|
58
|
+
import { editTargets, runPostEditValidators, snapshotFile } from "../policies/post-edit-validation.ts";
|
|
59
|
+
import { audit, summarizeInput } from "./audit.ts";
|
|
60
|
+
import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
|
|
61
|
+
import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
|
|
62
|
+
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
|
|
63
|
+
import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
|
|
64
|
+
|
|
65
|
+
type V2Context = Plugin.Context;
|
|
66
|
+
|
|
67
|
+
// ── V1 SDK client adapter ─────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reconstruct the current todo list for a session from the session message
|
|
71
|
+
* history (V2 exposes no dedicated todo endpoint). The newest applied
|
|
72
|
+
* `todowrite` tool call's input is the current state.
|
|
73
|
+
*/
|
|
74
|
+
async function fetchTodosFromContext(ctx: V2Context, sessionID: string): Promise<TodoItem[] | undefined> {
|
|
75
|
+
try {
|
|
76
|
+
const messages = (await ctx.session.context({ sessionID })) as unknown as Array<{ content?: unknown }>;
|
|
77
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
78
|
+
const content = (messages[i] as { content?: unknown })?.content;
|
|
79
|
+
if (!Array.isArray(content)) continue;
|
|
80
|
+
for (let j = content.length - 1; j >= 0; j--) {
|
|
81
|
+
const part = content[j] as { type?: string; name?: string; state?: { status?: string; input?: { todos?: unknown } } };
|
|
82
|
+
if (part?.type !== "tool" || typeof part.name !== "string") continue;
|
|
83
|
+
if (!/^todowrite$/i.test(part.name)) continue;
|
|
84
|
+
const status = part.state?.status;
|
|
85
|
+
if (status === "pending" || status === "streaming" || status === "running") continue;
|
|
86
|
+
const todos = part.state?.input?.todos;
|
|
87
|
+
if (Array.isArray(todos)) return todos as TodoItem[];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [];
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
97
|
+
// V2 has no server-side log API or TUI surface, so app.log degrades to
|
|
98
|
+
// console logging and showBlockToast becomes a no-op. Block reasons still
|
|
99
|
+
// reach the agent through the thrown tool-hook error; keep this adapter
|
|
100
|
+
// from logging anything beyond guard decision summaries.
|
|
101
|
+
return {
|
|
102
|
+
app: {
|
|
103
|
+
log: async ({ body }) => {
|
|
104
|
+
try {
|
|
105
|
+
console.log(`[${body.service}] ${body.level}: ${body.message}`);
|
|
106
|
+
} catch {}
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
session: {
|
|
110
|
+
todo: async ({ path }) => ({ data: await fetchTodosFromContext(ctx, path.id) }),
|
|
111
|
+
get: async ({ path }) => {
|
|
112
|
+
const session = await ctx.session.get({ sessionID: path.id });
|
|
113
|
+
return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
|
|
114
|
+
},
|
|
115
|
+
promptAsync: async (opts) => {
|
|
116
|
+
for (const part of opts.body.parts) {
|
|
117
|
+
if (part.synthetic) {
|
|
118
|
+
await ctx.session.synthetic({ sessionID: opts.path.id, text: part.text });
|
|
119
|
+
} else {
|
|
120
|
+
await ctx.session.prompt({ sessionID: opts.path.id, text: part.text });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
lsp: {},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Builtin tool description enrichment ───────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
const TODOWRITE_SUFFIX =
|
|
132
|
+
"\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. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
|
|
133
|
+
|
|
134
|
+
const EDIT_SUFFIX =
|
|
135
|
+
"\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
|
|
136
|
+
|
|
137
|
+
const SUBAGENT_SUFFIX =
|
|
138
|
+
"\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
|
|
139
|
+
|
|
140
|
+
const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
|
|
141
|
+
{ candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker: "verification evidence" },
|
|
142
|
+
{ candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker: "Workflow Guard requirement" },
|
|
143
|
+
{ candidates: ["task", "subagent"], suffix: SUBAGENT_SUFFIX, marker: "Workflow Guard subagent guidance" },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// ── Setup ─────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
149
|
+
const hostRoot = ctx.location?.directory || process.cwd();
|
|
150
|
+
const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
|
|
151
|
+
const projectDirectory = (ctx.location as { project?: { directory?: string; canonical?: string } })?.project?.directory;
|
|
152
|
+
let effectiveRoot = hostIsFilesystemRoot && projectDirectory && resolve(projectDirectory) !== resolve(projectDirectory, "..")
|
|
153
|
+
? projectDirectory
|
|
154
|
+
: hostRoot;
|
|
155
|
+
if (resolve(effectiveRoot) === resolve(effectiveRoot, "..")) {
|
|
156
|
+
const nonRootCandidate = [ctx.location?.directory, process.cwd()].find((p) => typeof p === "string" && resolve(p) !== resolve(p, ".."));
|
|
157
|
+
if (nonRootCandidate) {
|
|
158
|
+
const gitRoot = findGitRoot(nonRootCandidate);
|
|
159
|
+
effectiveRoot = gitRoot ?? nonRootCandidate;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const client = createV2SdkClient(ctx);
|
|
164
|
+
setWorkspaceRoot(effectiveRoot);
|
|
165
|
+
setSdkClient(client);
|
|
166
|
+
reloadProjectConfig(effectiveRoot);
|
|
167
|
+
|
|
168
|
+
const learningEnabled = isLearningEnabled(effectiveRoot);
|
|
169
|
+
const projectMemoryEnabled = isProjectMemoryEnabled(effectiveRoot);
|
|
170
|
+
const learningInterventions = new Map<string, number>();
|
|
171
|
+
const portableMemoryPath = join(effectiveRoot, ".opencode", "memory", "project-memory.jsonl");
|
|
172
|
+
let followupStore: ReturnType<typeof openProjectMemory> | undefined;
|
|
173
|
+
let projectMemory: ReturnType<typeof openProjectMemory> | undefined;
|
|
174
|
+
try {
|
|
175
|
+
followupStore = openProjectMemory(getProjectMemoryIdentity(effectiveRoot));
|
|
176
|
+
} catch {}
|
|
177
|
+
try {
|
|
178
|
+
if (!projectMemoryEnabled) throw new Error("Project memory disabled");
|
|
179
|
+
projectMemory = openProjectMemory(getProjectMemoryIdentity(effectiveRoot));
|
|
180
|
+
ensureProjectMemoryExcluded(effectiveRoot);
|
|
181
|
+
importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined);
|
|
182
|
+
} catch {
|
|
183
|
+
try { projectMemory?.close(); } catch {}
|
|
184
|
+
projectMemory = undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
console.log(`[workflow-guard] info: Workflow Guard plugin initialized for ${effectiveRoot}; provenance is plugin-local in V2`);
|
|
189
|
+
} catch {}
|
|
190
|
+
|
|
191
|
+
const toolLifecycle = new ToolInvocationLifecycle();
|
|
192
|
+
const toolOutcomes = new ToolOutcomeTracker();
|
|
193
|
+
|
|
194
|
+
// ── Custom tools + builtin description enrichment ──
|
|
195
|
+
const customTools = createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client });
|
|
196
|
+
await ctx.tool.transform((editor) => {
|
|
197
|
+
for (const [name, definition] of Object.entries(customTools)) {
|
|
198
|
+
editor.add({
|
|
199
|
+
name,
|
|
200
|
+
description: definition.description,
|
|
201
|
+
// definition.args is a V1 zod raw shape (plain object of zod fields);
|
|
202
|
+
// wrap it so the V2 registry receives a real StandardSchemaV1.
|
|
203
|
+
input: z.object(definition.args as unknown as z.ZodRawShape),
|
|
204
|
+
execute: async (rawInput: unknown, toolContext: { sessionID: string; id: string }) => {
|
|
205
|
+
const result = await (definition.execute as (input: unknown, context: unknown) => Promise<unknown>)(rawInput, {
|
|
206
|
+
sessionID: toolContext.sessionID,
|
|
207
|
+
worktree: getSessionWorkspace(toolContext.sessionID) ?? effectiveRoot,
|
|
208
|
+
directory: effectiveRoot,
|
|
209
|
+
});
|
|
210
|
+
const text = typeof result === "string" ? result : (result as { output?: string } | undefined)?.output;
|
|
211
|
+
return {
|
|
212
|
+
content: typeof text === "string" ? text : JSON.stringify(result),
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
} as never);
|
|
216
|
+
}
|
|
217
|
+
for (const target of ENRICHMENT_TARGETS) {
|
|
218
|
+
for (const id of target.candidates) {
|
|
219
|
+
editor.update(id, (tool) => {
|
|
220
|
+
const description = typeof tool.description === "string" ? tool.description : "";
|
|
221
|
+
if (description.includes(target.marker)) return;
|
|
222
|
+
tool.description = description + target.suffix;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── Deterministic enforcement ──
|
|
229
|
+
await ctx.tool.hook("execute.before", (event) => {
|
|
230
|
+
const toolWorktree = getSessionWorkspace(event.sessionID) || effectiveRoot;
|
|
231
|
+
return runWithRuntimeState(toolWorktree, client, async () => {
|
|
232
|
+
const args = event.input;
|
|
233
|
+
const reason = await guardToolCall(event.tool, args, {
|
|
234
|
+
sessionID: event.sessionID,
|
|
235
|
+
callID: event.id,
|
|
236
|
+
worktree: toolWorktree,
|
|
237
|
+
agent: event.agent,
|
|
238
|
+
});
|
|
239
|
+
if (reason !== undefined) {
|
|
240
|
+
await showBlockToast(reason);
|
|
241
|
+
const failureCount = event.sessionID ? toolOutcomes.getFailureCount(event.sessionID) : 0;
|
|
242
|
+
const circuitBreakerSuffix = failureCount >= 2
|
|
243
|
+
? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly, or inspect policy details with guard_status or guard_why.]"
|
|
244
|
+
: "";
|
|
245
|
+
throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
|
|
246
|
+
}
|
|
247
|
+
toolLifecycle.start(event.sessionID, event.id);
|
|
248
|
+
const record = asRecord(args);
|
|
249
|
+
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
250
|
+
const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
|
|
251
|
+
const candidatePath = workdir ?? filePath ?? toolWorktree;
|
|
252
|
+
if (candidatePath) {
|
|
253
|
+
const gitRoot = findGitRoot(candidatePath);
|
|
254
|
+
if (gitRoot) setSessionWorkspace(event.sessionID, gitRoot);
|
|
255
|
+
}
|
|
256
|
+
if (event.tool === "read") {
|
|
257
|
+
const target = editTargets(args, toolWorktree)[0];
|
|
258
|
+
if (target) {
|
|
259
|
+
const observation = beginReadObservation(target);
|
|
260
|
+
if (observation) toolLifecycle.setReadObservation(event.sessionID, event.id, observation);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (EDIT_TOOL_NAMES.has(event.tool)) {
|
|
264
|
+
const snapshots = editTargets(args, toolWorktree).map(snapshotFile);
|
|
265
|
+
if (snapshots.length) toolLifecycle.setPostEditSnapshots(event.sessionID, event.id, toolWorktree, snapshots);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await ctx.tool.hook("execute.after", async (event) => {
|
|
271
|
+
const startedAt = toolLifecycle.finish(event.sessionID, event.id);
|
|
272
|
+
const outcome = toolOutcomes.recordFallbackCompleted(event.sessionID, event.id, event.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
|
|
273
|
+
if (outcome) {
|
|
274
|
+
audit({
|
|
275
|
+
ts: new Date().toISOString(),
|
|
276
|
+
sessionID: outcome.sessionID,
|
|
277
|
+
callID: outcome.callID,
|
|
278
|
+
tool: outcome.tool,
|
|
279
|
+
decision: "allow",
|
|
280
|
+
phase: "outcome",
|
|
281
|
+
durationMs: outcome.durationMs,
|
|
282
|
+
reason: outcome.status,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
releaseFileClaims(event.sessionID, event.id);
|
|
286
|
+
if (event.tool === "read") {
|
|
287
|
+
const observation = toolLifecycle.takeReadObservation(event.sessionID, event.id);
|
|
288
|
+
if (observation) recordSuccessfulRead(observation, event.sessionID);
|
|
289
|
+
}
|
|
290
|
+
if (event.status === "error") return;
|
|
291
|
+
const pending = toolLifecycle.takePostEditSnapshots(event.sessionID, event.id);
|
|
292
|
+
if (!pending) return;
|
|
293
|
+
await runWithRuntimeState(pending.root, client, async () => {
|
|
294
|
+
const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
|
|
295
|
+
const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
|
|
296
|
+
if (!report) return;
|
|
297
|
+
const result = event.result as { content?: unknown } | undefined;
|
|
298
|
+
if (result && "content" in result) {
|
|
299
|
+
if (typeof result.content === "string") {
|
|
300
|
+
result.content = `${result.content}\n\n${report}`;
|
|
301
|
+
} else if (Array.isArray(result.content)) {
|
|
302
|
+
result.content = [...result.content, { type: "text", text: report }];
|
|
303
|
+
} else {
|
|
304
|
+
result.content = report;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── Session request hooks ──
|
|
311
|
+
await ctx.session.hook("context", (event) => {
|
|
312
|
+
try {
|
|
313
|
+
const existing = Array.isArray(event.system)
|
|
314
|
+
? event.system.map((part) => (typeof part === "string" ? part : (part as { text?: string }).text ?? "")).join("\n")
|
|
315
|
+
: "";
|
|
316
|
+
if (existing.includes("## Workflow Guard & Operational Tools")) return;
|
|
317
|
+
const isReadOnly = event.agent ? isReadOnlyRole(event.agent) : false;
|
|
318
|
+
const guidance = buildWorkflowGuardSystemGuidance({
|
|
319
|
+
projectMemoryEnabled,
|
|
320
|
+
learningEnabled,
|
|
321
|
+
recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
|
|
322
|
+
isReadOnly,
|
|
323
|
+
});
|
|
324
|
+
event.system.push({ type: "text", text: guidance });
|
|
325
|
+
} catch {}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await ctx.session.hook("compaction", async (event) => {
|
|
329
|
+
try {
|
|
330
|
+
const sessionID = event.sessionID;
|
|
331
|
+
const parentID = sessionID ? await fetchParentSessionID(sessionID) : undefined;
|
|
332
|
+
const todos = await effectiveTodos(sessionID);
|
|
333
|
+
const active = todos?.filter((t) => {
|
|
334
|
+
const s = String(t.status ?? "");
|
|
335
|
+
return s === "pending" || s === "in_progress";
|
|
336
|
+
});
|
|
337
|
+
const branch = currentGitBranch(effectiveRoot) ?? "unknown";
|
|
338
|
+
const isProtected = branch !== "unknown" && isProtectedBranchName(branch, effectiveRoot);
|
|
339
|
+
const sessionMutationCount = getSessionMutationCount(sessionID);
|
|
340
|
+
// Mirror V1: zero-mutation subagent sessions inherit the parent's
|
|
341
|
+
// verification/review evidence so inherited-todo work keeps fresh gates.
|
|
342
|
+
const lastV = sessionVerifyResults.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionVerifyResults.get(parentID) : undefined);
|
|
343
|
+
const lastR = sessionReviews.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionReviews.get(parentID) : undefined);
|
|
344
|
+
const mutationCountVal = sessionMutationCount;
|
|
345
|
+
|
|
346
|
+
const priorityContextBlocks: string[] = [];
|
|
347
|
+
|
|
348
|
+
if (active && active.length > 0) {
|
|
349
|
+
const lines = active.slice(0, 20).map(
|
|
350
|
+
(t) =>
|
|
351
|
+
`- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "").slice(0, 300)}`,
|
|
352
|
+
);
|
|
353
|
+
if (active.length > 20) lines.push(`- ... ${active.length - 20} more active task(s) omitted`);
|
|
354
|
+
const attribution = parentID
|
|
355
|
+
? ` (Subagent session: ${sessionID}, Parent: ${parentID})`
|
|
356
|
+
: sessionID
|
|
357
|
+
? ` (Session: ${sessionID})`
|
|
358
|
+
: "";
|
|
359
|
+
priorityContextBlocks.push(
|
|
360
|
+
`## Active Tasks${attribution}\n` +
|
|
361
|
+
lines.join("\n") +
|
|
362
|
+
"\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const stateLines: string[] = [
|
|
367
|
+
`## Operational Guard State`,
|
|
368
|
+
`- Git Branch: ${branch}${isProtected ? " (PROTECTED BRANCH - edits/commits require feature branch)" : " (feature branch - edits allowed)"}`,
|
|
369
|
+
`- Uncommitted Mutations: ${mutationCountVal} recorded in current session`,
|
|
370
|
+
];
|
|
371
|
+
if (lastV) {
|
|
372
|
+
stateLines.push(
|
|
373
|
+
`- Test Verification: ${lastV.passed ? "PASSED" : "FAILED"} (${lastV.command})${lastV.commitHash ? ` at commit ${lastV.commitHash.slice(0, 7)}` : ""}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
if (lastR) {
|
|
377
|
+
stateLines.push(
|
|
378
|
+
`- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
let openFollowups: ReturnType<typeof listReviewFollowups> = [];
|
|
382
|
+
try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
|
|
383
|
+
if (openFollowups.length > 0) {
|
|
384
|
+
stateLines.push(`- Open Review Follow-ups: ${openFollowups.length} local P2/P3 item(s)`);
|
|
385
|
+
priorityContextBlocks.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.`);
|
|
386
|
+
}
|
|
387
|
+
const operationalState = stateLines.join("\n");
|
|
388
|
+
let projectKnowledge: ReturnType<typeof getRecentProjectMemory> = [];
|
|
389
|
+
try {
|
|
390
|
+
const candidates = (projectMemory ? getRecentProjectMemory(projectMemory, 8) : [])
|
|
391
|
+
.filter((memory) => memory.source !== "portable");
|
|
392
|
+
const freshness = await Promise.all(candidates.map((memory) => isProjectMemoryFreshAsync(memory, effectiveRoot)));
|
|
393
|
+
projectKnowledge = candidates.filter((_, index) => freshness[index]);
|
|
394
|
+
} catch {}
|
|
395
|
+
if (projectKnowledge.length > 0) {
|
|
396
|
+
const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);
|
|
397
|
+
priorityContextBlocks.push(`## Project Memory\nUse project_memory_search when deeper historical context is needed. Treat these as historical project knowledge; verify against current repository state when relevant files have changed.\n${lines.join("\n")}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const contextText = buildCompactionContext(operationalState, priorityContextBlocks);
|
|
401
|
+
if (Array.isArray(event.messages)) {
|
|
402
|
+
event.messages.push(Message.user([{ type: "text", text: contextText }]));
|
|
403
|
+
}
|
|
404
|
+
} catch {}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await ctx.session.hook("prompt", async (event) => {
|
|
408
|
+
const sessionID = event.sessionID;
|
|
409
|
+
try {
|
|
410
|
+
// V2 prompt hooks do not run for synthetic messages, but keep the V1
|
|
411
|
+
// generated-continuation guard so checkpoint creation cannot churn on
|
|
412
|
+
// continuation prompts admitted through any other path.
|
|
413
|
+
if (isRecoveryCheckpointsEnabled(effectiveRoot) && !isGeneratedContinuationMessage(sessionID, event.messageID)) {
|
|
414
|
+
const parent = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSession(sessionID));
|
|
415
|
+
if (parent.ok && !parent.parentID) {
|
|
416
|
+
const run = nextRecoveryRun(effectiveRoot, sessionID);
|
|
417
|
+
const checkpoint = createRecoveryCheckpoint(effectiveRoot, sessionID, run);
|
|
418
|
+
if (checkpoint) toolLifecycle.setRecoveryRun(sessionID, run);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
recordUserMessage(sessionID, event.messageID);
|
|
422
|
+
} catch {}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// ── Permission journaling ──
|
|
426
|
+
await ctx.permission.hook("evaluate", (event) => {
|
|
427
|
+
try {
|
|
428
|
+
audit({
|
|
429
|
+
ts: new Date().toISOString(),
|
|
430
|
+
sessionID: event.sessionID,
|
|
431
|
+
tool: "permission.ask",
|
|
432
|
+
decision: event.effect === "deny" ? "block" : "allow",
|
|
433
|
+
input: {
|
|
434
|
+
action: event.action,
|
|
435
|
+
resources: event.resources,
|
|
436
|
+
agent: event.agent,
|
|
437
|
+
effect: event.effect,
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
} catch {}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ── Shell env scrubbing ──
|
|
444
|
+
await ctx.shell.hook("create.before", (event) => {
|
|
445
|
+
try {
|
|
446
|
+
const env = event.env;
|
|
447
|
+
if (env && typeof env === "object") {
|
|
448
|
+
const scrubbed: string[] = [];
|
|
449
|
+
for (const key of Object.keys(env)) {
|
|
450
|
+
if (isSensitiveEnvKey(key) && env[key] !== "") {
|
|
451
|
+
env[key] = "";
|
|
452
|
+
scrubbed.push(key);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (scrubbed.length > 0) {
|
|
456
|
+
console.log(`[workflow-guard] warn: Scrubbed sensitive env vars: ${scrubbed.join(", ")}. Auth failures may be due to this.`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
} catch {}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ── Event stream ──
|
|
463
|
+
const controller = new AbortController();
|
|
464
|
+
void (async () => {
|
|
465
|
+
try {
|
|
466
|
+
for await (const raw of ctx.event.subscribe({ signal: controller.signal })) {
|
|
467
|
+
const event = raw as { type?: string; data?: unknown };
|
|
468
|
+
try {
|
|
469
|
+
await handleV2Event(event, {
|
|
470
|
+
effectiveRoot,
|
|
471
|
+
client,
|
|
472
|
+
toolLifecycle,
|
|
473
|
+
toolOutcomes,
|
|
474
|
+
});
|
|
475
|
+
} catch {}
|
|
476
|
+
}
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (!controller.signal.aborted) throw error;
|
|
479
|
+
}
|
|
480
|
+
})();
|
|
481
|
+
|
|
482
|
+
return () => controller.abort();
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// ── Event handling ────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
interface V2EventState {
|
|
488
|
+
effectiveRoot: string;
|
|
489
|
+
client: unknown;
|
|
490
|
+
toolLifecycle: ToolInvocationLifecycle;
|
|
491
|
+
toolOutcomes: ToolOutcomeTracker;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function handleV2Event(
|
|
495
|
+
event: { type?: string; data?: unknown },
|
|
496
|
+
state: V2EventState,
|
|
497
|
+
): Promise<void> {
|
|
498
|
+
const { effectiveRoot, client, toolLifecycle, toolOutcomes } = state;
|
|
499
|
+
|
|
500
|
+
if (event.type === "session.message.content.updated") {
|
|
501
|
+
const data = event.data as { sessionID?: unknown; content?: unknown } | undefined;
|
|
502
|
+
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
503
|
+
if (!sessionID || !Array.isArray(data?.content)) return;
|
|
504
|
+
for (const part of data.content as Array<Record<string, unknown>>) {
|
|
505
|
+
if (part?.type !== "tool") continue;
|
|
506
|
+
const toolState = part.state as { status?: unknown; error?: { message?: string } | string } | undefined;
|
|
507
|
+
const status = toolState?.status;
|
|
508
|
+
if (status !== "completed" && status !== "error") continue;
|
|
509
|
+
// V2 keeps timing on the tool part itself (part.time), not inside state.
|
|
510
|
+
const partTime = part.time as { ran?: unknown; completed?: unknown } | undefined;
|
|
511
|
+
const start = typeof partTime?.ran === "number" ? partTime.ran : undefined;
|
|
512
|
+
const end = typeof partTime?.completed === "number" ? partTime.completed : undefined;
|
|
513
|
+
const mapped: ToolOutcomePart = {
|
|
514
|
+
type: "tool",
|
|
515
|
+
sessionID,
|
|
516
|
+
callID: part.id,
|
|
517
|
+
tool: part.name,
|
|
518
|
+
state: {
|
|
519
|
+
status,
|
|
520
|
+
error: typeof toolState?.error === "string" ? toolState.error : toolState?.error?.message,
|
|
521
|
+
time: { start, end },
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
const outcome = toolOutcomes.record(mapped);
|
|
525
|
+
if (outcome) {
|
|
526
|
+
audit({
|
|
527
|
+
ts: new Date().toISOString(),
|
|
528
|
+
sessionID: outcome.sessionID,
|
|
529
|
+
callID: outcome.callID,
|
|
530
|
+
tool: outcome.tool,
|
|
531
|
+
decision: "allow",
|
|
532
|
+
phase: "outcome",
|
|
533
|
+
durationMs: outcome.durationMs,
|
|
534
|
+
reason: outcome.status,
|
|
535
|
+
});
|
|
536
|
+
if (outcome.status === "error" && outcome.repeatedFailureCount === 3) {
|
|
537
|
+
audit({
|
|
538
|
+
ts: new Date().toISOString(),
|
|
539
|
+
sessionID: outcome.sessionID,
|
|
540
|
+
callID: outcome.callID,
|
|
541
|
+
tool: outcome.tool,
|
|
542
|
+
decision: "allow",
|
|
543
|
+
phase: "event",
|
|
544
|
+
reason: "repeated-equivalent-failure:3",
|
|
545
|
+
});
|
|
546
|
+
console.log(`[workflow-guard] ${outcome.tool} failed equivalently 3 times in this session; change approach or inspect the underlying failure before retrying.`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (event.type === "session.text.ended") {
|
|
554
|
+
const data = event.data as { sessionID?: unknown; text?: unknown } | undefined;
|
|
555
|
+
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
556
|
+
const text = typeof data?.text === "string" ? data.text : "";
|
|
557
|
+
try {
|
|
558
|
+
const check = checkCompletionClaims(text, { sessionID });
|
|
559
|
+
if (check.claimsCompletion && check.evidenceState && check.evidenceState !== "fresh-pass") {
|
|
560
|
+
console.log(`[workflow-guard] completion claim mismatch: ${check.reason}`);
|
|
561
|
+
audit({
|
|
562
|
+
ts: new Date().toISOString(),
|
|
563
|
+
sessionID,
|
|
564
|
+
tool: "experimental.text.complete",
|
|
565
|
+
decision: "allow",
|
|
566
|
+
reason: `Completion claim '${check.claim}' has ${check.evidenceState} verification evidence`,
|
|
567
|
+
input: { claim: check.claim, evidenceState: check.evidenceState },
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
} catch {}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (event.type === "session.idle") {
|
|
575
|
+
const data = event.data as { sessionID?: unknown } | undefined;
|
|
576
|
+
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
577
|
+
if (typeof sessionID === "string") {
|
|
578
|
+
releaseFileClaims(sessionID);
|
|
579
|
+
clearReadFingerprints(sessionID);
|
|
580
|
+
toolLifecycle.clearSession(sessionID);
|
|
581
|
+
const recoveryRun = toolLifecycle.takeRecoveryRun(sessionID);
|
|
582
|
+
if (recoveryRun !== undefined) {
|
|
583
|
+
finalizeRecoveryCheckpoint(effectiveRoot, sessionID, recoveryRun);
|
|
584
|
+
}
|
|
585
|
+
const settleTitle = getProjectConfig(effectiveRoot).titleSettleWorkaround !== false;
|
|
586
|
+
await runWithRuntimeState(effectiveRoot, client, () => continueUnfinishedSession(sessionID, settleTitle));
|
|
587
|
+
}
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (event.type === "session.deleted") {
|
|
592
|
+
const data = event.data as { sessionID?: unknown } | undefined;
|
|
593
|
+
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
594
|
+
if (typeof sessionID === "string") {
|
|
595
|
+
releaseFileClaims(sessionID);
|
|
596
|
+
clearReadFingerprints(sessionID);
|
|
597
|
+
toolLifecycle.clearSession(sessionID);
|
|
598
|
+
toolOutcomes.clearSession(sessionID);
|
|
599
|
+
toolLifecycle.takeRecoveryRun(sessionID);
|
|
600
|
+
await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (event.type === "permission.asked" || event.type === "permission.replied") {
|
|
606
|
+
// V2 permission.replied data carries `reply` ("once" | "always" | "reject").
|
|
607
|
+
const data = event.data as { sessionID?: unknown; reply?: unknown } | undefined;
|
|
608
|
+
audit({
|
|
609
|
+
ts: new Date().toISOString(),
|
|
610
|
+
sessionID: typeof data?.sessionID === "string" ? data.sessionID : undefined,
|
|
611
|
+
tool: event.type ?? "event",
|
|
612
|
+
decision:
|
|
613
|
+
event.type === "permission.replied" && /reject|deny/i.test(String(data?.reply ?? ""))
|
|
614
|
+
? "block"
|
|
615
|
+
: "allow",
|
|
616
|
+
input: summarizeInput(event.data),
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Note on V1 events without a V2 counterpart:
|
|
621
|
+
// - `command.executed`: no V2 event stream equivalent (audit-only in V1; dropped).
|
|
622
|
+
// - `permission.updated`: replaced by `permission.asked`/`permission.replied` above.
|
|
623
|
+
// - `session.created`: exists in the V2 union but needed no V1 behavior beyond
|
|
624
|
+
// the audit above; session state resets happen on `session.deleted`.
|
|
625
|
+
}
|
package/src/lib/verify.ts
CHANGED
|
@@ -155,6 +155,17 @@ export function getGitWorktreeFingerprint(root: string): string | undefined {
|
|
|
155
155
|
return undefined;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// Verification timeout: WORKFLOW_GUARD_VERIFY_TIMEOUT_MS env wins, then the
|
|
159
|
+
// project config verifyTimeoutMs, then the 30s default. The 30s default is
|
|
160
|
+
// too short for large suites (this repository's own suite exceeds it), so
|
|
161
|
+
// projects can raise it without changing the verify command.
|
|
162
|
+
export function resolveVerifyTimeoutMs(root: string): number {
|
|
163
|
+
const envValue = Number(process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS);
|
|
164
|
+
if (Number.isFinite(envValue) && envValue > 0) return envValue;
|
|
165
|
+
const configured = getProjectConfig(root).verifyTimeoutMs;
|
|
166
|
+
return typeof configured === "number" && Number.isFinite(configured) && configured > 0 ? configured : 30_000;
|
|
167
|
+
}
|
|
168
|
+
|
|
158
169
|
export async function runVerify(
|
|
159
170
|
command: string,
|
|
160
171
|
root: string,
|
package/src/policies/boundary.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
recordMutation,
|
|
9
9
|
} from "../lib/state.ts";
|
|
10
10
|
import { shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
11
|
-
import { isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
|
|
11
|
+
import { isCollaborationInvocation, isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
|
|
12
12
|
import { isSecretPath, secretIn } from "./secrets.ts";
|
|
13
13
|
import { onProtectedBranch, branchGuardReason } from "./git.ts";
|
|
14
14
|
import {
|
|
@@ -303,8 +303,17 @@ export function secretSourceInFilesystemCommand(segment: string): string | undef
|
|
|
303
303
|
return undefined;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// Remove single- and double-quoted spans from a shell segment. Used to
|
|
307
|
+
// analyze the residue of collaboration invocations: quoted arguments are
|
|
308
|
+
// command data and can never be shell redirects, while unquoted redirects
|
|
309
|
+
// keep receiving full validation.
|
|
310
|
+
function stripQuotedSpans(segment: string): string {
|
|
311
|
+
return segment.replace(/'[^'\n]*'/g, " ").replace(/"[^"\n]*"/g, " ");
|
|
312
|
+
}
|
|
313
|
+
|
|
306
314
|
export function detectShellMutation(command: string): ShellMutation | undefined {
|
|
307
|
-
for (const
|
|
315
|
+
for (const rawSegment of command.split(/[\n|;&]+/)) {
|
|
316
|
+
const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
|
|
308
317
|
const simpleMutations = simpleFilesystemMutations(segment);
|
|
309
318
|
if (simpleMutations.length > 0) return simpleMutations[0];
|
|
310
319
|
const teeTargets = teeTargetsIn(segment);
|
|
@@ -328,7 +337,11 @@ export async function guardShellMutation(
|
|
|
328
337
|
): Promise<string | undefined> {
|
|
329
338
|
const root = getWorkspaceRoot();
|
|
330
339
|
let hasMutation = false;
|
|
331
|
-
for (const
|
|
340
|
+
for (const rawSegment of command.split(/[\n|;&]+/)) {
|
|
341
|
+
// Same residue analysis as detectShellMutation: quoted arguments of
|
|
342
|
+
// gh/glab/az PR/issue commands are command data, not shell redirects,
|
|
343
|
+
// while unquoted redirects still get full validation below.
|
|
344
|
+
const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
|
|
332
345
|
const secretSource = secretSourceInFilesystemCommand(segment);
|
|
333
346
|
if (secretSource) {
|
|
334
347
|
return `Blocked: shell command would copy, move, or link sensitive file '${secretSource}' under a non-secret name.`;
|