opencode-workflow-guard 1.12.1 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/docs/installation.md +4 -3
- package/docs/policies.md +6 -2
- package/package.json +4 -2
- package/src/lib/guard-dispatcher.ts +6 -2
- package/src/lib/types.ts +1 -0
- package/src/lib/v2-plugin.ts +625 -0
- package/src/lib/verify.ts +11 -0
- package/src/policies/boundary.ts +16 -3
- package/src/policies/continuation.ts +20 -1
- package/src/policies/git.ts +42 -1
- package/src/policies/tamper.ts +17 -0
- package/src/workflow-guard-ui.ts +95 -14
- package/src/workflow-guard.ts +26 -5
- package/test/test-e2e.mts +129 -27
- package/test/test.mts +67 -1
package/README.md
CHANGED
|
@@ -14,15 +14,18 @@ Workflow Guard is deliberately **not an agent harness**: it can constrain action
|
|
|
14
14
|
|
|
15
15
|
### 1. Installation
|
|
16
16
|
|
|
17
|
-
Install the current published version globally
|
|
17
|
+
Install the current published version globally:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
VERSION=$(npm view opencode-workflow-guard version)
|
|
21
|
-
opencode plugin "opencode-workflow-guard@$VERSION"
|
|
21
|
+
opencode plugin add "opencode-workflow-guard@$VERSION" # OpenCode 2.x
|
|
22
|
+
opencode plugin "opencode-workflow-guard@$VERSION" --global --force # OpenCode 1.x
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
*Requires OpenCode >= 1.18.* OpenCode detects the package's server and TUI targets and updates both global configs. Use the same command after a release to upgrade; the explicit version gives OpenCode a fresh package-cache key. Restart OpenCode after installation. See [docs/installation.md](docs/installation.md) for details and links to the OpenCode plugin documentation.
|
|
25
26
|
|
|
27
|
+
The plugin supports both generations from one package: **OpenCode 1.x** loads the `server()` entrypoint, and **OpenCode 2.x** loads the `setup()` entrypoint (`@opencode/plugin`). Policies, tools, and configuration behave the same on both; V2 load tests run against a real OpenCode 2 binary in `npm run test:install`.
|
|
28
|
+
|
|
26
29
|
For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. The companion keeps a static `Workflow Guard` shield in the prompt bar; warning toasts provide block details without changing agent behavior. Do not place the TUI module under the server `plugins/` directory.
|
|
27
30
|
|
|
28
31
|

