opencode-workflow-guard 1.19.0 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@ WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # Also run policy probes with Ope
45
45
 
46
46
  The plugin enforces **27 deterministic policy rules** across four main pillars:
47
47
 
48
- * **Branch & History Protection:** Enforces feature-branch workflows and blocks edits and commits on `main`/`master`. Direct and forced pushes to protected branches are hard-blocked. The guard checks mergeability before PR creation, requires secondary review approval bound to the reviewed worktree content, and requires a PR changelog or changeset. An opt-in dogfood gate can additionally require a recorded run of the change on real hardware before a PR opens.
48
+ * **Branch & History Protection:** Enforces feature-branch workflows and blocks edits and commits on `main`/`master`. Direct and forced pushes to protected branches are hard-blocked. The guard checks mergeability before PR creation, requires secondary review approval bound to the reviewed worktree content, and requires a PR changelog or changeset. An opt-in dogfood gate can additionally require a recorded run of the change on real hardware before a PR opens. Verdicts follow the tree the command actually runs in: git parsing takes the shell's `workdir` as its base, an in-command `cd`/`pushd`/`popd` and a wrapper `-C`/`-D` move the tree later segments are judged in, and a nested interpreter payload is judged by its own `cd` or `cwd` option. A linked worktree of the same repository is judged rather than boundary-blocked, a PR preflight resolves `--head`/`--source-branch` to the checkout holding that branch, and the V2 permission seat defers cwd-dependent commands to `execute.before` instead of judging the session-bound checkout. A `workdir` in a different repository is not judged: mutations and git writes there are blocked, because relative targets would be validated against the wrong tree.
49
49
  * **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks, which subagents inherit. A task can be marked completed only after its own recorded work. Review and investigation tasks use an audited `[read-only]` marker instead. The guard also blocks concurrent direct edits to the same canonical file across sessions. It requires a same-session fresh read before `edit`/`write` can replace an existing file. Silent task deletion is blocked, and task completion requires fresh test verification evidence. Completion claims and sweeping repository claims are journaled against their evidence. Repeated failures force a live-documentation lookup instead of a guess from training data.
50
50
  * **Secrets & Workspace Confinement:** Confines edit tools and recognized shell/git mutations to the workspace root, including symlink and `../` escape checks. Agent subshells have sensitive environment variables scrubbed, and `.env` reads return a safe schema mask. Arbitrary executables are not OS-sandboxed. Use containers or filesystem isolation when hard confinement is required.
51
51
  * **Destructive CLI & Environment Safety:** Intercepts destructive cloud, database, and infrastructure commands. Stops script laundering and interpreter evasion, blocks TTY hangs such as `vim`, `nano`, and `sudo`, and guards against dangerous package manager flags.
package/docs/policies.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  On OpenCode 1.x those hooks are `tool.execute.before`, `experimental.session.compacting`, `shell.env`, `permission.ask`, and `event` journaling. On OpenCode 2.x they are `tool.hook("execute.before"/"execute.after")`, `session.hook("context"/"compaction"/"prompt")`, `command.transform`, `permission.hook("evaluate")`, `shell.hook("create.before")`, and `event.subscribe()`. V1 plugin implementations do not run on OpenCode 2. The two lists therefore describe separate entrypoints, not alternatives for the same host.
6
6
 
7
+ **A V2 event handler runs only if the host delivers its type.** `ctx.event.subscribe()` is served from `EventManifest.ServerDefinitions`. A type that appears in `EventManifest.Definitions` but not in `ServerDefinitions` is never delivered, so a handler for it is dead code even though it compiles and looks live. Check every `event.type` comparison against that manifest before relying on it. The test suite enforces this structurally: a scan of `src/lib/v2-plugin.ts` asserts every compared literal is delivered.
8
+
7
9
  This is a policy and enforcement layer, not an agent harness. Policies may constrain actions, record or explain decisions, and preserve bounded context. They do not plan, prioritize, delegate, or autonomously sequence work. Where Workflow Guard resumes an idle session, it only enforces already-owned todo lifecycle state. It does not choose what the agent works on next.
8
10
 
9
11
  ---
@@ -19,7 +21,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
19
21
  - **Read-only escape:** A task whose text carries `[read-only]`, also `[read_only]` or `[read only]`, completes without a mutation. Review, investigation, and reading are legitimate work that changes no file. The marker is an annotation, not part of task identity, so it can be appended to an already-planned task without reading as a deletion. Every use is journaled as `todo_completed_read_only`, so relabeling real work as read-only stays visible in the audit trail. In a repository with no detectable verify command, that journal is the only counterweight. Work that was deliberately dropped uses `cancelled` instead.
20
22
  - `guard_status` reports `taskEvidence` (`active`, `completed`, `tasksWithoutRecordedWork`, `readOnlyTasks`), so the requirement is visible before it blocks anything. This is not task authority. Fresh verification evidence and the review gate remain the authority on finalization.
21
23
  - **Todo Capability Visibility:** Because the gate depends on the host exposing `todowrite` at all, `guard_status` reports `todoCapability` (`available` / `unavailable` / `unknown`). A fail-open gate is therefore visible rather than decorative.
22
- - **No Silent Early Exit:** When an owning session becomes `session.idle` with unfinished native todos, the guard asynchronously asks OpenCode to resume the existing run. This enforces the session's existing lifecycle state. Workflow Guard does not choose the next task, reorder work, create work, or delegate it. Ordinary automatic continuations are capped at three consecutive attempts, and the budget resets on genuine user input. User-initiated interrupts (Esc) surface as `MessageAbortedError` and suspend automatic continuation for that session until genuine user input arrives, mirroring the ralph `user_stopped` semantics. The independently opt-in `ralphMode` uses `ralphMaxIterations` (1-100, default 10) instead. Genuine user input terminates an active Ralph run, and unfinished work at the budget limit reports `budget_exhausted` rather than inferring a policy block. Ralph remains disabled by default under every operation profile. Use OpenCode's native `question` tool when user feedback or a decision is required. Question and permission interactions wait inside the active run rather than producing an idle completion.
24
+ - **No Silent Early Exit:** When an owning session reaches the idle state with unfinished native todos, the guard asynchronously asks OpenCode to resume the existing run. That trigger is `session.status` with `status.type === "idle"` or the deprecated `session.idle`; both are in `EventManifest.ServerDefinitions`, and which one the server publishes is not settled, so neither is relied on alone. A host that emits both for one transition still produces a single continuation prompt, because the send holds a per-session in-flight lock. This enforces the session's existing lifecycle state. Workflow Guard does not choose the next task, reorder work, create work, or delegate it. Ordinary automatic continuations are capped at three consecutive attempts, and the budget resets on genuine user input. User-initiated interrupts (Esc) suspend automatic continuation for that session until genuine user input arrives, mirroring the ralph `user_stopped` semantics. On OpenCode 2 this is driven by `session.execution.interrupted`, and only reason `user` suspends: `shutdown`, `superseded`, and `inactivity` are host-lifecycle events, not user intent, and they leave continuation state alone. The independently opt-in `ralphMode` uses `ralphMaxIterations` (1-100, default 10) instead. Genuine user input terminates an active Ralph run, and unfinished work at the budget limit reports `budget_exhausted` rather than inferring a policy block. Ralph remains disabled by default under every operation profile. Use OpenCode's native `question` tool when user feedback or a decision is required. Question and permission interactions wait inside the active run rather than producing an idle completion.
23
25
  - **Subagent Inheritance & Role Confinement:** Subagents inherit active tasks from their parent session via the `parentID` hierarchy. Subagents spawned with read-only roles (`reviewer`, `planner`, `advisor`, `critic`, `explorer`, `scout`, `evaluator`) are hard-confined. File mutations, lifecycle tools, and mutating shell commands are strictly blocked. On OpenCode 2 the confinement is two-layered. `session.hook("context")` deletes the mutating tools (`write`, `edit`, `patch`, `apply_patch`, `shell`, `bash`, `execute`, `todowrite`, `subagent`/`task`, worktree lifecycle tools) from that role's per-request tool snapshot. `execute.before` stays as the backstop for tools supplied outside the snapshot, such as ACP agents and nested Code Mode calls. The pruning is journaled as `session.context` / `read_only_role_tools_pruned`.
