opencode-workflow-guard 1.14.1 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,3 +7,6 @@ Append-only, evidence-backed lessons from base-loop iterations. Each item: id, e
7
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
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
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.
@@ -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)
@@ -139,6 +141,8 @@ To opt out of the PR secondary review gate for a project, set `requireReview: fa
139
141
 
140
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.
141
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
+
142
146
  ---
143
147
 
144
148
  ## Recommended Companion Configuration
package/docs/policies.md CHANGED
@@ -17,7 +17,7 @@ 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.
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
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
@@ -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.
@@ -141,7 +143,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
141
143
 
142
144
  ### 20. Merged Branch & Base Freshness Guard
143
145
  - Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
144
- - Blocks creating fresh feature branches when the local base branch is behind the remote, prompting the agent to pull latest changes first.
146
+ - 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, and start points that cannot be resolved fail closed. Block reasons name the branch actually checked and remedies that work under the guard (rebase or ff-merge the branch, or branch from the remote default directly).
145
147
  - 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.
146
148
 
147
149
  ### 21. Documentation Review & Synchronization Guard
@@ -196,10 +198,11 @@ Besides enforcement hooks, the guard registers companion tools in OpenCode:
196
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.
197
199
 
198
200
  ### Inspection Tools
199
- - `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.
200
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.
201
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.
202
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.
203
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.
204
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.
205
208
 
@@ -109,4 +109,9 @@ After the command reports `Detected server + tui targets`, confirm it reports re
109
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
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
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.
112
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.14.1",
3
+ "version": "1.15.1",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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,
@@ -41,6 +42,8 @@ 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
  },
@@ -249,13 +252,13 @@ export function createCustomTools(options: {
249
252
  recommendedActions.push("All operational requirements are satisfied for task finalization and PR creation.");
250
253
  }
251
254
  const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
252
- 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);
253
256
  },
254
257
  }),
