opencode-workflow-guard 1.15.2 → 1.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- 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/installation.md +1 -1
- package/docs/policies.md +3 -1
- package/package.json +1 -1
- package/src/lib/guard-dispatcher.ts +28 -2
- package/src/lib/project-memory.ts +16 -0
- package/src/lib/tool-lifecycle.ts +15 -1
- package/src/lib/v2-plugin.ts +37 -2
- package/src/policies/boundary.ts +17 -5
- package/src/policies/git.ts +27 -1
- package/src/policies/stale-write.ts +1 -1
- package/src/workflow-guard.ts +38 -2
- package/test/test-e2e.mts +59 -4
- package/test/test.mts +142 -0
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/opencode-workflow-guard)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](test/test.mts)
|
|
6
6
|
|
|
7
|
-
Deterministic policy and enforcement layer for OpenCode. Workflow Guard enforces
|
|
7
|
+
Deterministic policy and enforcement layer for OpenCode. Workflow Guard enforces safe engineering discipline through **hard plugin hooks**, not prompt instructions that LLMs can ignore, and pairs enforcement with a proactive workflow-tool surface for planning, review, worktree isolation, technical debt, and policy simulation.
|
|
8
8
|
|
|
9
9
|
Workflow Guard is deliberately **not an agent harness**: it can constrain actions, record and explain decisions, and supply bounded context, but it does not plan, prioritize, delegate, or autonomously sequence the agent's work. Bounded continuation may resume an existing session with unfinished owned todos; it never chooses the next task or creates new work.
|
|
10
10
|
|
|
@@ -34,7 +34,7 @@ For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`.
|
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
36
|
npm run typecheck # Strict TypeScript check (0 errors)
|
|
37
|
-
npm test # Run
|
|
37
|
+
npm test # Run 1100+ unit and adversarial tests
|
|
38
38
|
npm run test:all # Typecheck + unit + install/load checks
|
|
39
39
|
WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # Also run policy probes with OpenCode's most recently selected model
|
|
40
40
|
```
|
|
@@ -54,11 +54,27 @@ For complete policy specifications and override rules, see [docs/policies.md](do
|
|
|
54
54
|
|
|
55
55
|
---
|
|
56
56
|
|
|
57
|
+
## Proactive Workflow Tools
|
|
58
|
+
|
|
59
|
+
Enforcement is only half the surface. The plugin also registers first-class agent tools (same tool names on OpenCode 1.x and 2.x) so disciplined workflow is the path of least resistance:
|
|
60
|
+
|
|
61
|
+
* **Task Planning:** `guard_next_tasks` surfaces durable repository planning context (`TODO.md`, `ROADMAP.md`, `PLAN.md`, `TASKS.md`, `BACKLOG.md`, their `docs/` counterparts, and plans under `docs/plans/`) at session start or during planning; `guard_status` reports active guardrails, branch protection, mutation counts, outstanding verification/review gates, a read-only git-hygiene snapshot, and the loaded `pluginVersion`.
|
|
62
|
+
* **Review & Verification:** before PR creation, a secondary reviewer subagent evaluates the diff against `guard_review_rubric` (five core axes: test integrity, task completeness, cleanliness, security, platform) and records its verdict with `record_review`; approvals are bound to the reviewed worktree content.
|
|
63
|
+
* **Worktree Isolation:** `guard_worktree_create` and `guard_worktree_cleanup` give parallel subagents isolated linked worktrees so concurrent mutations never collide.
|
|
64
|
+
* **Technical Debt:** `guard_review_followups` lists open P2/P3 durable review follow-ups; `guard_review_followup_resolve` closes them after the underlying issue is fixed and verified.
|
|
65
|
+
* **Policy Simulation:** `guard_why` returns the structured policy decision for a tool call or command without performing it; `guard_audit` shows recent audited policy entries.
|
|
66
|
+
* **Project Memory** (on by default): `project_memory_search`, `project_memory_record`, `project_memory_export`, and `project_memory_import` maintain durable facts, decisions, constraints, and lessons; secret content is rejected, and export promotes records to the repo-local `.opencode/memory/project-memory.jsonl`.
|
|
67
|
+
* **Socratic Learning** (off by default): `learning_checkpoint`, `learning_profile`, and `learning_record` maintain a global evidence-based learner profile with a per-session intervention budget.
|
|
68
|
+
|
|
69
|
+
The context modules — project memory, Socratic learning, and recovery checkpoints (`guard_recovery_restore`) — are independently gated and can be disabled without weakening policy enforcement; see [docs/installation.md](docs/installation.md). Full tool specifications live in [docs/policies.md](docs/policies.md#custom-tools).
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
57
73
|
## Documentation Index
|
|
58
74
|
|
|
59
75
|
| Guide | Description |
|
|
60
76
|
|---|---|
|
|
61
|
-
| [**Policy Reference**](docs/policies.md) | Comprehensive specification of all 24 enforced policies, invariants, and
|
|
77
|
+
| [**Policy Reference**](docs/policies.md) | Comprehensive specification of all 24 enforced policies, invariants, and the proactive tool surface (`guard_*`, `project_memory_*`, `learning_*`) |
|
|
62
78
|
| [**Installation & Configuration**](docs/installation.md) | Setup options, global vs. local install, worktree isolation, and project configuration |
|
|
63
79
|
| [**Managed Deployment**](docs/managed-deployment.md) | Administrator-managed OpenCode policy, platform locations, and startup diagnostics |
|
|
64
80
|
| [**Troubleshooting**](docs/troubleshooting.md) | Diagnosing policy blocks, common false positives, and emergency override procedures |
|
|
@@ -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/installation.md
CHANGED
|
@@ -206,7 +206,7 @@ Project memory is optional supporting context, enabled by default, and requires
|
|
|
206
206
|
|
|
207
207
|
The plugin exposes `project_memory_search`, `project_memory_record`, `project_memory_export`, and `project_memory_import`. Recording is intended only for durable facts, decisions, constraints, and lessons; recognized secret content is rejected. Routine tool/test/verification output, transient errors and retries, successful operational telemetry, todos, and session progress belong in their existing operational stores rather than project memory.
|
|
208
208
|
|
|
209
|
-
Project-memory maintenance runs opportunistically when a project index is opened, at most once per day. Superseded memories and resolved review follow-ups are retained for 90 days and then pruned; current memories are not expired just because they are old. Maintenance checkpoints and compacts the active SQLite database. When the project-memory directory exceeds 100 MB, least-recently-used inactive project databases are evicted toward about 80 MB when eligible databases exist. The current project's database is never selected for global eviction, and maintenance failures do not disable core guard enforcement.
|
|
209
|
+
Project-memory maintenance runs opportunistically when a project index is opened, at most once per day. Superseded memories and resolved review follow-ups are retained for 90 days and then pruned; current memories are not expired just because they are old. Maintenance checkpoints and compacts the active SQLite database, and reaps coordination markers (`*.sqlite.active.*`) left behind by processes that died without closing their store; markers owned by live or unidentifiable processes are never removed. When the project-memory directory exceeds 100 MB, least-recently-used inactive project databases are evicted toward about 80 MB when eligible databases exist. The current project's database is never selected for global eviction, and maintenance failures do not disable core guard enforcement.
|
|
210
210
|
|
|
211
211
|
Portable project knowledge lives at the fixed path `.opencode/memory/project-memory.jsonl`. Importing that file bootstraps the local index, but exporting is always explicit and accepts selected record IDs rather than dumping private working memory. The plugin adds `.opencode/memory/` to `.git/info/exclude` for the local clone. To share promoted knowledge through Git, deliberately remove that local exclusion or force-add the JSONL file after reviewing its contents. Do not commit the SQLite database.
|
|
212
212
|
|
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
|
@@ -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)
|
|
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) {
|
|
@@ -150,6 +150,21 @@ function databaseIsActive(path: string, directory: string): boolean {
|
|
|
150
150
|
return active;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
function reapStaleActiveMarkers(directory: string): void {
|
|
154
|
+
const databaseNames = new Set(readdirSync(directory).filter((entry) => entry.endsWith(".sqlite")));
|
|
155
|
+
for (const name of readdirSync(directory)) {
|
|
156
|
+
const markerSeparator = name.lastIndexOf(".active.");
|
|
157
|
+
if (markerSeparator < 0) continue;
|
|
158
|
+
const databaseName = name.slice(0, markerSeparator);
|
|
159
|
+
if (!databaseNames.has(databaseName)) continue;
|
|
160
|
+
const pid = Number(name.slice(markerSeparator + ".active.".length).split(".", 1)[0]);
|
|
161
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
162
|
+
try { process.kill(pid, 0); } catch (error) {
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") rmSync(join(directory, name), { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
153
168
|
export function maintainProjectMemoryStorage(store: ProjectMemoryStore, directory = getProjectMemoryDir(), options: MaintenanceOptions = {}): void {
|
|
154
169
|
const now = options.now ?? Date.now();
|
|
155
170
|
const maintenanceMarker = `${store.path}.maintenance`;
|
|
@@ -173,6 +188,7 @@ export function maintainProjectMemoryStorage(store: ProjectMemoryStore, director
|
|
|
173
188
|
store.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
174
189
|
store.db.exec("PRAGMA optimize");
|
|
175
190
|
store.db.exec("VACUUM");
|
|
191
|
+
reapStaleActiveMarkers(directory);
|
|
176
192
|
try { utimesSync(store.path, now / 1000, now / 1000); } catch {}
|
|
177
193
|
writeFileSync(maintenanceMarker, "", { mode: 0o600 });
|
|
178
194
|
try { utimesSync(maintenanceMarker, now / 1000, now / 1000); } catch {}
|
|
@@ -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
|
}
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -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 () => {
|
package/src/policies/boundary.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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,
|
package/src/policies/git.ts
CHANGED
|
@@ -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 (
|
|
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.`;
|
package/src/workflow-guard.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
239
|
-
\
|
|
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-"));
|
|
@@ -4759,6 +4878,29 @@ check("project memory retains newly superseded old records for the retention win
|
|
|
4759
4878
|
memoryDb.db.prepare("UPDATE memories SET superseded_at = ? WHERE id = ?").run(retentionNow - 91 * 24 * 60 * 60 * 1000, oldCurrentMemory.id);
|
|
4760
4879
|
maintainProjectMemoryStorage(memoryDb, memoryDir, { now: retentionNow + 1_000 });
|
|
4761
4880
|
check("project memory maintenance is throttled to once per day", memoryDb.db.prepare("SELECT 1 FROM memories WHERE id = ?").get(oldCurrentMemory.id) !== undefined);
|
|
4881
|
+
const reapDeadChild = spawnSync(process.execPath, ["-e", "process.exit(0)"], { stdio: "ignore" });
|
|
4882
|
+
const staleMarkerDb = join(memoryDir, "stale-marker-project.sqlite");
|
|
4883
|
+
writeFileSync(staleMarkerDb, "x".repeat(64));
|
|
4884
|
+
const staleDeadMarker = `${staleMarkerDb}.active.${reapDeadChild.pid}.dead`;
|
|
4885
|
+
const staleLiveMarker = `${staleMarkerDb}.active.${process.pid}.live`;
|
|
4886
|
+
const staleOpaqueMarker = `${staleMarkerDb}.active.opaque`;
|
|
4887
|
+
const trickyProjectDb = join(memoryDir, "tricky.sqlite.active.55.sqlite");
|
|
4888
|
+
const trickyDeadMarker = `${trickyProjectDb}.active.${reapDeadChild.pid}.dead`;
|
|
4889
|
+
const trickyLiveMarker = `${trickyProjectDb}.active.${process.pid}.live`;
|
|
4890
|
+
writeFileSync(staleDeadMarker, "");
|
|
4891
|
+
writeFileSync(staleLiveMarker, "");
|
|
4892
|
+
writeFileSync(staleOpaqueMarker, "");
|
|
4893
|
+
writeFileSync(trickyProjectDb, "x".repeat(32));
|
|
4894
|
+
writeFileSync(trickyDeadMarker, "");
|
|
4895
|
+
writeFileSync(trickyLiveMarker, "");
|
|
4896
|
+
maintainProjectMemoryStorage(memoryDb, memoryDir, { force: true, now: retentionNow + 25 * 60 * 60 * 1000 });
|
|
4897
|
+
check("project memory reaps markers of dead owners during maintenance without storage pressure", !existsSync(staleDeadMarker) && existsSync(staleLiveMarker) && existsSync(staleOpaqueMarker));
|
|
4898
|
+
check("project memory reap never removes a database file or live marker for tricky names", existsSync(trickyProjectDb) && !existsSync(trickyDeadMarker) && existsSync(trickyLiveMarker));
|
|
4899
|
+
rmSync(staleLiveMarker, { force: true });
|
|
4900
|
+
rmSync(staleOpaqueMarker, { force: true });
|
|
4901
|
+
rmSync(staleMarkerDb, { force: true });
|
|
4902
|
+
rmSync(trickyLiveMarker, { force: true });
|
|
4903
|
+
rmSync(trickyProjectDb, { force: true });
|
|
4762
4904
|
const privateFact = recordProjectMemory(memoryDb, { kind: "fact", content: "A transient local observation.", source: "agent" });
|
|
4763
4905
|
const portableConstraint = recordProjectMemory(memoryDb, { kind: "constraint", content: "Project memory committed to Git must be human-readable.", source: "user" });
|
|
4764
4906
|
const portablePath = join(root, ".opencode", "memory", "project-memory.jsonl");
|