24
26
  - **Handoff Safety:** An idle subagent whose effective todos are inherited from its parent is not auto-continued. Inherited work belongs to the parent and can be handed back normally.
25
27
  - **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.
@@ -65,7 +67,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
65
67
  - MCP tools that mutate live GitHub or Azure/DevOps systems (`create`, `update`, `delete`, `merge`, `push`, `close`, etc.) are blocked.
66
68
  - Server names are tokenized on all non-alphanumerics, so `mcp__azure-devops__...`, `mcp__gh__...` and flat legacy names (`azure_devops_*`) are matched.
67
69
  - Read-only tools (`get`, `list`, `search`, `query`, etc.) are permitted.
68
- - On OpenCode 2 the guarded server set is widened from the host's own MCP roster (`ctx.mcp.list()`), refreshed on `mcp.tools.changed`. A renamed or enterprise instance of a guarded product is therefore still matched. Widening is one-way by design: removing a server adds nothing, and the static token list stays the floor. Tool names are model-supplied text, and this is a live-system rail.
70
+ - On OpenCode 2 the guarded server set is widened from the host's own MCP roster (`ctx.mcp.list()`), refreshed on `mcp.status.changed`. That is the delivered trigger: `mcp.tools.changed` is an ephemeral host-internal event absent from `EventManifest.ServerDefinitions`, so a handler for it never runs. A renamed or enterprise instance of a guarded product is therefore still matched, including a server that connects mid-session. Widening is one-way by design: removing a server adds nothing, and the static token list stays the floor. Tool names are model-supplied text, and this is a live-system rail.
69
71
 
70
72
  ### 6. Settings Tamper Guard
71
73
  - Prevents the agent from weakening its own permission gates **via shell or edit tools**:
@@ -85,16 +87,20 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
85
87
  - The agent is prompted to create a feature branch first (`git switch -c feat/my-feature`).
86
88
 
87
89
  ### 8. Workspace Boundary Guard
88
- - File modification tools (`edit`, `write`, `apply_patch`) and recognized shell mutations are validated so detected targets cannot escape the current workspace root. The recognized shell mutations are redirection `>`, `>>`, `&>`, `>&`, `tee`, `sed -i`, `cp`/`mv`/`ln`, `touch`, `mkdir`, `rm`, `truncate`, `dd of=`, `git apply`, and `git am`. The check covers `../` traversal, symlinks, absolute paths, and shell expansions (`~`, `~user`, `$HOME`). Unresolvable `$VARIABLE` references fail closed. This is command-level enforcement, not filesystem sandboxing. Arbitrary programs and dynamically computed paths require the OS isolation described under Known Limits.
89
- - **Unresolvable variable cause attribution:** A target containing an unresolvable `$VARIABLE` reference is indeterminate without executing shell semantics. Examples are `mkdir -p $D/sub` and `> $CACHE/out.txt`. The guard therefore treats it as outside the workspace. The block message says so ("contains an unresolvable variable reference … use a literal workspace-relative path") instead of showing the generic escape message. Deterministically expandable forms (`~`, `~user`, `$HOME`, `${HOME}`) keep the generic message. Cache-path and other literal destinations inside the workspace are unaffected.
90
+ - File modification tools (`edit`, `write`, `apply_patch`) and recognized shell mutations are validated so detected targets cannot escape the current workspace root. The recognized shell mutations are redirection `>`, `>>`, `&>`, `>&`, `tee`, `sed -i`, `cp`/`mv`/`ln`, `touch`, `mkdir`, `rm`, `truncate`, `dd of=`, `git apply`, and `git am`. The check covers `../` traversal, symlinks, absolute paths, and shell expansions (`~`, `~user`, `$HOME`). Unresolvable `$VARIABLE` references fail closed. This is command-level enforcement, not filesystem sandboxing. Arbitrary programs and dynamically computed paths require the OS isolation described under Known Limits. Another user's home cannot be read portably, so `~user` is guessed as the name joined onto this home's parent. A workspace rooted at that parent therefore contains the guess. Keep the workspace below the home directory, which is the usual layout, and the guess stays outside it.
91
+ - **Unresolvable variable cause attribution:** A target containing an unresolvable `$VARIABLE` reference is indeterminate without executing shell semantics. Examples are `mkdir -p $D/sub` and `> $CACHE/out.txt`. The guard therefore treats it as outside the workspace. The block message says so ("contains an unresolvable variable reference … use a literal workspace-relative path") instead of showing the generic escape message. Deterministically expandable forms (`~`, `~user`, `$HOME`, `${HOME}`) keep the generic message. Cache-path and other literal destinations inside the workspace are unaffected. A git repository selection is judged the same way: `git -C $OTHER_REPO commit` stays a boundary block even when the shell's own workspace is a repository. The same-repository exemption that admits linked worktrees resolves the nearest existing ancestor, so an indeterminate value there would otherwise read as the workspace's own root, and the write would look local. Known limit: another user's home is not readable portably, so `~user` joins the name onto this home's parent. A workspace rooted at that parent therefore contains the guessed path, and a `~user`-relative mutation there is judged local. Keep the workspace root below the home directory, which is the normal layout, and this guess lands outside it.
90
92
  - **Scratch-directory cause attribution:** `/tmp/opencode` is the environment's designated agent scratch space, but the workspace boundary is workspace-absolute and does not cover it. Writes there stay blocked. The block message names the directory and points to the remedy instead of reading as a guard bug. The remedy is an untracked scratch file inside the workspace, for example `.tmp-notes.md`. The enforcement decision is unchanged.
91
93
  - **Symlink-alias acceptance:** A target whose lexical path falls outside the workspace root but whose canonical resolution is inside it is accepted. One example is `/home/<user>/...` on hosts where `/home` is a symlink into `/var/home` and the workspace root lives under `/var/home`. A target stays blocked whenever the canonical resolution is outside the canonicalized root. That includes symlinks inside the workspace that point outside, and not-yet-created files under them (nearest existing ancestor). When canonicalization is indeterminate, the lexical verdict stands (fail closed). A path is outside the workspace only when both its lexical and its canonical resolution are outside.
92
94
  - `tee` flag parsing generically skips option flags (`-a`, `--append`, `-ai`, `--`) and validates all specified target files against the boundary.
93
95
  - Embedded non-shell comparisons are not mutations. A bare `>` whose target is purely numeric (`WHERE count > 5`, awk `$3 > 100`) and `>=` or `==` operators are treated as comparison operands, not redirects. Real redirects are unaffected (`> src/a.ts`, `>>`, `&>`, `>&`, and fd targets like `/dev/null` still follow their existing rules).
94
96
  - `mv` sources are validated too. Moving a file from outside the workspace is blocked, since `mv` mutates the source. The same applies to moving a protected settings or plugin file to an innocuous name.
95
97
  - External repository git writes (`git -C /other-repo commit`, `git --git-dir=/other-repo/.git push`, `GIT_DIR=/other-repo/.git git push`, `GIT_WORK_TREE=...`) are also confined to the workspace.
