opencode-workflow-guard 1.12.1 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,649 @@
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
+ * V2 tool parts carry the tool name in `tool` (the V1 field was `name`); ACP
71
+ * agents may still report either shape, so accept both.
72
+ */
73
+ function toolPartName(part: { tool?: unknown; name?: unknown }): string | undefined {
74
+ if (typeof part.tool === "string") return part.tool;
75
+ if (typeof part.name === "string") return part.name;
76
+ return undefined;
77
+ }
78
+
79
+ /**
80
+ * Reconstruct the current todo list for a session from the session message
81
+ * history (V2 exposes no dedicated todo endpoint). The newest applied
82
+ * `todowrite` tool call's input is the current state.
83
+ */
84
+ async function fetchTodosFromContext(ctx: V2Context, sessionID: string): Promise<TodoItem[] | undefined> {
85
+ try {
86
+ const messages = (await ctx.session.context({ sessionID })) as unknown as Array<{ content?: unknown }>;
87
+ for (let i = messages.length - 1; i >= 0; i--) {
88
+ const content = (messages[i] as { content?: unknown })?.content;
89
+ if (!Array.isArray(content)) continue;
90
+ for (let j = content.length - 1; j >= 0; j--) {
91
+ const part = content[j] as { type?: string; tool?: unknown; name?: unknown; state?: { status?: string; input?: { todos?: unknown } } };
92
+ if (part?.type !== "tool") continue;
93
+ const toolName = toolPartName(part);
94
+ if (toolName === undefined) continue;
95
+ if (!/^todowrite$/i.test(toolName)) continue;
96
+ const status = part.state?.status;
97
+ if (status === "pending" || status === "streaming" || status === "running") continue;
98
+ const todos = part.state?.input?.todos;
99
+ if (Array.isArray(todos)) return todos as TodoItem[];
100
+ }
101
+ }
102
+ return [];
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
109
+ // V2 has no server-side log API or TUI surface, so app.log degrades to
110
+ // console logging and showBlockToast becomes a no-op. Block reasons still
111
+ // reach the agent through the thrown tool-hook error; keep this adapter
112
+ // from logging anything beyond guard decision summaries.
113
+ return {
114
+ app: {
115
+ log: async ({ body }) => {
116
+ try {
117
+ console.log(`[${body.service}] ${body.level}: ${body.message}`);
118
+ } catch {}
119
+ },
120
+ },
121
+ session: {
122
+ todo: async ({ path }) => ({ data: await fetchTodosFromContext(ctx, path.id) }),
123
+ get: async ({ path }) => {
124
+ const session = await ctx.session.get({ sessionID: path.id });
125
+ return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
126
+ },
127
+ promptAsync: async (opts) => {
128
+ for (const part of opts.body.parts) {
129
+ if (part.synthetic) {
130
+ await ctx.session.synthetic({ sessionID: opts.path.id, text: part.text });
131
+ } else {
132
+ await ctx.session.prompt({ sessionID: opts.path.id, text: part.text });
133
+ }
134
+ }
135
+ },
136
+ },
137
+ lsp: {},
138
+ };
139
+ }
140
+
141
+ // ── Builtin tool description enrichment ───────────────────────────────────────
142
+
143
+ const TODOWRITE_SUFFIX =
144
+ "\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.";
145
+
146
+ const EDIT_SUFFIX =
147
+ "\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.";
148
+
149
+ const SUBAGENT_SUFFIX =
150
+ "\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.";
151
+
152
+ // Builtin tool description enrichment. Candidates are matched against the
153
+ // host's tool registry; `ToolEditor.update` ignores missing IDs, so extra
154
+ // candidates are harmless no-ops.
155
+ //
156
+ // V2.0.10 builtin surface (docs + binary): read, glob, grep, edit, write,
157
+ // patch, shell, webfetch, websearch, question, skill, subagent, execute.
158
+ // - There is no builtin `todowrite` (the V1 name). Todowrite tool parts seen
159
+ // in V2 sessions come from ACP agents that supply their own tools; those
160
+ // are not in the server registry, so this enrichment cannot reach them.
161
+ // The candidate is kept for hosts that do register a todowrite tool.
162
+ // - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
163
+ // candidates for the same reason.
164
+ const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
165
+ { candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker: "verification evidence" },
166
+ { candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker: "Workflow Guard requirement" },
167
+ { candidates: ["task", "subagent"], suffix: SUBAGENT_SUFFIX, marker: "Workflow Guard subagent guidance" },
168
+ ];
169
+
170
+ // ── Setup ─────────────────────────────────────────────────────────────────────
171
+
172
+ export const WorkflowGuardV2 = async (ctx: V2Context) => {
173
+ const hostRoot = ctx.location?.directory || process.cwd();
174
+ const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
175
+ const projectDirectory = (ctx.location as { project?: { directory?: string; canonical?: string } })?.project?.directory;
176
+ let effectiveRoot = hostIsFilesystemRoot && projectDirectory && resolve(projectDirectory) !== resolve(projectDirectory, "..")
177
+ ? projectDirectory
178
+ : hostRoot;
179
+ if (resolve(effectiveRoot) === resolve(effectiveRoot, "..")) {
180
+ const nonRootCandidate = [ctx.location?.directory, process.cwd()].find((p) => typeof p === "string" && resolve(p) !== resolve(p, ".."));
181
+ if (nonRootCandidate) {
182
+ const gitRoot = findGitRoot(nonRootCandidate);
183
+ effectiveRoot = gitRoot ?? nonRootCandidate;
184
+ }
185
+ }
186
+
187
+ const client = createV2SdkClient(ctx);
188
+ setWorkspaceRoot(effectiveRoot);
189
+ setSdkClient(client);
190
+ reloadProjectConfig(effectiveRoot);
191
+
192
+ const learningEnabled = isLearningEnabled(effectiveRoot);
193
+ const projectMemoryEnabled = isProjectMemoryEnabled(effectiveRoot);
194
+ const learningInterventions = new Map<string, number>();
195
+ const portableMemoryPath = join(effectiveRoot, ".opencode", "memory", "project-memory.jsonl");
196
+ let followupStore: ReturnType<typeof openProjectMemory> | undefined;
197
+ let projectMemory: ReturnType<typeof openProjectMemory> | undefined;
198
+ try {
199
+ followupStore = openProjectMemory(getProjectMemoryIdentity(effectiveRoot));
200
+ } catch {}
201
+ try {
202
+ if (!projectMemoryEnabled) throw new Error("Project memory disabled");
203
+ projectMemory = openProjectMemory(getProjectMemoryIdentity(effectiveRoot));
204
+ ensureProjectMemoryExcluded(effectiveRoot);
205
+ importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined);
206
+ } catch {
207
+ try { projectMemory?.close(); } catch {}
208
+ projectMemory = undefined;
209
+ }
210
+
211
+ try {
212
+ console.log(`[workflow-guard] info: Workflow Guard plugin initialized for ${effectiveRoot}; provenance is plugin-local in V2`);
213
+ } catch {}
214
+
215
+ const toolLifecycle = new ToolInvocationLifecycle();
216
+ const toolOutcomes = new ToolOutcomeTracker();
217
+
218
+ // ── Custom tools + builtin description enrichment ──
219
+ const customTools = createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client });
220
+ await ctx.tool.transform((editor) => {
221
+ for (const [name, definition] of Object.entries(customTools)) {
222
+ editor.add({
223
+ name,
224
+ description: definition.description,
225
+ // definition.args is a V1 zod raw shape (plain object of zod fields);
226
+ // wrap it so the V2 registry receives a real StandardSchemaV1.
227
+ input: z.object(definition.args as unknown as z.ZodRawShape),
228
+ execute: async (rawInput: unknown, toolContext: { sessionID: string; id: string }) => {
229
+ const result = await (definition.execute as (input: unknown, context: unknown) => Promise<unknown>)(rawInput, {
230
+ sessionID: toolContext.sessionID,
231
+ worktree: getSessionWorkspace(toolContext.sessionID) ?? effectiveRoot,
232
+ directory: effectiveRoot,
233
+ });
234
+ const text = typeof result === "string" ? result : (result as { output?: string } | undefined)?.output;
235
+ return {
236
+ content: typeof text === "string" ? text : JSON.stringify(result),
237
+ };
238
+ },
239
+ } as never);
240
+ }
241
+ for (const target of ENRICHMENT_TARGETS) {
242
+ for (const id of target.candidates) {
243
+ editor.update(id, (tool) => {
244
+ const description = typeof tool.description === "string" ? tool.description : "";
245
+ if (description.includes(target.marker)) return;
246
+ tool.description = description + target.suffix;
247
+ });
248
+ }
249
+ }
250
+ });
251
+
252
+ // ── Deterministic enforcement ──
253
+ await ctx.tool.hook("execute.before", (event) => {
254
+ const toolWorktree = getSessionWorkspace(event.sessionID) || effectiveRoot;
255
+ return runWithRuntimeState(toolWorktree, client, async () => {
256
+ const args = event.input;
257
+ const reason = await guardToolCall(event.tool, args, {
258
+ sessionID: event.sessionID,
259
+ callID: event.id,
260
+ worktree: toolWorktree,
261
+ agent: event.agent,
262
+ });
263
+ if (reason !== undefined) {
264
+ await showBlockToast(reason);
265
+ const failureCount = event.sessionID ? toolOutcomes.getFailureCount(event.sessionID) : 0;
266
+ const circuitBreakerSuffix = failureCount >= 2
267
+ ? "\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.]"
268
+ : "";
269
+ throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
270
+ }
271
+ toolLifecycle.start(event.sessionID, event.id);
272
+ const record = asRecord(args);
273
+ const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
274
+ const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
275
+ const candidatePath = workdir ?? filePath ?? toolWorktree;
276
+ if (candidatePath) {
277
+ const gitRoot = findGitRoot(candidatePath);
278
+ if (gitRoot) setSessionWorkspace(event.sessionID, gitRoot);
279
+ }
280
+ if (event.tool === "read") {
281
+ const target = editTargets(args, toolWorktree)[0];
282
+ if (target) {
283
+ const observation = beginReadObservation(target);
284
+ if (observation) toolLifecycle.setReadObservation(event.sessionID, event.id, observation);
285
+ }
286
+ }
287
+ if (EDIT_TOOL_NAMES.has(event.tool)) {
288
+ const snapshots = editTargets(args, toolWorktree).map(snapshotFile);
289
+ if (snapshots.length) toolLifecycle.setPostEditSnapshots(event.sessionID, event.id, toolWorktree, snapshots);
290
+ }
291
+ });
292
+ });
293
+
294
+ await ctx.tool.hook("execute.after", async (event) => {
295
+ const startedAt = toolLifecycle.finish(event.sessionID, event.id);
296
+ const outcome = toolOutcomes.recordFallbackCompleted(event.sessionID, event.id, event.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
297
+ if (outcome) {
298
+ audit({
299
+ ts: new Date().toISOString(),
300
+ sessionID: outcome.sessionID,
301
+ callID: outcome.callID,
302
+ tool: outcome.tool,
303
+ decision: "allow",
304
+ phase: "outcome",
305
+ durationMs: outcome.durationMs,
306
+ reason: outcome.status,
307
+ });
308
+ }
309
+ releaseFileClaims(event.sessionID, event.id);
310
+ if (event.tool === "read") {
311
+ const observation = toolLifecycle.takeReadObservation(event.sessionID, event.id);
312
+ if (observation) recordSuccessfulRead(observation, event.sessionID);
313
+ }
314
+ if (event.status === "error") return;
315
+ const pending = toolLifecycle.takePostEditSnapshots(event.sessionID, event.id);
316
+ if (!pending) return;
317
+ await runWithRuntimeState(pending.root, client, async () => {
318
+ const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
319
+ const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
320
+ if (!report) return;
321
+ const result = event.result as { content?: unknown } | undefined;
322
+ if (result && "content" in result) {
323
+ if (typeof result.content === "string") {
324
+ result.content = `${result.content}\n\n${report}`;
325
+ } else if (Array.isArray(result.content)) {
326
+ result.content = [...result.content, { type: "text", text: report }];
327
+ } else {
328
+ result.content = report;
329
+ }
330
+ }
331
+ });
332
+ });
333
+
334
+ // ── Session request hooks ──
335
+ await ctx.session.hook("context", (event) => {
336
+ try {
337
+ const existing = Array.isArray(event.system)
338
+ ? event.system.map((part) => (typeof part === "string" ? part : (part as { text?: string }).text ?? "")).join("\n")
339
+ : "";
340
+ if (existing.includes("## Workflow Guard & Operational Tools")) return;
341
+ const isReadOnly = event.agent ? isReadOnlyRole(event.agent) : false;
342
+ const guidance = buildWorkflowGuardSystemGuidance({
343
+ projectMemoryEnabled,
344
+ learningEnabled,
345
+ recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
346
+ isReadOnly,
347
+ });
348
+ event.system.push({ type: "text", text: guidance });
349
+ } catch {}
350
+ });
351
+
352
+ await ctx.session.hook("compaction", async (event) => {
353
+ try {
354
+ const sessionID = event.sessionID;
355
+ const parentID = sessionID ? await fetchParentSessionID(sessionID) : undefined;
356
+ const todos = await effectiveTodos(sessionID);
357
+ const active = todos?.filter((t) => {
358
+ const s = String(t.status ?? "");
359
+ return s === "pending" || s === "in_progress";
360
+ });
361
+ const branch = currentGitBranch(effectiveRoot) ?? "unknown";
362
+ const isProtected = branch !== "unknown" && isProtectedBranchName(branch, effectiveRoot);
363
+ const sessionMutationCount = getSessionMutationCount(sessionID);
364
+ // Mirror V1: zero-mutation subagent sessions inherit the parent's
365
+ // verification/review evidence so inherited-todo work keeps fresh gates.
366
+ const lastV = sessionVerifyResults.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionVerifyResults.get(parentID) : undefined);
367
+ const lastR = sessionReviews.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionReviews.get(parentID) : undefined);
368
+ const mutationCountVal = sessionMutationCount;
369
+
370
+ const priorityContextBlocks: string[] = [];
371
+
372
+ if (active && active.length > 0) {
373
+ const lines = active.slice(0, 20).map(
374
+ (t) =>
375
+ `- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "").slice(0, 300)}`,
376
+ );
377
+ if (active.length > 20) lines.push(`- ... ${active.length - 20} more active task(s) omitted`);
378
+ const attribution = parentID
379
+ ? ` (Subagent session: ${sessionID}, Parent: ${parentID})`
380
+ : sessionID
381
+ ? ` (Session: ${sessionID})`
382
+ : "";
383
+ priorityContextBlocks.push(
384
+ `## Active Tasks${attribution}\n` +
385
+ lines.join("\n") +
386
+ "\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
387
+ );
388
+ }
389
+
390
+ const stateLines: string[] = [
391
+ `## Operational Guard State`,
392
+ `- Git Branch: ${branch}${isProtected ? " (PROTECTED BRANCH - edits/commits require feature branch)" : " (feature branch - edits allowed)"}`,
393
+ `- Uncommitted Mutations: ${mutationCountVal} recorded in current session`,
394
+ ];
395
+ if (lastV) {
396
+ stateLines.push(
397
+ `- Test Verification: ${lastV.passed ? "PASSED" : "FAILED"} (${lastV.command})${lastV.commitHash ? ` at commit ${lastV.commitHash.slice(0, 7)}` : ""}`,
398
+ );
399
+ }
400
+ if (lastR) {
401
+ stateLines.push(
402
+ `- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
403
+ );
404
+ }
405
+ let openFollowups: ReturnType<typeof listReviewFollowups> = [];
406
+ try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
407
+ if (openFollowups.length > 0) {
408
+ stateLines.push(`- Open Review Follow-ups: ${openFollowups.length} local P2/P3 item(s)`);
409
+ 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.`);
410
+ }
411
+ const operationalState = stateLines.join("\n");
412
+ let projectKnowledge: ReturnType<typeof getRecentProjectMemory> = [];
413
+ try {
414
+ const candidates = (projectMemory ? getRecentProjectMemory(projectMemory, 8) : [])
415
+ .filter((memory) => memory.source !== "portable");
416
+ const freshness = await Promise.all(candidates.map((memory) => isProjectMemoryFreshAsync(memory, effectiveRoot)));
417
+ projectKnowledge = candidates.filter((_, index) => freshness[index]);
418
+ } catch {}
419
+ if (projectKnowledge.length > 0) {
420
+ const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);
421
+ 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")}`);
422
+ }
423
+
424
+ const contextText = buildCompactionContext(operationalState, priorityContextBlocks);
425
+ if (Array.isArray(event.messages)) {
426
+ event.messages.push(Message.user([{ type: "text", text: contextText }]));
427
+ }
428
+ } catch {}
429
+ });
430
+
431
+ await ctx.session.hook("prompt", async (event) => {
432
+ const sessionID = event.sessionID;
433
+ try {
434
+ // V2 prompt hooks do not run for synthetic messages, but keep the V1
435
+ // generated-continuation guard so checkpoint creation cannot churn on
436
+ // continuation prompts admitted through any other path.
437
+ if (isRecoveryCheckpointsEnabled(effectiveRoot) && !isGeneratedContinuationMessage(sessionID, event.messageID)) {
438
+ const parent = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSession(sessionID));
439
+ if (parent.ok && !parent.parentID) {
440
+ const run = nextRecoveryRun(effectiveRoot, sessionID);
441
+ const checkpoint = createRecoveryCheckpoint(effectiveRoot, sessionID, run);
442
+ if (checkpoint) toolLifecycle.setRecoveryRun(sessionID, run);
443
+ }
444
+ }
445
+ recordUserMessage(sessionID, event.messageID);
446
+ } catch {}
447
+ });
448
+
449
+ // ── Permission journaling ──
450
+ await ctx.permission.hook("evaluate", (event) => {
451
+ try {
452
+ audit({
453
+ ts: new Date().toISOString(),
454
+ sessionID: event.sessionID,
455
+ tool: "permission.ask",
456
+ decision: event.effect === "deny" ? "block" : "allow",
457
+ input: {
458
+ action: event.action,
459
+ resources: event.resources,
460
+ agent: event.agent,
461
+ effect: event.effect,
462
+ },
463
+ });
464
+ } catch {}
465
+ });
466
+
467
+ // ── Shell env scrubbing ──
468
+ await ctx.shell.hook("create.before", (event) => {
469
+ try {
470
+ const env = event.env;
471
+ if (env && typeof env === "object") {
472
+ const scrubbed: string[] = [];
473
+ for (const key of Object.keys(env)) {
474
+ if (isSensitiveEnvKey(key) && env[key] !== "") {
475
+ env[key] = "";
476
+ scrubbed.push(key);
477
+ }
478
+ }
479
+ if (scrubbed.length > 0) {
480
+ console.log(`[workflow-guard] warn: Scrubbed sensitive env vars: ${scrubbed.join(", ")}. Auth failures may be due to this.`);
481
+ }
482
+ }
483
+ } catch {}
484
+ });
485
+
486
+ // ── Event stream ──
487
+ const controller = new AbortController();
488
+ void (async () => {
489
+ try {
490
+ for await (const raw of ctx.event.subscribe({ signal: controller.signal })) {
491
+ const event = raw as { type?: string; data?: unknown };
492
+ try {
493
+ await handleV2Event(event, {
494
+ effectiveRoot,
495
+ client,
496
+ toolLifecycle,
497
+ toolOutcomes,
498
+ });
499
+ } catch {}
500
+ }
501
+ } catch (error) {
502
+ if (!controller.signal.aborted) throw error;
503
+ }
504
+ })();
505
+
506
+ return () => controller.abort();
507
+ };
508
+
509
+ // ── Event handling ────────────────────────────────────────────────────────────
510
+
511
+ interface V2EventState {
512
+ effectiveRoot: string;
513
+ client: unknown;
514
+ toolLifecycle: ToolInvocationLifecycle;
515
+ toolOutcomes: ToolOutcomeTracker;
516
+ }
517
+
518
+ async function handleV2Event(
519
+ event: { type?: string; data?: unknown },
520
+ state: V2EventState,
521
+ ): Promise<void> {
522
+ const { effectiveRoot, client, toolLifecycle, toolOutcomes } = state;
523
+
524
+ if (event.type === "session.message.content.updated") {
525
+ const data = event.data as { sessionID?: unknown; content?: unknown } | undefined;
526
+ const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
527
+ if (!sessionID || !Array.isArray(data?.content)) return;
528
+ for (const part of data.content as Array<Record<string, unknown>>) {
529
+ if (part?.type !== "tool") continue;
530
+ const toolState = part.state as { status?: unknown; error?: { message?: string } | string } | undefined;
531
+ const status = toolState?.status;
532
+ if (status !== "completed" && status !== "error") continue;
533
+ // V2 keeps timing on the tool part itself (part.time), not inside state.
534
+ const partTime = part.time as { ran?: unknown; completed?: unknown } | undefined;
535
+ const start = typeof partTime?.ran === "number" ? partTime.ran : undefined;
536
+ const end = typeof partTime?.completed === "number" ? partTime.completed : undefined;
537
+ const mapped: ToolOutcomePart = {
538
+ type: "tool",
539
+ sessionID,
540
+ callID: part.id,
541
+ tool: toolPartName(part),
542
+ state: {
543
+ status,
544
+ error: typeof toolState?.error === "string" ? toolState.error : toolState?.error?.message,
545
+ time: { start, end },
546
+ },
547
+ };
548
+ const outcome = toolOutcomes.record(mapped);
549
+ if (outcome) {
550
+ audit({
551
+ ts: new Date().toISOString(),
552
+ sessionID: outcome.sessionID,
553
+ callID: outcome.callID,
554
+ tool: outcome.tool,
555
+ decision: "allow",
556
+ phase: "outcome",
557
+ durationMs: outcome.durationMs,
558
+ reason: outcome.status,
559
+ });
560
+ if (outcome.status === "error" && outcome.repeatedFailureCount === 3) {
561
+ audit({
562
+ ts: new Date().toISOString(),
563
+ sessionID: outcome.sessionID,
564
+ callID: outcome.callID,
565
+ tool: outcome.tool,
566
+ decision: "allow",
567
+ phase: "event",
568
+ reason: "repeated-equivalent-failure:3",
569
+ });
570
+ console.log(`[workflow-guard] ${outcome.tool} failed equivalently 3 times in this session; change approach or inspect the underlying failure before retrying.`);
571
+ }
572
+ }
573
+ }
574
+ return;
575
+ }
576
+
577
+ if (event.type === "session.text.ended") {
578
+ const data = event.data as { sessionID?: unknown; text?: unknown } | undefined;
579
+ const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
580
+ const text = typeof data?.text === "string" ? data.text : "";
581
+ try {
582
+ const check = checkCompletionClaims(text, { sessionID });
583
+ if (check.claimsCompletion && check.evidenceState && check.evidenceState !== "fresh-pass") {
584
+ console.log(`[workflow-guard] completion claim mismatch: ${check.reason}`);
585
+ audit({
586
+ ts: new Date().toISOString(),
587
+ sessionID,
588
+ tool: "experimental.text.complete",
589
+ decision: "allow",
590
+ reason: `Completion claim '${check.claim}' has ${check.evidenceState} verification evidence`,
591
+ input: { claim: check.claim, evidenceState: check.evidenceState },
592
+ });
593
+ }
594
+ } catch {}
595
+ return;
596
+ }
597
+
598
+ if (event.type === "session.idle") {
599
+ const data = event.data as { sessionID?: unknown } | undefined;
600
+ const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
601
+ if (typeof sessionID === "string") {
602
+ releaseFileClaims(sessionID);
603
+ clearReadFingerprints(sessionID);
604
+ toolLifecycle.clearSession(sessionID);
605
+ const recoveryRun = toolLifecycle.takeRecoveryRun(sessionID);
606
+ if (recoveryRun !== undefined) {
607
+ finalizeRecoveryCheckpoint(effectiveRoot, sessionID, recoveryRun);
608
+ }
609
+ const settleTitle = getProjectConfig(effectiveRoot).titleSettleWorkaround !== false;
610
+ await runWithRuntimeState(effectiveRoot, client, () => continueUnfinishedSession(sessionID, settleTitle));
611
+ }
612
+ return;
613
+ }
614
+
615
+ if (event.type === "session.deleted") {
616
+ const data = event.data as { sessionID?: unknown } | undefined;
617
+ const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
618
+ if (typeof sessionID === "string") {
619
+ releaseFileClaims(sessionID);
620
+ clearReadFingerprints(sessionID);
621
+ toolLifecycle.clearSession(sessionID);
622
+ toolOutcomes.clearSession(sessionID);
623
+ toolLifecycle.takeRecoveryRun(sessionID);
624
+ await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
625
+ }
626
+ return;
627
+ }
628
+
629
+ if (event.type === "permission.asked" || event.type === "permission.replied") {
630
+ // V2 permission.replied data carries `reply` ("once" | "always" | "reject").
631
+ const data = event.data as { sessionID?: unknown; reply?: unknown } | undefined;
632
+ audit({
633
+ ts: new Date().toISOString(),
634
+ sessionID: typeof data?.sessionID === "string" ? data.sessionID : undefined,
635
+ tool: event.type ?? "event",
636
+ decision:
637
+ event.type === "permission.replied" && /reject|deny/i.test(String(data?.reply ?? ""))
638
+ ? "block"
639
+ : "allow",
640
+ input: summarizeInput(event.data),
641
+ });
642
+ return;
643
+ }
644
+ // Note on V1 events without a V2 counterpart:
645
+ // - `command.executed`: no V2 event stream equivalent (audit-only in V1; dropped).
646
+ // - `permission.updated`: replaced by `permission.asked`/`permission.replied` above.
647
+ // - `session.created`: exists in the V2 union but needed no V1 behavior beyond
648
+ // the audit above; session state resets happen on `session.deleted`.
649
+ }
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,