opencode-workflow-guard 1.19.1 → 1.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/policies.md +10 -7
- package/docs/testing.md +1 -0
- package/package.json +1 -1
- package/src/lib/guard-api.ts +85 -0
- package/src/lib/permission-seat.ts +3 -1
- package/src/lib/tool-descriptions.ts +36 -0
- package/src/lib/types.ts +7 -1
- package/src/lib/v2-plugin.ts +193 -133
- package/src/policies/continuation.ts +41 -11
- package/src/workflow-guard.ts +17 -79
- package/test/test.mts +262 -0
package/docs/policies.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
On OpenCode 1.x those hooks are `tool.execute.before`, `experimental.session.compacting`, `shell.env`, `permission.ask`, and `event` journaling. On OpenCode 2.x they are `tool.hook("execute.before"/"execute.after")`, `session.hook("context"/"compaction"/"prompt")`, `command.transform`, `permission.hook("evaluate")`, `shell.hook("create.before")`, and `event.subscribe()`. V1 plugin implementations do not run on OpenCode 2. The two lists therefore describe separate entrypoints, not alternatives for the same host.
|
|
6
6
|
|
|
7
|
+
**A V2 event handler runs only if the host delivers its type.** `ctx.event.subscribe()` is served from `EventManifest.ServerDefinitions`. A type that appears in `EventManifest.Definitions` but not in `ServerDefinitions` is never delivered, so a handler for it is dead code even though it compiles and looks live. Check every `event.type` comparison against that manifest before relying on it. The test suite enforces this structurally: a scan of `src/lib/v2-plugin.ts` asserts every compared literal is delivered.
|
|
8
|
+
|
|
7
9
|
This is a policy and enforcement layer, not an agent harness. Policies may constrain actions, record or explain decisions, and preserve bounded context. They do not plan, prioritize, delegate, or autonomously sequence work. Where Workflow Guard resumes an idle session, it only enforces already-owned todo lifecycle state. It does not choose what the agent works on next.
|
|
8
10
|
|
|
9
11
|
---
|
|
@@ -19,7 +21,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
19
21
|
- **Read-only escape:** A task whose text carries `[read-only]`, also `[read_only]` or `[read only]`, completes without a mutation. Review, investigation, and reading are legitimate work that changes no file. The marker is an annotation, not part of task identity, so it can be appended to an already-planned task without reading as a deletion. Every use is journaled as `todo_completed_read_only`, so relabeling real work as read-only stays visible in the audit trail. In a repository with no detectable verify command, that journal is the only counterweight. Work that was deliberately dropped uses `cancelled` instead.
|
|
20
22
|
- `guard_status` reports `taskEvidence` (`active`, `completed`, `tasksWithoutRecordedWork`, `readOnlyTasks`), so the requirement is visible before it blocks anything. This is not task authority. Fresh verification evidence and the review gate remain the authority on finalization.
|
|
21
23
|
- **Todo Capability Visibility:** Because the gate depends on the host exposing `todowrite` at all, `guard_status` reports `todoCapability` (`available` / `unavailable` / `unknown`). A fail-open gate is therefore visible rather than decorative.
|
|
22
|
-
- **No Silent Early Exit:** When an owning session
|
|
24
|
+
- **No Silent Early Exit:** When an owning session reaches the idle state with unfinished native todos, the guard asynchronously asks OpenCode to resume the existing run. That trigger is `session.status` with `status.type === "idle"` or the deprecated `session.idle`; both are in `EventManifest.ServerDefinitions`, and which one the server publishes is not settled, so neither is relied on alone. A host that emits both for one transition still produces a single continuation prompt, because the send holds a per-session in-flight lock. This enforces the session's existing lifecycle state. Workflow Guard does not choose the next task, reorder work, create work, or delegate it. Ordinary automatic continuations are capped at three consecutive attempts, and the budget resets on genuine user input. User-initiated interrupts (Esc) suspend automatic continuation for that session until genuine user input arrives, mirroring the ralph `user_stopped` semantics. On OpenCode 2 this is driven by `session.execution.interrupted`, and only reason `user` suspends: `shutdown`, `superseded`, and `inactivity` are host-lifecycle events, not user intent, and they leave continuation state alone. The independently opt-in `ralphMode` uses `ralphMaxIterations` (1-100, default 10) instead. Genuine user input terminates an active Ralph run, and unfinished work at the budget limit reports `budget_exhausted` rather than inferring a policy block. Ralph remains disabled by default under every operation profile. Use OpenCode's native `question` tool when user feedback or a decision is required. Question and permission interactions wait inside the active run rather than producing an idle completion.
|
|
23
25
|
- **Subagent Inheritance & Role Confinement:** Subagents inherit active tasks from their parent session via the `parentID` hierarchy. Subagents spawned with read-only roles (`reviewer`, `planner`, `advisor`, `critic`, `explorer`, `scout`, `evaluator`) are hard-confined. File mutations, lifecycle tools, and mutating shell commands are strictly blocked. On OpenCode 2 the confinement is two-layered. `session.hook("context")` deletes the mutating tools (`write`, `edit`, `patch`, `apply_patch`, `shell`, `bash`, `execute`, `todowrite`, `subagent`/`task`, worktree lifecycle tools) from that role's per-request tool snapshot. `execute.before` stays as the backstop for tools supplied outside the snapshot, such as ACP agents and nested Code Mode calls. The pruning is journaled as `session.context` / `read_only_role_tools_pruned`.
|
|
24
26
|
- **Handoff Safety:** An idle subagent whose effective todos are inherited from its parent is not auto-continued. Inherited work belongs to the parent and can be handed back normally.
|
|
25
27
|
- **Durable Planning Context Discovery:** `guard_next_tasks` prefers a repository-root `TODO.md`. When it is absent, it reads conventional `ROADMAP.md`, `PLAN.md`, `TASKS.md`, `BACKLOG.md`, their `docs/` counterparts, and Markdown files under `docs/plans/`. The tool exposes candidate planning context without selecting, prioritizing, or sequencing tasks. These files never replace OpenCode's native runtime todo state.
|
|
@@ -65,7 +67,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
65
67
|
- MCP tools that mutate live GitHub or Azure/DevOps systems (`create`, `update`, `delete`, `merge`, `push`, `close`, etc.) are blocked.
|
|
66
68
|
- Server names are tokenized on all non-alphanumerics, so `mcp__azure-devops__...`, `mcp__gh__...` and flat legacy names (`azure_devops_*`) are matched.
|
|
67
69
|
- Read-only tools (`get`, `list`, `search`, `query`, etc.) are permitted.
|
|
68
|
-
- On OpenCode 2 the guarded server set is widened from the host's own MCP roster (`ctx.mcp.list()`), refreshed on `mcp.
|
|
70
|
+
- On OpenCode 2 the guarded server set is widened from the host's own MCP roster (`ctx.mcp.list()`), refreshed on `mcp.status.changed`. That is the delivered trigger: `mcp.tools.changed` is an ephemeral host-internal event absent from `EventManifest.ServerDefinitions`, so a handler for it never runs. A renamed or enterprise instance of a guarded product is therefore still matched, including a server that connects mid-session. Widening is one-way by design: removing a server adds nothing, and the static token list stays the floor. Tool names are model-supplied text, and this is a live-system rail.
|
|
69
71
|
|
|
70
72
|
### 6. Settings Tamper Guard
|
|
71
73
|
- Prevents the agent from weakening its own permission gates **via shell or edit tools**:
|
|
@@ -110,7 +112,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
110
112
|
- The verification timeout defaults to 30 seconds. Raise it per project with `verifyTimeoutMs` in `.opencode/workflow-guard.json`, or per environment with `WORKFLOW_GUARD_VERIFY_TIMEOUT_MS`, which wins over project config. Non-positive values are ignored and fall back to the default. Raise the timeout for suites that legitimately exceed the default, for example this repository's own unit suite.
|
|
111
113
|
- A timeout is reported as `verification_timed_out`, not `verification_failed`. Both block finalization, because neither produced a passing result. They differ in what the agent is told: a timeout says the result is unknown and names the limit and the remedy, while a failure names the failing output. Calling a timeout a failure sent agents looking for an assertion that never ran, and the fix for a timeout is a longer limit rather than a code change.
|
|
112
114
|
- Project `postEditValidators` can run faster targeted checks after a direct file edit actually changes the target. Each entry accepts a workspace-relative glob `pattern` with `*`, `**`, and `?` wildcards. Brace expansion and character classes are not supported. Each entry also accepts a non-empty `command` and an optional positive `timeoutMs`. Invalid entries are reported as advisory feedback rather than silently ignored. Matching commands run from the workspace root with the same scrubbed verification environment. Validator failures and timeouts are appended to the edit result as advisory feedback. They do not replace the blocking whole-project finalization verification. Multi-file `apply_patch` calls validate every changed target.
|
|
113
|
-
- With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list. Subagents and generated continuation prompts do not replace them. At `session.idle
|
|
115
|
+
- With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list. Subagents and generated continuation prompts do not replace them. At the idle trigger (`session.status` with `status.type === "idle"`), the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
|
|
114
116
|
- Automatic continuation remains synthetic. By default, an eligible root session with unfinished owned todos briefly waits for OpenCode's native asynchronous title generation before that continuation is sent. Set `titleSettleWorkaround: false` to opt out. The bounded wait never rewrites titles or provider and model parameters.
|
|
115
117
|
- **Git State & Snapshot Binding:** Verification evidence records the current git commit hash (`git rev-parse HEAD`) and working tree status. If unverified edits or index modifications occur after verification, fresh verification is enforced.
|
|
116
118
|
- **Untracked Special Entries:** The worktree fingerprint binds untracked symlinks by their target string. It follows git semantics, so re-pointing a link moves the fingerprint and changing the link target's contents does not. FIFOs and sockets are bound by path and mode. Unopenable entries are skipped with a `worktree-fingerprint` warning in the audit trail instead of voiding the whole fingerprint. One unreadable scratch symlink or `chmod 000` file can no longer make review or verification evidence permanently unfresh. The PR preflight names the actual cause when an approval cannot be matched: no fingerprint binding, fingerprint mismatch, or uncomputable fingerprint. It no longer shows the generic "review approval required". Documented tradeoff: the contents of a skipped entry are excluded from the fingerprint. A file that stays unreadable to the guard's user across the entire window can therefore change contents without moving the fingerprint. Any readability transition is fingerprint-visible (fail-closed), and each skip is named in the durable audit trail.
|
|
@@ -128,7 +130,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
128
130
|
- This prevents the agent from carrying live credentials by default. The user can still grant access through OpenCode's own permission system if a task genuinely needs it.
|
|
129
131
|
|
|
130
132
|
### 13. Command-Channel Audit
|
|
131
|
-
- User-triggered slash commands (`command.executed` events) are journaled to the audit trail so agents cannot perform hidden work through a user-facing side channel.
|
|
133
|
+
- User-triggered slash commands (`command.executed` events) are journaled to the audit trail so agents cannot perform hidden work through a user-facing side channel. **This audit is V1 only.** On V2 the type sits in `EventManifest.Definitions` but is absent from `ServerDefinitions`, so it is never delivered and no V2 handler can journal it. No delivered V2 event carries a slash-command invocation, so the audit is not re-routed there: `command.updated` reports registry config changes, not invocations.
|
|
132
134
|
|
|
133
135
|
### 14. Audit Trail & Permission Journaling
|
|
134
136
|
- Every block and allow decision is appended to `~/.local/state/opencode/workflow-guard/workflow-guard.jsonl`, with XDG_STATE_HOME respected. Each record carries a timestamp, session id, tool name, decision, subagent and parent session attribution, and reason. This is a durable record.
|
|
@@ -153,8 +155,9 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
153
155
|
- Preserves policy-relevant context across session compactions without assigning, prioritizing, or sequencing tasks for the model.
|
|
154
156
|
|
|
155
157
|
### 16. TUI Visual Feedback & Static Status Badge
|
|
156
|
-
-
|
|
157
|
-
-
|
|
158
|
+
- On OpenCode 1.x, emits real-time warning toasts to the user interface via `tui.showToast` whenever a guard policy blocks a tool call.
|
|
159
|
+
- On OpenCode 2.x there is no client-side `tui.showToast` on the plugin's client shim, so block paths do not attempt a toast. The thrown tool-hook error carries the reason to the model, which is what the model actually acts on. A V2 session therefore has no toast surface, and the reason arrives as the call's failure message.
|
|
160
|
+
- The companion TUI plugin (`workflow-guard-ui.ts`) keeps `Workflow Guard 🛡️` static in the prompt bar. Block details remain in the warning toast rather than changing badge content or color. That companion is installed separately and is not part of the V2 server entrypoint.
|
|
158
161
|
|
|
159
162
|
### 17. Secret-File READ Block & Safe Schema Masking
|
|
160
163
|
- Blocks reading sensitive credential files through the `read` tool, shell commands, or inline interpreter payloads. The credential patterns are `.env*`, `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*`, `*kubeconfig*`, `*credentials*.json`, and `service-account*.json`. The shell commands are `cat`, `less`, `more`, `grep`, `awk`, `head`, `tail`, and `base64`. Interpreter payload examples are `python3 -c "open('.env')"` and `bash -c 'cat id_rsa'`.
|
|
@@ -203,7 +206,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
203
206
|
- **A failed research attempt never accrues debt.** Only non-research failures count. Otherwise a flaky or rate-limited search would deepen the block the agent is trying to escape. The floor would then deadlock a session that followed its own advice.
|
|
204
207
|
- **Research recognition:** the builtin names (`websearch`, `webfetch`, and common aliases) match outright. MCP sources match on documented server names, so `mcp__context7__*`, `mcp__deepwiki__*`, `mcp__tavily__*`, `mcp__brave__*`, and similar docs servers count. A guard inspection tool or a generic file reader cannot clear the debt by name accident. Add an unrecognised server with `researchTools` in project config, or `WORKFLOW_GUARD_RESEARCH_TOOLS` (comma-separated name fragments).
|
|
205
208
|
- Prior art: iterative grounding / De-Hallucinator (arXiv 2401.01701), knowledge-lookup gates for agent writes, and deterministic supersession (STALE, arXiv 2605.06527). Failure-mode context: false/illusory completion (agents self-report done) and temporal obsolescence of training data.
|
|
206
|
-
- Enabled **by default** to force the failover. Disable with `.opencode/workflow-guard.json` (`requireLiveDocs: false`) or `WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0`. Raise or lower the trigger with `liveDocFailureThreshold` or `WORKFLOW_GUARD_LIVE_DOC_THRESHOLD`. An unparseable, zero, or negative threshold falls back to the default of 3, so a typo cannot silently disable the floor. Decisions are journaled (`reason: live_docs_research_floor`) under both entrypoints. On OpenCode 1.x block feedback also reaches `client.app.log` at warn level. OpenCode 2 has no server-side log API, so V2 journals the decision and
|
|
209
|
+
- Enabled **by default** to force the failover. Disable with `.opencode/workflow-guard.json` (`requireLiveDocs: false`) or `WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0`. Raise or lower the trigger with `liveDocFailureThreshold` or `WORKFLOW_GUARD_LIVE_DOC_THRESHOLD`. An unparseable, zero, or negative threshold falls back to the default of 3, so a typo cannot silently disable the floor. Decisions are journaled (`reason: live_docs_research_floor`) under both entrypoints. On OpenCode 1.x block feedback also reaches `client.app.log` at warn level and a warning toast. OpenCode 2 has no server-side log API and no client toast, so V2 journals the decision and throws it as the call's failure message.
|
|
207
210
|
- **Known limit:** Code Mode `execute` is not gated, because the nested tools it calls do not pass through guard hooks. A session that reaches the floor can still mutate through that path, the same way it can through an unmodelled indirection described under Known Limits.
|
|
208
211
|
- **Prerequisite:** the acting agent must be allowed to call a research tool. An agent whose toolset denies `websearch`/`webfetch` and exposes no MCP docs server cannot satisfy the floor. Grant one, or the agent will (correctly) stall on the block. This is an opt-out-able default for that reason.
|
|
209
212
|
|
package/docs/testing.md
CHANGED
|
@@ -56,6 +56,7 @@ npm run test:all
|
|
|
56
56
|
| **V2 Permission Seat Abstention** | Verifies the simulated seat denies a real finding, abstains on a decision it cannot make instead of denying, never upgrades a configured `ask`, and journals the abstention as `deferred_to_execute_before` with the preserved decision. Covers both the remote-probe deferral (`remote_state_unchecked`) and the cwd-dependent deferral (`repository_unidentified`). |
|
|
57
57
|
| **Worktree-Aware Git Verdicts** | Verifies a cwd-dependent push or PR creation abstains with `repository_unidentified` instead of being judged against the session-bound checkout, that a non-checkout finding (`cat .env`) in the same compound command is still denied, that an explicit `-C` still runs the core, that a push to `main`/`master` is denied regardless of working directory while a push to a configured `protectedBranches` destination defers, that an explicitly named protected checkout is denied, that an explicitly named repository outside the workspace stays a boundary finding, and that a journaled deferral carries its own code. Also verifies that a `GIT_DIR=` value inside a commit message is not read as a repository claim while a real assignment, including through `env`/`sudo`/`timeout` wrappers, is, that an unresolvable repository selection (`git -C $OTHER_REPO`) stays a boundary block even inside a git workspace, and that a host session move rebinds only on a genuine move rather than on every `session.updated`. Also verifies that a relative shell mutation or git write in a `workdir` outside the workspace is blocked while a read-only command there, and a mutation in a linked worktree of the same repository, are allowed. Also verifies in-command `cd` tracking: `cd <foreign> && git commit`, `cd <foreign> && echo x > f` are blocked while a `cd` there followed by a read stays allowed, a newline-separated `cd <foreign>` moves the tree of the lines that follow for a removal, a git write, and a redirect, while its read-only form stays allowed, a heredoc shell body that cds into a foreign repository is blocked for a removal and a git write, including when the body is unterminated, read by `source`, or piped into a shell or an uncommon executor such as `ash`, `busybox sh`, `make -f -`, or `xargs sh -c`, while a body piped into a known filter such as `grep` stays prose, a body reaching a shell through a later pipeline stage (`| grep x | sh`, `| cat | bash`, `| tee /dev/null | sh`), a body delivered through `|&` to a shell (`|& sh`, `|& ash`) while `|&` into a filter and `||` stay prose, is blocked, a body consumed by a program that can run a command named in its own arguments (`sed 'e'`, `sed -n '1e echo hi'`, `awk '{system($0)}'`, `sort --compress-program`) is blocked, a data heredoc quoting a blocked command shape or a bare `rm` line stays writable and a second body on the same header line stays data, a quoted `"<<"` does not mask a later interpreter payload, a tilde repository selector (`git -C ~/repo`, `git -C~/repo`, `~root/path`) expands through the shell instead of naming a literal `~` directory inside the workspace, an assignment-prefixed `eval` moves the tree of later segments while a harmless one does not, an in-command `cd` into a workspace subdirectory is judged where it lands rather than as a foreign tree, an in-command `cd` into a linked worktree is allowed, an indeterminate `cd $VAR` before a write fails closed, `env -C <foreign> git commit` is blocked, a `cd` inside a grouping subshell does not move a later segment's tree, and nested payloads are judged by their own tree (`bash -c 'cd <foreign> && git commit'` blocked, `execSync(..., {cwd: '<foreign>'})` blocked, `process.chdir('<foreign>')` before a write blocked, an unrelated `{cwd: …}` object key not treated as a working directory, `regex.exec(str, {cwd})` not treated as a spawn, a non-literal `{cwd: process.env.X}` failing closed, a payload launched from an indeterminate `cd` failing closed while its read-only form stays allowed, a doubly nested launcher re-planned to its innermost tree, a payload `git -C` into a foreign repository blocked while a linked worktree stays allowed, a spawn `cwd` after a nested call still read as the working directory, a `pushd` inside a grouping subshell not leaking into a later outer `popd`, a `pushd +N`/`-n` rotation failing closed before a mutation while its read-only form stays allowed, a `cd` inside a `then`/`do`/`case`/brace-group body moving the tree of later segments, a `cd ~user` not read as a workspace-relative path, and a `--head` token inside a quoted PR body not read as the source branch, in-workspace and linked-worktree payloads allowed). |
|
|
58
58
|
| **V2 Todo Capability** | Verifies the `todowrite` registry probe (`available` / `unavailable` / `unknown`), that an empty history gates only when planning is possible, the once-per-session fail-open warning and audit record, and `todoCapability` in `guard_status`. |
|
|
59
|
+
| **V2 Delivered Event Handlers** | Verifies the deny contract the adapter provides (a rejected `execute.before` surfaces as a tool error carrying the reason), that a tool error recorded from `execute.after` accrues failure debt and raises the circuit-breaker path while a success clears it, that `session.execution.interrupted` with reason `user` suspends continuation while a host-lifecycle reason does not, that an idle `session.status` continues the session while `busy` does not, that the deprecated `session.idle` still continues it because the published idle type is not settled, that both idle types for one transition still yield a single continuation prompt, and that a delivered `mcp.status.changed` refreshes the guarded inventory with additive-only widening. A structural meta-test scans `src/lib/v2-plugin.ts` for every `event.type === "<literal>"` comparison and asserts each literal exists in `EventManifest.ServerDefinitions`, so a handler built on a type the host never delivers fails the suite instead of shipping dead. It also asserts the scan matched a nonzero number of literals, so a broken regex cannot pass vacuously. |
|
|
59
60
|
| **Per-Task Completion Evidence** | Verifies a task cannot move to `completed` without a mutation in its own window, that one mutation cannot vouch for several completions, that staggered windows reject a claim whose window saw no work, that mutations before a task became active never count, that `[read-only]` completes freely and is journaled, that the marker can be appended to an already-planned task without tripping the no-silent-deletion rule, that duplicate content cannot hide a new completion, and that `guard_why` reports the same verdict while moving no window. |
|
|
60
61
|
| **Static Policy Posture** | Verifies the emitted posture is deny-only, emits both host envelopes (`experimental.policies`/`permission` for 2.0.x, top-level `policies`/`tool.use` for 2.1.x), requires both to reach the same verdict for every probed resource, and matches the intended commands under a replica of the host's whole-string glob matcher, including the compound-command evasion it does not cover. |
|
|
61
62
|
| **Dogfood Gate (Policy 27)** | Verifies the gate stays off until a repository opts in, that PR creation is blocked without fresh evidence and allowed with it, that a tracked edit and a new commit each invalidate the record with a named cause, that a HOLD is never durable and removes that host's cached pass so a restart cannot re-satisfy the gate, that a same-millisecond HOLD and PASS resolve to the HOLD in the durable cache so a restart stays unsatisfied, that a later pass on another host cannot resurrect an evicted one, that a HOLD in a cold process or in another workspace leaves unrelated hosts' passes intact, that an out-of-matrix HOLD is not reported as the blocking cause, that a `pass` is refused when any check is skipped or the check list is empty, that a HOLD must name a taxonomy class, that an actor session cannot record its own verdict, that the deploy matrix needs one fresh pass per host, that a newer HOLD on a host reopens the gate, that the PR audit names every covered host, and that per-host retention cannot evict another host's pass. |
|
package/package.json
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared guard entry surface, host-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* These three helpers used to live in `workflow-guard.ts`, the V1 entrypoint.
|
|
5
|
+
* `lib/v2-plugin.ts` imported them from there, which created an import cycle:
|
|
6
|
+
* `workflow-guard.ts` imports `WorkflowGuardV2` from `lib/v2-plugin.ts`, so the
|
|
7
|
+
* cycle dragged the whole V1 file, `@opencode-ai/plugin`, and its
|
|
8
|
+
* `@opencode-ai/sdk` subtree into a V2 host.
|
|
9
|
+
*
|
|
10
|
+
* They live here so both entrypoints import a leaf module instead. This is a
|
|
11
|
+
* pure move: behavior, audit fields, and return values are unchanged.
|
|
12
|
+
* `workflow-guard.ts` keeps re-exporting them, because the test harness imports
|
|
13
|
+
* that public surface.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { guardToolCallImpl } from "./guard-dispatcher.ts";
|
|
17
|
+
import { getSdkClient, runWithRuntimeState, lastMutationTimestamp, lastVerify } from "./state.ts";
|
|
18
|
+
import { logDecision } from "./audit.ts";
|
|
19
|
+
import { extractRecordTargetPath } from "./utils.ts";
|
|
20
|
+
import type { PolicyDecision } from "./types.ts";
|
|
21
|
+
import { EDIT_TOOL_NAMES } from "../policies/todo.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Public guard entry point. Audits every decision (block or allow) to a
|
|
25
|
+
* durable JSONL file before returning.
|
|
26
|
+
*/
|
|
27
|
+
export async function guardToolDecision(
|
|
28
|
+
toolName: string,
|
|
29
|
+
input: unknown,
|
|
30
|
+
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
31
|
+
): Promise<PolicyDecision> {
|
|
32
|
+
const customRoot = context?.worktree || context?.directory;
|
|
33
|
+
const runImpl = () => guardToolCallImpl(toolName, input, context);
|
|
34
|
+
return customRoot
|
|
35
|
+
? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
|
|
36
|
+
: await runImpl();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Convenience wrapper: the block reason, or `undefined` when allowed. */
|
|
40
|
+
export async function guardToolCall(
|
|
41
|
+
toolName: string,
|
|
42
|
+
input: unknown,
|
|
43
|
+
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
44
|
+
): Promise<string | undefined> {
|
|
45
|
+
const decision = await guardToolDecision(toolName, input, context);
|
|
46
|
+
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
47
|
+
const isMutation = EDIT_TOOL_NAMES.has(toolName);
|
|
48
|
+
const targetPath = extractRecordTargetPath(input);
|
|
49
|
+
logDecision(toolName, input, context, decision, {
|
|
50
|
+
mutation: isMutation,
|
|
51
|
+
targetPath,
|
|
52
|
+
allowLive,
|
|
53
|
+
verification: lastVerify
|
|
54
|
+
? {
|
|
55
|
+
command: lastVerify.command,
|
|
56
|
+
passed: lastVerify.passed,
|
|
57
|
+
fresh: lastVerify.timestamp >= lastMutationTimestamp,
|
|
58
|
+
durationMs: lastVerify.durationMs,
|
|
59
|
+
}
|
|
60
|
+
: undefined,
|
|
61
|
+
});
|
|
62
|
+
return decision.status === "allowed" ? undefined : decision.message;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the bounded compaction context from the operational state plus the
|
|
67
|
+
* priority blocks, capping the total at `maxChars` (default 8,000).
|
|
68
|
+
*/
|
|
69
|
+
export function buildCompactionContext(operationalState: string, priorityBlocks: string[], maxChars = 8_000): string {
|
|
70
|
+
if (!Number.isFinite(maxChars) || maxChars < 0 || !Number.isInteger(maxChars)) {
|
|
71
|
+
throw new RangeError("maxChars must be a non-negative finite integer");
|
|
72
|
+
}
|
|
73
|
+
const truncate = (value: string, limit: number): string => {
|
|
74
|
+
let end = Math.min(value.length, limit);
|
|
75
|
+
if (end > 0 && end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
|
|
76
|
+
return value.slice(0, end);
|
|
77
|
+
};
|
|
78
|
+
let context = truncate(operationalState, maxChars);
|
|
79
|
+
for (const block of priorityBlocks) {
|
|
80
|
+
const remaining = maxChars - context.length - 2;
|
|
81
|
+
if (remaining <= 0) break;
|
|
82
|
+
context += `\n\n${truncate(block, remaining)}`;
|
|
83
|
+
}
|
|
84
|
+
return context;
|
|
85
|
+
}
|
|
@@ -205,8 +205,10 @@ export async function evaluatePermissionSeat(
|
|
|
205
205
|
|
|
206
206
|
// `<server>_<tool>` MCP actions: only the guarded live-system servers are
|
|
207
207
|
// mapped (GitHub/Azure DevOps), matching the execute.before MCP policy.
|
|
208
|
+
// Cache the host action's mutability to avoid recomputing it on every iteration.
|
|
209
|
+
const actionIsMutating = mcpMutationTool(event.action);
|
|
208
210
|
for (const resource of resources) {
|
|
209
|
-
if (!
|
|
211
|
+
if (!actionIsMutating && !mcpMutationTool(resource)) continue;
|
|
210
212
|
const decision = await decide(event.action, {});
|
|
211
213
|
return seatVerdict(decision, event.action) ?? { effect: "allow", mappedTool: event.action };
|
|
212
214
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the builtin tool-description suffixes.
|
|
3
|
+
*
|
|
4
|
+
* Both entrypoints append the same text: the V1 `tool.definition` hook in
|
|
5
|
+
* `workflow-guard.ts` and the V2 `ctx.tool.transform` enrichment in
|
|
6
|
+
* `lib/v2-plugin.ts`. Keeping one copy prevents the two hosts from drifting
|
|
7
|
+
* apart, which would make a model's expectations depend on which host it runs
|
|
8
|
+
* on.
|
|
9
|
+
*
|
|
10
|
+
* Each suffix carries a marker. The marker is the idempotence key: a
|
|
11
|
+
* description already containing it is left untouched, so a second plugin
|
|
12
|
+
* instance (or a re-applied transform) cannot append the text twice.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const TODOWRITE_MARKER = "verification evidence";
|
|
16
|
+
export const TODOWRITE_SUFFIX =
|
|
17
|
+
"\n\nWorkflow Guard lifecycle: use todowrite to plan before working - it is the only view of progress the operator has, and every file mutation is gated on it. Each call replaces the complete task list: preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled, and do not omit active tasks when adding new work. A task may be marked completed only after its own work is recorded: the guard blocks a completion that has no file mutation in that task's active window, and one mutation cannot vouch for several completions. If a task is genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text; every such use is journaled. Use `cancelled` for work that was deliberately dropped. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
|
|
18
|
+
|
|
19
|
+
export const EDIT_MARKER = "Workflow Guard requirement";
|
|
20
|
+
export const EDIT_SUFFIX =
|
|
21
|
+
"\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
|
|
22
|
+
|
|
23
|
+
export const SUBAGENT_MARKER = "Workflow Guard subagent guidance";
|
|
24
|
+
export const SUBAGENT_SUFFIX =
|
|
25
|
+
"\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Append `suffix` unless the description already carries `marker`.
|
|
29
|
+
*
|
|
30
|
+
* Returns `undefined` when the suffix is already present, so a caller can tell
|
|
31
|
+
* "nothing to do" from "wrote an empty string".
|
|
32
|
+
*/
|
|
33
|
+
export function appendToolDescriptionSuffix(description: string, suffix: string, marker: string): string | undefined {
|
|
34
|
+
if (description.includes(marker)) return undefined;
|
|
35
|
+
return description + suffix;
|
|
36
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -19,8 +19,14 @@ export interface TodoSdkClient {
|
|
|
19
19
|
get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown; title?: unknown; time?: { idle?: unknown } } }>;
|
|
20
20
|
promptAsync?: (opts: {
|
|
21
21
|
path: { id: string };
|
|
22
|
-
body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }
|
|
22
|
+
body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }>; delivery?: "steer" | "queue"; resume?: boolean };
|
|
23
23
|
}) => Promise<unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* V2 only. The shim forwards a synthetic continuation through here, and
|
|
26
|
+
* the presence of this method is what marks a V2 client in
|
|
27
|
+
* `policies/continuation.ts` for `delivery`/`resume` support.
|
|
28
|
+
*/
|
|
29
|
+
synthetic?: (opts: { sessionID: string; text: string; delivery?: "steer" | "queue"; resume?: boolean }) => Promise<unknown>;
|
|
24
30
|
};
|
|
25
31
|
tui?: {
|
|
26
32
|
showToast?: (opts: { body: { title?: string; message: string; variant?: string } }) => Promise<unknown>;
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - `ctx.command.transform` for the server-side `/guard-status` command
|
|
11
11
|
* - `ctx.permission.hook("evaluate")` for the pre-execution deny seat
|
|
12
12
|
* - `ctx.shell.hook("create.before")` for env scrubbing
|
|
13
|
-
* - `ctx.mcp.list()` plus `mcp.
|
|
13
|
+
* - `ctx.mcp.list()` plus `mcp.status.changed` for the guarded-server inventory
|
|
14
14
|
* - `ctx.event.subscribe()` for tool outcomes, session lifecycle, and claims-vs-evidence
|
|
15
15
|
*
|
|
16
16
|
* The V1 SDK client surface (`TodoSdkClient`) is satisfied by an adapter so
|
|
@@ -22,7 +22,7 @@ import { Message } from "@opencode/ai/effect";
|
|
|
22
22
|
import { z } from "zod";
|
|
23
23
|
import { join, resolve } from "node:path";
|
|
24
24
|
import type { TodoSdkClient } from "./types.ts";
|
|
25
|
-
import { detectTodoCapability, recordTodoCapability, recordedTodoCapability, scanSessionTodos
|
|
25
|
+
import { detectTodoCapability, recordTodoCapability, recordedTodoCapability, scanSessionTodos } from "./v2-todo.ts";
|
|
26
26
|
import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
|
|
27
27
|
import {
|
|
28
28
|
setWorkspaceRoot,
|
|
@@ -60,7 +60,7 @@ import { secretIn } from "../policies/secrets.ts";
|
|
|
60
60
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
|
|
61
61
|
import { isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
|
|
62
62
|
import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
|
|
63
|
-
import { ToolOutcomeTracker
|
|
63
|
+
import { ToolOutcomeTracker } from "./tool-outcomes.ts";
|
|
64
64
|
import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
|
|
65
65
|
import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
|
|
66
66
|
import { checkCompletionClaims } from "../policies/completion.ts";
|
|
@@ -72,11 +72,21 @@ import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } f
|
|
|
72
72
|
import { extractCommands, splitShellSegments } from "./shell.ts";
|
|
73
73
|
import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "../policies/boundary.ts";
|
|
74
74
|
import { audit, summarizeInput } from "./audit.ts";
|
|
75
|
-
import { asRecord,
|
|
75
|
+
import { asRecord, isSensitiveEnvKey } from "./utils.ts";
|
|
76
76
|
import { loadedPluginVersion } from "./version.ts";
|
|
77
|
-
import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
|
|
77
|
+
import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, markInterrupted, recordUserMessage } from "../policies/continuation.ts";
|
|
78
78
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
|
|
79
|
-
import { buildCompactionContext,
|
|
79
|
+
import { buildCompactionContext, guardToolCall } from "./guard-api.ts";
|
|
80
|
+
import { evaluateDogfoodGate } from "./dogfood.ts";
|
|
81
|
+
import {
|
|
82
|
+
appendToolDescriptionSuffix,
|
|
83
|
+
EDIT_MARKER,
|
|
84
|
+
EDIT_SUFFIX,
|
|
85
|
+
SUBAGENT_MARKER,
|
|
86
|
+
SUBAGENT_SUFFIX,
|
|
87
|
+
TODOWRITE_MARKER,
|
|
88
|
+
TODOWRITE_SUFFIX,
|
|
89
|
+
} from "./tool-descriptions.ts";
|
|
80
90
|
import { registerPluginInstance, releasePluginInstance } from "./plugin-instance.ts";
|
|
81
91
|
import { claimToolCall, releaseToolCallClaim } from "./call-claims.ts";
|
|
82
92
|
import { evaluatePermissionSeat } from "./permission-seat.ts";
|
|
@@ -94,11 +104,12 @@ type V2Context = Plugin.Context;
|
|
|
94
104
|
// capability) so builtin-only sessions are not gated forever. See v2-todo.ts.
|
|
95
105
|
|
|
96
106
|
function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
97
|
-
// V2 has no server-side log API or TUI surface
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
107
|
+
// V2 has no server-side log API or TUI surface. The client shim supplies
|
|
108
|
+
// only { app, session, lsp }, so there is no `tui.showToast` to call: block
|
|
109
|
+
// paths on V2 do not attempt a toast and rely on the thrown hook error,
|
|
110
|
+
// which is what actually reaches the model. Keep this adapter from logging
|
|
111
|
+
// anything beyond guard decision summaries.
|
|
112
|
+
const client: TodoSdkClient = {
|
|
102
113
|
app: {
|
|
103
114
|
log: async ({ body }) => {
|
|
104
115
|
try {
|
|
@@ -112,10 +123,32 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
|
112
123
|
const session = await ctx.session.get({ sessionID: path.id });
|
|
113
124
|
return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
|
|
114
125
|
},
|
|
126
|
+
// V2 only: forwards a synthetic message with `delivery`/`resume`.
|
|
127
|
+
// Its presence is also what marks this client as V2 for
|
|
128
|
+
// `policies/continuation.ts`, which is why the values travel here
|
|
129
|
+
// instead of through the shared `promptAsync` body type.
|
|
130
|
+
synthetic: async (opts) => {
|
|
131
|
+
await ctx.session.synthetic({
|
|
132
|
+
sessionID: opts.sessionID,
|
|
133
|
+
text: opts.text,
|
|
134
|
+
...(opts.delivery ? { delivery: opts.delivery } : {}),
|
|
135
|
+
...(opts.resume === undefined ? {} : { resume: opts.resume }),
|
|
136
|
+
});
|
|
137
|
+
},
|
|
115
138
|
promptAsync: async (opts) => {
|
|
116
|
-
|
|
139
|
+
const body = opts.body as {
|
|
140
|
+
parts: Array<{ type: "text"; text: string; synthetic?: boolean }>;
|
|
141
|
+
delivery?: "steer" | "queue";
|
|
142
|
+
resume?: boolean;
|
|
143
|
+
};
|
|
144
|
+
for (const part of body.parts) {
|
|
117
145
|
if (part.synthetic) {
|
|
118
|
-
await
|
|
146
|
+
await client.session!.synthetic!({
|
|
147
|
+
sessionID: opts.path.id,
|
|
148
|
+
text: part.text,
|
|
149
|
+
...(body.delivery ? { delivery: body.delivery } : {}),
|
|
150
|
+
...(body.resume === undefined ? {} : { resume: body.resume }),
|
|
151
|
+
});
|
|
119
152
|
} else {
|
|
120
153
|
await ctx.session.prompt({ sessionID: opts.path.id, text: part.text });
|
|
121
154
|
}
|
|
@@ -124,19 +157,9 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
|
|
|
124
157
|
},
|
|
125
158
|
lsp: {},
|
|
126
159
|
};
|
|
160
|
+
return client;
|
|
127
161
|
}
|
|
128
162
|
|
|
129
|
-
// ── Builtin tool description enrichment ───────────────────────────────────────
|
|
130
|
-
|
|
131
|
-
const TODOWRITE_SUFFIX =
|
|
132
|
-
"\n\nWorkflow Guard lifecycle: use todowrite to plan before working - it is the only view of progress the operator has, and every file mutation is gated on it. Each call replaces the complete task list: preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled, and do not omit active tasks when adding new work. A task may be marked completed only after its own work is recorded: the guard blocks a completion that has no file mutation in that task's active window, and one mutation cannot vouch for several completions. If a task is genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text; every such use is journaled. Use `cancelled` for work that was deliberately dropped. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
|
|
133
|
-
|
|
134
|
-
const EDIT_SUFFIX =
|
|
135
|
-
"\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
|
|
136
|
-
|
|
137
|
-
const SUBAGENT_SUFFIX =
|
|
138
|
-
"\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
|
|
139
|
-
|
|
140
163
|
// Builtin tool description enrichment. Candidates are matched against the
|
|
141
164
|
// host's tool registry; `ToolEditor.update` ignores missing IDs, so extra
|
|
142
165
|
// candidates are harmless no-ops.
|
|
@@ -155,9 +178,9 @@ const SUBAGENT_SUFFIX =
|
|
|
155
178
|
// - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
|
|
156
179
|
// candidates for the same reason.
|
|
157
180
|
const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
|
|
158
|
-
{ candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker:
|
|
159
|
-
{ candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker:
|
|
160
|
-
{ candidates: ["task", "subagent"], suffix: SUBAGENT_SUFFIX, marker:
|
|
181
|
+
{ candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker: TODOWRITE_MARKER },
|
|
182
|
+
{ candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker: EDIT_MARKER },
|
|
183
|
+
{ candidates: ["task", "subagent"], suffix: SUBAGENT_SUFFIX, marker: SUBAGENT_MARKER },
|
|
161
184
|
];
|
|
162
185
|
|
|
163
186
|
// ── Setup ─────────────────────────────────────────────────────────────────────
|
|
@@ -256,8 +279,8 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
256
279
|
for (const id of target.candidates) {
|
|
257
280
|
editor.update(id, (tool) => {
|
|
258
281
|
const description = typeof tool.description === "string" ? tool.description : "";
|
|
259
|
-
|
|
260
|
-
tool.description =
|
|
282
|
+
const next = appendToolDescriptionSuffix(description, target.suffix, target.marker);
|
|
283
|
+
if (next !== undefined) tool.description = next;
|
|
261
284
|
});
|
|
262
285
|
}
|
|
263
286
|
}
|
|
@@ -300,7 +323,6 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
300
323
|
});
|
|
301
324
|
if (reason !== undefined) {
|
|
302
325
|
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
303
|
-
await showBlockToast(reason);
|
|
304
326
|
const failureCount = event.sessionID ? toolOutcomes.getFailureCount(event.sessionID) : 0;
|
|
305
327
|
const circuitBreakerSuffix = failureCount >= 2
|
|
306
328
|
? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly, or inspect policy details with guard_status or guard_why.]"
|
|
@@ -316,7 +338,6 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
316
338
|
const researchReason = liveDocsGatingReason(event.sessionID, event.tool, threshold, getResearchToolPatterns(toolWorktree));
|
|
317
339
|
if (researchReason !== undefined) {
|
|
318
340
|
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
319
|
-
await showBlockToast(researchReason);
|
|
320
341
|
console.log(`[workflow-guard] warn: ${researchReason}`);
|
|
321
342
|
audit({
|
|
322
343
|
ts: new Date().toISOString(),
|
|
@@ -383,19 +404,13 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
383
404
|
await ctx.tool.hook("execute.after", async (event) => {
|
|
384
405
|
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
385
406
|
const startedAt = toolLifecycle.finish(event.sessionID, event.id);
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
decision: "allow",
|
|
394
|
-
phase: "outcome",
|
|
395
|
-
durationMs: outcome.durationMs,
|
|
396
|
-
reason: outcome.status,
|
|
397
|
-
});
|
|
398
|
-
}
|
|
407
|
+
recordV2ToolOutcome(toolOutcomes, effectiveRoot, {
|
|
408
|
+
tool: event.tool,
|
|
409
|
+
sessionID: event.sessionID,
|
|
410
|
+
callID: event.id,
|
|
411
|
+
status: event.status,
|
|
412
|
+
error: event.status === "error" ? event.error : undefined,
|
|
413
|
+
}, startedAt);
|
|
399
414
|
releaseFileClaims(event.sessionID, event.id);
|
|
400
415
|
if (event.tool === "read") {
|
|
401
416
|
const observation = toolLifecycle.takeReadObservation(event.sessionID, event.id);
|
|
@@ -721,93 +736,127 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
721
736
|
};
|
|
722
737
|
};
|
|
723
738
|
|
|
739
|
+
// ── Tool outcome telemetry ────────────────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Record one tool call's terminal outcome from `execute.after`.
|
|
743
|
+
*
|
|
744
|
+
* The former `session.message.content.updated` subscription never fired: that
|
|
745
|
+
* type is absent from `EventManifest.ServerDefinitions`, so no tool part was
|
|
746
|
+
* ever mapped and the repeated-failure circuit breaker plus the Policy 25
|
|
747
|
+
* research floor both read an always-zero count. `execute.after` does fire,
|
|
748
|
+
* including for a failed call, because the host triggers the hook with
|
|
749
|
+
* `status: "error"` before it rethrows. The error's own message feeds the
|
|
750
|
+
* tracker's signature, so repeat detection keys on what the model actually saw.
|
|
751
|
+
*/
|
|
752
|
+
function recordV2ToolOutcome(
|
|
753
|
+
toolOutcomes: ToolOutcomeTracker,
|
|
754
|
+
effectiveRoot: string,
|
|
755
|
+
event: {
|
|
756
|
+
tool: string;
|
|
757
|
+
sessionID?: string;
|
|
758
|
+
callID: string;
|
|
759
|
+
status?: "completed" | "error";
|
|
760
|
+
error?: { message?: string } | string;
|
|
761
|
+
},
|
|
762
|
+
startedAt?: number,
|
|
763
|
+
): void {
|
|
764
|
+
const status = event.status;
|
|
765
|
+
if (status !== "completed" && status !== "error") {
|
|
766
|
+
// The host's `execute.after` always carries a terminal status. A partial
|
|
767
|
+
// event from a harness or stub leaves it unknown, so record the
|
|
768
|
+
// optimistic completion that this hook used to record unconditionally
|
|
769
|
+
// rather than dropping the call from telemetry entirely. The audit row
|
|
770
|
+
// is kept here so this path does not lose the coverage it had before
|
|
771
|
+
// outcomes moved onto `execute.after`.
|
|
772
|
+
const fallback = toolOutcomes.recordFallbackCompleted(event.sessionID ?? "", event.callID, event.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
|
|
773
|
+
if (fallback) {
|
|
774
|
+
audit({
|
|
775
|
+
ts: new Date().toISOString(),
|
|
776
|
+
sessionID: fallback.sessionID,
|
|
777
|
+
callID: fallback.callID,
|
|
778
|
+
tool: fallback.tool,
|
|
779
|
+
decision: "allow",
|
|
780
|
+
phase: "outcome",
|
|
781
|
+
durationMs: fallback.durationMs,
|
|
782
|
+
reason: fallback.status,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const error = typeof event.error === "string" ? event.error : event.error?.message;
|
|
788
|
+
const outcome = toolOutcomes.record({
|
|
789
|
+
type: "tool",
|
|
790
|
+
sessionID: event.sessionID,
|
|
791
|
+
callID: event.callID,
|
|
792
|
+
tool: event.tool,
|
|
793
|
+
state: {
|
|
794
|
+
status,
|
|
795
|
+
error,
|
|
796
|
+
time: startedAt === undefined ? undefined : { start: startedAt, end: Date.now() },
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
if (!outcome) return;
|
|
800
|
+
// Policy 25 research floor (V1 parity): failures accrue debt; successful
|
|
801
|
+
// live-source lookups clear it. A failed research attempt never accrues
|
|
802
|
+
// debt, so a flaky lookup cannot deepen the block it was meant to escape.
|
|
803
|
+
if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
|
|
804
|
+
if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
|
|
805
|
+
} else if (outcome.status === "error") {
|
|
806
|
+
recordLiveDocsFailure(outcome.sessionID);
|
|
807
|
+
}
|
|
808
|
+
audit({
|
|
809
|
+
ts: new Date().toISOString(),
|
|
810
|
+
sessionID: outcome.sessionID,
|
|
811
|
+
callID: outcome.callID,
|
|
812
|
+
tool: outcome.tool,
|
|
813
|
+
decision: "allow",
|
|
814
|
+
phase: "outcome",
|
|
815
|
+
durationMs: outcome.durationMs,
|
|
816
|
+
reason: outcome.status,
|
|
817
|
+
});
|
|
818
|
+
if (outcome.status === "error" && outcome.repeatedFailureCount === 3) {
|
|
819
|
+
audit({
|
|
820
|
+
ts: new Date().toISOString(),
|
|
821
|
+
sessionID: outcome.sessionID,
|
|
822
|
+
callID: outcome.callID,
|
|
823
|
+
tool: outcome.tool,
|
|
824
|
+
decision: "allow",
|
|
825
|
+
phase: "event",
|
|
826
|
+
reason: "repeated-equivalent-failure:3",
|
|
827
|
+
});
|
|
828
|
+
console.log(`[workflow-guard] ${outcome.tool} failed equivalently 3 times in this session; change approach or inspect the underlying failure before retrying.`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
724
832
|
// ── Event handling ────────────────────────────────────────────────────────────
|
|
725
833
|
|
|
726
|
-
interface V2EventState {
|
|
834
|
+
export interface V2EventState {
|
|
727
835
|
effectiveRoot: string;
|
|
728
836
|
client: unknown;
|
|
729
837
|
toolLifecycle: ToolInvocationLifecycle;
|
|
730
838
|
toolOutcomes: ToolOutcomeTracker;
|
|
731
|
-
/** Re-read the host MCP roster after `mcp.
|
|
839
|
+
/** Re-read the host MCP roster after `mcp.status.changed`. */
|
|
732
840
|
refreshMcpInventory?: () => Promise<void>;
|
|
733
841
|
}
|
|
734
842
|
|
|
735
|
-
async function handleV2Event(
|
|
843
|
+
export async function handleV2Event(
|
|
736
844
|
event: { type?: string; data?: unknown },
|
|
737
845
|
state: V2EventState,
|
|
738
846
|
): Promise<void> {
|
|
739
847
|
const { effectiveRoot, client, toolLifecycle, toolOutcomes } = state;
|
|
740
848
|
|
|
741
|
-
if (event.type === "mcp.
|
|
742
|
-
//
|
|
849
|
+
if (event.type === "mcp.status.changed") {
|
|
850
|
+
// A server's connection status settled (connected, failed, needs_auth,
|
|
851
|
+
// closed), which is when its tools become callable. Re-read the roster
|
|
743
852
|
// so a newly connected live-system server is guarded before its tools
|
|
744
|
-
//
|
|
853
|
+
// can be called. Widen-only: this can add tokens, never remove them.
|
|
854
|
+
// `mcp.resources.changed` is deliberately not a trigger: it reports
|
|
855
|
+
// resources, not tool availability.
|
|
745
856
|
await state.refreshMcpInventory?.();
|
|
746
857
|
return;
|
|
747
858
|
}
|
|
748
859
|
|
|
749
|
-
if (event.type === "session.message.content.updated") {
|
|
750
|
-
const data = event.data as { sessionID?: unknown; content?: unknown } | undefined;
|
|
751
|
-
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
752
|
-
if (!sessionID || !Array.isArray(data?.content)) return;
|
|
753
|
-
for (const part of data.content as Array<Record<string, unknown>>) {
|
|
754
|
-
if (part?.type !== "tool") continue;
|
|
755
|
-
const toolState = part.state as { status?: unknown; error?: { message?: string } | string } | undefined;
|
|
756
|
-
const status = toolState?.status;
|
|
757
|
-
if (status !== "completed" && status !== "error") continue;
|
|
758
|
-
// V2 keeps timing on the tool part itself (part.time), not inside state.
|
|
759
|
-
const partTime = part.time as { ran?: unknown; completed?: unknown } | undefined;
|
|
760
|
-
const start = typeof partTime?.ran === "number" ? partTime.ran : undefined;
|
|
761
|
-
const end = typeof partTime?.completed === "number" ? partTime.completed : undefined;
|
|
762
|
-
const mapped: ToolOutcomePart = {
|
|
763
|
-
type: "tool",
|
|
764
|
-
sessionID,
|
|
765
|
-
callID: part.id,
|
|
766
|
-
tool: toolPartName(part),
|
|
767
|
-
state: {
|
|
768
|
-
status,
|
|
769
|
-
error: typeof toolState?.error === "string" ? toolState.error : toolState?.error?.message,
|
|
770
|
-
time: { start, end },
|
|
771
|
-
},
|
|
772
|
-
};
|
|
773
|
-
const outcome = toolOutcomes.record(mapped);
|
|
774
|
-
if (outcome) {
|
|
775
|
-
// Policy 25 research floor (V1 parity): failures accrue debt;
|
|
776
|
-
// successful live-source lookups clear it. A failed research
|
|
777
|
-
// attempt never accrues debt, so a flaky lookup cannot deepen
|
|
778
|
-
// the block it was meant to escape.
|
|
779
|
-
if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
|
|
780
|
-
if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
|
|
781
|
-
} else if (outcome.status === "error") {
|
|
782
|
-
recordLiveDocsFailure(outcome.sessionID);
|
|
783
|
-
}
|
|
784
|
-
audit({
|
|
785
|
-
ts: new Date().toISOString(),
|
|
786
|
-
sessionID: outcome.sessionID,
|
|
787
|
-
callID: outcome.callID,
|
|
788
|
-
tool: outcome.tool,
|
|
789
|
-
decision: "allow",
|
|
790
|
-
phase: "outcome",
|
|
791
|
-
durationMs: outcome.durationMs,
|
|
792
|
-
reason: outcome.status,
|
|
793
|
-
});
|
|
794
|
-
if (outcome.status === "error" && outcome.repeatedFailureCount === 3) {
|
|
795
|
-
audit({
|
|
796
|
-
ts: new Date().toISOString(),
|
|
797
|
-
sessionID: outcome.sessionID,
|
|
798
|
-
callID: outcome.callID,
|
|
799
|
-
tool: outcome.tool,
|
|
800
|
-
decision: "allow",
|
|
801
|
-
phase: "event",
|
|
802
|
-
reason: "repeated-equivalent-failure:3",
|
|
803
|
-
});
|
|
804
|
-
console.log(`[workflow-guard] ${outcome.tool} failed equivalently 3 times in this session; change approach or inspect the underlying failure before retrying.`);
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
860
|
if (event.type === "session.text.ended") {
|
|
812
861
|
const data = event.data as { sessionID?: unknown; text?: unknown } | undefined;
|
|
813
862
|
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
@@ -860,8 +909,36 @@ async function handleV2Event(
|
|
|
860
909
|
return;
|
|
861
910
|
}
|
|
862
911
|
|
|
863
|
-
if (event.type === "session.
|
|
864
|
-
|
|
912
|
+
if (event.type === "session.execution.interrupted") {
|
|
913
|
+
// `reason` is one of "user" | "shutdown" | "superseded" | "inactivity".
|
|
914
|
+
// Only "user" is user intent (Esc), so only it suspends automatic
|
|
915
|
+
// continuation. The other three are host-lifecycle events and must
|
|
916
|
+
// leave continuation state alone.
|
|
917
|
+
const data = event.data as { sessionID?: unknown; reason?: unknown } | undefined;
|
|
918
|
+
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
919
|
+
if (sessionID && data?.reason === "user") {
|
|
920
|
+
// Continuation state is keyed on the SDK client plus project root,
|
|
921
|
+
// so it must be reached inside the runtime state.
|
|
922
|
+
await runWithRuntimeState(effectiveRoot, client, () => markInterrupted(sessionID));
|
|
923
|
+
}
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Idle is the trigger for automatic continuation. The host keeps two
|
|
928
|
+
// event types for it: `session.status` with `status.type === "idle"`, and
|
|
929
|
+
// the deprecated `session.idle`. Both are in
|
|
930
|
+
// `EventManifest.ServerDefinitions` on 2.0.15, 2.1.3, and 2.2.0, but which
|
|
931
|
+
// one the server actually publishes is not settled: the TUI derives idle
|
|
932
|
+
// from `session.execution.*` instead of consuming `session.status`, and an
|
|
933
|
+
// upstream report says `session.status` is declared but never published.
|
|
934
|
+
// Handling both keeps continuation working whichever one arrives, and adds
|
|
935
|
+
// no new trigger beyond what each host already emits. A host that emits
|
|
936
|
+
// both for one transition still sends a single prompt, because
|
|
937
|
+
// `continueUnfinishedSession` holds a per-session in-flight lock for the
|
|
938
|
+
// whole awaited send. The side effects keep their original order.
|
|
939
|
+
if (event.type === "session.status" || event.type === "session.idle") {
|
|
940
|
+
const data = event.data as { sessionID?: unknown; status?: { type?: unknown } } | undefined;
|
|
941
|
+
if (event.type === "session.status" && data?.status?.type !== "idle") return;
|
|
865
942
|
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
866
943
|
if (typeof sessionID === "string") {
|
|
867
944
|
releaseFileClaims(sessionID);
|
|
@@ -909,25 +986,8 @@ async function handleV2Event(
|
|
|
909
986
|
});
|
|
910
987
|
return;
|
|
911
988
|
}
|
|
912
|
-
// `command.executed` exists in the V2 event stream (`data: { name, sessionID,
|
|
913
|
-
// arguments, messageID }`) and carries what the V1 audit wanted: the slash
|
|
914
|
-
// command name, its arguments, and the session. It is journaled here rather
|
|
915
|
-
// than dropped, so a command that mutates workspace state stays attributable.
|
|
916
|
-
if (event.type === "command.executed") {
|
|
917
|
-
const data = event.data as { sessionID?: unknown; name?: unknown; arguments?: unknown } | undefined;
|
|
918
|
-
audit({
|
|
919
|
-
ts: new Date().toISOString(),
|
|
920
|
-
sessionID: typeof data?.sessionID === "string" ? data.sessionID : undefined,
|
|
921
|
-
tool: "command.executed",
|
|
922
|
-
decision: "allow",
|
|
923
|
-
phase: "event",
|
|
924
|
-
reason: typeof data?.name === "string" ? data.name : undefined,
|
|
925
|
-
input: summarizeInput({ name: data?.name, arguments: data?.arguments }),
|
|
926
|
-
});
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
989
|
// Note on V1 events without a V2 counterpart:
|
|
930
990
|
// - `permission.updated`: replaced by `permission.asked`/`permission.replied` above.
|
|
931
|
-
// - `session.created`:
|
|
932
|
-
//
|
|
991
|
+
// - `session.created`: V2 journals no create event; session state resets on
|
|
992
|
+
// `session.deleted`.
|
|
933
993
|
}
|
|
@@ -19,6 +19,19 @@ type ContinuationState = {
|
|
|
19
19
|
export type RalphOutcome = "running" | "completed" | "blocked" | "budget_exhausted" | "user_stopped";
|
|
20
20
|
const states = new WeakMap<object, Map<string, ContinuationState>>();
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Whether the active client is the V2 shim, which can carry `delivery` and
|
|
24
|
+
* `resume` on a synthetic continuation. `session.synthetic` is the
|
|
25
|
+
* discriminator: the V2 shim supplies it, and the V1 SDK client does not
|
|
26
|
+
* declare it. `session.todo` is required alongside it only because this module
|
|
27
|
+
* reads todos through the same surface, so a client missing it cannot be the
|
|
28
|
+
* V2 shim for our purposes.
|
|
29
|
+
*/
|
|
30
|
+
function isV2Host(): boolean {
|
|
31
|
+
const session = getSdkClient()?.session as { synthetic?: unknown; todo?: unknown } | undefined;
|
|
32
|
+
return typeof session?.synthetic === "function" && typeof session?.todo === "function";
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
export async function waitForSessionTitle(sessionID: string): Promise<void> {
|
|
23
36
|
const session = getSdkClient()?.session;
|
|
24
37
|
if (typeof session?.get !== "function") return;
|
|
@@ -95,17 +108,34 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
|
|
|
95
108
|
: "";
|
|
96
109
|
|
|
97
110
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
// Delivery and resume are stated explicitly so a V2 synthetic
|
|
112
|
+
// continuation does not depend on host defaults. `queue` keeps it in
|
|
113
|
+
// the session's inbox instead of steering an in-flight turn, and
|
|
114
|
+
// `resume: true` starts a run when the session is idle.
|
|
115
|
+
//
|
|
116
|
+
// The fields are V2-only. The V1 SDK body type declares neither
|
|
117
|
+
// (`SessionPromptAsyncData` in @opencode-ai/sdk), so adding them
|
|
118
|
+
// there would send unmodelled fields to a strict server. The V2
|
|
119
|
+
// client shim reads them off this same object, so the values travel
|
|
120
|
+
// without changing the V1 request shape.
|
|
121
|
+
const promptBody: {
|
|
122
|
+
messageID: string;
|
|
123
|
+
parts: Array<{ type: "text"; text: string; synthetic?: boolean }>;
|
|
124
|
+
delivery?: "steer" | "queue";
|
|
125
|
+
resume?: boolean;
|
|
126
|
+
} = {
|
|
127
|
+
messageID,
|
|
128
|
+
parts: [{
|
|
129
|
+
type: "text" as const,
|
|
130
|
+
text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run. Check guard_status for outstanding verification/review requirements, and guard_review_rubric before final completion.`,
|
|
131
|
+
synthetic: true,
|
|
132
|
+
}],
|
|
133
|
+
};
|
|
134
|
+
if (isV2Host()) {
|
|
135
|
+
promptBody.delivery = "queue";
|
|
136
|
+
promptBody.resume = true;
|
|
137
|
+
}
|
|
138
|
+
await session.promptAsync({ path: { id: sessionID }, body: promptBody });
|
|
109
139
|
return true;
|
|
110
140
|
} catch {
|
|
111
141
|
state.counts.set(sessionID, count);
|
package/src/workflow-guard.ts
CHANGED
|
@@ -22,10 +22,10 @@ import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, re
|
|
|
22
22
|
import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
23
23
|
import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
24
24
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
25
|
-
import {
|
|
25
|
+
import { isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
|
|
26
26
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
27
27
|
import { evaluateDogfoodGate } from "./lib/dogfood.ts";
|
|
28
|
-
import type {
|
|
28
|
+
import type { TodoSdkClient } from "./lib/types.ts";
|
|
29
29
|
export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
30
30
|
export { extractReviewFollowups, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
31
31
|
export { evaluateDogfoodGate } from "./lib/dogfood.ts";
|
|
@@ -59,7 +59,6 @@ import {
|
|
|
59
59
|
setSdkClient,
|
|
60
60
|
getSdkClient,
|
|
61
61
|
runWithRuntimeState,
|
|
62
|
-
lastMutationTimestamp,
|
|
63
62
|
getLastMutationTimestamp,
|
|
64
63
|
getMutationCount,
|
|
65
64
|
getSessionMutationCount,
|
|
@@ -163,7 +162,6 @@ export {
|
|
|
163
162
|
// ── Shared shell/env utilities ───────────────────────────────────────────────
|
|
164
163
|
import {
|
|
165
164
|
asRecord,
|
|
166
|
-
extractRecordTargetPath,
|
|
167
165
|
getCleanEnv,
|
|
168
166
|
showBlockToast,
|
|
169
167
|
isSensitiveEnvKey,
|
|
@@ -212,13 +210,19 @@ import {
|
|
|
212
210
|
persistReviewCache,
|
|
213
211
|
loadReviewCache,
|
|
214
212
|
audit,
|
|
215
|
-
logDecision,
|
|
216
213
|
summarizeInput,
|
|
217
214
|
} from "./lib/audit.ts";
|
|
218
215
|
|
|
219
216
|
export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getDogfoodCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, persistDogfoodCache, loadDogfoodCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
|
|
220
217
|
export { summarizeInput };
|
|
221
218
|
|
|
219
|
+
// The guard entry surface lives in a leaf module so the V2 entrypoint can
|
|
220
|
+
// import it without pulling in this V1 file (and its SDK subtree) through a
|
|
221
|
+
// cycle. Re-exported here because the test harness imports this public surface.
|
|
222
|
+
export { guardToolCall, guardToolDecision, buildCompactionContext } from "./lib/guard-api.ts";
|
|
223
|
+
import { guardToolCall, buildCompactionContext } from "./lib/guard-api.ts";
|
|
224
|
+
import { TODOWRITE_SUFFIX, TODOWRITE_MARKER, EDIT_SUFFIX, EDIT_MARKER, SUBAGENT_SUFFIX, SUBAGENT_MARKER, appendToolDescriptionSuffix } from "./lib/tool-descriptions.ts";
|
|
225
|
+
|
|
222
226
|
export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
|
|
223
227
|
const directory = platform === "darwin"
|
|
224
228
|
? "/Library/Application Support/opencode"
|
|
@@ -398,69 +402,10 @@ function logWarn(client: unknown, message: string): void {
|
|
|
398
402
|
} catch {}
|
|
399
403
|
}
|
|
400
404
|
|
|
401
|
-
/**
|
|
402
|
-
* Public guard entry point. Audits every decision (block or allow) to a
|
|
403
|
-
* durable JSONL file before returning.
|
|
404
|
-
*/
|
|
405
|
-
export async function guardToolDecision(
|
|
406
|
-
toolName: string,
|
|
407
|
-
input: unknown,
|
|
408
|
-
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
409
|
-
): Promise<PolicyDecision> {
|
|
410
|
-
const customRoot = context?.worktree || context?.directory;
|
|
411
|
-
const runImpl = () => guardToolCallImpl(toolName, input, context);
|
|
412
|
-
return customRoot
|
|
413
|
-
? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
|
|
414
|
-
: await runImpl();
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
export async function guardToolCall(
|
|
418
|
-
toolName: string,
|
|
419
|
-
input: unknown,
|
|
420
|
-
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
421
|
-
): Promise<string | undefined> {
|
|
422
|
-
const decision = await guardToolDecision(toolName, input, context);
|
|
423
|
-
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
424
|
-
const isMutation = EDIT_TOOL_NAMES.has(toolName);
|
|
425
|
-
const targetPath = extractRecordTargetPath(input);
|
|
426
|
-
logDecision(toolName, input, context, decision, {
|
|
427
|
-
mutation: isMutation,
|
|
428
|
-
targetPath,
|
|
429
|
-
allowLive,
|
|
430
|
-
verification: lastVerify
|
|
431
|
-
? {
|
|
432
|
-
command: lastVerify.command,
|
|
433
|
-
passed: lastVerify.passed,
|
|
434
|
-
fresh: lastVerify.timestamp >= lastMutationTimestamp,
|
|
435
|
-
durationMs: lastVerify.durationMs,
|
|
436
|
-
}
|
|
437
|
-
: undefined,
|
|
438
|
-
});
|
|
439
|
-
return decision.status === "allowed" ? undefined : decision.message;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
405
|
async function emitBlockFeedback(message: string): Promise<void> {
|
|
443
406
|
await showBlockToast(message);
|
|
444
407
|
}
|
|
445
408
|
|
|
446
|
-
export function buildCompactionContext(operationalState: string, priorityBlocks: string[], maxChars = 8_000): string {
|
|
447
|
-
if (!Number.isFinite(maxChars) || maxChars < 0 || !Number.isInteger(maxChars)) {
|
|
448
|
-
throw new RangeError("maxChars must be a non-negative finite integer");
|
|
449
|
-
}
|
|
450
|
-
const truncate = (value: string, limit: number): string => {
|
|
451
|
-
let end = Math.min(value.length, limit);
|
|
452
|
-
if (end > 0 && end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
|
|
453
|
-
return value.slice(0, end);
|
|
454
|
-
};
|
|
455
|
-
let context = truncate(operationalState, maxChars);
|
|
456
|
-
for (const block of priorityBlocks) {
|
|
457
|
-
const remaining = maxChars - context.length - 2;
|
|
458
|
-
if (remaining <= 0) break;
|
|
459
|
-
context += `\n\n${truncate(block, remaining)}`;
|
|
460
|
-
}
|
|
461
|
-
return context;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
409
|
export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
465
410
|
// Honor worktree if present (e.g. opencode worktrees or devcontainers)
|
|
466
411
|
// so worktree plugins cannot punch through boundary gates. When the host
|
|
@@ -839,28 +784,21 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
839
784
|
// Similarly, mutating tools reflect their active-todo, feature-branch, and prior-read requirements.
|
|
840
785
|
// Subagent orchestration tools reflect worktree isolation and review expectations.
|
|
841
786
|
"tool.definition": async (input, output) => {
|
|
842
|
-
|
|
787
|
+
const append = (suffix: string, marker: string) => {
|
|
843
788
|
const description = typeof output.description === "string" ? output.description : "";
|
|
844
|
-
|
|
845
|
-
output.description =
|
|
846
|
-
|
|
847
|
-
|
|
789
|
+
const next = appendToolDescriptionSuffix(description, suffix, marker);
|
|
790
|
+
if (next !== undefined) output.description = next;
|
|
791
|
+
};
|
|
792
|
+
if (input.toolID === "todowrite") {
|
|
793
|
+
append(TODOWRITE_SUFFIX, TODOWRITE_MARKER);
|
|
848
794
|
return;
|
|
849
795
|
}
|
|
850
796
|
if (EDIT_TOOL_NAMES.has(input.toolID)) {
|
|
851
|
-
|
|
852
|
-
if (description.includes("Workflow Guard requirement")) return;
|
|
853
|
-
output.description =
|
|
854
|
-
description +
|
|
855
|
-
"\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
|
|
797
|
+
append(EDIT_SUFFIX, EDIT_MARKER);
|
|
856
798
|
return;
|
|
857
799
|
}
|
|
858
800
|
if (input.toolID === "task") {
|
|
859
|
-
|
|
860
|
-
if (description.includes("Workflow Guard subagent guidance")) return;
|
|
861
|
-
output.description =
|
|
862
|
-
description +
|
|
863
|
-
"\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
|
|
801
|
+
append(SUBAGENT_SUFFIX, SUBAGENT_MARKER);
|
|
864
802
|
return;
|
|
865
803
|
}
|
|
866
804
|
},
|
package/test/test.mts
CHANGED
|
@@ -7,6 +7,8 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
7
7
|
import type { PluginModule } from "@opencode-ai/plugin";
|
|
8
8
|
import { getRalphMaxIterations, runWithRuntimeState } from "../src/lib/state.ts";
|
|
9
9
|
import { isGeneratedContinuationMessage } from "../src/policies/continuation.ts";
|
|
10
|
+
import { clearContinuationState, continueUnfinishedSession, recordUserMessage } from "../src/policies/continuation.ts";
|
|
11
|
+
import { ToolInvocationLifecycle } from "../src/lib/tool-lifecycle.ts";
|
|
10
12
|
import {
|
|
11
13
|
guardToolCall,
|
|
12
14
|
guardToolDecision,
|
|
@@ -7646,5 +7648,265 @@ rmSync(liveBase, { recursive: true, force: true });
|
|
|
7646
7648
|
|
|
7647
7649
|
rmSync(root, { recursive: true, force: true });
|
|
7648
7650
|
if (prevLive !== undefined) process.env.WORKFLOW_GUARD_ALLOW_LIVE = prevLive;
|
|
7651
|
+
|
|
7652
|
+
// ── V2 delivered event handlers ──────────────────────────────────────────────
|
|
7653
|
+
// `ctx.event.subscribe()` is served from EventManifest.ServerDefinitions. A type
|
|
7654
|
+
// present in `Definitions` but absent from that manifest is never delivered,
|
|
7655
|
+
// even when a plugin handles it. Three handlers were built on such types, so
|
|
7656
|
+
// tool-outcome telemetry, the Policy 25 research floor, the MCP inventory
|
|
7657
|
+
// refresh, and the command audit were all silently dead on V2. These tests pin
|
|
7658
|
+
// the repaired paths and add a structural guard against the recurrence.
|
|
7659
|
+
console.log("- V2 delivered event handlers -");
|
|
7660
|
+
|
|
7661
|
+
const v2EventModule = await import("../src/lib/v2-plugin.ts");
|
|
7662
|
+
const { handleV2Event } = v2EventModule;
|
|
7663
|
+
const { EventManifest } = await import("@opencode/schema/event-manifest");
|
|
7664
|
+
const serverEventTypes = new Set<string>([...EventManifest.ServerDefinitions].map((entry) => entry.type as string));
|
|
7665
|
+
|
|
7666
|
+
// E5 (structural, highest value): every literal the plugin compares against
|
|
7667
|
+
// `event.type` must be a type the host actually delivers. This is what would
|
|
7668
|
+
// have caught the three dead handlers before they shipped.
|
|
7669
|
+
{
|
|
7670
|
+
const pluginSource = readFileSync("src/lib/v2-plugin.ts", "utf8");
|
|
7671
|
+
const literals = [...pluginSource.matchAll(/event\??\.type\s*(?:===|!==)\s*"([^"]+)"/g)].map((match) => match[1]);
|
|
7672
|
+
const unique = [...new Set(literals)];
|
|
7673
|
+
const undelivered = unique.filter((type) => !serverEventTypes.has(type));
|
|
7674
|
+
check("V2 event-liveness scan finds the plugin's event comparisons", unique.length >= 5);
|
|
7675
|
+
check("every event.type literal in v2-plugin.ts is in EventManifest.ServerDefinitions", undelivered.length === 0);
|
|
7676
|
+
if (undelivered.length > 0) console.log(` undelivered literal(s): ${undelivered.join(", ")}`);
|
|
7677
|
+
// The three types that made A1/A3/A4 dead must not come back as literals.
|
|
7678
|
+
check("the replay-only tool-content type is no longer handled", !unique.includes("session.message.content.updated"));
|
|
7679
|
+
check("the ephemeral mcp.tools.changed type is no longer handled", !unique.includes("mcp.tools.changed"));
|
|
7680
|
+
check("the replay-only command.executed type is no longer handled", !unique.includes("command.executed"));
|
|
7681
|
+
}
|
|
7682
|
+
|
|
7683
|
+
// E1: the deny contract. `execute.before` may reject, and that rejection is what
|
|
7684
|
+
// fails the call with the guard's reason. Source-only fact: the adapter wraps
|
|
7685
|
+
// only that hook in `Effect.tryPromise`. A real V2 instance is driven so the
|
|
7686
|
+
// assertion covers the shipped registration, not a re-creation of it.
|
|
7687
|
+
{
|
|
7688
|
+
resetPluginInstancesForTesting();
|
|
7689
|
+
resetToolCallClaimsForTesting();
|
|
7690
|
+
const denyRoot = mkdtempSync(join(tmpdir(), "wg-v2-deny-"));
|
|
7691
|
+
const v2Deny = makeV2Context(denyRoot);
|
|
7692
|
+
await (defaultExport.setup as any)(v2Deny.ctx);
|
|
7693
|
+
const beforeHook = v2Deny.state.beforeHooks[0];
|
|
7694
|
+
check("V2 registers exactly one execute.before hook", v2Deny.state.beforeHooks.length === 1);
|
|
7695
|
+
let rejected: unknown;
|
|
7696
|
+
let allowedReason: unknown = "sentinel";
|
|
7697
|
+
try {
|
|
7698
|
+
await beforeHook({ tool: "write", sessionID: "s-v2-deny", id: "call-deny", agent: "build", messageID: "m1", input: { filePath: join(denyRoot, "..", "escape.txt"), content: "x" } });
|
|
7699
|
+
} catch (error) {
|
|
7700
|
+
rejected = error;
|
|
7701
|
+
}
|
|
7702
|
+
check("a blocked execute.before rejects the call", rejected instanceof Error);
|
|
7703
|
+
check("the rejection carries the workflow-guard reason prefix", rejected instanceof Error && rejected.message.includes("[workflow-guard]"));
|
|
7704
|
+
check("the rejection carries an actionable policy reason", rejected instanceof Error && rejected.message.length > "[workflow-guard] ".length + 10);
|
|
7705
|
+
// A permitted call must stay silent: the hook resolves without throwing.
|
|
7706
|
+
try {
|
|
7707
|
+
allowedReason = await beforeHook({ tool: "read", sessionID: "s-v2-deny", id: "call-allow", agent: "build", messageID: "m2", input: { filePath: join(denyRoot, "ok.txt") } });
|
|
7708
|
+
} catch (error) {
|
|
7709
|
+
allowedReason = error;
|
|
7710
|
+
}
|
|
7711
|
+
check("a permitted execute.before resolves without throwing", !(allowedReason instanceof Error));
|
|
7712
|
+
rmSync(denyRoot, { recursive: true, force: true });
|
|
7713
|
+
}
|
|
7714
|
+
|
|
7715
|
+
// E2: tool-outcome telemetry (A1). The old subscription keyed on a replay-only
|
|
7716
|
+
// event, so nothing ever reached the tracker and the circuit breaker could not
|
|
7717
|
+
// fire. Recording now happens in `execute.after`, which does fire for a failed
|
|
7718
|
+
// call, so an error accrues failure debt and a success clears it.
|
|
7719
|
+
{
|
|
7720
|
+
resetPluginInstancesForTesting();
|
|
7721
|
+
resetToolCallClaimsForTesting();
|
|
7722
|
+
const outcomeRoot = mkdtempSync(join(tmpdir(), "wg-v2-outcome-"));
|
|
7723
|
+
const v2Outcome = makeV2Context(outcomeRoot);
|
|
7724
|
+
await (defaultExport.setup as any)(v2Outcome.ctx);
|
|
7725
|
+
const beforeHook = v2Outcome.state.beforeHooks[0];
|
|
7726
|
+
const afterHook = v2Outcome.state.afterHooks[0];
|
|
7727
|
+
const breaker = (error: unknown) => error instanceof Error && error.message.includes("[Workflow Guard Circuit Breaker");
|
|
7728
|
+
// One error is not enough: the suffix starts at a count of 2.
|
|
7729
|
+
await afterHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-a", agent: "build", messageID: "m1", input: {}, status: "error", error: new Error("boom") });
|
|
7730
|
+
let firstError: unknown;
|
|
7731
|
+
try {
|
|
7732
|
+
await beforeHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-b", agent: "build", messageID: "m2", input: { filePath: join(outcomeRoot, "..", "escape.txt"), content: "x" } });
|
|
7733
|
+
} catch (error) {
|
|
7734
|
+
firstError = error;
|
|
7735
|
+
}
|
|
7736
|
+
check("one failure stays below the circuit-breaker threshold", !breaker(firstError));
|
|
7737
|
+
// A second equivalent error crosses the threshold.
|
|
7738
|
+
await afterHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-c", agent: "build", messageID: "m3", input: {}, status: "error", error: new Error("boom") });
|
|
7739
|
+
let secondError: unknown;
|
|
7740
|
+
try {
|
|
7741
|
+
await beforeHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-d", agent: "build", messageID: "m4", input: { filePath: join(outcomeRoot, "..", "escape.txt"), content: "x" } });
|
|
7742
|
+
} catch (error) {
|
|
7743
|
+
secondError = error;
|
|
7744
|
+
}
|
|
7745
|
+
check("two equivalent tool errors raise the circuit-breaker path", breaker(secondError));
|
|
7746
|
+
// A completed call clears the debt, so the suffix disappears again.
|
|
7747
|
+
await afterHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-e", agent: "build", messageID: "m5", input: {}, status: "completed", result: { content: "ok" } });
|
|
7748
|
+
let clearedError: unknown;
|
|
7749
|
+
try {
|
|
7750
|
+
await beforeHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-f", agent: "build", messageID: "m6", input: { filePath: join(outcomeRoot, "..", "escape.txt"), content: "x" } });
|
|
7751
|
+
} catch (error) {
|
|
7752
|
+
clearedError = error;
|
|
7753
|
+
}
|
|
7754
|
+
check("the circuit-breaker suffix is gone after a success", !breaker(clearedError));
|
|
7755
|
+
check("the failure outcome is journaled for diagnosis", getRecentAuditEntries(50).some((entry) => entry.sessionID === "s-v2-outcome" && entry.phase === "outcome" && entry.reason === "error"));
|
|
7756
|
+
// Read the counter directly, so the debt assertions cannot pass on a call
|
|
7757
|
+
// that throws for an unrelated reason. Two errors accrued, one completion
|
|
7758
|
+
// cleared them; the audit rows are the durable witness for the same state.
|
|
7759
|
+
const outcomeAudit = getRecentAuditEntries(50).filter((entry) => entry.sessionID === "s-v2-outcome" && entry.phase === "outcome");
|
|
7760
|
+
check("two failures and one completion are each journaled", outcomeAudit.filter((entry) => entry.reason === "error").length === 2 && outcomeAudit.some((entry) => entry.reason === "completed"));
|
|
7761
|
+
// A partial event with no terminal status takes the fallback path. That path
|
|
7762
|
+
// must still journal its outcome row, which is the coverage the move onto
|
|
7763
|
+
// `execute.after` nearly dropped.
|
|
7764
|
+
await afterHook({ tool: "write", sessionID: "s-v2-outcome", id: "call-g", agent: "build", messageID: "m7", input: {}, result: { content: "ok" } });
|
|
7765
|
+
check("a status-less after event still journals an outcome row", getRecentAuditEntries(50).some((entry) => entry.sessionID === "s-v2-outcome" && entry.callID === "call-g" && entry.phase === "outcome"));
|
|
7766
|
+
rmSync(outcomeRoot, { recursive: true, force: true });
|
|
7767
|
+
}
|
|
7768
|
+
|
|
7769
|
+
// E3: a user interrupt suspends continuation on V2 (A2). Only reason `user` is
|
|
7770
|
+
// user intent; the other three are host-lifecycle events and must not stop the
|
|
7771
|
+
// session's own unfinished work from continuing.
|
|
7772
|
+
{
|
|
7773
|
+
const makeState = (root: string, client: unknown) => ({
|
|
7774
|
+
effectiveRoot: root,
|
|
7775
|
+
client,
|
|
7776
|
+
toolLifecycle: new ToolInvocationLifecycle(),
|
|
7777
|
+
toolOutcomes: new ToolOutcomeTracker(),
|
|
7778
|
+
});
|
|
7779
|
+
const interrupts = [
|
|
7780
|
+
{ reason: "user", suspends: true },
|
|
7781
|
+
{ reason: "shutdown", suspends: false },
|
|
7782
|
+
{ reason: "superseded", suspends: false },
|
|
7783
|
+
{ reason: "inactivity", suspends: false },
|
|
7784
|
+
];
|
|
7785
|
+
for (const { reason, suspends } of interrupts) {
|
|
7786
|
+
const sessionID = `s-v2-int-${reason}`;
|
|
7787
|
+
todo(sessionID, item("unfinished work", "pending"));
|
|
7788
|
+
// A continuation can only be observed if the client can actually prompt,
|
|
7789
|
+
// and continuation state is keyed on the client object, so each case
|
|
7790
|
+
// gets its own client and asserts on the delivered prompt rather than on
|
|
7791
|
+
// a false-negative return value.
|
|
7792
|
+
const prompted: string[] = [];
|
|
7793
|
+
const continuationClient = {
|
|
7794
|
+
session: {
|
|
7795
|
+
todo: async ({ path }: { path: { id: string } }) => ({ data: fakeTodos.get(path.id) ?? [] }),
|
|
7796
|
+
get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: fakeParents.get(path.id) } }),
|
|
7797
|
+
promptAsync: async ({ path }: { path: { id: string } }) => { prompted.push(path.id); },
|
|
7798
|
+
},
|
|
7799
|
+
};
|
|
7800
|
+
await handleV2Event({ type: "session.execution.interrupted", data: { sessionID, reason } }, makeState(root, continuationClient) as any);
|
|
7801
|
+
const continued = await runWithRuntimeState(root, continuationClient, () => continueUnfinishedSession(sessionID));
|
|
7802
|
+
check(`session.execution.interrupted reason "${reason}" ${suspends ? "suspends" : "does not suspend"} continuation`, continued === !suspends && prompted.length === (suspends ? 0 : 1));
|
|
7803
|
+
await runWithRuntimeState(root, continuationClient, () => clearContinuationState(sessionID));
|
|
7804
|
+
}
|
|
7805
|
+
// A genuine user message re-enables a suspended session, proven by an
|
|
7806
|
+
// actual continuation prompt: suspension must block it, user input must not.
|
|
7807
|
+
const resumedSession = "s-v2-int-resume";
|
|
7808
|
+
todo(resumedSession, item("unfinished work", "pending"));
|
|
7809
|
+
const resumedPrompts: string[] = [];
|
|
7810
|
+
const resumeClient = {
|
|
7811
|
+
session: {
|
|
7812
|
+
todo: async ({ path }: { path: { id: string } }) => ({ data: fakeTodos.get(path.id) ?? [] }),
|
|
7813
|
+
get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: fakeParents.get(path.id) } }),
|
|
7814
|
+
promptAsync: async ({ path }: { path: { id: string } }) => { resumedPrompts.push(path.id); },
|
|
7815
|
+
},
|
|
7816
|
+
};
|
|
7817
|
+
await handleV2Event({ type: "session.execution.interrupted", data: { sessionID: resumedSession, reason: "user" } }, makeState(root, resumeClient) as any);
|
|
7818
|
+
const suspendedResult = await runWithRuntimeState(root, resumeClient, () => continueUnfinishedSession(resumedSession));
|
|
7819
|
+
check("a suspended session sends no continuation prompt", suspendedResult === false && resumedPrompts.length === 0);
|
|
7820
|
+
// Continuation state is keyed on the client object, so the user-message
|
|
7821
|
+
// record must run against the same client the session was suspended under.
|
|
7822
|
+
await runWithRuntimeState(root, resumeClient, () => recordUserMessage(resumedSession));
|
|
7823
|
+
const afterUserInput = await runWithRuntimeState(root, resumeClient, () => continueUnfinishedSession(resumedSession));
|
|
7824
|
+
check("genuine user input re-enables a suspended continuation", afterUserInput === true && resumedPrompts.length === 1);
|
|
7825
|
+
await runWithRuntimeState(root, resumeClient, () => clearContinuationState(resumedSession));
|
|
7826
|
+
}
|
|
7827
|
+
|
|
7828
|
+
// E4: the MCP inventory refresh keys on a delivered event (A3), and widening is
|
|
7829
|
+
// additive only. A guarded server that connects mid-session must be guarded
|
|
7830
|
+
// without waiting for the next session, and a quieter roster must never unguard
|
|
7831
|
+
// a token that a static rule already covers.
|
|
7832
|
+
{
|
|
7833
|
+
const refreshCalls: string[] = [];
|
|
7834
|
+
const state = {
|
|
7835
|
+
effectiveRoot: root,
|
|
7836
|
+
client: fakeClient,
|
|
7837
|
+
toolLifecycle: new ToolInvocationLifecycle(),
|
|
7838
|
+
toolOutcomes: new ToolOutcomeTracker(),
|
|
7839
|
+
refreshMcpInventory: async () => { refreshCalls.push("refresh"); },
|
|
7840
|
+
};
|
|
7841
|
+
await handleV2Event({ type: "mcp.status.changed", data: { server: "github-enterprise" } }, state as any);
|
|
7842
|
+
check("a delivered mcp.status.changed triggers the inventory refresh", refreshCalls.length === 1);
|
|
7843
|
+
await handleV2Event({ type: "mcp.tools.changed", data: { server: "github-enterprise" } }, state as any);
|
|
7844
|
+
check("the undelivered mcp.tools.changed no longer triggers anything", refreshCalls.length === 1);
|
|
7845
|
+
// Widening is additive: a later roster without the server cannot remove it.
|
|
7846
|
+
resetGuardedMcpTokensForTesting();
|
|
7847
|
+
const added = recordHostMcpServers(["github-enterprise"]);
|
|
7848
|
+
const widened = [...guardedMcpServers()];
|
|
7849
|
+
check("a host-declared guarded server widens the token set", added.length > 0 && widened.some((token) => added.includes(token)));
|
|
7850
|
+
recordHostMcpServers([]);
|
|
7851
|
+
check("an emptied roster never narrows the guarded token set", [...guardedMcpServers()].length >= widened.length);
|
|
7852
|
+
resetGuardedMcpTokensForTesting();
|
|
7853
|
+
}
|
|
7854
|
+
|
|
7855
|
+
// E6: the idle trigger. The host keeps two event types for it and neither is
|
|
7856
|
+
// established as the one the server publishes: `session.status` with
|
|
7857
|
+
// `status.type === "idle"`, and the deprecated `session.idle`. Handling only
|
|
7858
|
+
// one silently disables automatic continuation if the other is what arrives.
|
|
7859
|
+
// The assertions are delivered continuation prompts, not return values.
|
|
7860
|
+
{
|
|
7861
|
+
const makeIdleState = (client: unknown) => ({
|
|
7862
|
+
effectiveRoot: root,
|
|
7863
|
+
client,
|
|
7864
|
+
toolLifecycle: new ToolInvocationLifecycle(),
|
|
7865
|
+
toolOutcomes: new ToolOutcomeTracker(),
|
|
7866
|
+
});
|
|
7867
|
+
const idleSession = "s-v2-idle-status";
|
|
7868
|
+
todo(idleSession, item("unfinished work", "pending"));
|
|
7869
|
+
const idlePrompts: string[] = [];
|
|
7870
|
+
const idleClient = {
|
|
7871
|
+
session: {
|
|
7872
|
+
todo: async ({ path }: { path: { id: string } }) => ({ data: fakeTodos.get(path.id) ?? [] }),
|
|
7873
|
+
get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: fakeParents.get(path.id) } }),
|
|
7874
|
+
promptAsync: async ({ path }: { path: { id: string } }) => { idlePrompts.push(path.id); },
|
|
7875
|
+
},
|
|
7876
|
+
};
|
|
7877
|
+
await handleV2Event({ type: "session.status", data: { sessionID: idleSession, status: { type: "busy" } } }, makeIdleState(idleClient) as any);
|
|
7878
|
+
check("a busy session.status does not continue the session", idlePrompts.length === 0);
|
|
7879
|
+
await handleV2Event({ type: "session.status", data: { sessionID: idleSession, status: { type: "idle" } } }, makeIdleState(idleClient) as any);
|
|
7880
|
+
check("an idle session.status continues the session", idlePrompts.length === 1);
|
|
7881
|
+
// The deprecated trigger must still work: a host that emits only this one
|
|
7882
|
+
// would otherwise lose auto-continuation entirely.
|
|
7883
|
+
await handleV2Event({ type: "session.idle", data: { sessionID: idleSession } }, makeIdleState(idleClient) as any);
|
|
7884
|
+
check("the deprecated session.idle still continues the session", idlePrompts.length === 2);
|
|
7885
|
+
await runWithRuntimeState(root, idleClient, () => clearContinuationState(idleSession));
|
|
7886
|
+
|
|
7887
|
+
// A host that emits both types for one transition must produce one prompt.
|
|
7888
|
+
// Each event is handled on its own call, so this pins the in-flight lock
|
|
7889
|
+
// rather than any per-call dedupe.
|
|
7890
|
+
const bothSession = "s-v2-idle-both";
|
|
7891
|
+
todo(bothSession, item("unfinished work", "pending"));
|
|
7892
|
+
let bothPrompts = 0;
|
|
7893
|
+
let releaseBoth!: () => void;
|
|
7894
|
+
const bothGate = new Promise<void>((resolve) => { releaseBoth = resolve; });
|
|
7895
|
+
const bothClient = {
|
|
7896
|
+
session: {
|
|
7897
|
+
todo: async ({ path }: { path: { id: string } }) => ({ data: fakeTodos.get(path.id) ?? [] }),
|
|
7898
|
+
get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: fakeParents.get(path.id) } }),
|
|
7899
|
+
promptAsync: async () => { bothPrompts++; await bothGate; },
|
|
7900
|
+
},
|
|
7901
|
+
};
|
|
7902
|
+
const firstTrigger = handleV2Event({ type: "session.status", data: { sessionID: bothSession, status: { type: "idle" } } }, makeIdleState(bothClient) as any);
|
|
7903
|
+
const secondTrigger = handleV2Event({ type: "session.idle", data: { sessionID: bothSession } }, makeIdleState(bothClient) as any);
|
|
7904
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
7905
|
+
releaseBoth();
|
|
7906
|
+
await Promise.all([firstTrigger, secondTrigger]);
|
|
7907
|
+
check("both idle event types for one transition yield a single continuation prompt", bothPrompts === 1);
|
|
7908
|
+
await runWithRuntimeState(root, bothClient, () => clearContinuationState(bothSession));
|
|
7909
|
+
}
|
|
7910
|
+
|
|
7649
7911
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
7650
7912
|
process.exitCode = fail ? 1 : 0;
|