98
+ - A **linked worktree of the same repository** is not foreign state. The shell's `workdir` legitimately lives there when a session works in an isolated checkout, so git mutations and pushes there are judged rather than boundary-blocked. A genuinely different repository keeps the boundary block. Git invocation parsing takes the shell's `workdir` as its base directory, so branch, mergeability, and protected-branch checks judge the tree the command actually runs in rather than the session-bound checkout. Recognized shell mutations are validated against that same tree, so a relative target in a linked worktree is judged where it actually lands. A `workdir` that is a genuinely different repository cannot be judged at all, because the command runs there while its relative targets would be resolved against the workspace root. Any recognized mutation or git write in that workdir is therefore blocked as a workspace escape, and only read-only commands there are allowed.
99
+ - **An in-command directory change moves the tree later segments are judged in.** `planShellSegments` walks `cd`, `pushd`, and `popd` and resolves a root per segment. Without it, `cd /other-repo && git commit` read as a commit in the session's own checkout, which both allowed a foreign write and denied nothing. The resolved root drives git parsing, shell-mutation validation, redirect snapshots, and the protected-branch check, so `cd nested && echo x > artifact.txt` is judged where the file lands. A `cd` inside a grouping subshell is scoped to it, and the subshell's own directory stack goes with it, so `( cd /other && ls ) && rm -rf dist` stays a workspace removal. A `cd` inside a conditional or loop body (`then`, `else`, `do`, a `case` arm, a brace group) is not scoped, because that body runs in the current shell. Which body runs depends on a condition the walk cannot evaluate, so if any body changes directory the later directory is undetermined and a mutation after the construct fails closed. A body that does not change directory leaves the tree intact. A `case` arm counts wherever the `case` keyword sits, including after `then`, `else`, `do`, or `elif`, and a pattern's own `)` closes no group. A wrapper that changes directory (`env -C`, `sudo -D`, `--chdir`) is followed for its own command only. A newline separates commands exactly like `;`, so a newline-separated `cd` moves the tree of the lines that follow. Planning runs on the raw command text rather than a whitespace-flattened copy, because flattening erased that separator. A heredoc body stays inside the segment that introduces it, and it is code only when the command reading it executes it. Only deterministically expandable targets are resolved (`~`, `~user`, `~/…`, `$HOME`, `${HOME}`). An indeterminate target (`cd $DIR`, `cd -`, a bare or rotating `pushd`, `pushd +N`/`-N`/`-n`, a rotating `popd +N`/`-N`, a `popd` with no tracked push) followed by a mutation fails closed, because relative targets there cannot be validated against the tree the command would actually change. Reads stay allowed.
100
+ - **A nested interpreter payload is judged against its own declared tree.** `bash -c 'cd /other-repo && git commit'` is one shell word to the outer shell, so its nested `cd` and mutation were invisible to the top-level walk. Each payload is re-planned, recursively, so a launcher inside a launcher (`bash -c "bash -c 'cd /other && git commit'"`) is judged at its innermost tree. A payload's `git -C <repo>` is checked against the workspace like any top-level git write. A working directory counts only where one actually applies: a Node spawn-family `cwd` (`execSync`, `spawn`, `fork`) or `process.chdir`. An unrelated `{cwd: …}` object key names none, so it cannot foreign-flag a payload that writes in the workspace, and `regex.exec(str, {cwd})` is not mistaken for a spawned process. A payload that mutates outside the workspace, and is not a linked worktree of it, is refused. A non-literal working directory (`{cwd: process.env.OTHER}`) fails closed for a mutation, matching the shell rule. A payload launched from an indeterminate `cd` fails closed for a mutation too, instead of being assumed to run in the workspace root. A heredoc body is data for the command that consumes it, so it is scanned as code only when that command executes it (`bash <<'EOF' … EOF`, `source /dev/stdin <<'EOF'`, or a body piped into a command). A pipe hands the body to *every* stage, so a filter followed by a shell (`| grep x | sh`) is code too. `|&` is a pipe as well. `||` is not: it runs the right side only on failure, so it delivers no body. A stage counts as data only when its command word is a known filter (`cat`, `grep`, `jq`, `column`, and similar). Programs that can run a command named in their own arguments are not filters, so `sed` (`sed 'e'`), `awk` (`awk '{system($0)}'`), and `sort` (`sort --compress-program`) are scanned as code. Any other downstream command, including an uncommon shell such as `ash`, is scanned as code, because naming only the common launchers let those bodies run unread. A body feeding `cat` or another file writer is prose, so documentation quoting a blocked command shape stays writable, and that applies to the interpreter, shell-mutation, and secret-read scans alike. One header line may introduce several bodies, and an unterminated body is scanned to end of input because the shell still runs it. Heredocs are located with the splitter's own quote tracking, so a quoted `"<<"` opens no body and cannot mask the code after it. Only a leading `eval` (optionally behind `builtin`/`command`/`time` or a `NAME=value` assignment) forwards its body as shell text; a prose mention of `eval` does not. Known limits: only the Node forms are modelled, so another interpreter's own directory change (`python -c "os.chdir(x); open('a','w')"`) is not detected, though its absolute write targets are still caught by the interpreter scanner. A working directory whose base is a literal but whose tail is computed (`{cwd: path.join('<workspace>', 'sub')}`) is reported as indeterminate and blocked, which is conservative rather than an escape.
101
+ - **A repository claim is only an assignment.** `GIT_DIR=`/`GIT_WORK_TREE=`/`GIT_COMMON_DIR=` select a repository only when consumed as an assignment before the command word, including through `env`/`sudo`/`timeout` wrappers. A value that merely appears inside an argument is message text, not a claim, so `git commit -m GIT_DIR=/repo` is parsed as a normal commit in the shell's checkout. A selector expands through the shell's own forms, so `git -C ~/repo` names the home-directory repository and `~user/path` keeps the path after the home, and the attached short form (`git -C/path`) names one too. A selector this walk cannot expand keeps its literal text, so an unresolvable `$VARIABLE` still fails closed instead of resolving to the workspace root.
96
102
  - The boundary has **no override**: `WORKFLOW_GUARD_ALLOW_LIVE=1` covers live-system commands only and never weakens the workspace confinement.
97
- - **The boundary is bound once, then only the host may widen it.** A session adopts its workspace from its own first tool-call context. That context is a call-argument path, or the host-provided per-call worktree. After that, a path outside the boundary is foreign state. The boundary policy refuses mutations there, and the path never re-anchors the session. Only an explicit host-declared per-call worktree widens a bound session. Worktree isolation and relay flows set it deliberately. A subagent is bound by the same rule through its nearest bound ancestor. With no direct binding of its own, it already inherits the parent's workspace for policy decisions. Its first foreign read is therefore refused as a widening rather than adopted as a first bind. Every granted widening is journaled (`workspace.binding` / `workspace_rebind_host_worktree`). Every refused one is journaled with the read-or-mutation label (`workspace.binding` / `workspace_rebind_refused`). This closes a defect where one allowed read of another repository moved the entire write boundary there, because `external_directory` defaults to allow.
103
+ - **The boundary is bound once, then only the host may widen it.** A session adopts its workspace from its own first tool-call context. That context is a call-argument path, or the host-provided per-call worktree. After that, a path outside the boundary is foreign state. The boundary policy refuses mutations there, and the path never re-anchors the session. Only a host statement widens a bound session: an explicit per-call worktree, or a host session move. `session_move` relocates a session between checkouts, and the host announces it as V1 `session.updated` or as V2 `session.moved`. The guard rebinds the session to the moved directory and journals `workspace.binding` / `workspace_rebind_session_move`. Only a genuine move counts. An announced `session.moved` counts by itself, and so does an update naming a directory different from the last one the host named for that session. A routine title or cost update names the same directory and changes nothing, because a session bound to a linked worktree by its own tool-call `workdir` legitimately differs from the session's creation directory. On OpenCode 1.x there is no move event, so a first observation falls back to the repository itself: a directory that resolves to a DIFFERENT repository is a move and rebinds, while one that resolves to a linked worktree of the bound repository only records the baseline. Worktree isolation and relay flows set it deliberately. A subagent is bound by the same rule through its nearest bound ancestor. With no direct binding of its own, it already inherits the parent's workspace for policy decisions. Its first foreign read is therefore refused as a widening rather than adopted as a first bind. Every granted widening is journaled (`workspace.binding` / `workspace_rebind_host_worktree`). Every refused one is journaled with the read-or-mutation label (`workspace.binding` / `workspace_rebind_refused`). This closes a defect where one allowed read of another repository moved the entire write boundary there, because `external_directory` defaults to allow.
98
104
 
