opencode-workflow-guard 1.19.0 → 1.19.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.
- package/README.md +1 -1
- package/docs/policies.md +21 -10
- package/docs/testing.md +3 -1
- package/package.json +1 -1
- package/src/lib/guard-api.ts +85 -0
- package/src/lib/guard-dispatcher.ts +104 -13
- package/src/lib/permission-seat.ts +82 -2
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +80 -1
- package/src/lib/tool-descriptions.ts +36 -0
- package/src/lib/types.ts +14 -1
- package/src/lib/v2-plugin.ts +213 -133
- package/src/policies/boundary.ts +307 -24
- package/src/policies/continuation.ts +41 -11
- package/src/policies/git.ts +144 -22
- package/src/policies/interpreter.ts +93 -16
- package/src/workflow-guard.ts +44 -82
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +922 -20
package/src/lib/state.ts
CHANGED
|
@@ -81,6 +81,10 @@ export function clearTodoSessionState(sessionID: string): void {
|
|
|
81
81
|
// instance must never outrank the call's own context or the host fallback.
|
|
82
82
|
const sessionWorkspaceBound = new Set<string>();
|
|
83
83
|
|
|
84
|
+
// Last directory the HOST named per session, so a repeated `session.updated`
|
|
85
|
+
// costs one string comparison instead of a `git rev-parse` child process.
|
|
86
|
+
const sessionHostDirectories = new Map<string, string>();
|
|
87
|
+
|
|
84
88
|
export function setSessionWorkspace(sessionID: string, workspace: string): void {
|
|
85
89
|
if (!sessionID || !workspace) return;
|
|
86
90
|
const gitRoot = findGitRoot(workspace);
|
|
@@ -88,6 +92,18 @@ export function setSessionWorkspace(sessionID: string, workspace: string): void
|
|
|
88
92
|
sessionWorkspaceBound.add(sessionID);
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Drop a deleted session's workspace binding and host-directory cache entry.
|
|
97
|
+
*
|
|
98
|
+
* Both are keyed by session id, so a deleted session can never read them again.
|
|
99
|
+
* Leaving them would grow both maps for the life of a long-lived server.
|
|
100
|
+
*/
|
|
101
|
+
export function forgetSessionWorkspace(sessionID: string): void {
|
|
102
|
+
sessionWorkspaces.delete(sessionID);
|
|
103
|
+
sessionWorkspaceBound.delete(sessionID);
|
|
104
|
+
sessionHostDirectories.delete(sessionID);
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
export function getSessionWorkspace(sessionID?: string): string | undefined {
|
|
92
108
|
if (!sessionID) return undefined;
|
|
93
109
|
return sessionWorkspaces.get(sessionID);
|
|
@@ -236,11 +252,74 @@ function auditWorkspaceRebind(sessionID: string, from: string | undefined, to: s
|
|
|
236
252
|
tool: "workspace.binding",
|
|
237
253
|
decision: "allow",
|
|
238
254
|
phase: "event",
|
|
239
|
-
reason: "workspace_rebind_host_worktree",
|
|
255
|
+
reason: source === "session-move" ? "workspace_rebind_session_move" : "workspace_rebind_host_worktree",
|
|
240
256
|
input: { from, to, source },
|
|
241
257
|
});
|
|
242
258
|
}
|
|
243
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Rebind a session to the workspace the HOST says it now occupies.
|
|
262
|
+
*
|
|
263
|
+
* `session_move` (and the equivalent host move command) relocates a session
|
|
264
|
+
* between checkouts. Without this the binding stayed on the old checkout, so
|
|
265
|
+
* every later boundary, branch, and PR decision judged the tree the session had
|
|
266
|
+
* left: the move looked like it had no effect. A host move is an explicit
|
|
267
|
+
* boundary statement from the host, in the same class as a per-call worktree,
|
|
268
|
+
* so it may widen an already-bound session. It is journaled for that reason.
|
|
269
|
+
*
|
|
270
|
+
* The hard part is telling a move from a routine update. `session.updated` is a
|
|
271
|
+
* hot event, emitted for title and cost changes, and it names the session's
|
|
272
|
+
* creation directory. A session bound to a LINKED WORKTREE by its own tool-call
|
|
273
|
+
* `workdir` legitimately differs from that directory, so treating every update
|
|
274
|
+
* as a move pulled the session back to the checkout it had deliberately left.
|
|
275
|
+
* Three signals therefore count, and nothing else does:
|
|
276
|
+
*
|
|
277
|
+
* - The host announced the relocation itself (`moved: true`, from
|
|
278
|
+
* `session.moved`).
|
|
279
|
+
* - The directory the host names now differs from the one it named last for
|
|
280
|
+
* this session.
|
|
281
|
+
* - The named repository is a DIFFERENT repository from the bound workspace. A
|
|
282
|
+
* linked worktree is the same repository, so this catches the cross-repository
|
|
283
|
+
* move that a first observation would otherwise miss on OpenCode 1.x, which
|
|
284
|
+
* has no move event.
|
|
285
|
+
*
|
|
286
|
+
* The last directory is remembered so a repeated update costs one string
|
|
287
|
+
* comparison instead of a `git rev-parse` child process. The cache stores a
|
|
288
|
+
* directory that actually resolved to a repository root: an unresolved
|
|
289
|
+
* directory is not cached, so a later event for the same path retries instead
|
|
290
|
+
* of being suppressed forever.
|
|
291
|
+
*/
|
|
292
|
+
export function rebindSessionWorkspaceFromHost(
|
|
293
|
+
sessionID: string | undefined,
|
|
294
|
+
directory: string | undefined,
|
|
295
|
+
options: { moved?: boolean } = {},
|
|
296
|
+
): void {
|
|
297
|
+
if (!sessionID || !directory) return;
|
|
298
|
+
const gitRoot = findGitRoot(directory);
|
|
299
|
+
if (!gitRoot) return;
|
|
300
|
+
const previousHostDirectory = sessionHostDirectories.get(sessionID);
|
|
301
|
+
sessionHostDirectories.set(sessionID, directory);
|
|
302
|
+
const previous = sessionWorkspaces.get(sessionID);
|
|
303
|
+
const moved =
|
|
304
|
+
options.moved === true
|
|
305
|
+
|| (previousHostDirectory !== undefined && previousHostDirectory !== directory)
|
|
306
|
+
|| (previous !== undefined && gitRoot !== previous && !isSameGitRepo(gitRoot, previous));
|
|
307
|
+
if (!moved) {
|
|
308
|
+
if (sessionWorkspaceBound.has(sessionID)) return;
|
|
309
|
+
// A bootstrap entry that was never bound still needs binding: the host
|
|
310
|
+
// just named the session's real directory.
|
|
311
|
+
sessionWorkspaces.set(sessionID, gitRoot);
|
|
312
|
+
sessionWorkspaceBound.add(sessionID);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (previous === gitRoot) {
|
|
316
|
+
sessionWorkspaceBound.add(sessionID);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
setSessionWorkspace(sessionID, gitRoot);
|
|
320
|
+
auditWorkspaceRebind(sessionID, previous, gitRoot, "session-move");
|
|
321
|
+
}
|
|
322
|
+
|
|
244
323
|
/**
|
|
245
324
|
* Durable record of a refused widening. The refusal itself is the boundary
|
|
246
325
|
* policy's decision; this entry exists so an operator can tell whether a
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the builtin tool-description suffixes.
|
|
3
|
+
*
|
|
4
|
+
* Both entrypoints append the same text: the V1 `tool.definition` hook in
|
|
5
|
+
* `workflow-guard.ts` and the V2 `ctx.tool.transform` enrichment in
|
|
6
|
+
* `lib/v2-plugin.ts`. Keeping one copy prevents the two hosts from drifting
|
|
7
|
+
* apart, which would make a model's expectations depend on which host it runs
|
|
8
|
+
* on.
|
|
9
|
+
*
|
|
10
|
+
* Each suffix carries a marker. The marker is the idempotence key: a
|
|
11
|
+
* description already containing it is left untouched, so a second plugin
|
|
12
|
+
* instance (or a re-applied transform) cannot append the text twice.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const TODOWRITE_MARKER = "verification evidence";
|
|
16
|
+
export const TODOWRITE_SUFFIX =
|
|
17
|
+
"\n\nWorkflow Guard lifecycle: use todowrite to plan before working - it is the only view of progress the operator has, and every file mutation is gated on it. Each call replaces the complete task list: preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled, and do not omit active tasks when adding new work. A task may be marked completed only after its own work is recorded: the guard blocks a completion that has no file mutation in that task's active window, and one mutation cannot vouch for several completions. If a task is genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text; every such use is journaled. Use `cancelled` for work that was deliberately dropped. 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.";
|
|
18
|
+
|
|
19
|
+
export const EDIT_MARKER = "Workflow Guard requirement";
|
|
20
|
+
export const EDIT_SUFFIX =
|
|
21
|
+
"\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.";
|
|
22
|
+
|
|
23
|
+
export const SUBAGENT_MARKER = "Workflow Guard subagent guidance";
|
|
24
|
+
export const SUBAGENT_SUFFIX =
|
|
25
|
+
"\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.";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Append `suffix` unless the description already carries `marker`.
|
|
29
|
+
*
|
|
30
|
+
* Returns `undefined` when the suffix is already present, so a caller can tell
|
|
31
|
+
* "nothing to do" from "wrote an empty string".
|
|
32
|
+
*/
|
|
33
|
+
export function appendToolDescriptionSuffix(description: string, suffix: string, marker: string): string | undefined {
|
|
34
|
+
if (description.includes(marker)) return undefined;
|
|
35
|
+
return description + suffix;
|
|
36
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -19,8 +19,14 @@ export interface TodoSdkClient {
|
|
|
19
19
|
get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown; title?: unknown; time?: { idle?: unknown } } }>;
|
|
20
20
|
promptAsync?: (opts: {
|
|
21
21
|
path: { id: string };
|
|
22
|
-
body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }
|
|
22
|
+
body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }>; delivery?: "steer" | "queue"; resume?: boolean };
|
|
23
23
|
}) => Promise<unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* V2 only. The shim forwards a synthetic continuation through here, and
|
|
26
|
+
* the presence of this method is what marks a V2 client in
|
|
27
|
+
* `policies/continuation.ts` for `delivery`/`resume` support.
|
|
28
|
+
*/
|
|
29
|
+
synthetic?: (opts: { sessionID: string; text: string; delivery?: "steer" | "queue"; resume?: boolean }) => Promise<unknown>;
|
|
24
30
|
};
|
|
25
31
|
tui?: {
|
|
26
32
|
showToast?: (opts: { body: { title?: string; message: string; variant?: string } }) => Promise<unknown>;
|
|
@@ -247,6 +253,13 @@ export interface AuditEntry {
|
|
|
247
253
|
export interface GitInvocation {
|
|
248
254
|
repoDir: string;
|
|
249
255
|
rest: string;
|
|
256
|
+
/**
|
|
257
|
+
* True when the command text itself names the repository (`-C`,
|
|
258
|
+
* `--git-dir`, `--work-tree`, `GIT_DIR=`). False means `repoDir` is only
|
|
259
|
+
* the invocation's assumed base directory, which is the shell's working
|
|
260
|
+
* directory and not a fact the command string carries.
|
|
261
|
+
*/
|
|
262
|
+
explicitRepoDir?: boolean;
|
|
250
263
|
}
|
|
251
264
|
|
|
252
265
|
export interface ShellMutation {
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - `ctx.command.transform` for the server-side `/guard-status` command
|
|
11
11
|
* - `ctx.permission.hook("evaluate")` for the pre-execution deny seat
|
|
12
12
|
* - `ctx.shell.hook("create.before")` for env scrubbing
|
|
13
|
-
* - `ctx.mcp.list()` plus `mcp.
|
|
13
|
+
* - `ctx.mcp.list()` plus `mcp.status.changed` for the guarded-server inventory
|
|
14
14
|
* - `ctx.event.subscribe()` for tool outcomes, session lifecycle, and claims-vs-evidence
|
|
15
15
|
*
|
|
16
16
|
* The V1 SDK client surface (`TodoSdkClient`) is satisfied by an adapter so
|
|
@@ -22,12 +22,14 @@ import { Message } from "@opencode/ai/effect";
|
|
|
22
22
|
import { z } from "zod";
|
|
23
23
|
import { join, resolve } from "node:path";
|
|
24
24
|
import type { TodoSdkClient } from "./types.ts";
|
|
25
|
-
import { detectTodoCapability, recordTodoCapability, recordedTodoCapability, scanSessionTodos
|
|
25
|
+
import { detectTodoCapability, recordTodoCapability, recordedTodoCapability, scanSessionTodos } from "./v2-todo.ts";
|
|
26
26
|
import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
|
|
27
27
|
import {
|
|
28
28
|
setWorkspaceRoot,
|
|
29
29
|
setSdkClient,
|
|
30
30
|
getSessionBoundWorkspace,
|
|
31
|
+
rebindSessionWorkspaceFromHost,
|
|
32
|
+
forgetSessionWorkspace,
|
|
31
33
|
registerSessionWorkspaceFromToolCall,
|
|
32
34
|
runWithRuntimeState,
|
|
33
35
|
getSessionMutationCount,
|
|
@@ -58,7 +60,7 @@ import { secretIn } from "../policies/secrets.ts";
|
|
|
58
60
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
|
|
59
61
|
import { isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
|
|
60
62
|
import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
|
|
61
|
-
import { ToolOutcomeTracker
|
|
63
|
+
import { ToolOutcomeTracker } from "./tool-outcomes.ts";
|
|
62
64
|
import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
|
|
63
65
|
import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
|
|
64
66
|
import { checkCompletionClaims } from "../policies/completion.ts";
|
|
@@ -70,11 +72,21 @@ import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } f
|
|
|
70
72
|
import { extractCommands, splitShellSegments } from "./shell.ts";
|
|
71
73
|
import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "../policies/boundary.ts";
|
|
72
74
|
import { audit, summarizeInput } from "./audit.ts";
|
|
73
|
-
import { asRecord,
|
|
75
|
+
import { asRecord, isSensitiveEnvKey } from "./utils.ts";
|
|
74
76
|
import { loadedPluginVersion } from "./version.ts";
|
|
75
|
-
import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
|
|
77
|
+
import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, markInterrupted, recordUserMessage } from "../policies/continuation.ts";
|
|
76
78
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
|
|
77
|
-
import { buildCompactionContext,
|
|
79
|
+
import { buildCompactionContext, guardToolCall } from "./guard-api.ts";
|
|
80
|
+
import { evaluateDogfoodGate } from "./dogfood.ts";
|
|
81
|
+
import {
|
|
82
|
+
appendToolDescriptionSuffix,
|
|
83
|
+
EDIT_MARKER,
|
|
84
|
+
EDIT_SUFFIX,
|
|
85
|
+
SUBAGENT_MARKER,
|
|
86
|
+
SUBAGENT_SUFFIX,
|
|
87
|
+
TODOWRITE_MARKER,
|
|
88
|
+
TODOWRITE_SUFFIX,
|
|
89
|
+
} from "./tool-descriptions.ts";
|
|
78
90
|
import { registerPluginInstance, releasePluginInstance } from "./plugin-instance.ts";
|
|
79
91
|
import { claimToolCall, releaseToolCallClaim } from "./call-claims.ts";
|
|
80
92
|
import { evaluatePermissionSeat } from "./permission-seat.ts";
|
|
@@ -92,11 +104,12 @@ type V2Context = Plugin.Context;
|
|
|
92
104
|
// capability) so builtin-only sessions are not gated forever. See v2-todo.ts.
|
|
93
105
|
|
|
94
106
|
function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
95
|
-
// V2 has no server-side log API or TUI surface
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
107
|
+
// V2 has no server-side log API or TUI surface. The client shim supplies
|
|
108
|
+
// only { app, session, lsp }, so there is no `tui.showToast` to call: block
|
|
109
|
+
// paths on V2 do not attempt a toast and rely on the thrown hook error,
|
|
110
|
+
// which is what actually reaches the model. Keep this adapter from logging
|
|
111
|
+
// anything beyond guard decision summaries.
|
|
112
|
+
const client: TodoSdkClient = {
|
|
100
113
|
app: {
|
|
101
114
|
log: async ({ body }) => {
|
|
102
115
|
try {
|
|
@@ -110,10 +123,32 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
|
110
123
|
const session = await ctx.session.get({ sessionID: path.id });
|
|
111
124
|
return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
|
|
112
125
|
},
|
|
126
|
+
// V2 only: forwards a synthetic message with `delivery`/`resume`.
|
|
127
|
+
// Its presence is also what marks this client as V2 for
|
|
128
|
+
// `policies/continuation.ts`, which is why the values travel here
|
|
129
|
+
// instead of through the shared `promptAsync` body type.
|
|
130
|
+
synthetic: async (opts) => {
|
|
131
|
+
await ctx.session.synthetic({
|
|
132
|
+
sessionID: opts.sessionID,
|
|
133
|
+
text: opts.text,
|
|
134
|
+
...(opts.delivery ? { delivery: opts.delivery } : {}),
|
|
135
|
+
...(opts.resume === undefined ? {} : { resume: opts.resume }),
|
|
136
|
+
});
|
|
137
|
+
},
|
|
113
138
|
promptAsync: async (opts) => {
|
|
114
|
-
|
|
139
|
+
const body = opts.body as {
|
|
140
|
+
parts: Array<{ type: "text"; text: string; synthetic?: boolean }>;
|
|
141
|
+
delivery?: "steer" | "queue";
|
|
142
|
+
resume?: boolean;
|
|
143
|
+
};
|
|
144
|
+
for (const part of body.parts) {
|
|
115
145
|
if (part.synthetic) {
|
|
116
|
-
await
|
|
146
|
+
await client.session!.synthetic!({
|
|
147
|
+
sessionID: opts.path.id,
|
|
148
|
+
text: part.text,
|
|
149
|
+
...(body.delivery ? { delivery: body.delivery } : {}),
|
|
150
|
+
...(body.resume === undefined ? {} : { resume: body.resume }),
|
|
151
|
+
});
|
|
117
152
|
} else {
|
|
118
153
|
await ctx.session.prompt({ sessionID: opts.path.id, text: part.text });
|
|
119
154
|
}
|
|
@@ -122,19 +157,9 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
|
122
157
|
},
|
|
123
158
|
lsp: {},
|
|
124
159
|
};
|
|
160
|
+
return client;
|
|
125
161
|
}
|
|
126
162
|
|
|
127
|
-
// ── Builtin tool description enrichment ───────────────────────────────────────
|
|
128
|
-
|
|
129
|
-
const TODOWRITE_SUFFIX =
|
|
130
|
-
"\n\nWorkflow Guard lifecycle: use todowrite to plan before working - it is the only view of progress the operator has, and every file mutation is gated on it. Each call replaces the complete task list: preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled, and do not omit active tasks when adding new work. A task may be marked completed only after its own work is recorded: the guard blocks a completion that has no file mutation in that task's active window, and one mutation cannot vouch for several completions. If a task is genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text; every such use is journaled. Use `cancelled` for work that was deliberately dropped. 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.";
|
|
131
|
-
|
|
132
|
-
const EDIT_SUFFIX =
|
|
133
|
-
"\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.";
|
|
134
|
-
|
|
135
|
-
const SUBAGENT_SUFFIX =
|
|
136
|
-
"\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.";
|
|
137
|
-
|
|
138
163
|
// Builtin tool description enrichment. Candidates are matched against the
|
|
139
164
|
// host's tool registry; `ToolEditor.update` ignores missing IDs, so extra
|
|
140
165
|
// candidates are harmless no-ops.
|
|
@@ -153,9 +178,9 @@ const SUBAGENT_SUFFIX =
|
|
|
153
178
|
// - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
|
|
154
179
|
// candidates for the same reason.
|
|
155
180
|
const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
|
|
156
|
-
{ candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker:
|
|
157
|
-
{ candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker:
|
|
158
|
-
{ candidates: ["task", "subagent"], suffix: SUBAGENT_SUFFIX, marker:
|
|
181
|
+
{ candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker: TODOWRITE_MARKER },
|
|
182
|
+
{ candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker: EDIT_MARKER },
|
|
183
|
+
{ candidates: ["task", "subagent"], suffix: SUBAGENT_SUFFIX, marker: SUBAGENT_MARKER },
|
|
159
184
|
];
|
|
160
185
|
|
|
161
186
|
// ── Setup ─────────────────────────────────────────────────────────────────────
|
|
@@ -254,8 +279,8 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
254
279
|
for (const id of target.candidates) {
|
|
255
280
|
editor.update(id, (tool) => {
|
|
256
281
|
const description = typeof tool.description === "string" ? tool.description : "";
|
|
257
|
-
|
|
258
|
-
tool.description =
|
|
282
|
+
const next = appendToolDescriptionSuffix(description, target.suffix, target.marker);
|
|
283
|
+
if (next !== undefined) tool.description = next;
|
|
259
284
|
});
|
|
260
285
|
}
|
|
261
286
|
}
|
|
@@ -298,7 +323,6 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
298
323
|
});
|
|
299
324
|
if (reason !== undefined) {
|
|
300
325
|
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
301
|
-
await showBlockToast(reason);
|
|
302
326
|
const failureCount = event.sessionID ? toolOutcomes.getFailureCount(event.sessionID) : 0;
|
|
303
327
|
const circuitBreakerSuffix = failureCount >= 2
|
|
304
328
|
? "\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.]"
|
|
@@ -314,7 +338,6 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
314
338
|
const researchReason = liveDocsGatingReason(event.sessionID, event.tool, threshold, getResearchToolPatterns(toolWorktree));
|
|
315
339
|
if (researchReason !== undefined) {
|
|
316
340
|
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
317
|
-
await showBlockToast(researchReason);
|
|
318
341
|
console.log(`[workflow-guard] warn: ${researchReason}`);
|
|
319
342
|
audit({
|
|
320
343
|
ts: new Date().toISOString(),
|
|
@@ -381,19 +404,13 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
381
404
|
await ctx.tool.hook("execute.after", async (event) => {
|
|
382
405
|
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
383
406
|
const startedAt = toolLifecycle.finish(event.sessionID, event.id);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
decision: "allow",
|
|
392
|
-
phase: "outcome",
|
|
393
|
-
durationMs: outcome.durationMs,
|
|
394
|
-
reason: outcome.status,
|
|
395
|
-
});
|
|
396
|
-
}
|
|
407
|
+
recordV2ToolOutcome(toolOutcomes, effectiveRoot, {
|
|
408
|
+
tool: event.tool,
|
|
409
|
+
sessionID: event.sessionID,
|
|
410
|
+
callID: event.id,
|
|
411
|
+
status: event.status,
|
|
412
|
+
error: event.status === "error" ? event.error : undefined,
|
|
413
|
+
}, startedAt);
|
|
397
414
|
releaseFileClaims(event.sessionID, event.id);
|
|
398
415
|
if (event.tool === "read") {
|
|
399
416
|
const observation = toolLifecycle.takeReadObservation(event.sessionID, event.id);
|
|
@@ -719,93 +736,127 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
719
736
|
};
|
|
720
737
|
};
|
|
721
738
|
|
|
739
|
+
// ── Tool outcome telemetry ────────────────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Record one tool call's terminal outcome from `execute.after`.
|
|
743
|
+
*
|
|
744
|
+
* The former `session.message.content.updated` subscription never fired: that
|
|
745
|
+
* type is absent from `EventManifest.ServerDefinitions`, so no tool part was
|
|
746
|
+
* ever mapped and the repeated-failure circuit breaker plus the Policy 25
|
|
747
|
+
* research floor both read an always-zero count. `execute.after` does fire,
|
|
748
|
+
* including for a failed call, because the host triggers the hook with
|
|
749
|
+
* `status: "error"` before it rethrows. The error's own message feeds the
|
|
750
|
+
* tracker's signature, so repeat detection keys on what the model actually saw.
|
|
751
|
+
*/
|
|
752
|
+
function recordV2ToolOutcome(
|
|
753
|
+
toolOutcomes: ToolOutcomeTracker,
|
|
754
|
+
effectiveRoot: string,
|
|
755
|
+
event: {
|
|
756
|
+
tool: string;
|
|
757
|
+
sessionID?: string;
|
|
758
|
+
callID: string;
|
|
759
|
+
status?: "completed" | "error";
|
|
760
|
+
error?: { message?: string } | string;
|
|
761
|
+
},
|
|
762
|
+
startedAt?: number,
|
|
763
|
+
): void {
|
|
764
|
+
const status = event.status;
|
|
765
|
+
if (status !== "completed" && status !== "error") {
|
|
766
|
+
// The host's `execute.after` always carries a terminal status. A partial
|
|
767
|
+
// event from a harness or stub leaves it unknown, so record the
|
|
768
|
+
// optimistic completion that this hook used to record unconditionally
|
|
769
|
+
// rather than dropping the call from telemetry entirely. The audit row
|
|
770
|
+
// is kept here so this path does not lose the coverage it had before
|
|
771
|
+
// outcomes moved onto `execute.after`.
|
|
772
|
+
const fallback = toolOutcomes.recordFallbackCompleted(event.sessionID ?? "", event.callID, event.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
|
|
773
|
+
if (fallback) {
|
|
774
|
+
audit({
|
|
775
|
+
ts: new Date().toISOString(),
|
|
776
|
+
sessionID: fallback.sessionID,
|
|
777
|
+
callID: fallback.callID,
|
|
778
|
+
tool: fallback.tool,
|
|
779
|
+
decision: "allow",
|
|
780
|
+
phase: "outcome",
|
|
781
|
+
durationMs: fallback.durationMs,
|
|
782
|
+
reason: fallback.status,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const error = typeof event.error === "string" ? event.error : event.error?.message;
|
|
788
|
+
const outcome = toolOutcomes.record({
|
|
789
|
+
type: "tool",
|
|
790
|
+
sessionID: event.sessionID,
|
|
791
|
+
callID: event.callID,
|
|
792
|
+
tool: event.tool,
|
|
793
|
+
state: {
|
|
794
|
+
status,
|
|
795
|
+
error,
|
|
796
|
+
time: startedAt === undefined ? undefined : { start: startedAt, end: Date.now() },
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
if (!outcome) return;
|
|
800
|
+
// Policy 25 research floor (V1 parity): failures accrue debt; successful
|
|
801
|
+
// live-source lookups clear it. A failed research attempt never accrues
|
|
802
|
+
// debt, so a flaky lookup cannot deepen the block it was meant to escape.
|
|
803
|
+
if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
|
|
804
|
+
if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
|
|
805
|
+
} else if (outcome.status === "error") {
|
|
806
|
+
recordLiveDocsFailure(outcome.sessionID);
|
|
807
|
+
}
|
|
808
|
+
audit({
|
|
809
|
+
ts: new Date().toISOString(),
|
|
810
|
+
sessionID: outcome.sessionID,
|
|
811
|
+
callID: outcome.callID,
|
|
812
|
+
tool: outcome.tool,
|
|
813
|
+
decision: "allow",
|
|
814
|
+
phase: "outcome",
|
|
815
|
+
durationMs: outcome.durationMs,
|
|
816
|
+
reason: outcome.status,
|
|
817
|
+
});
|
|
818
|
+
if (outcome.status === "error" && outcome.repeatedFailureCount === 3) {
|
|
819
|
+
audit({
|
|
820
|
+
ts: new Date().toISOString(),
|
|
821
|
+
sessionID: outcome.sessionID,
|
|
822
|
+
callID: outcome.callID,
|
|
823
|
+
tool: outcome.tool,
|
|
824
|
+
decision: "allow",
|
|
825
|
+
phase: "event",
|
|
826
|
+
reason: "repeated-equivalent-failure:3",
|
|
827
|
+
});
|
|
828
|
+
console.log(`[workflow-guard] ${outcome.tool} failed equivalently 3 times in this session; change approach or inspect the underlying failure before retrying.`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
722
832
|
// ── Event handling ────────────────────────────────────────────────────────────
|
|
723
833
|
|
|
724
|
-
interface V2EventState {
|
|
834
|
+
export interface V2EventState {
|
|
725
835
|
effectiveRoot: string;
|
|
726
836
|
client: unknown;
|
|
727
837
|
toolLifecycle: ToolInvocationLifecycle;
|
|
728
838
|
toolOutcomes: ToolOutcomeTracker;
|
|
729
|
-
/** Re-read the host MCP roster after `mcp.
|
|
839
|
+
/** Re-read the host MCP roster after `mcp.status.changed`. */
|
|
730
840
|
refreshMcpInventory?: () => Promise<void>;
|
|
731
841
|
}
|
|
732
842
|
|
|
733
|
-
async function handleV2Event(
|
|
843
|
+
export async function handleV2Event(
|
|
734
844
|
event: { type?: string; data?: unknown },
|
|
735
845
|
state: V2EventState,
|
|
736
846
|
): Promise<void> {
|
|
737
847
|
const { effectiveRoot, client, toolLifecycle, toolOutcomes } = state;
|
|
738
848
|
|
|
739
|
-
if (event.type === "mcp.
|
|
740
|
-
//
|
|
849
|
+
if (event.type === "mcp.status.changed") {
|
|
850
|
+
// A server's connection status settled (connected, failed, needs_auth,
|
|
851
|
+
// closed), which is when its tools become callable. Re-read the roster
|
|
741
852
|
// so a newly connected live-system server is guarded before its tools
|
|
742
|
-
//
|
|
853
|
+
// can be called. Widen-only: this can add tokens, never remove them.
|
|
854
|
+
// `mcp.resources.changed` is deliberately not a trigger: it reports
|
|
855
|
+
// resources, not tool availability.
|
|
743
856
|
await state.refreshMcpInventory?.();
|
|
744
857
|
return;
|
|
745
858
|
}
|
|
746
859
|
|
|
747
|
-
if (event.type === "session.message.content.updated") {
|
|
748
|
-
const data = event.data as { sessionID?: unknown; content?: unknown } | undefined;
|
|
749
|
-
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
750
|
-
if (!sessionID || !Array.isArray(data?.content)) return;
|
|
751
|
-
for (const part of data.content as Array<Record<string, unknown>>) {
|
|
752
|
-
if (part?.type !== "tool") continue;
|
|
753
|
-
const toolState = part.state as { status?: unknown; error?: { message?: string } | string } | undefined;
|
|
754
|
-
const status = toolState?.status;
|
|
755
|
-
if (status !== "completed" && status !== "error") continue;
|
|
756
|
-
// V2 keeps timing on the tool part itself (part.time), not inside state.
|
|
757
|
-
const partTime = part.time as { ran?: unknown; completed?: unknown } | undefined;
|
|
758
|
-
const start = typeof partTime?.ran === "number" ? partTime.ran : undefined;
|
|
759
|
-
const end = typeof partTime?.completed === "number" ? partTime.completed : undefined;
|
|
760
|
-
const mapped: ToolOutcomePart = {
|
|
761
|
-
type: "tool",
|
|
762
|
-
sessionID,
|
|
763
|
-
callID: part.id,
|
|
764
|
-
tool: toolPartName(part),
|
|
765
|
-
state: {
|
|
766
|
-
status,
|
|
767
|
-
error: typeof toolState?.error === "string" ? toolState.error : toolState?.error?.message,
|
|
768
|
-
time: { start, end },
|
|
769
|
-
},
|
|
770
|
-
};
|
|
771
|
-
const outcome = toolOutcomes.record(mapped);
|
|
772
|
-
if (outcome) {
|
|
773
|
-
// Policy 25 research floor (V1 parity): failures accrue debt;
|
|
774
|
-
// successful live-source lookups clear it. A failed research
|
|
775
|
-
// attempt never accrues debt, so a flaky lookup cannot deepen
|
|
776
|
-
// the block it was meant to escape.
|
|
777
|
-
if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
|
|
778
|
-
if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
|
|
779
|
-
} else if (outcome.status === "error") {
|
|
780
|
-
recordLiveDocsFailure(outcome.sessionID);
|
|
781
|
-
}
|
|
782
|
-
audit({
|
|
783
|
-
ts: new Date().toISOString(),
|
|
784
|
-
sessionID: outcome.sessionID,
|
|
785
|
-
callID: outcome.callID,
|
|
786
|
-
tool: outcome.tool,
|
|
787
|
-
decision: "allow",
|
|
788
|
-
phase: "outcome",
|
|
789
|
-
durationMs: outcome.durationMs,
|
|
790
|
-
reason: outcome.status,
|
|
791
|
-
});
|
|
792
|
-
if (outcome.status === "error" && outcome.repeatedFailureCount === 3) {
|
|
793
|
-
audit({
|
|
794
|
-
ts: new Date().toISOString(),
|
|
795
|
-
sessionID: outcome.sessionID,
|
|
796
|
-
callID: outcome.callID,
|
|
797
|
-
tool: outcome.tool,
|
|
798
|
-
decision: "allow",
|
|
799
|
-
phase: "event",
|
|
800
|
-
reason: "repeated-equivalent-failure:3",
|
|
801
|
-
});
|
|
802
|
-
console.log(`[workflow-guard] ${outcome.tool} failed equivalently 3 times in this session; change approach or inspect the underlying failure before retrying.`);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
return;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
860
|
if (event.type === "session.text.ended") {
|
|
810
861
|
const data = event.data as { sessionID?: unknown; text?: unknown } | undefined;
|
|
811
862
|
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
@@ -841,8 +892,53 @@ async function handleV2Event(
|
|
|
841
892
|
return;
|
|
842
893
|
}
|
|
843
894
|
|
|
844
|
-
if (event.type === "session.
|
|
845
|
-
|
|
895
|
+
if (event.type === "session.moved") {
|
|
896
|
+
// `session_move` relocates a session between checkouts, and the host
|
|
897
|
+
// announces it. On the pinned 2.2.0 SDK the V2 union carries
|
|
898
|
+
// `session.moved` and no `session.updated`, and the move event itself
|
|
899
|
+
// names the destination: `data.sessionID` plus
|
|
900
|
+
// `data.location.directory`. Without this rebind the session kept
|
|
901
|
+
// judging the tree it left.
|
|
902
|
+
const data = event.data as {
|
|
903
|
+
sessionID?: unknown;
|
|
904
|
+
location?: { directory?: unknown };
|
|
905
|
+
} | undefined;
|
|
906
|
+
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
907
|
+
const directory = typeof data?.location?.directory === "string" ? data.location.directory : undefined;
|
|
908
|
+
rebindSessionWorkspaceFromHost(sessionID, directory, { moved: true });
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (event.type === "session.execution.interrupted") {
|
|
913
|
+
// `reason` is one of "user" | "shutdown" | "superseded" | "inactivity".
|
|
914
|
+
// Only "user" is user intent (Esc), so only it suspends automatic
|
|
915
|
+
// continuation. The other three are host-lifecycle events and must
|
|
916
|
+
// leave continuation state alone.
|
|
917
|
+
const data = event.data as { sessionID?: unknown; reason?: unknown } | undefined;
|
|
918
|
+
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
919
|
+
if (sessionID && data?.reason === "user") {
|
|
920
|
+
// Continuation state is keyed on the SDK client plus project root,
|
|
921
|
+
// so it must be reached inside the runtime state.
|
|
922
|
+
await runWithRuntimeState(effectiveRoot, client, () => markInterrupted(sessionID));
|
|
923
|
+
}
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Idle is the trigger for automatic continuation. The host keeps two
|
|
928
|
+
// event types for it: `session.status` with `status.type === "idle"`, and
|
|
929
|
+
// the deprecated `session.idle`. Both are in
|
|
930
|
+
// `EventManifest.ServerDefinitions` on 2.0.15, 2.1.3, and 2.2.0, but which
|
|
931
|
+
// one the server actually publishes is not settled: the TUI derives idle
|
|
932
|
+
// from `session.execution.*` instead of consuming `session.status`, and an
|
|
933
|
+
// upstream report says `session.status` is declared but never published.
|
|
934
|
+
// Handling both keeps continuation working whichever one arrives, and adds
|
|
935
|
+
// no new trigger beyond what each host already emits. A host that emits
|
|
936
|
+
// both for one transition still sends a single prompt, because
|
|
937
|
+
// `continueUnfinishedSession` holds a per-session in-flight lock for the
|
|
938
|
+
// whole awaited send. The side effects keep their original order.
|
|
939
|
+
if (event.type === "session.status" || event.type === "session.idle") {
|
|
940
|
+
const data = event.data as { sessionID?: unknown; status?: { type?: unknown } } | undefined;
|
|
941
|
+
if (event.type === "session.status" && data?.status?.type !== "idle") return;
|
|
846
942
|
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
847
943
|
if (typeof sessionID === "string") {
|
|
848
944
|
releaseFileClaims(sessionID);
|
|
@@ -870,6 +966,7 @@ async function handleV2Event(
|
|
|
870
966
|
clearTodoSessionState(sessionID);
|
|
871
967
|
toolLifecycle.takeRecoveryRun(sessionID);
|
|
872
968
|
await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
|
|
969
|
+
forgetSessionWorkspace(sessionID);
|
|
873
970
|
}
|
|
874
971
|
return;
|
|
875
972
|
}
|
|
@@ -889,25 +986,8 @@ async function handleV2Event(
|
|
|
889
986
|
});
|
|
890
987
|
return;
|
|
891
988
|
}
|
|
892
|
-
// `command.executed` exists in the V2 event stream (`data: { name, sessionID,
|
|
893
|
-
// arguments, messageID }`) and carries what the V1 audit wanted: the slash
|
|
894
|
-
// command name, its arguments, and the session. It is journaled here rather
|
|
895
|
-
// than dropped, so a command that mutates workspace state stays attributable.
|
|
896
|
-
if (event.type === "command.executed") {
|
|
897
|
-
const data = event.data as { sessionID?: unknown; name?: unknown; arguments?: unknown } | undefined;
|
|
898
|
-
audit({
|
|
899
|
-
ts: new Date().toISOString(),
|
|
900
|
-
sessionID: typeof data?.sessionID === "string" ? data.sessionID : undefined,
|
|
901
|
-
tool: "command.executed",
|
|
902
|
-
decision: "allow",
|
|
903
|
-
phase: "event",
|
|
904
|
-
reason: typeof data?.name === "string" ? data.name : undefined,
|
|
905
|
-
input: summarizeInput({ name: data?.name, arguments: data?.arguments }),
|
|
906
|
-
});
|
|
907
|
-
return;
|
|
908
|
-
}
|
|
909
989
|
// Note on V1 events without a V2 counterpart:
|
|
910
990
|
// - `permission.updated`: replaced by `permission.asked`/`permission.replied` above.
|
|
911
|
-
// - `session.created`:
|
|
912
|
-
//
|
|
991
|
+
// - `session.created`: V2 journals no create event; session state resets on
|
|
992
|
+
// `session.deleted`.
|
|
913
993
|
}
|