opencode-workflow-guard 1.15.2 → 1.15.3

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.
@@ -0,0 +1,7 @@
1
+ # LL-013 — A lexical workspace-prefix check must not preempt canonical resolution; decide "outside" only when both lexical and canonical resolution are outside
2
+
3
+ - **Evidence:** Device audit (`~/.local/state/opencode/workflow-guard/workflow-guard.jsonl`), `workspace_escape` 2026-09-27T05:08:31Z: a write to `/home/hunter/.local/share/opencode/worktrees/azure-container-jobs-remote-sandbox/feat-p12-cache-metering-fields/src/integrations/acp-runtime.ts` was blocked against a workspace root under `/var/home/hunter/...` although the host has `/home -> var/home` (verified: `readlink -f /home/hunter` = `/var/home/hunter`), so the target canonicalizes inside the root. Root cause: `isPathOutsideWorkspace` (`src/policies/boundary.ts`) returned `true` on the lexical prefix check before the realpath/nearest-existing-ancestor canonicalization ran. The alias also leaked into tooling: `guard_worktree_create` returned worktree paths spelled `/home/hunter/...`, and an executor session that tried to edit via `/home/...` spellings hit this exact block.
4
+ - **Fix:** the lexical verdict is retained and the existing realpath/ancestor canonicalization now runs regardless; the function returns outside only when the lexical resolution is outside AND the canonical resolution is not inside; indeterminate canonicalization keeps the lexical verdict (fail closed). Lexically-inside paths keep the pre-existing canonical check that catches symlinks inside the workspace pointing outside. Four machine-independent regression checks (alias new-file allowed, alias existing-file allowed, escaping symlink existing+new target still blocked); `npm test` 1099 passed, 0 failed.
5
+ - **Lesson:** when a boundary check compares paths, the lexical and canonical answers must agree before blocking: block only when both say outside, and let indeterminate canonicalization fail closed to the lexical verdict. Also treat symlink-aliased path spellings in your own tool output (worktree paths, guard messages) as a distribution channel for the alias into downstream sessions.
6
+
7
+ Review: APPROVE (5 axes). P3 follow-up recorded: a dangling escape symlink inside the workspace (target does not exist) evades the ancestor walk and the OS create lands outside — pre-existing, to be fixed separately.
@@ -0,0 +1,6 @@
1
+ # LL-009 — Branch-creation staleness classification must skip shell redirection tokens; `&`-segment splitting leaves bare fd operators behind
2
+
3
+ - **Evidence:** Device audit (`~/.local/state/opencode/workflow-guard/workflow-guard.jsonl`) recorded three `branch_base_behind` blocks on 2026-09-27 whose "explicit start point" was a redirection token: `the explicit start point '2>' could not be resolved as a commit ... fail closed` (04:29:29Z, 04:29:32Z, 06:15:00Z). Root cause: `branchCreationStartPoint` (`src/policies/git.ts`) had no redirection model, and `splitShellSegments` (`src/lib/shell.ts`) splits unquoted `&`, so `git switch -c feat/x 2>&1` reached the parser as a segment ending in bare `2>`, which was classified as the start point and failed closed even when HEAD was fresh.
4
+ - **Fix:** `BRANCH_REDIRECTION_OPERATORS` (separated operators, target word skipped with them) plus `BRANCH_REDIRECTION_ATTACHED_RE` (attached forms `2>/dev/null`, `2>&1`, `&>/dev/null`) in `src/policies/git.ts`; valid refs cannot contain `<`/`>`, so no real start point can be mistaken for a redirection. All fail-closed fallbacks (quotes, `$vars`, unknown flags) unchanged per LL-002.
5
+ - **Verification:** 8 regression checks in `test/test.mts` Policy 20 block (stale-branch redirect case still blocks with the reason naming the stale branch and not `'2>'`; five fresh-branch redirect forms allowed; stale-SHA start point behind a redirect still blocks as stale; `$WG_REF` still fails closed). `npm run typecheck` green, `npm test` = 1095 passed, 0 failed. First executor run failed its own `2> /dev/null` case (fd-prefixed separated operator), which forced extending the separated-operator match to fd-prefixed forms — the spec's operator set alone was insufficient.
6
+ - **Lesson:** When a classifier extracts operands from a shell fragment that passes through segment splitting, enumerate the redirection grammar (fd-prefixed separated operators, attached operator+target, fd duplication) in the same iteration as the classifier change — a partial operator set fails its own adversarial case. Segment splitting at `&` guarantees `n>&m` arrives as a trailing bare `n>` token, so "operator at end of tokens with no following target" is the shape most likely to be missed.
@@ -0,0 +1,4 @@
1
+ # LL-011 — Verify config plumbing with a fail-closed sentinel: raw passthrough config fields prove the file was read, resolved fields can be satisfied by env inheritance
2
+
3
+ - **Evidence:** The e2e harness (`test/test-e2e.mts`) never verified that project configuration (`.opencode/workflow-guard.json[c]`, candidates at `src/lib/project-config.ts:65-72`) reaches the real OpenCode runtime: `guard_status` echoes resolved config (`src/lib/custom-tools.ts:279`) but the harness only asserted `workspaceRoot`, so a silent config-loading failure (wrong path, swallowed parse error — `loadProjectConfig` returns `{}` on any parse failure, `src/lib/project-config.ts:111-118`) would degrade to defaults invisibly. The fix seeds sentinel values (`protectedBranches: ["e2e-config-probe-branch"]`, `requireReview: false`) into the isolated testDir and asserts `guard_status` reports them: `npm run test:install` went 25 passed/0 failed → 26 passed/0 failed, `npm run typecheck` green; fresh-eyes reviewer approved with P3 notes only (verdict recorded via `record_review`, bound to commit `921f895`).
4
+ - **Lesson:** A config-loading regression is silent by construction — the loader's failure mode IS "fall back to defaults" — so e2e proof must assert a value that only the file can produce: prefer a raw passthrough field with a sentinel that exists nowhere in source (here `protectedBranches`, a `cfg.protectedBranches ?? ["main","master"]` passthrough in `src/lib/custom-tools.ts:241,279`), because resolved fields can be satisfied by other inputs (the reviewer found `requireReview: false` is also reachable via an inherited `WORKFLOW_GUARD_REQUIRE_REVIEW` env var, `src/lib/state.ts:395-396`, so it corroborates but cannot carry the proof), and choose sentinel values that are behaviorally inert for the rest of the harness so the coverage gain cannot perturb the tests it sits inside. The executor delegation for this edit failed on an unrelated harness defect (a freshly spawned subagent session resolved its workspace root to the operator's dotfiles repo, and `session_move` did not re-root the guard's boundary check) — the parent session applied the spec-exact edit itself; spawn-context workspace binding for subagents in worktrees deserves its own investigation.
@@ -0,0 +1,6 @@
1
+ # LL-010 — Policy-scanner fixtures are unwritable from inside a guarded session by design; redesign the probe around benign inputs that hit the same deterministic policy paths instead of obfuscating or weakening the scanner
2
+
3
+ - **Evidence:** Extending the provider-free e2e harness (`test/test-e2e.mts`) beyond its single real-runtime policy probe (`guard_why` simulate of a protected-branch push asserted at the pre-change line 359, executed on the V1 hook path line 238 / V2 registered-tool path line 223): the executor subagent tried to write a kubectl destructive-delete fixture string into the test file — intended purely as simulate-only input to `guard_why`, never executed — and the guard's edit-content scanner blocked the write with `destructive/live_mutation_payload` ("Script files are not a way to smuggle destructive commands past the shell guard"). The scanner intentionally does not distinguish simulation fixtures from real payloads, and no in-session override exists (`WORKFLOW_GUARD_ALLOW_LIVE` is honored only when set before OpenCode starts; the in-command `# allow-live` escape was deliberately removed). The executor refused to obfuscate the string (correct), and the scanner even blocked a `git grep` whose pattern merely contained an interactive-editor fixture name (`shell-safety/interactive_tty`), confirming classifier-triggering text is classified wherever it appears.
4
+ - **Accepted change (test-only, no src/ changes):** the headless adapter now probes four deterministic policy families through the real OpenCode-loaded plugin's `guard_why` on both V1 and V2 paths with inputs that trigger the classifiers without literal live-destructive strings: read `.env` → `secrets/secret_read`; `cat .env` → `secrets/secret_read` (shell path); write `../outside_escaped.txt` → `boundary/workspace_escape`; `vim notes.txt` → `shell-safety/interactive_tty`. Previously CI never exercised those families in the real runtime (only via opt-in model-driven probes requiring a provider).
5
+ - **Verification:** `npm run test:install` 21 passed/0 failed (baseline, pre-change) → 25 passed/0 failed with the change; reproduced a second time on a different base after a concurrent fleet agent's branch switch replaced this worktree's HEAD mid-iteration and wiped the first (uncommitted) application; `npm run typecheck` green. Fresh-eyes reviewer approved across the 5 rubric axes with only P3 notes (probe-map duplication between the V1/V2 runners; missing-tool failure mode changed, not weakened — every new check also asserts `status === "blocked"`); verdict recorded via `record_review`.
6
+ - **Lesson:** When extending harness files in a repo whose guard scans edit content, pick probe inputs that trigger the policy classifier without embedding live destructive command strings (secret reads, workspace escapes, interactive TUI apps), dispatch one `guard_why` simulate call per policy family for real-runtime policy-dispatch coverage without a model provider — and treat "fixture unwritable" as a probe-redesign signal, never an invitation to obfuscate or to weaken the scanner. Uncommitted accepted work in a shared multi-agent checkout does not survive another agent's branch switch: commit the verified artifact immediately, scoped to its own files.
@@ -0,0 +1,4 @@
1
+ # LL-012 — Assert conditional registration surfaces exhaustively, and scrub env overrides that would satisfy a config-gated proof without the config file
2
+
3
+ - **Evidence:** The e2e harness asserted only 2 of the 18 custom tool ids in the real runtime (`guard_status`, `guard_why` at the pre-change line ~406); the other 16 register inside three conditional spread groups in `src/lib/custom-tools.ts` (`guard_recovery_restore` gated on `isRecoveryCheckpointsEnabled` :148-149, `project_memory_*` on `isProjectMemoryEnabled` :170-197, `learning_*` on `isLearningEnabled` :199-233), so a real-runtime failure of one gate's enablement chain would silently drop tools with no harness signal. The fix extends the sentinel config with `learning: true` and `recoveryCheckpoints: true` and asserts all 18 ids against the `ctx.tool.list()` surface (V2-gated, since only the V2 runner writes the tools list): `npm run test:install` 26 passed/0 failed → 27 passed/0 failed, `npm run typecheck` green; fresh-eyes reviewer verified the 18-id list matches source id-for-id and approved (verdict recorded via `record_review`, bound to commits `adced74` + `1cc8207`).
4
+ - **Lesson:** When a harness asserts a registered surface, enumerate it exhaustively and make each enabling input load-bearing: a subset assertion (2 of 18) cannot see a silently missing conditional group, and an env-overridable gate means an inherited variable (here `WORKFLOW_GUARD_LEARNING=1`, `src/lib/state.ts:446-448`, spread into the harness `runtimeEnv`) can satisfy the assertion even if the config file stops being read — scrub the override from the runtime environment exactly like the harness already scrubs `OPENCODE_*`, or the config leg of the proof is decorative. Prefer at least one gate with no env path (`recoveryCheckpoints`) as the pure config-parse proof.
@@ -0,0 +1,7 @@
1
+ # LL-015 — A fail-closed block message should name the construct and a compliant remedy; retry loops with identical generic blocks are the measurable symptom
2
+
3
+ - **Evidence:** Device audit: 52 `dynamic_shell_syntax` blocks in three days (38 command/process substitution, 14 malformed quoting), all with the generic "cannot be safely classified without executing shell expansion" text and no remedy; the same sessions produced chains of near-identical retries (and this loop's own orchestration hit two of them live on read-only inspection commands).
4
+ - **Fix:** message-only — a `DYNAMIC_SHELL_SYNTAX_GUIDANCE` map in `src/lib/guard-dispatcher.ts` keyed by the four construct strings returned by `dynamicShellSyntaxIn` appends construct naming (substitution forms, IFS expansion, ambiguous whitespace, malformed quoting) plus a compliant remedy (literal values / separate literal commands / balanced quoting; `guard_why` to simulate). Unknown constructs fall back to the byte-identical old template; decision, code, and detectors unchanged. Two message-content regression checks; `npm test` 1097 passed, 0 failed.
5
+ - **Lesson:** the cost of a fail-closed gate is paid in its message: name the construct class and the compliant rewrite, or agents (and guard tooling itself) burn turns retrying spellings that will block identically. Remedies must always be compliant rewrites or sanctioned simulation tools — never a path around the policy.
6
+
7
+ Review: APPROVE (5 axes, verification reproduced independently). P3 follow-ups recorded: add a drift-guard test asserting every detector construct has guidance; `src/lib/verify.ts:247` has a separate generic dynamic-syntax message family worth the same treatment; changeset trailing newline (cosmetic).
@@ -0,0 +1,7 @@
1
+ # LL-014 — Freshness observations must cover every surface the session can author bytes with; shell redirect writes author bytes like edits do
2
+
3
+ - **Evidence:** Device audit, `stale_write` class: 43 blocks since 2026-09-25, including files the session had just created through allowed shell redirects (`.tmp-pr-body.md`, `.tmp-pr-body2.md`, `.tmp-pr-body-wave2.md`, `.tmp-commit-msg.txt`) and then could not edit ("has not been read by this session") — pure-friction re-read loops of the kind LL-003 fixed for direct edit/write. Shell writers were deliberately left outside the observation seeding, so the session's own authored bytes were invisible to the gate.
4
+ - **Fix:** allowed shell commands' redirect targets (parsed via the now-exported `redirectMutationsIn`, resolved against `args.workdir ?? toolWorktree`, skipping `/dev/null`, `/tmp/opencode`, and outside-workspace targets) are pre-snapshotted into a new `shellWriteSnapshots` lifecycle map (`src/lib/tool-lifecycle.ts`) and seeded after the call with the same change-gated `recordMutationObservation` (identical bytes seed nothing; `/dev/null` and skips seed nothing). V1 (`src/workflow-guard.ts`) and V2 (`src/lib/v2-plugin.ts`) in parity; no post-edit validators run for shell snapshots. Four hook-pair-driven regression checks; `npm test` 1099 passed, 0 failed.
5
+ - **Lesson:** a freshness/freshness-observation gate must learn from every mutation surface the session controls, not only the tool calls it was designed around — otherwise the gate manufactures its own re-read loops on the session's just-authored files. Redirects apply before command execution, so even a failed command whose redirect truncated/created the file genuinely authored that state; the digest gate keeps no-ops from seeding.
6
+
7
+ Review: APPROVE (5 axes, verification reproduced independently). P3 follow-ups recorded: V2 skips errored calls before consuming the map (conservative, mirrors the existing pattern); `workdir`-relative resolution, `/tmp/opencode` skip, and outside-workspace skip lack direct regression coverage; ~25-line V1/V2 duplication is drift-prone; `tee`/`dd`/`sed -i`/`cp`/`mv` writers remain unseeded (documented).
@@ -10,3 +10,7 @@ Append-only, evidence-backed lessons from base-loop iterations. Each item: id, e
10
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
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
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.
13
+ - **LL-009 — Branch-creation staleness classification must skip shell redirection tokens; `&`-segment splitting leaves bare fd operators behind.** Evidence: three `branch_base_behind` blocks on 2026-09-27 whose "explicit start point" was a redirection token (`the explicit start point '2>' could not be resolved as a commit ... fail closed`), because `branchCreationStartPoint` (`src/policies/git.ts`) had no redirection model and `splitShellSegments` splits unquoted `&`, so `git switch -c feat/x 2>&1` reached the parser as a segment ending in bare `2>` and failed closed even on a fresh branch. The fix skips separated redirection operators (fd-prefixed included, target word consumed with them) and attached operator+target forms (`2>/dev/null`, `2>&1`, `&>/dev/null`); valid refs cannot contain `<`/`>` so no real start point can match, and all fail-closed fallbacks (quotes, `$vars`, unknown flags) stay intact per LL-002. Eight Policy 20 regression checks; `npm test` 1095 passed. Lesson: when a classifier extracts operands from a shell fragment that passes through segment splitting, enumerate the redirection grammar (fd-prefixed separated operators, attached operator+target, fd duplication) in the same change — a partial operator set fails its own adversarial case, and "operator at end of tokens with no following target" is the shape most likely to be missed. Details: `docs/agents/lessons/2026-09-27-branch-create-redirect-start-point.md`.
14
+ - **LL-013 — A lexical workspace-prefix check must not preempt canonical resolution; decide "outside" only when both lexical and canonical resolution are outside.** Evidence: `workspace_escape` 2026-09-27T05:08:31Z blocked a write to `/home/hunter/...` against a `/var/home/hunter/...` workspace root although the host has `/home -> var/home` (the target canonicalizes inside); `isPathOutsideWorkspace` (`src/policies/boundary.ts`) returned true on the lexical prefix check before the realpath/ancestor canonicalization ran, and the alias leaked into tool output (`guard_worktree_create` returned `/home/...` worktree paths that then blocked executor edits). The fix decides outside only when lexical AND canonical resolutions are outside, keeps indeterminate canonicalization fail-closed to the lexical verdict, and preserves blocking for symlinks inside the workspace pointing outside; four regression checks, `npm test` 1099 passed. Lesson: block only when both the lexical and the canonical answer say outside, and treat symlink-aliased path spellings in your own tool output as a distribution channel for the alias into downstream sessions. Details: `docs/agents/lessons/2026-09-27-boundary-symlink-alias-escape.md`.
15
+ - **LL-014 — Freshness observations must cover every surface the session can author bytes with; shell redirect writes author bytes like edits do.** Evidence: 43 `stale_write` blocks since 2026-09-25 included files the session had just created through allowed shell redirects (`.tmp-pr-body.md`, `.tmp-commit-msg.txt`) and then could not edit — LL-003's re-read friction, reintroduced by leaving shell writers outside the observation seeding. The fix seeds the same change-gated `recordMutationObservation` from allowed shell redirect targets (parsed via exported `redirectMutationsIn`, skipping `/dev/null`, `/tmp/opencode`, and outside-workspace targets) through a new `shellWriteSnapshots` lifecycle map with V1/V2 parity and no post-edit validators; four hook-pair regression checks, `npm test` 1099 passed. Lesson: a freshness gate must learn from every mutation surface the session controls, or it manufactures re-read loops on the session's just-authored files; redirects apply before command execution, so even failed commands author their redirect writes. Details: `docs/agents/lessons/2026-09-27-stale-write-shell-seeding.md`.
16
+ - **LL-015 — A fail-closed block message should name the construct and a compliant remedy; retry loops with identical generic blocks are the measurable symptom.** Evidence: 52 `dynamic_shell_syntax` blocks in three days (38 substitution, 14 malformed quoting) with a generic no-remedy message produced chains of near-identical retries, including two live hits on this loop's own read-only inspection commands. The fix is message-only (guidance map in `src/lib/guard-dispatcher.ts` keyed by the four detector constructs, byte-identical fallback for unknown ones); decision, code, and detectors unchanged; two message-content regression checks, `npm test` 1097 passed. Lesson: the cost of a fail-closed gate is paid in its message — name the construct class and the compliant rewrite (or the sanctioned `guard_why` simulation), never a path around the policy. Details: `docs/agents/lessons/2026-09-27-shell-syntax-remedy-messages.md`.
package/docs/policies.md CHANGED
@@ -19,6 +19,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
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
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
+ - **Shell-redirect seeding:** an allowed shell command that redirects into a workspace file (`cmd > file`, `cmd >> file`, fd forms) seeds the session's observation of the resulting bytes in the same change-gated way as direct edits, so a file this session just created or rewrote via a shell redirect can be edited without a redundant re-read. Redirects to `/dev/null`, the `/tmp/opencode` scratch directory, or outside the workspace seed nothing; an identical-byte (no-op) redirect does not seed; the not-read block message also suggests coordinating when another session may own the file. Shell writers themselves remain outside the bounded pre-check: only the observation seeding applies.
22
23
 
23
24
  ### 2. No Pushes to Protected Branches
24
25
  - `git push ... main` and `git push ... master` are blocked in all shell commands, including direct refs, refspecs (`git push origin HEAD:main`, `git push origin feature/x:main`), deletion refspecs (`git push origin :main`), and forced refspecs (`git push origin +main`).
@@ -75,6 +76,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
75
76
  - 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
77
  - **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
78
  - **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.
79
+ - **Symlink-alias acceptance:** a target whose lexical path falls outside the workspace root but whose canonical resolution is inside it is accepted — for example `/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, including symlinks inside the workspace that point outside and not-yet-created files under them (nearest existing ancestor), and when canonicalization is indeterminate the lexical verdict stands (fail closed). In other words, a path is outside the workspace only when both its lexical and its canonical resolution are outside.
78
80
  - `tee` flag parsing generically skips option flags (`-a`, `--append`, `-ai`, `--`) and validates all specified target files against the boundary.
79
81
  - 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).
80
82
  - `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.
@@ -143,7 +145,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
143
145
 
144
146
  ### 20. Merged Branch & Base Freshness Guard
145
147
  - Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
146
- - Blocks creating feature branches (`git switch -c`, `git checkout -b`) from a stale base: without an explicit start point, the current branch must be fresh against the remote default; an explicit start point that already contains the remote default (`origin/HEAD`) is accepted because the new branch is fresh by construction, and start points that cannot be resolved fail closed. Block reasons name the branch actually checked and remedies that work under the guard (rebase or ff-merge the branch, or branch from the remote default directly).
148
+ - Blocks creating feature branches (`git switch -c`, `git checkout -b`) from a stale base: without an explicit start point, the current branch must be fresh against the remote default; an explicit start point that already contains the remote default (`origin/HEAD`) is accepted because the new branch is fresh by construction, and start points that cannot be resolved fail closed. Block reasons name the branch actually checked and remedies that work under the guard (rebase or ff-merge the branch, or branch from the remote default directly). Shell redirections attached to or following a branch creation (stdout/stderr redirection and fd duplication such as `2>&1`) are ignored when classifying the start point, because they never determine which commit the branch is created from.
147
149
  - Tag push refspecs are exempt: publishing an existing tag (`git push origin v1.2.0`, `git push origin refs/tags/v1.2.0`) and creating tags (`git tag v1.2.0`, `git tag -a -m ...`) are release operations, not branch mutations - only tag deletions (`git tag -d`, `git push origin :refs/tags/v1.2.0`, `--delete`) stay blocked.
148
150
 
149
151
  ### 21. Documentation Review & Synchronization Guard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.15.2",
3
+ "version": "1.15.3",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -78,7 +78,30 @@ import {
78
78
  validateTodoLifecycle,
79
79
  } from "../policies/todo.ts";
80
80
 
81
- const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
81
+ export const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
82
+
83
+ // dynamic_shell_syntax is fail-closed by design; naming the construct and a
84
+ // remedy keeps agents from retrying near-identical commands. Keyed by the
85
+ // construct strings returned by dynamicShellSyntaxIn; unknown constructs fall
86
+ // back to the generic template.
87
+ const DYNAMIC_SHELL_SYNTAX_GUIDANCE: Record<string, { detail: string; remedy: string }> = {
88
+ "dynamic command/process substitution": {
89
+ detail: "($( ... ), backticks, <( ) / >( ))",
90
+ remedy: "Rewrite the step with literal values or as separate literal commands, then simulate the result with guard_why if unsure.",
91
+ },
92
+ "dynamic IFS expansion": {
93
+ detail: "(${IFS} token construction)",
94
+ remedy: "Build the command from literal words separated by spaces instead of injecting field separators.",
95
+ },
96
+ "ambiguous shell whitespace": {
97
+ detail: "(carriage return or Unicode whitespace)",
98
+ remedy: "Re-run with plain spaces between literal words.",
99
+ },
100
+ "malformed shell quoting": {
101
+ detail: "(unbalanced or improperly nested quotes/escapes)",
102
+ remedy: "Re-run with simple, balanced quoting around literal values.",
103
+ },
104
+ };
82
105
 
83
106
  const READ_ONLY_ROLES = new Set([
84
107
  "reviewer",
@@ -339,7 +362,10 @@ export async function guardToolCallImpl(
339
362
 
340
363
  for (const raw of extractCommands(input)) {
341
364
  const dynamicSyntax = dynamicShellSyntaxIn(raw);
342
- if (dynamicSyntax) return block("shell-safety", "dynamic_shell_syntax", `Blocked: command contains ${dynamicSyntax} that cannot be safely classified without executing shell expansion.`);
365
+ if (dynamicSyntax) {
366
+ const guidance = DYNAMIC_SHELL_SYNTAX_GUIDANCE[dynamicSyntax];
367
+ return block("shell-safety", "dynamic_shell_syntax", `Blocked: command contains ${dynamicSyntax}${guidance ? ` ${guidance.detail}` : ""} that cannot be safely classified without executing shell expansion.${guidance ? ` ${guidance.remedy}` : ""}`);
368
+ }
343
369
  const command = normalize(raw);
344
370
  const ttyCheck = checkInteractiveTtyCommand(command);
345
371
  if (ttyCheck.isInteractive) {
@@ -7,6 +7,9 @@ function invocationKey(sessionID: string, callID: string): string {
7
7
 
8
8
  export class ToolInvocationLifecycle {
9
9
  private readonly postEditSnapshots = new Map<string, { root: string; snapshots: FileSnapshot[] }>();
10
+ // Shell-redirect pre-state snapshots: they seed freshness observations
11
+ // only and must never run the post-edit validators.
12
+ private readonly shellWriteSnapshots = new Map<string, { root: string; snapshots: FileSnapshot[] }>();
10
13
  private readonly startedAt = new Map<string, number>();
11
14
  private readonly readObservations = new Map<string, ReadObservation>();
12
15
  private readonly recoveryRuns = new Map<string, number>();
@@ -44,6 +47,17 @@ export class ToolInvocationLifecycle {
44
47
  return snapshots;
45
48
  }
46
49
 
50
+ setShellWriteSnapshots(sessionID: string, callID: string, root: string, snapshots: FileSnapshot[]): void {
51
+ this.shellWriteSnapshots.set(invocationKey(sessionID, callID), { root, snapshots });
52
+ }
53
+
54
+ takeShellWriteSnapshots(sessionID: string, callID: string): { root: string; snapshots: FileSnapshot[] } | undefined {
55
+ const key = invocationKey(sessionID, callID);
56
+ const snapshots = this.shellWriteSnapshots.get(key);
57
+ this.shellWriteSnapshots.delete(key);
58
+ return snapshots;
59
+ }
60
+
47
61
  setRecoveryRun(sessionID: string, run: number): void {
48
62
  this.recoveryRuns.set(sessionID, run);
49
63
  }
@@ -56,7 +70,7 @@ export class ToolInvocationLifecycle {
56
70
 
57
71
  clearSession(sessionID: string): void {
58
72
  const prefix = `${sessionID}\0`;
59
- for (const map of [this.startedAt, this.readObservations, this.postEditSnapshots]) {
73
+ for (const map of [this.startedAt, this.readObservations, this.postEditSnapshots, this.shellWriteSnapshots]) {
60
74
  for (const key of map.keys()) {
61
75
  if (key.startsWith(prefix)) map.delete(key);
62
76
  }
@@ -48,7 +48,7 @@ import {
48
48
  } from "./project-memory.ts";
49
49
  import { secretIn } from "../policies/secrets.ts";
50
50
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
51
- import { isReadOnlyRole } from "./guard-dispatcher.ts";
51
+ import { isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
52
52
  import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
53
53
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./tool-outcomes.ts";
54
54
  import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
@@ -56,7 +56,9 @@ import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
56
56
  import { checkCompletionClaims } from "../policies/completion.ts";
57
57
  import { releaseFileClaims } from "../policies/file-claims.ts";
58
58
  import { beginReadObservation, recordMutationObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
59
- import { editTargets, runPostEditValidators, snapshotFile } from "../policies/post-edit-validation.ts";
59
+ import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "../policies/post-edit-validation.ts";
60
+ import { extractCommands, splitShellSegments } from "./shell.ts";
61
+ import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "../policies/boundary.ts";
60
62
  import { audit, summarizeInput } from "./audit.ts";
61
63
  import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
62
64
  import { loadedPluginVersion } from "./version.ts";
@@ -255,6 +257,31 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
255
257
  const snapshots = editTargets(args, toolWorktree).map(snapshotFile);
256
258
  if (snapshots.length) toolLifecycle.setPostEditSnapshots(event.sessionID, event.id, toolWorktree, snapshots);
257
259
  }
260
+ if (SHELL_TOOL_NAMES.has(event.tool)) {
261
+ // An allowed shell redirect authors the target's bytes exactly
262
+ // like edit/write (LL-003), so pre-register each redirect
263
+ // target's pre-state and seed the session's freshness
264
+ // observation after the call succeeds. Observation only: shell
265
+ // writes never run the post-edit validators. (V1 parity.)
266
+ const shellWorkdir = typeof record?.workdir === "string" ? record.workdir : undefined;
267
+ const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
268
+ const snapshots: FileSnapshot[] = [];
269
+ for (const command of extractCommands(args)) {
270
+ for (const segment of splitShellSegments(command)) {
271
+ for (const mutation of redirectMutationsIn(segment)) {
272
+ const target = mutation.target ?? "";
273
+ if (!target || target === "/dev/null") continue;
274
+ const expanded = expandShellTargetPath(target);
275
+ if (!expanded) continue;
276
+ const resolvedTarget = resolve(shellCwd, expanded);
277
+ if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
278
+ if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
279
+ snapshots.push(snapshotFile(resolvedTarget));
280
+ }
281
+ }
282
+ }
283
+ if (snapshots.length) toolLifecycle.setShellWriteSnapshots(event.sessionID, event.id, toolWorktree, snapshots);
284
+ }
258
285
  });
259
286
  });
260
287
 
@@ -279,6 +306,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
279
306
  if (observation) recordSuccessfulRead(observation, event.sessionID);
280
307
  }
281
308
  if (event.status === "error") return;
309
+ const shellPending = toolLifecycle.takeShellWriteSnapshots(event.sessionID, event.id);
310
+ if (shellPending) {
311
+ // Seed the session's observation of the bytes its shell redirect
312
+ // authored (LL-003); observation-only, validators are not run for
313
+ // shell writes, and the digest gate keeps no-op calls from
314
+ // seeding. (V1 parity.)
315
+ for (const before of shellPending.snapshots) recordMutationObservation(before.path, event.sessionID, before.digest);
316
+ }
282
317
  const pending = toolLifecycle.takePostEditSnapshots(event.sessionID, event.id);
283
318
  if (!pending) return;
284
319
  await runWithRuntimeState(pending.root, client, async () => {
@@ -85,9 +85,15 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
85
85
  if (expanded === null) return true;
86
86
  const resolved = resolve(root, expanded);
87
87
  const normalizedRoot = root.endsWith("/") ? root : root + "/";
88
- if (resolved !== root && !resolved.startsWith(normalizedRoot)) {
89
- return true;
90
- }
88
+ // A path is outside only when BOTH the lexical resolution and the
89
+ // canonical resolution are outside. The lexical verdict is therefore
90
+ // held instead of returned early: symlink aliases of the workspace
91
+ // itself (e.g. /home -> /var/home) resolve lexically outside but
92
+ // canonically inside, and must be accepted, while the canonical checks
93
+ // below keep blocking symlinks inside the workspace that point outside.
94
+ // When canonicalization is indeterminate (no existing ancestor), the
95
+ // lexical verdict stands (fail-closed).
96
+ const lexicallyOutside = resolved !== root && !resolved.startsWith(normalizedRoot);
91
97
  // The boundary is resolved against the workspace THIS call declares (the
92
98
  // `root` argument), never against a process-global latched root: concurrent
93
99
  // loops share one guard process, and a latched root must not redefine
@@ -97,12 +103,14 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
97
103
  try {
98
104
  realRootVal = realpathSync(root);
99
105
  } catch {}
106
+ let canonicallyInside = false;
100
107
  try {
101
108
  const real = realpathSync(resolved);
102
109
  const realRoot = realRootVal.endsWith("/") ? realRootVal : realRootVal + "/";
103
110
  if (real !== realRootVal && !real.startsWith(realRoot)) {
104
111
  return true;
105
112
  }
113
+ canonicallyInside = true;
106
114
  } catch {
107
115
  let curr = resolved;
108
116
  while (curr && curr !== "/" && curr !== ".") {
@@ -115,11 +123,15 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
115
123
  if (realParent !== realRootVal && !realParent.startsWith(realRoot)) {
116
124
  return true;
117
125
  }
126
+ canonicallyInside = true;
118
127
  break;
119
128
  } catch {}
120
129
  }
121
130
  }
122
- return false;
131
+ // Relocated lexical verdict: reached only when canonicalization did not
132
+ // find the path outside, so a lexically-outside path that canonicalized
133
+ // inside (a symlink alias of the workspace) is no longer blocked here.
134
+ return lexicallyOutside && !canonicallyInside;
123
135
  }
124
136
 
125
137
  export function extractPatchPaths(patchText: string): string[] {
@@ -161,7 +173,7 @@ export function teeTargetsIn(segment: string): string[] {
161
173
  return targets;
162
174
  }
163
175
 
164
- function redirectMutationsIn(segment: string): ShellMutation[] {
176
+ export function redirectMutationsIn(segment: string): ShellMutation[] {
165
177
  const mutations: ShellMutation[] = [];
166
178
  // Redirect detection runs on the quote-stripped residue: quoted data
167
179
  // spans are command data and their ">" characters are not redirects,
@@ -346,6 +346,24 @@ const BRANCH_CREATION_VALUELESS_FLAGS = new Set([
346
346
  "--no-track",
347
347
  ]);
348
348
 
349
+ // Shell redirections never determine the branch start point. Exact tokens
350
+ // cover separated operators (`2> /dev/null`); the attached pattern covers
351
+ // operators fused with their target (`2>/dev/null`, `2>&1`, `&>/dev/null`).
352
+ // Valid refs cannot contain `<` or `>`, so no start point can be mistaken
353
+ // for a redirection token.
354
+ const BRANCH_REDIRECTION_OPERATORS = new Set([
355
+ "<",
356
+ ">",
357
+ "<<",
358
+ ">>",
359
+ "<<<",
360
+ "<&",
361
+ ">&",
362
+ "&>",
363
+ "&>>",
364
+ ]);
365
+ const BRANCH_REDIRECTION_ATTACHED_RE = /^(?:&|\d{1,2})?(?:>>|>|<<<|<<|>&|<&|<)[^<>]*$/;
366
+
349
367
  // Classifies the optional start-point operand of an already-matched branch
350
368
  // creation invocation (`git switch -c|--create` / `git checkout -b`). Returns
351
369
  // the explicit start point when present, `{ start: undefined }` when the
@@ -357,7 +375,15 @@ export function branchCreationStartPoint(rest: string): { start?: string } | und
357
375
  const tokens = rest.split(/\s+/).filter(Boolean);
358
376
  let sawCreate = false;
359
377
  let named = false;
360
- for (const token of tokens) {
378
+ for (let i = 0; i < tokens.length; i++) {
379
+ const token = tokens[i]!;
380
+ if (BRANCH_REDIRECTION_OPERATORS.has(token) || /^\d{1,2}(?:>>|>|<<<|<<|>&|<&|<)$/.test(token)) {
381
+ // Separated redirection: its target word follows and is equally
382
+ // irrelevant to the start point.
383
+ i += 1;
384
+ continue;
385
+ }
386
+ if (BRANCH_REDIRECTION_ATTACHED_RE.test(token)) continue;
361
387
  const indeterminate = /[$`"']/.test(token);
362
388
  if (!sawCreate) {
363
389
  if (token === "switch" || token === "checkout") continue;
@@ -74,7 +74,7 @@ export function staleWriteReason(path: string, sessionID: string): string | unde
74
74
  const observed = reads.get(sessionID)?.get(canonical);
75
75
  if (!observed) {
76
76
  if (!current) return undefined;
77
- return `Blocked: existing file '${canonical}' has not been read by this session. Re-read it before editing or overwriting it.`;
77
+ return `Blocked: existing file '${canonical}' has not been read by this session. Re-read it before editing or overwriting it. If another session may own it, coordinate before writing.`;
78
78
  }
79
79
  if (!current || !equal(observed, current)) {
80
80
  return `Blocked: file '${canonical}' changed since this session read it. Re-read it before editing or overwriting it.`;
@@ -15,12 +15,14 @@ import { existsSync, readFileSync } from "node:fs";
15
15
  import { resolve } from "node:path";
16
16
  import { join } from "node:path";
17
17
  import { loadedPluginVersion } from "./lib/version.ts";
18
- import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
18
+ import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "./policies/post-edit-validation.ts";
19
19
  import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
20
+ import { extractCommands, splitShellSegments } from "./lib/shell.ts";
21
+ import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "./policies/boundary.ts";
20
22
  import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
21
23
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
22
24
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
23
- import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
25
+ import { guardToolCallImpl, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
24
26
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
25
27
  import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
26
28
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
@@ -503,6 +505,32 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
503
505
  const snapshots = editTargets(args, toolWorktree).map(snapshotFile);
504
506
  if (snapshots.length) toolLifecycle.setPostEditSnapshots(input.sessionID, input.callID, toolWorktree, snapshots);
505
507
  }
508
+ if (SHELL_TOOL_NAMES.has(input.tool)) {
509
+ // An allowed shell redirect authors the target's bytes exactly
510
+ // like edit/write (LL-003), so pre-register each redirect
511
+ // target's pre-state and seed the session's freshness
512
+ // observation after the call succeeds. Observation only: shell
513
+ // writes never run the post-edit validators.
514
+ const record = asRecord(args);
515
+ const shellWorkdir = typeof record?.workdir === "string" ? record.workdir : undefined;
516
+ const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
517
+ const snapshots: FileSnapshot[] = [];
518
+ for (const command of extractCommands(args)) {
519
+ for (const segment of splitShellSegments(command)) {
520
+ for (const mutation of redirectMutationsIn(segment)) {
521
+ const target = mutation.target ?? "";
522
+ if (!target || target === "/dev/null") continue;
523
+ const expanded = expandShellTargetPath(target);
524
+ if (!expanded) continue;
525
+ const resolvedTarget = resolve(shellCwd, expanded);
526
+ if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
527
+ if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
528
+ snapshots.push(snapshotFile(resolvedTarget));
529
+ }
530
+ }
531
+ }
532
+ if (snapshots.length) toolLifecycle.setShellWriteSnapshots(input.sessionID, input.callID, toolWorktree, snapshots);
533
+ }
506
534
  });
507
535
  },
508
536
 
@@ -526,6 +554,14 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
526
554
  const observation = toolLifecycle.takeReadObservation(input.sessionID, input.callID);
527
555
  if (observation) recordSuccessfulRead(observation, input.sessionID);
528
556
  }
557
+ const shellPending = toolLifecycle.takeShellWriteSnapshots(input.sessionID, input.callID);
558
+ if (shellPending) {
559
+ // An allowed shell redirect authored these bytes (LL-003): seed
560
+ // the session's observation so a follow-up edit needs no
561
+ // redundant re-read. The digest gate keeps no-op/failed calls
562
+ // from seeding; validators are intentionally not run here.
563
+ for (const before of shellPending.snapshots) recordMutationObservation(before.path, input.sessionID, before.digest);
564
+ }
529
565
  const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
530
566
  if (!pending) return;
531
567
  await runWithRuntimeState(pending.root, ctx.client, async () => {
package/test/test-e2e.mts CHANGED
@@ -220,12 +220,24 @@ const runV2Probes = async (ctx: any) => {
220
220
  \tconst guardWhy = byId("guard_why");
221
221
  \tconst pick = (r: any) => (typeof r?.content === "string" ? r.content : JSON.stringify(r));
222
222
  \tconst statusRes = guardStatus ? await guardStatus.execute({}, headlessToolContext) : undefined;
223
- \tconst whyRes = guardWhy ? await guardWhy.execute({ tool: "bash", input: { command: "git push origin main" } }, headlessToolContext) : undefined;
223
+ \tconst whyProbes: Record<string, { tool: string; input: Record<string, unknown> }> = {
224
+ \t\tgit: { tool: "bash", input: { command: "git push origin main" } },
225
+ \t\t"secrets-read": { tool: "read", input: { filePath: ".env" } },
226
+ \t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
227
+ \t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
228
+ \t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
229
+ \t};
230
+ \tconst whyResults: Record<string, any> = {};
231
+ \tfor (const [name, probe] of Object.entries(whyProbes)) {
232
+ \t\tconst res = guardWhy ? await guardWhy.execute(probe, headlessToolContext) : undefined;
233
+ \t\twhyResults[name] = res === undefined ? null : JSON.parse(pick(res));
234
+ \t}
224
235
  \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({
225
236
  \t\ttools: tools.map((t: any) => t.id),
226
237
  \t\ttodowriteEnriched: Boolean(todowrite?.description?.includes("Workflow Guard lifecycle")),
227
238
  \t\tstatus: JSON.parse(pick(statusRes)),
228
- \t\twhy: JSON.parse(pick(whyRes)),
239
+ \t\twhy: whyResults.git,
240
+ \t\twhyProbes: whyResults,
229
241
  \t}) + "\\n");
230
242
  \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
231
243
  };
@@ -235,8 +247,19 @@ const runV1Probes = async (ctx: any) => {
235
247
  \tconst directory = ctx?.location?.directory ?? ctx?.directory ?? process.cwd();
236
248
  \tconst toolCtx = { sessionID: "headless-e2e", directory, worktree: directory };
237
249
  \tconst status = await hooks.tool?.guard_status?.execute({}, toolCtx);
238
- \tconst why = await hooks.tool?.guard_why?.execute({ tool: "bash", input: { command: "git push origin main" } }, toolCtx);
239
- \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: JSON.parse(String(why)) }) + "\\n");
250
+ \tconst whyProbes: Record<string, { tool: string; input: Record<string, unknown> }> = {
251
+ \t\tgit: { tool: "bash", input: { command: "git push origin main" } },
252
+ \t\t"secrets-read": { tool: "read", input: { filePath: ".env" } },
253
+ \t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
254
+ \t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
255
+ \t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
256
+ \t};
257
+ \tconst whyResults: Record<string, any> = {};
258
+ \tfor (const [name, probe] of Object.entries(whyProbes)) {
259
+ \t\tconst res = await hooks.tool?.guard_why?.execute(probe, toolCtx);
260
+ \t\twhyResults[name] = res === undefined ? null : JSON.parse(String(res));
261
+ \t}
262
+ \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: whyResults.git, whyProbes: whyResults }) + "\\n");
240
263
  \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
241
264
  \treturn hooks;
242
265
  };
@@ -266,6 +289,18 @@ check("local plugin adapter and source copied successfully", existsSync(localAda
266
289
  spawnSync("git", ["init", "-b", "feat/install-verification"], { cwd: testDir });
267
290
  spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: testDir });
268
291
  spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: testDir });
292
+ // Project configuration loads from .opencode/workflow-guard.json[c]; sentinel
293
+ // values prove the real runtime reads project config (guard_status echoes it)
294
+ // instead of silently falling back to defaults. Both fields are behaviorally
295
+ // inert for every harness probe: no PR is created, record_review is not
296
+ // called, and verifyCommand is left unset so the all-done verification gate
297
+ // still finds no configured command in this configless test project.
298
+ writeFileSync(join(testDir, ".opencode", "workflow-guard.json"), JSON.stringify({
299
+ protectedBranches: ["e2e-config-probe-branch"],
300
+ requireReview: false,
301
+ learning: true,
302
+ recoveryCheckpoints: true,
303
+ }, null, 2) + "\n");
269
304
 
270
305
  const runtimeEnv: NodeJS.ProcessEnv = {
271
306
  ...process.env,
@@ -276,6 +311,10 @@ const runtimeEnv: NodeJS.ProcessEnv = {
276
311
  delete runtimeEnv.OPENCODE_PID;
277
312
  delete runtimeEnv.OPENCODE_PURE;
278
313
  delete runtimeEnv.OPENCODE;
314
+ // The learning config key must stay load-bearing: an inherited
315
+ // WORKFLOW_GUARD_LEARNING=1 would register the learning tools without the
316
+ // sentinel config file being read (review P2, tool-surface iteration).
317
+ delete runtimeEnv.WORKFLOW_GUARD_LEARNING;
279
318
  if (isOpenCodeV2) {
280
319
  // V2 runs a shared managed service on a fixed default port. Each run uses
281
320
  // its own isolated XDG dirs, so pick a run-unique port to keep concurrent
@@ -357,12 +396,28 @@ try {
357
396
  headlessAccountability = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
358
397
  } catch {}
359
398
  check("headless OpenCode runtime exposes structured guard status and why without TUI", headlessAccountability?.status?.workspaceRoot === testDir && headlessAccountability?.why?.policy === "git" && headlessAccountability?.why?.code === "protected_branch_push");
399
+ const whyProbe = (name: string): any => headlessAccountability?.whyProbes?.[name];
400
+ check("headless guard_why simulates secret read policy via read tool", whyProbe("secrets-read")?.policy === "secrets" && whyProbe("secrets-read")?.code === "secret_read" && whyProbe("secrets-read")?.status === "blocked");
401
+ check("headless guard_why simulates secret read policy via shell", whyProbe("secrets-shell")?.policy === "secrets" && whyProbe("secrets-shell")?.code === "secret_read" && whyProbe("secrets-shell")?.status === "blocked");
402
+ check("headless guard_why simulates workspace boundary policy via write tool", whyProbe("boundary-write")?.policy === "boundary" && whyProbe("boundary-write")?.code === "workspace_escape" && whyProbe("boundary-write")?.status === "blocked");
403
+ check("headless guard_why simulates interactive tty shell-safety policy", whyProbe("shell-tty")?.policy === "shell-safety" && whyProbe("shell-tty")?.code === "interactive_tty" && whyProbe("shell-tty")?.status === "blocked");
404
+ check("headless guard_status reports project config loaded from .opencode/workflow-guard.json", headlessAccountability?.status?.projectConfig?.protectedBranches?.[0] === "e2e-config-probe-branch" && headlessAccountability?.status?.projectConfig?.requireReview === false);
360
405
  if (isOpenCodeV2) {
361
406
  // The V2 adapter runs the shipped setup(ctx) for real; verify the
362
407
  // registered tool surface. Builtin description enrichment is best-effort:
363
408
  // builtins are not visible to ctx.tool.list() during plugin setup, so
364
409
  // todowriteEnriched is reported but not asserted.
365
410
  check("V2 setup registers custom guard tools", (headlessAccountability?.tools ?? []).includes("guard_status") && (headlessAccountability?.tools ?? []).includes("guard_why"));
411
+ const expectedGuardTools = [
412
+ "guard_status", "guard_why", "guard_audit", "guard_next_tasks", "record_review",
413
+ "guard_review_rubric", "guard_review_followups", "guard_review_followup_resolve",
414
+ "guard_worktree_create", "guard_worktree_cleanup",
415
+ "project_memory_record", "project_memory_search", "project_memory_export", "project_memory_import",
416
+ "learning_checkpoint", "learning_profile", "learning_record", "guard_recovery_restore",
417
+ ];
418
+ const missingGuardTools = expectedGuardTools.filter((id) => !(headlessAccountability?.tools ?? []).includes(id));
419
+ check("V2 setup registers the full config-gated guard tool surface", missingGuardTools.length === 0);
420
+ if (missingGuardTools.length > 0) console.log(" missing guard tools: " + missingGuardTools.join(", "));
366
421
  console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
367
422
  }
368
423
 
package/test/test.mts CHANGED
@@ -731,6 +731,34 @@ check("tee -- flag separator outside workspace is blocked", blocked(await call("
731
731
  check("multi-target tee with an outside path is blocked", blocked(await call("bash", { command: "echo x | tee in.txt /tmp/wg-outside-tee" }, { sessionID: "s-active" })));
732
732
  check("tee --append within workspace is allowed with todos", !(await call("bash", { command: "echo x | tee --append src/a.ts" }, { sessionID: "s-active" })));
733
733
 
734
+ // Regression (device audit, workspace_escape): symlink aliases of the
735
+ // workspace itself. An absolute target that is lexically outside the root
736
+ // but canonically inside (the alias resolves back into the workspace) must
737
+ // be accepted, while symlinks inside the workspace pointing outside stay
738
+ // blocked. Machine-independent: the alias is built inside the fixture
739
+ // instead of relying on host aliasing such as /home -> /var/home.
740
+ const aliasDir = mkdtempSync(join(tmpdir(), "wg-boundary-alias-"));
741
+ mkdirSync(join(aliasDir, "real"), { recursive: true });
742
+ symlinkSync(aliasDir, join(aliasDir, "alias"), "dir");
743
+ writeFileSync(join(aliasDir, "real", "existing.txt"), "keep\n");
744
+ symlinkSync("/etc", join(aliasDir, "real", "escape"));
745
+ setWorkspaceRoot(join(aliasDir, "real"));
746
+ check("write through symlink alias of the workspace is allowed (new file)", !(await call("write", { filePath: join(aliasDir, "alias", "real", "new-file.txt"), content: "x" }, { sessionID: "s-active" })));
747
+ // The write gate also applies stale-write protection (existing files need a
748
+ // prior session read), so seed the observation by reading through the alias.
749
+ // Plugin setup latches the process root to its directory/worktree
750
+ // (workflow-guard.ts setWorkspaceRoot on setup), so re-latch the alias
751
+ // workspace root before the gated checks.
752
+ const boundaryAliasPlugin = await WorkflowGuard({ directory: root, worktree: root, client: fakeClient as any } as any);
753
+ setWorkspaceRoot(join(aliasDir, "real"));
754
+ await boundaryAliasPlugin["tool.execute.before"]?.({ tool: "read", sessionID: "s-active", callID: "alias-read" }, { args: { filePath: join(aliasDir, "alias", "real", "existing.txt") } } as any);
755
+ await boundaryAliasPlugin["tool.execute.after"]?.({ tool: "read", sessionID: "s-active", callID: "alias-read", args: { filePath: join(aliasDir, "alias", "real", "existing.txt") } } as any, { title: "existing.txt", output: "keep", metadata: {} } as any);
756
+ check("write through symlink alias of the workspace is allowed (existing file)", !(await call("write", { filePath: join(aliasDir, "alias", "real", "existing.txt"), content: "updated" }, { sessionID: "s-active" })));
757
+ check("write through escaping symlink to an outside file stays blocked", blocked(await call("write", { filePath: join(aliasDir, "real", "escape", "passwd"), content: "x" }, { sessionID: "s-active" })));
758
+ check("new file through an escaping symlink stays blocked", blocked(await call("write", { filePath: join(aliasDir, "real", "escape", "wg-escape-new.txt"), content: "x" }, { sessionID: "s-active" })));
759
+ rmSync(aliasDir, { recursive: true, force: true });
760
+ setWorkspaceRoot(root);
761
+
734
762
  console.log("- Compaction focus preservation & TUI toast -");
735
763
  const boundedCompaction = buildCompactionContext("## Operational Guard State\n- critical", ["## Active Tasks\n" + "a".repeat(10_000), "## Project Memory\nshould-not-fit"], 500);
736
764
  check("compaction packer enforces its total context budget", boundedCompaction.length === 500);
@@ -1228,6 +1256,72 @@ try {
1228
1256
  noopEditBlocked = String(error).includes("has not been read");
1229
1257
  }
1230
1258
  check("a no-op mutation does not seed an observation for a later edit", noopEditBlocked);
1259
+ // LL-003 extension: an allowed shell redirect authors the target's bytes
1260
+ // exactly like edit/write, so the follow-up edit/write of the file it wrote
1261
+ // needs no redundant read. Seeding stays byte-change-gated: a /dev/null
1262
+ // redirect seeds nothing and an identical-bytes rewrite seeds nothing.
1263
+ const shellSession = "s-stale-shell";
1264
+ todo(shellSession, item("write scratch file via shell redirect", "in_progress"));
1265
+ const shellAuthoredPath = join(staleDir, "shell-authored.ts");
1266
+ await staleBefore?.({ tool: "bash", sessionID: shellSession, callID: "shell-write" }, { args: { command: "echo x > shell-authored.ts" } });
1267
+ writeFileSync(shellAuthoredPath, "x\n");
1268
+ await staleAfter?.({ tool: "bash", sessionID: shellSession, callID: "shell-write", args: {} }, { title: "bash", output: "x", metadata: {} });
1269
+ let shellAuthoredWriteAllowed = true;
1270
+ try {
1271
+ await staleBefore?.({ tool: "write", sessionID: shellSession, callID: "shell-write-followup" }, { args: { filePath: shellAuthoredPath, content: "next" } });
1272
+ } catch {
1273
+ shellAuthoredWriteAllowed = false;
1274
+ }
1275
+ check("an allowed shell redirect seeds the observation so its authored file can be written without a read", shellAuthoredWriteAllowed);
1276
+ await staleAfter?.({ tool: "write", sessionID: shellSession, callID: "shell-write-followup", args: {} }, { title: "write", output: "written", metadata: {} });
1277
+ const shellUnrelatedSession = "s-stale-shell-unrelated";
1278
+ todo(shellUnrelatedSession, item("edit file untouched by shell", "in_progress"));
1279
+ const shellUnrelatedPath = join(staleDir, "shell-untouched.ts");
1280
+ writeFileSync(shellUnrelatedPath, "observed");
1281
+ await staleBefore?.({ tool: "bash", sessionID: shellUnrelatedSession, callID: "shell-devnull" }, { args: { command: "echo x > /dev/null" } });
1282
+ await staleAfter?.({ tool: "bash", sessionID: shellUnrelatedSession, callID: "shell-devnull", args: {} }, { title: "bash", output: "x", metadata: {} });
1283
+ let shellUnrelatedWriteBlocked = false;
1284
+ try {
1285
+ await staleBefore?.({ tool: "write", sessionID: shellUnrelatedSession, callID: "shell-untouched-write" }, { args: { filePath: shellUnrelatedPath, content: "next" } });
1286
+ } catch (error) {
1287
+ shellUnrelatedWriteBlocked = String(error).includes("has not been read");
1288
+ }
1289
+ check("a shell redirect to /dev/null seeds nothing, so an unobserved file stays blocked", shellUnrelatedWriteBlocked);
1290
+ const shellNoopSession = "s-stale-shell-noop";
1291
+ todo(shellNoopSession, item("no-op shell redirect", "in_progress"));
1292
+ const shellNoopPath = join(staleDir, "shell-noop.ts");
1293
+ writeFileSync(shellNoopPath, "x\n");
1294
+ await staleBefore?.({ tool: "bash", sessionID: shellNoopSession, callID: "shell-noop" }, { args: { command: "echo x > shell-noop.ts" } });
1295
+ writeFileSync(shellNoopPath, "x\n");
1296
+ await staleAfter?.({ tool: "bash", sessionID: shellNoopSession, callID: "shell-noop", args: {} }, { title: "bash", output: "x", metadata: {} });
1297
+ let shellNoopWriteBlocked = false;
1298
+ try {
1299
+ await staleBefore?.({ tool: "write", sessionID: shellNoopSession, callID: "shell-noop-write" }, { args: { filePath: shellNoopPath, content: "changed" } });
1300
+ } catch (error) {
1301
+ shellNoopWriteBlocked = String(error).includes("has not been read");
1302
+ }
1303
+ check("a no-op shell redirect (identical bytes) does not seed an observation", shellNoopWriteBlocked);
1304
+ // Path-spelling alias convergence: an observation seeded through one
1305
+ // absolute spelling of a file must authorize the mutation requested through
1306
+ // a symlink-aliased spelling of the same file (canonicalPath convergence).
1307
+ const staleAliasDir = mkdtempSync(join(tmpdir(), "wg-stale-alias-"));
1308
+ const staleAliasRealDir = join(staleAliasDir, "real");
1309
+ mkdirSync(staleAliasRealDir);
1310
+ symlinkSync(staleAliasRealDir, join(staleAliasDir, "alias"));
1311
+ const aliasRealPath = join(staleAliasRealDir, "aliased.ts");
1312
+ writeFileSync(aliasRealPath, "observed");
1313
+ todo("s-stale-alias", item("edit aliased file", "in_progress"));
1314
+ await staleBefore?.({ tool: "read", sessionID: "s-stale-alias", callID: "alias-read", worktree: staleAliasDir } as any, { args: { filePath: aliasRealPath } });
1315
+ await staleAfter?.({ tool: "read", sessionID: "s-stale-alias", callID: "alias-read", args: { filePath: aliasRealPath } }, { title: "aliased.ts", output: "observed", metadata: {} });
1316
+ let aliasWriteAllowed = true;
1317
+ try {
1318
+ await staleBefore?.({ tool: "write", sessionID: "s-stale-alias", callID: "alias-write", worktree: staleAliasDir } as any, { args: { filePath: join(staleAliasDir, "alias", "aliased.ts"), content: "next" } });
1319
+ } catch {
1320
+ aliasWriteAllowed = false;
1321
+ }
1322
+ check("a read observed through one path spelling authorizes the write through its symlink alias", aliasWriteAllowed);
1323
+ await staleAfter?.({ tool: "write", sessionID: "s-stale-alias", callID: "alias-write", args: {} }, { title: "write", output: "written", metadata: {} });
1324
+ rmSync(staleAliasDir, { recursive: true, force: true });
1231
1325
  const staleAlternateRoot = mkdtempSync(join(tmpdir(), "wg-stale-root-"));
1232
1326
  writeFileSync(join(staleAlternateRoot, "relative.ts"), "observed");
1233
1327
  todo("s-stale-root", item("edit relative file", "in_progress"));
@@ -1737,6 +1831,10 @@ check("dynamic shell syntax detects IFS construction", dynamicShellSyntaxIn("git
1737
1831
  check("dynamic shell syntax detects ambiguous whitespace", dynamicShellSyntaxIn("git\rpush") !== undefined && dynamicShellSyntaxIn("git\u00a0push") !== undefined);
1738
1832
  check("dynamic shell syntax detects malformed quote boundaries", dynamicShellSyntaxIn("echo 'unterminated") !== undefined && dynamicShellSyntaxIn("echo trailing\\") !== undefined);
1739
1833
  check("dynamic shell syntax preserves quoted literals", dynamicShellSyntaxIn("printf '%s' '$(literal) `literal` <(literal) $IFS'") === undefined);
1834
+ const substitutionBlock = await guardToolDecision("bash", { command: "echo $(dangerous)" }, { sessionID: "s-dynamic-syntax" });
1835
+ check("substitution block names the construct and remedy", substitutionBlock.status === "blocked" && substitutionBlock.code === "dynamic_shell_syntax" && substitutionBlock.message.includes("process substitution") && substitutionBlock.message.includes("guard_why"));
1836
+ const quotingBlock = await guardToolDecision("bash", { command: "echo 'unterminated" }, { sessionID: "s-dynamic-syntax" });
1837
+ check("malformed quoting block names the construct and remedy", quotingBlock.status === "blocked" && quotingBlock.code === "dynamic_shell_syntax" && quotingBlock.message.includes("balanced quoting"));
1740
1838
  check(
1741
1839
  "runVerify terminates timed-out verification commands safely",
1742
1840
  !verifyTimeout.passed && verifyTimeout.output.includes("timed out"),
@@ -3154,6 +3252,27 @@ check("git checkout -b origin/main with a stale branch is allowed", !blocked(awa
3154
3252
  check("wrapper git switch -c origin/main keeps the fresh start point exemption", !blocked(await shell("env git switch -c wg-wrapper-fresh origin/main")));
3155
3253
  check("variable start point fails closed on a stale branch", blocked(await shell("git switch -c wg-start-var $WG_REF")));
3156
3254
  check("plain git branch is not treated as guarded branch creation", !blocked(await shell("git branch wg-plain-branch origin/main")));
3255
+ // Redirection tokens are never start points (audit regression: `git switch
3256
+ // -c feat/x 2>&1` classified the trailing `2>` left by `&`-segment splitting
3257
+ // as an explicit start point and failed closed).
3258
+ const redirStale = await shell("git switch -c wg-redir-stale 2>/dev/null");
3259
+ check(
3260
+ "redirect to /dev/null is not a start point on a stale branch (still stale-blocked)",
3261
+ blocked(redirStale) && (redirStale as string).includes("'feat/conflict-branch'") && !(redirStale as string).includes("'2>'"),
3262
+ );
3263
+ spawnSync("git", ["switch", "-c", "wg-redir-base", "origin/main"], { cwd: conflictRepo });
3264
+ check("attached stderr redirect is not a start point on a fresh branch", !blocked(await shell("git switch -c wg-redir-null 2>/dev/null")));
3265
+ check("separated stdout redirect is not a start point on a fresh branch", !blocked(await shell("git checkout -b wg-redir-out > /dev/null")));
3266
+ check("fd duplication 2>&1 left by &-segment splitting is not a start point", !blocked(await shell("git switch -c wg-redir-dup 2>&1")));
3267
+ check("separated fd stderr redirect is not a start point on a fresh branch", !blocked(await shell("git switch -c wg-redir-sep 2> /dev/null")));
3268
+ check("explicit start point before a redirect stays effective", !blocked(await shell("git switch -c wg-redir-after origin/main 2>&1")));
3269
+ spawnSync("git", ["switch", "feat/conflict-branch"], { cwd: conflictRepo });
3270
+ const staleShaRedirect = await shell(`git switch -c wg-stale-redir ${staleStartSha} >/dev/null`);
3271
+ check(
3272
+ "explicit stale start point is still classified behind a redirect",
3273
+ blocked(staleShaRedirect) && (staleShaRedirect as string).includes("start point") && (staleShaRedirect as string).includes(staleStartSha),
3274
+ );
3275
+ check("variable start point behind a redirect still fails closed", blocked(await shell("git switch -c wg-var-redir $WG_REF 2>/dev/null")));
3157
3276
 
3158
3277
  // Lockfile synchronization tests
3159
3278
  const lockRepo = mkdtempSync(join(tmpdir(), "wg-lock-repo-"));