99
105
  ### 9. Script-Laundering Guard
100
106
  - Content written via `edit`, `write`, or `apply_patch` is scanned for destructive CLI commands.
@@ -106,7 +112,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
106
112
  - The verification timeout defaults to 30 seconds. Raise it per project with `verifyTimeoutMs` in `.opencode/workflow-guard.json`, or per environment with `WORKFLOW_GUARD_VERIFY_TIMEOUT_MS`, which wins over project config. Non-positive values are ignored and fall back to the default. Raise the timeout for suites that legitimately exceed the default, for example this repository's own unit suite.
107
113
  - A timeout is reported as `verification_timed_out`, not `verification_failed`. Both block finalization, because neither produced a passing result. They differ in what the agent is told: a timeout says the result is unknown and names the limit and the remedy, while a failure names the failing output. Calling a timeout a failure sent agents looking for an assertion that never ran, and the fix for a timeout is a longer limit rather than a code change.
108
114
  - Project `postEditValidators` can run faster targeted checks after a direct file edit actually changes the target. Each entry accepts a workspace-relative glob `pattern` with `*`, `**`, and `?` wildcards. Brace expansion and character classes are not supported. Each entry also accepts a non-empty `command` and an optional positive `timeoutMs`. Invalid entries are reported as advisory feedback rather than silently ignored. Matching commands run from the workspace root with the same scrubbed verification environment. Validator failures and timeouts are appended to the edit result as advisory feedback. They do not replace the blocking whole-project finalization verification. Multi-file `apply_patch` calls validate every changed target.
109
- - With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list. Subagents and generated continuation prompts do not replace them. At `session.idle`, the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
115
+ - With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list. Subagents and generated continuation prompts do not replace them. At the idle trigger (`session.status` with `status.type === "idle"`), the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
110
116
  - Automatic continuation remains synthetic. By default, an eligible root session with unfinished owned todos briefly waits for OpenCode's native asynchronous title generation before that continuation is sent. Set `titleSettleWorkaround: false` to opt out. The bounded wait never rewrites titles or provider and model parameters.
111
117
  - **Git State & Snapshot Binding:** Verification evidence records the current git commit hash (`git rev-parse HEAD`) and working tree status. If unverified edits or index modifications occur after verification, fresh verification is enforced.
112
118
  - **Untracked Special Entries:** The worktree fingerprint binds untracked symlinks by their target string. It follows git semantics, so re-pointing a link moves the fingerprint and changing the link target's contents does not. FIFOs and sockets are bound by path and mode. Unopenable entries are skipped with a `worktree-fingerprint` warning in the audit trail instead of voiding the whole fingerprint. One unreadable scratch symlink or `chmod 000` file can no longer make review or verification evidence permanently unfresh. The PR preflight names the actual cause when an approval cannot be matched: no fingerprint binding, fingerprint mismatch, or uncomputable fingerprint. It no longer shows the generic "review approval required". Documented tradeoff: the contents of a skipped entry are excluded from the fingerprint. A file that stays unreadable to the guard's user across the entire window can therefore change contents without moving the fingerprint. Any readability transition is fingerprint-visible (fail-closed), and each skip is named in the durable audit trail.
@@ -124,7 +130,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
124
130
  - This prevents the agent from carrying live credentials by default. The user can still grant access through OpenCode's own permission system if a task genuinely needs it.
125
131
 
126
132
  ### 13. Command-Channel Audit
127
- - User-triggered slash commands (`command.executed` events) are journaled to the audit trail so agents cannot perform hidden work through a user-facing side channel.
133
+ - User-triggered slash commands (`command.executed` events) are journaled to the audit trail so agents cannot perform hidden work through a user-facing side channel. **This audit is V1 only.** On V2 the type sits in `EventManifest.Definitions` but is absent from `ServerDefinitions`, so it is never delivered and no V2 handler can journal it. No delivered V2 event carries a slash-command invocation, so the audit is not re-routed there: `command.updated` reports registry config changes, not invocations.
128
134
 
129
135
  ### 14. Audit Trail & Permission Journaling
130
136
  - Every block and allow decision is appended to `~/.local/state/opencode/workflow-guard/workflow-guard.jsonl`, with XDG_STATE_HOME respected. Each record carries a timestamp, session id, tool name, decision, subagent and parent session attribution, and reason. This is a durable record.
@@ -132,6 +138,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
132
138
  - On OpenCode 2, the V2 plugin also *decides* at that seat. `ctx.permission.hook("evaluate")` maps the host's normalized `action` and `resources` back onto the policy core and sets `effect = "deny"` with an attributable message, so the tool never starts. Shell resources feed the shell, git, destructive, and workspace policies. `read` resources feed the secret policy. `edit`, `write`, `patch`, and `apply_patch` resources feed the boundary, tamper, secret, protected-branch, and todo policies. Guarded MCP servers (`GitHub`, `Azure DevOps`) feed the MCP policy.
133
139
  - The seat can only tighten. An explicit configured `deny` is final and never reaches the hook. An unmapped host action (`external_directory`, `glob`, `grep`, `webfetch`, `websearch`, `question`, `subagent`) is left to host policy. If the seat itself throws, it fails closed with a deny instead of becoming a pass.
134
140
  - **Only a finding denies; an undecidable simulation abstains.** The seat always evaluates the policy core with `simulate: true`, which skips the remote GitHub/Azure PR probe on purpose. The core reports `needs_approval` (`remote_state_unchecked`) when it cannot decide without that probe. Collapsing that status into a denial blocked every non-protected `git push`, so the seat now abstains: it leaves the host's configured effect and message untouched and defers the real decision to `execute.before`, which runs the probe. The abstention is journaled with `decision: "abstain"`, `reason: "deferred_to_execute_before"`, and the preserved `needs_approval` decision, so a skipped probe stays visible instead of reading as a plain allow. `guard_why` keeps reporting `needs_approval`; this is a change to the seat, not to the policy core.
141
+ - **A cwd-dependent command is deferred, not judged.** The seat sees normalized `action`/`resources`, never the shell tool's `workdir`. For a bare `git push`, `git commit`, other history-mutating git writes, branch creation, and PR creation, the repository is therefore an assumption rather than a fact. The session binding may be the primary checkout while the command targets a linked worktree. Both directions of that verdict are unreliable: a deny may be the protected branch of the wrong tree, and an allow may be a feature checkout that hides a push to a protected destination. Those commands therefore abstain with code `repository_unidentified`, and `execute.before` resolves the workdir and enforces. Two cases stay decisive: an explicit `-C`/`--git-dir`/`--work-tree`/`GIT_DIR` names the repository in the command text, and a push whose destination is `main` or `master` is a finding in every checkout. A configured `protectedBranches` entry is not that case: the seat recognizes the built-in pair only, so a push to a configured protected branch abstains and `execute.before` judges it against the resolved workdir. A trailing `-C`/`--git-dir` with no value names no repository but still carries the flag, so it counts as an explicit claim and both seats agree the command is unclassifiable. A block from any other policy (`cat .env`, a destructive live command) is a finding about the command text itself, so it keeps its denial even in the same compound command.
135
142
  - `execute.before` is kept as the second, last-resort seat. It sees raw tool input, so todo lifecycle, stale-write observations, and post-edit snapshots stay there. Its thrown message carries the specific remediation text to the model.
136
143
  - `client.app.log()` complements the on-disk trail with in-app logs.
137
144
 
@@ -148,8 +155,9 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
148
155
  - Preserves policy-relevant context across session compactions without assigning, prioritizing, or sequencing tasks for the model.
149
156
 
150
157
  ### 16. TUI Visual Feedback & Static Status Badge
