opencode-workflow-guard 1.15.5 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@ WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # Also run policy probes with Ope
46
46
  The plugin enforces **24 deterministic policy rules** across four main pillars:
47
47
 
48
48
  * **Branch & History Protection:** Enforces feature-branch workflows (blocks edits/commits on `main`/`master`), hard-blocks direct or forced pushes to protected branches, enforces mergeability pre-flight checks, enforces secondary review approval (bound to the reviewed worktree content) before PR creation by default, and requires PR changelogs / changesets.
49
- * **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks with subagent inheritance, blocks concurrent direct edits to the same canonical file across sessions, requires same-session fresh reads before `edit`/`write` can replace existing files, blocks silent task deletion, enforces fresh test verification evidence before task completion, and journals completion claims vs. verification mismatches.
49
+ * **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks with subagent inheritance, requires each task's own recorded work before it can be marked completed (with an audited `[read-only]` marker for review and investigation tasks), blocks concurrent direct edits to the same canonical file across sessions, requires same-session fresh reads before `edit`/`write` can replace existing files, blocks silent task deletion, and enforces fresh test verification evidence before task completion.
50
50
  * **Secrets & Workspace Confinement:** Confines edit tools and recognized shell/git mutations within the workspace root (including symlink and `../` escape checks), scrubs sensitive environment variables in agent subshells, and redacts `.env` reads with safe schema masks. Arbitrary executables are not OS-sandboxed; use containers or filesystem isolation when hard confinement is required.
51
51
  * **Destructive CLI & Environment Safety:** Intercepts destructive cloud/database/infrastructure commands, stops script laundering and interpreter evasion, blocks TTY hangs (`vim`, `nano`, `sudo`), and guards against dangerous package manager flags.
52
52
 
@@ -76,6 +76,7 @@ The context modules — project memory, Socratic learning, and recovery checkpoi
76
76
  |---|---|
77
77
  | [**Policy Reference**](docs/policies.md) | Comprehensive specification of all 24 enforced policies, invariants, and the proactive tool surface (`guard_*`, `project_memory_*`, `learning_*`) |
78
78
  | [**Installation & Configuration**](docs/installation.md) | Setup options, global vs. local install, worktree isolation, and project configuration |
79
+ | [**Static Policy Posture**](docs/policy-posture.md) | Emitting host-level policies for invariants a repository cannot lift: both host envelopes (`experimental.policies` on 2.0.x, top-level `policies` on 2.1.x), precedence, and residual risk |
79
80
  | [**Managed Deployment**](docs/managed-deployment.md) | Administrator-managed OpenCode policy, platform locations, and startup diagnostics |
80
81
  | [**Troubleshooting**](docs/troubleshooting.md) | Diagnosing policy blocks, common false positives, and emergency override procedures |
81
82
  | [**Testing Architecture**](docs/testing.md) | Test harness design, adversarial regression matrix, and CI verification |
@@ -2,15 +2,23 @@
2
2
 
3
3
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
6
- import { parse, modify, applyEdits } from "jsonc-parser";
5
+ import { join } from "node:path";
6
+ import { preparePluginUpdate, PACKAGE_NAME } from "../src/lib/config-writer.mjs";
7
7
 
8
- const PACKAGE_NAME = "opencode-workflow-guard";
9
8
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
9
  const SERVER_SPEC = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
11
10
  const TUI_SPEC = SERVER_SPEC;
12
11
  const LEGACY_TUI_SPEC = "opencode-workflow-guard/tui";
13
12
 
13
+ const USAGE = `Usage:
14
+ opencode-workflow-guard setup [--major 1|2]
15
+ opencode-workflow-guard policies --print [--format both|2.0|next]
16
+
17
+ setup writes \`plugins\` for OpenCode 2 (the default) and \`plugin\` for OpenCode 1.
18
+ An existing config's key wins unless --major is passed.
19
+ \`policies\` defaults to both, which the 2.0.x and 2.1.x hosts each read under
20
+ their own key. Pass --format to pin one host.`;
21
+
14
22
  function configHome() {
15
23
  return process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
16
24
  }
@@ -27,49 +35,62 @@ function restoreConfig(path, previous) {
27
35
  else writeFileSync(path, previous);
28
36
  }
29
37
 
