opencode-workflow-guard 1.15.5 → 1.16.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 +2 -1
- package/bin/opencode-workflow-guard.mjs +58 -37
- package/docs/installation.md +79 -4
- package/docs/managed-deployment.md +1 -1
- package/docs/policies.md +20 -5
- package/docs/policy-posture.md +171 -0
- package/docs/testing.md +4 -1
- 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 +69 -8
- package/src/lib/guard-command.ts +97 -0
- package/src/lib/guard-dispatcher.ts +56 -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 +167 -36
- package/src/lib/v2-plugin.ts +208 -22
- 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/mcp.ts +47 -2
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +221 -6
- package/src/workflow-guard.ts +17 -5
- package/test/test-e2e.mts +91 -6
- package/test/test.mts +838 -4
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-instance tool-call claiming.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode 2 merges plugin arrays, so naming the guard in both the global and
|
|
5
|
+
* the project config loads the same module twice under two ids. Both instances
|
|
6
|
+
* register `tool.execute.before`, which evaluated and audited every call twice
|
|
7
|
+
* and let a second instance's decision shadow the first.
|
|
8
|
+
*
|
|
9
|
+
* The first instance to reach a given (session, call, tool) owns the decision.
|
|
10
|
+
* A second instance for the same call defers instead of evaluating again, so a
|
|
11
|
+
* call produces exactly one policy decision and one decision audit record.
|
|
12
|
+
* Within a single instance nothing is skipped: the claim only deduplicates
|
|
13
|
+
* *across* instances, because one instance must still evaluate each of its own
|
|
14
|
+
* calls even if a host reuses call ids.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const MAX_CLAIMS = 4096;
|
|
18
|
+
// Process-global, not module-global: OpenCode can load the guard from two
|
|
19
|
+
// distinct paths (a cache copy plus a source checkout), and each copy
|
|
20
|
+
// evaluates this module separately. A `Symbol.for` slot on `globalThis` keeps
|
|
21
|
+
// ONE claim table so the second copy still defers to the first.
|
|
22
|
+
const CLAIMS_REGISTRY = Symbol.for("opencode-workflow-guard.call-claims.v1");
|
|
23
|
+
const globalRegistry = globalThis as unknown as Record<symbol, Map<string, string> | undefined>;
|
|
24
|
+
const claims: Map<string, string> = globalRegistry[CLAIMS_REGISTRY] ?? (globalRegistry[CLAIMS_REGISTRY] = new Map());
|
|
25
|
+
|
|
26
|
+
function claimKey(sessionID: string | undefined, callID: string | undefined, tool: string): string | undefined {
|
|
27
|
+
if (!sessionID || !callID) return undefined;
|
|
28
|
+
return `${sessionID}::${callID}::${tool}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Claim a tool call for `instanceKey`. Returns true when the caller owns the
|
|
33
|
+
* decision. Returns false when another instance already claimed it, which means
|
|
34
|
+
* the caller must defer (no evaluation, no audit) to keep one decision per call.
|
|
35
|
+
*/
|
|
36
|
+
export function claimToolCall(
|
|
37
|
+
instanceKey: string,
|
|
38
|
+
sessionID: string | undefined,
|
|
39
|
+
callID: string | undefined,
|
|
40
|
+
tool: string,
|
|
41
|
+
): boolean {
|
|
42
|
+
const key = claimKey(sessionID, callID, tool);
|
|
43
|
+
if (!key) return true;
|
|
44
|
+
const owner = claims.get(key);
|
|
45
|
+
if (owner === undefined) {
|
|
46
|
+
claims.set(key, instanceKey);
|
|
47
|
+
if (claims.size > MAX_CLAIMS) {
|
|
48
|
+
const oldest = claims.keys().next().value;
|
|
49
|
+
if (oldest !== undefined) claims.delete(oldest);
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return owner === instanceKey;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Release a claim after the call settles so the map stays bounded in long sessions. */
|
|
57
|
+
export function releaseToolCallClaim(
|
|
58
|
+
instanceKey: string,
|
|
59
|
+
sessionID: string | undefined,
|
|
60
|
+
callID: string | undefined,
|
|
61
|
+
tool: string,
|
|
62
|
+
): void {
|
|
63
|
+
const key = claimKey(sessionID, callID, tool);
|
|
64
|
+
if (!key) return;
|
|
65
|
+
if (claims.get(key) === instanceKey) claims.delete(key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Test seam. */
|
|
69
|
+
export function resetToolCallClaimsForTesting(): void {
|
|
70
|
+
claims.clear();
|
|
71
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Types for `config-writer.mjs`, the testable half of the `setup` command. */
|
|
2
|
+
|
|
3
|
+
export declare const PACKAGE_NAME: string;
|
|
4
|
+
export declare function pluginKeyFor(config: Record<string, unknown>, explicitMajor?: number): "plugin" | "plugins";
|
|
5
|
+
export declare function matchesPackageSpec(entry: unknown, spec: string): boolean;
|
|
6
|
+
export declare function preparePluginUpdate(
|
|
7
|
+
path: string,
|
|
8
|
+
schema: string,
|
|
9
|
+
spec: string,
|
|
10
|
+
legacySpec?: string,
|
|
11
|
+
explicitMajor?: number,
|
|
12
|
+
key?: "plugin" | "plugins",
|
|
13
|
+
): string | undefined;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config writing for `opencode-workflow-guard setup`.
|
|
3
|
+
*
|
|
4
|
+
* Split out of the bin script so the key-selection rules are testable without
|
|
5
|
+
* writing a live config. The bin stays a thin wrapper.
|
|
6
|
+
*
|
|
7
|
+
* Which key the target host reads:
|
|
8
|
+
*
|
|
9
|
+
* - OpenCode 1.x reads the singular `plugin` key.
|
|
10
|
+
* - OpenCode 2.x reads only the plural `plugins` key. The 2.0.10 config schema
|
|
11
|
+
* drops a singular `plugin` key without an error
|
|
12
|
+
* (`Schema.decodeUnknownSync(Config.Info)` returns an object with no `plugin`
|
|
13
|
+
* field), so writing the wrong key produces a config that looks configured
|
|
14
|
+
* and loads nothing.
|
|
15
|
+
*
|
|
16
|
+
* `--major` states the target explicitly. Without it, the existing config
|
|
17
|
+
* decides: a `plugins` key means 2.x, a `plugin` key means 1.x. A fresh config
|
|
18
|
+
* defaults to 2, because this package's host dependency is pinned to a 2.x
|
|
19
|
+
* release.
|
|
20
|
+
*
|
|
21
|
+
* `tui.json` is the exception. Its schema is singular `plugin` on both majors:
|
|
22
|
+
* OpenCode 2 reads `tui.json` with that key and migrates it into
|
|
23
|
+
* `cli.json.plugins`. Writing `plugins` there on 2.x produces a file the TUI
|
|
24
|
+
* schema ignores, so the TUI plugin silently never loads. Callers pass
|
|
25
|
+
* `key` to pin the TUI array's key independently of the server config.
|
|
26
|
+
*
|
|
27
|
+
* Evidence for that migration, since no TUI schema is vendored here: the
|
|
28
|
+
* installed 2.0.15 binary's cli-config migration step reads
|
|
29
|
+
* `join(config, "tui.json")`, decodes it with a schema whose only plugin field
|
|
30
|
+
* is singular `plugin`, and emits `plugins` into `cli.json`. The unit tests
|
|
31
|
+
* therefore pin the writer's key choice; the host behavior itself is
|
|
32
|
+
* host-version-dependent and is re-checked by `npm run test:install`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
36
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
37
|
+
|
|
38
|
+
export const PACKAGE_NAME = "opencode-workflow-guard";
|
|
39
|
+
|
|
40
|
+
/** Which plugin array key the target OpenCode major reads. */
|
|
41
|
+
export function pluginKeyFor(config, explicitMajor) {
|
|
42
|
+
const major = explicitMajor ?? (config.plugins !== undefined ? 2 : config.plugin !== undefined ? 1 : 2);
|
|
43
|
+
return major === 1 ? "plugin" : "plugins";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function matchesPackageSpec(entry, spec) {
|
|
47
|
+
const value = Array.isArray(entry) ? entry[0] : entry;
|
|
48
|
+
return typeof value === "string" && (value === spec || value.startsWith(`${spec}@`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Prepare an updated config document adding `spec` to the target's plugin
|
|
53
|
+
* array, removing a `legacySpec` entry when one is supplied.
|
|
54
|
+
*
|
|
55
|
+
* Returns `undefined` when the document already names the package, so an
|
|
56
|
+
* unchanged config is never rewritten. Throws on an unparseable document or a
|
|
57
|
+
* non-array plugin key rather than clobbering a config it does not understand.
|
|
58
|
+
*/
|
|
59
|
+
export function preparePluginUpdate(path, schema, spec, legacySpec, explicitMajor, key) {
|
|
60
|
+
const source = existsSync(path)
|
|
61
|
+
? readFileSync(path, "utf8")
|
|
62
|
+
: `{
|
|
63
|
+
"$schema": "${schema}"
|
|
64
|
+
}\n`;
|
|
65
|
+
const errors = [];
|
|
66
|
+
const config = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
|
|
67
|
+
if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) {
|
|
68
|
+
throw new Error(`Cannot update invalid config: ${path}`);
|
|
69
|
+
}
|
|
70
|
+
const targetKey = key ?? pluginKeyFor(config, explicitMajor);
|
|
71
|
+
if (config[targetKey] !== undefined && !Array.isArray(config[targetKey])) {
|
|
72
|
+
throw new Error(`Cannot update ${path}: "${targetKey}" must be an array`);
|
|
73
|
+
}
|
|
74
|
+
const plugins = config[targetKey] ?? [];
|
|
75
|
+
const hasSpec = plugins.some((entry) => matchesPackageSpec(entry, PACKAGE_NAME));
|
|
76
|
+
const updatedPlugins = legacySpec
|
|
77
|
+
? plugins.filter((entry) => !matchesPackageSpec(entry, legacySpec))
|
|
78
|
+
: plugins;
|
|
79
|
+
if (updatedPlugins.length !== plugins.length) {
|
|
80
|
+
if (!hasSpec) updatedPlugins.push(spec);
|
|
81
|
+
return applyEdits(source, modify(source, [targetKey], updatedPlugins, {
|
|
82
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
if (hasSpec) return undefined;
|
|
86
|
+
return applyEdits(source, modify(source, [targetKey, plugins.length], spec, {
|
|
87
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
88
|
+
}));
|
|
89
|
+
}
|
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) {
|
|
@@ -121,8 +123,25 @@ export function createCustomTools(options: {
|
|
|
121
123
|
portableMemoryPath: string;
|
|
122
124
|
learningInterventions: Map<string, number>;
|
|
123
125
|
client: unknown;
|
|
126
|
+
/**
|
|
127
|
+
* Host worktree port. When present and available, creation goes through the
|
|
128
|
+
* host so the worktree lands in its configured directory and inventory.
|
|
129
|
+
* Removal stays with the guard's snapshot-then-remove flow regardless.
|
|
130
|
+
*/
|
|
131
|
+
hostWorktree?: HostWorktreePort;
|
|
132
|
+
/**
|
|
133
|
+
* Probed todo capability for this host, reported in `guard_status`.
|
|
134
|
+
* Omitted on V1, which has a native todo endpoint and therefore always can
|
|
135
|
+
* plan; V2 passes its registry probe so a fail-open gate is visible.
|
|
136
|
+
*/
|
|
137
|
+
todoCapability?: () => string;
|
|
138
|
+
/**
|
|
139
|
+
* Live host version, reported next to the version this build was qualified
|
|
140
|
+
* against so a mismatch is visible before it becomes a silent no-op.
|
|
141
|
+
*/
|
|
142
|
+
hostVersion?: () => string | undefined;
|
|
124
143
|
}) {
|
|
125
|
-
const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
|
|
144
|
+
const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client, hostWorktree, todoCapability, hostVersion } = options;
|
|
126
145
|
// Resolve the workspace for a tool call from the CALL's own context: an
|
|
127
146
|
// explicit argument first, then the host-provided per-call worktree or
|
|
128
147
|
// directory (even when unusable — the call declared its target), then the
|
|
@@ -276,7 +295,8 @@ export function createCustomTools(options: {
|
|
|
276
295
|
recommendedActions.push("All operational requirements are satisfied for task finalization and PR creation.");
|
|
277
296
|
}
|
|
278
297
|
const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
|
|
279
|
-
|
|
298
|
+
const taskEvidence = await runWithRuntimeState(root, client, () => todoEvidenceReport(toolContext.sessionID));
|
|
299
|
+
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
300
|
},
|
|
281
301
|
}),
|
|
282
302
|
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 +394,58 @@ export function createCustomTools(options: {
|
|
|
374
394
|
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
395
|
execute: async (args, toolContext) => {
|
|
376
396
|
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);
|
|
397
|
+
const toolRoot = await resolveCallWorkspace(toolContext);
|
|
398
|
+
// Host first: it honors the operator's configured worktree directory
|
|
399
|
+
// and keeps the worktree in host inventory. The result is validated
|
|
400
|
+
// independently (registered worktree of THIS repo, not the primary
|
|
401
|
+
// tree); any failure falls back to the guard's git path.
|
|
402
|
+
//
|
|
403
|
+
// The host path is used only for the default base. The host create
|
|
404
|
+
// input's `from` is an absolute directory path, not a git ref, so an
|
|
405
|
+
// explicit `baseBranch` cannot be expressed there; honoring it via
|
|
406
|
+
// the git path is better than silently ignoring it.
|
|
407
|
+
let createdPath: string | undefined;
|
|
408
|
+
let hostNote = "";
|
|
409
|
+
const requestedBase = args.baseBranch ?? "HEAD";
|
|
410
|
+
if (hostWorktree?.available && requestedBase === "HEAD") {
|
|
411
|
+
const hostResult = await hostWorktree.create({ branch: args.branch });
|
|
412
|
+
if (hostResult.directory) {
|
|
413
|
+
const validation = validateHostWorktreeDirectory(hostResult.directory, toolRoot);
|
|
414
|
+
if (validation.ok) {
|
|
415
|
+
createdPath = resolve(hostResult.directory);
|
|
416
|
+
hostNote = " (created by the host worktree strategy)";
|
|
417
|
+
} else {
|
|
418
|
+
hostNote = ` Host worktree creation was rejected by the guard (${validation.error}); used the guard's git path instead.`;
|
|
419
|
+
}
|
|
420
|
+
} else if (hostResult.error) {
|
|
421
|
+
hostNote = ` Host worktree creation failed (${hostResult.error}); used the guard's git path instead.`;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (!createdPath) {
|
|
425
|
+
const res = createGitWorktree(args.branch, requestedBase, toolRoot);
|
|
426
|
+
if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}${hostNote}`;
|
|
427
|
+
createdPath = res.worktreePath;
|
|
428
|
+
}
|
|
378
429
|
const mutationRoot = await resolveCallWorkspace(toolContext);
|
|
379
430
|
await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
|
|
380
|
-
return `[workflow-guard] Worktree created successfully at: ${
|
|
431
|
+
return `[workflow-guard] Worktree created successfully at: ${createdPath}${hostNote}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
|
|
381
432
|
},
|
|
382
433
|
}),
|
|
383
434
|
guard_worktree_cleanup: tool({
|
|
384
435
|
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
436
|
execute: async (args, toolContext) => {
|
|
386
437
|
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);
|
|
438
|
+
const toolRoot = await resolveCallWorkspace(toolContext);
|
|
439
|
+
// Removal never delegates to the host: its `remove` takes a `force`
|
|
440
|
+
// flag and has no snapshot step, so it can destroy uncommitted work
|
|
441
|
+
// the guard's snapshot-then-remove flow preserves. The host list is
|
|
442
|
+
// used only to authorize a directory outside the guard's storage.
|
|
443
|
+
const hostWorktrees = hostWorktree?.available ? await hostWorktree.list() : undefined;
|
|
444
|
+
const res = cleanupGitWorktree(args.worktreePath, toolRoot, { hostWorktrees });
|
|
445
|
+
if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
|
|
446
|
+
// Tell the host its inventory is stale, or it keeps listing the
|
|
447
|
+
// removed directory until something else refreshes it.
|
|
448
|
+
await hostWorktree?.refresh();
|
|
388
449
|
const mutationRoot = await resolveCallWorkspace(toolContext);
|
|
389
450
|
await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
|
|
390
451
|
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,9 +76,14 @@ 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
|
|
|
@@ -192,6 +199,22 @@ export async function guardToolCallImpl(
|
|
|
192
199
|
logPolicyBlock(`[workflow-guard] ${err}`);
|
|
193
200
|
return block("todo", "todo_lifecycle", err);
|
|
194
201
|
}
|
|
202
|
+
// Per-task completion evidence. Every task may enter `completed`
|
|
203
|
+
// only after the session mutated inside that task's window. The
|
|
204
|
+
// check is pure, so simulation reports the same verdict without
|
|
205
|
+
// journaling or moving any window.
|
|
206
|
+
const readOnlyCompleted: string[] = [];
|
|
207
|
+
if (context?.sessionID) {
|
|
208
|
+
const starts = todoTaskStarts(context.sessionID);
|
|
209
|
+
const total = getSessionMutationCount(context.sessionID);
|
|
210
|
+
const unverified = unverifiedTodoCompletions(existingTodos, newTodos, starts, total);
|
|
211
|
+
if (unverified.length > 0) {
|
|
212
|
+
const reason = todoCompletionBlockReason(unverified);
|
|
213
|
+
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
214
|
+
return block("todo", "todo_completion_unverified", reason);
|
|
215
|
+
}
|
|
216
|
+
if (!context.simulate) readOnlyCompleted.push(...readOnlyTodoCompletions(existingTodos, newTodos));
|
|
217
|
+
}
|
|
195
218
|
const allDone = newTodos.length > 0 && newTodos.every((t) => {
|
|
196
219
|
const s = String(t.status ?? "");
|
|
197
220
|
return s === "completed" || s === "cancelled";
|
|
@@ -227,6 +250,22 @@ export async function guardToolCallImpl(
|
|
|
227
250
|
}
|
|
228
251
|
}
|
|
229
252
|
}
|
|
253
|
+
// Only an accepted write becomes the task list, so task windows move
|
|
254
|
+
// and read-only marker use is journaled after every gate has passed.
|
|
255
|
+
if (context?.sessionID && !context.simulate) {
|
|
256
|
+
recordTodoTaskStarts(todoTaskStarts(context.sessionID), existingTodos, newTodos, getSessionMutationCount(context.sessionID));
|
|
257
|
+
if (readOnlyCompleted.length > 0) {
|
|
258
|
+
audit({
|
|
259
|
+
ts: new Date().toISOString(),
|
|
260
|
+
sessionID: context.sessionID,
|
|
261
|
+
tool: "todowrite",
|
|
262
|
+
decision: "allow",
|
|
263
|
+
phase: "event",
|
|
264
|
+
reason: "todo_completed_read_only",
|
|
265
|
+
input: { tasks: readOnlyCompleted, mutationCount: getSessionMutationCount(context.sessionID) },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
230
269
|
}
|
|
231
270
|
return allow();
|
|
232
271
|
}
|
|
@@ -309,6 +348,23 @@ export async function guardToolCallImpl(
|
|
|
309
348
|
logPolicyBlock(`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`);
|
|
310
349
|
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
350
|
}
|
|
351
|
+
if (todos === undefined) {
|
|
352
|
+
// Fail-open, but never silent: once per session, record that the
|
|
353
|
+
// gate did not enforce and why. A rails policy that quietly stops
|
|
354
|
+
// enforcing is indistinguishable from one that was removed.
|
|
355
|
+
const capability = recordedTodoCapability();
|
|
356
|
+
if (reportTodoGateFailOpen(context?.sessionID, capability)) {
|
|
357
|
+
audit({
|
|
358
|
+
ts: new Date().toISOString(),
|
|
359
|
+
sessionID: context?.sessionID,
|
|
360
|
+
tool: "todo.gate",
|
|
361
|
+
decision: "allow",
|
|
362
|
+
phase: "event",
|
|
363
|
+
reason: `todo_gate_fail_open_${capability}`,
|
|
364
|
+
input: { [toolName]: true },
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
312
368
|
if (context?.sessionID) {
|
|
313
369
|
const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
|
|
314
370
|
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
|
+
}
|