opencode-workflow-guard 1.17.0 → 1.18.1

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.
@@ -6,8 +6,14 @@
6
6
 
7
7
  ## Requirements
8
8
 
9
- - **OpenCode >= 1.18** (V1: requires the `GET /session/:id/todo` endpoint and the V1 `PluginModule` loader) or **OpenCode 2.x**. V2 loads through the `@opencode/plugin` API. The current todo list is reconstructed from session message history, or read from the native `todowrite` on 2.1.x when `experimental.todo` is enabled.
9
+ - **OpenCode >= 1.18** (V1: requires the `GET /session/:id/todo` endpoint and the V1 `PluginModule` loader) or **OpenCode 2.x**. V2 loads through the `@opencode/plugin` API. The current todo list is reconstructed from session message history, or read from the native `todowrite` behind `experimental.todo`. The guard probes the host tool registry for that capability instead of assuming it per version.
10
10
  - **Node.js >= 22.18** or **Bun** (for running test scripts directly).
11
+ `@opentui/core` declares `node >=26.4.0` in its own `engines`, and that has
12
+ been true since at least `0.6.6`. It arrives as a transitive dependency of
13
+ `@opentui/solid`, so npm reports an engine warning rather than refusing the
14
+ install, and the server plugin never imports it. Only the optional TUI
15
+ companion loads `@opentui/solid`. The pin set is exercised on Node 22.18+ in
16
+ CI, which is the practical floor for the plugin itself.
11
17
 
12
18
  ### Version Qualification
13
19
 
@@ -22,13 +28,24 @@ verified it.
22
28
  | 1.16.x | 2.0.15 | `permission.hook("evaluate")`, `tool.hook("execute.before"/"execute.after")`, `session.hook("context")`, `experimental.text.complete`, command registration, `mcp.list`, `worktree` client | Unit and package-loader verified. Live model-driven probes reached the provider and were classified `live unavailable` (`Model unavailable: openrouter/openrouter/auto`), so no guard verdict was produced. |
23
29
  | 1.16.x | 2.1.3 | `permission.hook("evaluate")`, `tool.hook(...)`, `session.hook(...)`, `experimental.text.complete`, command registration, `mcp.list`, `worktree` client, `todowrite` behind `experimental.todo` | Package-loader and headless-runtime verified (`npm run test:install`: 30 passed). Live host probe confirmed `ctx.tool.list()` exposes `todowrite` with `experimental.todo: true` and omits it when false. Live static-policy denial canary produced `Blocked by configuration policy`. |
24
30
  | 1.16.x | 1.18.32 (`@opencode-ai/plugin`) | V1 `PluginModule` loader, native todo endpoint | Compatibility export only; unit-tested, no live host probe. |
25
-
26
- The 2.1.3 pins require the 2.1.x package set. `@opencode/plugin` and
27
- `@opencode/ai` are pinned to `2.1.3`, which depends on `effect@4.0.1` and
28
- requires `@opentui/solid >=0.6.6`. The guard pins `0.6.6`. A 2.0.x pin set on a
29
- 2.1.x host fails to load at all. The plugin imports an `effect` path the host no
30
- longer provides, and the host reports `failed to load plugin` while every
31
- policy stays inert.
31
+ | 1.18.x | 2.2.0 | `permission.hook("evaluate")`, `tool.hook("execute.before"/"execute.after")`, `session.hook("context"/"compaction"/"prompt")`, `command.transform`, `shell.hook("create.before")`, `event.subscribe()` (completion-claim observation via `session.text.ended`), command registration, `mcp.list`, `worktree` client, `todowrite` behind `experimental.todo` | Package-loader and headless-runtime verified on the 2.2.0 pin set (`npm run test:install`: 32 passed). Registration of the hook seats and custom tools is asserted, not re-derived: the 2.2.0-specific probes are the permission seat (a real finding denies, an undecidable push abstains) and the todo capability (both branches asserted on this host). V2 exposes completion claims through `event.subscribe()` rather than the V1 `experimental.text.complete` hook; the audit trail keeps the V1 tool name for continuity. Live model-driven probes were skipped, so no guard verdict from a model turn is claimed here. |
32
+
33
+ The 2.2.0 pins require the 2.2.x package set. `@opencode/plugin` and
34
+ `@opencode/ai` are pinned to `2.2.0`, and `@opentui/solid` moves to `0.7.0`
35
+ because `@opencode/plugin@2.2.0` declares that as an exact peer requirement.
36
+ `@opentui/core` is not pinned directly: `@opentui/solid@0.7.0` already declares
37
+ it as an exact dependency, so resolution supplies the matching version. That
38
+ release also moves `Message` out of the `@opencode/ai` root: on 2.2.0 the root
39
+ no longer re-exports the schema surface, so a root named import of `Message`
40
+ throws at load, and the schema lives behind `@opencode/ai/effect`. The guard
41
+ imports from `/effect`, which is the path `@opencode/plugin@2.2.0` uses itself.
42
+ `@opencode/ai@2.1.3` resolves `./effect` only through its `"./*"` wildcard, and
43
+ that release ships no `dist/effect.js` for the wildcard to reach, so the two pin
44
+ sets are not interchangeable: one release's import path does not exist in the
45
+ other.
46
+
47
+ `@opencode-ai/plugin` stays at `1.19.3`. That is the V1 pin, emitted alongside
48
+ the V2 entrypoint, and the 2.2.0 release does not change it.
32
49
 