|
package/docs/installation.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
|
-
- **OpenCode >= 1.18** (requires the `GET /session/:id/todo` endpoint and V1 `PluginModule` loader).
|
|
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).
|
|
10
10
|
- **Node.js >= 22.18** or **Bun** (for running test scripts directly).
|
|
11
11
|
|
|
12
12
|
---
|
|
@@ -19,10 +19,11 @@ Install the current published version with OpenCode's plugin command:
|
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
VERSION=$(npm view opencode-workflow-guard version)
|
|
22
|
-
opencode plugin "opencode-workflow-guard@$VERSION"
|
|
22
|
+
opencode plugin add "opencode-workflow-guard@$VERSION" # OpenCode 2.x
|
|
23
|
+
opencode plugin "opencode-workflow-guard@$VERSION" --global --force # OpenCode 1.x
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
OpenCode documents `opencode plugin <
|
|
26
|
+
OpenCode 2 documents `opencode plugin add <package>` as the command to install a plugin and add it to the global configuration; OpenCode 1 documents `opencode plugin <module>` with `--global` and `--force`. Workflow Guard exposes both server and TUI targets, so OpenCode detects both and updates their global configuration files (OpenCode 2 uses one global `cli.json` for terminal-client settings). Restart OpenCode after installation because configuration is loaded at startup. See the [OpenCode plugin CLI documentation](https://opencode.ai/docs/cli/#plugin).
|
|
26
27
|
|
|
27
28
|
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.
|
|
28
29
|
|
package/docs/policies.md
CHANGED
|
@@ -12,7 +12,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
12
12
|
- **Pre-edit Gate:** All file-editing tools (`edit`, `write`, `apply_patch`) are blocked until the session has an active task list in OpenCode's native todo system (`todowrite`, persisted at `GET /session/:id/todo`).
|
|
13
13
|
- **Flexible Task Execution:** Tasks can be completed in any order as work concludes without artificial sequential blockers, maintaining maximum pair-programming DX.
|
|
14
14
|
- **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`.
|
|
15
|
-
- **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. 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.
|
|
15
|
+
- **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
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.
|
|
17
17
|
- **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
18
|
- **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.
|
|
@@ -60,6 +60,8 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
60
60
|
- Direct edits of the same paths through the `edit`/`write`/`apply_patch` tools
|
|
61
61
|
- Shell commands invoking `opencode auth`, `opencode config`, `opencode permission`, or `opencode run --auto`
|
|
62
62
|
- Evasion-normalized: quote-concatenation (`open''code.json`), escapes (`open\c\ode`), and glob wildcards (`opencode.jso?`) are stripped before matching.
|
|
63
|
+
- Collaboration commands (`gh issue`, `gh pr`, `glab issue`, `glab pr`, `az repos pr`) and document content mentioning guarded paths are not tampering: quoted arguments are command data, not shell syntax (redirect analysis runs on the quote-stripped residue), and the redirect heuristic applies to shell commands only (write/edit content is guarded by the target path check instead).
|
|
64
|
+
- Plan files under `.opencode/plans/` are exempt: opencode's plan mode writes agent-authored plan markdown there, and plans are content, not configuration. Everything else under `.opencode/` (including the directory itself) stays protected.
|
|
63
65
|
- **Read-only access is allowed** (`cat`, `less`, `grep`, `head`, `tail` on config files) - only modification attempts trigger the guard.
|
|
64
66
|
|
|
65
67
|
### 7. Feature-Branch Workflow
|
|
@@ -75,12 +77,13 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
75
77
|
- The boundary has **no override**: `WORKFLOW_GUARD_ALLOW_LIVE=1` covers live-system commands only and never weakens the workspace confinement.
|
|
76
78
|
|
|
77
79
|
### 9. Script-Laundering Guard
|
|
78
|
-
- Content written via `edit`, `write`, or `apply_patch` is scanned for destructive CLI commands
|
|
80
|
+
- Content written via `edit`, `write`, or `apply_patch` is scanned for destructive CLI commands.
|
|
79
81
|
- Prevents agents from bypassing shell guards by writing destructive commands to script files (e.g. `write deploy.sh` -> `bash deploy.sh`).
|
|
80
82
|
|
|
81
83
|
### 10. Evidence-Based Verification
|
|
82
84
|
- When the agent attempts to mark **every** task completed (finalizing the request), the guard requires passing verification evidence.
|
|
83
85
|
- Verification (`WORKFLOW_GUARD_VERIFY` env, project `verifyCommand`, or auto-detected test/typecheck runner across ecosystems: `package.json` (`npm test`, `npm run typecheck`, `npm run check`, `npm run build`), `Cargo.toml` (`cargo test`), `go.mod` (`go test ./...`), `pytest.ini`/`pyproject.toml` (`pytest`), or `deno.json` (`deno test`)) executes in an isolated environment with scrubbed credentials, output caps, token-efficient stdout/stderr snipping, and strict timeout protection. Environment configuration takes precedence over project configuration.
|
|
86
|
+
- The verification timeout defaults to 30 seconds and can be raised per project (`verifyTimeoutMs` in `.opencode/workflow-guard.json`) or per environment (`WORKFLOW_GUARD_VERIFY_TIMEOUT_MS`, which wins over project config); non-positive values are ignored and fall back to the default. Raise it for suites that legitimately exceed the default - for example this repository's own unit suite.
|
|
84
87
|
- Project `postEditValidators` can run faster targeted checks after a direct file edit actually changes the target. Each entry accepts a workspace-relative glob `pattern` (`*`, `**`, and `?` wildcards; brace expansion and character classes are not supported), a non-empty `command`, and an optional positive `timeoutMs`; invalid entries are reported as advisory feedback rather than silently ignored. Matching commands run from the workspace root with the same scrubbed verification environment. Validator failures and timeouts are appended to the edit result as advisory feedback and do not replace the blocking whole-project finalization verification. Multi-file `apply_patch` calls validate every changed target.
|
|
85
88
|
- With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list; subagents and generated continuation prompts do not replace them. At `session.idle`, the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
|
|
86
89
|
- Automatic continuation remains synthetic. By default, an eligible root session with unfinished owned todos briefly waits for OpenCode's native asynchronous title generation before that continuation is sent; set `titleSettleWorkaround: false` to opt out. The bounded wait never rewrites titles or provider/model parameters.
|
|
@@ -135,6 +138,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
135
138
|
### 20. Merged Branch & Base Freshness Guard
|
|
136
139
|
- Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
|
|
137
140
|
- Blocks creating fresh feature branches when the local base branch is behind the remote, prompting the agent to pull latest changes first.
|
|
141
|
+
- Tag push refspecs are exempt: publishing an existing tag (`git push origin v1.2.0`, `git push origin refs/tags/v1.2.0`) and creating tags (`git tag v1.2.0`, `git tag -a -m ...`) are release operations, not branch mutations - only tag deletions (`git tag -d`, `git push origin :refs/tags/v1.2.0`, `--delete`) stay blocked.
|
|
138
142
|
|
|
139
143
|
### 21. Documentation Review & Synchronization Guard
|
|
140
144
|
- Ensures that relevant documentation is updated whenever changes introduce new features, policies, or public tools before PR creation. Configurable via `.opencode/workflow-guard.json` (`requireDocumentation: true`) or `WORKFLOW_GUARD_REQUIRE_DOCS=1`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workflow-guard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.1",
|
|
4
4
|
"description": "Deterministic workflow and safety policy enforcement for OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,8 +48,10 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@opencode-ai/plugin": "latest",
|
|
51
|
+
"@opencode/plugin": "latest",
|
|
51
52
|
"@opentui/solid": "latest",
|
|
52
|
-
"jsonc-parser": "latest"
|
|
53
|
+
"jsonc-parser": "latest",
|
|
54
|
+
"@opencode/ai": "latest"
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
55
57
|
"@changesets/cli": "latest",
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
getGitStatusSummary,
|
|
35
35
|
getGitWorktreeFingerprint,
|
|
36
36
|
runVerify,
|
|
37
|
+
resolveVerifyTimeoutMs,
|
|
37
38
|
snipVerifyOutput,
|
|
38
39
|
} from "./verify.ts";
|
|
39
40
|
import { detectShellMutation, extractPatchPaths, guardShellMutation, isPathOutsideWorkspace } from "../policies/boundary.ts";
|
|
@@ -55,6 +56,7 @@ import {
|
|
|
55
56
|
onProtectedBranch,
|
|
56
57
|
parseGitInvocation,
|
|
57
58
|
pushedProtectedBranchIn,
|
|
59
|
+
tagRefspecIn,
|
|
58
60
|
} from "../policies/git.ts";
|
|
59
61
|
import { extractInterpreterPayload, outsideWritePathInPayload, secretPathInPayload, writePathsInPayload } from "../policies/interpreter.ts";
|
|
60
62
|
import { mcpMutationTool } from "../policies/mcp.ts";
|
|
@@ -180,7 +182,7 @@ export async function guardToolCallImpl(
|
|
|
180
182
|
const isFresh = verifyResult !== undefined && verifyResult.passed && verifyResult.command === command && isEvidenceFresh(verificationEvidence(verifyResult, sessionID), currentSubject, mutationTimestamp);
|
|
181
183
|
if (!isFresh) {
|
|
182
184
|
if (context?.simulate) return block("verification", "verification_required", `Blocked: finalization requires fresh passing verification (${command}).`);
|
|
183
|
-
const result = await runVerify(command, currentRoot);
|
|
185
|
+
const result = await runVerify(command, currentRoot, resolveVerifyTimeoutMs(currentRoot));
|
|
184
186
|
recordVerifyResult(command, result, sessionID, currentRoot);
|
|
185
187
|
if (!result.passed) {
|
|
186
188
|
const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
|
|
@@ -248,7 +250,6 @@ export async function guardToolCallImpl(
|
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
252
|
}
|
|
251
|
-
for (const content of extractEditContent(input)) if (isSettingsTamper(content)) return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
|
|
252
253
|
if (onProtectedBranch(currentRoot)) {
|
|
253
254
|
logPolicyBlock(`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`);
|
|
254
255
|
return block("git", "protected_branch", branchGuardReason());
|
|
@@ -329,6 +330,7 @@ export async function guardToolCallImpl(
|
|
|
329
330
|
}
|
|
330
331
|
}
|
|
331
332
|
for (const invocation of gitInvocations) {
|
|
333
|
+
if (tagRefspecIn(`git ${invocation.rest}`, invocation.repoDir)) continue;
|
|
332
334
|
if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
|
|
333
335
|
logPolicyBlock(`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`);
|
|
334
336
|
return block("git", "protected_branch", branchGuardReason());
|
|
@@ -397,6 +399,7 @@ export async function guardToolCallImpl(
|
|
|
397
399
|
for (const invocation of gitInvocations) {
|
|
398
400
|
const pushText = `git ${invocation.rest}`;
|
|
399
401
|
if (!/\bgit\s+push\b/.test(pushText)) continue;
|
|
402
|
+
if (tagRefspecIn(pushText, invocation.repoDir)) continue;
|
|
400
403
|
const pushedBranch = pushedProtectedBranchIn(pushText, invocation.repoDir);
|
|
401
404
|
if (pushedBranch) {
|
|
402
405
|
logPolicyBlock(`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`);
|
|
@@ -405,6 +408,7 @@ export async function guardToolCallImpl(
|
|
|
405
408
|
}
|
|
406
409
|
for (const invocation of gitInvocations) {
|
|
407
410
|
if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
|
|
411
|
+
if (tagRefspecIn(`git ${invocation.rest}`, invocation.repoDir)) continue;
|
|
408
412
|
if (onProtectedBranch(invocation.repoDir)) {
|
|
409
413
|
logPolicyBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
|
|
410
414
|
return block("git", "protected_branch", branchGuardReason());
|
package/src/lib/types.ts
CHANGED