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.
@@ -0,0 +1,171 @@
1
+ # Static Policy Posture
2
+
3
+ Workflow Guard has three enforcement seats. Two of them live inside the plugin:
4
+
5
+ | Seat | Runs in | Removed by |
6
+ |---|---|---|
7
+ | `ctx.permission.hook("evaluate")` | the plugin | uninstalling or disabling the plugin |
8
+ | `execute.before` | the plugin | uninstalling or disabling the plugin |
9
+ | static configuration policies | the host | editing the config that carries them |
10
+
11
+ Static policies are the only layer a repository cannot lift. A project cannot
12
+ grant itself a permission the host denies in global config, because the host
13
+ resolves static policy before any plugin hook runs. That is the whole point of
14
+ this surface, and it is also why the plugin refuses to install it for you.
15
+
16
+ ## Emit, do not install
17
+
18
+ ```
19
+ opencode-workflow-guard policies --print
20
+ opencode-workflow-guard policies --print --format 2.0
21
+ opencode-workflow-guard policies --print --format next
22
+ ```
23
+
24
+ The command writes JSONC to stdout and nothing else. It refuses to run without
25
+ `--print`. A plugin that can write the configuration holding its own static
26
+ rails has moved those rails back inside the plugin, so the operator places the
27
+ block.
28
+
29
+ The default is `both`, which emits each rule under the key its host reads:
30
+
31
+ | Format | Envelope | Rule action | Host that reads it |
32
+ |---|---|---|---|
33
+ | `both` (default) | `experimental.policies` and top-level `policies` | `permission` and `tool.use` | 2.0.x and 2.1.x |
34
+ | `2.0` | `experimental.policies` | `permission` | 2.0.x only |
35
+ | `next` | top-level `policies` | `tool.use` | 2.1.x only |
36
+
37
+ The envelopes are not interchangeable, and neither host warns about the one it
38
+ does not read. OpenCode 2.0.x evaluates `experimental.policies` with
39
+ `action === "permission"` and ignores a top-level `policies` array entirely.
40
+ OpenCode 2.1.x evaluates the top-level `policies` array with
41
+ `action === "tool.use"` and ignores `experimental.policies`. A single-key block
42
+ therefore looks applied on the other host and enforces nothing.
43
+
44
+ Both hosts read only their own key, so carrying both cannot double-apply a rule
45
+ or make them conflict. `--format 2.0` and `--format next` exist for a pinned
46
+ host. `both` is the default, because the silent no-op is the failure this
47
+ surface exists to prevent.
48
+
49
+ Schema acceptance is not runtime acceptance. `@opencode/schema` 2.0.10 admits a
50
+ top-level `policies` array that the 2.0.15 runtime never reads, so a decode test
51
+ grades the block as valid while the host enforces nothing. The envelope table
52
+ above is verified by live denial canaries on both hosts, in the acceptance
53
+ section below.
54
+
55
+ ## What is emitted
56
+
57
+ | Rule | Action | Resource | Why |
58
+ |---|---|---|---|
59
+ | protected trunk push | `shell` | `git push * main`, `git push * master`, `git push * HEAD:main`, `git push * HEAD:master` | trunk must stay protected when the plugin is absent |
60
+ | live control plane write | `edit` | `*/.config/opencode/*`, `/etc/opencode/*`, `/Library/Application Support/opencode/*` | an agent that can edit the control plane can rewrite its own constraints |
61
+ | secret material read | `read` | `*/.ssh/*`, `*id_rsa*` | private keys should never enter a model context |
62
+ | external directory widening | `external_directory` | `*` | `external_directory` defaults to allow; denying it is what makes the write boundary a boundary |
63
+ | Code Mode surface | `execute` | `*` | removes the nested tool surface where the host asserts the action |
64
+
65
+ Every emitted rule is a denial. A posture that granted access would be a second,
66
+ weaker policy engine in a config file, and the plugin already owns that job.
67
+
68
+ The control-plane rule starts with `*/` rather than `~/` on purpose: 2.0.x
69
+ expands `~` and `$HOME` only for the `permissions` key, not for
70
+ `experimental.policies`, so a tilde resource would never match an absolute path.
71
+ `*/.config/opencode/*` is version-independent and covers every home directory.
72
+
73
+ Two of these are judgement calls rather than obvious wins, so drop them
74
+ deliberately if they conflict with normal work:
75
+
76
+ - `external_directory: *` denies every path outside the active project. Cross
77
+ repository reads then require an explicit later `allow` rule. That is the
78
+ strict posture, and it is the one the write-confinement work assumes.
79
+ - `execute: *` removes Code Mode. The qualified hosts (2.0.15 and 2.1.3)
80
+ register no `execute` tool and never assert an `execute` permission action,
81
+ so the rule is inert on both and Code Mode stays available. On a host that
82
+ does assert the action, the rule removes a surface whose nested tool calls
83
+ this guard does not evaluate. The plugin's read-only tool pruning and
84
+ `execute.before` remain the coverage today.
85
+
86
+ ## Precedence
87
+
88
+ Highest authority first:
89
+
90
+ | Authority | Source |
91
+ |---|---|
92
+ | 0 | managed preferences and Console policies |
93
+ | 2 | `OPENCODE_CONFIG_CONTENT` |
94
+ | 3 | `/etc/opencode` |
95
+ | 4 | `~/.config/opencode` |
96
+ | 5 | project `opencode.json(c)` |
97
+
98
+ Within one ruleset the last matching rule wins. Across documents the earlier
99
+ authority wins. Two consequences matter when you place the block:
100
+
101
+ 1. Put it in the system or global config (authority 3 or 4), never in a project
102
+ file. A project file is the scope the rules are meant to outrank.
103
+ 2. If you add broad `allow` rules for local workflows, place these denies after
104
+ them in the same file. A deny that lands earlier is shadowed.
105
+
106
+ On 2.0.x, the host builds its rule list by reversing the document order and
107
+ then taking the last match per resource, so a global document beats a project
108
+ document for the same resource. Verified in the 2.0.15 binary
109
+ (`opencode.config.policy`).
110
+
111
+ ## Residual risk
112
+
113
+ Read this before treating the block as a sandbox.
114
+
115
+ - Policies do not sandbox plugin code. A plugin can still do anything the
116
+ process can do.
117
+ - A process that can write the file carrying these rules can remove them. File
118
+ ownership and permissions are the control there, not policy.
119
+ - Shell patterns match the whole command string. `cd /tmp && git push origin
120
+ main` does not match `git push * main`, and neither does an exotic refspec
121
+ spelled differently. The static floor covers the direct forms only. This gap
122
+ is covered by a regression test, which asserts the evasion succeeds so the
123
+ limit stays visible.
124
+ - A denied provider is blocked on the normal path only. Alternate paths are not
125
+ covered by `provider.use` policies.
126
+ - Effect values are `allow` and `deny`. There is no `ask` at this seat, which is
127
+ why the emitted rules deny rather than prompt.
128
+
129
+ ## Acceptance status
130
+
131
+ **Status: RUN.** The canary installs the emitted block into an isolated project
132
+ config and drives a real model turn that calls the denied tool. It is run once
133
+ per host.
134
+
135
+ | Host | Emitted block | Probed rule | Result |
136
+ |---|---|---|---|
137
+ | 2.0.15 | `--format 2.0` | `shell:printf*` | `Blocked by configuration policy` |
138
+ | 2.1.3 | `--format 2.0` | `shell:printf*` | **executed** (silent no-op) |
139
+ | 2.0.15 | `--format next` | `shell:printf*` | **executed** (silent no-op) |
140
+ | 2.1.3 | `--format next` | `shell:printf*` | `Blocked by configuration policy` |
141
+ | 2.0.15 | default (`both`) | `read:*id_rsa*` | `Blocked by configuration policy` |
142
+ | 2.1.3 | default (`both`) | `read:*id_rsa*` | `Blocked by configuration policy` |
143
+
144
+ The two silent no-ops are the defect this section exists to prevent, and they
145
+ are why the default emits both envelopes. A single-key block is not merely
146
+ unverified on the other host. It is actively misleading there, because the
147
+ config loads and reports no error.
148
+
149
+ Two limits on the canary, so nobody over-reads it:
150
+
151
+ - The `write` tool is gated by the `edit` permission action on 2.1.3 but not on
152
+ 2.0.15, so a `write` canary would not have proved the `edit` rule on both.
153
+ The `read` rule was chosen for the cross-host proof because both hosts map it
154
+ identically.
155
+ - The `execute` invariant stays inert on both qualified hosts, because neither
156
+ registers an `execute` tool. Its canary cannot pass until a host asserts the
157
+ action, and the rule is documented as inert rather than claimed as coverage.
158
+
159
+ Placing the block in a live control-plane path stays out of scope for an agent:
160
+ the tamper policy refuses that write, and that refusal is correct. The canary
161
+ uses an isolated project config instead, which exercises the same evaluator.
162
+
163
+ The emitted resources are also asserted against a replica of the host matcher in
164
+ `test/test.mts`, and both envelopes are required to reach the same verdict for
165
+ every probed resource.
166
+
167
+ To run the canary yourself, install the block into `~/.config/opencode`, then
168
+ attempt a denied action in a live session and confirm the message names
169
+ configuration policy rather than the plugin. Use the default (`both`) envelope
170
+ unless you have pinned the host, and check the result on the host you actually
171
+ run.
package/docs/testing.md CHANGED
@@ -13,18 +13,18 @@ npm run typecheck # or: npm run build
13
13
  ```
14
14
 
15
15
  ### 2. In-Memory Unit Tests
16
- Runs the comprehensive unit and adversarial tests covering all guard policies, lifecycle validation, subagent parent-chain resolution, privilege-isolated verification, and path/symlink boundary checks:
16
+ Runs the unit and adversarial tests covering all guard policies, lifecycle validation, subagent parent-chain resolution, privilege-isolated verification, and path/symlink boundary checks:
17
17
  ```bash