151
- - Emits real-time warning toasts to the user interface via `tui.showToast` whenever a guard policy blocks a tool call.
152
- - The companion TUI plugin (`workflow-guard-ui.ts`) keeps `Workflow Guard 🛡️` static in the prompt bar. Block details remain in the warning toast rather than changing badge content or color.
158
+ - On OpenCode 1.x, emits real-time warning toasts to the user interface via `tui.showToast` whenever a guard policy blocks a tool call.
159
+ - On OpenCode 2.x there is no client-side `tui.showToast` on the plugin's client shim, so block paths do not attempt a toast. The thrown tool-hook error carries the reason to the model, which is what the model actually acts on. A V2 session therefore has no toast surface, and the reason arrives as the call's failure message.
160
+ - The companion TUI plugin (`workflow-guard-ui.ts`) keeps `Workflow Guard 🛡️` static in the prompt bar. Block details remain in the warning toast rather than changing badge content or color. That companion is installed separately and is not part of the V2 server entrypoint.
153
161
 
154
162
  ### 17. Secret-File READ Block & Safe Schema Masking
155
163
  - Blocks reading sensitive credential files through the `read` tool, shell commands, or inline interpreter payloads. The credential patterns are `.env*`, `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*`, `*kubeconfig*`, `*credentials*.json`, and `service-account*.json`. The shell commands are `cat`, `less`, `more`, `grep`, `awk`, `head`, `tail`, and `base64`. Interpreter payload examples are `python3 -c "open('.env')"` and `bash -c 'cat id_rsa'`.
@@ -158,9 +166,12 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
158
166
 
159
167
  ### 18. Interpreter Inline Evasion Scanner
160
168
  - Decodes and inspects inline interpreter payloads to prevent smuggling live destructive commands past shell pattern checks. Smuggled content includes settings tampering, secret reads, and out-of-workspace writes. The inspected forms are `python -c`, `node -e`, `perl -e`, `ruby -e`, `osascript -e`, `bash -c`, `sh -c`, `zsh -c`, `powershell -enc`, and `echo <base64> | base64 -d | sh`.
169
+ - A payload is read as one whole shell word, not as the text between the first pair of quotes. Concatenated spans are joined and quote escapes are decoded, because that is what one shell argument is. The `'\''` idiom closes and reopens a single-quoted span, so reading only up to the first closing quote truncated `bash -c 'bash -c '\''cd /other && rm -rf x'\'''` to a bare `bash -c `, and the real nested command escaped the scan. The decoded body is re-planned at its own tree. Nesting past the modelled depth is refused rather than skipped.
161
170
 
162
171
  ### 19. Conflict-Free Pre-Flight Guard
163
172
  - Evaluates `git merge-tree` against the base branch (`origin/main`, `origin/master`, `main`) before PR creation or final task handoff, ensuring changes can be merged cleanly without conflicts.
173
+ - **A PR is judged against the branch it publishes, not against the caller's checkout.** The preflight reads `gh --head`/`-H` and `az --source-branch` to learn the source branch. It then resolves that branch to the checkout that holds it with `git worktree list`, so a session sitting in the primary checkout still judges a branch reviewed in a linked worktree. Accuracy requires the caller to supply the PR source branch and the linked worktree: a bare PR command with neither falls back to the invoking checkout.
174
+ - The merge check takes the resolved ref, so a branch that is not checked out anywhere locally is judged by its own ref instead of silently checking `HEAD`. Resolution is limited to what the checkout can see: a ref that does not exist in the repository at all produces no merge base against any base candidate, so the check reports no conflict rather than guessing at one.
164
175
 
165
176
  ### 20. Merged Branch & Base Freshness Guard
166
177
  - Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
@@ -195,7 +206,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
195
206
  - **A failed research attempt never accrues debt.** Only non-research failures count. Otherwise a flaky or rate-limited search would deepen the block the agent is trying to escape. The floor would then deadlock a session that followed its own advice.
196
207
  - **Research recognition:** the builtin names (`websearch`, `webfetch`, and common aliases) match outright. MCP sources match on documented server names, so `mcp__context7__*`, `mcp__deepwiki__*`, `mcp__tavily__*`, `mcp__brave__*`, and similar docs servers count. A guard inspection tool or a generic file reader cannot clear the debt by name accident. Add an unrecognised server with `researchTools` in project config, or `WORKFLOW_GUARD_RESEARCH_TOOLS` (comma-separated name fragments).
197
208
  - Prior art: iterative grounding / De-Hallucinator (arXiv 2401.01701), knowledge-lookup gates for agent writes, and deterministic supersession (STALE, arXiv 2605.06527). Failure-mode context: false/illusory completion (agents self-report done) and temporal obsolescence of training data.
198
- - Enabled **by default** to force the failover. Disable with `.opencode/workflow-guard.json` (`requireLiveDocs: false`) or `WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0`. Raise or lower the trigger with `liveDocFailureThreshold` or `WORKFLOW_GUARD_LIVE_DOC_THRESHOLD`. An unparseable, zero, or negative threshold falls back to the default of 3, so a typo cannot silently disable the floor. Decisions are journaled (`reason: live_docs_research_floor`) under both entrypoints. On OpenCode 1.x block feedback also reaches `client.app.log` at warn level. OpenCode 2 has no server-side log API, so V2 journals the decision and toasts instead.
209
+ - Enabled **by default** to force the failover. Disable with `.opencode/workflow-guard.json` (`requireLiveDocs: false`) or `WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0`. Raise or lower the trigger with `liveDocFailureThreshold` or `WORKFLOW_GUARD_LIVE_DOC_THRESHOLD`. An unparseable, zero, or negative threshold falls back to the default of 3, so a typo cannot silently disable the floor. Decisions are journaled (`reason: live_docs_research_floor`) under both entrypoints. On OpenCode 1.x block feedback also reaches `client.app.log` at warn level and a warning toast. OpenCode 2 has no server-side log API and no client toast, so V2 journals the decision and throws it as the call's failure message.
199
210
  - **Known limit:** Code Mode `execute` is not gated, because the nested tools it calls do not pass through guard hooks. A session that reaches the floor can still mutate through that path, the same way it can through an unmodelled indirection described under Known Limits.
200
211
  - **Prerequisite:** the acting agent must be allowed to call a research tool. An agent whose toolset denies `websearch`/`webfetch` and exposes no MCP docs server cannot satisfy the floor. Grant one, or the agent will (correctly) stall on the block. This is an opt-out-able default for that reason.
201
212
 
package/docs/testing.md CHANGED
@@ -53,8 +53,10 @@ npm run test:all
53
53
  | **Completion Claims & Tool Honesty** | Verifies the claims-vs-evidence detector (missing/failing/stale/fresh states), `experimental.text.complete` journaling, and `tool.definition` enrichment idempotence. |
54
54
  | **Compaction & TUI Feedback** | Verifies active task injection in `experimental.session.compacting`, the static TUI badge, guard block warning toasts, and clean error propagation. |
55
55
  | **Runtime Instance Isolation** | Verifies concurrent plugin instances keep workspace root and SDK client state isolated via `runWithRuntimeState`. |
