opencode-workflow-guard 1.15.1 → 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.
- package/docs/agents/lessons/2026-09-27-boundary-symlink-alias-escape.md +7 -0
- package/docs/agents/lessons/2026-09-27-branch-create-redirect-start-point.md +6 -0
- package/docs/agents/lessons/2026-09-27-e2e-config-sentinel-proof.md +4 -0
- package/docs/agents/lessons/2026-09-27-e2e-guard-why-policy-probes.md +6 -0
- package/docs/agents/lessons/2026-09-27-e2e-tool-surface-enumeration.md +4 -0
- package/docs/agents/lessons/2026-09-27-shell-syntax-remedy-messages.md +7 -0
- package/docs/agents/lessons/2026-09-27-stale-write-shell-seeding.md +7 -0
- package/docs/agents/lessons.md +4 -0
- package/docs/policies.md +4 -2
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +58 -18
- package/src/lib/guard-dispatcher.ts +38 -3
- package/src/lib/state.ts +118 -49
- package/src/lib/tool-lifecycle.ts +15 -1
- package/src/lib/v2-plugin.ts +47 -12
- package/src/policies/boundary.ts +26 -7
- package/src/policies/changelog.ts +112 -11
- package/src/policies/git.ts +27 -1
- package/src/policies/stale-write.ts +1 -1
- package/src/workflow-guard.ts +52 -10
- package/test/test-e2e.mts +59 -4
- package/test/test.mts +334 -0
|
@@ -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).
|
package/docs/agents/lessons.md
CHANGED
|
@@ -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`).
|
|
@@ -31,7 +32,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
31
32
|
- The branch diff modifies a `CHANGELOG` file, OR
|
|
32
33
|
- The branch diff adds/modifies a `.changeset/*.md` fragment file (Changesets workflow), OR
|
|
33
34
|
- The PR description (`--body`, `--description`, or `--body-file`/`-F`) contains a release-information section headed `Changelog`, `Changes`, `Release notes`, or `Summary`. Multiline Bash ANSI-C `\n`/`\r` quoting is supported for inline descriptions; inline descriptions that would render Markdown line breaks as literal `\n`/`\r` text are blocked with guidance to use real newlines or a description file.
|
|
34
|
-
- **Lockfile Synchronization Gate:** Whenever package manifests (`package.json`, `Cargo.toml`, `go.mod`) are modified, PR creation is blocked until their corresponding lockfiles (`package-lock.json`/`bun.lock`/`pnpm-lock.yaml`/`yarn.lock`, `Cargo.lock`, `go.sum`) are updated.
|
|
35
|
+
- **Lockfile Synchronization Gate:** Whenever package manifests (`package.json`, `Cargo.toml`, `go.mod`) are modified, PR creation is blocked until their corresponding lockfiles (`package-lock.json`/`bun.lock`/`pnpm-lock.yaml`/`yarn.lock`, `Cargo.lock`, `go.sum`) are updated. For `package.json` the demand is section-aware: it fires when a section the lockfile records changed (`dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`, `peerDependenciesMeta`, `engines`, `packageManager`, `overrides`, `resolutions`, `workspaces`, `pnpm`, or the project `name`/`version`). Scripts- or metadata-only manifest edits regenerate the lockfile byte-identical and pass without one. `Cargo.toml` and `go.mod` changes conservatively keep demanding their lockfiles.
|
|
35
36
|
- When multiple PR prerequisites fail at once, the guard reports all detected preflight failures together so they can be resolved before retrying PR creation.
|
|
36
37
|
|
|
37
38
|
### 4. Destructive CLI Operations Guard
|
|
@@ -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
package/src/lib/custom-tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { resolve } from "node:path";
|
|
3
4
|
import type { AuditEntry, LearningEvidenceKind } from "./types.ts";
|
|
4
5
|
import type { ProjectMemoryStore } from "./project-memory.ts";
|
|
5
6
|
import {
|
|
@@ -31,10 +32,11 @@ import {
|
|
|
31
32
|
recordMutation,
|
|
32
33
|
recordReviewResult,
|
|
33
34
|
resolveEffectiveWorkspace,
|
|
35
|
+
resolveSessionBoundWorkspace,
|
|
34
36
|
runWithRuntimeState,
|
|
35
37
|
} from "./state.ts";
|
|
36
38
|
import { getRalphOutcome } from "../policies/continuation.ts";
|
|
37
|
-
import { loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
39
|
+
import { findGitRoot, loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
38
40
|
import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
|
|
39
41
|
import { buildReviewRubric } from "./review.ts";
|
|
40
42
|
import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
|
|
@@ -121,6 +123,27 @@ export function createCustomTools(options: {
|
|
|
121
123
|
client: unknown;
|
|
122
124
|
}) {
|
|
123
125
|
const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
|
|
126
|
+
// Resolve the workspace for a tool call from the CALL's own context: an
|
|
127
|
+
// explicit argument first, then the host-provided per-call worktree or
|
|
128
|
+
// directory (even when unusable — the call declared its target), then the
|
|
129
|
+
// session's bound workspace. The plugin-instance default (`effectiveRoot`)
|
|
130
|
+
// is only the last resort — concurrent loops share one guard process, so
|
|
131
|
+
// an instance-derived root must never re-anchor another session's tool
|
|
132
|
+
// execution.
|
|
133
|
+
const resolveCallWorkspace = async (
|
|
134
|
+
toolContext: { sessionID?: string; worktree?: string; directory?: string } | undefined,
|
|
135
|
+
explicitDirectory?: string,
|
|
136
|
+
): Promise<string> => {
|
|
137
|
+
if (explicitDirectory) {
|
|
138
|
+
return findGitRoot(explicitDirectory) ?? projectRootKey(explicitDirectory);
|
|
139
|
+
}
|
|
140
|
+
const hostContext = toolContext?.worktree || toolContext?.directory;
|
|
141
|
+
const hostIsFilesystemRoot = hostContext ? resolve(hostContext) === resolve(hostContext, "..") : true;
|
|
142
|
+
if (hostContext && !hostIsFilesystemRoot) {
|
|
143
|
+
return findGitRoot(hostContext) ?? projectRootKey(hostContext);
|
|
144
|
+
}
|
|
145
|
+
return resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, fallback: effectiveRoot });
|
|
146
|
+
};
|
|
124
147
|
return {
|
|
125
148
|
...(isRecoveryCheckpointsEnabled(effectiveRoot) ? {
|
|
126
149
|
guard_recovery_restore: tool({
|
|
@@ -131,7 +154,7 @@ export function createCustomTools(options: {
|
|
|
131
154
|
const parent = await fetchParentSession(toolContext.sessionID);
|
|
132
155
|
if (!parent.ok) return "[workflow-guard] Recovery rejected: could not confirm this is a root session.";
|
|
133
156
|
if (parent.parentID) return "[workflow-guard] Recovery rejected: only root sessions can restore their checkpoints.";
|
|
134
|
-
const result = restoreRecoveryCheckpoint(
|
|
157
|
+
const result = restoreRecoveryCheckpoint(await resolveCallWorkspace(toolContext), toolContext.sessionID, args.run);
|
|
135
158
|
return result.ok ? `[workflow-guard] Restored recovery checkpoint for run ${args.run}.` : `[workflow-guard] Recovery rejected: ${result.error}`;
|
|
136
159
|
},
|
|
137
160
|
}),
|
|
@@ -139,8 +162,8 @@ export function createCustomTools(options: {
|
|
|
139
162
|
guard_next_tasks: tool({
|
|
140
163
|
description: "Load durable repository task context when deciding what to work on next or planning upcoming steps. Proactively call at session start or during planning to discover roadmap, plan, tasks, backlog, and TODO.md files.",
|
|
141
164
|
args: {},
|
|
142
|
-
execute: async () => {
|
|
143
|
-
const sources = discoverPlanningSources(
|
|
165
|
+
execute: async (_args, toolContext) => {
|
|
166
|
+
const sources = discoverPlanningSources(await resolveCallWorkspace(toolContext));
|
|
144
167
|
return sources.length > 0 ? JSON.stringify({ sources }, null, 2) : JSON.stringify({ sources: [], message: "No TODO, roadmap, plan, tasks, or backlog Markdown files found." });
|
|
145
168
|
},
|
|
146
169
|
}),
|
|
@@ -158,7 +181,7 @@ export function createCustomTools(options: {
|
|
|
158
181
|
if (!(["fact", "decision", "constraint", "lesson"] as string[]).includes(args.kind)) return "[workflow-guard] Project memory rejected: invalid kind.";
|
|
159
182
|
const detectedSecret = secretIn(args.content);
|
|
160
183
|
if (detectedSecret) return `[workflow-guard] Project memory rejected: possible secret detected (${detectedSecret}).`;
|
|
161
|
-
return JSON.stringify(recordProjectMemory(projectMemory, { kind: args.kind as "fact" | "decision" | "constraint" | "lesson", content: args.content, source: "agent", sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(
|
|
184
|
+
return JSON.stringify(recordProjectMemory(projectMemory, { kind: args.kind as "fact" | "decision" | "constraint" | "lesson", content: args.content, source: "agent", sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(await resolveCallWorkspace(toolContext)), paths: args.paths, supersedes: args.supersedes || undefined }));
|
|
162
185
|
},
|
|
163
186
|
}),
|
|
164
187
|
project_memory_export: tool({
|
|
@@ -181,7 +204,7 @@ export function createCustomTools(options: {
|
|
|
181
204
|
execute: async (args, toolContext) => {
|
|
182
205
|
const valid = args.opportunities.slice(0, 20).filter((candidate): candidate is typeof candidate & { type: "design" | "debugging" | "new-concept" } => (candidate.type === "design" || candidate.type === "debugging" || candidate.type === "new-concept") && candidate.concept.length > 0 && candidate.concept.length <= 100 && candidate.relevance >= 0 && candidate.relevance <= 1 && candidate.consequence >= 0 && candidate.consequence <= 1);
|
|
183
206
|
const used = learningInterventions.get(toolContext.sessionID) ?? 0;
|
|
184
|
-
const selected = selectLearningOpportunity(loadLearnerProfile(), valid, { interventionsThisSession: used, maxInterventionsPerSession: getLearningInterventionBudget(
|
|
207
|
+
const selected = selectLearningOpportunity(loadLearnerProfile(), valid, { interventionsThisSession: used, maxInterventionsPerSession: getLearningInterventionBudget(await resolveCallWorkspace(toolContext)) });
|
|
185
208
|
if (!selected) return JSON.stringify({ intervene: false, reason: "No opportunity selected or session learning budget reached." });
|
|
186
209
|
learningInterventions.set(toolContext.sessionID, used + 1);
|
|
187
210
|
return JSON.stringify({ intervene: true, opportunity: selected, guidance: "Ask one question, use the answer as task context, briefly reconcile if useful, then continue building." });
|
|
@@ -194,10 +217,11 @@ export function createCustomTools(options: {
|
|
|
194
217
|
const validKinds = new Set(["exposed", "developing", "demonstrated", "independent", "critique", "needs-reinforcement"]);
|
|
195
218
|
if (!validKinds.has(args.kind)) return "[workflow-guard] Learning evidence rejected: invalid evidence kind.";
|
|
196
219
|
if (args.concept.length < 1 || args.concept.length > 100 || args.summary.length < 1 || args.summary.length > 1000) return "[workflow-guard] Learning evidence rejected: concept must be 1-100 characters and summary 1-1000 characters.";
|
|
220
|
+
const learningWorkspace = await resolveCallWorkspace(toolContext);
|
|
197
221
|
try {
|
|
198
222
|
updateLearnerProfile((profile) => {
|
|
199
223
|
if (!profile.concepts[args.concept] && Object.keys(profile.concepts).length >= 500) throw new Error("concept-limit");
|
|
200
|
-
recordLearningEvidence(profile, { concept: args.concept, kind: args.kind as LearningEvidenceKind, summary: args.summary, timestamp: Date.now(), sessionID: toolContext.sessionID, project:
|
|
224
|
+
recordLearningEvidence(profile, { concept: args.concept, kind: args.kind as LearningEvidenceKind, summary: args.summary, timestamp: Date.now(), sessionID: toolContext.sessionID, project: learningWorkspace });
|
|
201
225
|
});
|
|
202
226
|
} catch (error) {
|
|
203
227
|
if ((error as Error).message === "concept-limit") return "[workflow-guard] Learning evidence rejected: learner profile concept limit reached.";
|
|
@@ -208,12 +232,12 @@ export function createCustomTools(options: {
|
|
|
208
232
|
}),
|
|
209
233
|
} : {}),
|
|
210
234
|
guard_status: tool({
|
|
211
|
-
description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. The result includes pluginVersion, the version of the loaded guard package, so a stale installation is visible, and a read-only gitHygiene snapshot (local branches already merged into the mainline, prunable worktree admin entries, base-behind distance). Proactively call at session start and before completing tasks or creating PRs.",
|
|
235
|
+
description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. The result includes pluginVersion, the version of the loaded guard package, so a stale installation is visible, and a read-only gitHygiene snapshot (local branches already merged into the mainline, prunable worktree admin entries, base-behind distance). Proactively call at session start and before completing tasks or creating PRs. Concurrent loops share one guard process, so pass 'directory' explicitly when inspecting a root other than this session's own workspace.",
|
|
212
236
|
args: {
|
|
213
|
-
directory: tool.schema.string().optional().describe("Target repository directory to inspect (defaults to
|
|
237
|
+
directory: tool.schema.string().optional().describe("Target repository directory to inspect (defaults to this session's own workspace binding, never another concurrent session's root)"),
|
|
214
238
|
},
|
|
215
239
|
execute: async (args, toolContext) => {
|
|
216
|
-
const root = await
|
|
240
|
+
const root = await resolveCallWorkspace(toolContext, args?.directory);
|
|
217
241
|
const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
|
|
218
242
|
const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
|
|
219
243
|
const verifyEvidence = lastV ? verificationEvidence(lastV) : undefined;
|
|
@@ -256,14 +280,14 @@ export function createCustomTools(options: {
|
|
|
256
280
|
},
|
|
257
281
|
}),
|
|
258
282
|
guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard. Use to diagnose policy decisions, blocked commands, or outcome telemetry.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
|
|
259
|
-
guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(
|
|
283
|
+
guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(await resolveCallWorkspace(toolContext), client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
|
|
260
284
|
record_review: tool({
|
|
261
285
|
description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric (test integrity, task completeness, cleanliness, security, platform). Projects with requireSubagentReview enabled only accept APPROVALS recorded from a subagent session (one with a parent session), never from the root session itself.",
|
|
262
286
|
args: {
|
|
263
287
|
reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"),
|
|
264
288
|
summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"),
|
|
265
289
|
passed: tool.schema.boolean().describe("True if change is approved, false if changes requested"),
|
|
266
|
-
directory: tool.schema.string().optional().describe("Target repository directory being reviewed
|
|
290
|
+
directory: tool.schema.string().optional().describe("Target repository directory being reviewed. Pass it explicitly whenever the recorder session has no workspace binding: concurrent loops share one guard process, and a verdict is never bound to a guessed root."),
|
|
267
291
|
},
|
|
268
292
|
execute: async (args, toolContext) => {
|
|
269
293
|
const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string, binding?: NonNullable<AuditEntry["evidence"]>["binding"]) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict, ...(binding ? { binding } : {}) } });
|
|
@@ -277,7 +301,19 @@ export function createCustomTools(options: {
|
|
|
277
301
|
// hash + tracked worktree fingerprint); the recorder's lineage is
|
|
278
302
|
// captured in the audit trail. The review-before-PR requirement
|
|
279
303
|
// itself is unchanged.
|
|
280
|
-
|
|
304
|
+
// Binding is never guessed: an explicit directory, the session's
|
|
305
|
+
// own workspace binding (direct or inherited from a bound
|
|
306
|
+
// parent), or a host-provided per-call worktree. When none of
|
|
307
|
+
// those exist the verdict is rejected instead of silently bound
|
|
308
|
+
// to a latched root — concurrent loops share one guard process,
|
|
309
|
+
// and a latched root can belong to another loop's repository.
|
|
310
|
+
const hostProvidesWorkspace = Boolean(toolContext?.worktree);
|
|
311
|
+
const sessionBinding = await resolveSessionBoundWorkspace(toolContext?.sessionID);
|
|
312
|
+
if (!args.directory && !sessionBinding && !hostProvidesWorkspace) {
|
|
313
|
+
auditVerdict("rejected", "review_binding_unresolved", { recorderSessionID: toolContext?.sessionID });
|
|
314
|
+
return "[workflow-guard] Review rejected: the reviewed workspace could not be resolved from this call (no explicit 'directory', no session workspace binding, no per-call worktree). Concurrent loops share one guard process, so a verdict is never bound to a guessed root. Call record_review with 'directory' set to the reviewed repository worktree.";
|
|
315
|
+
}
|
|
316
|
+
const reviewWorkspace = await resolveCallWorkspace(toolContext, args.directory);
|
|
281
317
|
const bindingFingerprint = getTrackedWorktreeFingerprint(reviewWorkspace);
|
|
282
318
|
if (!bindingFingerprint) {
|
|
283
319
|
auditVerdict("rejected", "review_workspace_unbindingable", { workspace: reviewWorkspace, recorderSessionID: toolContext.sessionID });
|
|
@@ -325,7 +361,7 @@ export function createCustomTools(options: {
|
|
|
325
361
|
execute: async (args, toolContext) => {
|
|
326
362
|
const sanitizedBase = typeof args.base === "string" && !args.base.startsWith("-") && !/[\s;'"\0]/.test(args.base) ? args.base : undefined;
|
|
327
363
|
const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
|
|
328
|
-
const rubricWorkspace = await
|
|
364
|
+
const rubricWorkspace = await resolveCallWorkspace(toolContext, args?.directory);
|
|
329
365
|
let diffText = "";
|
|
330
366
|
// Revision before the trailing `--` (LL-001) so the base ref is
|
|
331
367
|
// always parsed as a revision, never as a pathspec.
|
|
@@ -338,16 +374,20 @@ export function createCustomTools(options: {
|
|
|
338
374
|
description: "Create an isolated git worktree directory for concurrent subagent execution. Proactively call when launching parallel tasks/subagents to prevent file collision and maintain branch isolation.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
|
|
339
375
|
execute: async (args, toolContext) => {
|
|
340
376
|
const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
|
|
341
|
-
const toolRoot =
|
|
342
|
-
|
|
377
|
+
const toolRoot = await resolveCallWorkspace(toolContext); const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
|
|
378
|
+
const mutationRoot = await resolveCallWorkspace(toolContext);
|
|
379
|
+
await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
|
|
380
|
+
return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
|
|
343
381
|
},
|
|
344
382
|
}),
|
|
345
383
|
guard_worktree_cleanup: tool({
|
|
346
384
|
description: "Commit a final snapshot and remove an isolated git worktree directory. Call to clean up worktrees created with guard_worktree_create after subagent tasks complete.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
|
|
347
385
|
execute: async (args, toolContext) => {
|
|
348
386
|
const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
|
|
349
|
-
const toolRoot =
|
|
350
|
-
|
|
387
|
+
const toolRoot = await resolveCallWorkspace(toolContext); const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
|
|
388
|
+
const mutationRoot = await resolveCallWorkspace(toolContext);
|
|
389
|
+
await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
|
|
390
|
+
return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
|
|
351
391
|
},
|
|
352
392
|
}),
|
|
353
393
|
};
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getLastReviewResultForWorkspace,
|
|
8
8
|
getLiveControlPlaneRoots,
|
|
9
9
|
getSdkClient,
|
|
10
|
+
getSessionBoundWorkspace,
|
|
10
11
|
getWorkspaceRoot,
|
|
11
12
|
isDocumentationRequired,
|
|
12
13
|
isReviewRequired,
|
|
@@ -77,7 +78,30 @@ import {
|
|
|
77
78
|
validateTodoLifecycle,
|
|
78
79
|
} from "../policies/todo.ts";
|
|
79
80
|
|
|
80
|
-
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
|
+
};
|
|
81
105
|
|
|
82
106
|
const READ_ONLY_ROLES = new Set([
|
|
83
107
|
"reviewer",
|
|
@@ -114,7 +138,15 @@ export async function guardToolCallImpl(
|
|
|
114
138
|
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string; simulate?: boolean },
|
|
115
139
|
): Promise<PolicyDecision> {
|
|
116
140
|
const logPolicyBlock = (message: string) => logBlock(message, context?.simulate);
|
|
117
|
-
|
|
141
|
+
// The workspace for THIS decision comes from the call's own context (the
|
|
142
|
+
// hook's per-call worktree/directory) or, failing that, the session's own
|
|
143
|
+
// bound workspace — never from one process-global slot: concurrent loops
|
|
144
|
+
// share this process, and the last-active instance must not re-anchor
|
|
145
|
+
// another session's policy evaluation.
|
|
146
|
+
const currentRoot = context?.worktree
|
|
147
|
+
|| context?.directory
|
|
148
|
+
|| getSessionBoundWorkspace(context?.sessionID)
|
|
149
|
+
|| getWorkspaceRoot();
|
|
118
150
|
const allow = (): PolicyDecision => ({ status: "allowed", code: "allowed", message: "Allowed by current guardrails." });
|
|
119
151
|
const block = (policy: string, code: string, message: string): PolicyDecision => ({ status: "blocked", policy, code, message });
|
|
120
152
|
const needsApproval = (policy: string, code: string, message: string): PolicyDecision => ({ status: "needs_approval", policy, code, message });
|
|
@@ -330,7 +362,10 @@ export async function guardToolCallImpl(
|
|
|
330
362
|
|
|
331
363
|
for (const raw of extractCommands(input)) {
|
|
332
364
|
const dynamicSyntax = dynamicShellSyntaxIn(raw);
|
|
333
|
-
if (dynamicSyntax)
|
|
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
|
+
}
|
|
334
369
|
const command = normalize(raw);
|
|
335
370
|
const ttyCheck = checkInteractiveTtyCommand(command);
|
|
336
371
|
if (ttyCheck.isInteractive) {
|