opencode-workflow-guard 1.14.0 → 1.15.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 +1 -1
- package/docs/agents/lessons.md +12 -0
- package/docs/installation.md +6 -0
- package/docs/policies.md +10 -4
- package/docs/troubleshooting.md +15 -0
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +60 -16
- package/src/lib/guard-dispatcher.ts +95 -24
- package/src/lib/hygiene.ts +66 -0
- package/src/lib/state.ts +20 -11
- package/src/lib/types.ts +29 -1
- package/src/lib/v2-plugin.ts +6 -2
- package/src/lib/verify.ts +33 -13
- package/src/lib/version.ts +21 -0
- package/src/lib/worktree.ts +123 -9
- package/src/policies/boundary.ts +36 -0
- package/src/policies/file-claims.ts +68 -2
- package/src/policies/shell-safety.ts +32 -12
- package/src/policies/stale-write.ts +17 -0
- package/src/workflow-guard.ts +17 -2
- package/test/test.mts +759 -9
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # Also run policy probes with Ope
|
|
|
45
45
|
|
|
46
46
|
The plugin enforces **24 deterministic policy rules** across four main pillars:
|
|
47
47
|
|
|
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 before PR creation by default, and requires PR changelogs / changesets.
|
|
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
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.
|
|
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.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Agent loop lessons
|
|
2
|
+
|
|
3
|
+
Append-only, evidence-backed lessons from base-loop iterations. Each item: id, evidence, one bullet.
|
|
4
|
+
|
|
5
|
+
- **LL-001 — Git revision/pathspec argument order is a silent failure mode; assert on diff contents, not wrapper text.** Evidence: `guard_review_rubric` in `src/lib/custom-tools.ts` passed `--` before the revision (`git diff -- <base>...HEAD`, `git diff -- HEAD~1`), so git parsed the revision as a pathspec: `git diff -- HEAD~1` returned 0 bytes while `git diff --no-ext-diff HEAD~1` returned 68,709 bytes, and a live rubric call returned 1,714 bytes with an empty `diff` block (12 rubric invocations present in the device audit log). No existing test caught it because the rubric tests asserted the static heading `Code Diff Under Review` rather than the diff body; the fix (revision before `--`, plus `--no-ext-diff`) is covered by a regression test that asserts a changed path/content marker, and post-fix the live rubric grew to 31,714 bytes with real diff markers.
|
|
6
|
+
- **LL-002 — Don't tighten a shell-pattern gate by replacing a quote-agnostic raw-string detector with a parsed-invocation-only detector; a "fail-closed" rewrite regressed quote-wrapped forms and unresolvable refs.** Evidence: the branch-base staleness fix (archived, not merged, on `loop/branch-base-start-point`) judged `git switch -c new <base>` by its start point; the dispatcher's fallback was changed to require a delimiter before `git` (`(?:^|[\s;|&/])git\s+`), so `bash -c "git switch -c new"` and `sh -c '...'` stopped matching even though the previous `GIT_BRANCH_CREATE_RE` raw regex matched the quoted text and ran the HEAD check. Separately, an unresolvable/variable start point (`git switch -c new $START`) passed because `rev-list` failed and the check reported not-behind, losing the old HEAD backstop. Four review rounds each surfaced another spelling (attached `-bnew`, `-B`/`-C`/`--force-create`, option reordering, `@`/`refs/heads/` aliases, quote wrappers, `$VAR`, `git -C<dir>`), showing branch-create detection needs a proper shell-token extraction or the original conservative HEAD check — not incremental regex patching. Negative result archived with the 1006-passing suite (the passing tests only prove the covered spellings); lesson: for adversarial shell gates, keep at least the pre-existing conservative detector as a fallback and add fail-closed handling for unresolvable operands rather than removing coverage.
|
|
7
|
+
- **LL-003 — A freshness gate that only learns from reads makes agents re-read their own writes; seed observations from successful mutations, gated on actual change.** Evidence: the stale-write gate (`src/policies/stale-write.ts`) recorded per-session fingerprints only from successful `read`, so a follow-up `edit` to a file the session had just created or written was blocked with "has not been read by this session" — the top guard block on this device (64 of the audit's `stale_write` decisions, including agent-authored temp scripts such as `order-processing/.tmp-rsi-iter7/step1-transform.js`). The fix adds `recordMutationObservation(path, sessionID, beforeDigest)` called from the V1/V2 after-hooks over the existing post-edit snapshots; it seeds only when the pre/post digest differs, so no-op/failed calls seed nothing and external changes after the mutation still block (tests: write-then-edit allowed, edit-chained allowed, external change blocked, no-op `apply_patch` does not seed a later edit). Lesson: when a concurrency/freshness gate is keyed on prior observation, treat the session's own successful mutation as an observation of the resulting bytes (gated on the bytes actually changing), or it will generate pure-friction re-read loops.
|
|
8
|
+
- **LL-004 — A token detector must distinguish command position from argument position; argument-position matches generate false positives that eventually block the guard's own tooling.** Evidence: `checkProcessMonitorCommand` (`src/policies/shell-safety.ts`) matched the monitor word anywhere as a whitespace-delimited token, so an Azure key-vault secret lookup whose `--name` value was that literal word was blocked as an "interactive process monitor" (device audit, `interactive_tty`), and during this loop the stale installed detector blocked a verification command and a commit whose text merely contained the word — proving the defect against real tooling. The fix flags only the executable of a quote-aware segment after wrapper unwrapping (`sudo`/`env`/`timeout`/…), handles `busybox <cmd>`, mirrors the pager recursion for `eval`/`sh -c`, scopes the batch-mode exemption to the monitor's own flags, and documents residual indirection (`watch`/`xargs`/`man`, `find -exec`/`su -c`, shell loop bodies) plus the pre-existing decode-escape quirk. Lesson: when a detector classifies a token as a command, verify it is in command position (executable after wrappers), and test both the false-positive argument case and the detection cases it replaced.
|
|
9
|
+
- **LL-005 — Lifecycle tools must handle the partially-gone state idempotently, not just the happy path.** Evidence: `cleanupGitWorktree` (`src/lib/worktree.ts`) returned "Worktree path '...' does not exist." for a registered worktree whose directory was missing, so the prunable admin entries that `git worktree list` shows after a manual removal, a crashed add, or external cleanup could not be cleared through the tool. The fix validates ownership first and, for a missing registered path, deregisters the stale entry with `git worktree prune` (exit status reported via `describeGitFailure`) and returns success; outside-storage, unregistered, and primary-tree cases stay refused and no raw deletion is used. Tests assert the prunable state exists before cleanup, that cleanup succeeds and deregisters, and that an unregistered missing path is still refused. Lesson: a create/cleanup pair should treat "the artifact's directory/state is partly gone" as a first-class cleanup case (deregister what exists, refuse what is outside the owned scope), or agents accumulate stale metadata they cannot clear.
|
|
10
|
+
- **LL-006 — Close a reviewer-flagged design trade-off with a narrowly scoped opt-in gate on the exact trust boundary, not by reworking the default path.** Evidence: the review-binding redesign deliberately let any session record a verdict (read-only reviewer toolsets cannot call `record_review`), which the loop-3 review flagged as mechanical root-session self-approval (P2: "recommend an optional strictness knob requiring a subagent recorder"). The fix adds `requireSubagentReview` (project config plus `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW` env override, default off): in strict mode `record_review` rejects APPROVALS recorded by a session with no parent (audit reason `subagent_recorder_required`) while changes-requested verdicts stay relayable, so the relay flow that motivated the original trade-off keeps working, and `guard_status` echoes the effective requirement. Tests cover root-approval rejection with audit + gate unsatisfied + PR preflight still blocked, root changes-requested relay, subagent-recorder acceptance end-to-end through PR preflight, env on/off overrides, and the default relay flow. Lesson: when a reviewer flags an accepted trade-off as a risk, prefer an opt-in knob that tightens WHO is trusted for the risky action only (approve ≠ speak), rather than weakening or redesigning the default that other documented flows depend on.
|
|
11
|
+
- **LL-007 — When a lock's validity depends on its owner still being active, derive staleness from an authoritative lifecycle record re-checked at decision time, not from a TTL or a pre-await snapshot.** Evidence: a direct-edit file claim (`src/policies/file-claims.ts`) survived its owner's own recorded idle timestamp on-device (2026-09-22: session `ses_f39203782ffeTHMdcufoS6lfH2` idled at 05:38Z per the server record while holding a claim on `test/test.mts`) and blocked every other session's edits to that path until the owner was deleted via the server API — claims have no expiry, and only the owning after-hook or the owner's `session.idle`/`session.deleted` events release them, and the idle event demonstrably did not arrive. A wall-clock TTL was rejected: it would starve legitimate long calls and leave recent dead-owner claims lingering. The takeover instead uses a server-verifiable signal — a claim created before the owner's last recorded `time.idle` cannot belong to an in-flight tool call (that call finished before the turn ended), so it is provably stale — via the same SDK `session.get` the guard already uses for lineage; missing or failing lookups fail closed, and every claim is re-read after the round-trip so a mid-turn refresh (which also fixed a latent stale-callID release miss) keeps living. Lesson: derive "the owner is gone" from an authoritative record of the owner's lifecycle, re-checked after every await, or the guard trades a permanent deadlock for a guess.
|
|
12
|
+
- **LL-008 — A package that can be resolved from several install surfaces must publish the version that is actually running, and the docs must name the surface that wins.** Evidence: the user updated opencode-workflow-guard via `npm i -g` twice (1.14.0, then 1.14.1) and restarted, yet the OpenCode server log kept showing `loading plugin ... entrypoint=file:///var/home/hunter/node_modules/.pnpm/opencode-workflow-guard@1.13.5/...` — a pnpm-managed home-level dependency (`"opencode-workflow-guard": "^1.13.5"` in `~/package.json`) shadowing the global npm copy through Node's parent-directory resolution from `~/.config/opencode`, while OpenCode's own plugin-update check failed with only a server-log `NpmInstallFailedError`. Two releases of "updates" never reached the running enforcement layer, and the empty rubric diff (LL-001's bug) persisted until the home dependency was updated with `pnpm add`; a TUI-only restart also did not reload the background service. The fix adds `loadedPluginVersion()` (`src/lib/version.ts`, read from the adjacent package.json, fail-soft), surfaced as `guard_status`'s `pluginVersion` and in the startup app log, plus installation/troubleshooting documentation of the shadowing behavior, the verification path, and `opencode service restart` as the reload step. Lesson: when "updated" and "running" can silently diverge across install surfaces, the running module must publish its own version — or every resolution mismatch gets misdiagnosed as something else.
|
package/docs/installation.md
CHANGED
|
@@ -40,6 +40,8 @@ If you only want the server guard without the TUI companion, add the package to
|
|
|
40
40
|
|
|
41
41
|
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
42
|
|
|
43
|
+
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
|
+
|
|
43
45
|
---
|
|
44
46
|
|
|
45
47
|
### 2. Manual Local-File Installation (Server Plugin)
|
|
@@ -127,6 +129,8 @@ OpenCode's model/provider request timeout is separate from Workflow Guard. In Op
|
|
|
127
129
|
|
|
128
130
|
Workflow Guard enforces secondary review approval (`requireReview`) by default during PR creation (`gh pr create` and `az repos pr create`). Before a pull request can be opened, an agent must invoke a secondary review subagent and record passing review approval using `record_review` evaluated against `guard_review_rubric`.
|
|
129
131
|
|
|
132
|
+
The recorded verdict is bound to the reviewed repository content (commit hash + tracked-content worktree fingerprint of the directory it was recorded against), and the PR preflight accepts any approval whose binding matches the tree the PR would publish. A review recorded by a subagent rooted in a linked git worktree therefore satisfies a `pr create` run from that worktree, even from a different session than the one that spawned the reviewer. Review freshness is scoped to tracked content: deleting an untracked scratch file does not invalidate an approval, but any tracked edit or new commit does. Because reviewer agents are frequently configured with read-only toolsets that exclude `record_review`, the tool accepts recorders of any session type and binds the verdict to the recording session's directory; the audit trail records who recorded it and where it is bound.
|
|
133
|
+
|
|
130
134
|
To opt out of the PR secondary review gate for a project, set `requireReview: false` in `.opencode/workflow-guard.json`:
|
|
131
135
|
|
|
132
136
|
```json
|
|
@@ -137,6 +141,8 @@ To opt out of the PR secondary review gate for a project, set `requireReview: fa
|
|
|
137
141
|
|
|
138
142
|
Or set `WORKFLOW_GUARD_REQUIRE_REVIEW=0` in your environment. Conversely, `WORKFLOW_GUARD_REQUIRE_REVIEW=1` enforces the review gate regardless of repository configuration.
|
|
139
143
|
|
|
144
|
+
For stricter environments, set `requireSubagentReview: true` in `.opencode/workflow-guard.json` to reject approvals recorded by the root session itself: a passing verdict must then be recorded from a subagent session (one with a parent session), never by the root session approving its own work. Changes-requested findings remain relayable from any session. `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1` enables the same requirement for any project; `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=0` disables it even when project config enables it.
|
|
145
|
+
|
|
140
146
|
---
|
|
141
147
|
|
|
142
148
|
## Recommended Companion Configuration
|
package/docs/policies.md
CHANGED
|
@@ -17,8 +17,8 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
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.
|
|
19
19
|
- **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.
|
|
20
|
-
- **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. Shell writers are not covered, and isolated worktrees remain preferred for substantial parallel mutations.
|
|
21
|
-
- **Stale-Write Protection:** A successful `read` records a same-session fingerprint for an existing regular file using its canonical path, filesystem identity, size, nanosecond mtime, and SHA-256 content digest. 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/subagent sessions and are cleared on session idle/deletion. `apply_patch` and shell writers are deliberately outside this bounded mechanism; the pre-execution comparison also cannot make external changes between the check and OpenCode's write atomic.
|
|
20
|
+
- **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, so 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 preferred for substantial parallel mutations.
|
|
21
|
+
- **Stale-Write Protection:** A successful `read` records a same-session fingerprint for an existing regular file using its canonical path, filesystem identity, size, nanosecond mtime, and SHA-256 content digest; a successful `edit`/`write` also records the resulting bytes (only when the file actually changed), since the session authored them, 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/subagent sessions and are cleared on session idle/deletion. `apply_patch` and shell writers are 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.
|
|
22
22
|
|
|
23
23
|
### 2. No Pushes to Protected Branches
|
|
24
24
|
- `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`).
|
|
@@ -73,6 +73,8 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
73
73
|
|
|
74
74
|
### 8. Workspace Boundary Guard
|
|
75
75
|
- File modification tools (`edit`, `write`, `apply_patch`) and recognized shell mutations (redirection `>`, `>>`, `&>`, `>&`, `tee`, `sed -i`, `cp`/`mv`/`ln`, `touch`, `mkdir`, `rm`, `truncate`, `dd of=`, `git apply`/`git am`) are validated to ensure detected targets cannot escape the current workspace root via `../` traversal, symlinks, absolute paths, or 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.
|
|
76
|
+
- **Unresolvable variable cause attribution:** a target containing an unresolvable `$VARIABLE` reference (for example `mkdir -p $D/sub` or `> $CACHE/out.txt`) is indeterminate without executing shell semantics, so it is treated as outside the workspace — and the block message says so ("contains an unresolvable variable reference … use a literal workspace-relative path") instead of 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.
|
|
77
|
+
- **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 (an untracked scratch file inside the workspace, for example `.tmp-notes.md`) instead of reading as a guard bug; the enforcement decision is unchanged.
|
|
76
78
|
- `tee` flag parsing generically skips option flags (`-a`, `--append`, `-ai`, `--`) and validates all specified target files against the boundary.
|
|
77
79
|
- Embedded non-shell comparisons are not mutations: a bare `>` whose target is purely numeric (`WHERE count > 5`, awk `$3 > 100`) and `>=`/`==` operators are treated as comparison operands, not redirects. Real redirects are unaffected (`> src/a.ts`, `>>`, `&>`, `>&`, fd targets like `/dev/null` still follow their existing rules).
|
|
78
80
|
- `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.
|
|
@@ -149,7 +151,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
149
151
|
- Only **README.md** (root or package level) and files under **`docs/`** count as documentation updates; arbitrary markdown (e.g. `.changeset/*.md` fragments or `CHANGELOG.md`) does not satisfy this gate.
|
|
150
152
|
|
|
151
153
|
### 22. Non-Interactive Shell & TTY Hang Guard
|
|
152
|
-
- Blocks commands that spawn interactive text editors (`nano`, `vim`, `emacs`), interactive pagers (`less`, `more`) including through common shell wrappers, interactive monitors (`top`, `htop`), interactive rebase/patch prompts (`git rebase -i`, `git add -p`), `sudo`, or package managers missing non-interactive confirmation flags (`npm init` without `-y`, `apt-get` without `-y`). Pager names appearing only
|
|
154
|
+
- Blocks commands that spawn interactive text editors (`nano`, `vim`, `emacs`), interactive pagers (`less`, `more`) including through common shell wrappers, interactive monitors (`top`, `htop`), interactive rebase/patch prompts (`git rebase -i`, `git add -p`), `sudo`, or package managers missing non-interactive confirmation flags (`npm init` without `-y`, `apt-get` without `-y`). Pager and monitor names appearing only as command arguments (for example `az keyvault secret show --name top` or `echo top`) are not treated as pager/monitor execution; monitors are only flagged in command position (after wrappers such as `sudo`/`env`/`timeout`, through `eval` and `sh -c`, and via `busybox`), and `top` batch mode stays allowed. Indirection this does not model (`watch`/`xargs`/`man`, `find -exec`/`su -c`, and shell loop bodies such as `while true; do top; done`) is a known limitation.
|
|
153
155
|
- Prevents subshell agents from hanging indefinitely waiting for user stdin in background execution.
|
|
154
156
|
- **Desktop Notifications:** Emits native OS notifications (Linux `notify-send`, macOS `osascript`) when a policy blocks a command or when verification runs finish, keeping developers informed when their terminal is backgrounded. Configurable via `WORKFLOW_GUARD_NOTIFY=0`.
|
|
155
157
|
|
|
@@ -189,14 +191,18 @@ Besides enforcement hooks, the guard registers companion tools in OpenCode:
|
|
|
189
191
|
### `guard_worktree_cleanup`
|
|
190
192
|
- 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`.
|
|
191
193
|
- **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.
|
|
194
|
+
- **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).
|
|
192
195
|
- **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.
|
|
193
196
|
|
|
194
197
|
### Hook-Context Safety
|
|
195
198
|
- 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.
|
|
196
199
|
|
|
197
200
|
### Inspection Tools
|
|
198
|
-
- `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.
|
|
201
|
+
- `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.
|
|
199
202
|
- **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.
|
|
203
|
+
- **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.
|
|
204
|
+
- **`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.
|
|
205
|
+
- **`requireSubagentReview` strict recorder mode (opt-in):** projects that cannot tolerate root-session self-approval can set `requireSubagentReview: true` in `.opencode/workflow-guard.json`, or `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1` in the environment. In that mode `record_review` rejects APPROVALS recorded by the root session itself (a session with no parent) with 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, and `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.
|
|
200
206
|
- **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 and remain open across sessions until explicitly resolved with `guard_review_followup_resolve`; they are not silently discarded with the review session.
|
|
201
207
|
- **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.
|
|
202
208
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -100,3 +100,18 @@ After the command reports `Detected server + tui targets`, confirm it reports re
|
|
|
100
100
|
1. V2 message parts record the tool name in the `tool` field; V1 used `name`. Code reading `part.name` silently gets `undefined` on V2.
|
|
101
101
|
2. V2's builtin tool registry contains only `read`, `glob`, `grep`, `edit`, `write`, `patch`, `shell`, `webfetch`, `websearch`, `question`, `skill`, `subagent`, and `execute`. There is no builtin `todowrite` (the V1 name), and the V1 `task` subagent tool was renamed to `subagent`. `todowrite`/`bash` tool parts observed in V2 sessions come from ACP agents (for example pi) that supply their own tools; those tools are not in the server-side registry and cannot be reached by `ctx.tool.transform`.
|
|
102
102
|
- **Solution:** Read the tool name as `part.tool ?? part.name` and treat tool-part names as agent-reported, not as the builtin registry. `ToolEditor.update` ignores missing IDs, so enriching a tool that does not exist is a harmless no-op.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### 12. Review recorded with `record_review`, but `pr create` is still blocked
|
|
107
|
+
|
|
108
|
+
- **Symptoms:** `record_review` returned "Review recorded as APPROVED" and `guard_status` showed a fresh `lastReview`, yet `gh pr create` / `az repos pr create` was blocked with "Passing secondary review approval is required"; or re-calling `record_review` returned success but `guard_status` still showed no fresh review.
|
|
109
|
+
- **Root Cause (fixed by content binding):** The PR preflight previously also required the review's target session to equal the PR-creating session's ID. `record_review` records from a secondary session (binding the verdict to its parent or its own directory), sessions can move between directories mid-flight (`session_move`), and subagents spawn from other roots — so a review recorded against a linked git worktree could never satisfy a PR created from another session there. Mutation invalidation additionally erased the whole review record on any mutation, including unrelated untracked-file cleanup.
|
|
110
|
+
- **Binding rule:** a recorded approval satisfies the preflight when its binding matches the tree the PR publishes — same repository, same commit hash, same tracked-content worktree fingerprint. Review freshness is scoped to tracked content, so deleting an untracked scratch file does not invalidate it; a tracked edit or new commit does. When the tracked fingerprint is unchanged but HEAD moved (for example a review recorded with changes already staged, then committed), the preflight names the commit drift specifically.
|
|
111
|
+
- **Diagnosis:** `guard_audit` now records the binding key (workspace, commit hash, fingerprint prefix, recorder session and `recorderRole`) on every `record_review.verdict` entry, and a `pr-preflight.review-binding` entry when a PR preflight accepts a review. If a verdict cannot bind at all (directory is not a git repository), `record_review` rejects loudly with the reason instead of returning a success string.
|
|
112
|
+
- **Strict recorder mode rejects the approval:** projects with `requireSubagentReview: true` (or `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1`) reject `record_review` approval verdicts recorded by the root session with "this project requires approvals to be recorded from a subagent session". Record the verdict from a subagent session instead (one with a parent session), or disable the requirement in project config; the `guard_audit` entry carries reason `subagent_recorder_required`. Changes-requested verdicts remain relayable from any session. The knob is enforced when verdicts are recorded: an approval recorded by the root session before the requirement was enabled — including durable review-cache evidence recovered after a restart — still satisfies the PR preflight until its content binding goes stale, so re-run the secondary review after adopting the knob to re-bind it.
|
|
113
|
+
- **Worktree creation failure leaving a branch behind:** `guard_worktree_create` failures previously surfaced only git's stderr (often checkout progress) and could leave a half-created branch. Failures are now reported with git's exit status, the created branch is rolled back (pre-existing branches are preserved), and stale worktree admin entries are pruned only for the newly-created path. A live registered worktree whose sanitized name collides with the requested branch is left fully intact — rollback never removes pre-existing worktrees. Large checkouts that exceed the 15s timeout can set `WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS`.
|
|
114
|
+
- **Edit blocked by "claimed by another active session" though no other session is working:** a missed `session.idle` release can leave a finished session's direct-edit claim behind, and claims have no expiry, so every other session is blocked from that path until the owning session is deleted or the service restarts. On a real (non-simulated) call the takeover now resolves it: a claim created before its owner's last recorded idle timestamp is provably stale and is released, with `guard_audit` recording a `file-claims` entry with reason `stale_claim_takeover`. Sessions still mid-turn (no idle timestamp) keep their claim, and the takeover fails closed without an SDK session lookup or when the lookup fails. `guard_why` remains read-only and still reports the conflict.
|
|
115
|
+
- **You updated the plugin but the old version still loads:** a configured bare package name is resolved through Node's parent-directory lookup from OpenCode's configuration location, so a copy in any parent `node_modules` (a pnpm-managed home directory or a manual install) shadows OpenCode's own plugin cache and a global `npm install -g`; OpenCode's own update check can fail with only a server-log record (`NpmInstallFailedError`). Verify what actually loaded with `guard_status` (the `pluginVersion` field), the startup app-log line (`Workflow Guard v<version> plugin initialized ...`), or the server log's `loading plugin ... entrypoint=file:///...` record. Then update the resolved copy in place (for a pnpm-managed home directory: `cd ~ && pnpm add opencode-workflow-guard@<version>`) and run `opencode service restart` — the plugin loads in the background service, so restarting only the TUI does not reload it.
|
|
116
|
+
- **A benign-looking command with a shell variable is blocked as "outside the workspace":** a mutation or redirect target containing an unresolvable `$VARIABLE` reference (for example `mkdir -p $D/sub`, `cp a.txt "$D/b.txt"`, `> $CACHE/out.txt`) is indeterminate without executing shell semantics, and the boundary fails closed on indeterminate destinations — the block message names this cause ("contains an unresolvable variable reference … use a literal workspace-relative path"). Deterministically expandable forms (`~`, `$HOME`) and literal paths inside the workspace are unaffected, and reads (including cache-path reads) are not gated. Replace the variable with a literal workspace-relative path in the command, or assign it inside a script file within the workspace that the boundary has already validated. Resolving the variable would require executing shell semantics, which the boundary deliberately does not do (see the shell-expansion hardening notes in the roadmap).
|
|
117
|
+
- **A scratch write to `/tmp/opencode` is blocked:** the environment designates that directory as agent scratch space, but the workspace boundary is workspace-absolute and does not cover it, so writes there stay blocked — the block message names the directory and the remedy. Write scratch as an untracked file inside the workspace instead (for example `.tmp-notes.md`); the boundary has no override for it, and the enforcement decision is unchanged by this attribution.
|
package/package.json
CHANGED
package/src/lib/custom-tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import type { LearningEvidenceKind } from "./types.ts";
|
|
3
|
+
import type { AuditEntry, LearningEvidenceKind } from "./types.ts";
|
|
4
4
|
import type { ProjectMemoryStore } from "./project-memory.ts";
|
|
5
5
|
import {
|
|
6
6
|
exportProjectKnowledge,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
isRecoveryCheckpointsEnabled,
|
|
28
28
|
isRalphModeEnabled,
|
|
29
29
|
isReviewRequired,
|
|
30
|
+
isSubagentReviewRequired,
|
|
30
31
|
recordMutation,
|
|
31
32
|
recordReviewResult,
|
|
32
33
|
resolveEffectiveWorkspace,
|
|
@@ -34,13 +35,15 @@ import {
|
|
|
34
35
|
} from "./state.ts";
|
|
35
36
|
import { getRalphOutcome } from "../policies/continuation.ts";
|
|
36
37
|
import { loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
37
|
-
import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint } from "./verify.ts";
|
|
38
|
+
import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
|
|
38
39
|
import { buildReviewRubric } from "./review.ts";
|
|
39
40
|
import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
|
|
40
41
|
import { branchHasDocumentationChange } from "../policies/docs.ts";
|
|
41
42
|
import { restoreRecoveryCheckpoint } from "./checkpoint.ts";
|
|
42
43
|
import { audit, getRecentAuditEntries } from "./audit.ts";
|
|
43
44
|
import { guardToolCallImpl } from "./guard-dispatcher.ts";
|
|
45
|
+
import { loadedPluginVersion } from "./version.ts";
|
|
46
|
+
import { gitHygieneSnapshot } from "./hygiene.ts";
|
|
44
47
|
import { discoverPlanningSources } from "../policies/planning.ts";
|
|
45
48
|
import { secretIn } from "../policies/secrets.ts";
|
|
46
49
|
import { currentGitBranch, onProtectedBranch } from "../policies/git.ts";
|
|
@@ -205,7 +208,7 @@ export function createCustomTools(options: {
|
|
|
205
208
|
}),
|
|
206
209
|
} : {}),
|
|
207
210
|
guard_status: tool({
|
|
208
|
-
description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. Proactively call at session start and before completing tasks or creating PRs.",
|
|
211
|
+
description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. The result includes pluginVersion, the version of the loaded guard package, so a stale installation is visible, and a read-only gitHygiene snapshot (local branches already merged into the mainline, prunable worktree admin entries, base-behind distance). Proactively call at session start and before completing tasks or creating PRs.",
|
|
209
212
|
args: {
|
|
210
213
|
directory: tool.schema.string().optional().describe("Target repository directory to inspect (defaults to active session directory or workspace root)"),
|
|
211
214
|
},
|
|
@@ -218,7 +221,12 @@ export function createCustomTools(options: {
|
|
|
218
221
|
const verifyCommand = detectVerifyCommand(root);
|
|
219
222
|
const verifyFresh = Boolean(lastV && verifyEvidence && lastV.passed && lastV.command === verifyCommand && isEvidenceFresh(verifyEvidence, subject, lastMut));
|
|
220
223
|
const reviewRequired = isReviewRequired(root);
|
|
221
|
-
|
|
224
|
+
// Review freshness is content-scoped, matching the PR preflight:
|
|
225
|
+
// the approval covers the tracked tree it reviewed, so it stays
|
|
226
|
+
// fresh until that content changes. Untracked scratch cleanup
|
|
227
|
+
// (or mutations in a sibling worktree) must not read as stale,
|
|
228
|
+
// while any tracked edit or new commit does.
|
|
229
|
+
const reviewFresh = Boolean(lastR && reviewEvidenceRecord && lastR.passed && isEvidenceFresh(reviewEvidenceRecord, { ...subject, worktreeFingerprint: getTrackedWorktreeFingerprint(root), sessionID: lastR.targetSessionID }, 0));
|
|
222
230
|
const documentationRequired = isDocumentationRequired(root);
|
|
223
231
|
const outstandingRequirements = [
|
|
224
232
|
...(verifyCommand && !verifyFresh ? ["verification"] : []),
|
|
@@ -244,13 +252,13 @@ export function createCustomTools(options: {
|
|
|
244
252
|
recommendedActions.push("All operational requirements are satisfied for task finalization and PR creation.");
|
|
245
253
|
}
|
|
246
254
|
const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
|
|
247
|
-
return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, outstandingRequirements, recommendedActions, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) } }, null, 2);
|
|
255
|
+
return JSON.stringify({ pluginVersion: loadedPluginVersion() ?? null, workspaceRoot: root, branch, onProtectedBranch: isProtected, outstandingRequirements, recommendedActions, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, requireSubagentReview: isSubagentReviewRequired(root), recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) }, gitHygiene: gitHygieneSnapshot(root) ?? null }, null, 2);
|
|
248
256
|
},
|
|
249
257
|
}),
|
|
250
258
|
guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard. Use to diagnose policy decisions, blocked commands, or outcome telemetry.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
|
|
251
259
|
guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(effectiveRoot, client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
|
|
252
260
|
record_review: tool({
|
|
253
|
-
description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric (test integrity, task completeness, cleanliness, security, platform).",
|
|
261
|
+
description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric (test integrity, task completeness, cleanliness, security, platform). Projects with requireSubagentReview enabled only accept APPROVALS recorded from a subagent session (one with a parent session), never from the root session itself.",
|
|
254
262
|
args: {
|
|
255
263
|
reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"),
|
|
256
264
|
summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"),
|
|
@@ -258,18 +266,52 @@ export function createCustomTools(options: {
|
|
|
258
266
|
directory: tool.schema.string().optional().describe("Target repository directory being reviewed (defaults to parent session's repository or active directory)"),
|
|
259
267
|
},
|
|
260
268
|
execute: async (args, toolContext) => {
|
|
261
|
-
const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict } });
|
|
262
|
-
const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
|
|
263
|
-
if (!parentSessionID) { auditVerdict("rejected", "missing_parent_session"); return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session."; }
|
|
269
|
+
const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string, binding?: NonNullable<AuditEntry["evidence"]>["binding"]) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict, ...(binding ? { binding } : {}) } });
|
|
264
270
|
const axesRefs = ["test integrity", "task completeness", "cleanliness", "security", "platform"];
|
|
265
271
|
const referenced = axesRefs.filter((axis) => args.summary.toLowerCase().includes(axis));
|
|
266
272
|
if (referenced.length < 3) { auditVerdict("rejected", "insufficient_rubric_axes"); return `[workflow-guard] Review rejected: summary must reference the review axes (found ${referenced.length}/5). Call guard_review_rubric to get the rubric, evaluate each axis, and include findings per axis in the summary.`; }
|
|
267
273
|
if (args.passed && /(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) { auditVerdict("rejected", "approval_contains_blocker"); return "[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. Resolve all P0/P1 issues before approving or record review with passed=false."; }
|
|
274
|
+
// Any session may record a verdict so that reviewer agents whose
|
|
275
|
+
// toolset lacks record_review can have their verdict relayed.
|
|
276
|
+
// The verdict is bound to the reviewed DIRECTORY content (commit
|
|
277
|
+
// hash + tracked worktree fingerprint); the recorder's lineage is
|
|
278
|
+
// captured in the audit trail. The review-before-PR requirement
|
|
279
|
+
// itself is unchanged.
|
|
268
280
|
const reviewWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
281
|
+
const bindingFingerprint = getTrackedWorktreeFingerprint(reviewWorkspace);
|
|
282
|
+
if (!bindingFingerprint) {
|
|
283
|
+
auditVerdict("rejected", "review_workspace_unbindingable", { workspace: reviewWorkspace, recorderSessionID: toolContext.sessionID });
|
|
284
|
+
return `[workflow-guard] Review rejected: the verdict cannot be bound because no tracked-content fingerprint is available for '${reviewWorkspace}' (not a git repository, or git is unavailable). The verdict was NOT recorded. Call record_review with 'directory' set to the reviewed repository worktree, or run it from a session rooted there.`;
|
|
285
|
+
}
|
|
286
|
+
const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
|
|
287
|
+
if (args.passed && !parentSessionID && isSubagentReviewRequired(reviewWorkspace)) {
|
|
288
|
+
// Opt-in strict mode: a root session approving its own work
|
|
289
|
+
// is the self-approval loophole; require the recorder to be
|
|
290
|
+
// a subagent session (lineage, not agent role, so relay
|
|
291
|
+
// subagents still work). Content binding is unchanged —
|
|
292
|
+
// this only tightens WHO may record an approval.
|
|
293
|
+
auditVerdict("rejected", "subagent_recorder_required", { workspace: reviewWorkspace, recorderSessionID: toolContext.sessionID });
|
|
294
|
+
return `[workflow-guard] Review rejected: this project requires approvals to be recorded from a subagent session (requireSubagentReview), and '${toolContext.sessionID}' has no parent session. Spawn a secondary reviewer subagent with the task tool and record the verdict from that session, or set requireSubagentReview=false / WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=0.`;
|
|
295
|
+
}
|
|
296
|
+
const bindingCommitHash = getCurrentGitCommitHash(reviewWorkspace);
|
|
297
|
+
const binding = {
|
|
298
|
+
workspace: reviewWorkspace,
|
|
299
|
+
commitHash: bindingCommitHash,
|
|
300
|
+
worktreeFingerprint: bindingFingerprint.slice(0, 16),
|
|
301
|
+
recorderSessionID: toolContext.sessionID,
|
|
302
|
+
recorderRole: parentSessionID ? ("subagent" as const) : ("root" as const),
|
|
303
|
+
targetSessionID: parentSessionID ?? toolContext.sessionID,
|
|
304
|
+
};
|
|
305
|
+
recordReviewResult(args.reviewer, args.summary, args.passed, parentSessionID ?? toolContext.sessionID, reviewWorkspace);
|
|
306
|
+
if (followupStore && !secretIn(args.summary)) for (const finding of extractReviewFollowups(args.summary)) recordReviewFollowup(followupStore, { severity: finding.severity, summary: finding.summary, reviewer: args.reviewer, sessionID: toolContext.sessionID, commit: bindingCommitHash });
|
|
307
|
+
auditVerdict(args.passed ? "approved" : "changes_requested", args.passed ? "approved" : "changes_requested", binding);
|
|
308
|
+
const recorderNote = parentSessionID
|
|
309
|
+
? `Recorded by secondary session ${toolContext.sessionID} for parent ${parentSessionID}.`
|
|
310
|
+
: `Recorded by root session ${toolContext.sessionID}; prefer relaying through an independent secondary reviewer subagent when one is available.`;
|
|
311
|
+
const boundTo = `Bound to ${reviewWorkspace} (commit ${bindingCommitHash?.slice(0, 12) ?? "unversioned"}, tracked fingerprint ${bindingFingerprint.slice(0, 12)}).`;
|
|
312
|
+
return args.passed
|
|
313
|
+
? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}. ${boundTo} ${recorderNote}`
|
|
314
|
+
: `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}. ${boundTo} ${recorderNote}`;
|
|
273
315
|
},
|
|
274
316
|
}),
|
|
275
317
|
guard_review_followups: tool({ description: "List durable local P2/P3 review follow-ups (technical debt) for this project. Proactively check during planning or before final verification to address open findings.", args: {}, execute: async () => JSON.stringify(followupStore ? listReviewFollowups(followupStore) : [], null, 2) }),
|
|
@@ -277,7 +319,7 @@ export function createCustomTools(options: {
|
|
|
277
319
|
guard_review_rubric: tool({
|
|
278
320
|
description: "Get the secondary-review rubric for the current branch diff across 5 core review axes (test integrity, task completeness, cleanliness, security, platform). The orchestrator should call this before finalizing work or creating a PR, then spawn a reviewer subagent with the rubric as the prompt, which records its verdict via record_review.",
|
|
279
321
|
args: {
|
|
280
|
-
base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main)"),
|
|
322
|
+
base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main, master)"),
|
|
281
323
|
directory: tool.schema.string().optional().describe("Target repository directory (defaults to active session directory or workspace root)"),
|
|
282
324
|
},
|
|
283
325
|
execute: async (args, toolContext) => {
|
|
@@ -285,8 +327,10 @@ export function createCustomTools(options: {
|
|
|
285
327
|
const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
|
|
286
328
|
const rubricWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, directory: args?.directory, fallback: toolContext?.worktree || toolContext?.directory || effectiveRoot });
|
|
287
329
|
let diffText = "";
|
|
288
|
-
|
|
289
|
-
|
|
330
|
+
// Revision before the trailing `--` (LL-001) so the base ref is
|
|
331
|
+
// always parsed as a revision, never as a pathspec.
|
|
332
|
+
for (const base of bases) { const res = spawnSync("git", ["diff", "--no-ext-diff", `${base}...HEAD`, "--"], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); if (res.status === 0 && res.stdout.trim()) { diffText = res.stdout; break; } }
|
|
333
|
+
if (!diffText) { const last = spawnSync("git", ["diff", "--no-ext-diff", "HEAD~1", "--"], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); diffText = last.status === 0 ? last.stdout : "(no diff available)"; }
|
|
290
334
|
return buildReviewRubric(diffText);
|
|
291
335
|
},
|
|
292
336
|
}),
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { audit, loadVerifyCache } from "./audit.ts";
|
|
4
4
|
import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
|
|
5
5
|
import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
getLastReviewResultForWorkspace,
|
|
8
8
|
getLiveControlPlaneRoots,
|
|
9
9
|
getSdkClient,
|
|
10
10
|
getWorkspaceRoot,
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
recordMutation,
|
|
16
16
|
recordVerifyResult,
|
|
17
17
|
sessionMutationTimestamps,
|
|
18
|
-
sessionReviews,
|
|
19
18
|
sessionVerifyResults,
|
|
20
19
|
} from "./state.ts";
|
|
21
20
|
import {
|
|
@@ -34,15 +33,16 @@ import {
|
|
|
34
33
|
getCurrentGitCommitHash,
|
|
35
34
|
getGitStatusSummary,
|
|
36
35
|
getGitWorktreeFingerprint,
|
|
36
|
+
getTrackedWorktreeFingerprint,
|
|
37
37
|
runVerify,
|
|
38
38
|
resolveVerifyTimeoutMs,
|
|
39
39
|
snipVerifyOutput,
|
|
40
40
|
} from "./verify.ts";
|
|
41
|
-
import { detectShellMutation, extractPatchPaths, guardShellMutation, isPathOutsideWorkspace } from "../policies/boundary.ts";
|
|
41
|
+
import { detectShellMutation, extractPatchPaths, guardShellMutation, hasUnresolvableVariable, isEnvironmentScratchTarget, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR } from "../policies/boundary.ts";
|
|
42
42
|
import { branchHasChangelogChange, checkLockfileSync, hasPrCreateInvocation, prBodyHasLiteralLineBreakEscapes, prBodyIncludesChangelog } from "../policies/changelog.ts";
|
|
43
43
|
import { extractEditContent, liveMutationIn } from "../policies/destructive.ts";
|
|
44
44
|
import { branchHasDocumentationChange } from "../policies/docs.ts";
|
|
45
|
-
import { claimFiles, fileClaimConflictReason } from "../policies/file-claims.ts";
|
|
45
|
+
import { claimFiles, fileClaimConflictReason, releaseStaleFileClaims } from "../policies/file-claims.ts";
|
|
46
46
|
import {
|
|
47
47
|
GIT_BRANCH_CREATE_RE,
|
|
48
48
|
GIT_WRITE_RE,
|
|
@@ -223,13 +223,29 @@ export async function guardToolCallImpl(
|
|
|
223
223
|
}
|
|
224
224
|
if (isPathOutsideWorkspace(patchPath, currentRoot)) {
|
|
225
225
|
logPolicyBlock(`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`);
|
|
226
|
-
return block(
|
|
226
|
+
return block(
|
|
227
|
+
"boundary",
|
|
228
|
+
"workspace_escape",
|
|
229
|
+
hasUnresolvableVariable(patchPath)
|
|
230
|
+
? `Blocked: patch targets file '${patchPath}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${currentRoot}). Use a literal workspace-relative path.`
|
|
231
|
+
: isEnvironmentScratchTarget(patchPath, currentRoot)
|
|
232
|
+
? `Blocked: patch targets file '${patchPath}' under ${OPENCODE_SCRATCH_DIR}, the environment scratch directory the workspace boundary does not cover; write scratch as an untracked file inside the workspace root (${currentRoot}) instead (for example .tmp-notes.md).`
|
|
233
|
+
: `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`,
|
|
234
|
+
);
|
|
227
235
|
}
|
|
228
236
|
}
|
|
229
237
|
}
|
|
230
238
|
if (target && isPathOutsideWorkspace(target, currentRoot)) {
|
|
231
239
|
logPolicyBlock(`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`);
|
|
232
|
-
return block(
|
|
240
|
+
return block(
|
|
241
|
+
"boundary",
|
|
242
|
+
"workspace_escape",
|
|
243
|
+
hasUnresolvableVariable(target)
|
|
244
|
+
? `Blocked: file path '${target}' contains an unresolvable variable reference, so the boundary treats it as outside the workspace root (${currentRoot}). Use a literal workspace-relative path; all changes must stay within the workspace.`
|
|
245
|
+
: isEnvironmentScratchTarget(target, currentRoot)
|
|
246
|
+
? `Blocked: file path '${target}' is under ${OPENCODE_SCRATCH_DIR}, the environment scratch directory the workspace boundary does not cover; write scratch as an untracked file inside the workspace root (${currentRoot}) instead (for example .tmp-notes.md).`
|
|
247
|
+
: `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`,
|
|
248
|
+
);
|
|
233
249
|
}
|
|
234
250
|
for (const content of extractEditContent(input)) {
|
|
235
251
|
const secret = secretIn(content);
|
|
@@ -278,6 +294,12 @@ export async function guardToolCallImpl(
|
|
|
278
294
|
}
|
|
279
295
|
}
|
|
280
296
|
const targets = editTargets(input, currentRoot);
|
|
297
|
+
if (!context.simulate) {
|
|
298
|
+
// A missed idle release can leave a finished session's claims
|
|
299
|
+
// behind; release provably stale ones before claiming. The
|
|
300
|
+
// simulate path (guard_why) stays read-only.
|
|
301
|
+
await releaseStaleFileClaims(targets, context.sessionID);
|
|
302
|
+
}
|
|
281
303
|
const claimReason = context.simulate
|
|
282
304
|
? fileClaimConflictReason(targets, context.sessionID)
|
|
283
305
|
: context.callID ? claimFiles(targets, context.sessionID, context.callID) : undefined;
|
|
@@ -327,7 +349,13 @@ export async function guardToolCallImpl(
|
|
|
327
349
|
const normalizedInvocation = `git ${invocation.rest}`;
|
|
328
350
|
if (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))) {
|
|
329
351
|
logPolicyBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
|
|
330
|
-
return block(
|
|
352
|
+
return block(
|
|
353
|
+
"boundary",
|
|
354
|
+
"workspace_escape",
|
|
355
|
+
hasUnresolvableVariable(invocation.repoDir)
|
|
356
|
+
? `Blocked: git command targets repository '${invocation.repoDir}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${currentRoot}). Use a literal workspace-relative path.`
|
|
357
|
+
: `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`,
|
|
358
|
+
);
|
|
331
359
|
}
|
|
332
360
|
}
|
|
333
361
|
for (const invocation of gitInvocations) {
|
|
@@ -359,7 +387,13 @@ export async function guardToolCallImpl(
|
|
|
359
387
|
const outsidePath = outsideWritePathInPayload(payload, currentRoot);
|
|
360
388
|
if (outsidePath) {
|
|
361
389
|
logPolicyBlock(`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`);
|
|
362
|
-
return block(
|
|
390
|
+
return block(
|
|
391
|
+
"boundary",
|
|
392
|
+
"workspace_escape",
|
|
393
|
+
hasUnresolvableVariable(outsidePath)
|
|
394
|
+
? `Blocked: inline interpreter script targets file '${outsidePath}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${currentRoot}). Use a literal workspace-relative path.`
|
|
395
|
+
: `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`,
|
|
396
|
+
);
|
|
363
397
|
}
|
|
364
398
|
const writePaths = writePathsInPayload(payload);
|
|
365
399
|
if (writePaths.length > 0) {
|
|
@@ -440,16 +474,45 @@ export async function guardToolCallImpl(
|
|
|
440
474
|
if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
|
|
441
475
|
}
|
|
442
476
|
if (isReviewRequired(prRoot)) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
477
|
+
// Review evidence binds to the reviewed repository CONTENT: a
|
|
478
|
+
// passing approval satisfies this PR when its recorded subject
|
|
479
|
+
// (same repository, same commit hash, same tracked-content
|
|
480
|
+
// worktree fingerprint) matches the tree this PR would publish.
|
|
481
|
+
// It deliberately does NOT bind to the PR-creating session's ID:
|
|
482
|
+
// record_review records from a secondary session (binding the
|
|
483
|
+
// verdict to the recorder's parent or its own directory), and
|
|
484
|
+
// sessions can move between directories (session_move) and
|
|
485
|
+
// spawn subagents from many roots, so the previous
|
|
486
|
+
// session-identity requirement made the documented
|
|
487
|
+
// review->PR flow impossible to satisfy from linked worktrees.
|
|
488
|
+
// Content binding is the security property; the requirement
|
|
489
|
+
// itself is not removed or weakened.
|
|
490
|
+
const prCommitHash = getCurrentGitCommitHash(prRoot);
|
|
491
|
+
const prFingerprint = getTrackedWorktreeFingerprint(prRoot);
|
|
492
|
+
const review = getLastReviewResultForWorkspace(prRoot);
|
|
493
|
+
const matchedReview = review?.passed === true && isEvidenceFresh(reviewEvidence(review), { workspace: projectRootKey(prRoot), commitHash: prCommitHash, worktreeFingerprint: prFingerprint, sessionID: review.targetSessionID }, 0) ? review : undefined;
|
|
494
|
+
if (matchedReview) {
|
|
495
|
+
if (!context?.simulate) {
|
|
496
|
+
audit({
|
|
497
|
+
ts: new Date().toISOString(),
|
|
498
|
+
sessionID: context?.sessionID,
|
|
499
|
+
tool: "pr-preflight.review-binding",
|
|
500
|
+
decision: "allow",
|
|
501
|
+
phase: "event",
|
|
502
|
+
reason: "review_evidence_matched",
|
|
503
|
+
evidence: {
|
|
504
|
+
evidenceId: reviewEvidence(matchedReview).id,
|
|
505
|
+
reviewer: matchedReview.reviewer,
|
|
506
|
+
reviewTimestamp: matchedReview.timestamp,
|
|
507
|
+
binding: {
|
|
508
|
+
workspace: projectRootKey(prRoot),
|
|
509
|
+
commitHash: prCommitHash,
|
|
510
|
+
worktreeFingerprint: prFingerprint?.slice(0, 16),
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
});
|
|
448
514
|
}
|
|
449
|
-
}
|
|
450
|
-
const prFingerprint = getGitWorktreeFingerprint(prRoot);
|
|
451
|
-
const reviewMatchesContext = review?.passed === true && isEvidenceFresh(reviewEvidence(review), { workspace: projectRootKey(prRoot), commitHash: getCurrentGitCommitHash(prRoot), worktreeFingerprint: prFingerprint, sessionID: review.targetSessionID }, 0) && (!review.targetSessionID || review.targetSessionID === context?.sessionID);
|
|
452
|
-
if (!reviewMatchesContext) {
|
|
515
|
+
} else {
|
|
453
516
|
// When an approval exists but cannot be matched to this
|
|
454
517
|
// worktree, the message must name the actual cause instead
|
|
455
518
|
// of the generic "review required" (which sends the agent
|
|
@@ -457,13 +520,21 @@ export async function guardToolCallImpl(
|
|
|
457
520
|
// Cause attribution only makes sense for approvals of THIS
|
|
458
521
|
// repository; a review of another repo is not evidence here.
|
|
459
522
|
const reviewIsForThisRepo = typeof review?.workspace === "string" && (projectRootKey(review.workspace) === projectRootKey(prRoot) || isSameGitRepo(review.workspace, prRoot));
|
|
460
|
-
const
|
|
461
|
-
if (
|
|
523
|
+
const sameRepoReview = review && reviewIsForThisRepo ? review : undefined;
|
|
524
|
+
if (sameRepoReview && !sameRepoReview.passed) {
|
|
525
|
+
preflightFailures.push(`The most recent secondary review for this repository (by '${sameRepoReview.reviewer}') requested changes; address the findings and obtain a fresh passing approval with record_review.`);
|
|
526
|
+
} else if (sameRepoReview && prFingerprint === undefined) {
|
|
462
527
|
preflightFailures.push("Worktree fingerprint could not be computed (git unavailable or worktree unreadable), so review evidence cannot be bound to this worktree; resolve worktree readability and re-run the secondary review.");
|
|
463
|
-
} else if (
|
|
464
|
-
preflightFailures.push(`The recorded review approval from '${
|
|
465
|
-
} else if (
|
|
466
|
-
preflightFailures.push(`The recorded review approval from '${
|
|
528
|
+
} else if (sameRepoReview && !sameRepoReview.worktreeFingerprint) {
|
|
529
|
+
preflightFailures.push(`The recorded review approval from '${sameRepoReview.reviewer}' has no worktree fingerprint binding (recorded against an unreadable worktree or an older guard version); re-run the secondary review and record it with record_review.`);
|
|
530
|
+
} else if (sameRepoReview && sameRepoReview.worktreeFingerprint !== prFingerprint) {
|
|
531
|
+
preflightFailures.push(`The recorded review approval from '${sameRepoReview.reviewer}' does not match the current worktree contents (tracked content changed after review); re-run the secondary review and record it with record_review.`);
|
|
532
|
+
} else if (sameRepoReview && sameRepoReview.commitHash && prCommitHash && sameRepoReview.commitHash !== prCommitHash) {
|
|
533
|
+
// Tracked fingerprint can be unchanged while HEAD moves
|
|
534
|
+
// (for example a review recorded with changes already
|
|
535
|
+
// staged, then committed): name the commit drift instead
|
|
536
|
+
// of the generic review-required message.
|
|
537
|
+
preflightFailures.push(`The recorded review approval from '${sameRepoReview.reviewer}' is bound to commit ${sameRepoReview.commitHash.slice(0, 12)} but this worktree is at commit ${prCommitHash.slice(0, 12)}; re-run the secondary review at the current commit and record it with record_review.`);
|
|
467
538
|
} else {
|
|
468
539
|
preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
|
|
469
540
|
}
|