255
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) }),
256
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) }),
257
260
  record_review: tool({
258
- 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.",
259
262
  args: {
260
263
  reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"),
261
264
  summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"),
@@ -281,6 +284,15 @@ export function createCustomTools(options: {
281
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.`;
282
285
  }
283
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
+ }
284
296
  const bindingCommitHash = getCurrentGitCommitHash(reviewWorkspace);
285
297
  const binding = {
286
298
  workspace: reviewWorkspace,
@@ -307,7 +319,7 @@ export function createCustomTools(options: {
307
319
  guard_review_rubric: tool({
308
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.",
309
321
  args: {
310
- 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)"),
311
323
  directory: tool.schema.string().optional().describe("Target repository directory (defaults to active session directory or workspace root)"),
312
324
  },
313
325
  execute: async (args, toolContext) => {
@@ -315,8 +327,10 @@ export function createCustomTools(options: {
315
327
  const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
316
328
  const rubricWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, directory: args?.directory, fallback: toolContext?.worktree || toolContext?.directory || effectiveRoot });
317
329
  let diffText = "";
318
- 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; } }
319
- 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)"; }
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)"; }
320
334
  return buildReviewRubric(diffText);
321
335
  },
322
336
  }),
@@ -38,16 +38,17 @@ import {
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,
49
49
  PUSH_TO_MAIN_RE,
50
50
  branchGuardReason,
51
+ branchCreationStartPoint,
51
52
  checkBranchBaseIsUpToDate,
52
53
  checkMergeConflicts,
53
54
  currentGitBranch,
@@ -223,13 +224,29 @@ export async function guardToolCallImpl(
223
224
  }
224
225
  if (isPathOutsideWorkspace(patchPath, currentRoot)) {
225
226
  logPolicyBlock(`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`);
226
- return block("boundary", "workspace_escape", `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`);
227
+ return block(
228
+ "boundary",
229
+ "workspace_escape",
230
+ hasUnresolvableVariable(patchPath)
231
+ ? `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.`
232
+ : isEnvironmentScratchTarget(patchPath, currentRoot)
233
+ ? `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).`
234
+ : `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`,
235
+ );
227
236
  }
228
237
  }
229
238
  }
230
239
  if (target && isPathOutsideWorkspace(target, currentRoot)) {
231
240
  logPolicyBlock(`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`);
232
- return block("boundary", "workspace_escape", `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`);
241
+ return block(
242
+ "boundary",
243
+ "workspace_escape",
244
+ hasUnresolvableVariable(target)
245
+ ? `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.`
246
+ : isEnvironmentScratchTarget(target, currentRoot)
247
+ ? `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).`
248
+ : `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`,
249
+ );
233
250
  }
234
251
  for (const content of extractEditContent(input)) {
235
252
  const secret = secretIn(content);
@@ -278,6 +295,12 @@ export async function guardToolCallImpl(
278
295
  }
279
296
  }
280
297
  const targets = editTargets(input, currentRoot);
298
+ if (!context.simulate) {
299
+ // A missed idle release can leave a finished session's claims
300
+ // behind; release provably stale ones before claiming. The
301
+ // simulate path (guard_why) stays read-only.
302
+ await releaseStaleFileClaims(targets, context.sessionID);
303
+ }
281
304
  const claimReason = context.simulate
282
305
  ? fileClaimConflictReason(targets, context.sessionID)
283
306
  : context.callID ? claimFiles(targets, context.sessionID, context.callID) : undefined;
@@ -327,7 +350,13 @@ export async function guardToolCallImpl(
327
350
  const normalizedInvocation = `git ${invocation.rest}`;
328
351
  if (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))) {
329
352
  logPolicyBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
330
- return block("boundary", "workspace_escape", `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`);
353
+ return block(
354
+ "boundary",
355
+ "workspace_escape",
356
+ hasUnresolvableVariable(invocation.repoDir)
357
+ ? `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.`
358
+ : `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`,
359
+ );
331
360
  }
332
361
  }
333
362
  for (const invocation of gitInvocations) {
@@ -338,9 +367,27 @@ export async function guardToolCallImpl(
338
367
  }
339
368
  }
340
369
  if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
341
- const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
370
+ // A branch created from an explicit start point that already contains
371
+ // the remote default is fresh by construction; only creations without
372
+ // one (or with an unclassifiable shape — fail closed) inherit the
373
+ // current branch's staleness.
374
+ const creationStarts: Array<string | undefined> = [];
375
+ let classifiable = true;
376
+ for (const invocation of gitInvocations) {
377
+ if (!GIT_BRANCH_CREATE_RE.test(`git ${invocation.rest}`)) continue;
378
+ const parsed = branchCreationStartPoint(invocation.rest);
379
+ if (parsed === undefined) {
380
+ classifiable = false;
381
+ break;
382
+ }
383
+ creationStarts.push(parsed.start);
384
+ }
385
+ const behindCheck = checkBranchBaseIsUpToDate(
386
+ effectiveRoot,
387
+ classifiable && creationStarts.length > 0 ? creationStarts : undefined,
388
+ );
342
389
  if (behindCheck.isBehind) {
343
- logPolicyBlock(`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`);
390
+ logPolicyBlock(`[workflow-guard] blocked branch creation: ${behindCheck.reason ?? "base is behind remote"}`);
344
391
  return block("git", "branch_base_behind", `Blocked: ${behindCheck.reason}`);
345
392
  }
346
393
  }
@@ -359,7 +406,13 @@ export async function guardToolCallImpl(
359
406
  const outsidePath = outsideWritePathInPayload(payload, currentRoot);
360
407
  if (outsidePath) {
361
408
  logPolicyBlock(`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`);
362
- return block("boundary", "workspace_escape", `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`);
409
+ return block(
410
+ "boundary",
411
+ "workspace_escape",
412
+ hasUnresolvableVariable(outsidePath)
413
+ ? `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.`
414
+ : `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`,
415
+ );
363
416
  }
364
417
  const writePaths = writePathsInPayload(payload);
365
418
  if (writePaths.length > 0) {
@@ -0,0 +1,66 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { checkBranchBaseIsUpToDate, currentGitBranch } from "../policies/git.ts";
3
+ import { getCleanGitEnv } from "./worktree.ts";
4
+
5
+ const MERGED_BASES = ["origin/main", "origin/master", "main", "master"];
6
+ const MAX_LISTED_BRANCHES = 10;
7
+
8
+ export interface GitHygieneSnapshot {
9
+ /** Local branches fully merged into the mainline, capped for bounded output. */
10
+ mergedLocalBranches: string[];
11
+ /** True count of merged local branches (the list above may be capped). */
12
+ mergedLocalBranchCount: number;
13
+ /** Worktree admin entries whose directory is missing (clearable via cleanupGitWorktree or git worktree prune). */
14
+ prunableWorktreeCount: number;
15
+ /** Commits the current branch's base line is ahead by, when a remote base exists. */
16
+ baseBehind?: number;
17
+ baseRef?: string;
18
+ }
19
+
20
+ function gitOut(args: string[], root: string): string | undefined {
21
+ const res = spawnSync("git", args, {
22
+ cwd: root,
23
+ encoding: "utf8",
24
+ timeout: 10_000,
25
+ env: getCleanGitEnv(),
26
+ });
27
+ if (res.status !== 0) return undefined;
28
+ return res.stdout;
29
+ }
30
+
31
+ /**
32
+ * Read-only accumulation snapshot for guard_status: local branches already
33
+ * merged into the mainline, prunable worktree admin entries, and how far the
34
+ * current branch's base line is ahead. Hygiene itself stays manual — the
35
+ * guard reports, it never prunes or deletes branches on its own.
36
+ */
37
+ export function gitHygieneSnapshot(root: string): GitHygieneSnapshot | undefined {
38
+ const current = currentGitBranch(root);
39
+ if (current === undefined) {
40
+ // Not a git repository (or git unavailable): no hygiene snapshot.
41
+ return undefined;
42
+ }
43
+ const snapshot: GitHygieneSnapshot = { mergedLocalBranches: [], mergedLocalBranchCount: 0, prunableWorktreeCount: 0 };
44
+ for (const base of MERGED_BASES) {
45
+ const out = gitOut(["branch", "--merged", base, "--format=%(refname:short)"], root);
46
+ if (out === undefined) continue;
47
+ const branches = out
48
+ .split("\n")
49
+ .map((line) => line.trim())
50
+ .filter((name) => name.length > 0 && name !== base && name !== current)
51
+ .sort();
52
+ snapshot.mergedLocalBranchCount = branches.length;
53
+ snapshot.mergedLocalBranches = branches.slice(0, MAX_LISTED_BRANCHES);
54
+ break;
55
+ }
56
+ const worktreeOut = gitOut(["worktree", "list", "--porcelain"], root);
57
+ if (worktreeOut !== undefined) {
58
+ snapshot.prunableWorktreeCount = worktreeOut.split("\n").filter((line) => line.startsWith("prunable")).length;
59
+ }
60
+ const behind = checkBranchBaseIsUpToDate(root);
61
+ if (behind.isBehind) {
62
+ snapshot.baseBehind = behind.count;
63
+ snapshot.baseRef = behind.baseRef;
64
+ }
65
+ return snapshot;
66
+ }
package/src/lib/state.ts CHANGED
@@ -330,6 +330,13 @@ export function isReviewRequired(root: string): boolean {
330
330
  return cfg.requireReview !== false;
331
331
  }
332
332
 
333
+ export function isSubagentReviewRequired(root: string): boolean {
334
+ const env = process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW?.toLowerCase();
335
+ if (env === "0" || env === "false" || env === "off") return false;
336
+ if (env === "1" || env === "true" || env === "on") return true;
337
+ return getProjectConfig(root).requireSubagentReview === true;
338
+ }
339
+
333
340
  export function isDocumentationRequired(root: string): boolean {
334
341
  if (process.env.WORKFLOW_GUARD_REQUIRE_DOCS === "1") return true;
335
342
  const cfg = getProjectConfig(root);
package/src/lib/types.ts CHANGED
@@ -9,7 +9,14 @@ export interface TodoSdkClient {
9
9
  };
10
10
  session?: {
11
11
  todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
12
- get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown; title?: unknown } }>;
12
+ /**
13
+ * The returned record's `data.time.idle` (ms epoch, server-recorded
14
+ * when a session's turn ends; absent/zero while mid-turn) is the
15
+ * stale-claim staleness boundary in src/policies/file-claims.ts. The
16
+ * field is assumption-typed because the minimal SDK surface here does
17
+ * not declare it; a missing, zero, or non-numeric value fails closed.
18
+ */
19
+ get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown; title?: unknown; time?: { idle?: unknown } } }>;
13
20
  promptAsync?: (opts: {
14
21
  path: { id: string };
15
22
  body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }> };
@@ -34,6 +41,14 @@ export interface ProjectConfig {
34
41
  timeoutMs?: number;
35
42
  }>;
36
43
  requireReview?: boolean;
44
+ /**
45
+ * Opt-in strict mode for secondary reviews: when true, record_review
46
+ * rejects approvals recorded by the root session itself, so a passing
47
+ * verdict must come from a subagent session with a parent. Unset or false
48
+ * keeps the documented relay flow where any session may record a verdict
49
+ * that is then bound to the reviewed content.
50
+ */
51
+ requireSubagentReview?: boolean;
37
52
  requireDocumentation?: boolean;
38
53
  /**
39
54
  * Absolute paths of the LIVE control plane (runtime `~/.config/opencode`,
@@ -122,6 +137,8 @@ export interface AuditEntry {
122
137
  evidence?: {
123
138
  mutation?: boolean;
124
139
  targetPath?: string;
140
+ /** Owner of a released stale file claim (stale_claim_takeover audits). */
141
+ claimOwnerSessionID?: string;
125
142
  verification?: {
126
143
  command: string;
127
144
  passed: boolean;
@@ -59,6 +59,7 @@ import { beginReadObservation, recordMutationObservation, recordSuccessfulRead,
59
59
  import { editTargets, runPostEditValidators, snapshotFile } from "../policies/post-edit-validation.ts";
60
60
  import { audit, summarizeInput } from "./audit.ts";
61
61
  import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
62
+ import { loadedPluginVersion } from "./version.ts";
62
63
  import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
63
64
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
64
65
  import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
@@ -175,7 +176,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
175
176
  }
176
177
 
177
178
  try {
178
- console.log(`[workflow-guard] info: Workflow Guard plugin initialized for ${effectiveRoot}; provenance is plugin-local in V2`);
179
+ console.log(`[workflow-guard] info: Workflow Guard v${loadedPluginVersion() ?? "unknown"} plugin initialized for ${effectiveRoot}; provenance is plugin-local in V2`);
179
180
  } catch {}
180
181
 
181
182
  const toolLifecycle = new ToolInvocationLifecycle();
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * The version of the package this module was loaded from, read from the
6
+ * adjacent package.json (the package ships TypeScript source, so the manifest
7
+ * always sits two levels above src/). Fail-soft: an unreadable or malformed
8
+ * manifest (for example a bundled or relocated copy without package.json)
9
+ * yields undefined, and callers surface that absence rather than guessing.
10
+ * This exists because OpenCode resolves configured bare package names through
11
+ * Node's parent-directory lookup from its configuration location, so the
12
+ * loaded copy can silently differ from a globally npm-installed one.
13
+ */
14
+ export function loadedPluginVersion(moduleUrl: string = import.meta.url): string | undefined {
15
+ try {
16
+ const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../../package.json", moduleUrl)), "utf8")) as { version?: unknown };
17
+ return typeof manifest.version === "string" && manifest.version ? manifest.version : undefined;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
@@ -53,6 +53,33 @@ export function expandShellTargetPath(targetPath: string): string | null {
53
53
  return out;
54
54
  }
55
55
 
56
+ /**
57
+ * A mutation/redirect target whose expansion is indeterminate (it contains an
58
+ * unresolvable `$VARIABLE` reference): the boundary treats such destinations
59
+ * as outside the workspace, and block messages attribute that cause instead
60
+ * of the generic escape message.
61
+ */
62
+ export function hasUnresolvableVariable(targetPath: string): boolean {
63
+ return expandShellTargetPath(targetPath) === null;
64
+ }
65
+
66
+ /**
67
+ * The OpenCode harness designates this directory as the agents' scratch
68
+ * space ("pre-created and approved for external access"), but the workspace
69
+ * boundary is workspace-absolute and does not cover it. Targets under it get
70
+ * a cause-attributed block message so agents stop treating the block as a
71
+ * guard bug and write scratch as untracked files inside the workspace
72
+ * instead. Message-only: the boundary decision itself is unchanged.
73
+ */
74
+ export const OPENCODE_SCRATCH_DIR = "/tmp/opencode";
75
+
76
+ export function isEnvironmentScratchTarget(targetPath: string, root: string): boolean {
77
+ const expanded = expandShellTargetPath(targetPath);
78
+ if (expanded === null) return false;
79
+ const resolved = resolve(root, expanded);
80
+ return resolved === OPENCODE_SCRATCH_DIR || resolved.startsWith(OPENCODE_SCRATCH_DIR + "/");
81
+ }
82
+
56
83
  export function isPathOutsideWorkspace(targetPath: string, root: string): boolean {
57
84
  if (!targetPath) return false;
58
85
  const expanded = expandShellTargetPath(targetPath);
@@ -372,6 +399,9 @@ export async function guardShellMutation(
372
399
  return PROTECTED_PATH_REASON;
373
400
  }
374
401
  if (isPathOutsideWorkspace(source, root)) {
402
+ if (hasUnresolvableVariable(source)) {
403
+ return `Blocked: mv source '${source}' contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
404
+ }
375
405
  return `Blocked: mv would remove source '${source}' from outside the workspace root (${root}). File mutations must stay within the workspace.`;
376
406
  }
377
407
  }
@@ -403,6 +433,12 @@ export async function guardShellMutation(
403
433
  // The workspace boundary has no override: a write outside the
404
434
  // workspace is out of bounds even with WORKFLOW_GUARD_ALLOW_LIVE
405
435
  // (that override covers live-system commands, not the boundary).
436
+ if (hasUnresolvableVariable(target)) {
437
+ return `Blocked: shell mutation '${mutation.what}' targets '${target}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
438
+ }
439
+ if (isEnvironmentScratchTarget(target, root)) {
440
+ return `Blocked: shell mutation '${mutation.what}' targets '${target}' under ${OPENCODE_SCRATCH_DIR}, the environment scratch directory the workspace boundary does not cover; write scratch as an untracked file inside the workspace root (${root}) instead (for example .tmp-notes.md).`;
441
+ }
406
442
  return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${root}). All changes must stay within the workspace.`;
407
443
  }
408
444
  if (onProtectedBranch(root)) {