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.
@@ -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. Check `guard_status` to inspect branch protection, mutation count, and outstanding verification/review gates.",
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
- return JSON.stringify({ pluginVersion: loadedPluginVersion() ?? null, workspaceRoot: root, branch, onProtectedBranch: isProtected, 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);
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); const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
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: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
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); const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
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
+ }