33
50
  `guard_status` reports `pluginVersion`, `qualifiedHostVersion`, and the live
34
51
  `hostVersion`, so a mismatch is visible at runtime rather than surfacing as a
@@ -36,21 +53,29 @@ silently inert hook.
36
53
 
37
54
  ### Dependency Audit Overrides
38
55
 
39
- Two transitive dependencies are pinned above the range their parent declares, to
40
- clear advisories that would otherwise ship:
56
+ Three transitive dependencies are pinned above the range their parent declares,
57
+ to clear advisories that would otherwise ship:
41
58
 
42
59
  | Package | Parent constraint | Override | Why |
43
60
  |---|---|---|---|
44
- | `solid-js` | `@opentui/solid@0.6.6` peer-pins exactly `1.9.12` | `1.9.17` | `1.9.12` requires `seroval ~1.5.0`, which carries memory-exhaustion and thenable-assimilation advisories. `1.9.17` requires `seroval ~1.6.8`, which is patched. The TUI imports `solid-js` directly, so the bump is also the version the guard actually runs. |
61
+ | `solid-js` | `@opentui/solid@0.7.0` peer-pins exactly `1.9.12` | `1.9.17` | `1.9.12` requires `seroval ~1.5.0`, which carries memory-exhaustion and thenable-assimilation advisories. `1.9.17` requires `seroval ~1.6.8`, which is patched. The TUI imports `solid-js` directly, so the bump is also the version the guard actually runs. |
45
62
  | `toml` | `effect@4.0.0-beta.83` (via `@opencode-ai/plugin`) requires `^4.1.1` | `5.0.1` | `toml <=5.0.0` carries three quadratic-complexity denial-of-service advisories. `5.0.1` keeps the same single `parse` export the caller uses. The guard itself never parses TOML; this is host plumbing. |
63
+ | `zod` | `@opencode-ai/plugin@1.19.3` pins `4.1.8` exactly | `4.6.6` | `GHSA-mrhx-c7xc-m3w5` (published 2026-10-01) covers `zod <4.6.6` with a memory-exhaustion advisory. `4.6.6` is the patched release in the same major line. `zod` reaches the tree only through the V1 host pin, but the guard's own V2 entrypoint imports it, so one deduped patched copy serves both. An override is required where a direct dependency would not do: the V1 pin is exact, so declaring `zod` at the root alone leaves a nested `4.1.8` and the advisory standing. |
46
64
 
47
- Both are `overrides` entries in `package.json`, and `npm audit` reports zero
65
+ All three are `overrides` entries in `package.json`, and `npm audit` reports zero
48
66
  vulnerabilities for this repository. `npm overrides` apply only to the root
49
67
  project, so they protect this repository's install and its CI, not a consumer's
50
68
  transitive tree. A consumer that installs the published package resolves the
51
- parent-declared ranges unless it declares the same overrides itself. Revisit both
52
- when `@opentui/solid` or `@opencode-ai/plugin` widen their ranges, and drop the
53
- override rather than carrying it forward.
69
+ parent-declared ranges unless it declares the same overrides itself. Revisit
70
+ them when `@opentui/solid` or `@opencode-ai/plugin` widen their ranges, and drop
71
+ the override rather than carrying it forward.
72
+
73
+ Changing an override requires a clean reinstall. `npm install` over an existing
74
+ `node_modules` can leave a lockfile that still resolves the old version, and the
75
+ resulting failure reads like an incompatibility. With the `zod` override, an
76
+ incremental install produced 14 `test:install` failures and a `tsc` error about
77
+ a missing `zod`, while removing `node_modules` and `package-lock.json` and
78
+ reinstalling passed every check.
54
79
 
55
80
  ---
56
81
 
package/docs/policies.md CHANGED
@@ -104,6 +104,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
104
104
  - When the agent attempts to mark **every** task completed (finalizing the request), the guard requires passing verification evidence.
105
105
  - Verification executes in an isolated environment with scrubbed credentials, output caps, token-efficient stdout/stderr snipping, and strict timeout protection. The command comes from the `WORKFLOW_GUARD_VERIFY` env, the project `verifyCommand`, or an auto-detected test or typecheck runner across ecosystems. Auto-detection covers `package.json` (`npm test`, `npm run typecheck`, `npm run check`, `npm run build`), `Cargo.toml` (`cargo test`), `go.mod` (`go test ./...`), `pytest.ini` or `pyproject.toml` (`pytest`), and `deno.json` (`deno test`). Environment configuration takes precedence over project configuration.
106
106
  - The verification timeout defaults to 30 seconds. Raise it per project with `verifyTimeoutMs` in `.opencode/workflow-guard.json`, or per environment with `WORKFLOW_GUARD_VERIFY_TIMEOUT_MS`, which wins over project config. Non-positive values are ignored and fall back to the default. Raise the timeout for suites that legitimately exceed the default, for example this repository's own unit suite.
107
+ - A timeout is reported as `verification_timed_out`, not `verification_failed`. Both block finalization, because neither produced a passing result. They differ in what the agent is told: a timeout says the result is unknown and names the limit and the remedy, while a failure names the failing output. Calling a timeout a failure sent agents looking for an assertion that never ran, and the fix for a timeout is a longer limit rather than a code change.
107
108
  - Project `postEditValidators` can run faster targeted checks after a direct file edit actually changes the target. Each entry accepts a workspace-relative glob `pattern` with `*`, `**`, and `?` wildcards. Brace expansion and character classes are not supported. Each entry also accepts a non-empty `command` and an optional positive `timeoutMs`. Invalid entries are reported as advisory feedback rather than silently ignored. Matching commands run from the workspace root with the same scrubbed verification environment. Validator failures and timeouts are appended to the edit result as advisory feedback. They do not replace the blocking whole-project finalization verification. Multi-file `apply_patch` calls validate every changed target.
