opencode-workflow-guard 1.15.5 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -21
- package/bin/opencode-workflow-guard.mjs +58 -37
- package/docs/accountability-evolution-plan.md +9 -9
- package/docs/installation.md +118 -23
- package/docs/managed-deployment.md +4 -4
- package/docs/policies.md +100 -64
- package/docs/policy-posture.md +171 -0
- package/docs/testing.md +6 -3
- package/docs/troubleshooting.md +20 -19
- package/package.json +16 -9
- package/src/lib/call-claims.ts +71 -0
- package/src/lib/config-writer.d.mts +13 -0
- package/src/lib/config-writer.mjs +89 -0
- package/src/lib/custom-tools.ts +70 -8
- package/src/lib/guard-command.ts +97 -0
- package/src/lib/guard-dispatcher.ts +70 -0
- package/src/lib/host-worktree.ts +109 -0
- package/src/lib/mcp-inventory.ts +86 -0
- package/src/lib/permission-seat.ts +119 -0
- package/src/lib/plugin-instance.ts +124 -0
- package/src/lib/policy-posture.ts +227 -0
- package/src/lib/read-only-tools.ts +61 -0
- package/src/lib/state.ts +212 -36
- package/src/lib/types.ts +15 -0
- package/src/lib/v2-plugin.ts +262 -23
- package/src/lib/v2-todo.ts +137 -7
- package/src/lib/version.ts +22 -0
- package/src/lib/worktree.ts +61 -18
- package/src/policies/live-docs.ts +157 -0
- package/src/policies/mcp.ts +47 -2
- package/src/policies/stale-write.ts +10 -0
- package/src/policies/sweeping-claims.ts +71 -0
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +221 -6
- package/src/workflow-guard.ts +97 -6
- package/test/test-e2e.mts +91 -6
- package/test/test.mts +1140 -4
package/src/lib/custom-tools.ts
CHANGED
|
@@ -36,15 +36,17 @@ import {
|
|
|
36
36
|
runWithRuntimeState,
|
|
37
37
|
} from "./state.ts";
|
|
38
38
|
import { getRalphOutcome } from "../policies/continuation.ts";
|
|
39
|
+
import { todoEvidenceReport } from "../policies/todo.ts";
|
|
39
40
|
import { findGitRoot, loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
40
41
|
import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
|
|
41
42
|
import { buildReviewRubric } from "./review.ts";
|
|
42
|
-
import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
|
|
43
|
+
import { createGitWorktree, cleanupGitWorktree, validateHostWorktreeDirectory } from "./worktree.ts";
|
|
44
|
+
import type { HostWorktreePort } from "./host-worktree.ts";
|
|
43
45
|
import { branchHasDocumentationChange } from "../policies/docs.ts";
|
|
44
46
|
import { restoreRecoveryCheckpoint } from "./checkpoint.ts";
|
|
45
47
|
import { audit, getRecentAuditEntries } from "./audit.ts";
|
|
46
48
|
import { guardToolCallImpl } from "./guard-dispatcher.ts";
|
|
47
|
-
import { loadedPluginVersion } from "./version.ts";
|
|
49
|
+
import { loadedPluginVersion, qualifiedHostVersion } from "./version.ts";
|
|
48
50
|
import { gitHygieneSnapshot } from "./hygiene.ts";
|
|
49
51
|
import { discoverPlanningSources } from "../policies/planning.ts";
|
|
50
52
|
import { secretIn } from "../policies/secrets.ts";
|
|
@@ -76,7 +78,7 @@ export function buildWorkflowGuardSystemGuidance(options: {
|
|
|
76
78
|
const sections: string[] = [
|
|
77
79
|
"## Workflow Guard & Operational Tools",
|
|
78
80
|
"Workflow Guard enforces safe engineering discipline and provides proactive workflow tools:",
|
|
79
|
-
"- Task Planning: Proactively call `guard_next_tasks` at session start or when planning work to discover repository roadmap, plan, and TODO items
|
|
81
|
+
"- Task Planning: The session's todo list is the unit of visible progress and it gates every file mutation. Call `todowrite` before starting work, keep exactly the real remaining tasks in it, and mark a task completed only after its work is actually done. A completion is checked against recorded work in that task's window, so finishing a task without doing it is blocked. Genuinely read-only tasks (review, investigation, reading) are marked `[read-only]` in their text. Proactively call `guard_next_tasks` at session start or when planning work to discover repository roadmap, plan, and TODO items, and check `guard_status` to inspect branch protection, mutation count, and outstanding verification/review gates.",
|
|
80
82
|
];
|
|
81
83
|
|
|
82
84
|
if (!options.isReadOnly) {
|
|
@@ -89,6 +91,7 @@ export function buildWorkflowGuardSystemGuidance(options: {
|
|
|
89
91
|
sections.push(
|
|
90
92
|
"- Technical Debt: Inspect open P2/P3 review follow-ups with `guard_review_followups` during planning; resolve fixed items with `guard_review_followup_resolve`.",
|
|
91
93
|
"- Policy Simulation: If uncertain whether a command or file edit is permitted, simulate it with `guard_why`.",
|
|
94
|
+
"- Live Documentation: Verify APIs, flags, and library behavior against current sources with `websearch`/`webfetch` rather than guessing from training data. After repeated tool failures with no lookup, Workflow Guard blocks further mutations and shell commands until you consult a live source; grounding clears the block.",
|
|
92
95
|
);
|
|
93
96
|
|
|
94
97
|
if (options.projectMemoryEnabled) {
|
|
@@ -121,8 +124,25 @@ export function createCustomTools(options: {
|
|
|
121
124
|
portableMemoryPath: string;
|
|
122
125
|
learningInterventions: Map<string, number>;
|
|
123
126
|
client: unknown;
|
|
127
|
+
/**
|
|
128
|
+
* Host worktree port. When present and available, creation goes through the
|
|
129
|
+
* host so the worktree lands in its configured directory and inventory.
|
|
130
|
+
* Removal stays with the guard's snapshot-then-remove flow regardless.
|
|
131
|
+
*/
|
|
132
|
+
hostWorktree?: HostWorktreePort;
|
|
133
|
+
/**
|
|
134
|
+
* Probed todo capability for this host, reported in `guard_status`.
|
|
135
|
+
* Omitted on V1, which has a native todo endpoint and therefore always can
|
|
136
|
+
* plan; V2 passes its registry probe so a fail-open gate is visible.
|
|
137
|
+
*/
|
|
138
|
+
todoCapability?: () => string;
|
|
139
|
+
/**
|
|
140
|
+
* Live host version, reported next to the version this build was qualified
|
|
141
|
+
* against so a mismatch is visible before it becomes a silent no-op.
|
|
142
|
+
*/
|
|
143
|
+
hostVersion?: () => string | undefined;
|
|
124
144
|
}) {
|
|
125
|
-
const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
|
|
145
|
+
const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client, hostWorktree, todoCapability, hostVersion } = options;
|
|
126
146
|
// Resolve the workspace for a tool call from the CALL's own context: an
|
|
127
147
|
// explicit argument first, then the host-provided per-call worktree or
|
|
128
148
|
// directory (even when unusable — the call declared its target), then the
|
|
@@ -276,7 +296,8 @@ export function createCustomTools(options: {
|
|
|
276
296
|
recommendedActions.push("All operational requirements are satisfied for task finalization and PR creation.");
|
|
277
297
|
}
|
|
278
298
|
const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
|
|
279
|
-
|
|
299
|
+
const taskEvidence = await runWithRuntimeState(root, client, () => todoEvidenceReport(toolContext.sessionID));
|
|
300
|
+
return JSON.stringify({ pluginVersion: loadedPluginVersion() ?? null, qualifiedHostVersion: qualifiedHostVersion() ?? null, hostVersion: hostVersion?.() ?? null, workspaceRoot: root, branch, onProtectedBranch: isProtected, todoCapability: todoCapability ? todoCapability() : "available", taskEvidence, outstandingRequirements, recommendedActions, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, requireSubagentReview: isSubagentReviewRequired(root), recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) }, gitHygiene: gitHygieneSnapshot(root) ?? null }, null, 2);
|
|
280
301
|
},
|
|
281
302
|
}),
|
|
282
303
|
guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard. Use to diagnose policy decisions, blocked commands, or outcome telemetry.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
|
|
@@ -374,17 +395,58 @@ export function createCustomTools(options: {
|
|
|
374
395
|
description: "Create an isolated git worktree directory for concurrent subagent execution. Proactively call when launching parallel tasks/subagents to prevent file collision and maintain branch isolation.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
|
|
375
396
|
execute: async (args, toolContext) => {
|
|
376
397
|
const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
|
|
377
|
-
const toolRoot = await resolveCallWorkspace(toolContext);
|
|
398
|
+
const toolRoot = await resolveCallWorkspace(toolContext);
|
|
399
|
+
// Host first: it honors the operator's configured worktree directory
|
|
400
|
+
// and keeps the worktree in host inventory. The result is validated
|
|
401
|
+
// independently (registered worktree of THIS repo, not the primary
|
|
402
|
+
// tree); any failure falls back to the guard's git path.
|
|
403
|
+
//
|
|
404
|
+
// The host path is used only for the default base. The host create
|
|
405
|
+
// input's `from` is an absolute directory path, not a git ref, so an
|
|
406
|
+
// explicit `baseBranch` cannot be expressed there; honoring it via
|
|
407
|
+
// the git path is better than silently ignoring it.
|
|
408
|
+
let createdPath: string | undefined;
|
|
409
|
+
let hostNote = "";
|
|
410
|
+
const requestedBase = args.baseBranch ?? "HEAD";
|
|
411
|
+
if (hostWorktree?.available && requestedBase === "HEAD") {
|
|
412
|
+
const hostResult = await hostWorktree.create({ branch: args.branch });
|
|
413
|
+
if (hostResult.directory) {
|
|
414
|
+
const validation = validateHostWorktreeDirectory(hostResult.directory, toolRoot);
|
|
415
|
+
if (validation.ok) {
|
|
416
|
+
createdPath = resolve(hostResult.directory);
|
|
417
|
+
hostNote = " (created by the host worktree strategy)";
|
|
418
|
+
} else {
|
|
419
|
+
hostNote = ` Host worktree creation was rejected by the guard (${validation.error}); used the guard's git path instead.`;
|
|
420
|
+
}
|
|
421
|
+
} else if (hostResult.error) {
|
|
422
|
+
hostNote = ` Host worktree creation failed (${hostResult.error}); used the guard's git path instead.`;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (!createdPath) {
|
|
426
|
+
const res = createGitWorktree(args.branch, requestedBase, toolRoot);
|
|
427
|
+
if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}${hostNote}`;
|
|
428
|
+
createdPath = res.worktreePath;
|
|
429
|
+
}
|
|
378
430
|
const mutationRoot = await resolveCallWorkspace(toolContext);
|
|
379
431
|
await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
|
|
380
|
-
return `[workflow-guard] Worktree created successfully at: ${
|
|
432
|
+
return `[workflow-guard] Worktree created successfully at: ${createdPath}${hostNote}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
|
|
381
433
|
},
|
|
382
434
|
}),
|
|
383
435
|
guard_worktree_cleanup: tool({
|
|
384
436
|
description: "Commit a final snapshot and remove an isolated git worktree directory. Call to clean up worktrees created with guard_worktree_create after subagent tasks complete.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
|
|
385
437
|
execute: async (args, toolContext) => {
|
|
386
438
|
const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
|
|
387
|
-
const toolRoot = await resolveCallWorkspace(toolContext);
|
|
439
|
+
const toolRoot = await resolveCallWorkspace(toolContext);
|
|
440
|
+
// Removal never delegates to the host: its `remove` takes a `force`
|
|
441
|
+
// flag and has no snapshot step, so it can destroy uncommitted work
|
|
442
|
+
// the guard's snapshot-then-remove flow preserves. The host list is
|
|
443
|
+
// used only to authorize a directory outside the guard's storage.
|
|
444
|
+
const hostWorktrees = hostWorktree?.available ? await hostWorktree.list() : undefined;
|
|
445
|
+
const res = cleanupGitWorktree(args.worktreePath, toolRoot, { hostWorktrees });
|
|
446
|
+
if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
|
|
447
|
+
// Tell the host its inventory is stale, or it keeps listing the
|
|
448
|
+
// removed directory until something else refreshes it.
|
|
449
|
+
await hostWorktree?.refresh();
|
|
388
450
|
const mutationRoot = await resolveCallWorkspace(toolContext);
|
|
389
451
|
await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
|
|
390
452
|
return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side `/guard-status` command for OpenCode 2.
|
|
3
|
+
*
|
|
4
|
+
* Before this, the only way to read guard state from inside a session was the
|
|
5
|
+
* separately installed TUI companion, or knowing the `guard_status` tool name.
|
|
6
|
+
* `ctx.command.transform` registers a command that runs in every V2 client.
|
|
7
|
+
*
|
|
8
|
+
* The command computes the report from the guard's own `guard_status` tool and
|
|
9
|
+
* delivers it as a synthetic message. Two deliberate choices:
|
|
10
|
+
*
|
|
11
|
+
* - The report is not relayed through the model. `guard_status` is
|
|
12
|
+
* authoritative state; asking a model to restate it would introduce a
|
|
13
|
+
* paraphrase step between the operator and the evidence.
|
|
14
|
+
* - Delivery uses `session.synthetic`, so the command costs no model turn.
|
|
15
|
+
* Synthetic messages do not run the `session.prompt` hook, so this cannot
|
|
16
|
+
* churn recovery checkpoints.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Plugin } from "@opencode/plugin";
|
|
20
|
+
import { audit } from "./audit.ts";
|
|
21
|
+
import { runWithRuntimeState } from "./state.ts";
|
|
22
|
+
|
|
23
|
+
/** The command id. Prefixed so it cannot shadow a user command by accident. */
|
|
24
|
+
export const GUARD_STATUS_COMMAND = "guard-status";
|
|
25
|
+
|
|
26
|
+
export interface GuardStatusCommandDeps {
|
|
27
|
+
/** Produces the same JSON report as the `guard_status` tool. */
|
|
28
|
+
runStatus: (sessionID: string) => Promise<string>;
|
|
29
|
+
effectiveRoot: string;
|
|
30
|
+
client: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CommandInvocation {
|
|
34
|
+
readonly sessionID: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Register the command. Registration is additive: a collision with an existing
|
|
39
|
+
* command id is the host's to resolve, and the `guard-` prefix keeps that from
|
|
40
|
+
* being accidental.
|
|
41
|
+
*/
|
|
42
|
+
export async function registerGuardStatusCommand(
|
|
43
|
+
ctx: Plugin.Context,
|
|
44
|
+
deps: GuardStatusCommandDeps,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const editorHost = ctx.command as unknown as {
|
|
47
|
+
transform: (callback: (editor: {
|
|
48
|
+
add: (definition: {
|
|
49
|
+
name: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
execute: (input: CommandInvocation) => Promise<void>;
|
|
52
|
+
}) => void;
|
|
53
|
+
}) => void) => Promise<unknown>;
|
|
54
|
+
};
|
|
55
|
+
await editorHost.transform((editor) => {
|
|
56
|
+
editor.add({
|
|
57
|
+
name: GUARD_STATUS_COMMAND,
|
|
58
|
+
description: "Show Workflow Guard status: branch protection, mutation count, outstanding verification/review gates, and todo capability.",
|
|
59
|
+
execute: async ({ sessionID }) => {
|
|
60
|
+
let report: string;
|
|
61
|
+
try {
|
|
62
|
+
report = await deps.runStatus(sessionID);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
report = JSON.stringify({
|
|
65
|
+
error: `guard_status could not be computed: ${(error as Error)?.message ?? "unknown error"}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
audit({
|
|
69
|
+
ts: new Date().toISOString(),
|
|
70
|
+
sessionID,
|
|
71
|
+
tool: `command.${GUARD_STATUS_COMMAND}`,
|
|
72
|
+
decision: "allow",
|
|
73
|
+
phase: "event",
|
|
74
|
+
reason: "guard_status_command",
|
|
75
|
+
});
|
|
76
|
+
await runWithRuntimeState(deps.effectiveRoot, deps.client, async () => {
|
|
77
|
+
const session = ctx.session as unknown as {
|
|
78
|
+
synthetic: (input: { sessionID: string; text: string }) => Promise<unknown>;
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
await session.synthetic({ sessionID, text: `## Workflow Guard status\n\n\`\`\`json\n${report}\n\`\`\`` });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// A client that cannot admit synthetic messages still needs the
|
|
84
|
+
// report to be visible; fall back to a normal prompt.
|
|
85
|
+
try {
|
|
86
|
+
console.warn(`[workflow-guard] warn: /${GUARD_STATUS_COMMAND} synthetic delivery failed (${(error as Error)?.message ?? "unknown error"}); falling back to a prompt.`);
|
|
87
|
+
} catch {}
|
|
88
|
+
const sessionPrompt = ctx.session as unknown as {
|
|
89
|
+
prompt: (input: { sessionID: string; text: string }) => Promise<unknown>;
|
|
90
|
+
};
|
|
91
|
+
await sessionPrompt.prompt({ sessionID, text: `Report this guard status verbatim:\n\n${report}` });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getLiveControlPlaneRoots,
|
|
9
9
|
getSdkClient,
|
|
10
10
|
getSessionBoundWorkspace,
|
|
11
|
+
getSessionMutationCount,
|
|
11
12
|
getWorkspaceRoot,
|
|
12
13
|
isDocumentationRequired,
|
|
13
14
|
isReviewRequired,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
recordVerifyResult,
|
|
18
19
|
sessionMutationTimestamps,
|
|
19
20
|
sessionVerifyResults,
|
|
21
|
+
todoTaskStarts,
|
|
20
22
|
} from "./state.ts";
|
|
21
23
|
import {
|
|
22
24
|
dynamicShellSyntaxIn,
|
|
@@ -74,12 +76,31 @@ import {
|
|
|
74
76
|
effectiveTodos,
|
|
75
77
|
fetchSessionTodos,
|
|
76
78
|
hasActiveTodo,
|
|
79
|
+
readOnlyTodoCompletions,
|
|
80
|
+
recordTodoTaskStarts,
|
|
77
81
|
subagentMutationBudgetReason,
|
|
82
|
+
todoCompletionBlockReason,
|
|
83
|
+
unverifiedTodoCompletions,
|
|
78
84
|
validateTodoLifecycle,
|
|
79
85
|
} from "../policies/todo.ts";
|
|
86
|
+
import { recordedTodoCapability, reportTodoGateFailOpen } from "./v2-todo.ts";
|
|
80
87
|
|
|
81
88
|
export const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
|
|
82
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Tools that can change repository or workspace state, and so sit behind the
|
|
92
|
+
* Policy 25 research floor. Widening past the edit/shell sets matters because
|
|
93
|
+
* the guard's own lifecycle tools mutate the repository without a shell.
|
|
94
|
+
*/
|
|
95
|
+
export function isMutatingSurfaceTool(toolName: string): boolean {
|
|
96
|
+
return (
|
|
97
|
+
EDIT_TOOL_NAMES.has(toolName) ||
|
|
98
|
+
SHELL_TOOL_NAMES.has(toolName) ||
|
|
99
|
+
toolName.startsWith("guard_worktree_") ||
|
|
100
|
+
toolName === "guard_recovery_restore"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
83
104
|
// dynamic_shell_syntax is fail-closed by design; naming the construct and a
|
|
84
105
|
// remedy keeps agents from retrying near-identical commands. Keyed by the
|
|
85
106
|
// construct strings returned by dynamicShellSyntaxIn; unknown constructs fall
|
|
@@ -192,6 +213,22 @@ export async function guardToolCallImpl(
|
|
|
192
213
|
logPolicyBlock(`[workflow-guard] ${err}`);
|
|
193
214
|
return block("todo", "todo_lifecycle", err);
|
|
194
215
|
}
|
|
216
|
+
// Per-task completion evidence. Every task may enter `completed`
|
|
217
|
+
// only after the session mutated inside that task's window. The
|
|
218
|
+
// check is pure, so simulation reports the same verdict without
|
|
219
|
+
// journaling or moving any window.
|
|
220
|
+
const readOnlyCompleted: string[] = [];
|
|
221
|
+
if (context?.sessionID) {
|
|
222
|
+
const starts = todoTaskStarts(context.sessionID);
|
|
223
|
+
const total = getSessionMutationCount(context.sessionID);
|
|
224
|
+
const unverified = unverifiedTodoCompletions(existingTodos, newTodos, starts, total);
|
|
225
|
+
if (unverified.length > 0) {
|
|
226
|
+
const reason = todoCompletionBlockReason(unverified);
|
|
227
|
+
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
228
|
+
return block("todo", "todo_completion_unverified", reason);
|
|
229
|
+
}
|
|
230
|
+
if (!context.simulate) readOnlyCompleted.push(...readOnlyTodoCompletions(existingTodos, newTodos));
|
|
231
|
+
}
|
|
195
232
|
const allDone = newTodos.length > 0 && newTodos.every((t) => {
|
|
196
233
|
const s = String(t.status ?? "");
|
|
197
234
|
return s === "completed" || s === "cancelled";
|
|
@@ -227,6 +264,22 @@ export async function guardToolCallImpl(
|
|
|
227
264
|
}
|
|
228
265
|
}
|
|
229
266
|
}
|
|
267
|
+
// Only an accepted write becomes the task list, so task windows move
|
|
268
|
+
// and read-only marker use is journaled after every gate has passed.
|
|
269
|
+
if (context?.sessionID && !context.simulate) {
|
|
270
|
+
recordTodoTaskStarts(todoTaskStarts(context.sessionID), existingTodos, newTodos, getSessionMutationCount(context.sessionID));
|
|
271
|
+
if (readOnlyCompleted.length > 0) {
|
|
272
|
+
audit({
|
|
273
|
+
ts: new Date().toISOString(),
|
|
274
|
+
sessionID: context.sessionID,
|
|
275
|
+
tool: "todowrite",
|
|
276
|
+
decision: "allow",
|
|
277
|
+
phase: "event",
|
|
278
|
+
reason: "todo_completed_read_only",
|
|
279
|
+
input: { tasks: readOnlyCompleted, mutationCount: getSessionMutationCount(context.sessionID) },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
230
283
|
}
|
|
231
284
|
return allow();
|
|
232
285
|
}
|
|
@@ -309,6 +362,23 @@ export async function guardToolCallImpl(
|
|
|
309
362
|
logPolicyBlock(`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`);
|
|
310
363
|
return block("todo", "no_active_todo", "Blocked: no active todo item. First break the request down with the todowrite tool (create items with status 'pending' or 'in_progress'), then work them through, marking each completed via todowrite as you finish it. When every item is completed, create a fresh todo list before starting new work.");
|
|
311
364
|
}
|
|
365
|
+
if (todos === undefined) {
|
|
366
|
+
// Fail-open, but never silent: once per session, record that the
|
|
367
|
+
// gate did not enforce and why. A rails policy that quietly stops
|
|
368
|
+
// enforcing is indistinguishable from one that was removed.
|
|
369
|
+
const capability = recordedTodoCapability();
|
|
370
|
+
if (reportTodoGateFailOpen(context?.sessionID, capability)) {
|
|
371
|
+
audit({
|
|
372
|
+
ts: new Date().toISOString(),
|
|
373
|
+
sessionID: context?.sessionID,
|
|
374
|
+
tool: "todo.gate",
|
|
375
|
+
decision: "allow",
|
|
376
|
+
phase: "event",
|
|
377
|
+
reason: `todo_gate_fail_open_${capability}`,
|
|
378
|
+
input: { [toolName]: true },
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
312
382
|
if (context?.sessionID) {
|
|
313
383
|
const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
|
|
314
384
|
if (reason) {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host worktree port for the V2 native worktree API.
|
|
3
|
+
*
|
|
4
|
+
* The guard hand-rolls `git worktree add` and keeps its own storage directory,
|
|
5
|
+
* so worktrees it creates are invisible to the host's worktree inventory. The
|
|
6
|
+
* V2 API exposes `ctx.worktree` with `create`, `list`, `remove`, and `refresh`.
|
|
7
|
+
*
|
|
8
|
+
* Adoption is deliberately asymmetric, because the two paths do not carry the
|
|
9
|
+
* same guarantees:
|
|
10
|
+
*
|
|
11
|
+
* - **create** goes through the host first, so the worktree lands where the
|
|
12
|
+
* operator configured `worktree.directory` and appears in host inventory.
|
|
13
|
+
* The result is validated before it is accepted: the directory must exist,
|
|
14
|
+
* must be a worktree of THIS repository, and must not be the primary tree.
|
|
15
|
+
* Any failure falls back to the git implementation.
|
|
16
|
+
* - **remove** stays with the guard. The host's `remove` takes a `force` flag
|
|
17
|
+
* and has no snapshot step, so it can destroy uncommitted work that the
|
|
18
|
+
* guard's snapshot-then-remove flow preserves. After the guard removes a
|
|
19
|
+
* worktree, the port calls `refresh` so host inventory does not keep a
|
|
20
|
+
* stale entry.
|
|
21
|
+
* - **list** supplies the bounded, host-declared set of directories that
|
|
22
|
+
* cleanup may touch. This is the same widening authority Task 3 gave
|
|
23
|
+
* `hostWorktree`: the host says the directory is its worktree, and the
|
|
24
|
+
* guard still independently requires a git registration and a non-primary
|
|
25
|
+
* path.
|
|
26
|
+
*
|
|
27
|
+
* Unknown or unavailable host surfaces degrade to `undefined`, never to a
|
|
28
|
+
* guess. The git path stays the only authority for correctness.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Narrow, duck-typed view of `ctx.worktree` so this module stays testable. */
|
|
32
|
+
export interface HostWorktreeApi {
|
|
33
|
+
create(input: { projectID: string; branch?: string; from?: string; name?: string; directory?: string }): Promise<{ directory?: string } | undefined>;
|
|
34
|
+
list(input: { projectID: string }): Promise<ReadonlyArray<{ directory?: string }> | undefined>;
|
|
35
|
+
refresh(input: { projectID: string }): Promise<unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HostWorktreePort {
|
|
39
|
+
/** True when the host exposes a usable project id and the create/list calls. */
|
|
40
|
+
readonly available: boolean;
|
|
41
|
+
/** Project id used for every call, or undefined when the host did not supply one. */
|
|
42
|
+
readonly projectID: string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Create a worktree. `from` is an absolute DIRECTORY path, matching the host
|
|
45
|
+
* schema, not a git ref; callers that need a specific base ref use the git
|
|
46
|
+
* path instead of passing a ref here.
|
|
47
|
+
*/
|
|
48
|
+
create(input: { branch: string; from?: string }): Promise<{ directory?: string; error?: string }>;
|
|
49
|
+
list(): Promise<readonly string[] | undefined>;
|
|
50
|
+
refresh(): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const unavailable: HostWorktreePort = {
|
|
54
|
+
available: false,
|
|
55
|
+
projectID: undefined,
|
|
56
|
+
create: async () => ({ error: "host worktree API unavailable" }),
|
|
57
|
+
list: async () => undefined,
|
|
58
|
+
refresh: async () => {},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the port from a V2 plugin context. A missing API, a missing project id,
|
|
63
|
+
* or a throwing call all downgrade to the git path instead of failing the tool.
|
|
64
|
+
*/
|
|
65
|
+
export function createHostWorktreePort(ctx: unknown): HostWorktreePort {
|
|
66
|
+
const context = ctx as {
|
|
67
|
+
location?: { project?: { id?: unknown } };
|
|
68
|
+
worktree?: Partial<HostWorktreeApi>;
|
|
69
|
+
} | undefined;
|
|
70
|
+
const api = context?.worktree;
|
|
71
|
+
const rawProjectID = context?.location?.project?.id;
|
|
72
|
+
const projectID = typeof rawProjectID === "string" && rawProjectID.length > 0 ? rawProjectID : undefined;
|
|
73
|
+
if (!api || typeof api.create !== "function" || typeof api.list !== "function" || !projectID) {
|
|
74
|
+
return unavailable;
|
|
75
|
+
}
|
|
76
|
+
const create = api.create.bind(api);
|
|
77
|
+
const list = api.list.bind(api);
|
|
78
|
+
const refresh = typeof api.refresh === "function" ? api.refresh.bind(api) : undefined;
|
|
79
|
+
return {
|
|
80
|
+
available: true,
|
|
81
|
+
projectID,
|
|
82
|
+
create: async ({ branch, from }) => {
|
|
83
|
+
try {
|
|
84
|
+
const result = await create({ projectID, branch, ...(from ? { from } : {}), name: branch.replace(/[/\\:]/g, "-") });
|
|
85
|
+
const directory = typeof result?.directory === "string" && result.directory.length > 0 ? result.directory : undefined;
|
|
86
|
+
return directory ? { directory } : { error: "host worktree create returned no directory" };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { error: `host worktree create failed: ${(error as Error)?.message ?? String(error)}` };
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
list: async () => {
|
|
92
|
+
try {
|
|
93
|
+
const result = await list({ projectID });
|
|
94
|
+
if (!Array.isArray(result)) return undefined;
|
|
95
|
+
return result
|
|
96
|
+
.map((entry) => (typeof entry?.directory === "string" ? entry.directory : undefined))
|
|
97
|
+
.filter((directory): directory is string => Boolean(directory));
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
refresh: async () => {
|
|
103
|
+
if (!refresh) return;
|
|
104
|
+
try {
|
|
105
|
+
await refresh({ projectID });
|
|
106
|
+
} catch {}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host MCP server inventory for the guard's MCP policy.
|
|
3
|
+
*
|
|
4
|
+
* The MCP policy identifies a guarded live-system call by name tokens
|
|
5
|
+
* (`github`, `azure`, `ado`, ...). That static list covers the common servers,
|
|
6
|
+
* but it cannot learn a server the operator renamed, and it cannot tell when
|
|
7
|
+
* the host's roster changed.
|
|
8
|
+
*
|
|
9
|
+
* `ctx.mcp.list()` reports the servers for this location, and the
|
|
10
|
+
* `mcp.tools.changed` event says when that roster went stale. Together they
|
|
11
|
+
* widen the guarded token set from host-declared names only.
|
|
12
|
+
*
|
|
13
|
+
* Two deliberate limits:
|
|
14
|
+
*
|
|
15
|
+
* - **Widen-only.** A server the operator removed must not silently unguard a
|
|
16
|
+
* tool that still names it. Removal therefore contributes nothing; the
|
|
17
|
+
* static defaults in `policies/mcp.ts` remain the floor.
|
|
18
|
+
* - **Never a replacement for the policy.** A token learned here only reaches
|
|
19
|
+
* the same `mcpMutationTool` decision the static list already used. Nothing
|
|
20
|
+
* here can allow a call the policy would otherwise block.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { addGuardedMcpTokens, GUARDED_MCP_SERVERS } from "../policies/mcp.ts";
|
|
24
|
+
|
|
25
|
+
/** A server name counts as guarded when it names a known live-system product. */
|
|
26
|
+
const GUARDED_SERVER_TOKEN_RE =
|
|
27
|
+
/(?:^|[^a-z0-9])(?:github|gh|azure|azmcp|ado|devops|azuredevops)(?:[^a-z0-9]|$)/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Turn a host server name into tokens for the policy's token scan.
|
|
31
|
+
*
|
|
32
|
+
* Host names are free-form (`github`, `github-enterprise`, `Azure DevOps`), so
|
|
33
|
+
* a guarded server contributes its tokens rather than a single literal. A name
|
|
34
|
+
* that does not look guarded contributes nothing.
|
|
35
|
+
*/
|
|
36
|
+
export function guardedTokensFromServerName(serverName: string): string[] {
|
|
37
|
+
if (!serverName || !GUARDED_SERVER_TOKEN_RE.test(serverName.toLowerCase())) return [];
|
|
38
|
+
return serverName
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
41
|
+
.trim()
|
|
42
|
+
.split(" ")
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the MCP servers the host reports.
|
|
48
|
+
*
|
|
49
|
+
* `undefined` means "the host could not tell us", which is different from an
|
|
50
|
+
* empty roster. Callers must not treat unknown as "nothing is guarded".
|
|
51
|
+
*
|
|
52
|
+
* The 2.0.10 MCP domain types `list` with a location filter and returns
|
|
53
|
+
* `{ data: [...] }`; the duck-typed read tolerates a bare array too, because a
|
|
54
|
+
* shape mismatch must degrade to unknown rather than throw.
|
|
55
|
+
*/
|
|
56
|
+
export async function listHostMcpServerNames(ctx: unknown): Promise<string[] | undefined> {
|
|
57
|
+
const api = (ctx as { mcp?: { list?: (input?: unknown) => Promise<unknown> } } | undefined)?.mcp;
|
|
58
|
+
if (!api || typeof api.list !== "function") return undefined;
|
|
59
|
+
try {
|
|
60
|
+
const result = await api.list.call(api, {});
|
|
61
|
+
const data = (result as { data?: unknown } | undefined)?.data;
|
|
62
|
+
const entries = Array.isArray(data) ? data : Array.isArray(result) ? result : undefined;
|
|
63
|
+
if (!entries) return undefined;
|
|
64
|
+
return entries
|
|
65
|
+
.map((entry) => (typeof entry === "string" ? entry : (entry as { name?: unknown })?.name))
|
|
66
|
+
.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Merge host-declared server names into the guarded token set.
|
|
74
|
+
*
|
|
75
|
+
* Returns the tokens this call added, so the caller can journal widening once
|
|
76
|
+
* instead of logging the whole set on every event. `undefined` input (host
|
|
77
|
+
* could not answer) adds nothing and is not an error.
|
|
78
|
+
*/
|
|
79
|
+
export function recordHostMcpServers(serverNames: readonly string[] | undefined): string[] {
|
|
80
|
+
if (!serverNames) return [];
|
|
81
|
+
const tokens = serverNames.flatMap((name) => guardedTokensFromServerName(name));
|
|
82
|
+
return addGuardedMcpTokens(tokens);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Static defaults, for callers that only need to report the floor. */
|
|
86
|
+
export const STATIC_GUARDED_MCP_SERVERS = GUARDED_MCP_SERVERS;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V2 permission seat: `ctx.permission.hook("evaluate")`.
|
|
3
|
+
*
|
|
4
|
+
* Two enforcement seats, deliberately complementary:
|
|
5
|
+
*
|
|
6
|
+
* - `evaluate` sees the host's normalized permission check (an `action` plus
|
|
7
|
+
* its `resources`) before anything runs, so a denial there means the tool
|
|
8
|
+
* never starts. It does NOT see raw tool input, so tool-lifecycle work
|
|
9
|
+
* (todo transitions, stale-write observations, post-edit snapshots) stays in
|
|
10
|
+
* `execute.before`.
|
|
11
|
+
* - `execute.before` sees the raw arguments and aborts with a specific
|
|
12
|
+
* remediation message, but only after the permission layer has allowed.
|
|
13
|
+
*
|
|
14
|
+
* The seat can only tighten. An explicit configured `deny` is final: the host
|
|
15
|
+
* never invokes the hook for it (it resolves deny first and only calls the hook
|
|
16
|
+
* when the configured effect is allow/ask). A hook denial is therefore always a
|
|
17
|
+
* response to something the host would otherwise permit.
|
|
18
|
+
*
|
|
19
|
+
* Every decision here is a simulation of the policy core: `simulate: true`
|
|
20
|
+
* skips mutation accounting, file claims, verification runs, and durable audit
|
|
21
|
+
* writes, because the same call is evaluated for real in `execute.before`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { guardToolCallImpl } from "./guard-dispatcher.ts";
|
|
25
|
+
import { getSessionBoundWorkspace, runWithRuntimeState } from "./state.ts";
|
|
26
|
+
import { mcpMutationTool } from "../policies/mcp.ts";
|
|
27
|
+
import type { PolicyDecision } from "./types.ts";
|
|
28
|
+
|
|
29
|
+
/** The subset of the host's permission evaluation this seat reads. */
|
|
30
|
+
export interface PermissionSeatEvent {
|
|
31
|
+
readonly sessionID: string;
|
|
32
|
+
readonly agent?: string;
|
|
33
|
+
readonly action: string;
|
|
34
|
+
readonly resources: readonly string[];
|
|
35
|
+
readonly effect: "allow" | "deny" | "ask";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PermissionSeatDeps {
|
|
39
|
+
workspaceRoot: string;
|
|
40
|
+
client: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PermissionSeatResult {
|
|
44
|
+
/** The effect the seat leaves in place. */
|
|
45
|
+
effect: "allow" | "deny";
|
|
46
|
+
/** Set only when the seat denied. */
|
|
47
|
+
decision?: PolicyDecision;
|
|
48
|
+
/** The guard tool the action was mapped onto, for attribution. */
|
|
49
|
+
mappedTool?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EDIT_ACTIONS = new Set(["edit", "write", "patch", "apply_patch"]);
|
|
53
|
+
|
|
54
|
+
function mappedToolFor(action: string): { tool: string; input: (resource: string) => unknown } | undefined {
|
|
55
|
+
if (action === "shell" || action === "bash") {
|
|
56
|
+
return { tool: "bash", input: (resource) => ({ command: resource }) };
|
|
57
|
+
}
|
|
58
|
+
if (action === "read" || action === "read_file") {
|
|
59
|
+
return { tool: "read", input: (resource) => ({ filePath: resource }) };
|
|
60
|
+
}
|
|
61
|
+
if (EDIT_ACTIONS.has(action)) {
|
|
62
|
+
// `edit` is the canonical mutation tool name: it carries the tamper,
|
|
63
|
+
// secret, boundary, protected-branch, and todo checks.
|
|
64
|
+
return { tool: "edit", input: (resource) => ({ filePath: resource }) };
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Evaluate one host permission request. Returns the effect to leave in place.
|
|
71
|
+
*
|
|
72
|
+
* Unmapped actions (`external_directory`, `glob`, `grep`, `webfetch`,
|
|
73
|
+
* `websearch`, `question`, `subagent`, ...) stay with the host: config policy
|
|
74
|
+
* and the user prompt are the right seats for them, and the guard's shell,
|
|
75
|
+
* file, and MCP checks have nothing to add. The hook still journals them.
|
|
76
|
+
*/
|
|
77
|
+
export async function evaluatePermissionSeat(
|
|
78
|
+
event: PermissionSeatEvent,
|
|
79
|
+
deps: PermissionSeatDeps,
|
|
80
|
+
): Promise<PermissionSeatResult> {
|
|
81
|
+
if (event.effect === "deny") return { effect: "deny" };
|
|
82
|
+
const resources = event.resources.filter((resource): resource is string => typeof resource === "string" && resource.length > 0);
|
|
83
|
+
if (resources.length === 0) return { effect: "allow" };
|
|
84
|
+
const workspaceRoot = getSessionBoundWorkspace(event.sessionID) || deps.workspaceRoot;
|
|
85
|
+
const context = { sessionID: event.sessionID, agent: event.agent, worktree: workspaceRoot, simulate: true };
|
|
86
|
+
const decide = (tool: string, input: unknown): Promise<PolicyDecision> =>
|
|
87
|
+
runWithRuntimeState(workspaceRoot, deps.client, () => guardToolCallImpl(tool, input, context));
|
|
88
|
+
|
|
89
|
+
const mapping = mappedToolFor(event.action);
|
|
90
|
+
if (mapping) {
|
|
91
|
+
for (const resource of resources) {
|
|
92
|
+
const decision = await decide(mapping.tool, mapping.input(resource));
|
|
93
|
+
if (decision.status !== "allowed") {
|
|
94
|
+
return {
|
|
95
|
+
effect: "deny",
|
|
96
|
+
decision: { ...decision, message: `[workflow-guard permission.evaluate] ${decision.message}` },
|
|
97
|
+
mappedTool: mapping.tool,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { effect: "allow", mappedTool: mapping.tool };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `<server>_<tool>` MCP actions: only the guarded live-system servers are
|
|
105
|
+
// mapped (GitHub/Azure DevOps), matching the execute.before MCP policy.
|
|
106
|
+
for (const resource of resources) {
|
|
107
|
+
if (!mcpMutationTool(event.action) && !mcpMutationTool(resource)) continue;
|
|
108
|
+
const decision = await decide(event.action, {});
|
|
109
|
+
if (decision.status !== "allowed") {
|
|
110
|
+
return {
|
|
111
|
+
effect: "deny",
|
|
112
|
+
decision: { ...decision, message: `[workflow-guard permission.evaluate] ${decision.message}` },
|
|
113
|
+
mappedTool: event.action,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { effect: "allow", mappedTool: event.action };
|
|
117
|
+
}
|
|
118
|
+
return { effect: "allow" };
|
|
119
|
+
}
|