opencode-workflow-guard 1.14.1 → 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/docs/agents/lessons.md +3 -0
- package/docs/installation.md +4 -0
- package/docs/policies.md +5 -2
- package/docs/troubleshooting.md +5 -0
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +20 -6
- package/src/lib/guard-dispatcher.ts +40 -6
- package/src/lib/hygiene.ts +66 -0
- package/src/lib/state.ts +7 -0
- package/src/lib/types.ts +18 -1
- package/src/lib/v2-plugin.ts +2 -1
- package/src/lib/version.ts +21 -0
- package/src/policies/boundary.ts +36 -0
- package/src/policies/file-claims.ts +68 -2
- package/src/workflow-guard.ts +6 -1
- package/test/test.mts +411 -0
package/docs/agents/lessons.md
CHANGED
|
@@ -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.
|
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)
|
|
@@ -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.
|
|
@@ -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
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
package/src/lib/custom-tools.ts
CHANGED
|
@@ -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
|
-
|
|
319
|
-
|
|
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,11 +38,11 @@ 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,
|
|
@@ -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) {
|
|
@@ -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
|
-
|
|
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;
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/policies/boundary.ts
CHANGED
|
@@ -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)) {
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { readlinkSync, realpathSync } from "node:fs";
|
|
2
2
|
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { audit } from "../lib/audit.ts";
|
|
4
|
+
import { getSdkClient } from "../lib/state.ts";
|
|
3
5
|
|
|
4
6
|
interface FileClaim {
|
|
5
7
|
sessionID: string;
|
|
6
8
|
callID: string;
|
|
9
|
+
/** Creation timestamp; a claim older than its owner's last idle is stale. */
|
|
10
|
+
ts: number;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
const claims = new Map<string, FileClaim>();
|
|
@@ -36,8 +40,14 @@ export function claimFiles(paths: string[], sessionID: string, callID: string):
|
|
|
36
40
|
const conflict = fileClaimConflictReason(canonical, sessionID);
|
|
37
41
|
if (conflict) return conflict;
|
|
38
42
|
for (const path of canonical) {
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const existing = claims.get(path);
|
|
44
|
+
if (!existing) {
|
|
45
|
+
claims.set(path, { sessionID, callID, ts: Date.now() });
|
|
46
|
+
} else if (existing.sessionID === sessionID) {
|
|
47
|
+
// The same session re-claiming the path refreshes the claim so the
|
|
48
|
+
// timestamp (and callID) reflect the in-flight call: stale-takeover
|
|
49
|
+
// never fires for it, and the after-hook release matches.
|
|
50
|
+
claims.set(path, { sessionID, callID, ts: Date.now() });
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
return undefined;
|
|
@@ -54,6 +64,62 @@ export function fileClaimConflictReason(paths: string[], sessionID: string): str
|
|
|
54
64
|
return undefined;
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A claim is normally released by its owning tool call (after-hook) or by the
|
|
69
|
+
* owner's session.idle/session.deleted events. When an idle release is missed,
|
|
70
|
+
* the claim would otherwise block every other session forever. A claim created
|
|
71
|
+
* BEFORE its owner's last recorded idle timestamp cannot belong to an
|
|
72
|
+
* in-flight tool call — that call finished before the turn ended — so it is
|
|
73
|
+
* provably stale and is released with an audited takeover. Without an SDK
|
|
74
|
+
* client, or when the owner record cannot be read, nothing is released:
|
|
75
|
+
* fail-closed keeps live two-session protection intact. Clock skew between
|
|
76
|
+
* this process and the server is not expected for a local (same-host)
|
|
77
|
+
* service: negative skew fails closed; forward skew could make fresh claims
|
|
78
|
+
* look stale and is accepted for that deployment. Concurrent claimants can
|
|
79
|
+
* emit duplicate takeover audits (append-only, harmless). Release is
|
|
80
|
+
* per-claim: only claims whose ts is provably stale are dropped, re-read
|
|
81
|
+
* AFTER the lookup round-trip so an owner that re-claimed (and refreshed) a
|
|
82
|
+
* path meanwhile keeps its live claim.
|
|
83
|
+
*/
|
|
84
|
+
export async function releaseStaleFileClaims(paths: string[], claimantSessionID: string): Promise<void> {
|
|
85
|
+
const canonical = [...new Set(paths.map((path) => canonicalPath(path)))];
|
|
86
|
+
const staleOwners = new Map<string, string>();
|
|
87
|
+
for (const path of canonical) {
|
|
88
|
+
const existing = claims.get(path);
|
|
89
|
+
if (existing && existing.sessionID !== claimantSessionID && !staleOwners.has(existing.sessionID)) {
|
|
90
|
+
staleOwners.set(existing.sessionID, path);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (staleOwners.size === 0) return;
|
|
94
|
+
const client = getSdkClient();
|
|
95
|
+
const session = client?.session;
|
|
96
|
+
const get = session?.get;
|
|
97
|
+
if (!session || typeof get !== "function") return;
|
|
98
|
+
for (const [ownerSessionID] of staleOwners) {
|
|
99
|
+
try {
|
|
100
|
+
const result = await get.call(session, { path: { id: ownerSessionID } });
|
|
101
|
+
const idle = (result as { data?: { time?: { idle?: unknown } } } | undefined)?.data?.time?.idle;
|
|
102
|
+
if (typeof idle !== "number" || idle <= 0) continue;
|
|
103
|
+
// Release every claim of this owner that is provably stale. Each
|
|
104
|
+
// claim is re-read after the lookup round-trip so an owner that
|
|
105
|
+
// re-claimed (and refreshed) a path meanwhile keeps its live claim.
|
|
106
|
+
for (const [claimPath, claim] of [...claims]) {
|
|
107
|
+
if (claim.sessionID !== ownerSessionID || claim.ts >= idle) continue;
|
|
108
|
+
claims.delete(claimPath);
|
|
109
|
+
audit({
|
|
110
|
+
ts: new Date().toISOString(),
|
|
111
|
+
sessionID: claimantSessionID,
|
|
112
|
+
tool: "file-claims",
|
|
113
|
+
decision: "allow",
|
|
114
|
+
phase: "event",
|
|
115
|
+
reason: "stale_claim_takeover",
|
|
116
|
+
evidence: { mutation: false, targetPath: claimPath, claimOwnerSessionID: ownerSessionID },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
57
123
|
export function releaseFileClaims(sessionID: string, callID?: string): void {
|
|
58
124
|
for (const [path, claim] of claims) {
|
|
59
125
|
if (claim.sessionID === sessionID && (callID === undefined || claim.callID === callID)) {
|
package/src/workflow-guard.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { WorkflowGuardV2 } from "./lib/v2-plugin.ts";
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { resolve } from "node:path";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
+
import { loadedPluginVersion } from "./lib/version.ts";
|
|
17
18
|
import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
|
|
18
19
|
import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
|
|
19
20
|
import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
@@ -70,6 +71,7 @@ import {
|
|
|
70
71
|
getOperationProfile,
|
|
71
72
|
isRecoveryCheckpointsEnabled,
|
|
72
73
|
isReviewRequired,
|
|
74
|
+
isSubagentReviewRequired,
|
|
73
75
|
isDocumentationRequired,
|
|
74
76
|
getSubagentMutationBudget,
|
|
75
77
|
isLearningEnabled,
|
|
@@ -103,6 +105,7 @@ export {
|
|
|
103
105
|
getOperationProfile,
|
|
104
106
|
isRecoveryCheckpointsEnabled,
|
|
105
107
|
isReviewRequired,
|
|
108
|
+
isSubagentReviewRequired,
|
|
106
109
|
isDocumentationRequired,
|
|
107
110
|
getSubagentMutationBudget,
|
|
108
111
|
isLearningEnabled,
|
|
@@ -189,6 +192,8 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
|
|
|
189
192
|
: "managed config location is unknown on this platform; plugin provenance is not verified by the OpenCode V1 API";
|
|
190
193
|
}
|
|
191
194
|
|
|
195
|
+
export { loadedPluginVersion } from "./lib/version.ts";
|
|
196
|
+
|
|
192
197
|
// ── Verification engine ──────────────────────────────────────────────────────
|
|
193
198
|
import {
|
|
194
199
|
detectVerifyCommand,
|
|
@@ -431,7 +436,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
431
436
|
body: {
|
|
432
437
|
service: "workflow-guard",
|
|
433
438
|
level: "info",
|
|
434
|
-
message: `Workflow Guard plugin initialized for ${effectiveRoot}; ${managedConfigDiagnostic()}`,
|
|
439
|
+
message: `Workflow Guard v${loadedPluginVersion() ?? "unknown"} plugin initialized for ${effectiveRoot}; ${managedConfigDiagnostic()}`,
|
|
435
440
|
},
|
|
436
441
|
});
|
|
437
442
|
} catch {}
|
package/test/test.mts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync, existsSy
|
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
5
6
|
import { DatabaseSync } from "node:sqlite";
|
|
6
7
|
import type { PluginModule } from "@opencode-ai/plugin";
|
|
7
8
|
import { getRalphMaxIterations, runWithRuntimeState } from "../src/lib/state.ts";
|
|
@@ -74,6 +75,7 @@ import {
|
|
|
74
75
|
checkLockfileSync,
|
|
75
76
|
branchHasDocumentationChange,
|
|
76
77
|
isDocumentationRequired,
|
|
78
|
+
loadedPluginVersion,
|
|
77
79
|
isReviewRequired,
|
|
78
80
|
getOperationProfile,
|
|
79
81
|
isRecoveryCheckpointsEnabled,
|
|
@@ -678,6 +680,35 @@ check("block write to /etc/passwd", blocked(await call("write", { filePath: "/et
|
|
|
678
680
|
check("block write to ~ path", blocked(await call("write", { filePath: "~/.bashrc", content: "x" }, { sessionID: "s-active" })));
|
|
679
681
|
check("block write to $HOME path", blocked(await call("write", { filePath: "$HOME/.profile", content: "x" }, { sessionID: "s-active" })));
|
|
680
682
|
check("block write to unresolvable $VAR path", blocked(await call("write", { filePath: "$UNKNOWN_DIR/file.txt", content: "x" }, { sessionID: "s-active" })));
|
|
683
|
+
// Cause attribution: indeterminate $VARIABLE destinations get a message that
|
|
684
|
+
// names the unresolvable reference (the on-device over-block class), while
|
|
685
|
+
// deterministically-outside paths keep the generic escape message.
|
|
686
|
+
const unresolvableWrite = await call("write", { filePath: "$UNKNOWN_DIR/file.txt", content: "x" }, { sessionID: "s-active" });
|
|
687
|
+
check("unresolvable $VAR write names the variable cause", blocked(unresolvableWrite) && String(unresolvableWrite).includes("unresolvable variable reference") && String(unresolvableWrite).includes("$UNKNOWN_DIR"));
|
|
688
|
+
const literalEscapeWrite = await call("write", { filePath: "/etc/passwd", content: "x" }, { sessionID: "s-active" });
|
|
689
|
+
check("literal outside path keeps the generic escape message", blocked(literalEscapeWrite) && String(literalEscapeWrite).includes("escapes workspace root") && !String(literalEscapeWrite).includes("unresolvable variable reference"));
|
|
690
|
+
const homeWrite = await call("write", { filePath: "$HOME/.profile", content: "x" }, { sessionID: "s-active" });
|
|
691
|
+
check("deterministically-expanded $HOME path keeps the generic escape message", blocked(homeWrite) && String(homeWrite).includes("escapes workspace root") && !String(homeWrite).includes("unresolvable variable reference"));
|
|
692
|
+
const unresolvableShell = await call("bash", { command: "mkdir -p $D/sub" }, { sessionID: "s-active" });
|
|
693
|
+
check("shell mutation with unresolvable $VAR names the variable cause", blocked(unresolvableShell) && String(unresolvableShell).includes("unresolvable variable reference") && String(unresolvableShell).includes("$D"));
|
|
694
|
+
const unresolvablePatch = await call("apply_patch", { patchText: "*** Update File: $UNKNOWN_DIR/file.ts\n" }, { sessionID: "s-active" });
|
|
695
|
+
check("apply_patch with unresolvable $VAR names the variable cause", blocked(unresolvablePatch) && String(unresolvablePatch).includes("unresolvable variable reference"));
|
|
696
|
+
const unresolvableMv = await call("bash", { command: "mv $D/foo.txt ./bar.txt" }, { sessionID: "s-active" });
|
|
697
|
+
check("mv source with unresolvable $VAR names the variable cause", blocked(unresolvableMv) && String(unresolvableMv).includes("unresolvable variable reference"));
|
|
698
|
+
const unresolvableGit = await call("bash", { command: "git -C $OTHER_REPO commit -m x" }, { sessionID: "s-active" });
|
|
699
|
+
check("git repoDir with unresolvable $VAR names the variable cause", blocked(unresolvableGit) && String(unresolvableGit).includes("unresolvable variable reference"));
|
|
700
|
+
// Scratch-directory cause attribution: /tmp/opencode is the environment's
|
|
701
|
+
// designated scratch space, but the workspace boundary is workspace-absolute
|
|
702
|
+
// and does not cover it; the message explains that instead of reading as a
|
|
703
|
+
// guard bug (6 of the device audit's boundary blocks were this class).
|
|
704
|
+
const scratchWrite = await call("write", { filePath: "/tmp/opencode/scratch.txt", content: "x" }, { sessionID: "s-active" });
|
|
705
|
+
check("scratch-dir write attributes the environment scratch cause", blocked(scratchWrite) && String(scratchWrite).includes("environment scratch directory") && String(scratchWrite).includes("/tmp/opencode") && !String(scratchWrite).includes("unresolvable variable reference"));
|
|
706
|
+
const scratchRedirect = await call("bash", { command: "echo x > /tmp/opencode/out.md" }, { sessionID: "s-active" });
|
|
707
|
+
check("scratch-dir redirect attributes the environment scratch cause", blocked(scratchRedirect) && String(scratchRedirect).includes("environment scratch directory"));
|
|
708
|
+
const scratchPatch = await call("apply_patch", { patchText: "*** Add File: /tmp/opencode/scratch.ts\n+scratch\n" }, { sessionID: "s-active" });
|
|
709
|
+
check("scratch-dir apply_patch attributes the environment scratch cause", blocked(scratchPatch) && String(scratchPatch).includes("environment scratch directory"));
|
|
710
|
+
const otherOutsideWrite = await call("write", { filePath: "/var/tmp/elsewhere.txt", content: "x" }, { sessionID: "s-active" });
|
|
711
|
+
check("non-scratch outside path keeps the generic escape message", blocked(otherOutsideWrite) && String(otherOutsideWrite).includes("escapes workspace root") && !String(otherOutsideWrite).includes("scratch directory"));
|
|
681
712
|
check("allow apply_patch within workspace", !(await call("apply_patch", { patchText: "*** Update File: src/app.ts\n" }, { sessionID: "s-active" })));
|
|
682
713
|
check("block apply_patch escaping workspace", blocked(await call("apply_patch", { patchText: "*** Update File: ../../secret.env\n" }, { sessionID: "s-active" })));
|
|
683
714
|
|
|
@@ -897,6 +928,169 @@ try {
|
|
|
897
928
|
}
|
|
898
929
|
check("blocked multi-target patch leaves no partial claims", !partialPatchClaim);
|
|
899
930
|
|
|
931
|
+
// Stale claim takeover (missed idle release): a claim created BEFORE its
|
|
932
|
+
// owner's last recorded idle timestamp cannot belong to an in-flight tool
|
|
933
|
+
// call, so a leaked claim must not block another session forever. Owners that
|
|
934
|
+
// are still mid-session (no idle timestamp), a client without a session
|
|
935
|
+
// lookup, and a failing lookup all keep the block (fail-closed).
|
|
936
|
+
console.log("- Stale file-claim takeover -");
|
|
937
|
+
const takeoverDir = mkdtempSync(join(tmpdir(), "wg-claim-takeover-"));
|
|
938
|
+
writeFileSync(join(takeoverDir, "stale.ts"), "before");
|
|
939
|
+
writeFileSync(join(takeoverDir, "live.ts"), "before");
|
|
940
|
+
writeFileSync(join(takeoverDir, "refresh.ts"), "before");
|
|
941
|
+
const takeoverIdle = new Map<string, number>();
|
|
942
|
+
const takeoverClient = {
|
|
943
|
+
session: {
|
|
944
|
+
todo: fakeClient.session.todo,
|
|
945
|
+
get: async ({ path }: { path: { id: string } }) => {
|
|
946
|
+
const idle = takeoverIdle.get(path.id);
|
|
947
|
+
return { data: { id: path.id, parentID: fakeParents.get(path.id), ...(typeof idle === "number" ? { time: { idle } } : {}) } };
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
};
|
|
951
|
+
const takeoverHooks = await pluginFn({
|
|
952
|
+
directory: takeoverDir,
|
|
953
|
+
client: takeoverClient as any,
|
|
954
|
+
project: {} as any,
|
|
955
|
+
worktree: takeoverDir,
|
|
956
|
+
experimental_workspace: {} as any,
|
|
957
|
+
serverUrl: new URL("http://localhost:4096"),
|
|
958
|
+
$: undefined as any,
|
|
959
|
+
});
|
|
960
|
+
todo("s-stale-owner", item("edit stale file", "in_progress"));
|
|
961
|
+
todo("s-live-owner", item("edit live file", "in_progress"));
|
|
962
|
+
todo("s-claimant", item("take over stale claim", "in_progress"));
|
|
963
|
+
const takeoverBefore = takeoverHooks["tool.execute.before"];
|
|
964
|
+
const takeoverAfter = takeoverHooks["tool.execute.after"];
|
|
965
|
+
|
|
966
|
+
// The owner claims the file, its after-hook is skipped, and it idles LATER.
|
|
967
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "stale.ts"), "takeover-read-stale");
|
|
968
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "stale-claim" }, { args: { filePath: join(takeoverDir, "stale.ts"), content: "a" } });
|
|
969
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
970
|
+
takeoverIdle.set("s-stale-owner", Date.now());
|
|
971
|
+
// A live owner, still mid-session, claims the other file.
|
|
972
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-live-owner", join(takeoverDir, "live.ts"), "takeover-read-live");
|
|
973
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-live-owner", callID: "live-claim" }, { args: { filePath: join(takeoverDir, "live.ts"), content: "b" } });
|
|
974
|
+
|
|
975
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "stale.ts"), "takeover-read-claimant");
|
|
976
|
+
let staleTakeoverAllowed = true;
|
|
977
|
+
try {
|
|
978
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "takeover-claim" }, { args: { filePath: join(takeoverDir, "stale.ts"), content: "c" } });
|
|
979
|
+
} catch {
|
|
980
|
+
staleTakeoverAllowed = false;
|
|
981
|
+
}
|
|
982
|
+
check("a claim older than its owner's recorded idle is released for the next claimant", staleTakeoverAllowed);
|
|
983
|
+
const takeoverAudit = getRecentAuditEntries(30).find((entry) => entry.tool === "file-claims" && entry.reason === "stale_claim_takeover");
|
|
984
|
+
check("stale claim takeover is audited with the released path and owner", takeoverAudit?.decision === "allow" && typeof takeoverAudit?.evidence?.targetPath === "string" && takeoverAudit.evidence.targetPath.endsWith("stale.ts") && takeoverAudit.evidence.claimOwnerSessionID === "s-stale-owner");
|
|
985
|
+
await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "takeover-claim", args: {} }, { title: "edit", output: "edited", metadata: {} });
|
|
986
|
+
|
|
987
|
+
// A live owner, still mid-turn (no idle timestamp recorded), keeps its claim.
|
|
988
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "live.ts"), "takeover-read-live-claimant");
|
|
989
|
+
let liveClaimBlocked = false;
|
|
990
|
+
try {
|
|
991
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "live-blocked" }, { args: { filePath: join(takeoverDir, "live.ts"), content: "c" } });
|
|
992
|
+
} catch (error) {
|
|
993
|
+
liveClaimBlocked = String(error).includes("claimed by another active session");
|
|
994
|
+
}
|
|
995
|
+
check("a live owner without an idle timestamp keeps its claim", liveClaimBlocked);
|
|
996
|
+
await takeoverAfter?.({ tool: "edit", sessionID: "s-live-owner", callID: "live-claim", args: {} }, { title: "edit", output: "edited", metadata: {} });
|
|
997
|
+
|
|
998
|
+
// A same-session re-claim refreshes the stored claim (new callID/timestamp):
|
|
999
|
+
// an owner that idled with a leaked claim and comes back to the same file
|
|
1000
|
+
// mid-turn must not have its fresh claim taken over, and the refreshed
|
|
1001
|
+
// callID must be the one the after-hook releases.
|
|
1002
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "refresh.ts"), "takeover-read-refresh");
|
|
1003
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "refresh-claim-1" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "d" } });
|
|
1004
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1005
|
+
takeoverIdle.set("s-stale-owner", Date.now());
|
|
1006
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "refresh-claim-2" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "e" } });
|
|
1007
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "refresh.ts"), "takeover-read-refresh-claimant");
|
|
1008
|
+
let refreshClaimBlocked = false;
|
|
1009
|
+
try {
|
|
1010
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "refresh-conflict" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "f" } });
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
refreshClaimBlocked = String(error).includes("claimed by another active session");
|
|
1013
|
+
}
|
|
1014
|
+
check("a same-session re-claim refreshes past the owner's idle so it is not taken over mid-turn", refreshClaimBlocked);
|
|
1015
|
+
await takeoverAfter?.({ tool: "edit", sessionID: "s-stale-owner", callID: "refresh-claim-2", args: {} }, { title: "edit", output: "edited", metadata: {} });
|
|
1016
|
+
let refreshReleasedAllowed = true;
|
|
1017
|
+
try {
|
|
1018
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "refresh-after-release" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "g" } });
|
|
1019
|
+
} catch {
|
|
1020
|
+
refreshReleasedAllowed = false;
|
|
1021
|
+
}
|
|
1022
|
+
check("the refreshed claim releases through its updated callID", refreshReleasedAllowed);
|
|
1023
|
+
await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "refresh-after-release", args: {} }, { title: "edit", output: "edited", metadata: {} });
|
|
1024
|
+
|
|
1025
|
+
// The takeover releases every provably stale claim of the owner, each with
|
|
1026
|
+
// its own audit entry: an owner holding two stale paths loses both in one
|
|
1027
|
+
// pass, and no fresh claim of that owner is touched.
|
|
1028
|
+
writeFileSync(join(takeoverDir, "multi-a.ts"), "before");
|
|
1029
|
+
writeFileSync(join(takeoverDir, "multi-b.ts"), "before");
|
|
1030
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "multi-a.ts"), "takeover-read-multi-a");
|
|
1031
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "multi-claim-a" }, { args: { filePath: join(takeoverDir, "multi-a.ts"), content: "h" } });
|
|
1032
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "multi-b.ts"), "takeover-read-multi-b");
|
|
1033
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "multi-claim-b" }, { args: { filePath: join(takeoverDir, "multi-b.ts"), content: "h" } });
|
|
1034
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1035
|
+
takeoverIdle.set("s-stale-owner", Date.now());
|
|
1036
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "multi-a.ts"), "takeover-read-multi-a-claimant");
|
|
1037
|
+
let multiTakeoverAllowed = true;
|
|
1038
|
+
try {
|
|
1039
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-a" }, { args: { filePath: join(takeoverDir, "multi-a.ts"), content: "i" } });
|
|
1040
|
+
} catch {
|
|
1041
|
+
multiTakeoverAllowed = false;
|
|
1042
|
+
}
|
|
1043
|
+
check("one conflict releases every provably stale claim of the owner", multiTakeoverAllowed);
|
|
1044
|
+
await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-a", args: {} }, { title: "edit", output: "edited", metadata: {} });
|
|
1045
|
+
await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "multi-b.ts"), "takeover-read-multi-b-claimant");
|
|
1046
|
+
let multiSecondAllowed = true;
|
|
1047
|
+
try {
|
|
1048
|
+
await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-b" }, { args: { filePath: join(takeoverDir, "multi-b.ts"), content: "j" } });
|
|
1049
|
+
} catch {
|
|
1050
|
+
multiSecondAllowed = false;
|
|
1051
|
+
}
|
|
1052
|
+
check("the owner's other stale path is released in the same pass", multiSecondAllowed);
|
|
1053
|
+
await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-b", args: {} }, { title: "edit", output: "edited", metadata: {} });
|
|
1054
|
+
const takeoverAudits = getRecentAuditEntries(30).filter((entry) => entry.reason === "stale_claim_takeover" && typeof entry.evidence?.targetPath === "string" && entry.evidence.targetPath.includes("multi-"));
|
|
1055
|
+
check("each released stale path has its own audited takeover entry", takeoverAudits.length === 2 && takeoverAudits.every((entry) => entry.evidence?.claimOwnerSessionID === "s-stale-owner"));
|
|
1056
|
+
|
|
1057
|
+
// Fail-closed: no session lookup on the client, and a lookup that throws.
|
|
1058
|
+
const noLookupDir = mkdtempSync(join(tmpdir(), "wg-claim-nolookup-"));
|
|
1059
|
+
writeFileSync(join(noLookupDir, "file.ts"), "before");
|
|
1060
|
+
const noLookupHooks = await pluginFn({ directory: noLookupDir, client: {} as any, project: {} as any, worktree: noLookupDir, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
|
|
1061
|
+
const noLookupBefore = noLookupHooks["tool.execute.before"];
|
|
1062
|
+
const noLookupAfter = noLookupHooks["tool.execute.after"];
|
|
1063
|
+
todo("s-nolookup-owner", item("edit file", "in_progress"));
|
|
1064
|
+
await recordRead(noLookupBefore, noLookupAfter, "s-nolookup-owner", join(noLookupDir, "file.ts"), "nolookup-read-owner");
|
|
1065
|
+
await noLookupBefore?.({ tool: "edit", sessionID: "s-nolookup-owner", callID: "nolookup-claim" }, { args: { filePath: join(noLookupDir, "file.ts"), content: "a" } });
|
|
1066
|
+
todo("s-nolookup-claimant", item("edit file", "in_progress"));
|
|
1067
|
+
await recordRead(noLookupBefore, noLookupAfter, "s-nolookup-claimant", join(noLookupDir, "file.ts"), "nolookup-read-claimant");
|
|
1068
|
+
let noLookupBlocked = false;
|
|
1069
|
+
try {
|
|
1070
|
+
await noLookupBefore?.({ tool: "edit", sessionID: "s-nolookup-claimant", callID: "nolookup-claimant" }, { args: { filePath: join(noLookupDir, "file.ts"), content: "b" } });
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
noLookupBlocked = String(error).includes("claimed by another active session");
|
|
1073
|
+
}
|
|
1074
|
+
check("stale takeover fails closed when the client has no session lookup", noLookupBlocked);
|
|
1075
|
+
|
|
1076
|
+
const throwLookupDir = mkdtempSync(join(tmpdir(), "wg-claim-throwlookup-"));
|
|
1077
|
+
writeFileSync(join(throwLookupDir, "file.ts"), "before");
|
|
1078
|
+
const throwLookupHooks = await pluginFn({ directory: throwLookupDir, client: { session: { get: async () => { throw new Error("lookup failed"); } } } as any, project: {} as any, worktree: throwLookupDir, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
|
|
1079
|
+
const throwLookupBefore = throwLookupHooks["tool.execute.before"];
|
|
1080
|
+
const throwLookupAfter = throwLookupHooks["tool.execute.after"];
|
|
1081
|
+
todo("s-throwlookup-owner", item("edit file", "in_progress"));
|
|
1082
|
+
await recordRead(throwLookupBefore, throwLookupAfter, "s-throwlookup-owner", join(throwLookupDir, "file.ts"), "throwlookup-read-owner");
|
|
1083
|
+
await throwLookupBefore?.({ tool: "edit", sessionID: "s-throwlookup-owner", callID: "throwlookup-claim" }, { args: { filePath: join(throwLookupDir, "file.ts"), content: "a" } });
|
|
1084
|
+
todo("s-throwlookup-claimant", item("edit file", "in_progress"));
|
|
1085
|
+
await recordRead(throwLookupBefore, throwLookupAfter, "s-throwlookup-claimant", join(throwLookupDir, "file.ts"), "throwlookup-read-claimant");
|
|
1086
|
+
let throwLookupBlocked = false;
|
|
1087
|
+
try {
|
|
1088
|
+
await throwLookupBefore?.({ tool: "edit", sessionID: "s-throwlookup-claimant", callID: "throwlookup-claimant" }, { args: { filePath: join(throwLookupDir, "file.ts"), content: "b" } });
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
throwLookupBlocked = String(error).includes("claimed by another active session");
|
|
1091
|
+
}
|
|
1092
|
+
check("stale takeover fails closed when the session lookup throws", throwLookupBlocked);
|
|
1093
|
+
|
|
900
1094
|
// Existing files must be read by the same session before edit/write, and the
|
|
901
1095
|
// bytes plus filesystem identity must still match when the mutation starts.
|
|
902
1096
|
const staleDir = mkdtempSync(join(tmpdir(), "wg-stale-write-"));
|
|
@@ -2427,6 +2621,36 @@ check(
|
|
|
2427
2621
|
);
|
|
2428
2622
|
rmSync(rubricDiffRepo, { recursive: true, force: true });
|
|
2429
2623
|
|
|
2624
|
+
// The default-bases loop is directly asserted: with no origin remote, the
|
|
2625
|
+
// loop falls through origin/main and origin/master to the local main branch
|
|
2626
|
+
// and embeds the real branch diff. The spawn also keeps a trailing `--` after
|
|
2627
|
+
// the revision so the base ref can never parse as a pathspec (LL-001).
|
|
2628
|
+
const rubricNoOrigin = join(root, "wg-rubric-no-origin");
|
|
2629
|
+
mkdirSync(rubricNoOrigin, { recursive: true });
|
|
2630
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: rubricNoOrigin });
|
|
2631
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: rubricNoOrigin });
|
|
2632
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: rubricNoOrigin });
|
|
2633
|
+
writeFileSync(join(rubricNoOrigin, "fallthrough.txt"), "base\n");
|
|
2634
|
+
spawnSync("git", ["add", "fallthrough.txt"], { cwd: rubricNoOrigin });
|
|
2635
|
+
spawnSync("git", ["commit", "-m", "base"], { cwd: rubricNoOrigin });
|
|
2636
|
+
spawnSync("git", ["switch", "-c", "feature/rubric-fallthrough"], { cwd: rubricNoOrigin });
|
|
2637
|
+
writeFileSync(join(rubricNoOrigin, "fallthrough.txt"), "branch marker\n");
|
|
2638
|
+
spawnSync("git", ["add", "fallthrough.txt"], { cwd: rubricNoOrigin });
|
|
2639
|
+
spawnSync("git", ["commit", "-qm", "branch change"], { cwd: rubricNoOrigin });
|
|
2640
|
+
// An uncommitted tracked edit discriminates the fallbacks: main...HEAD is
|
|
2641
|
+
// committed-only and must NOT include it, while the HEAD~1 fallback (commit
|
|
2642
|
+
// vs worktree) would — so the assertion proves the base loop won.
|
|
2643
|
+
writeFileSync(join(rubricNoOrigin, "fallthrough.txt"), "branch marker\nuncommitted worktree edit\n");
|
|
2644
|
+
const rubricFallthrough = await customPlugin.tool?.guard_review_rubric?.execute(
|
|
2645
|
+
{ directory: rubricNoOrigin },
|
|
2646
|
+
{ sessionID: "s-rubric-fallthrough", worktree: rubricNoOrigin, directory: rubricNoOrigin } as any,
|
|
2647
|
+
);
|
|
2648
|
+
check(
|
|
2649
|
+
"rubric default bases fall through to the local main when no origin exists",
|
|
2650
|
+
typeof rubricFallthrough === "string" && rubricFallthrough.includes("diff --git") && rubricFallthrough.includes("fallthrough.txt") && rubricFallthrough.includes("branch marker") && !rubricFallthrough.includes("uncommitted worktree edit"),
|
|
2651
|
+
);
|
|
2652
|
+
rmSync(rubricNoOrigin, { recursive: true, force: true });
|
|
2653
|
+
|
|
2430
2654
|
// Reviewer-type agents often lack record_review in their toolset entirely, so
|
|
2431
2655
|
// the tool accepts recorders of any session type: the verdict binds to the
|
|
2432
2656
|
// recording session's directory and the audit trail captures the recorder
|
|
@@ -2677,6 +2901,119 @@ check("PR blocked when only the reviewed commit drifted, with a commit-specific
|
|
|
2677
2901
|
rmSync(driftRepo, { recursive: true, force: true });
|
|
2678
2902
|
resetReviewState();
|
|
2679
2903
|
|
|
2904
|
+
// Strict recorder mode (requireSubagentReview): an opt-in policy that closes
|
|
2905
|
+
// the root-session self-approval path which default content binding keeps
|
|
2906
|
+
// open for read-only reviewer relays. Approvals must be recorded from a
|
|
2907
|
+
// subagent session (lineage, not agent role); changes-requested verdicts
|
|
2908
|
+
// stay relayable.
|
|
2909
|
+
console.log("- Strict Recorder Mode (requireSubagentReview) -");
|
|
2910
|
+
const strictSummary = "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.";
|
|
2911
|
+
const strictRepo = join(root, "wg-review-strict");
|
|
2912
|
+
mkdirSync(strictRepo, { recursive: true });
|
|
2913
|
+
spawnSync("git", ["init", "-b", "feature/strict-recorder"], { cwd: strictRepo });
|
|
2914
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: strictRepo });
|
|
2915
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: strictRepo });
|
|
2916
|
+
writeFileSync(join(strictRepo, "code.txt"), "base\n");
|
|
2917
|
+
spawnSync("git", ["add", "code.txt"], { cwd: strictRepo });
|
|
2918
|
+
spawnSync("git", ["commit", "-m", "base"], { cwd: strictRepo });
|
|
2919
|
+
mkdirSync(join(strictRepo, ".opencode"), { recursive: true });
|
|
2920
|
+
writeFileSync(join(strictRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true, requireSubagentReview: true }));
|
|
2921
|
+
const strictPlugin = await WorkflowGuard({
|
|
2922
|
+
directory: root,
|
|
2923
|
+
worktree: root,
|
|
2924
|
+
client: fakeClient as any,
|
|
2925
|
+
project: {} as any,
|
|
2926
|
+
experimental_workspace: {} as any,
|
|
2927
|
+
serverUrl: new URL("http://localhost:4096"),
|
|
2928
|
+
$: undefined as any,
|
|
2929
|
+
});
|
|
2930
|
+
const strictPrProbe = { tool: "bash", input: { command: "gh pr create --title t --body 'Changelog: strict'", workdir: strictRepo } };
|
|
2931
|
+
|
|
2932
|
+
// Root-session self-approval is rejected before any verdict is recorded
|
|
2933
|
+
const strictRootAttempt = await strictPlugin.tool?.record_review?.execute(
|
|
2934
|
+
{ reviewer: "self-approved", summary: strictSummary, passed: true },
|
|
2935
|
+
{ sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any,
|
|
2936
|
+
);
|
|
2937
|
+
const strictRootAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-strict-root");
|
|
2938
|
+
check("strict mode: root-session approval is rejected with an actionable cause", typeof strictRootAttempt === "string" && strictRootAttempt.includes("rejected") && strictRootAttempt.includes("requireSubagentReview") && !strictRootAttempt.includes("APPROVED"));
|
|
2939
|
+
check("strict mode: rejected root approval is audited as subagent_recorder_required", strictRootAudit?.reason === "subagent_recorder_required");
|
|
2940
|
+
const strictStatusAfterRoot = JSON.parse(String(await strictPlugin.tool?.guard_status?.execute({ directory: strictRepo }, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
|
|
2941
|
+
check("strict mode: review gate stays unsatisfied after rejected self-approval", strictStatusAfterRoot.outstandingRequirements.includes("review") && strictStatusAfterRoot.lastReview === null && strictStatusAfterRoot.projectConfig?.requireSubagentReview === true);
|
|
2942
|
+
const strictPrAfterRoot = JSON.parse(String(await strictPlugin.tool?.guard_why?.execute(strictPrProbe, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
|
|
2943
|
+
check("strict mode: PR preflight still blocked after rejected self-approval", strictPrAfterRoot.status === "blocked" && strictPrAfterRoot.message.includes("Passing secondary review approval is required"));
|
|
2944
|
+
|
|
2945
|
+
// Changes-requested findings remain relayable from the root session
|
|
2946
|
+
const strictRootNegative = await strictPlugin.tool?.record_review?.execute(
|
|
2947
|
+
{ reviewer: "self-flagged", summary: strictSummary, passed: false },
|
|
2948
|
+
{ sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any,
|
|
2949
|
+
);
|
|
2950
|
+
check("strict mode: root session may still relay changes-requested verdicts", typeof strictRootNegative === "string" && strictRootNegative.includes("CHANGES REQUESTED"));
|
|
2951
|
+
|
|
2952
|
+
// A subagent session (has a parent) records the approval
|
|
2953
|
+
fakeParents.set("s-strict-reviewer", "s-strict-root");
|
|
2954
|
+
const strictSubagentResult = await strictPlugin.tool?.record_review?.execute(
|
|
2955
|
+
{ reviewer: "strict-reviewer", summary: strictSummary, passed: true },
|
|
2956
|
+
{ sessionID: "s-strict-reviewer", agent: "general", worktree: strictRepo, directory: strictRepo } as any,
|
|
2957
|
+
);
|
|
2958
|
+
check("strict mode: subagent recorder records the approval", typeof strictSubagentResult === "string" && strictSubagentResult.includes("APPROVED"));
|
|
2959
|
+
const strictStatusAfterSubagent = JSON.parse(String(await strictPlugin.tool?.guard_status?.execute({ directory: strictRepo }, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
|
|
2960
|
+
check("strict mode: subagent approval satisfies the review gate", !strictStatusAfterSubagent.outstandingRequirements.includes("review") && strictStatusAfterSubagent.lastReview?.fresh === true);
|
|
2961
|
+
const strictPrAfterSubagent = JSON.parse(String(await strictPlugin.tool?.guard_why?.execute(strictPrProbe, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
|
|
2962
|
+
check("strict mode: PR preflight passes after subagent-recorded approval", strictPrAfterSubagent.status !== "blocked" && (strictPrAfterSubagent.status === "allowed" || strictPrAfterSubagent.code === "remote_state_unchecked"));
|
|
2963
|
+
if (strictPrAfterSubagent.status === "blocked") console.log(" blocked:", strictPrAfterSubagent.message);
|
|
2964
|
+
|
|
2965
|
+
// The knob follows the REVIEWED workspace's config, not the caller's: a root
|
|
2966
|
+
// session based in a non-strict context cannot approve strictRepo's work by
|
|
2967
|
+
// naming it as the reviewed directory from outside strict mode. Note the
|
|
2968
|
+
// reviewed workspace comes from the tool ARG (`directory`), not the caller's
|
|
2969
|
+
// context; the context worktree below is deliberately the non-strict temp root.
|
|
2970
|
+
const strictCrossAttempt = await strictPlugin.tool?.record_review?.execute(
|
|
2971
|
+
{ reviewer: "cross-root", summary: strictSummary, passed: true, directory: strictRepo },
|
|
2972
|
+
{ sessionID: "s-strict-cross", worktree: root, directory: root } as any,
|
|
2973
|
+
);
|
|
2974
|
+
const strictCrossAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-strict-cross");
|
|
2975
|
+
check("strict mode: the knob follows the reviewed workspace, not the caller's repo", typeof strictCrossAttempt === "string" && strictCrossAttempt.includes("rejected") && strictCrossAttempt.includes("requireSubagentReview") && !strictCrossAttempt.includes("APPROVED"));
|
|
2976
|
+
check("strict mode: cross-repo root approval audited as subagent_recorder_required", strictCrossAudit?.reason === "subagent_recorder_required");
|
|
2977
|
+
|
|
2978
|
+
// Env override: =0 disables the knob even when project config enables it
|
|
2979
|
+
process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW = "0";
|
|
2980
|
+
const strictEnvOff = await strictPlugin.tool?.record_review?.execute(
|
|
2981
|
+
{ reviewer: "env-off-root", summary: strictSummary, passed: true },
|
|
2982
|
+
{ sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any,
|
|
2983
|
+
);
|
|
2984
|
+
check("strict mode env off: WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=0 overrides the enabled project config", typeof strictEnvOff === "string" && strictEnvOff.includes("APPROVED"));
|
|
2985
|
+
delete process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW;
|
|
2986
|
+
|
|
2987
|
+
rmSync(strictRepo, { recursive: true, force: true });
|
|
2988
|
+
resetReviewState();
|
|
2989
|
+
|
|
2990
|
+
// Environment overrides follow the requireReview pattern; the default keeps
|
|
2991
|
+
// root-session relay recording allowed without the knob.
|
|
2992
|
+
const relaxedRepo = join(root, "wg-review-relaxed");
|
|
2993
|
+
mkdirSync(relaxedRepo, { recursive: true });
|
|
2994
|
+
spawnSync("git", ["init", "-b", "feature/relaxed-recorder"], { cwd: relaxedRepo });
|
|
2995
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: relaxedRepo });
|
|
2996
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: relaxedRepo });
|
|
2997
|
+
writeFileSync(join(relaxedRepo, "code.txt"), "base\n");
|
|
2998
|
+
spawnSync("git", ["add", "code.txt"], { cwd: relaxedRepo });
|
|
2999
|
+
spawnSync("git", ["commit", "-m", "base"], { cwd: relaxedRepo });
|
|
3000
|
+
process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW = "1";
|
|
3001
|
+
const relaxedEnvAttempt = await strictPlugin.tool?.record_review?.execute(
|
|
3002
|
+
{ reviewer: "self-approved", summary: strictSummary, passed: true },
|
|
3003
|
+
{ sessionID: "s-relaxed-root", worktree: relaxedRepo, directory: relaxedRepo } as any,
|
|
3004
|
+
);
|
|
3005
|
+
check("strict recorder env override: WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1 applies without project config", typeof relaxedEnvAttempt === "string" && relaxedEnvAttempt.includes("rejected") && relaxedEnvAttempt.includes("subagent"));
|
|
3006
|
+
const relaxedEnvStatus = JSON.parse(String(await strictPlugin.tool?.guard_status?.execute({ directory: relaxedRepo }, { sessionID: "s-relaxed-root", worktree: relaxedRepo, directory: relaxedRepo } as any)));
|
|
3007
|
+
check("strict recorder env override: status echoes the forced requirement", relaxedEnvStatus.projectConfig?.requireSubagentReview === true);
|
|
3008
|
+
delete process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW;
|
|
3009
|
+
const relaxedDefault = await strictPlugin.tool?.record_review?.execute(
|
|
3010
|
+
{ reviewer: "relayed-reviewer", summary: strictSummary, passed: true },
|
|
3011
|
+
{ sessionID: "s-relaxed-root", worktree: relaxedRepo, directory: relaxedRepo } as any,
|
|
3012
|
+
);
|
|
3013
|
+
check("strict recorder default: root-session relay recording stays allowed without the knob", typeof relaxedDefault === "string" && relaxedDefault.includes("APPROVED"));
|
|
3014
|
+
rmSync(relaxedRepo, { recursive: true, force: true });
|
|
3015
|
+
resetReviewState();
|
|
3016
|
+
|
|
2680
3017
|
// Event hook handles permission events
|
|
2681
3018
|
if (typeof customPlugin.event === "function") {
|
|
2682
3019
|
await customPlugin.event({
|
|
@@ -4254,6 +4591,80 @@ check("project-memory initialization failure leaves core guard hooks active", ty
|
|
|
4254
4591
|
if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
4255
4592
|
else process.env.XDG_DATA_HOME = prevDataHome;
|
|
4256
4593
|
|
|
4594
|
+
// Loaded plugin version visibility: OpenCode resolves configured bare plugin
|
|
4595
|
+
// names through Node's parent-directory lookup from its configuration
|
|
4596
|
+
// location, so the loaded copy can silently differ from a globally
|
|
4597
|
+
// npm-installed one (observed on-device: npm i -g updates ignored while a
|
|
4598
|
+
// home-level pnpm node_modules copy kept loading). The version is therefore
|
|
4599
|
+
// surfaced in guard_status and in the startup app log.
|
|
4600
|
+
console.log("- Git hygiene snapshot -");
|
|
4601
|
+
const hygieneRepo = join(root, "wg-hygiene");
|
|
4602
|
+
mkdirSync(hygieneRepo, { recursive: true });
|
|
4603
|
+
const hgit = (args: string[]): string => {
|
|
4604
|
+
const res = spawnSync("git", args, { cwd: hygieneRepo, encoding: "utf8" });
|
|
4605
|
+
if (res.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${res.stderr}`);
|
|
4606
|
+
return res.stdout;
|
|
4607
|
+
};
|
|
4608
|
+
hgit(["init", "-b", "main"]);
|
|
4609
|
+
hgit(["config", "user.email", "test@test.local"]);
|
|
4610
|
+
hgit(["config", "user.name", "Test Runner"]);
|
|
4611
|
+
writeFileSync(join(hygieneRepo, "base.txt"), "base\n");
|
|
4612
|
+
hgit(["add", "base.txt"]);
|
|
4613
|
+
hgit(["commit", "-m", "base"]);
|
|
4614
|
+
hgit(["switch", "-c", "feature/done"]);
|
|
4615
|
+
writeFileSync(join(hygieneRepo, "done.txt"), "done\n");
|
|
4616
|
+
hgit(["add", "done.txt"]);
|
|
4617
|
+
hgit(["commit", "-m", "done work"]);
|
|
4618
|
+
hgit(["switch", "main"]);
|
|
4619
|
+
hgit(["merge", "--ff-only", "feature/done"]);
|
|
4620
|
+
hgit(["switch", "-c", "feature/open"]);
|
|
4621
|
+
writeFileSync(join(hygieneRepo, "open.txt"), "open\n");
|
|
4622
|
+
hgit(["add", "open.txt"]);
|
|
4623
|
+
hgit(["commit", "-m", "open work"]);
|
|
4624
|
+
// Prunable worktree: register one, then delete its directory (crashed-add
|
|
4625
|
+
// simulation) so the admin entry stays behind.
|
|
4626
|
+
hgit(["worktree", "add", join(root, "wg-hygiene-wt"), "feature/done"]);
|
|
4627
|
+
rmSync(join(root, "wg-hygiene-wt"), { recursive: true, force: true });
|
|
4628
|
+
const hygieneStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: hygieneRepo }, { sessionID: "s-hygiene", worktree: hygieneRepo, directory: hygieneRepo } as any)));
|
|
4629
|
+
check("guard_status exposes the git hygiene snapshot", typeof hygieneStatus.gitHygiene === "object" && hygieneStatus.gitHygiene !== null);
|
|
4630
|
+
check("hygiene lists merged local branches excluding the current and unmerged", Array.isArray(hygieneStatus.gitHygiene.mergedLocalBranches) && hygieneStatus.gitHygiene.mergedLocalBranches.includes("feature/done") && !hygieneStatus.gitHygiene.mergedLocalBranches.includes("feature/open") && !hygieneStatus.gitHygiene.mergedLocalBranches.includes("main"));
|
|
4631
|
+
check("hygiene counts prunable worktree admin entries", hygieneStatus.gitHygiene.prunableWorktreeCount === 1);
|
|
4632
|
+
check("hygiene omits base-behind without a remote", hygieneStatus.gitHygiene.baseBehind === undefined);
|
|
4633
|
+
// The list is capped while the count reflects reality.
|
|
4634
|
+
for (let i = 0; i < 12; i++) {
|
|
4635
|
+
hgit(["switch", "main"]);
|
|
4636
|
+
hgit(["switch", "-c", `feature/merged-${i}`]);
|
|
4637
|
+
writeFileSync(join(hygieneRepo, `merged-${i}.txt`), `${i}\n`);
|
|
4638
|
+
hgit(["add", `merged-${i}.txt`]);
|
|
4639
|
+
hgit(["commit", "-m", `merged ${i}`]);
|
|
4640
|
+
hgit(["switch", "main"]);
|
|
4641
|
+
hgit(["merge", "--ff-only", `feature/merged-${i}`]);
|
|
4642
|
+
}
|
|
4643
|
+
// End on a merged, non-base current branch: the current-branch filter must
|
|
4644
|
+
// exclude it from the merged list independently of the base filter.
|
|
4645
|
+
hgit(["switch", "feature/merged-11"]);
|
|
4646
|
+
const cappedStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: hygieneRepo }, { sessionID: "s-hygiene-capped", worktree: hygieneRepo, directory: hygieneRepo } as any)));
|
|
4647
|
+
check("hygiene branch list is capped, counts reality, and excludes the merged current branch", cappedStatus.gitHygiene.mergedLocalBranchCount === 12 && cappedStatus.gitHygiene.mergedLocalBranches.length === 10 && !cappedStatus.gitHygiene.mergedLocalBranches.includes("feature/merged-11") && cappedStatus.gitHygiene.mergedLocalBranches.includes("feature/merged-10"));
|
|
4648
|
+
rmSync(hygieneRepo, { recursive: true, force: true });
|
|
4649
|
+
const hygieneNonGit = mkdtempSync(join(tmpdir(), "wg-hygiene-nongit-"));
|
|
4650
|
+
const nonGitStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: hygieneNonGit }, { sessionID: "s-hygiene-nongit", worktree: hygieneNonGit, directory: hygieneNonGit } as any)));
|
|
4651
|
+
check("hygiene snapshot is null outside a git repository", nonGitStatus.gitHygiene === null);
|
|
4652
|
+
rmSync(hygieneNonGit, { recursive: true, force: true });
|
|
4653
|
+
|
|
4654
|
+
console.log("- Loaded plugin version visibility -");
|
|
4655
|
+
const expectedVersion = String((JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version);
|
|
4656
|
+
check("loadedPluginVersion reads the adjacent package manifest", loadedPluginVersion() === expectedVersion);
|
|
4657
|
+
check("loadedPluginVersion fails soft for an unreadable manifest", loadedPluginVersion(pathToFileURL(join(root, "wg-version-none", "src", "workflow-guard.ts")).href) === undefined);
|
|
4658
|
+
const versionStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: root }, { sessionID: "s-version", worktree: root, directory: root } as any)));
|
|
4659
|
+
check("guard_status exposes the loaded plugin version", versionStatus.pluginVersion === expectedVersion);
|
|
4660
|
+
const versionLogs: string[] = [];
|
|
4661
|
+
await WorkflowGuard({
|
|
4662
|
+
directory: root,
|
|
4663
|
+
worktree: root,
|
|
4664
|
+
client: { app: { log: async (opts: { body: { message: string } }) => { versionLogs.push(opts.body.message); } } } as any,
|
|
4665
|
+
} as any);
|
|
4666
|
+
check("startup app log states the loaded plugin version", versionLogs.some((message) => message.startsWith(`Workflow Guard v${expectedVersion} plugin initialized for ${root}; `) && message.includes("managed config")));
|
|
4667
|
+
|
|
4257
4668
|
console.log("- live control-plane paths (tier port: config-path facts) -");
|
|
4258
4669
|
const liveBase = mkdtempSync(join(tmpdir(), "wg-lcp-base-"));
|
|
4259
4670
|
const livePlane = join(liveBase, ".config", "open" + "code");
|