56
- | **V2 Permission Seat Abstention** | Verifies the simulated seat denies a real finding, abstains on `needs_approval` (`remote_state_unchecked`) instead of denying, never upgrades a configured `ask`, and journals the abstention as `deferred_to_execute_before` with the preserved decision. |
56
+ | **V2 Permission Seat Abstention** | Verifies the simulated seat denies a real finding, abstains on a decision it cannot make instead of denying, never upgrades a configured `ask`, and journals the abstention as `deferred_to_execute_before` with the preserved decision. Covers both the remote-probe deferral (`remote_state_unchecked`) and the cwd-dependent deferral (`repository_unidentified`). |
57
+ | **Worktree-Aware Git Verdicts** | Verifies a cwd-dependent push or PR creation abstains with `repository_unidentified` instead of being judged against the session-bound checkout, that a non-checkout finding (`cat .env`) in the same compound command is still denied, that an explicit `-C` still runs the core, that a push to `main`/`master` is denied regardless of working directory while a push to a configured `protectedBranches` destination defers, that an explicitly named protected checkout is denied, that an explicitly named repository outside the workspace stays a boundary finding, and that a journaled deferral carries its own code. Also verifies that a `GIT_DIR=` value inside a commit message is not read as a repository claim while a real assignment, including through `env`/`sudo`/`timeout` wrappers, is, that an unresolvable repository selection (`git -C $OTHER_REPO`) stays a boundary block even inside a git workspace, and that a host session move rebinds only on a genuine move rather than on every `session.updated`. Also verifies that a relative shell mutation or git write in a `workdir` outside the workspace is blocked while a read-only command there, and a mutation in a linked worktree of the same repository, are allowed. Also verifies in-command `cd` tracking: `cd <foreign> && git commit`, `cd <foreign> && echo x > f` are blocked while a `cd` there followed by a read stays allowed, a newline-separated `cd <foreign>` moves the tree of the lines that follow for a removal, a git write, and a redirect, while its read-only form stays allowed, a heredoc shell body that cds into a foreign repository is blocked for a removal and a git write, including when the body is unterminated, read by `source`, or piped into a shell or an uncommon executor such as `ash`, `busybox sh`, `make -f -`, or `xargs sh -c`, while a body piped into a known filter such as `grep` stays prose, a body reaching a shell through a later pipeline stage (`| grep x | sh`, `| cat | bash`, `| tee /dev/null | sh`), a body delivered through `|&` to a shell (`|& sh`, `|& ash`) while `|&` into a filter and `||` stay prose, is blocked, a body consumed by a program that can run a command named in its own arguments (`sed 'e'`, `sed -n '1e echo hi'`, `awk '{system($0)}'`, `sort --compress-program`) is blocked, a data heredoc quoting a blocked command shape or a bare `rm` line stays writable and a second body on the same header line stays data, a quoted `"<<"` does not mask a later interpreter payload, a tilde repository selector (`git -C ~/repo`, `git -C~/repo`, `~root/path`) expands through the shell instead of naming a literal `~` directory inside the workspace, an assignment-prefixed `eval` moves the tree of later segments while a harmless one does not, an in-command `cd` into a workspace subdirectory is judged where it lands rather than as a foreign tree, an in-command `cd` into a linked worktree is allowed, an indeterminate `cd $VAR` before a write fails closed, `env -C <foreign> git commit` is blocked, a `cd` inside a grouping subshell does not move a later segment's tree, and nested payloads are judged by their own tree (`bash -c 'cd <foreign> && git commit'` blocked, `execSync(..., {cwd: '<foreign>'})` blocked, `process.chdir('<foreign>')` before a write blocked, an unrelated `{cwd: …}` object key not treated as a working directory, `regex.exec(str, {cwd})` not treated as a spawn, a non-literal `{cwd: process.env.X}` failing closed, a payload launched from an indeterminate `cd` failing closed while its read-only form stays allowed, a doubly nested launcher re-planned to its innermost tree, a payload `git -C` into a foreign repository blocked while a linked worktree stays allowed, a spawn `cwd` after a nested call still read as the working directory, a `pushd` inside a grouping subshell not leaking into a later outer `popd`, a `pushd +N`/`-n` rotation failing closed before a mutation while its read-only form stays allowed, a `cd` inside a `then`/`do`/`case`/brace-group body moving the tree of later segments, a `cd ~user` not read as a workspace-relative path, and a `--head` token inside a quoted PR body not read as the source branch, in-workspace and linked-worktree payloads allowed). |
57
58
  | **V2 Todo Capability** | Verifies the `todowrite` registry probe (`available` / `unavailable` / `unknown`), that an empty history gates only when planning is possible, the once-per-session fail-open warning and audit record, and `todoCapability` in `guard_status`. |
59
+ | **V2 Delivered Event Handlers** | Verifies the deny contract the adapter provides (a rejected `execute.before` surfaces as a tool error carrying the reason), that a tool error recorded from `execute.after` accrues failure debt and raises the circuit-breaker path while a success clears it, that `session.execution.interrupted` with reason `user` suspends continuation while a host-lifecycle reason does not, that an idle `session.status` continues the session while `busy` does not, that the deprecated `session.idle` still continues it because the published idle type is not settled, that both idle types for one transition still yield a single continuation prompt, and that a delivered `mcp.status.changed` refreshes the guarded inventory with additive-only widening. A structural meta-test scans `src/lib/v2-plugin.ts` for every `event.type === "<literal>"` comparison and asserts each literal exists in `EventManifest.ServerDefinitions`, so a handler built on a type the host never delivers fails the suite instead of shipping dead. It also asserts the scan matched a nonzero number of literals, so a broken regex cannot pass vacuously. |
58
60
  | **Per-Task Completion Evidence** | Verifies a task cannot move to `completed` without a mutation in its own window, that one mutation cannot vouch for several completions, that staggered windows reject a claim whose window saw no work, that mutations before a task became active never count, that `[read-only]` completes freely and is journaled, that the marker can be appended to an already-planned task without tripping the no-silent-deletion rule, that duplicate content cannot hide a new completion, and that `guard_why` reports the same verdict while moving no window. |
59
61
  | **Static Policy Posture** | Verifies the emitted posture is deny-only, emits both host envelopes (`experimental.policies`/`permission` for 2.0.x, top-level `policies`/`tool.use` for 2.1.x), requires both to reach the same verdict for every probed resource, and matches the intended commands under a replica of the host's whole-string glob matcher, including the compound-command evasion it does not cover. |
60
62
  | **Dogfood Gate (Policy 27)** | Verifies the gate stays off until a repository opts in, that PR creation is blocked without fresh evidence and allowed with it, that a tracked edit and a new commit each invalidate the record with a named cause, that a HOLD is never durable and removes that host's cached pass so a restart cannot re-satisfy the gate, that a same-millisecond HOLD and PASS resolve to the HOLD in the durable cache so a restart stays unsatisfied, that a later pass on another host cannot resurrect an evicted one, that a HOLD in a cold process or in another workspace leaves unrelated hosts' passes intact, that an out-of-matrix HOLD is not reported as the blocking cause, that a `pass` is refused when any check is skipped or the check list is empty, that a HOLD must name a taxonomy class, that an actor session cannot record its own verdict, that the deploy matrix needs one fresh pass per host, that a newer HOLD on a host reopens the gate, that the PR audit names every covered host, and that per-host retention cannot evict another host's pass. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.19.0",
