opencode-workflow-guard 1.16.0 → 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 -22
- package/docs/accountability-evolution-plan.md +9 -9
- package/docs/installation.md +47 -27
- package/docs/managed-deployment.md +3 -3
- package/docs/policies.md +98 -77
- package/docs/policy-posture.md +1 -1
- package/docs/testing.md +3 -3
- package/docs/troubleshooting.md +20 -19
- package/package.json +3 -3
- package/src/lib/custom-tools.ts +1 -0
- package/src/lib/guard-dispatcher.ts +14 -0
- package/src/lib/state.ts +45 -0
- package/src/lib/types.ts +15 -0
- package/src/lib/v2-plugin.ts +54 -1
- package/src/policies/live-docs.ts +157 -0
- package/src/policies/stale-write.ts +10 -0
- package/src/policies/sweeping-claims.ts +71 -0
- package/src/workflow-guard.ts +80 -1
- package/test/test.mts +302 -0
package/docs/policies.md
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
# Enforced Policies & Guardrails
|
|
2
2
|
|
|
3
|
-
`opencode-workflow-guard` enforces workflow discipline through **deterministic TypeScript hooks
|
|
3
|
+
`opencode-workflow-guard` enforces workflow discipline through **deterministic TypeScript hooks**. It does not rely on prompt rules that an LLM can ignore.
|
|
4
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
|
|
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. The two lists therefore describe separate entrypoints, not alternatives for the same host.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
This 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. It does not choose what the agent works on next.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
## Policy Reference
|
|
12
12
|
|
|
13
13
|
### 1. Task Breakdown & Lifecycle (`todowrite`)
|
|
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`).
|
|
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`). Before 2.1.0, the OpenCode 2.x builtin tool registry has no `todowrite`, and there is no todo endpoint. The list is then reconstructed from the newest applied `todowrite` tool call in the session history, which ACP agents may supply. 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 from an **undeterminable** one. A definitively empty list means the capability exists and reports no active task, which stays blocked. An undeterminable list means no todo capability, or the history cannot be read. The gate then fails open and records that once per session at warn level. This keeps builtin-only V2 sessions usable instead of deadlocking every edit forever.
|
|
15
15
|
- **Flexible Task Execution:** Tasks can be completed in any order as work concludes without artificial sequential blockers, maintaining maximum pair-programming DX.
|
|
16
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
|
|
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
|
|
19
|
-
- **Read-only escape:**
|
|
20
|
-
- `guard_status` reports `taskEvidence` (`active`, `completed`, `tasksWithoutRecordedWork`, `readOnlyTasks`) so the requirement is visible before it blocks anything. This is not task authority
|
|
21
|
-
- **Todo Capability Visibility:** Because the gate depends on the host exposing `todowrite` at all, `guard_status` reports `todoCapability` (`available` / `unavailable` / `unknown`)
|
|
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
|
|
23
|
-
- **Subagent Inheritance & Role Confinement:** Subagents
|
|
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. Mutations spent before a task became active never count toward it. A blocked write names the task and the remedy. The check also runs in simulation, 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. It stays bounded by the one-mutation-per-completion rule.
|
|
19
|
+
- **Read-only escape:** A task whose text carries `[read-only]`, also `[read_only]` or `[read only]`, completes without a mutation. 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 stays visible in the audit trail. In a repository with no detectable verify command, that journal is the only counterweight. 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`). A fail-open gate is therefore visible rather than decorative.
|
|
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.
|
|
23
|
+
- **Subagent Inheritance & Role Confinement:** Subagents 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. `execute.before` stays as the backstop for tools supplied outside the snapshot, such as ACP agents and nested Code Mode calls. The pruning is journaled as `session.context` / `read_only_role_tools_pruned`.
|
|
24
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.
|
|
25
|
-
- **Durable Planning Context Discovery:** `guard_next_tasks` prefers a repository-root `TODO.md
|
|
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)
|
|
27
|
-
- **Concurrent File Claims:** Direct `edit`, `write`, and `apply_patch` calls claim each canonical target for the tool-call lifetime. A different active session is blocked from racing the same file
|
|
28
|
-
- **Stale-Write Protection:** A successful `read` records a same-session fingerprint for an existing regular file
|
|
29
|
-
- **Shell-redirect seeding:**
|
|
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.
|
|
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). The budget terminates runaway edit loops deterministically.
|
|
27
|
+
- **Concurrent File Claims:** Direct `edit`, `write`, and `apply_patch` calls claim each canonical target for the tool-call lifetime. A different active session is blocked from racing the same file, including through symlink aliases. Claims are released after the call, with session idle/deletion as stale-claim cleanup when an after hook is missed. A claim created before its owner's last recorded idle timestamp cannot belong to an in-flight tool call. When another session hits such a conflict, the stale claim is released with an audited `stale_claim_takeover` entry. Owners still mid-session (no idle timestamp), clients without a session lookup, and failed lookups keep the block (fail-closed). `guard_why` stays read-only and still reports the conflict as blocked. Shell writers are not covered, and isolated worktrees remain the better choice for substantial parallel mutations.
|
|
28
|
+
- **Stale-Write Protection:** A successful `read` records a same-session fingerprint for an existing regular file from five fields. Those fields are canonical path, filesystem identity, size, nanosecond mtime, and SHA-256 content digest. A successful `edit`/`write` also records the resulting bytes, and only when the file actually changed. The session authored those bytes, so iterating on a just-created or just-modified file needs no redundant re-read. Before `edit` or `write` replaces an existing file, that fingerprint must exist and still match. Otherwise the call is blocked and must re-read. New-file creation is allowed without a prior read. Fingerprints are not inherited between parent and subagent sessions, and they are cleared on session idle or deletion. `apply_patch` and shell writers sit deliberately outside this bounded mechanism. An `apply_patch` result still seeds the session observation, but its clobbering itself is not pre-checked. The pre-execution comparison also cannot make external changes between the check and OpenCode's write atomic.
|
|
29
|
+
- **Shell-redirect seeding:** An allowed shell command that redirects into a workspace file (`cmd > file`, `cmd >> file`, fd forms) seeds the session's observation of the resulting bytes. It does so in the same change-gated way as direct edits. A file this session just created or rewrote via a shell redirect can therefore be edited without a redundant re-read. Redirects to `/dev/null`, the `/tmp/opencode` scratch directory, or outside the workspace seed nothing. An identical-byte (no-op) redirect does not seed. The not-read block message also suggests coordinating when another session may own the file. Shell writers themselves remain outside the bounded pre-check, so only the observation seeding applies.
|
|
30
30
|
|
|
31
31
|
### 2. No Pushes to Protected Branches
|
|
32
32
|
- `git push ... main` and `git push ... master` are blocked in all shell commands, including direct refs, refspecs (`git push origin HEAD:main`, `git push origin feature/x:main`), deletion refspecs (`git push origin :main`), and forced refspecs (`git push origin +main`).
|
|
@@ -38,21 +38,25 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
38
38
|
- `gh pr create` and `az repos pr create` are blocked unless:
|
|
39
39
|
- The branch diff modifies a `CHANGELOG` file, OR
|
|
40
40
|
- The branch diff adds/modifies a `.changeset/*.md` fragment file (Changesets workflow), OR
|
|
41
|
-
- The PR description (`--body`, `--description`, or `--body-file`/`-F`) contains a release-information section headed `Changelog`, `Changes`, `Release notes`, or `Summary`. Multiline Bash ANSI-C `\n`/`\r` quoting is supported for inline descriptions
|
|
42
|
-
- **Lockfile Synchronization Gate:** Whenever package manifests (`package.json`, `Cargo.toml`, `go.mod`) are modified, PR creation is blocked until their corresponding lockfiles
|
|
41
|
+
- The PR description (`--body`, `--description`, or `--body-file`/`-F`) contains a release-information section headed `Changelog`, `Changes`, `Release notes`, or `Summary`. Multiline Bash ANSI-C `\n`/`\r` quoting is supported for inline descriptions. Inline descriptions that would render Markdown line breaks as literal `\n`/`\r` text are blocked. The block message directs the author to real newlines or a description file.
|
|
42
|
+
- **Lockfile Synchronization Gate:** Whenever package manifests (`package.json`, `Cargo.toml`, `go.mod`) are modified, PR creation is blocked until their corresponding lockfiles are updated. Those lockfiles are `package-lock.json`/`bun.lock`/`pnpm-lock.yaml`/`yarn.lock`, `Cargo.lock`, and `go.sum`. For `package.json` the demand is section-aware. It fires when a section the lockfile records changed. Those sections are `dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`, `peerDependenciesMeta`, `engines`, `packageManager`, `overrides`, `resolutions`, `workspaces`, `pnpm`, and the project `name` or `version`. Scripts- or metadata-only manifest edits regenerate the lockfile byte-identical and pass without one. `Cargo.toml` and `go.mod` changes conservatively keep demanding their lockfiles.
|
|
43
43
|
- When multiple PR prerequisites fail at once, the guard reports all detected preflight failures together so they can be resolved before retrying PR creation.
|
|
44
44
|
|
|
45
45
|
### 4. Destructive CLI Operations Guard
|
|
46
46
|
- Blocks destructive filesystem, infrastructure, cloud, database, and git operations unless the **user** explicitly overrides via environment variable.
|
|
47
47
|
- **Blocked:**
|
|
48
|
-
- Filesystem & Disks:
|
|
48
|
+
- Filesystem & Disks:
|
|
49
|
+
- `rm -rf`, `rm -r`, and forced deletion of system or home paths
|
|
50
|
+
- Disk wipes (`mkfs`, `wipefs`, `parted`, `sfdisk`) and raw block writes (`dd of=/dev/...`, `shred /dev/...`)
|
|
51
|
+
- Recursive permission or ownership clobbering (`chmod -R ... /`, `chown -R ... ~`)
|
|
52
|
+
- Reverse shells and raw sockets (`/dev/tcp/...`, `nc -e`, `socat exec:`)
|
|
49
53
|
- Kubernetes: `kubectl delete`, `kubectl drain`, `kubectl cordon`, `kubectl rollout undo/restart`
|
|
50
54
|
- Helm: `helm uninstall`, `helm rollback`, `helm delete`
|
|
51
55
|
- IaC: `terraform destroy`, `tofu destroy`, `pulumi destroy`
|
|
52
56
|
- Containers: `docker rm`, `docker container/system/image/volume prune`, `docker volume rm`
|
|
53
57
|
- Cloud: `az ... delete/purge`, `aws ... delete/terminate`, `gcloud ... delete/abandon`
|
|
54
58
|
- Hosted Git: `gh repo/issue/pr/release/secret/variable delete|close`
|
|
55
|
-
- Databases: `psql/mysql/mariadb/mongosh/redis-cli/sqlite3` drop, delete, truncate, flushall
|
|
59
|
+
- Databases: `psql/mysql/mariadb/mongosh/redis-cli/sqlite3` drop, delete, truncate, and flushall, plus `prisma migrate reset`
|
|
56
60
|
- Remote HTTP: `curl -X DELETE` against non-localhost endpoints
|
|
57
61
|
- Git: `git push --force`, `git push +refspec`, `git clean -fdx`
|
|
58
62
|
- **Allowed:** Non-destructive mutations (`kubectl apply`, `terraform apply`, `helm upgrade`, `az create/update`, DB inserts, `curl POST/PUT/PATCH`, `docker ps`, single-file `rm`).
|
|
@@ -61,7 +65,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
61
65
|
- MCP tools that mutate live GitHub or Azure/DevOps systems (`create`, `update`, `delete`, `merge`, `push`, `close`, etc.) are blocked.
|
|
62
66
|
- Server names are tokenized on all non-alphanumerics, so `mcp__azure-devops__...`, `mcp__gh__...` and flat legacy names (`azure_devops_*`) are matched.
|
|
63
67
|
- 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
|
|
68
|
+
- On OpenCode 2 the guarded server set is widened from the host's own MCP roster (`ctx.mcp.list()`), refreshed on `mcp.tools.changed`. A renamed or enterprise instance of a guarded product is therefore still matched. Widening is one-way by design: removing a server adds nothing, and the static token list stays the floor. Tool names are model-supplied text, and this is a live-system rail.
|
|
65
69
|
|
|
66
70
|
### 6. Settings Tamper Guard
|
|
67
71
|
- Prevents the agent from weakening its own permission gates **via shell or edit tools**:
|
|
@@ -69,28 +73,28 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
69
73
|
- Direct edits of the same paths through the `edit`/`write`/`apply_patch` tools
|
|
70
74
|
- Shell commands invoking `opencode auth`, `opencode config`, `opencode permission`, or `opencode run --auto`
|
|
71
75
|
- Evasion-normalized: quote-concatenation (`open''code.json`), escapes (`open\c\ode`), and glob wildcards (`opencode.jso?`) are stripped before matching.
|
|
72
|
-
- Collaboration commands (`gh issue`, `gh pr`, `glab issue`, `glab pr`, `az repos pr`) and document content mentioning guarded paths are not tampering
|
|
73
|
-
- Quoted data spans are not shell syntax
|
|
76
|
+
- 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. The redirect heuristic applies to shell commands only. Write and edit content is guarded by the target path check instead.
|
|
77
|
+
- Quoted data spans are not shell syntax. Pattern matching runs on the quote-stripped residue, where redirect targets are honored quoted or not, and quote-concatenated paths keep normalizing. A quoted span containing a guarded path is never treated as a redirect or command verb. Unquoted redirects into guarded paths stay blocked.
|
|
74
78
|
- 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.
|
|
75
79
|
- **Read-only access is allowed** (`cat`, `less`, `grep`, `head`, `tail` on config files) - only modification attempts trigger the guard.
|
|
76
|
-
- **Live control-plane facts (`liveControlPlanePaths`, opt-in):**
|
|
80
|
+
- **Live control-plane facts (`liveControlPlanePaths`, opt-in):** Declare the absolute paths of the live control plane in `.opencode/workflow-guard.json` or via `WORKFLOW_GUARD_LIVE_CONTROL_PLANE_PATHS` (comma-separated). Examples are `~/.config/opencode` and the guard's own install. When set, config-path protection classifies by runtime consumption instead of filename segments. Only targets that resolve under a live root are protected (symlink-aware). Copying **from** a config path into a sanctioned workspace draft is therefore no longer blocked, because the copy destination is what matters. Versioned drafts such as a project `.opencode/` or a dotfiles `.config/opencode` are editable. Roots must be absolute (`~`/`$HOME` are expanded). An unusable root rejects the whole set and falls back to the legacy fail-closed segment matching. Unset keeps the legacy behavior.
|
|
77
81
|
|
|
78
82
|
### 7. Feature-Branch Workflow
|
|
79
|
-
- When the workspace git repository is on `main` or `master`, all file mutations and history-changing git commands
|
|
83
|
+
- When the workspace git repository is on `main` or `master`, all file mutations and history-changing git commands are blocked. Those commands are `commit`, `merge`, `rebase`, `cherry-pick`, `revert`, `apply`, `am`, `reset`, `restore`, `stash pop`, `update-ref`, `filter-branch`, and `branch -D/-M`.
|
|
80
84
|
- Git global flags are parsed, so `git -C /repo commit` is gated on `/repo`'s branch, not the workspace's.
|
|
81
85
|
- The agent is prompted to create a feature branch first (`git switch -c feat/my-feature`).
|
|
82
86
|
|
|
83
87
|
### 8. Workspace Boundary Guard
|
|
84
|
-
- File modification tools (`edit`, `write`, `apply_patch`) and recognized shell mutations
|
|
85
|
-
- **Unresolvable variable cause attribution:**
|
|
86
|
-
- **Scratch-directory cause attribution:** `/tmp/opencode` is the environment's designated agent scratch space, but the workspace boundary is workspace-absolute and does not cover it
|
|
87
|
-
- **Symlink-alias acceptance:**
|
|
88
|
+
- File modification tools (`edit`, `write`, `apply_patch`) and recognized shell mutations are validated so detected targets cannot escape the current workspace root. The recognized shell mutations are redirection `>`, `>>`, `&>`, `>&`, `tee`, `sed -i`, `cp`/`mv`/`ln`, `touch`, `mkdir`, `rm`, `truncate`, `dd of=`, `git apply`, and `git am`. The check covers `../` traversal, symlinks, absolute paths, and shell expansions (`~`, `~user`, `$HOME`). Unresolvable `$VARIABLE` references fail closed. This is command-level enforcement, not filesystem sandboxing. Arbitrary programs and dynamically computed paths require the OS isolation described under Known Limits.
|
|
89
|
+
- **Unresolvable variable cause attribution:** A target containing an unresolvable `$VARIABLE` reference is indeterminate without executing shell semantics. Examples are `mkdir -p $D/sub` and `> $CACHE/out.txt`. The guard therefore treats it as outside the workspace. The block message says so ("contains an unresolvable variable reference … use a literal workspace-relative path") instead of showing the generic escape message. Deterministically expandable forms (`~`, `~user`, `$HOME`, `${HOME}`) keep the generic message. Cache-path and other literal destinations inside the workspace are unaffected.
|
|
90
|
+
- **Scratch-directory cause attribution:** `/tmp/opencode` is the environment's designated agent scratch space, but the workspace boundary is workspace-absolute and does not cover it. Writes there stay blocked. The block message names the directory and points to the remedy instead of reading as a guard bug. The remedy is an untracked scratch file inside the workspace, for example `.tmp-notes.md`. The enforcement decision is unchanged.
|
|
91
|
+
- **Symlink-alias acceptance:** A target whose lexical path falls outside the workspace root but whose canonical resolution is inside it is accepted. One example is `/home/<user>/...` on hosts where `/home` is a symlink into `/var/home` and the workspace root lives under `/var/home`. A target stays blocked whenever the canonical resolution is outside the canonicalized root. That includes symlinks inside the workspace that point outside, and not-yet-created files under them (nearest existing ancestor). When canonicalization is indeterminate, the lexical verdict stands (fail closed). A path is outside the workspace only when both its lexical and its canonical resolution are outside.
|
|
88
92
|
- `tee` flag parsing generically skips option flags (`-a`, `--append`, `-ai`, `--`) and validates all specified target files against the boundary.
|
|
89
|
-
- Embedded non-shell comparisons are not mutations
|
|
90
|
-
- `mv` sources are validated too
|
|
93
|
+
- Embedded non-shell comparisons are not mutations. A bare `>` whose target is purely numeric (`WHERE count > 5`, awk `$3 > 100`) and `>=` or `==` operators are treated as comparison operands, not redirects. Real redirects are unaffected (`> src/a.ts`, `>>`, `&>`, `>&`, and fd targets like `/dev/null` still follow their existing rules).
|
|
94
|
+
- `mv` sources are validated too. Moving a file from outside the workspace is blocked, since `mv` mutates the source. The same applies to moving a protected settings or plugin file to an innocuous name.
|
|
91
95
|
- 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.
|
|
92
96
|
- 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
|
|
97
|
+
- **The boundary is bound once, then only the host may widen it.** A session adopts its workspace from its own first tool-call context. That context is 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. 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. Its first foreign read is therefore refused as a widening rather than adopted as a first bind. Every granted widening is journaled (`workspace.binding` / `workspace_rebind_host_worktree`). Every refused one is journaled with the read-or-mutation label (`workspace.binding` / `workspace_rebind_refused`). This closes a defect where one allowed read of another repository moved the entire write boundary there, because `external_directory` defaults to allow.
|
|
94
98
|
|
|
95
99
|
### 9. Script-Laundering Guard
|
|
96
100
|
- Content written via `edit`, `write`, or `apply_patch` is scanned for destructive CLI commands.
|
|
@@ -98,40 +102,40 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
98
102
|
|
|
99
103
|
### 10. Evidence-Based Verification
|
|
100
104
|
- When the agent attempts to mark **every** task completed (finalizing the request), the guard requires passing verification evidence.
|
|
101
|
-
- Verification
|
|
102
|
-
- The verification timeout defaults to 30 seconds
|
|
103
|
-
- Project `postEditValidators` can run faster targeted checks after a direct file edit actually changes the target. Each entry accepts a workspace-relative glob `pattern`
|
|
104
|
-
- 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
|
|
105
|
-
- 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
|
|
105
|
+
- Verification executes in an isolated environment with scrubbed credentials, output caps, token-efficient stdout/stderr snipping, and strict timeout protection. The command comes from the `WORKFLOW_GUARD_VERIFY` env, the project `verifyCommand`, or an auto-detected test or typecheck runner across ecosystems. Auto-detection covers `package.json` (`npm test`, `npm run typecheck`, `npm run check`, `npm run build`), `Cargo.toml` (`cargo test`), `go.mod` (`go test ./...`), `pytest.ini` or `pyproject.toml` (`pytest`), and `deno.json` (`deno test`). Environment configuration takes precedence over project configuration.
|
|
106
|
+
- The verification timeout defaults to 30 seconds. Raise it per project with `verifyTimeoutMs` in `.opencode/workflow-guard.json`, or per environment with `WORKFLOW_GUARD_VERIFY_TIMEOUT_MS`, which wins over project config. Non-positive values are ignored and fall back to the default. Raise the timeout for suites that legitimately exceed the default, for example this repository's own unit suite.
|
|
107
|
+
- Project `postEditValidators` can run faster targeted checks after a direct file edit actually changes the target. Each entry accepts a workspace-relative glob `pattern` with `*`, `**`, and `?` wildcards. Brace expansion and character classes are not supported. Each entry also accepts a non-empty `command` and an optional positive `timeoutMs`. Invalid entries are reported as advisory feedback rather than silently ignored. Matching commands run from the workspace root with the same scrubbed verification environment. Validator failures and timeouts are appended to the edit result as advisory feedback. They do not replace the blocking whole-project finalization verification. Multi-file `apply_patch` calls validate every changed target.
|
|
108
|
+
- With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list. Subagents and generated continuation prompts do not replace them. At `session.idle`, the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
|
|
109
|
+
- Automatic continuation remains synthetic. By default, an eligible root session with unfinished owned todos briefly waits for OpenCode's native asynchronous title generation before that continuation is sent. Set `titleSettleWorkaround: false` to opt out. The bounded wait never rewrites titles or provider and model parameters.
|
|
106
110
|
- **Git State & Snapshot Binding:** Verification evidence records the current git commit hash (`git rev-parse HEAD`) and working tree status. If unverified edits or index modifications occur after verification, fresh verification is enforced.
|
|
107
|
-
- **Untracked Special Entries:** The worktree fingerprint binds untracked symlinks by their target string
|
|
108
|
-
- **Durable Disk Cache:** Passing verification results (`~/.local/state/opencode/workflow-guard/last-verify.json`) and passing secondary review approvals (`~/.local/state/opencode/workflow-guard/last-review.json`) are cached to disk
|
|
111
|
+
- **Untracked Special Entries:** The worktree fingerprint binds untracked symlinks by their target string. It follows git semantics, so re-pointing a link moves the fingerprint and changing the link target's contents does not. FIFOs and sockets are bound by path and mode. Unopenable entries are skipped with a `worktree-fingerprint` warning in the audit trail instead of voiding the whole fingerprint. One unreadable scratch symlink or `chmod 000` file can no longer make review or verification evidence permanently unfresh. The PR preflight names the actual cause when an approval cannot be matched: no fingerprint binding, fingerprint mismatch, or uncomputable fingerprint. It no longer shows the generic "review approval required". Documented tradeoff: the contents of a skipped entry are excluded from the fingerprint. A file that stays unreadable to the guard's user across the entire window can therefore change contents without moving the fingerprint. Any readability transition is fingerprint-visible (fail-closed), and each skip is named in the durable audit trail.
|
|
112
|
+
- **Durable Disk Cache:** Passing verification results (`~/.local/state/opencode/workflow-guard/last-verify.json`) and passing secondary review approvals (`~/.local/state/opencode/workflow-guard/last-review.json`) are cached to disk. Session restarts and multi-agent handoffs can therefore retain valid verification and review evidence without redundant test runs. Durable evidence is **workspace-bound**: a cached result is only accepted for the workspace that produced it. A passing run in one project can never satisfy finalization in another.
|
|
109
113
|
- If verification fails, finalization is blocked with a structured summary of error markers, stack traces, and failure output.
|
|
110
|
-
- OpenCode's current `lsp.client.diagnostics` event identifies the server and path
|
|
114
|
+
- OpenCode's current `lsp.client.diagnostics` event identifies the server and path. It does not expose the diagnostics themselves. The guard therefore does not claim an LSP-clean finalization gate until the SDK provides a supported diagnostics source.
|
|
111
115
|
- The verify command is **disabled** when `WORKFLOW_GUARD_VERIFY` is empty (`""`).
|
|
112
116
|
|
|
113
117
|
### 11. Secret-Content Scan
|
|
114
|
-
- File payloads and recognized shell file-mutation commands are scanned for common credential material
|
|
118
|
+
- File payloads and recognized shell file-mutation commands are scanned for common credential material. That material includes AWS keys, private key headers, GitHub tokens, OpenAI and LLM keys, Google API keys, Slack tokens, and explicit env-style assignments.
|
|
115
119
|
- Flagged content is blocked before the write with an actionable message. Secret-file reads also resolve existing symlink aliases, and copying/moving/linking a known secret path under another name is blocked.
|
|
116
120
|
|
|
117
121
|
### 12. Shell Environment Scrub
|
|
118
|
-
- Sensitive environment variables
|
|
122
|
+
- Sensitive environment variables are **emptied** in agent shells by the `shell.env` hook. The list covers `AWS_*`, `KUBE*`, `OPENAI*`, `ANTHROPIC*`, `GOOGLE_/GCP_`, `AZURE_`, `SLACK_`, `NPM_`, `DOCKER_`, `KUBECONFIG`, and fixed names like `OPENAI_API_KEY`, `KUBECONFIG`, and `NPM_TOKEN`. GitHub tokens (`GITHUB_TOKEN`, `GH_TOKEN`) are preserved so `gh` CLI and git operations function without recurring login prompts.
|
|
119
123
|
- This prevents the agent from carrying live credentials by default. The user can still grant access through OpenCode's own permission system if a task genuinely needs it.
|
|
120
124
|
|
|
121
125
|
### 13. Command-Channel Audit
|
|
122
126
|
- User-triggered slash commands (`command.executed` events) are journaled to the audit trail so agents cannot perform hidden work through a user-facing side channel.
|
|
123
127
|
|
|
124
128
|
### 14. Audit Trail & Permission Journaling
|
|
125
|
-
- Every block
|
|
126
|
-
- Permission prompts are journaled at request time via the typed `permission.ask` plugin hook
|
|
127
|
-
- On OpenCode 2, the V2 plugin also *decides* at that seat
|
|
128
|
-
- The seat can only tighten. An explicit configured `deny` is final and never reaches the hook
|
|
129
|
-
- `execute.before` is kept as the second, last-resort seat
|
|
129
|
+
- Every block and allow decision is appended to `~/.local/state/opencode/workflow-guard/workflow-guard.jsonl`, with XDG_STATE_HOME respected. Each record carries a timestamp, session id, tool name, decision, subagent and parent session attribution, and reason. This is a durable record.
|
|
130
|
+
- Permission prompts are journaled at request time via the typed `permission.ask` plugin hook. `permission.replied` outcomes, including rejections, are preserved instead of being labeled as allows.
|
|
131
|
+
- On OpenCode 2, the V2 plugin also *decides* at that seat. `ctx.permission.hook("evaluate")` maps the host's normalized `action` and `resources` back onto the policy core and sets `effect = "deny"` with an attributable message, so the tool never starts. Shell resources feed the shell, git, destructive, and workspace policies. `read` resources feed the secret policy. `edit`, `write`, `patch`, and `apply_patch` resources feed the boundary, tamper, secret, protected-branch, and todo policies. Guarded MCP servers (`GitHub`, `Azure DevOps`) feed the MCP policy.
|
|
132
|
+
- The seat can only tighten. An explicit configured `deny` is final and never reaches the hook. An unmapped host action (`external_directory`, `glob`, `grep`, `webfetch`, `websearch`, `question`, `subagent`) is left to host policy. If the seat itself throws, it fails closed with a deny instead of becoming a pass.
|
|
133
|
+
- `execute.before` is kept as the second, last-resort seat. It sees raw tool input, so todo lifecycle, stale-write observations, and post-edit snapshots stay there. Its thrown message carries the specific remediation text to the model.
|
|
130
134
|
- `client.app.log()` complements the on-disk trail with in-app logs.
|
|
131
135
|
|
|
132
136
|
### 15. Compaction State & Policy Context Preservation
|
|
133
|
-
- Integrates with OpenCode's compaction hook to inject bounded active policy state before context summarization
|
|
134
|
-
- Caps injected continuity context at 8,000 characters
|
|
137
|
+
- Integrates with OpenCode's compaction hook to inject bounded active policy state before context summarization. The hook is `experimental.session.compacting` on OpenCode 1.x and `session.hook("compaction")` on OpenCode 2.x, which is stable in the installed 2.1.3 typings:
|
|
138
|
+
- Caps injected continuity context at 8,000 characters. Operational guard state is preserved first. The remaining budget holds active tasks, review follow-ups, and fresh local project memory.
|
|
135
139
|
- Active `todowrite` tasks with status badges and subagent hierarchy attribution.
|
|
136
140
|
- Active Git branch name and protected branch status.
|
|
137
141
|
- Test verification status (passed/failed, test command, and commit hash).
|
|
@@ -145,29 +149,29 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
145
149
|
- The companion TUI plugin (`workflow-guard-ui.ts`) keeps `Workflow Guard 🛡️` static in the prompt bar. Block details remain in the warning toast rather than changing badge content or color.
|
|
146
150
|
|
|
147
151
|
### 17. Secret-File READ Block & Safe Schema Masking
|
|
148
|
-
- Blocks reading sensitive credential files
|
|
149
|
-
- **Safe Schema Masking:** On `.env*` reads via the `read` tool, the guard parses the variable names and comments
|
|
152
|
+
- Blocks reading sensitive credential files through the `read` tool, shell commands, or inline interpreter payloads. The credential patterns are `.env*`, `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*`, `*kubeconfig*`, `*credentials*.json`, and `service-account*.json`. The shell commands are `cat`, `less`, `more`, `grep`, `awk`, `head`, `tail`, and `base64`. Interpreter payload examples are `python3 -c "open('.env')"` and `bash -c 'cat id_rsa'`.
|
|
153
|
+
- **Safe Schema Masking:** On `.env*` reads via the `read` tool, the guard parses the variable names and comments. It returns a safe redacted schema mask with secret values masked as `********`. Agents can then inspect variable existence without leaking secrets into model context.
|
|
150
154
|
- Standard non-secret fixtures (`.env.example`, `.env.sample`, `.env.template`) remain readable.
|
|
151
155
|
|
|
152
156
|
### 18. Interpreter Inline Evasion Scanner
|
|
153
|
-
- Decodes and inspects inline interpreter payloads
|
|
157
|
+
- Decodes and inspects inline interpreter payloads to prevent smuggling live destructive commands past shell pattern checks. Smuggled content includes settings tampering, secret reads, and out-of-workspace writes. The inspected forms are `python -c`, `node -e`, `perl -e`, `ruby -e`, `osascript -e`, `bash -c`, `sh -c`, `zsh -c`, `powershell -enc`, and `echo <base64> | base64 -d | sh`.
|
|
154
158
|
|
|
155
159
|
### 19. Conflict-Free Pre-Flight Guard
|
|
156
160
|
- Evaluates `git merge-tree` against the base branch (`origin/main`, `origin/master`, `main`) before PR creation or final task handoff, ensuring changes can be merged cleanly without conflicts.
|
|
157
161
|
|
|
158
162
|
### 20. Merged Branch & Base Freshness Guard
|
|
159
163
|
- Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
|
|
160
|
-
- Blocks creating feature branches (`git switch -c`, `git checkout -b`) from a stale base
|
|
161
|
-
- Tag push refspecs are exempt
|
|
164
|
+
- Blocks creating feature branches (`git switch -c`, `git checkout -b`) from a stale base. Without an explicit start point, the current branch must be fresh against the remote default. An explicit start point that already contains the remote default (`origin/HEAD`) is accepted, because the new branch is fresh by construction. Start points that cannot be resolved fail closed. Block reasons name the branch actually checked. They also name remedies that work under the guard: rebase or ff-merge the branch, or branch from the remote default directly. Shell redirections attached to or following a branch creation are ignored when classifying the start point. Those redirections are stdout and stderr redirection and fd duplication such as `2>&1`. They never determine which commit the branch is created from.
|
|
165
|
+
- 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.
|
|
162
166
|
|
|
163
167
|
### 21. Documentation Review & Synchronization Guard
|
|
164
168
|
- 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`.
|
|
165
|
-
- Only **README.md** (root or package level) and files under **`docs/`** count as documentation updates
|
|
169
|
+
- Only **README.md** (root or package level) and files under **`docs/`** count as documentation updates. Arbitrary markdown does not satisfy this gate. That includes `.changeset/*.md` fragments and `CHANGELOG.md`.
|
|
166
170
|
|
|
167
171
|
### 22. Non-Interactive Shell & TTY Hang Guard
|
|
168
|
-
- Blocks commands that spawn interactive text editors (`nano`, `vim`, `emacs`), interactive pagers (`less`, `more`) including through common shell wrappers, interactive monitors (`top`, `htop`)
|
|
172
|
+
- Blocks commands that spawn interactive text editors (`nano`, `vim`, `emacs`), interactive pagers (`less`, `more`) including through common shell wrappers, and interactive monitors (`top`, `htop`). It also blocks interactive rebase and patch prompts (`git rebase -i`, `git add -p`), `sudo`, and package managers missing non-interactive confirmation flags (`npm init` without `-y`, `apt-get` without `-y`). Pager and monitor names appearing only as command arguments are not treated as pager or monitor execution. Examples are `az keyvault secret show --name top` and `echo top`. Monitors are only flagged in command position. That means after wrappers such as `sudo`/`env`/`timeout`, through `eval` and `sh -c`, and via `busybox`. `top` batch mode stays allowed. Indirection this does not model is a known limitation. It covers `watch`/`xargs`/`man`, `find -exec`/`su -c`, and shell loop bodies such as `while true; do top; done`.
|
|
169
173
|
- Prevents subshell agents from hanging indefinitely waiting for user stdin in background execution.
|
|
170
|
-
- **Desktop Notifications:** Emits native OS notifications
|
|
174
|
+
- **Desktop Notifications:** Emits native OS notifications when a policy blocks a command or when verification runs finish. The backends are Linux `notify-send` and macOS `osascript`. This keeps developers informed when their terminal is backgrounded. Configure it via `WORKFLOW_GUARD_NOTIFY=0`.
|
|
171
175
|
|
|
172
176
|
### 23. Package Supply-Chain & Dependency Hygiene Guard
|
|
173
177
|
- Blocks destructive package manager actions that break dependency trees or pollute machines:
|
|
@@ -177,16 +181,32 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
177
181
|
- `pip install --force-reinstall`: Enforces pinned requirements.
|
|
178
182
|
|
|
179
183
|
### 24. Completion Claims vs Evidence (Observability)
|
|
180
|
-
- When the assistant's final response text asserts completion or passing verification
|
|
181
|
-
- Mismatches
|
|
182
|
-
- Uses the `experimental.text.complete` hook
|
|
184
|
+
- When the assistant's final response text asserts completion or passing verification, the guard compares the claim against recorded verification evidence for that session. The trigger phrases are "all tests pass", "work is done", and "verified".
|
|
185
|
+
- Mismatches are journaled to the audit trail and logged at info level. A mismatch means failing verification, stale evidence due to mutations after the verify run, or no evidence at all. This is **observability, not gating**. The response is never blocked, but a confident wrap-up cannot silently contradict a failing or absent verification state.
|
|
186
|
+
- Uses the `experimental.text.complete` hook. Claim detection is a conservative phrase heuristic to avoid false positives on casual wording.
|
|
187
|
+
|
|
188
|
+
### 25. Live-Documentation Research Floor
|
|
189
|
+
- Closes the "guess from training data" loop. When a session accumulates **3 tool failures** with no live-documentation lookup since, the guard blocks the next **mutation or shell** call. It tells the agent to consult a current source (`websearch`/`webfetch`, or an MCP documentation tool) before retrying. Reads, greps, and research tools are never blocked, so the escape hatch is always the correct next action.
|
|
190
|
+
- **Gated surface:** the floor covers `edit`/`write`/`patch`/`apply_patch`, the shell tools, and the guard's own mutating lifecycle tools (`guard_worktree_create`, `guard_worktree_cleanup`, `guard_recovery_restore`). A tool the guard provides must not be the way past a guard gate.
|
|
191
|
+
- **Debt-based, not success-based:** the floor counts failures since the session last successfully consulted a live source. An unrelated successful tool does *not* clear the debt (unlike the circuit-breaker counter), so a benign `echo` cannot slip a guessed change back through. A successful research call clears it.
|
|
192
|
+
- **A failed research attempt never accrues debt.** Only non-research failures count. Otherwise a flaky or rate-limited search would deepen the block the agent is trying to escape. The floor would then deadlock a session that followed its own advice.
|
|
193
|
+
- **Research recognition:** the builtin names (`websearch`, `webfetch`, and common aliases) match outright. MCP sources match on documented server names, so `mcp__context7__*`, `mcp__deepwiki__*`, `mcp__tavily__*`, `mcp__brave__*`, and similar docs servers count. A guard inspection tool or a generic file reader cannot clear the debt by name accident. Add an unrecognised server with `researchTools` in project config, or `WORKFLOW_GUARD_RESEARCH_TOOLS` (comma-separated name fragments).
|
|
194
|
+
- Prior art: iterative grounding / De-Hallucinator (arXiv 2401.01701), knowledge-lookup gates for agent writes, and deterministic supersession (STALE, arXiv 2605.06527). Failure-mode context: false/illusory completion (agents self-report done) and temporal obsolescence of training data.
|
|
195
|
+
- Enabled **by default** to force the failover. Disable with `.opencode/workflow-guard.json` (`requireLiveDocs: false`) or `WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0`. Raise or lower the trigger with `liveDocFailureThreshold` or `WORKFLOW_GUARD_LIVE_DOC_THRESHOLD`. An unparseable, zero, or negative threshold falls back to the default of 3, so a typo cannot silently disable the floor. Decisions are journaled (`reason: live_docs_research_floor`) under both entrypoints. On OpenCode 1.x block feedback also reaches `client.app.log` at warn level. OpenCode 2 has no server-side log API, so V2 journals the decision and toasts instead.
|
|
196
|
+
- **Known limit:** Code Mode `execute` is not gated, because the nested tools it calls do not pass through guard hooks. A session that reaches the floor can still mutate through that path, the same way it can through an unmodelled indirection described under Known Limits.
|
|
197
|
+
- **Prerequisite:** the acting agent must be allowed to call a research tool. An agent whose toolset denies `websearch`/`webfetch` and exposes no MCP docs server cannot satisfy the floor. Grant one, or the agent will (correctly) stall on the block. This is an opt-out-able default for that reason.
|
|
198
|
+
|
|
199
|
+
### 26. Sweeping Repository Claims vs Read Coverage (Observability)
|
|
200
|
+
- Journal-only companion to Policy 25, covering the other failure mode. An agent skims one or two files and then asserts whole-repository facts. Examples are "the entire codebase uses X", "no other callers reference this", and "nothing depends on it".
|
|
201
|
+
- Uses the `experimental.text.complete` hook to compare a sweeping claim against this session's read coverage (`sessionReadFileCount`: distinct files read or authored). A sweeping claim backed by fewer than 2 touched files is journaled to the audit trail (`Sweeping claim '<phrase>' with N file(s) read`) and logged at info level.
|
|
202
|
+
- **Observability, not gating**, matching Policy 24. The response is never blocked or rewritten, because a grep-heavy investigation can legitimately support "no other callers" while touching few files. Prior art covers the EPISTEMIC LEDGER "bare assertion" (arXiv 2602.07549) and the agent-control-tower false-completion study. Both find that more context does not fix overclaiming, and that the check belongs in the system, not in self-report.
|
|
183
203
|
|
|
184
204
|
### Tool Description Honesty & Cross-Model Adherence
|
|
185
|
-
- Via the `tool.definition` hook, the guard enriches tool descriptions to keep them honest and proactive across all model families
|
|
205
|
+
- Via the `tool.definition` hook, the guard enriches tool descriptions to keep them honest and proactive across all model families. The families are OpenAI, Gemini, Claude, and open-weights models:
|
|
186
206
|
- `todowrite`: Explains its replacement-list lifecycle, finalization verification gate, and directs models to `guard_next_tasks` for roadmap discovery and `guard_status` for gate checks.
|
|
187
207
|
- Mutating tools (`edit`, `write`, `apply_patch`): States preconditions (active todo, feature branch, prior read).
|
|
188
208
|
- `task`: Guides orchestrator agents to isolate parallel mutations with `guard_worktree_create` and conduct secondary reviews via `guard_review_rubric` and `record_review`.
|
|
189
|
-
- Via the `experimental.chat.system.transform` hook, the guard
|
|
209
|
+
- Via the `experimental.chat.system.transform` hook, the guard adds concise operational guidelines to the system prompt. The guidelines list the available guard tools: `guard_next_tasks`, `guard_status`, `guard_review_rubric`, `guard_worktree_create`, `project_memory_*`, `learning_*`, and `guard_why`. They strongly encourage open models and Gemini or Claude to use workflow discipline features throughout development.
|
|
190
210
|
- Companion tools feature proactive action triggers ("Proactively call...", "Recommended at session start..."), and `guard_status` returns actionable `recommendedActions` directing models to the exact next step.
|
|
191
211
|
|
|
192
212
|
---
|
|
@@ -197,31 +217,32 @@ Besides enforcement hooks, the guard registers companion tools in OpenCode:
|
|
|
197
217
|
|
|
198
218
|
### `guard_worktree_create`
|
|
199
219
|
- Creates an isolated git worktree for concurrent subagent execution: `guard_worktree_create(branch, baseBranch?)`.
|
|
200
|
-
- Branch names are validated with `git check-ref-format --branch
|
|
201
|
-
- Worktrees are stored outside the repository under `~/.local/share/opencode/worktrees/<repo-name
|
|
202
|
-
- If the branch already exists
|
|
220
|
+
- Branch names are validated with `git check-ref-format --branch`. Protected branches are refused. These are the built-in `main` and `master`, plus any configured via `protectedBranches` in `.opencode/workflow-guard.json`.
|
|
221
|
+
- Worktrees are stored outside the repository under `~/.local/share/opencode/worktrees/<repo-name>/`, which `WORKFLOW_GUARD_WORKTREE_DIR` can override. The parent's `node_modules` is symlinked into the new worktree, so tooling works without a fresh install.
|
|
222
|
+
- If the branch already exists as a local branch, the worktree is checked out on it. Otherwise it is created from `baseBranch`, which defaults to `HEAD`.
|
|
203
223
|
- Subject to the same todo gate as other mutations, and successful worktree mutations invalidate stale verification/review evidence.
|
|
204
224
|
|
|
205
225
|
### `guard_worktree_cleanup`
|
|
206
226
|
- 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`.
|
|
207
|
-
- **Ownership validation:**
|
|
208
|
-
- On OpenCode 2, creation goes through the host worktree API when it is available
|
|
209
|
-
- **Idempotent for prunable entries:**
|
|
227
|
+
- **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, which is `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.
|
|
228
|
+
- On OpenCode 2, creation goes through the host worktree API when it is available. The worktree then lands in the operator's configured `worktree.directory` and appears in host inventory. The host's answer is validated independently: an existing directory, a registered worktree of this repository, and not the primary tree. 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.
|
|
229
|
+
- **Idempotent for prunable entries:** A registered worktree whose directory is already gone is deregistered and reported as cleaned. The directory may be gone through manual removal, a crashed add, or external cleanup. Deregistration uses `git worktree prune`, which reports its exit status on failure. This lets stale admin entries that `git worktree list` shows as prunable 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. A temporarily unavailable path's admin entry can therefore be dropped. Worktree contents are never touched, so use `git worktree repair` if a path returns.
|
|
210
230
|
- **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.
|
|
211
231
|
|
|
212
232
|
### Hook-Context Safety
|
|
213
|
-
- All spawned git commands run with a sanitized environment
|
|
233
|
+
- All spawned git commands run with a sanitized environment. Inherited git context variables (`GIT_INDEX_FILE`, `GIT_DIR`, `GIT_WORK_TREE`, and others) 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.
|
|
214
234
|
|
|
215
235
|
### Inspection Tools
|
|
216
236
|
### `guard_status`
|
|
217
|
-
- `guard_status`, `guard_audit`, `guard_why`, `guard_review_rubric`, `record_review`, `guard_review_followups`, and `guard_review_followup_resolve` provide runtime introspection
|
|
218
|
-
- On OpenCode 2 the same report is also reachable as the server-side `/guard-status` command (`ctx.command.transform`)
|
|
237
|
+
- `guard_status`, `guard_audit`, `guard_why`, `guard_review_rubric`, `record_review`, `guard_review_followups`, and `guard_review_followup_resolve` provide runtime introspection. They cover 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, its iteration budget, and the calling-session outcome when applicable. It reports 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. `guard_status` also reports `pluginVersion`, the version of the loaded guard package, so a stale installation is visible.
|
|
238
|
+
- On OpenCode 2 the same report is also reachable as the server-side `/guard-status` command (`ctx.command.transform`). It delivers the JSON as a synthetic message with no model turn. That path needs no separately installed TUI companion.
|
|
219
239
|
- **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.
|
|
220
|
-
- **Review binding is content, not session identity:**
|
|
221
|
-
-
|
|
222
|
-
- **`
|
|
223
|
-
-
|
|
224
|
-
- **
|
|
240
|
+
- **Review binding is content, not session identity:** A recorded approval satisfies the PR preflight when its binding matches the tree the PR would publish. The binding covers the repository, the commit hash, and the tracked-content worktree fingerprint. Any session may record the verdict or create the PR. Reviewers record from subagent sessions, binding the verdict to their parent or their own directory. Sessions can move between directories (`session_move`), and subagents can spawn from other roots. Keying the gate on the PR-creating session's ID would make the documented flow impossible to satisfy from linked worktrees. Freshness is scoped to the reviewed diff, which is tracked content. Untracked scratch-file cleanup and mutations in sibling worktrees cannot invalidate an approval. Any tracked edit or new commit does.
|
|
241
|
+
- **Commit before you verify and review:** Both evidence gates bind to the HEAD commit hash, so a commit after either one invalidates it. Finish every tracked change first, then run verification, then record the review, then open the PR. That order satisfies both gates in one pass. Any other order costs a re-run. A review recorded before its change is committed keeps the same tracked fingerprint. The preflight then names the commit drift specifically and asks for the review again at the current commit.
|
|
242
|
+
- **`record_review` accepts any recorder session:** Reviewer agents are often configured with a read-only toolset, for example glob, grep, read, and webfetch only. Such a reviewer cannot call `record_review` itself. The tool therefore accepts recorders of any session type and binds the verdict to the recording session's resolved directory. The tool result echoes that binding. 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.
|
|
243
|
+
- **`requireSubagentReview` strict recorder mode (opt-in):** Projects that cannot tolerate root-session self-approval can set `requireSubagentReview: true` in `.opencode/workflow-guard.json`. They can also set `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1` in the environment. In that mode `record_review` rejects APPROVALS recorded by the root session itself, which is a session with no parent. The rejection carries an actionable message and a `subagent_recorder_required` audit entry. The verdict is not recorded and the review gate stays unsatisfied. Changes-requested verdicts remain relayable from any session. `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=0` disables the requirement even when project config enables it. The default keeps the documented relay flow for read-only reviewer toolsets.
|
|
244
|
+
- **Durable Lower-Priority Follow-ups:** Accepted review summaries containing a P2 or P3 finding are persisted in the project's local Workflow Guard SQLite database. Open findings are included in compacted agent context. They remain open across sessions until explicitly resolved with `guard_review_followup_resolve`, and they are not silently discarded with the review session.
|
|
245
|
+
- **Privacy-Safe Local Telemetry:** Guard decisions remain in the local audit JSONL. Tool after-hook observations add `callID` and duration records for local diagnosis and performance analysis. The current hook API does not distinguish success from every failure path, so these records do not claim a success outcome. Command and patch bodies are never written to the journal. Only their byte count and SHA-256 fingerprint are retained for correlation.
|
|
225
246
|
|
|
226
247
|
---
|
|
227
248
|
|
|
@@ -234,7 +255,7 @@ The ONLY override is the **`WORKFLOW_GUARD_ALLOW_LIVE=1` environment variable**,
|
|
|
234
255
|
|
|
235
256
|
Verification command selection uses **`WORKFLOW_GUARD_VERIFY`** (empty = auto-detect off, string = your command).
|
|
236
257
|
|
|
237
|
-
There is **deliberately no in-command override**
|
|
258
|
+
There is **deliberately no in-command override** and no `# allow-live` marker. An override the agent can append to its own command, or is told about in an error message, is an override the agent will use.
|
|
238
259
|
|
|
239
260
|
**Workflow & security gates (Policies 1, 2, 3, 6, 7, 8) have no override** - by design.
|
|
240
261
|
|
package/docs/policy-posture.md
CHANGED
|
@@ -43,7 +43,7 @@ therefore looks applied on the other host and enforces nothing.
|
|
|
43
43
|
|
|
44
44
|
Both hosts read only their own key, so carrying both cannot double-apply a rule
|
|
45
45
|
or make them conflict. `--format 2.0` and `--format next` exist for a pinned
|
|
46
|
-
host
|
|
46
|
+
host. `both` is the default, because the silent no-op is the failure this
|
|
47
47
|
surface exists to prevent.
|
|
48
48
|
|
|
49
49
|
Schema acceptance is not runtime acceptance. `@opencode/schema` 2.0.10 admits a
|
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:
|