108
109
  - With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list. Subagents and generated continuation prompts do not replace them. At `session.idle`, the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
109
110
  - Automatic continuation remains synthetic. By default, an eligible root session with unfinished owned todos briefly waits for OpenCode's native asynchronous title generation before that continuation is sent. Set `titleSettleWorkaround: false` to opt out. The bounded wait never rewrites titles or provider and model parameters.
@@ -130,11 +131,12 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
130
131
  - Permission prompts are journaled at request time via the typed `permission.ask` plugin hook. `permission.replied` outcomes, including rejections, are preserved instead of being labeled as allows.
131
132
  - On OpenCode 2, the V2 plugin also *decides* at that seat. `ctx.permission.hook("evaluate")` maps the host's normalized `action` and `resources` back onto the policy core and sets `effect = "deny"` with an attributable message, so the tool never starts. Shell resources feed the shell, git, destructive, and workspace policies. `read` resources feed the secret policy. `edit`, `write`, `patch`, and `apply_patch` resources feed the boundary, tamper, secret, protected-branch, and todo policies. Guarded MCP servers (`GitHub`, `Azure DevOps`) feed the MCP policy.
132
133
  - The seat can only tighten. An explicit configured `deny` is final and never reaches the hook. An unmapped host action (`external_directory`, `glob`, `grep`, `webfetch`, `websearch`, `question`, `subagent`) is left to host policy. If the seat itself throws, it fails closed with a deny instead of becoming a pass.
134
+ - **Only a finding denies; an undecidable simulation abstains.** The seat always evaluates the policy core with `simulate: true`, which skips the remote GitHub/Azure PR probe on purpose. The core reports `needs_approval` (`remote_state_unchecked`) when it cannot decide without that probe. Collapsing that status into a denial blocked every non-protected `git push`, so the seat now abstains: it leaves the host's configured effect and message untouched and defers the real decision to `execute.before`, which runs the probe. The abstention is journaled with `decision: "abstain"`, `reason: "deferred_to_execute_before"`, and the preserved `needs_approval` decision, so a skipped probe stays visible instead of reading as a plain allow. `guard_why` keeps reporting `needs_approval`; this is a change to the seat, not to the policy core.
133
135
  - `execute.before` is kept as the second, last-resort seat. It sees raw tool input, so todo lifecycle, stale-write observations, and post-edit snapshots stay there. Its thrown message carries the specific remediation text to the model.
134
136
  - `client.app.log()` complements the on-disk trail with in-app logs.
135
137
 
136
138
  ### 15. Compaction State & Policy Context Preservation
137
- - Integrates with OpenCode's compaction hook to inject bounded active policy state before context summarization. The hook is `experimental.session.compacting` on OpenCode 1.x and `session.hook("compaction")` on OpenCode 2.x, which is stable in the installed 2.1.3 typings:
139
+ - Integrates with OpenCode's compaction hook to inject bounded active policy state before context summarization. The hook is `experimental.session.compacting` on OpenCode 1.x and `session.hook("compaction")` on OpenCode 2.x, which is stable in the installed 2.2.0 typings:
138
140
  - Caps injected continuity context at 8,000 characters. Operational guard state is preserved first. The remaining budget holds active tasks, review follow-ups, and fresh local project memory.
139
141
  - Active `todowrite` tasks with status badges and subagent hierarchy attribution.
140
142
  - Active Git branch name and protected branch status.
package/docs/testing.md CHANGED
@@ -47,12 +47,13 @@ npm run test:all
47
47
  | **Policy 5: MCP Mutations** | Verifies GitHub/Azure mutating MCP tools are blocked while read-only tools pass. |
48
48
  | **Policy 6: Settings Tamper** | Verifies protection of `opencode.json`, `~/.config/opencode/*`, and `opencode auth`. |
49
49
  | **Policy 8: Workspace Boundary** | Verifies path traversal (`../`), symlinks, and absolute path escape protection in `edit`, `write`, `apply_patch`, and shell mutations. |
50
- | **Policy 10: Evidence Verification** | Verifies isolated verification execution, timeout handling, scrubbed environment, and finalization gating on fresh passing tests. |
50
+ | **Policy 10: Evidence Verification** | Verifies isolated verification execution, timeout handling, scrubbed environment, and finalization gating on fresh passing tests. A timeout blocks finalization under its own code, and its reason names the limit and the remedy instead of claiming a failing test. |
51
51
  | **Adversarial Invariants** | Verifies defense against compound shell mutations, symlink escapes, chained git normalization, verify script abuse, and external repo mutations. |
52
52
  | **Audit & Permission Events** | Verifies the typed `permission.ask` hook, permission replies, and command journaling in the audit trail. |
53
53
  | **Completion Claims & Tool Honesty** | Verifies the claims-vs-evidence detector (missing/failing/stale/fresh states), `experimental.text.complete` journaling, and `tool.definition` enrichment idempotence. |
54
54
  | **Compaction & TUI Feedback** | Verifies active task injection in `experimental.session.compacting`, the static TUI badge, guard block warning toasts, and clean error propagation. |
55
55
  | **Runtime Instance Isolation** | Verifies concurrent plugin instances keep workspace root and SDK client state isolated via `runWithRuntimeState`. |