3
+ "version": "1.19.2",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared guard entry surface, host-agnostic.
3
+ *
4
+ * These three helpers used to live in `workflow-guard.ts`, the V1 entrypoint.
5
+ * `lib/v2-plugin.ts` imported them from there, which created an import cycle:
6
+ * `workflow-guard.ts` imports `WorkflowGuardV2` from `lib/v2-plugin.ts`, so the
7
+ * cycle dragged the whole V1 file, `@opencode-ai/plugin`, and its
8
+ * `@opencode-ai/sdk` subtree into a V2 host.
9
+ *
10
+ * They live here so both entrypoints import a leaf module instead. This is a
11
+ * pure move: behavior, audit fields, and return values are unchanged.
12
+ * `workflow-guard.ts` keeps re-exporting them, because the test harness imports
13
+ * that public surface.
14
+ */
15
+
16
+ import { guardToolCallImpl } from "./guard-dispatcher.ts";
17
+ import { getSdkClient, runWithRuntimeState, lastMutationTimestamp, lastVerify } from "./state.ts";
18
+ import { logDecision } from "./audit.ts";
19
+ import { extractRecordTargetPath } from "./utils.ts";
20
+ import type { PolicyDecision } from "./types.ts";
21
+ import { EDIT_TOOL_NAMES } from "../policies/todo.ts";
22
+
23
+ /**
24
+ * Public guard entry point. Audits every decision (block or allow) to a
25
+ * durable JSONL file before returning.
26
+ */
27
+ export async function guardToolDecision(
28
+ toolName: string,
29
+ input: unknown,
30
+ context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
31
+ ): Promise<PolicyDecision> {
32
+ const customRoot = context?.worktree || context?.directory;
33
+ const runImpl = () => guardToolCallImpl(toolName, input, context);
34
+ return customRoot
35
+ ? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
36
+ : await runImpl();
37
+ }
38
+
39
+ /** Convenience wrapper: the block reason, or `undefined` when allowed. */
40
+ export async function guardToolCall(
41
+ toolName: string,
42
+ input: unknown,
43
+ context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
44
+ ): Promise<string | undefined> {
45
+ const decision = await guardToolDecision(toolName, input, context);
46
+ const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
47
+ const isMutation = EDIT_TOOL_NAMES.has(toolName);
48
+ const targetPath = extractRecordTargetPath(input);
49
+ logDecision(toolName, input, context, decision, {
50
+ mutation: isMutation,
51
+ targetPath,
52
+ allowLive,
53
+ verification: lastVerify
54
+ ? {
55
+ command: lastVerify.command,
56
+ passed: lastVerify.passed,
57
+ fresh: lastVerify.timestamp >= lastMutationTimestamp,
58
+ durationMs: lastVerify.durationMs,
59
+ }
60
+ : undefined,
61
+ });
62
+ return decision.status === "allowed" ? undefined : decision.message;
63
+ }
64
+
65
+ /**
66
+ * Build the bounded compaction context from the operational state plus the
67
+ * priority blocks, capping the total at `maxChars` (default 8,000).
68
+ */
69
+ export function buildCompactionContext(operationalState: string, priorityBlocks: string[], maxChars = 8_000): string {
70
+ if (!Number.isFinite(maxChars) || maxChars < 0 || !Number.isInteger(maxChars)) {
71
+ throw new RangeError("maxChars must be a non-negative finite integer");
72
+ }
73
+ const truncate = (value: string, limit: number): string => {
74
+ let end = Math.min(value.length, limit);
75
+ if (end > 0 && end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
76
+ return value.slice(0, end);
77
+ };
78
+ let context = truncate(operationalState, maxChars);
79
+ for (const block of priorityBlocks) {
80
+ const remaining = maxChars - context.length - 2;
81
+ if (remaining <= 0) break;
82
+ context += `\n\n${truncate(block, remaining)}`;
83
+ }
84
+ return context;
85
+ }
@@ -24,14 +24,16 @@ import {
24
24
  import {
25
25
  dynamicShellSyntaxIn,
26
26
  extractCommands,
27
+ isLeadingEvalSegment,
28
+ maskDataHeredocBodies,
27
29
  normalize,
30
+ planShellSegments,
28
31
  shellWrappersChangeCwd,
29
32
  splitShellSegments,
30
33
  unwrapShellCommand,
31
34
  } from "./shell.ts";
32
35
  import { asRecord, extractTargetPath } from "./utils.ts";
33
36
  import type { PolicyDecision } from "./types.ts";
34
- import { getProjectMemoryIdentity } from "./project-memory.ts";
35
37
  import {
36
38
  detectVerifyCommand,
37
39
  getCurrentGitCommitHash,
@@ -42,7 +44,7 @@ import {
42
44
  resolveVerifyTimeoutMs,
43
45
  snipVerifyOutput,
44
46
  } from "./verify.ts";
45
- import { detectShellMutation, extractPatchPaths, guardShellMutation, hasUnresolvableVariable, isEnvironmentScratchTarget, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR } from "../policies/boundary.ts";
47
+ import { detectShellMutation, extractPatchPaths, foreignSegmentMutationReason, guardShellMutation, hasUnresolvableVariable, isEnvironmentScratchTarget, isPathOutsideWorkspace, nestedPayloadMutationReason, OPENCODE_SCRATCH_DIR } from "../policies/boundary.ts";
46
48
  import { branchHasChangelogChange, checkLockfileSync, hasPrCreateInvocation, prBodyHasLiteralLineBreakEscapes, prBodyIncludesChangelog } from "../policies/changelog.ts";
47
49
  import { extractEditContent, liveMutationIn } from "../policies/destructive.ts";
48
50
  import { branchHasDocumentationChange } from "../policies/docs.ts";
@@ -56,11 +58,14 @@ import {
56
58
  checkBranchBaseIsUpToDate,
57
59
  checkMergeConflicts,
58
60
  currentGitBranch,
61
+ findWorktreeForBranch,
59
62
  hasUnsafeGitAlias,
60
63
  isBranchAlreadyMergedOrClosed,
64
+ isGitTreeWriteText,
61
65
  normalizeGitCommands,
62
66
  onProtectedBranch,
63
67
  parseGitInvocation,
68
+ prSourceBranch,
64
69
  pushedProtectedBranchIn,
65
70
  tagRefspecIn,
66
71
  } from "../policies/git.ts";
@@ -278,7 +283,7 @@ export async function guardToolCallImpl(
278
283
  // that would restate the misattribution.
279
284
  const tail = snipVerifyOutput(result.output, result.timedOut === true).slice(-500);
280
285
  const reason = result.timedOut
281
- ? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Raise the limit in the project .opencode/workflow-guard.json (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS, then complete the tasks again; output tail: ${tail}`
286
+ ? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Only the user can raise the limit, in the project .opencode/workflow-guard.json or .jsonc (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS set before the guard starts; ask them, then complete the tasks again. Output tail: ${tail}`
282
287
  : `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
283
288
  logPolicyBlock(`[workflow-guard] ${reason}`);
284
289
  return block("verification", result.timedOut ? "verification_timed_out" : "verification_failed", reason);
@@ -448,9 +453,13 @@ export async function guardToolCallImpl(
448
453
  if (!SHELL_TOOL_NAMES.has(toolName)) return allow();
449
454
  const shellWorkdir = asRecord(input)?.workdir;
450
455
  const requestedShellRoot = typeof shellWorkdir === "string" ? resolve(currentRoot, shellWorkdir) : currentRoot;
451
- const shellRoot = !isPathOutsideWorkspace(requestedShellRoot, currentRoot) || getProjectMemoryIdentity(requestedShellRoot) === getProjectMemoryIdentity(currentRoot)
452
- ? requestedShellRoot
453
- : currentRoot;
456
+ // A workdir outside the workspace is only usable when it is a linked
457
+ // worktree of the SAME repository: those commands mutate a tree of this
458
+ // workspace. A genuinely foreign repository cannot be judged here at all,
459
+ // because the command executes there while relative targets would be
460
+ // validated against the workspace root.
461
+ const foreignShellRoot = isPathOutsideWorkspace(requestedShellRoot, currentRoot) && !isSameGitRepo(requestedShellRoot, currentRoot);
462
+ const shellRoot = foreignShellRoot ? currentRoot : requestedShellRoot;
454
463
 
455
464
  for (const raw of extractCommands(input)) {
456
465
  const dynamicSyntax = dynamicShellSyntaxIn(raw);
@@ -471,11 +480,50 @@ export async function guardToolCallImpl(
471
480
  }
472
481
  const normalizedCommand = normalizeGitCommands(command);
473
482
  if (hasUnsafeGitAlias(command)) return block("git", "unsafe_git_alias", "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.");
474
- const gitInvocations = splitShellSegments(command).map((segment) => parseGitInvocation(segment.trim())).filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
483
+ // Each segment is judged in the directory it actually runs in. A `cd`
484
+ // earlier in the command moves the ones that follow, so parsing them all
485
+ // against the shell's initial `workdir` hid a mutation in another tree:
486
+ // `cd <other-repo> && git commit` was judged as a commit in the session's
487
+ // own checkout. Plan from `raw`: `normalize` folds newlines into spaces,
488
+ // which erases the separator that makes a newline-separated `cd` a real
489
+ // directory change.
490
+ const segmentPlan = planShellSegments(raw, shellRoot);
491
+ const gitInvocations = segmentPlan
492
+ .map(({ segment, root }) => parseGitInvocation(segment.trim(), root))
493
+ .filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
475
494
  const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
495
+ // The command executes in `requestedShellRoot`, not in the workspace, so
496
+ // every relative target in it would be validated against the wrong tree:
497
+ // `rm -rf dist` in a foreign workdir would be checked as
498
+ // `<workspace>/dist`. Reads elsewhere are fine, mutations are not.
499
+ const mutatesTree = (invocation: NonNullable<ReturnType<typeof parseGitInvocation>>): boolean =>
500
+ isGitTreeWriteText(`git ${invocation.rest}`);
501
+ if (foreignShellRoot && (detectShellMutation(command) || gitInvocations.some(mutatesTree))) {
502
+ logPolicyBlock(`[workflow-guard] blocked mutation in a workdir outside workspace: ${requestedShellRoot}`);
503
+ return block(
504
+ "boundary",
505
+ "workspace_escape",
506
+ `Blocked: the shell's workdir '${requestedShellRoot}' is outside workspace root (${currentRoot}) and is not a linked worktree of this repository, so relative targets cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`,
507
+ );
508
+ }
509
+ const foreignSegmentReason = foreignSegmentMutationReason(segmentPlan, currentRoot);
510
+ if (foreignSegmentReason) {
511
+ logPolicyBlock(`[workflow-guard] blocked mutation in a directory outside workspace: ${command.slice(0, 120)}`);
512
+ return block("boundary", "workspace_escape", foreignSegmentReason);
513
+ }
476
514
  for (const invocation of gitInvocations) {
477
515
  const normalizedInvocation = `git ${invocation.rest}`;
478
- if (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))) {
516
+ // A linked worktree of the SAME repository is not foreign state: the
517
+ // shell's workdir legitimately lives there when a session works in an
518
+ // isolated checkout, and judging those commands is the point of the
519
+ // workdir-aware parse above. A genuinely different repository stays
520
+ // confined, exactly as before.
521
+ // An indeterminate repository is foreign: `findGitRoot` walks up to
522
+ // the nearest existing ancestor, so `git -C $OTHER_REPO commit`
523
+ // resolved to the workspace's own root and read as the same repo.
524
+ const foreignRepo = hasUnresolvableVariable(invocation.repoDir)
525
+ || (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && !isSameGitRepo(invocation.repoDir, currentRoot));
526
+ if (foreignRepo && isGitTreeWriteText(normalizedInvocation)) {
479
527
  logPolicyBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
480
528
  return block(
481
529
  "boundary",
@@ -522,13 +570,25 @@ export async function guardToolCallImpl(
522
570
  logPolicyBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
523
571
  return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
524
572
  }
525
- for (const segment of command.split(/[\n|;&]+/)) {
573
+ // Mask from `raw`, not the normalized copy: `normalize` folds newlines
574
+ // into spaces, which erases the heredoc shape the mask needs to find.
575
+ for (const segment of maskDataHeredocBodies(raw).split(/[\n|;&]+/)) {
526
576
  const secretFile = secretFileReadIn(segment.trim());
527
577
  if (secretFile) {
528
578
  logPolicyBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
529
579
  return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`);
530
580
  }
531
581
  }
582
+ // A nested payload changes a tree this session may not judge when it
583
+ // changes directory itself, or names a foreign `cwd`. The outer shell
584
+ // sees one word, so the top-level walk above cannot see it. Plan from
585
+ // `raw` for the same reason the top-level walk does: a heredoc body's
586
+ // own lines are real commands to the shell that consumes it.
587
+ const nestedTreeReason = nestedPayloadMutationReason(extractInterpreterPayload, writePathsInPayload, raw, shellRoot, currentRoot);
588
+ if (nestedTreeReason) {
589
+ logPolicyBlock(`[workflow-guard] blocked nested interpreter mutation outside workspace: ${command.slice(0, 120)}`);
590
+ return block("boundary", "workspace_escape", nestedTreeReason);
591
+ }
532
592
  for (const payload of extractInterpreterPayload(raw)) {
533
593
  const outsidePath = outsideWritePathInPayload(payload, currentRoot);
534
594
  if (outsidePath) {
@@ -561,7 +621,13 @@ export async function guardToolCallImpl(
561
621
  const secretPath = secretPathInPayload(payload);
562
622
  if (secretPath) return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`);
563
623
  }
564
- const shellMutationReason = await guardShellMutation(command, context?.sessionID, !context?.simulate);
624
+ // `eval` runs its body in the current shell, so a `cd` inside it moves the
625
+ // directory of later segments. Another launcher's payload runs in a child
626
+ // process and cannot move this shell, so only `eval` is forwarded. The
627
+ // predicate lives in the shell walk so both call sites agree on which
628
+ // `eval` is a command.
629
+ const leadingEvalBody = (s: string): string | undefined => (isLeadingEvalSegment(s) ? extractInterpreterPayload(s)[0] : undefined);
630
+ const shellMutationReason = await guardShellMutation(raw, context?.sessionID, !context?.simulate, shellRoot, leadingEvalBody);
565
631
  if (shellMutationReason) {
566
632
  logPolicyBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
567
633
  return block("boundary", "shell_mutation", shellMutationReason);
@@ -605,15 +671,40 @@ export async function guardToolCallImpl(
605
671
  }
606
672
  }
607
673
  if (hasPrCreateInvocation(raw)) {
608
- const prRoot = shellRoot;
674
+ // The PR is about a BRANCH, not about whatever the shell's checkout
675
+ // happens to have checked out. Resolve the branch the command names
676
+ // (`gh --head`, `az --source-branch`) and then the checkout that
677
+ // holds it: a linked worktree is the normal case, because the
678
+ // orchestrator session may sit in the primary checkout while the
679
+ // reviewed branch lives in an isolated worktree. Every preflight
680
+ // check then judges that tree instead of the caller's.
681
+ const prSegment = splitShellSegments(raw).find((segment) => hasPrCreateInvocation(segment));
682
+ // Quote-aware on the raw segment: `unwrapShellCommand` joins the
683
+ // words back without their quotes, so re-parsing the joined text
684
+ // read a `--head`-looking token inside a quoted `--body` as the
685
+ // source branch.
686
+ const sourceBranch = prSegment ? prSourceBranch(prSegment) : undefined;
687
+ let prRoot = shellRoot;
688
+ let branch = currentGitBranch(shellRoot);
689
+ let missingSourceCheckout: string | undefined;
690
+ if (sourceBranch) {
691
+ const holder = findWorktreeForBranch(shellRoot, sourceBranch) ?? findWorktreeForBranch(currentRoot, sourceBranch);
692
+ prRoot = holder ?? shellRoot;
693
+ branch = sourceBranch;
694
+ // Review and merge evidence bind to the CONTENT of a checkout.
695
+ // Substituting the caller's checkout would let an approval of one
696
+ // tree satisfy a PR that publishes another, so a named branch no
697
+ // local checkout holds is refused instead.
698
+ if (!holder) missingSourceCheckout = sourceBranch;
699
+ }
609
700
  const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
610
701
  const prTool = isAz ? "az repos pr create" : "gh pr create";
611
702
  const descFlag = isAz ? "--description" : "--body";
612
703
  const preflightFailures: string[] = [];
704
+ if (missingSourceCheckout) preflightFailures.push(`Source branch '${missingSourceCheckout}' is not checked out in any worktree of this repository, so review and merge evidence cannot be bound to the content this PR would publish. Check that branch out (a linked worktree is fine) and re-run, or omit the source-branch flag to judge the current checkout.`);
613
705
  if (prBodyHasLiteralLineBreakEscapes(raw)) preflightFailures.push(`PR description contains literal \\n/\\r escapes that will render as text; use real newlines, ANSI-C quoting ($'...\\n...'), or a body/description file instead.`);
614
- const conflictCheck = checkMergeConflicts(prRoot);
706
+ const conflictCheck = checkMergeConflicts(prRoot, branch);
615
707
  if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
616
- const branch = currentGitBranch(prRoot);
617
708
  if (branch) {
618
709
  if (context?.simulate) remotePrStateUnchecked = true;
619
710
  const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch, !context?.simulate);