opencode-workflow-guard 1.15.5 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -21
- package/bin/opencode-workflow-guard.mjs +58 -37
- package/docs/accountability-evolution-plan.md +9 -9
- package/docs/installation.md +118 -23
- package/docs/managed-deployment.md +4 -4
- package/docs/policies.md +100 -64
- package/docs/policy-posture.md +171 -0
- package/docs/testing.md +6 -3
- package/docs/troubleshooting.md +20 -19
- package/package.json +16 -9
- package/src/lib/call-claims.ts +71 -0
- package/src/lib/config-writer.d.mts +13 -0
- package/src/lib/config-writer.mjs +89 -0
- package/src/lib/custom-tools.ts +70 -8
- package/src/lib/guard-command.ts +97 -0
- package/src/lib/guard-dispatcher.ts +70 -0
- package/src/lib/host-worktree.ts +109 -0
- package/src/lib/mcp-inventory.ts +86 -0
- package/src/lib/permission-seat.ts +119 -0
- package/src/lib/plugin-instance.ts +124 -0
- package/src/lib/policy-posture.ts +227 -0
- package/src/lib/read-only-tools.ts +61 -0
- package/src/lib/state.ts +212 -36
- package/src/lib/types.ts +15 -0
- package/src/lib/v2-plugin.ts +262 -23
- package/src/lib/v2-todo.ts +137 -7
- package/src/lib/version.ts +22 -0
- package/src/lib/worktree.ts +61 -18
- package/src/policies/live-docs.ts +157 -0
- package/src/policies/mcp.ts +47 -2
- package/src/policies/stale-write.ts +10 -0
- package/src/policies/sweeping-claims.ts +71 -0
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +221 -6
- package/src/workflow-guard.ts +97 -6
- package/test/test-e2e.mts +91 -6
- package/test/test.mts +1140 -4
|
@@ -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
|
|
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
|
|
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`)
|
|
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. |
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
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`).
|
|
19
|
-
- **Solution:** The agent must call `todowrite` first. Once all tasks are marked `completed` or `cancelled`, edits
|
|
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
|
|
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)`)
|
|
92
|
-
- **Solution:** Fixed in 1.13.2
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
110
|
-
- **Binding rule:**
|
|
111
|
-
- **
|
|
112
|
-
- **
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
115
|
-
- **
|
|
116
|
-
- **
|
|
117
|
-
- **A
|
|
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.
|
|
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": "
|
|
51
|
-
"@opencode/plugin": "
|
|
52
|
-
"@opentui/solid": "
|
|
53
|
-
"jsonc-parser": "
|
|
54
|
-
"@opencode/ai": "
|
|
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": "
|
|
58
|
-
"@types/node": "
|
|
59
|
-
"typescript": "
|
|
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
|
+
}
|