56
+ | **V2 Permission Seat Abstention** | Verifies the simulated seat denies a real finding, abstains on `needs_approval` (`remote_state_unchecked`) instead of denying, never upgrades a configured `ask`, and journals the abstention as `deferred_to_execute_before` with the preserved decision. |
56
57
  | **V2 Todo Capability** | Verifies the `todowrite` registry probe (`available` / `unavailable` / `unknown`), that an empty history gates only when planning is possible, the once-per-session fail-open warning and audit record, and `todoCapability` in `guard_status`. |
57
58
  | **Per-Task Completion Evidence** | Verifies a task cannot move to `completed` without a mutation in its own window, that one mutation cannot vouch for several completions, that staggered windows reject a claim whose window saw no work, that mutations before a task became active never count, that `[read-only]` completes freely and is journaled, that the marker can be appended to an already-planned task without tripping the no-silent-deletion rule, that duplicate content cannot hide a new completion, and that `guard_why` reports the same verdict while moving no window. |
58
59
  | **Static Policy Posture** | Verifies the emitted posture is deny-only, emits both host envelopes (`experimental.policies`/`permission` for 2.0.x, top-level `policies`/`tool.use` for 2.1.x), requires both to reach the same verdict for every probed resource, and matches the intended commands under a replica of the host's whole-string glob matcher, including the compound-command evasion it does not cover. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.17.0",
3
+ "version": "1.18.1",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,10 +51,10 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@opencode-ai/plugin": "1.19.3",
54
- "@opencode/plugin": "2.1.3",
55
- "@opentui/solid": "0.6.6",
56
- "jsonc-parser": "3.3.1",
57
- "@opencode/ai": "2.1.3"
54
+ "@opencode/ai": "2.2.0",
55
+ "@opencode/plugin": "2.2.0",
56
+ "@opentui/solid": "0.7.0",
57
+ "jsonc-parser": "3.3.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@changesets/cli": "3.0.3",
@@ -63,6 +63,7 @@
63
63
  },
64
64
  "overrides": {
65
65
  "solid-js": "1.9.17",
66
- "toml": "5.0.1"
66
+ "toml": "5.0.1",
67
+ "zod": "4.6.6"
67
68
  }
68
69
  }
@@ -153,6 +153,17 @@ function logBlock(message: string, simulate = false): void {
153
153
  } catch {}
154
154
  }
155
155
 