18
18
  npm test # runs test.mts with isolated XDG state/data (Node >= 22.18)
19
19
  ```
20
20
 
21
21
  ### 3. OpenCode Runtime & Install Tests
22
- Packs the npm tarball, installs it into an isolated project to verify the modular entrypoint resolves, then copies the server source into an isolated `.opencode/workflow-guard-source/` directory and exposes its `WorkflowGuard` plugin function through an explicitly registered `.opencode/plugins/` adapter. A provider-free config probe verifies OpenCode resolves that local plugin before the opt-in model-driven `opencode run` probes exercise its hooks:
22
+ Packs the npm tarball into an isolated project to verify the modular entrypoint resolves. It then copies the server source into an isolated `.opencode/workflow-guard-source/` directory. That source's `WorkflowGuard` plugin function is exposed through an explicitly registered `.opencode/plugins/` adapter. A provider-free config probe verifies OpenCode resolves that local plugin before the opt-in model-driven `opencode run` probes exercise its hooks:
23
23
  ```bash
24
24
  npm run test:install # package install + provider-free OpenCode loader check
25
25
  WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # also run model-driven policy probes with OpenCode's most recently selected model
26
26
  ```
27
- Package and loader checks remain deterministic. Live probes explicitly select the first entry in OpenCode's persisted recent-model state (`${XDG_STATE_HOME:-~/.local/state}/opencode/model.json`) instead of silently inheriting the configured default model. Set `WORKFLOW_GUARD_LIVE_MODEL=provider/model` to override that selection; provider credentials still come from the normal OpenCode environment. If the selected model provider rejects a live prompt before guard behavior can run because credits, rate limits, capacity, or overload make the provider unavailable, that live case is reported as unavailable rather than as a guard failure. Other live-runtime failures still fail the suite.
27
+ Package and loader checks remain deterministic. Live probes explicitly select the first entry in OpenCode's persisted recent-model state (`${XDG_STATE_HOME:-~/.local/state}/opencode/model.json`). They never silently inherit the configured default model. Set `WORKFLOW_GUARD_LIVE_MODEL=provider/model` to override that selection. Provider credentials still come from the normal OpenCode environment. A provider may reject a live prompt before guard behavior can run. That happens when credits, rate limits, capacity, overload, or an unresolvable model make the provider unavailable. Such a case is reported as unavailable rather than as a guard failure. The classifier matches the host's literal `Model unavailable:` error line rather than the bare phrase, so model prose cannot mask a real guard regression. Other live-runtime failures still fail the suite.
28
28
 
29
29
  ### 4. Full Verification Suite
30
30
  Runs typecheck, unit tests, and live runtime installation tests in sequence:
@@ -53,4 +53,7 @@ npm run test:all
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 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
+ | **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
+ | **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. |
56
59
  | **Modular Exports & Shape** | Verifies V1 `PluginModule` `{ id, server }` structure, modular policy imports, and `output.args` hook contract. |
@@ -7,7 +7,7 @@ Common issues, root causes, and solutions when using `opencode-workflow-guard`.
7
7
  ### 1. `plugin config hook failed` / `Unexpected server error` on startup
8
8
 
9
9
  - **Symptoms:** OpenCode crashes or reports `Unexpected server error` when starting a session, with logs showing `TypeError: undefined is not an object (evaluating '...event')`.
10
- - **Root Cause:** OpenCode 1.18+'s legacy loader treats *every exported function* in a plugin module as a separate plugin instance. If a plugin exports helper functions (like `guardToolCall` or `setWorkspaceRoot`) alongside a default function, OpenCode calls them as plugins, pushes `undefined` into the hook registry, and crashes event dispatch on `session.created`.
10
+ - **Root Cause:** OpenCode 1.18+'s legacy loader treats *every exported function* in a plugin module as a separate plugin instance. A plugin that exports helper functions (like `guardToolCall` or `setWorkspaceRoot`) alongside a default function therefore registers those helpers as plugins too. OpenCode calls them as plugins, pushes `undefined` into the hook registry, and crashes event dispatch on `session.created`.
11
11
  - **Solution:** Ensure `src/workflow-guard.ts` uses the V1 `PluginModule` export format (`export default { id: "workflow-guard", server: WorkflowGuard } satisfies PluginModule`). File plugins require an explicit `id` string.
12
12
 
13
13
  ---
@@ -15,8 +15,8 @@ Common issues, root causes, and solutions when using `opencode-workflow-guard`.
15
15
  ### 2. Edits Blocked: `Blocked: no active todo item`
16
16
 
17
17
  - **Symptoms:** The agent attempts an edit (`edit`, `write`, `apply_patch`) and receives a block message directing it to create a task list.
18
- - **Root Cause:** Policy 1 blocks file-editing tools while the session's **effective** todo list is known and contains no task with status `pending` or `in_progress`. On OpenCode 1.x that list is the native todo state (`GET /session/:id/todo`). On OpenCode 2.x there is no todo endpoint and no builtin `todowrite`, so the list is reconstructed from the newest applied `todowrite` tool call in the session history (some ACP agents supply that tool). A list is only treated as empty when a `todowrite` part actually exists; when the session has no `todowrite` capability at all, the gate fails open rather than blocking every edit forever.
19
- - **Solution:** The agent must call `todowrite` first. Once all tasks are marked `completed` or `cancelled`, edits will block again until a new breakdown is created for subsequent work. If an agent on OpenCode 2.x is blocked here despite having no todo tool available, that is a bug - report it rather than working around it through shell writes.
18
+ - **Root Cause:** Policy 1 blocks file-editing tools while the session's **effective** todo list is known and contains no task with status `pending` or `in_progress`. On OpenCode 1.x that list is the native todo state (`GET /session/:id/todo`). Before 2.1.0, OpenCode 2.x has no todo endpoint and no builtin `todowrite`. The list is then reconstructed from the newest applied `todowrite` tool call in the session history, which some ACP agents supply. A list counts as empty only when a `todowrite` part actually exists. When the session has no `todowrite` capability at all, the gate fails open rather than blocking every edit forever.
19
+ - **Solution:** The agent must call `todowrite` first. Once all tasks are marked `completed` or `cancelled`, edits block again until a new breakdown is created for subsequent work. If an agent on OpenCode 2.x is blocked here despite having no todo tool available, that is a bug. Report it rather than working around it through shell writes.
20
20
 
21
21
  ---
22
22
 
@@ -64,7 +64,7 @@ Common issues, root causes, and solutions when using `opencode-workflow-guard`.
64
64
 
65
65
  - **Symptoms:** A CLI command is rejected with a live-system mutation warning.
66
66
  - **Root Cause:** Policy 4 blocks destructive cloud, database, and infrastructure operations.
67
- - **Solution:** To intentionally run a live command, set `WORKFLOW_GUARD_ALLOW_LIVE=1` in the environment before launching OpenCode. There is no in-command override; an agent cannot grant this permission to itself.
67
+ - **Solution:** To intentionally run a live command, set `WORKFLOW_GUARD_ALLOW_LIVE=1` in the environment before launching OpenCode. There is no in-command override. An agent cannot grant this permission to itself.
68
68
 
69
69
  ---
70
70
 
@@ -88,30 +88,31 @@ After the command reports `Detected server + tui targets`, confirm it reports re
88
88
  ### 10. TUI companion crashes with `Keymap.Provider is missing` (OpenCode V2)
89
89
 
90
90
  - **Symptoms:** On opencode v2, the TUI companion plugin fails during setup with `Keymap.Provider is missing` and does not load.
91
- - **Root Cause:** `ctx.keymap.layer` resolves a Solid context (`useContext(KeymapContext)`) and only works inside the TUI's `Keymap.Provider` render tree; the API documents layers as "owned by the calling component". Plugin `setup()` runs as a plain async call outside that tree, so registering a layer there always throws.
92
- - **Solution:** Fixed in 1.13.2: the layer is registered from an `app`-slot claim (`ctx.ui.slot({ append: "app", render: () => { ...; return null } })`). Slot renders execute in a reactive scope under the provider, and the same session-panel pattern applies to any plugin API that requires component ownership. If you maintain a TUI plugin that registers keymap layers, commands, or reactive state, register them from a slot render (or a rendered component), never from `setup()`.
91
+ - **Root Cause:** `ctx.keymap.layer` resolves a Solid context (`useContext(KeymapContext)`), so it only works inside the TUI's `Keymap.Provider` render tree. The API documents layers as "owned by the calling component". Plugin `setup()` runs as a plain async call outside that tree, so registering a layer there always throws.
92
+ - **Solution:** Fixed in 1.13.2. The layer is registered from an `app`-slot claim (`ctx.ui.slot({ append: "app", render: () => { ...; return null } })`). Slot renders execute in a reactive scope under the provider, and the same session-panel pattern applies to any plugin API that requires component ownership. If you maintain a TUI plugin that registers keymap layers, commands, or reactive state, register them from a slot render or a rendered component. Never register them from `setup()`.
93
93
 
94
94
  ---
95
95
 
96
96
  ### 11. Reading tool parts from V2 sessions (integrators)
97
97
 
98
- - **Symptoms:** Code that parses recorded tool calls (message parts, the `part` DB table, or `session.message.content.updated` events) finds no tool name on OpenCode V2, or sees tool names that do not exist in OpenCode's builtin tool list.
98
+ - **Symptoms:** Code that parses recorded tool calls finds no tool name on OpenCode V2. It may also see tool names that do not exist in OpenCode's builtin tool list. The affected sources are message parts, the `part` DB table, and `session.message.content.updated` events.
99
99
  - **Root Cause:** Two independent things:
100
- 1. V2 message parts record the tool name in the `tool` field; V1 used `name`. Code reading `part.name` silently gets `undefined` on V2.
101
- 2. V2's builtin tool registry contains only `read`, `glob`, `grep`, `edit`, `write`, `patch`, `shell`, `webfetch`, `websearch`, `question`, `skill`, `subagent`, and `execute`. There is no builtin `todowrite` (the V1 name), and the V1 `task` subagent tool was renamed to `subagent`. `todowrite`/`bash` tool parts observed in V2 sessions come from ACP agents (for example pi) that supply their own tools; those tools are not in the server-side registry and cannot be reached by `ctx.tool.transform`.
100
+ 1. V2 message parts record the tool name in the `tool` field, where V1 used `name`. Code reading `part.name` silently gets `undefined` on V2.
101
+ 2. V2's builtin tool registry contains only `read`, `glob`, `grep`, `edit`, `write`, `patch`, `shell`, `webfetch`, `websearch`, `question`, `skill`, `subagent`, and `execute`. There is no builtin `todowrite`, which is the V1 name, and the V1 `task` subagent tool was renamed to `subagent`. `todowrite` and `bash` tool parts observed in V2 sessions come from ACP agents, for example pi, that supply their own tools. Those tools are not in the server-side registry and cannot be reached by `ctx.tool.transform`.
102
102
  - **Solution:** Read the tool name as `part.tool ?? part.name` and treat tool-part names as agent-reported, not as the builtin registry. `ToolEditor.update` ignores missing IDs, so enriching a tool that does not exist is a harmless no-op.
103
103
 
104
104
  ---
105
105
 
106
106
  ### 12. Review recorded with `record_review`, but `pr create` is still blocked
107
107
 
108
- - **Symptoms:** `record_review` returned "Review recorded as APPROVED" and `guard_status` showed a fresh `lastReview`, yet `gh pr create` / `az repos pr create` was blocked with "Passing secondary review approval is required"; or re-calling `record_review` returned success but `guard_status` still showed no fresh review.
109
- - **Root Cause (fixed by content binding):** The PR preflight previously also required the review's target session to equal the PR-creating session's ID. `record_review` records from a secondary session (binding the verdict to its parent or its own directory), sessions can move between directories mid-flight (`session_move`), and subagents spawn from other roots — so a review recorded against a linked git worktree could never satisfy a PR created from another session there. Mutation invalidation additionally erased the whole review record on any mutation, including unrelated untracked-file cleanup.
110
- - **Binding rule:** a recorded approval satisfies the preflight when its binding matches the tree the PR publishes — same repository, same commit hash, same tracked-content worktree fingerprint. Review freshness is scoped to tracked content, so deleting an untracked scratch file does not invalidate it; a tracked edit or new commit does. When the tracked fingerprint is unchanged but HEAD moved (for example a review recorded with changes already staged, then committed), the preflight names the commit drift specifically.
111
- - **Diagnosis:** `guard_audit` now records the binding key (workspace, commit hash, fingerprint prefix, recorder session and `recorderRole`) on every `record_review.verdict` entry, and a `pr-preflight.review-binding` entry when a PR preflight accepts a review. If a verdict cannot bind at all (directory is not a git repository), `record_review` rejects loudly with the reason instead of returning a success string.
112
- - **Strict recorder mode rejects the approval:** projects with `requireSubagentReview: true` (or `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1`) reject `record_review` approval verdicts recorded by the root session with "this project requires approvals to be recorded from a subagent session". Record the verdict from a subagent session instead (one with a parent session), or disable the requirement in project config; the `guard_audit` entry carries reason `subagent_recorder_required`. Changes-requested verdicts remain relayable from any session. The knob is enforced when verdicts are recorded: an approval recorded by the root session before the requirement was enabled — including durable review-cache evidence recovered after a restart — still satisfies the PR preflight until its content binding goes stale, so re-run the secondary review after adopting the knob to re-bind it.
113
- - **Worktree creation failure leaving a branch behind:** `guard_worktree_create` failures previously surfaced only git's stderr (often checkout progress) and could leave a half-created branch. Failures are now reported with git's exit status, the created branch is rolled back (pre-existing branches are preserved), and stale worktree admin entries are pruned only for the newly-created path. A live registered worktree whose sanitized name collides with the requested branch is left fully intact — rollback never removes pre-existing worktrees. Large checkouts that exceed the 15s timeout can set `WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS`.
114
- - **Edit blocked by "claimed by another active session" though no other session is working:** a missed `session.idle` release can leave a finished session's direct-edit claim behind, and claims have no expiry, so every other session is blocked from that path until the owning session is deleted or the service restarts. On a real (non-simulated) call the takeover now resolves it: a claim created before its owner's last recorded idle timestamp is provably stale and is released, with `guard_audit` recording a `file-claims` entry with reason `stale_claim_takeover`. Sessions still mid-turn (no idle timestamp) keep their claim, and the takeover fails closed without an SDK session lookup or when the lookup fails. `guard_why` remains read-only and still reports the conflict.
115
- - **You updated the plugin but the old version still loads:** a configured bare package name is resolved through Node's parent-directory lookup from OpenCode's configuration location, so a copy in any parent `node_modules` (a pnpm-managed home directory or a manual install) shadows OpenCode's own plugin cache and a global `npm install -g`; OpenCode's own update check can fail with only a server-log record (`NpmInstallFailedError`). Verify what actually loaded with `guard_status` (the `pluginVersion` field), the startup app-log line (`Workflow Guard v<version> plugin initialized ...`), or the server log's `loading plugin ... entrypoint=file:///...` record. Then update the resolved copy in place (for a pnpm-managed home directory: `cd ~ && pnpm add opencode-workflow-guard@<version>`) and run `opencode service restart` — the plugin loads in the background service, so restarting only the TUI does not reload it.
116
- - **A benign-looking command with a shell variable is blocked as "outside the workspace":** a mutation or redirect target containing an unresolvable `$VARIABLE` reference (for example `mkdir -p $D/sub`, `cp a.txt "$D/b.txt"`, `> $CACHE/out.txt`) is indeterminate without executing shell semantics, and the boundary fails closed on indeterminate destinations — the block message names this cause ("contains an unresolvable variable reference … use a literal workspace-relative path"). Deterministically expandable forms (`~`, `$HOME`) and literal paths inside the workspace are unaffected, and reads (including cache-path reads) are not gated. Replace the variable with a literal workspace-relative path in the command, or assign it inside a script file within the workspace that the boundary has already validated. Resolving the variable would require executing shell semantics, which the boundary deliberately does not do (see the shell-expansion hardening notes in the roadmap).
117
- - **A scratch write to `/tmp/opencode` is blocked:** the environment designates that directory as agent scratch space, but the workspace boundary is workspace-absolute and does not cover it, so writes there stay blocked — the block message names the directory and the remedy. Write scratch as an untracked file inside the workspace instead (for example `.tmp-notes.md`); the boundary has no override for it, and the enforcement decision is unchanged by this attribution.
108
+ - **Symptoms:** `record_review` returned "Review recorded as APPROVED" and `guard_status` showed a fresh `lastReview`, yet `pr create` was blocked with "Passing secondary review approval is required". A re-call of `record_review` may return success while `guard_status` still shows no fresh review.
109
+ - **Root Cause (fixed by content binding):** The PR preflight previously also required the review's target session to equal the PR-creating session's ID. `record_review` records from a secondary session, binding the verdict to its parent or its own directory. Sessions can move between directories mid-flight (`session_move`), and subagents spawn from other roots. A review recorded against a linked git worktree could therefore never satisfy a PR created from another session there. Mutation invalidation also erased the whole review record on any mutation, including unrelated untracked-file cleanup.
110
+ - **Binding rule:** A recorded approval satisfies the preflight when its binding matches the tree the PR publishes. The binding covers the repository, the commit hash, and the tracked-content worktree fingerprint. Review freshness is scoped to tracked content. Deleting an untracked scratch file does not invalidate it, and a tracked edit or new commit does. When the tracked fingerprint is unchanged but HEAD moved, the preflight names the commit drift specifically. That case arises when a review is recorded with changes already staged and then committed. The remedy is the same either way: record the review at the commit that will ship.
111
+ - **Commit before you review:** both evidence gates bind to the HEAD commit hash, so a commit after verification or after the review invalidates that evidence. Finish every tracked change first, then verify, then record the review, then open the PR. Any other order costs a re-run.
112
+ - **Diagnosis:** `guard_audit` records the binding key on every `record_review.verdict` entry. The key covers workspace, commit hash, fingerprint prefix, recorder session, and `recorderRole`. It also records a `pr-preflight.review-binding` entry when a PR preflight accepts a review. A verdict that cannot bind at all is rejected loudly with the reason instead of returning a success string. That case is a directory that is not a git repository.
113
+ - **Strict recorder mode rejects the approval:** Projects with `requireSubagentReview: true` (or `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1`) reject `record_review` approval verdicts recorded by the root session. The message reads "this project requires approvals to be recorded from a subagent session", and the `guard_audit` entry carries reason `subagent_recorder_required`. Record the verdict from a subagent session (one with a parent session) instead, or disable the requirement in project config. Changes-requested verdicts remain relayable from any session. The knob is enforced when verdicts are recorded. An approval recorded by the root session before the requirement was enabled still satisfies the PR preflight until its content binding goes stale. That includes durable review-cache evidence recovered after a restart. Re-run the secondary review after adopting the knob to re-bind it.
114
+ - **Worktree creation failure leaving a branch behind:** `guard_worktree_create` failures previously surfaced only git's stderr. That output is often just checkout progress, and the failure could leave a half-created branch. Failures now report git's exit status, and the created branch is rolled back. Pre-existing branches are preserved. Stale worktree admin entries are pruned only for the newly-created path. A live registered worktree whose sanitized name collides with the requested branch is left fully intact, because rollback never removes pre-existing worktrees. Large checkouts that exceed the 15s timeout can raise it with `WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS`.
115
+ - **Edit blocked by "claimed by another active session" though no other session is working:** A missed `session.idle` release can leave a finished session's direct-edit claim behind. Claims have no expiry. Every other session is blocked from that path until the owning session is deleted or the service restarts. On a real (non-simulated) call the takeover resolves it. A claim created before its owner's last recorded idle timestamp is provably stale and is released. `guard_audit` records a `file-claims` entry with reason `stale_claim_takeover`. Sessions still mid-turn (no idle timestamp) keep their claim. The takeover fails closed without an SDK session lookup, and when the lookup fails. `guard_why` remains read-only and still reports the conflict.
116
+ - **You updated the plugin but the old version still loads:** A configured bare package name is resolved through Node's parent-directory lookup from OpenCode's configuration location. A copy in any parent `node_modules` (a pnpm-managed home directory or a manual install) shadows OpenCode's own plugin cache and a global `npm install -g`. OpenCode's own update check can then fail with only a server-log record (`NpmInstallFailedError`). Verify what actually loaded with `guard_status` (the `pluginVersion` field), the startup app-log line (`Workflow Guard v<version> plugin initialized ...`), or the server log's `loading plugin ... entrypoint=file:///...` record. Then update the resolved copy in place (for a pnpm-managed home directory: `cd ~ && pnpm add opencode-workflow-guard@<version>`). Run `opencode service restart`, because the plugin loads in the background service and restarting only the TUI does not reload it.
117
+ - **A benign-looking command with a shell variable is blocked as "outside the workspace":** A mutation or redirect target that contains an unresolvable `$VARIABLE` reference is indeterminate without executing shell semantics. Examples are `mkdir -p $D/sub`, `cp a.txt "$D/b.txt"`, and `> $CACHE/out.txt`. The boundary fails closed on indeterminate destinations. The block message names this cause: "contains an unresolvable variable reference … use a literal workspace-relative path". Deterministically expandable forms (`~`, `$HOME`) and literal paths inside the workspace are unaffected. Reads, including cache-path reads, are not gated. Replace the variable with a literal workspace-relative path in the command. Alternatively, assign it inside a script file within the workspace that the boundary has already validated. Resolving the variable would need shell execution, which the boundary deliberately avoids.
118
+ - **A scratch write to `/tmp/opencode` is blocked:** The environment designates that directory as agent scratch space. The workspace boundary is workspace-absolute, though, and does not cover it. Writes there stay blocked, and the block message names both the directory and the remedy. Write scratch as an untracked file inside the workspace instead, for example `.tmp-notes.md`. The boundary has no override for it, and this attribution does not change the enforcement decision.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.15.5",
3
+ "version": "1.17.0",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,9 @@
36
36
  "guard",
37
37
  "workflow"
38
38
  ],
39
+ "engines": {
40
+ "node": ">=22.18"
41
+ },
39
42
  "scripts": {
40
43
  "test": "node test/run-test.mjs",
41
44
  "test:install": "node test/test-e2e.mts",
@@ -47,15 +50,19 @@
47
50
  "prepublishOnly": "npm run typecheck"
48
51
  },
49
52
  "dependencies": {
50
- "@opencode-ai/plugin": "latest",
51
- "@opencode/plugin": "latest",
52
- "@opentui/solid": "latest",
53
- "jsonc-parser": "latest",
54
- "@opencode/ai": "latest"
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"
55
58
  },
56
59
  "devDependencies": {
57
- "@changesets/cli": "latest",
58
- "@types/node": "latest",
59
- "typescript": "latest"
60
+ "@changesets/cli": "3.0.3",
61
+ "@types/node": "26.6.5",
62
+ "typescript": "7.0.2"
63
+ },
64
+ "overrides": {
65
+ "solid-js": "1.9.17",
66
+ "toml": "5.0.1"
60
67
  }
61
68
  }
@@ -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
+ }