30
- function matchesPackageSpec(entry, spec) {
31
- const value = Array.isArray(entry) ? entry[0] : entry;
32
- return typeof value === "string" && (value === spec || value.startsWith(`${spec}@`));
33
- }
34
- function preparePluginUpdate(path, schema, spec, legacySpec) {
35
- const source = existsSync(path) ? readFileSync(path, "utf8") : `{
36
- "$schema": "${schema}"
37
- }\n`;
38
- const errors = [];
39
- const config = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
40
- if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) {
41
- throw new Error(`Cannot update invalid config: ${path}`);
38
+ /**
39
+ * Static policy posture. Prints to stdout only.
40
+ *
41
+ * A plugin that writes another tool's configuration has moved the static rails
42
+ * back inside the thing they are meant to outlive. So: emit, never install.
43
+ */
44
+ async function runPolicies() {
45
+ const args = process.argv.slice(3);
46
+ const formats = ["both", "2.0", "next"];
47
+ let print = false;
48
+ let format = "both";
49
+ for (let i = 0; i < args.length; i++) {
50
+ const arg = args[i];
51
+ if (arg === "--print") print = true;
52
+ else if (arg === "--format") {
53
+ format = args[++i];
54
+ if (!formats.includes(format)) throw new Error(`Unknown --format '${format}'. Use both, 2.0 or next.`);
55
+ } else if (arg.startsWith("--format=")) {
56
+ format = arg.slice("--format=".length);
57
+ if (!formats.includes(format)) throw new Error(`Unknown --format '${format}'. Use both, 2.0 or next.`);
58
+ } else throw new Error(`Unknown argument '${arg}'.`);
42
59
  }
43
- if (config.plugin !== undefined && !Array.isArray(config.plugin)) {
44
- throw new Error(`Cannot update ${path}: "plugin" must be an array`);
45
- }
46
- const plugins = config.plugin ?? [];
47
- const hasSpec = plugins.some((entry) => matchesPackageSpec(entry, PACKAGE_NAME));
48
- const updatedPlugins = legacySpec
49
- ? plugins.filter((entry) => !matchesPackageSpec(entry, legacySpec))
50
- : plugins;
51
- if (updatedPlugins.length !== plugins.length) {
52
- if (!hasSpec) updatedPlugins.push(spec);
53
- return applyEdits(source, modify(source, ["plugin"], updatedPlugins, {
54
- formattingOptions: { insertSpaces: true, tabSize: 2 },
55
- }));
56
- }
57
- if (hasSpec) return undefined;
58
- return applyEdits(source, modify(source, ["plugin", plugins.length], spec, {
59
- formattingOptions: { insertSpaces: true, tabSize: 2 },
60
- }));
60
+ if (!print) throw new Error("Refusing to write config. Pass --print and place the block yourself.");
61
+ const { renderPolicyPosture } = await import(new URL("../src/lib/policy-posture.ts", import.meta.url).href);
62
+ process.stdout.write(renderPolicyPosture({ format }));
61
63
  }
62
64
 
63
- if (process.argv[2] !== "setup" || process.argv.length > 3) {
64
- console.error("Usage: opencode-workflow-guard setup");
65
+ if (process.argv[2] === "policies") {
66
+ try {
67
+ await runPolicies();
68
+ } catch (error) {
69
+ console.error(error instanceof Error ? error.message : String(error));
70
+ console.error(USAGE);
71
+ process.exitCode = 1;
72
+ }
73
+ } else if (process.argv[2] !== "setup") {
74
+ console.error(USAGE);
65
75
  process.exitCode = 1;
66
76
  } else {
67
77
  try {
78
+ const args = process.argv.slice(3);
79
+ let explicitMajor;
80
+ for (let i = 0; i < args.length; i++) {
81
+ const arg = args[i];
82
+ const raw = arg === "--major" ? args[++i] : arg.startsWith("--major=") ? arg.slice("--major=".length) : undefined;
83
+ if (raw === undefined) throw new Error(`Unknown argument '${arg}'.`);
84
+ if (raw !== "1" && raw !== "2") throw new Error(`Unknown --major '${raw}'. Use 1 or 2.`);
85
+ explicitMajor = Number(raw);
86
+ }
68
87
  const base = join(configHome(), "opencode");
69
88
  const serverPath = existingConfig(base, "opencode");
70
89
  const tuiPath = existingConfig(base, "tui");
71
- const serverUpdate = preparePluginUpdate(serverPath, "https://opencode.ai/config.json", SERVER_SPEC);
72
- const tuiUpdate = preparePluginUpdate(tuiPath, "https://opencode.ai/tui.json", TUI_SPEC, LEGACY_TUI_SPEC);
90
+ const serverUpdate = preparePluginUpdate(serverPath, "https://opencode.ai/config.json", SERVER_SPEC, undefined, explicitMajor);
91
+ // `tui.json` keeps the singular `plugin` key on both majors: OpenCode 2
92
+ // reads that key and migrates it into `cli.json.plugins`.
93
+ const tuiUpdate = preparePluginUpdate(tuiPath, "https://opencode.ai/tui.json", TUI_SPEC, LEGACY_TUI_SPEC, explicitMajor, "plugin");
73
94
  const serverBefore = existsSync(serverPath) ? readFileSync(serverPath, "utf8") : undefined;
74
95
  const tuiBefore = existsSync(tuiPath) ? readFileSync(tuiPath, "utf8") : undefined;
75
96
  mkdirSync(base, { recursive: true });
@@ -6,9 +6,52 @@
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 the session message history because V2 has no todo endpoint).
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).
10
10
  - **Node.js >= 22.18** or **Bun** (for running test scripts directly).
11
11
 
12
+ ### Version Qualification
13
+
14
+ The guard declares exact host dependency versions, so an unreviewed host API can
15
+ never float in. A host release is qualified only when it appears in this table
16
+ with the seats that were actually exercised against it. An unlisted version is
17
+ unqualified by definition, which does not mean it is broken; it means nobody has
18
+ verified it.
19
+
20
+ | Guard | OpenCode qualified | Hook seats exercised | Notes |
21
+ |---|---|---|---|
22
+ | 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
+ | 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
+ | 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.
32
+
33
+ `guard_status` reports `pluginVersion`, `qualifiedHostVersion`, and the live
34
+ `hostVersion`, so a mismatch is visible at runtime rather than surfacing as a
35
+ silently inert hook.
36
+
37
+ ### Dependency Audit Overrides
38
+
39
+ Two transitive dependencies are pinned above the range their parent declares, to
40
+ clear advisories that would otherwise ship:
41
+
42
+ | Package | Parent constraint | Override | Why |
43
+ |---|---|---|---|
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. |
45
+ | `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. |
46
+
47
+ Both are `overrides` entries in `package.json`, and `npm audit` reports zero
48
+ vulnerabilities for this repository. `npm overrides` apply only to the root
49
+ project, so they protect this repository's install and its CI, not a consumer's
50
+ 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.
54
+
12
55
  ---
13
56
 
14
57
  ## Installation Methods
@@ -27,7 +70,7 @@ OpenCode 2 documents `opencode plugin add <package>` as the command to install a
27
70
 
28
71
  Use the same two commands to upgrade after a Workflow Guard release. Keep the explicit version in OpenCode's configuration rather than relying on a bare package name or `@latest`: OpenCode caches npm plugins, and an already-populated `@latest` cache key may continue resolving to the version that originally populated it. An explicit new version creates a new package-cache key without manually deleting OpenCode's cache.
29
72
 
30
- If you only want the server guard without the TUI companion, add the package to your project's `opencode.json` (or global `~/.config/opencode/opencode.json`). OpenCode 2 uses the plural `plugins` key; OpenCode 1.x uses the singular `plugin` key (V2 normalizes the singular form in memory, but the native shape is `plugins`):
73
+ If you only want the server guard without the TUI companion, add the package to your project's `opencode.json` (or global `~/.config/opencode/opencode.json`). OpenCode 2 reads only the plural `plugins` key; OpenCode 1.x uses the singular `plugin` key. A singular key in an OpenCode 2 config is dropped by the config schema without an error, so the guard will appear configured and never load:
31
74
 
32
75
  ```json
33
76
  {
@@ -40,11 +83,43 @@ If you only want the server guard without the TUI companion, add the package to
40
83
 
41
84
  OpenCode automatically installs configured npm plugins and their dependencies at startup. The npm package contains both server and TUI entrypoints, but OpenCode activates them from separate configuration files. See [OpenCode's npm plugin documentation](https://opencode.ai/docs/plugins/#from-npm) and [plugin installation behavior](https://opencode.ai/docs/plugins/#how-plugins-are-installed).
42
85
 
86
+ ### 2. Self-Configuring `setup` Command
87
+
88
+ The package ships a `setup` command that writes the plugin entry into the global
89
+ configuration for you:
90
+
91
+ ```bash
92
+ opencode-workflow-guard setup # OpenCode 2: writes `plugins`
93
+ opencode-workflow-guard setup --major 1 # OpenCode 1: writes `plugin`
94
+ ```
95
+
96
+ The command detects the target from the existing config: a `plugins` key means
97
+ OpenCode 2, a `plugin` key means OpenCode 1, and a fresh config defaults to
98
+ OpenCode 2. `--major` overrides the detection. The two keys are not
99
+ interchangeable: OpenCode 2's config schema drops a singular `plugin` key
100
+ without an error, so writing the wrong one produces a config that looks
101
+ configured and loads nothing. The writer refuses to clobber an unparseable
102
+ config or a non-array plugin key.
103
+
104
+ `tui.json` is the exception to the key rule. Its schema reads singular `plugin`
105
+ on both majors, and OpenCode 2 migrates that key into `cli.json.plugins`. So
106
+ `--major 2` writes `plugins` to the server config and still writes `plugin` to
107
+ `tui.json`; a plural key there is silently ignored and the TUI badge never
108
+ loads.
109
+
110
+ ### Configure The Guard In One Location Only
111
+
112
+ Name the guard in exactly one place per OpenCode process: either the global configuration (`~/.config/opencode/opencode.json[c]`, `plugins` on 2.x or `plugin` on 1.x) or a project configuration, never both. OpenCode 2 merges plugin arrays instead of replacing them, so listing `opencode-workflow-guard` globally and again in a project's `.opencode/opencode.json` loads the same entrypoint twice under two ids. Two instances register duplicate hooks and duplicate tool definitions, which evaluates and audits every tool call twice, lets one instance overwrite the other's tool-description enrichment, and primes the process-global workspace latch from two roots in the same tick.
113
+
114
+ The guard detects the duplicate instead of failing silently: the first instance for a given (module path, workspace root) pair owns enforcement, and a later instance registers no hooks or tools. The duplicate is recorded at warn level in the server log and as an audit record (`tool: "plugin.instance"`, `reason: "duplicate_plugin_load"`) in `workflow-guard.jsonl`, so `grep 'loading plugin.*workflow-guard'` on a fresh server log showing two lines is a configuration error, not a supported setup. As a second layer, the first instance to reach a given (session, call, tool) claims its policy decision, so a guard loaded from two distinct paths (an npm cache copy plus a source checkout) still produces one decision per call.
115
+
116
+ A repository that ships its own `.opencode/opencode.json` with a `plugin`/`plugins` entry is the usual source of the second copy. Remove the entry there and let the global configuration carry the guard. Because OpenCode settings are tamper-protected from agent edits, that file must be changed by the operator.
117
+
43
118
  A configured bare package name is resolved through Node's parent-directory lookup from OpenCode's configuration location: any `node_modules` above that location that contains a copy of the package (a pnpm-managed home directory, a manual `npm install`, or a workspace dependency) is loaded instead, shadowing both OpenCode's own plugin cache and a global `npm install -g` — updating the wrong copy leaves an old version running while the update appears to succeed, and OpenCode's own plugin-update check can fail with only a server-log record (`NpmInstallFailedError`). Update the copy that actually loads (for a pnpm-managed home directory: `pnpm add opencode-workflow-guard@<version>` in that directory, or remove the dependency so OpenCode manages its own copy), then restart the background service (`opencode service restart`) — the plugin loads there, so a TUI-only restart is not enough. Verify what loaded: `guard_status` reports the loaded version as `pluginVersion`, the startup app log states `Workflow Guard v<version> plugin initialized`, and the server log records the resolved module path in its `loading plugin` entry.
44
119
 
45
120
  ---
46
121
 
47
- ### 2. Manual Local-File Installation (Server Plugin)
122
+ ### 3. Manual Local-File Installation (Server Plugin)
48
123
 
49
124
  The npm package entrypoint uses OpenCode's V1 plugin module shape (`{ id, server }`). For a source checkout, keep the modular server source outside the auto-loaded `plugins/` directory and add a small adapter with the same module shape:
50
125
 
@@ -59,7 +134,7 @@ OpenCode automatically discovers the adapter from the project-level `.opencode/p
59
134
 
60
135
  ---
61
136
 
62
- ### 3. TUI Visual Indicator (Prompt Box Badge)
137
+ ### 4. TUI Visual Indicator (Prompt Box Badge)
63
138
 
64
139
  To enable the static `Workflow Guard 🛡️` prompt-bar badge, with block details reported separately through warning toasts:
65
140
 
@@ -4,7 +4,7 @@ For organization-managed installations, deploy workflow-guard together with Open
4
4
 
5
5
  As documented by OpenCode, managed configuration has the highest configuration precedence. Place `opencode.json` or `opencode.jsonc` in `/etc/opencode/` on Linux, `/Library/Application Support/opencode/` on macOS, or `%ProgramData%\opencode` on Windows. macOS can additionally use the `ai.opencode.managed` preference domain through MDM. Verify the resolved host configuration with `opencode debug config`.
6
6
 
7
- A typical managed configuration should restrict organization-level capabilities, for example with `enabled_providers`, `disabled_providers`, `experimental.policies`, and `permission`. Install workflow-guard through your controlled OpenCode distribution or configuration alongside those settings, and pin/review the package version according to your normal software-supply-chain process.
7
+ A typical managed configuration should restrict organization-level capabilities, for example with `enabled_providers`, `disabled_providers`, `experimental.policies` (2.0.x) or top-level `policies` (2.1.x), and `permission`. Install workflow-guard through your controlled OpenCode distribution or configuration alongside those settings, and pin/review the package version according to your normal software-supply-chain process.
8
8
 
9
9
  Workflow Guard's project `profile` is a convenience for optional workflow defaults, not a security level. The `interactive` default and `autonomous` profile enforce the same core safety invariants; `autonomous` currently enables recovery checkpoints by default. Explicit project feature settings override profile defaults. Ralph mode is orchestration-adjacent and therefore remains disabled unless the project explicitly sets `ralphMode: true`; selecting `autonomous` does not enable it.
10
10
 
package/docs/policies.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Enforced Policies & Guardrails
2
2
 
3
- `opencode-workflow-guard` enforces workflow discipline through **deterministic TypeScript hooks** (`tool.execute.before`, `experimental.session.compacting`, `shell.env`, `permission.ask`, and `event` journaling) - not prompt rules that LLMs can ignore.
3
+ `opencode-workflow-guard` enforces workflow discipline through **deterministic TypeScript hooks** - not prompt rules that LLMs can ignore.
4
+
5
+ On OpenCode 1.x those hooks are `tool.execute.before`, `experimental.session.compacting`, `shell.env`, `permission.ask`, and `event` journaling. On OpenCode 2.x they are `tool.hook("execute.before"/"execute.after")`, `session.hook("context"/"compaction"/"prompt")`, `command.transform`, `permission.hook("evaluate")`, `shell.hook("create.before")`, and `event.subscribe()`. V1 plugin implementations do not run on OpenCode 2, so the two lists describe separate entrypoints, not alternatives for the same host.
4
6
 
5
7
  It is a policy and enforcement layer, not an agent harness. Policies may constrain actions, record or explain decisions, and preserve bounded context; they do not plan, prioritize, delegate, or autonomously sequence work. Where Workflow Guard resumes an idle session, it only enforces already-owned todo lifecycle state and does not choose what the agent works on next.
6
8
 
@@ -9,11 +11,16 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
9
11
  ## Policy Reference
10
12
 
11
13
  ### 1. Task Breakdown & Lifecycle (`todowrite`)
12
- - **Pre-edit Gate:** All file-editing tools (`edit`, `write`, `apply_patch`) are blocked while the session's effective todo list is known and contains no active task. On OpenCode 1.x the list comes from the native todo system (`todowrite`, persisted at `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 (ACP agents may supply that tool). The gate distinguishes a **definitively empty** list (a `todowrite` part exists and reports no active task → still blocked) from an **undeterminable** one (no `todowrite` part exists at all, i.e. no todo capability, or the history cannot be read → the gate fails open). This keeps builtin-only V2 sessions usable instead of deadlocking every edit forever.
14
+ - **Pre-edit Gate:** All file-editing tools (`edit`, `write`, `apply_patch`) are blocked while the session's effective todo list is known and contains no active task. On OpenCode 1.x the list comes from the native todo system (`todowrite`, persisted at `GET /session/:id/todo`). On OpenCode 2.x before 2.1.0 the builtin tool registry has no `todowrite`, and there is no todo endpoint, so the list is reconstructed from the newest applied `todowrite` tool call in the session history (ACP agents may supply that tool). OpenCode 2.1.x reintroduces a builtin `todowrite` behind the `experimental.todo` config key; the guard detects that capability by probing the host tool registry rather than inferring it from history. Verified on 2.1.3: `ctx.tool.list()` exposes `todowrite` when the flag is on, and omits it when off. The gate distinguishes a **definitively empty** list (the capability exists and reports no active task → still blocked) from an **undeterminable** one (no todo capability, or the history cannot be read → the gate fails open and records that once per session at warn level). This keeps builtin-only V2 sessions usable instead of deadlocking every edit forever.
13
15
  - **Flexible Task Execution:** Tasks can be completed in any order as work concludes without artificial sequential blockers, maintaining maximum pair-programming DX.
14
16
  - **No Silent Deletion:** Each `todowrite` update replaces the complete list. Active tasks must remain in subsequent updates until they are explicitly marked `completed` or `cancelled`.
17
+ - **Per-Task Completion Evidence (enforced):** Because a `todowrite` call replaces the whole list, a model can mark work `completed` without doing it. A task may therefore enter `completed` only when the session recorded at least one file mutation inside that task's active window. One mutation cannot vouch for several completions, so N completions require N mutations counted from the earliest of them, and mutations spent before a task became active never count toward it. A blocked write names the task and the remedy, and the check runs in simulation too, so `guard_why` predicts the gate instead of reporting a falsely permissive verdict. A finished or dropped task releases its window, so reusing the same task text starts clean. Tasks are matched to their prior occurrences by count, so duplicate text cannot hide a new completion behind an already-completed item; the mutation counter records permitted mutation calls, not confirmed byte changes.
18
+ - **Planned late:** a task first observed already `completed` has no window to judge, so it is scored against the session's mutation history instead. This is the deliberate path for an agent that did the work before writing the list, and it stays bounded by the one-mutation-per-completion rule.
19
+ - **Read-only escape:** a task whose text carries `[read-only]` (also `[read_only]` / `[read only]`) completes without a mutation, because review, investigation, and reading are legitimate work that changes no file. The marker is an annotation, not part of task identity, so it can be appended to an already-planned task without reading as a deletion. Every use is journaled as `todo_completed_read_only`, so relabeling real work as read-only is visible in the audit trail rather than silent; that journal is the only counterweight in a repository with no detectable verify command. Work that was deliberately dropped uses `cancelled` instead.
20
+ - `guard_status` reports `taskEvidence` (`active`, `completed`, `tasksWithoutRecordedWork`, `readOnlyTasks`) so the requirement is visible before it blocks anything. This is not task authority: fresh verification evidence and the review gate remain the authority on finalization.
21
+ - **Todo Capability Visibility:** Because the gate depends on the host exposing `todowrite` at all, `guard_status` reports `todoCapability` (`available` / `unavailable` / `unknown`), so a fail-open gate is visible rather than decorative.
15
22
  - **No Silent Early Exit:** When an owning session becomes `session.idle` with unfinished native todos, the guard asynchronously asks OpenCode to resume the existing run. This enforces the session's existing lifecycle state: Workflow Guard does not choose the next task, reorder work, create work, or delegate it. Ordinary automatic continuations are capped at three consecutive attempts and the budget resets on genuine user input. User-initiated interrupts (Esc) surface as `MessageAbortedError` and suspend automatic continuation for that session until genuine user input arrives, mirroring the ralph `user_stopped` semantics. The independently opt-in `ralphMode` uses `ralphMaxIterations` (1-100, default 10) instead; genuine user input terminates an active Ralph run, and unfinished work at the budget limit reports `budget_exhausted` rather than inferring a policy block. Ralph remains disabled by default under every operation profile. Use OpenCode's native `question` tool when user feedback or a decision is required; question and permission interactions wait inside the active run rather than producing an idle completion.
16
- - **Subagent Inheritance & Role Confinement:** Subagents automatically inherit active tasks from their parent session via the `parentID` hierarchy. Subagents spawned with read-only roles (`reviewer`, `planner`, `advisor`, `critic`, `explorer`, `scout`, `evaluator`) are hard-confined: file mutations, lifecycle tools, and mutating shell commands are strictly blocked.
23
+ - **Subagent Inheritance & Role Confinement:** Subagents automatically inherit active tasks from their parent session via the `parentID` hierarchy. Subagents spawned with read-only roles (`reviewer`, `planner`, `advisor`, `critic`, `explorer`, `scout`, `evaluator`) are hard-confined: file mutations, lifecycle tools, and mutating shell commands are strictly blocked. On OpenCode 2 the confinement is two-layered: `session.hook("context")` deletes the mutating tools (`write`, `edit`, `patch`, `apply_patch`, `shell`, `bash`, `execute`, `todowrite`, `subagent`/`task`, worktree lifecycle tools) from that role's per-request tool snapshot, and `execute.before` stays as the backstop for tools supplied outside the snapshot (ACP agents, nested Code Mode calls). The pruning is journaled as `session.context` / `read_only_role_tools_pruned`.
17
24
  - **Handoff Safety:** An idle subagent whose effective todos are inherited from its parent is not auto-continued. Inherited work belongs to the parent and can be handed back normally.
18
25
  - **Durable Planning Context Discovery:** `guard_next_tasks` prefers a repository-root `TODO.md`; when it is absent, it reads conventional `ROADMAP.md`, `PLAN.md`, `TASKS.md`, `BACKLOG.md`, their `docs/` counterparts, and Markdown files under `docs/plans/`. The tool exposes candidate planning context without selecting, prioritizing, or sequencing tasks. These files never replace OpenCode's native runtime todo state.
19
26
  - **Subagent Mutation Budget:** Subagent sessions are protected by a mutation safety budget (`maxSubagentMutations` in project config or `WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS` env, default 100) to terminate runaway edit loops deterministically.
@@ -54,6 +61,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
54
61
  - MCP tools that mutate live GitHub or Azure/DevOps systems (`create`, `update`, `delete`, `merge`, `push`, `close`, etc.) are blocked.
55
62
  - Server names are tokenized on all non-alphanumerics, so `mcp__azure-devops__...`, `mcp__gh__...` and flat legacy names (`azure_devops_*`) are matched.
56
63
  - Read-only tools (`get`, `list`, `search`, `query`, etc.) are permitted.
64
+ - On OpenCode 2 the guarded server set is widened from the host's own MCP roster (`ctx.mcp.list()`), refreshed on `mcp.tools.changed`, so a renamed or enterprise instance of a guarded product is still matched. Widening is one-way by design: removing a server adds nothing, and the static token list stays the floor, because tool names are model-supplied text and this is a live-system rail.
57
65
 
58
66
  ### 6. Settings Tamper Guard
59
67
  - Prevents the agent from weakening its own permission gates **via shell or edit tools**:
@@ -82,6 +90,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
82
90
  - `mv` sources are validated too: moving a file from outside the workspace (or a protected settings/plugin file to an innocuous name) is blocked, since `mv` mutates the source.
83
91
  - External repository git writes (`git -C /other-repo commit`, `git --git-dir=/other-repo/.git push`, `GIT_DIR=/other-repo/.git git push`, `GIT_WORK_TREE=...`) are also confined to the workspace.
84
92
  - The boundary has **no override**: `WORKFLOW_GUARD_ALLOW_LIVE=1` covers live-system commands only and never weakens the workspace confinement.
93
+ - **The boundary is bound once, then only the host may widen it.** A session adopts its workspace from its own first tool-call context (a call-argument path, or the host-provided per-call worktree). After that, a path outside the boundary is foreign state: the boundary policy refuses mutations there, and the path never re-anchors the session. Only an explicit host-declared per-call worktree widens a bound session, because worktree isolation and relay flows set it deliberately. A subagent is bound by the same rule through its nearest bound ancestor: with no direct binding of its own it already inherits the parent's workspace for policy decisions, so its first foreign read is refused as a widening rather than adopted as a first bind. Every granted widening is journaled (`workspace.binding` / `workspace_rebind_host_worktree`) and every refused one is journaled with the read-or-mutation label (`workspace.binding` / `workspace_rebind_refused`). This closes the defect where one allowed read of a file in another repository moved the entire write boundary into that repository, because `external_directory` defaults to allow.
85
94
 
86
95
  ### 9. Script-Laundering Guard
87
96
  - Content written via `edit`, `write`, or `apply_patch` is scanned for destructive CLI commands.
@@ -115,10 +124,13 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
115
124
  ### 14. Audit Trail & Permission Journaling
116
125
  - Every block/allow decision is appended to `~/.local/state/opencode/workflow-guard/workflow-guard.jsonl` (XDG_STATE_HOME respected) with a timestamp, session id, tool name, decision, subagent/parent session attribution, and reason - a durable record.
117
126
  - Permission prompts are journaled at request time via the typed `permission.ask` plugin hook, and `permission.replied` outcomes (including rejections) are preserved instead of being labeled as allows.
127
+ - On OpenCode 2, the V2 plugin also *decides* at that seat: `ctx.permission.hook("evaluate")` maps the host's normalized `action`/`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`/`apply_patch` resources feed the boundary, tamper, secret, protected-branch, and todo policies. Guarded MCP servers (`GitHub`, `Azure DevOps`) feed the MCP policy.
128
+ - The seat can only tighten. An explicit configured `deny` is final and never reaches the hook, and 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.
129
+ - `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, and its thrown message carries the specific remediation text to the model.
118
130
  - `client.app.log()` complements the on-disk trail with in-app logs.
119
131
 
120
132
  ### 15. Compaction State & Policy Context Preservation
121
- - Integrates with OpenCode's `experimental.session.compacting` hook to inject bounded active policy state into `output.context` before context summarization:
133
+ - Integrates with OpenCode's compaction hook to inject bounded active policy state before context summarization: `experimental.session.compacting` on OpenCode 1.x, `session.hook("compaction")` on OpenCode 2.x (stable in the installed 2.1.3 typings):
122
134
  - Caps injected continuity context at 8,000 characters, preserving operational guard state first and filling the remaining budget with active tasks, review follow-ups, and fresh local project memory.
123
135
  - Active `todowrite` tasks with status badges and subagent hierarchy attribution.
124
136
  - Active Git branch name and protected branch status.
@@ -192,7 +204,8 @@ Besides enforcement hooks, the guard registers companion tools in OpenCode:
192
204
 
193
205
  ### `guard_worktree_cleanup`
194
206
  - Commits a final snapshot of any remaining changes (`chore(worktree): auto-snapshot before cleanup`, excluding the plugin-created `node_modules` symlink) and then removes the worktree directory with `git worktree remove --force`.
195
- - **Ownership validation:** only registered worktrees of the current repository under the configured worktree storage directory can be cleaned up; arbitrary directories, other repositories' worktrees, and the primary working tree are refused. A failed `git worktree remove` is an error, never a fallback to raw directory deletion.
207
+ - **Ownership validation:** only registered worktrees of the current repository are cleaned up. The path must be under the configured worktree storage directory, or be declared by the host's own worktree inventory (`ctx.worktree.list()` on OpenCode 2). Arbitrary directories, other repositories' worktrees, and the primary working tree are refused. A failed `git worktree remove` is an error, never a fallback to raw directory deletion.
208
+ - On OpenCode 2, creation goes through the host worktree API when it is available, so the worktree lands in the operator's configured `worktree.directory` and appears in host inventory. The host's answer is validated independently (existing directory, registered worktree of this repository, not the primary tree) and any failure falls back to the guard's git path. Removal never delegates to the host: its `remove` takes a `force` flag with no snapshot step, so it could destroy uncommitted work that this policy preserves. After a successful removal the guard asks the host to refresh its inventory.
196
209
  - **Idempotent for prunable entries:** a registered worktree whose directory is already gone (manual removal, a crashed add, external cleanup) is deregistered (`git worktree prune`, reported with its exit status on failure) and reported as cleaned, so stale admin entries that `git worktree list` shows as prunable can be cleared through the tool. The prune is repo-wide for the current repository: every worktree whose directory is missing at that moment is deregistered, so a temporarily unavailable path's admin entry can be dropped (worktree contents are never touched; use `git worktree repair` if a path returns).
197
210
  - **Lossless by construction:** if the snapshot commit cannot be established (failing hook, missing identity, lock error), cleanup aborts and the worktree is left fully intact.
198
211
 
@@ -200,7 +213,9 @@ Besides enforcement hooks, the guard registers companion tools in OpenCode:
200
213
  - All spawned git commands run with a sanitized environment: inherited git context variables (`GIT_INDEX_FILE`, `GIT_DIR`, `GIT_WORK_TREE`, ...) are stripped so the tools resolve the repository from their working directory alone. This makes them safe to invoke from inside git hooks, which export those variables.
201
214
 
202
215
  ### Inspection Tools
216
+ ### `guard_status`
203
217
  - `guard_status`, `guard_audit`, `guard_why`, `guard_review_rubric`, `record_review`, `guard_review_followups`, and `guard_review_followup_resolve` provide runtime introspection of guardrail state, audit entries, block explanations, reviewer rubric definitions, reviewer decisions, and durable local review debt. `guard_status` also exposes the effective Ralph opt-in, iteration budget, and calling-session outcome when applicable, a read-only `gitHygiene` snapshot (local branches already merged into the mainline, prunable worktree admin entries, and the current branch's base-behind distance — hygiene itself stays manual: the guard reports, it never prunes or deletes branches), and `pluginVersion`, the version of the loaded guard package, so a stale installation is visible.
218
+ - On OpenCode 2 the same report is also reachable as the server-side `/guard-status` command (`ctx.command.transform`), which delivers the JSON as a synthetic message with no model turn. That path needs no separately installed TUI companion.
204
219
  - **Priority-Ranked Review Gate:** Secondary review approval (`requireReview`) is enforced by default during PR preflight (`gh pr create` / `az repos pr create`). Before creating a pull request, a secondary reviewer subagent must evaluate the changes using `guard_review_rubric` and record approval with `record_review`. Approvals that include active P0 (blocker) or P1 (major defect) issues are rejected until blockers are resolved. Projects can opt out via `.opencode/workflow-guard.json` (`{ "requireReview": false }`) or environment variable `WORKFLOW_GUARD_REQUIRE_REVIEW=0`. Conversely, `WORKFLOW_GUARD_REQUIRE_REVIEW=1` forces the review requirement regardless of project config.
205
220
  - **Review binding is content, not session identity:** a recorded approval satisfies the PR preflight when its binding (same repository, same commit hash, same tracked-content worktree fingerprint) matches the tree the PR would publish - regardless of which session records the verdict or creates the PR. Reviewers record from subagent sessions (binding the verdict to their parent or their own directory) and sessions can move between directories (`session_move`) or spawn subagents from other roots, so keying the gate on the PR-creating session's ID made the documented flow impossible to satisfy from linked worktrees. Freshness is scoped to the reviewed diff (tracked content): untracked scratch-file cleanup and mutations in sibling worktrees cannot invalidate an approval, while any tracked edit or new commit does.
206
221
  - **`record_review` accepts any recorder session:** reviewer agents configured with a read-only toolset (e.g. glob/grep/read/webfetch only) cannot call `record_review` themselves, so the tool no longer rejects root/orchestrator sessions; the verdict binds to the recording session's resolved directory, the tool result echoes that binding, and the audit entry (`record_review.verdict`) records the binding key and recorder lineage (`recorderRole: root | subagent`). Prefer relaying through an independent secondary reviewer subagent; the tool result reminds the recorder when it runs from a root session. A verdict recorded against a directory without a computable tracked fingerprint (not a git repository) is rejected loudly and never recorded.
@@ -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
@@ -24,7 +24,7 @@ Packs the npm tarball, installs it into an isolated project to verify the modula
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`) 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, overload, or an unresolvable model make the provider unavailable, that live 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.15.5",
3
+ "version": "1.16.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.18.32",
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.2",
62
+ "typescript": "7.0.2"
63
+ },
64
+ "overrides": {
65
+ "solid-js": "1.9.17",
66
+ "toml": "5.0.1"
60
67
  }
61
68
  }