156
+ /**
157
+ * Render a verification timeout limit for the block reason.
158
+ *
159
+ * Sub-second limits are exercised by tests, and a bare `Math.round(ms/1000)`
160
+ * reports those as "0s", which reads as no limit at all.
161
+ */
162
+ function formatTimeoutLimit(timeoutMs: number | undefined): string {
163
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return "the verification timeout";
164
+ return timeoutMs < 1000 ? `${timeoutMs}ms` : `${Math.round(timeoutMs / 1000)}s`;
165
+ }
166
+
156
167
  export async function guardToolCallImpl(
157
168
  toolName: string,
158
169
  input: unknown,
@@ -256,10 +267,20 @@ export async function guardToolCallImpl(
256
267
  const result = await runVerify(command, currentRoot, resolveVerifyTimeoutMs(currentRoot));
257
268
  recordVerifyResult(command, result, sessionID, currentRoot);
258
269
  if (!result.passed) {
259
- const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
260
- const reason = `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
270
+ // A timeout is not a failing test. Both block
271
+ // finalization, but naming a failure sends the
272
+ // agent chasing an assertion that never ran, and
273
+ // the remedy is a longer limit rather than a code
274
+ // change. The tail is snipped as passing output on
275
+ // that path, because snipVerifyOutput labels a
276
+ // failing run with a "Verification Failed" header
277
+ // that would restate the misattribution.
278
+ const tail = snipVerifyOutput(result.output, result.timedOut === true).slice(-500);
279
+ const reason = result.timedOut
280
+ ? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Raise the limit in the project .opencode/workflow-guard.json (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS, then complete the tasks again; output tail: ${tail}`
281
+ : `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
261
282
  logPolicyBlock(`[workflow-guard] ${reason}`);
262
- return block("verification", "verification_failed", reason);
283
+ return block("verification", result.timedOut ? "verification_timed_out" : "verification_failed", reason);
263
284
  }
264
285
  }
265
286
  }
@@ -16,6 +16,12 @@
16
16
  * when the configured effect is allow/ask). A hook denial is therefore always a
17
17
  * response to something the host would otherwise permit.
18
18
  *
19
+ * An `abstain` is the one case where the seat does not tighten. The simulated
20
+ * core reports `needs_approval` when it cannot decide without the remote
21
+ * GitHub/Azure PR probe, which simulation skips on purpose. The host's
22
+ * configured effect then stays untouched and `execute.before` runs the real
23
+ * check, probe included.
24
+ *
19
25
  * Every decision here is a simulation of the policy core: `simulate: true`
20
26
  * skips mutation accounting, file claims, verification runs, and durable audit
21
27
  * writes, because the same call is evaluated for real in `execute.before`.
@@ -41,9 +47,13 @@ export interface PermissionSeatDeps {
41
47
  }
42
48
 
43
49
  export interface PermissionSeatResult {
44
- /** The effect the seat leaves in place. */
45
- effect: "allow" | "deny";
46
- /** Set only when the seat denied. */
50
+ /**
51
+ * The effect the seat leaves in place. `abstain` is an explicit third
52
+ * state: the simulated policy core could not decide, so the host's
53
+ * configured effect stays untouched and `execute.before` enforces.
54
+ */
55
+ effect: "allow" | "deny" | "abstain";
56
+ /** Set when the seat denied, and kept on an abstention for the audit row. */
47
57
  decision?: PolicyDecision;
48
58
  /** The guard tool the action was mapped onto, for attribution. */
49
59
  mappedTool?: string;
@@ -51,6 +61,25 @@ export interface PermissionSeatResult {
51
61
 
52
62
  const EDIT_ACTIONS = new Set(["edit", "write", "patch", "apply_patch"]);
53
63
 
64
+ /**
65
+ * Map one simulated policy decision onto the seat's effect.
66
+ *
67
+ * Only `needs_approval` abstains. That status means the simulated core could
68
+ * not decide, because `simulate: true` deliberately skips the remote GitHub or
69
+ * Azure PR probe. Collapsing it into `deny` would miscategorise an abstention
70
+ * as a finding and block every non-protected `git push`. Deferring it leaves
71
+ * the host's configured effect untouched, and `execute.before` runs the real
72
+ * check with the probe. Every other non-allowed status stays a denial.
73
+ */
74
+ function seatVerdict(decision: PolicyDecision, mappedTool: string): PermissionSeatResult | undefined {
75
+ if (decision.status === "allowed") return undefined;
76
+ return {
77
+ effect: decision.status === "needs_approval" ? "abstain" : "deny",
78
+ decision: { ...decision, message: `[workflow-guard permission.evaluate] ${decision.message}` },
79
+ mappedTool,
80
+ };
81
+ }
82
+
54
83
  function mappedToolFor(action: string): { tool: string; input: (resource: string) => unknown } | undefined {
55
84
  if (action === "shell" || action === "bash") {
56
85
  return { tool: "bash", input: (resource) => ({ command: resource }) };
@@ -90,13 +119,8 @@ export async function evaluatePermissionSeat(
90
119
  if (mapping) {
91
120
  for (const resource of resources) {
92
121
  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
- }
122
+ const verdict = seatVerdict(decision, mapping.tool);
123
+ if (verdict) return verdict;
100
124
  }
101
125
  return { effect: "allow", mappedTool: mapping.tool };
102
126
  }
@@ -106,14 +130,7 @@ export async function evaluatePermissionSeat(
106
130
  for (const resource of resources) {
107
131
  if (!mcpMutationTool(event.action) && !mcpMutationTool(resource)) continue;
108
132
  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 };
133
+ return seatVerdict(decision, event.action) ?? { effect: "allow", mappedTool: event.action };
117
134
  }
118
135
  return { effect: "allow" };
119
136
  }
package/src/lib/types.ts CHANGED
@@ -143,7 +143,12 @@ export interface AuditEntry {
143
143
  sessionID?: string;
144
144
  callID?: string;
145
145
  tool: string;
146
- decision: "allow" | "block";
146
+ /**
147
+ * `abstain` records a decision the guard deferred instead of resolving:
148
+ * the simulated permission seat cannot judge remote PR state, so
149
+ * `execute.before` decides for real. It is not an allow.
150
+ */
151
+ decision: "allow" | "block" | "abstain";
147
152
  policyDecision?: PolicyDecision;
148
153
  phase?: "decision" | "outcome" | "event";
149
154
  durationMs?: number;
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { Plugin } from "@opencode/plugin";
21
- import { Message } from "@opencode/ai";
21
+ import { Message } from "@opencode/ai/effect";
22
22
  import { z } from "zod";
23
23
  import { join, resolve } from "node:path";
24
24
  import type { TodoSdkClient } from "./types.ts";
@@ -145,6 +145,9 @@ const SUBAGENT_SUFFIX =
145
145
  // server registry, so this enrichment cannot reach them.
146
146
  // - V2.1.x adds a builtin `todowrite` behind the `experimental.todo` config
147
147
  // key, so the candidate below reaches a real registry entry there.
148
+ // - V2.2.x exposes `todowrite` through that same `experimental.todo` opt-in.
149
+ // The guard probes the registry rather than inferring capability from the
150
+ // version, so 2.1.x and 2.2.x need no version check here.
148
151
  // - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
149
152
  // candidates for the same reason.
150
153
  const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
@@ -583,17 +586,22 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
583
586
  },
584
587
  { workspaceRoot: effectiveRoot, client },
585
588
  );
586
- if (seat.decision) {
589
+ if (seat.effect === "deny" && seat.decision) {
587
590
  event.effect = "deny";
588
591
  event.message = seat.decision.message;
589
592
  }
593
+ // An abstention leaves the configured effect and message untouched,
594
+ // and still journals. `execute.before` runs the real check, which
595
+ // includes the remote PR probe that simulation skips. It records
596
+ // `abstain` rather than `allow`, so the deferral stays visible.
597
+ const abstained = seat.effect === "abstain";
590
598
  audit({
591
599
  ts: new Date().toISOString(),
592
600
  sessionID: event.sessionID,
593
601
  tool: "permission.ask",
594
- decision: seat.effect === "deny" ? "block" : "allow",
602
+ decision: seat.effect === "deny" ? "block" : abstained ? "abstain" : "allow",
595
603
  phase: "decision",
596
- reason: seat.decision?.code,
604
+ reason: abstained ? "deferred_to_execute_before" : seat.decision?.code,
597
605
  policyDecision: seat.decision,
598
606
  input: {
599
607
  action: event.action,
@@ -601,6 +609,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
601
609
  agent: event.agent,
602
610
  configuredEffect,
603
611
  mappedTool: seat.mappedTool,
612
+ ...(abstained ? { deferred: "execute.before" } : {}),
604
613
  },
605
614
  });
606
615
  } catch (error) {
package/src/lib/verify.ts CHANGED
@@ -231,11 +231,26 @@ export function resolveVerifyTimeoutMs(root: string): number {
231
231
  return typeof configured === "number" && Number.isFinite(configured) && configured > 0 ? configured : 30_000;
232
232
  }
233
233
 
234
+ export interface VerifyRun {
235
+ passed: boolean;
236
+ output: string;
237
+ durationMs: number;
238
+ /**
239
+ * Set when the command was killed at the timeout rather than exiting on its
240
+ * own. A timeout is not a failing assertion: it says nothing about whether
241
+ * the change is correct, and the remedy is a longer limit, not a code fix.
242
+ * Callers that report a reason must attribute it, or the agent chases a
243
+ * failure that does not exist.
244
+ */
245
+ timedOut?: boolean;
246
+ timeoutMs?: number;
247
+ }
248
+
234
249
  export async function runVerify(
235
250
  command: string,
236
251
  root: string,
237
252
  timeoutMs = 30_000,
238
- ): Promise<{ passed: boolean; output: string; durationMs: number }> {
253
+ ): Promise<VerifyRun> {
239
254
  const start = Date.now();
240
255
  const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
241
256
 
@@ -294,6 +309,8 @@ export async function runVerify(
294
309
  passed: false,
295
310
  output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
296
311
  durationMs: Date.now() - start,
312
+ timedOut: true,
313
+ timeoutMs,
297
314
  });
298
315
  });
299
316
  }, timeoutMs);
package/test/test-e2e.mts CHANGED
@@ -225,6 +225,16 @@ const runV2Probes = async (ctx: any) => {
225
225
  \tconst guardWhy = byId("guard_why");
226
226
  \tconst pick = (r: any) => (typeof r?.content === "string" ? r.content : JSON.stringify(r));
227
227
  \tconst statusRes = guardStatus ? await guardStatus.execute({}, headlessToolContext) : undefined;
228
+ \t// Permission-seat canary. The seat runs inside the real host plugin setup,
229
+ \t// with the workspace taken from the host's own location.directory, so a
230
+ \t// typed-only seat cannot pass. No client is passed: this probe makes no
231
+ \t// remote call, and the push case is the one that must abstain precisely
232
+ \t// because simulation skips the remote PR probe.
233
+ \tconst { evaluatePermissionSeat } = await import("../workflow-guard-source/lib/permission-seat.ts");
234
+ \tconst workspaceRoot = ctx?.location?.directory ?? process.cwd();
235
+ \tconst seatEvent = (action: string, resources: string[]) => ({ sessionID: "headless-e2e-seat", agent: "build", action, resources, effect: "allow" as const });
236
+ \tconst seatDeny = await evaluatePermissionSeat(seatEvent("read", [".env"]), { workspaceRoot, client: undefined });
237
+ \tconst seatPush = await evaluatePermissionSeat(seatEvent("shell", ["git push origin feat/install-verification"]), { workspaceRoot, client: undefined });
228
238
  \tconst whyProbes: Record<string, { tool: string; input: Record<string, unknown> }> = {
229
239
  \t\tgit: { tool: "bash", input: { command: "git push origin main" } },
230
240
  \t\t"secrets-read": { tool: "read", input: { filePath: ".env" } },
@@ -244,6 +254,7 @@ const runV2Probes = async (ctx: any) => {
244
254
  \t\tstatus: JSON.parse(pick(statusRes)),
245
255
  \t\twhy: whyResults.git,
246
256
  \t\twhyProbes: whyResults,
257
+ \t\tseat: { deny: seatDeny, push: seatPush },
247
258
  \t}) + "\\n");
248
259
  \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
249
260
  };
@@ -432,6 +443,21 @@ if (isOpenCodeV2) {
432
443
  check("V2 setup registers the full config-gated guard tool surface", missingGuardTools.length === 0);
433
444
  if (missingGuardTools.length > 0) console.log(" missing guard tools: " + missingGuardTools.join(", "));
434
445
  console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
446
+ // Provenance: `workspaceRoot` is the host's own `location.directory`, read
447
+ // inside the real setup call. The seat runs with no client, so nothing here
448
+ // depends on hand-built remote state.
449
+ check(
450
+ "V2 permission seat denies a real finding against the live host context",
451
+ headlessAccountability?.seat?.deny?.effect === "deny" &&
452
+ headlessAccountability?.seat?.deny?.decision?.status === "blocked" &&
453
+ headlessAccountability?.seat?.deny?.decision?.policy === "secrets" &&
454
+ headlessAccountability?.seat?.deny?.decision?.code === "secret_read",
455
+ );
456
+ check(
457
+ "V2 permission seat abstains on an undecidable push instead of denying it",
458
+ headlessAccountability?.seat?.push?.effect === "abstain" &&
459
+ headlessAccountability?.seat?.push?.decision?.code === "remote_state_unchecked",
460
+ );
435
461
  }
436
462
 
437
463
  // The todo capability probe is only meaningful against a host that can gate
package/test/test.mts CHANGED
@@ -122,6 +122,7 @@ import {
122
122
  import { prBodyIncludesChangelog } from "../src/policies/changelog.ts";
123
123
  import { terminateProcessTree } from "../src/lib/verify.ts";
124
124
  import { audit } from "../src/lib/audit.ts";
125
+ import { evaluatePermissionSeat } from "../src/lib/permission-seat.ts";
125
126
  import { isProjectMemoryFreshAsync } from "../src/lib/project-memory.ts";
126
127
  import { ToolOutcomeTracker } from "../src/lib/tool-outcomes.ts";
127
128
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, listRecoveryCheckpoints, restoreRecoveryCheckpoint, setCheckpointGitForTesting } from "../src/lib/checkpoint.ts";
@@ -2335,6 +2336,66 @@ check(
2335
2336
  blocked(failFinalRes),
2336
2337
  );
2337
2338
 
2339
+ // A timeout is not a failing test. Both block finalization, but the reason
2340
+ // must name the timeout and its remedy. Reporting a timeout as "verification
2341
+ // is failing" sends the agent looking for a failing assertion that does not
2342
+ // exist, and the real fix is a longer timeout rather than a code change.
2343
+ console.log("- Policy 10: verification timeout attribution -");
2344
+ // The verify env overrides beat project config, so a developer with either one
2345
+ // exported (the docs recommend doing exactly that for this repo's suite) would
2346
+ // otherwise make these checks measure their shell instead of the guard.
2347
+ const prevTimeoutVerifyEnv = process.env.WORKFLOW_GUARD_VERIFY;
2348
+ const prevTimeoutLimitEnv = process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
2349
+ delete process.env.WORKFLOW_GUARD_VERIFY;
2350
+ delete process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
2351
+ const verifyTimeoutRepo = mkdtempSync(join(tmpdir(), "wg-verify-timeout-finalize-"));
2352
+ mkdirSync(join(verifyTimeoutRepo, ".opencode"), { recursive: true });
2353
+ // The command prints far more than snipVerifyOutput's line budget, so the
2354
+ // tail exercises the snipper rather than a short literal output.
2355
+ writeFileSync(
2356
+ join(verifyTimeoutRepo, ".opencode", "workflow-guard.json"),
2357
+ JSON.stringify({
2358
+ verifyCommand: "node -e \"for (let i = 0; i < 60; i++) console.log('noise ' + i); setTimeout(() => {}, 5000)\"",
2359
+ verifyTimeoutMs: 50,
2360
+ }),
2361
+ );
2362
+ reloadProjectConfig(verifyTimeoutRepo);
2363
+ setWorkspaceRoot(verifyTimeoutRepo);
2364
+ resetVerifyState();
2365
+ todo("s-verify-timeout", item("timeout work", "in_progress"));
2366
+ await call(
2367
+ "edit",
2368
+ { filePath: join(verifyTimeoutRepo, "code.ts"), content: "timeout" },
2369
+ { sessionID: "s-verify-timeout" },
2370
+ );
2371
+ const timeoutFinalRes = await call(
2372
+ "todowrite",
2373
+ { todos: [item("timeout work", "completed")] },
2374
+ { sessionID: "s-verify-timeout" },
2375
+ );
2376
+ check("verification timeout blocks finalization", blocked(timeoutFinalRes));
2377
+ check(
2378
+ "verification timeout is not reported as a failing test",
2379
+ !String(timeoutFinalRes).includes("verification is failing"),
2380
+ );
2381
+ check(
2382
+ "a timed-out tail carries no failure header that restates the misattribution",
2383
+ !String(timeoutFinalRes).includes("Verification Failed"),
2384
+ );
2385
+ check(
2386
+ "verification timeout names the elapsed limit at the precision it was set",
2387
+ String(timeoutFinalRes).includes("timed out") && String(timeoutFinalRes).includes("50ms"),
2388
+ );
2389
+ check(
2390
+ "verification timeout names a remedy the agent can actually apply",
2391
+ String(timeoutFinalRes).includes("verifyTimeoutMs") && String(timeoutFinalRes).includes("WORKFLOW_GUARD_VERIFY_TIMEOUT_MS"),
2392
+ );
2393
+ setWorkspaceRoot(verifyRepo);
2394
+ reloadProjectConfig(verifyRepo);
2395
+ if (prevTimeoutVerifyEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY = prevTimeoutVerifyEnv;
2396
+ if (prevTimeoutLimitEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS = prevTimeoutLimitEnv;
2397
+ rmSync(verifyTimeoutRepo, { recursive: true, force: true });
2398
+
2338
2399
  // A subagent mutation governed by the parent's todos must invalidate the
2339
2400
  // parent's cached verification result.
2340
2401
  writeFileSync(
@@ -5705,6 +5766,98 @@ if (prevDuplicateDataHome === undefined) delete process.env.XDG_DATA_HOME;
5705
5766
  else process.env.XDG_DATA_HOME = prevDuplicateDataHome;
5706
5767
  rmSync(v2DuplicateRoot, { recursive: true, force: true });
5707
5768
 
5769
+ // ── Permission seat: an undecidable simulation abstains, a finding denies ──
5770
+ // The seat always runs the policy core with `simulate: true`, which skips the
5771
+ // remote GitHub/Azure PR probe on purpose. `needs_approval` means exactly
5772
+ // that: the core could not decide. Collapsing it into a denial blocked every
5773
+ // non-protected `git push`, so the seat must abstain and leave the configured
5774
+ // effect untouched while `execute.before` runs the real check. A genuine local
5775
+ // finding still denies, and an abstention never upgrades a configured `ask`.
5776
+ console.log("- V2 permission seat: abstention -");
5777
+ const makeSeatRepo = (branch: string): string => {
5778
+ const repo = mkdtempSync(join(tmpdir(), "wg-seat-"));
5779
+ spawnSync("git", ["init", "-b", branch], { cwd: repo });
5780
+ writeFileSync(join(repo, "tracked.txt"), "tracked\n");
5781
+ spawnSync("git", ["add", "tracked.txt"], { cwd: repo });
5782
+ spawnSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"], { cwd: repo });
5783
+ return repo;
5784
+ };
5785
+ const seatFeatureRepo = makeSeatRepo("feature/push-seat");
5786
+ const seatMainRepo = makeSeatRepo("main");
5787
+ const seatEvent = (resources: string[], effect: "allow" | "ask" | "deny" = "allow") => ({
5788
+ sessionID: "s-seat-abstain",
5789
+ agent: "build",
5790
+ action: "shell",
5791
+ resources,
5792
+ effect,
5793
+ });
5794
+ try {
5795
+ const pushSeat = await evaluatePermissionSeat(
5796
+ seatEvent(["git push origin feature/push-seat"]),
5797
+ { workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
5798
+ );
5799
+ check(
5800
+ "seat abstains on a non-protected push whose remote PR state is unverified",
5801
+ pushSeat.effect !== "deny" && pushSeat.effect === "abstain",
5802
+ );
5803
+ check(
5804
+ "seat keeps the undecidable decision for audit attribution",
5805
+ pushSeat.decision?.status === "needs_approval" && pushSeat.decision?.code === "remote_state_unchecked",
5806
+ );
5807
+ const protectedPushSeat = await evaluatePermissionSeat(
5808
+ { ...seatEvent(["git push origin feature/push-seat"]), sessionID: "s-seat-block" },
5809
+ { workspaceRoot: seatMainRepo, client: fakeClient as unknown },
5810
+ );
5811
+ check(
5812
+ "seat still denies a real finding (push from a protected branch)",
5813
+ protectedPushSeat.effect === "deny" && protectedPushSeat.decision?.status === "blocked" && /protected branch/i.test(String(protectedPushSeat.decision?.message)),
5814
+ );
5815
+ const askedSeat = await evaluatePermissionSeat(
5816
+ { ...seatEvent(["git push origin feature/push-seat"], "ask"), sessionID: "s-seat-ask" },
5817
+ { workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
5818
+ );
5819
+ check("an abstaining seat never upgrades a configured ask to allow", askedSeat.effect === "abstain");
5820
+ } finally {
5821
+ rmSync(seatFeatureRepo, { recursive: true, force: true });
5822
+ rmSync(seatMainRepo, { recursive: true, force: true });
5823
+ }
5824
+
5825
+ // The end-to-end seat: the same undecidable push through the registered hook
5826
+ // must leave the configured effect and message untouched, and journal the
5827
+ // deferral so the skipped remote probe stays legible.
5828
+ {
5829
+ resetPluginInstancesForTesting();
5830
+ const seatHookRepo = makeSeatRepo("feature/push-seat-hook");
5831
+ const prevSeatDataHome = process.env.XDG_DATA_HOME;
5832
+ process.env.XDG_DATA_HOME = join(seatHookRepo, "data");
5833
+ const seatCtx = makeV2Context(seatHookRepo);
5834
+ const seatCleanup = (await defaultExport.setup(seatCtx.ctx as any)) as (() => void) | undefined;
5835
+ const seatAskEvent = { ...permissionEvent("shell", ["git push origin feature/push-seat-hook"], "s-seat-hook-ask"), effect: "ask" as "allow" | "deny" | "ask" };
5836
+ await seatCtx.state.permissionHooks[0]?.(seatAskEvent);
5837
+ check(
5838
+ "evaluate leaves a configured ask in place when the simulation abstains",
5839
+ seatAskEvent.effect === "ask" && seatAskEvent.message === undefined,
5840
+ );
5841
+ const seatPushEvent = permissionEvent("shell", ["git push origin feature/push-seat-hook"], "s-seat-hook");
5842
+ await seatCtx.state.permissionHooks[0]?.(seatPushEvent);
5843
+ check(
5844
+ "evaluate does not deny a non-protected push it cannot decide",
5845
+ seatPushEvent.effect === "allow" && seatPushEvent.message === undefined,
5846
+ );
5847
+ const abstainAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.reason === "deferred_to_execute_before");
5848
+ check(
5849
+ "an abstention is journaled as deferred to execute.before, not as a plain allow",
5850
+ abstainAudit !== undefined &&
5851
+ abstainAudit.policyDecision?.status === "needs_approval" &&
5852
+ abstainAudit.policyDecision?.code === "remote_state_unchecked" &&
5853
+ (abstainAudit.input as any)?.deferred === "execute.before",
5854
+ );
5855
+ seatCleanup?.();
5856
+ if (prevSeatDataHome === undefined) delete process.env.XDG_DATA_HOME;
5857
+ else process.env.XDG_DATA_HOME = prevSeatDataHome;
5858
+ rmSync(seatHookRepo, { recursive: true, force: true });
5859
+ }
5860
+
5708
5861
  // ── V2 adopt-now surfaces: command, read-only pruning, worktrees, MCP ──
5709
5862
  // Four additive V2 API adoptions, each tested for its narrowing direction:
5710
5863
  // the command adds an operator surface, pruning removes tools, worktree cleanup