opencode-workflow-guard 1.17.0 → 1.18.1
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/installation.md +40 -15
- package/docs/policies.md +3 -1
- package/docs/testing.md +2 -1
- package/package.json +7 -6
- package/src/lib/guard-dispatcher.ts +24 -3
- package/src/lib/permission-seat.ts +35 -18
- package/src/lib/types.ts +6 -1
- package/src/lib/v2-plugin.ts +13 -4
- package/src/lib/verify.ts +18 -1
- package/test/test-e2e.mts +26 -0
- package/test/test.mts +153 -0
package/docs/installation.md
CHANGED
|
@@ -6,8 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
|
-
- **OpenCode >= 1.18** (V1: requires the `GET /session/:id/todo` endpoint and the V1 `PluginModule` loader) or **OpenCode 2.x**. V2 loads through the `@opencode/plugin` API. The current todo list is reconstructed from session message history, or read from the native `todowrite`
|
|
9
|
+
- **OpenCode >= 1.18** (V1: requires the `GET /session/:id/todo` endpoint and the V1 `PluginModule` loader) or **OpenCode 2.x**. V2 loads through the `@opencode/plugin` API. The current todo list is reconstructed from session message history, or read from the native `todowrite` behind `experimental.todo`. The guard probes the host tool registry for that capability instead of assuming it per version.
|
|
10
10
|
- **Node.js >= 22.18** or **Bun** (for running test scripts directly).
|
|
11
|
+
`@opentui/core` declares `node >=26.4.0` in its own `engines`, and that has
|
|
12
|
+
been true since at least `0.6.6`. It arrives as a transitive dependency of
|
|
13
|
+
`@opentui/solid`, so npm reports an engine warning rather than refusing the
|
|
14
|
+
install, and the server plugin never imports it. Only the optional TUI
|
|
15
|
+
companion loads `@opentui/solid`. The pin set is exercised on Node 22.18+ in
|
|
16
|
+
CI, which is the practical floor for the plugin itself.
|
|
11
17
|
|
|
12
18
|
### Version Qualification
|
|
13
19
|
|
|
@@ -22,13 +28,24 @@ verified it.
|
|
|
22
28
|
| 1.16.x | 2.0.15 | `permission.hook("evaluate")`, `tool.hook("execute.before"/"execute.after")`, `session.hook("context")`, `experimental.text.complete`, command registration, `mcp.list`, `worktree` client | Unit and package-loader verified. Live model-driven probes reached the provider and were classified `live unavailable` (`Model unavailable: openrouter/openrouter/auto`), so no guard verdict was produced. |
|
|
23
29
|
| 1.16.x | 2.1.3 | `permission.hook("evaluate")`, `tool.hook(...)`, `session.hook(...)`, `experimental.text.complete`, command registration, `mcp.list`, `worktree` client, `todowrite` behind `experimental.todo` | Package-loader and headless-runtime verified (`npm run test:install`: 30 passed). Live host probe confirmed `ctx.tool.list()` exposes `todowrite` with `experimental.todo: true` and omits it when false. Live static-policy denial canary produced `Blocked by configuration policy`. |
|
|
24
30
|
| 1.16.x | 1.18.32 (`@opencode-ai/plugin`) | V1 `PluginModule` loader, native todo endpoint | Compatibility export only; unit-tested, no live host probe. |
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
2.
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
| 1.18.x | 2.2.0 | `permission.hook("evaluate")`, `tool.hook("execute.before"/"execute.after")`, `session.hook("context"/"compaction"/"prompt")`, `command.transform`, `shell.hook("create.before")`, `event.subscribe()` (completion-claim observation via `session.text.ended`), command registration, `mcp.list`, `worktree` client, `todowrite` behind `experimental.todo` | Package-loader and headless-runtime verified on the 2.2.0 pin set (`npm run test:install`: 32 passed). Registration of the hook seats and custom tools is asserted, not re-derived: the 2.2.0-specific probes are the permission seat (a real finding denies, an undecidable push abstains) and the todo capability (both branches asserted on this host). V2 exposes completion claims through `event.subscribe()` rather than the V1 `experimental.text.complete` hook; the audit trail keeps the V1 tool name for continuity. Live model-driven probes were skipped, so no guard verdict from a model turn is claimed here. |
|
|
32
|
+
|
|
33
|
+
The 2.2.0 pins require the 2.2.x package set. `@opencode/plugin` and
|
|
34
|
+
`@opencode/ai` are pinned to `2.2.0`, and `@opentui/solid` moves to `0.7.0`
|
|
35
|
+
because `@opencode/plugin@2.2.0` declares that as an exact peer requirement.
|
|
36
|
+
`@opentui/core` is not pinned directly: `@opentui/solid@0.7.0` already declares
|
|
37
|
+
it as an exact dependency, so resolution supplies the matching version. That
|
|
38
|
+
release also moves `Message` out of the `@opencode/ai` root: on 2.2.0 the root
|
|
39
|
+
no longer re-exports the schema surface, so a root named import of `Message`
|
|
40
|
+
throws at load, and the schema lives behind `@opencode/ai/effect`. The guard
|
|
41
|
+
imports from `/effect`, which is the path `@opencode/plugin@2.2.0` uses itself.
|
|
42
|
+
`@opencode/ai@2.1.3` resolves `./effect` only through its `"./*"` wildcard, and
|
|
43
|
+
that release ships no `dist/effect.js` for the wildcard to reach, so the two pin
|
|
44
|
+
sets are not interchangeable: one release's import path does not exist in the
|
|
45
|
+
other.
|
|
46
|
+
|
|
47
|
+
`@opencode-ai/plugin` stays at `1.19.3`. That is the V1 pin, emitted alongside
|
|
48
|
+
the V2 entrypoint, and the 2.2.0 release does not change it.
|
|
32
49
|
|
|
33
50
|
`guard_status` reports `pluginVersion`, `qualifiedHostVersion`, and the live
|
|
34
51
|
`hostVersion`, so a mismatch is visible at runtime rather than surfacing as a
|
|
@@ -36,21 +53,29 @@ silently inert hook.
|
|
|
36
53
|
|
|
37
54
|
### Dependency Audit Overrides
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
clear advisories that would otherwise ship:
|
|
56
|
+
Three transitive dependencies are pinned above the range their parent declares,
|
|
57
|
+
to clear advisories that would otherwise ship:
|
|
41
58
|
|
|
42
59
|
| Package | Parent constraint | Override | Why |
|
|
43
60
|
|---|---|---|---|
|
|
44
|
-
| `solid-js` | `@opentui/solid@0.
|
|
61
|
+
| `solid-js` | `@opentui/solid@0.7.0` peer-pins exactly `1.9.12` | `1.9.17` | `1.9.12` requires `seroval ~1.5.0`, which carries memory-exhaustion and thenable-assimilation advisories. `1.9.17` requires `seroval ~1.6.8`, which is patched. The TUI imports `solid-js` directly, so the bump is also the version the guard actually runs. |
|
|
45
62
|
| `toml` | `effect@4.0.0-beta.83` (via `@opencode-ai/plugin`) requires `^4.1.1` | `5.0.1` | `toml <=5.0.0` carries three quadratic-complexity denial-of-service advisories. `5.0.1` keeps the same single `parse` export the caller uses. The guard itself never parses TOML; this is host plumbing. |
|
|
63
|
+
| `zod` | `@opencode-ai/plugin@1.19.3` pins `4.1.8` exactly | `4.6.6` | `GHSA-mrhx-c7xc-m3w5` (published 2026-10-01) covers `zod <4.6.6` with a memory-exhaustion advisory. `4.6.6` is the patched release in the same major line. `zod` reaches the tree only through the V1 host pin, but the guard's own V2 entrypoint imports it, so one deduped patched copy serves both. An override is required where a direct dependency would not do: the V1 pin is exact, so declaring `zod` at the root alone leaves a nested `4.1.8` and the advisory standing. |
|
|
46
64
|
|
|
47
|
-
|
|
65
|
+
All three are `overrides` entries in `package.json`, and `npm audit` reports zero
|
|
48
66
|
vulnerabilities for this repository. `npm overrides` apply only to the root
|
|
49
67
|
project, so they protect this repository's install and its CI, not a consumer's
|
|
50
68
|
transitive tree. A consumer that installs the published package resolves the
|
|
51
|
-
parent-declared ranges unless it declares the same overrides itself. Revisit
|
|
52
|
-
when `@opentui/solid` or `@opencode-ai/plugin` widen their ranges, and drop
|
|
53
|
-
override rather than carrying it forward.
|
|
69
|
+
parent-declared ranges unless it declares the same overrides itself. Revisit
|
|
70
|
+
them when `@opentui/solid` or `@opencode-ai/plugin` widen their ranges, and drop
|
|
71
|
+
the override rather than carrying it forward.
|
|
72
|
+
|
|
73
|
+
Changing an override requires a clean reinstall. `npm install` over an existing
|
|
74
|
+
`node_modules` can leave a lockfile that still resolves the old version, and the
|
|
75
|
+
resulting failure reads like an incompatibility. With the `zod` override, an
|
|
76
|
+
incremental install produced 14 `test:install` failures and a `tsc` error about
|
|
77
|
+
a missing `zod`, while removing `node_modules` and `package-lock.json` and
|
|
78
|
+
reinstalling passed every check.
|
|
54
79
|
|
|
55
80
|
---
|
|
56
81
|
|
package/docs/policies.md
CHANGED
|
@@ -104,6 +104,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
104
104
|
- When the agent attempts to mark **every** task completed (finalizing the request), the guard requires passing verification evidence.
|
|
105
105
|
- Verification executes in an isolated environment with scrubbed credentials, output caps, token-efficient stdout/stderr snipping, and strict timeout protection. The command comes from the `WORKFLOW_GUARD_VERIFY` env, the project `verifyCommand`, or an auto-detected test or typecheck runner across ecosystems. Auto-detection covers `package.json` (`npm test`, `npm run typecheck`, `npm run check`, `npm run build`), `Cargo.toml` (`cargo test`), `go.mod` (`go test ./...`), `pytest.ini` or `pyproject.toml` (`pytest`), and `deno.json` (`deno test`). Environment configuration takes precedence over project configuration.
|
|
106
106
|
- The verification timeout defaults to 30 seconds. Raise it per project with `verifyTimeoutMs` in `.opencode/workflow-guard.json`, or per environment with `WORKFLOW_GUARD_VERIFY_TIMEOUT_MS`, which wins over project config. Non-positive values are ignored and fall back to the default. Raise the timeout for suites that legitimately exceed the default, for example this repository's own unit suite.
|
|
107
|
+
- 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.
|
|
107
108
|
- 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.
|
|
108
109
|
- With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list. Subagents and generated continuation prompts do not replace them. At `session.idle`, the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
|
|
109
110
|
- 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.
|
|
@@ -130,11 +131,12 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
130
131
|
- Permission prompts are journaled at request time via the typed `permission.ask` plugin hook. `permission.replied` outcomes, including rejections, are preserved instead of being labeled as allows.
|
|
131
132
|
- On OpenCode 2, the V2 plugin also *decides* at that seat. `ctx.permission.hook("evaluate")` maps the host's normalized `action` and `resources` back onto the policy core and sets `effect = "deny"` with an attributable message, so the tool never starts. Shell resources feed the shell, git, destructive, and workspace policies. `read` resources feed the secret policy. `edit`, `write`, `patch`, and `apply_patch` resources feed the boundary, tamper, secret, protected-branch, and todo policies. Guarded MCP servers (`GitHub`, `Azure DevOps`) feed the MCP policy.
|
|
132
133
|
- The seat can only tighten. An explicit configured `deny` is final and never reaches the hook. An unmapped host action (`external_directory`, `glob`, `grep`, `webfetch`, `websearch`, `question`, `subagent`) is left to host policy. If the seat itself throws, it fails closed with a deny instead of becoming a pass.
|
|
134
|
+
- **Only a finding denies; an undecidable simulation abstains.** The seat always evaluates the policy core with `simulate: true`, which skips the remote GitHub/Azure PR probe on purpose. The core reports `needs_approval` (`remote_state_unchecked`) when it cannot decide without that probe. Collapsing that status into a denial blocked every non-protected `git push`, so the seat now abstains: it leaves the host's configured effect and message untouched and defers the real decision to `execute.before`, which runs the probe. The abstention is journaled with `decision: "abstain"`, `reason: "deferred_to_execute_before"`, and the preserved `needs_approval` decision, so a skipped probe stays visible instead of reading as a plain allow. `guard_why` keeps reporting `needs_approval`; this is a change to the seat, not to the policy core.
|
|
133
135
|
- `execute.before` is kept as the second, last-resort seat. It sees raw tool input, so todo lifecycle, stale-write observations, and post-edit snapshots stay there. Its thrown message carries the specific remediation text to the model.
|
|
134
136
|
- `client.app.log()` complements the on-disk trail with in-app logs.
|
|
135
137
|
|
|
136
138
|
### 15. Compaction State & Policy Context Preservation
|
|
137
|
-
- Integrates with OpenCode's compaction hook to inject bounded active policy state before context summarization. The hook is `experimental.session.compacting` on OpenCode 1.x and `session.hook("compaction")` on OpenCode 2.x, which is stable in the installed 2.
|
|
139
|
+
- Integrates with OpenCode's compaction hook to inject bounded active policy state before context summarization. The hook is `experimental.session.compacting` on OpenCode 1.x and `session.hook("compaction")` on OpenCode 2.x, which is stable in the installed 2.2.0 typings:
|
|
138
140
|
- Caps injected continuity context at 8,000 characters. Operational guard state is preserved first. The remaining budget holds active tasks, review follow-ups, and fresh local project memory.
|
|
139
141
|
- Active `todowrite` tasks with status badges and subagent hierarchy attribution.
|
|
140
142
|
- Active Git branch name and protected branch status.
|
package/docs/testing.md
CHANGED
|
@@ -47,12 +47,13 @@ npm run test:all
|
|
|
47
47
|
| **Policy 5: MCP Mutations** | Verifies GitHub/Azure mutating MCP tools are blocked while read-only tools pass. |
|
|
48
48
|
| **Policy 6: Settings Tamper** | Verifies protection of `opencode.json`, `~/.config/opencode/*`, and `opencode auth`. |
|
|
49
49
|
| **Policy 8: Workspace Boundary** | Verifies path traversal (`../`), symlinks, and absolute path escape protection in `edit`, `write`, `apply_patch`, and shell mutations. |
|
|
50
|
-
| **Policy 10: Evidence Verification** | Verifies isolated verification execution, timeout handling, scrubbed environment, and finalization gating on fresh passing tests. |
|
|
50
|
+
| **Policy 10: Evidence Verification** | Verifies isolated verification execution, timeout handling, scrubbed environment, and finalization gating on fresh passing tests. A timeout blocks finalization under its own code, and its reason names the limit and the remedy instead of claiming a failing test. |
|
|
51
51
|
| **Adversarial Invariants** | Verifies defense against compound shell mutations, symlink escapes, chained git normalization, verify script abuse, and external repo mutations. |
|
|
52
52
|
| **Audit & Permission Events** | Verifies the typed `permission.ask` hook, permission replies, and command journaling in the audit trail. |
|
|
53
53
|
| **Completion Claims & Tool Honesty** | Verifies the claims-vs-evidence detector (missing/failing/stale/fresh states), `experimental.text.complete` journaling, and `tool.definition` enrichment idempotence. |
|
|
54
54
|
| **Compaction & TUI Feedback** | Verifies active task injection in `experimental.session.compacting`, the static TUI badge, guard block warning toasts, and clean error propagation. |
|
|
55
55
|
| **Runtime Instance Isolation** | Verifies concurrent plugin instances keep workspace root and SDK client state isolated via `runWithRuntimeState`. |
|
|
56
|
+
| **V2 Permission Seat Abstention** | Verifies the simulated seat denies a real finding, abstains on `needs_approval` (`remote_state_unchecked`) instead of denying, never upgrades a configured `ask`, and journals the abstention as `deferred_to_execute_before` with the preserved decision. |
|
|
56
57
|
| **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`. |
|
|
57
58
|
| **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. |
|
|
58
59
|
| **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. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workflow-guard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.1",
|
|
4
4
|
"description": "Deterministic workflow and safety policy enforcement for OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,10 +51,10 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@opencode-ai/plugin": "1.19.3",
|
|
54
|
-
"@opencode/
|
|
55
|
-
"@
|
|
56
|
-
"
|
|
57
|
-
"
|
|
54
|
+
"@opencode/ai": "2.2.0",
|
|
55
|
+
"@opencode/plugin": "2.2.0",
|
|
56
|
+
"@opentui/solid": "0.7.0",
|
|
57
|
+
"jsonc-parser": "3.3.1"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@changesets/cli": "3.0.3",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
},
|
|
64
64
|
"overrides": {
|
|
65
65
|
"solid-js": "1.9.17",
|
|
66
|
-
"toml": "5.0.1"
|
|
66
|
+
"toml": "5.0.1",
|
|
67
|
+
"zod": "4.6.6"
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -153,6 +153,17 @@ function logBlock(message: string, simulate = false): void {
|
|
|
153
153
|
} catch {}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Render a verification timeout limit for the block reason.
|
|
158
|
+
*
|
|
159
|
+
* Sub-second limits are exercised by tests, and a bare `Math.round(ms/1000)`
|
|
160
|
+
* reports those as "0s", which reads as no limit at all.
|
|
161
|
+
*/
|
|
162
|
+
function formatTimeoutLimit(timeoutMs: number | undefined): string {
|
|
163
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return "the verification timeout";
|
|
164
|
+
return timeoutMs < 1000 ? `${timeoutMs}ms` : `${Math.round(timeoutMs / 1000)}s`;
|
|
165
|
+
}
|
|
166
|
+
|
|
156
167
|
export async function guardToolCallImpl(
|
|
157
168
|
toolName: string,
|
|
158
169
|
input: unknown,
|
|
@@ -256,10 +267,20 @@ export async function guardToolCallImpl(
|
|
|
256
267
|
const result = await runVerify(command, currentRoot, resolveVerifyTimeoutMs(currentRoot));
|
|
257
268
|
recordVerifyResult(command, result, sessionID, currentRoot);
|
|
258
269
|
if (!result.passed) {
|
|
259
|
-
|
|
260
|
-
|
|
270
|
+
// A timeout is not a failing test. Both block
|
|
271
|
+
// finalization, but naming a failure sends the
|
|
272
|
+
// agent chasing an assertion that never ran, and
|
|
273
|
+
// the remedy is a longer limit rather than a code
|
|
274
|
+
// change. The tail is snipped as passing output on
|
|
275
|
+
// that path, because snipVerifyOutput labels a
|
|
276
|
+
// failing run with a "Verification Failed" header
|
|
277
|
+
// that would restate the misattribution.
|
|
278
|
+
const tail = snipVerifyOutput(result.output, result.timedOut === true).slice(-500);
|
|
279
|
+
const reason = result.timedOut
|
|
280
|
+
? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Raise the limit in the project .opencode/workflow-guard.json (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS, then complete the tasks again; output tail: ${tail}`
|
|
281
|
+
: `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
|
|
261
282
|
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
262
|
-
return block("verification", "verification_failed", reason);
|
|
283
|
+
return block("verification", result.timedOut ? "verification_timed_out" : "verification_failed", reason);
|
|
263
284
|
}
|
|
264
285
|
}
|
|
265
286
|
}
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* when the configured effect is allow/ask). A hook denial is therefore always a
|
|
17
17
|
* response to something the host would otherwise permit.
|
|
18
18
|
*
|
|
19
|
+
* An `abstain` is the one case where the seat does not tighten. The simulated
|
|
20
|
+
* core reports `needs_approval` when it cannot decide without the remote
|
|
21
|
+
* GitHub/Azure PR probe, which simulation skips on purpose. The host's
|
|
22
|
+
* configured effect then stays untouched and `execute.before` runs the real
|
|
23
|
+
* check, probe included.
|
|
24
|
+
*
|
|
19
25
|
* Every decision here is a simulation of the policy core: `simulate: true`
|
|
20
26
|
* skips mutation accounting, file claims, verification runs, and durable audit
|
|
21
27
|
* writes, because the same call is evaluated for real in `execute.before`.
|
|
@@ -41,9 +47,13 @@ export interface PermissionSeatDeps {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
export interface PermissionSeatResult {
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The effect the seat leaves in place. `abstain` is an explicit third
|
|
52
|
+
* state: the simulated policy core could not decide, so the host's
|
|
53
|
+
* configured effect stays untouched and `execute.before` enforces.
|
|
54
|
+
*/
|
|
55
|
+
effect: "allow" | "deny" | "abstain";
|
|
56
|
+
/** Set when the seat denied, and kept on an abstention for the audit row. */
|
|
47
57
|
decision?: PolicyDecision;
|
|
48
58
|
/** The guard tool the action was mapped onto, for attribution. */
|
|
49
59
|
mappedTool?: string;
|
|
@@ -51,6 +61,25 @@ export interface PermissionSeatResult {
|
|
|
51
61
|
|
|
52
62
|
const EDIT_ACTIONS = new Set(["edit", "write", "patch", "apply_patch"]);
|
|
53
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Map one simulated policy decision onto the seat's effect.
|
|
66
|
+
*
|
|
67
|
+
* Only `needs_approval` abstains. That status means the simulated core could
|
|
68
|
+
* not decide, because `simulate: true` deliberately skips the remote GitHub or
|
|
69
|
+
* Azure PR probe. Collapsing it into `deny` would miscategorise an abstention
|
|
70
|
+
* as a finding and block every non-protected `git push`. Deferring it leaves
|
|
71
|
+
* the host's configured effect untouched, and `execute.before` runs the real
|
|
72
|
+
* check with the probe. Every other non-allowed status stays a denial.
|
|
73
|
+
*/
|
|
74
|
+
function seatVerdict(decision: PolicyDecision, mappedTool: string): PermissionSeatResult | undefined {
|
|
75
|
+
if (decision.status === "allowed") return undefined;
|
|
76
|
+
return {
|
|
77
|
+
effect: decision.status === "needs_approval" ? "abstain" : "deny",
|
|
78
|
+
decision: { ...decision, message: `[workflow-guard permission.evaluate] ${decision.message}` },
|
|
79
|
+
mappedTool,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
54
83
|
function mappedToolFor(action: string): { tool: string; input: (resource: string) => unknown } | undefined {
|
|
55
84
|
if (action === "shell" || action === "bash") {
|
|
56
85
|
return { tool: "bash", input: (resource) => ({ command: resource }) };
|
|
@@ -90,13 +119,8 @@ export async function evaluatePermissionSeat(
|
|
|
90
119
|
if (mapping) {
|
|
91
120
|
for (const resource of resources) {
|
|
92
121
|
const decision = await decide(mapping.tool, mapping.input(resource));
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
effect: "deny",
|
|
96
|
-
decision: { ...decision, message: `[workflow-guard permission.evaluate] ${decision.message}` },
|
|
97
|
-
mappedTool: mapping.tool,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
122
|
+
const verdict = seatVerdict(decision, mapping.tool);
|
|
123
|
+
if (verdict) return verdict;
|
|
100
124
|
}
|
|
101
125
|
return { effect: "allow", mappedTool: mapping.tool };
|
|
102
126
|
}
|
|
@@ -106,14 +130,7 @@ export async function evaluatePermissionSeat(
|
|
|
106
130
|
for (const resource of resources) {
|
|
107
131
|
if (!mcpMutationTool(event.action) && !mcpMutationTool(resource)) continue;
|
|
108
132
|
const decision = await decide(event.action, {});
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
effect: "deny",
|
|
112
|
-
decision: { ...decision, message: `[workflow-guard permission.evaluate] ${decision.message}` },
|
|
113
|
-
mappedTool: event.action,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
return { effect: "allow", mappedTool: event.action };
|
|
133
|
+
return seatVerdict(decision, event.action) ?? { effect: "allow", mappedTool: event.action };
|
|
117
134
|
}
|
|
118
135
|
return { effect: "allow" };
|
|
119
136
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -143,7 +143,12 @@ export interface AuditEntry {
|
|
|
143
143
|
sessionID?: string;
|
|
144
144
|
callID?: string;
|
|
145
145
|
tool: string;
|
|
146
|
-
|
|
146
|
+
/**
|
|
147
|
+
* `abstain` records a decision the guard deferred instead of resolving:
|
|
148
|
+
* the simulated permission seat cannot judge remote PR state, so
|
|
149
|
+
* `execute.before` decides for real. It is not an allow.
|
|
150
|
+
*/
|
|
151
|
+
decision: "allow" | "block" | "abstain";
|
|
147
152
|
policyDecision?: PolicyDecision;
|
|
148
153
|
phase?: "decision" | "outcome" | "event";
|
|
149
154
|
durationMs?: number;
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { Plugin } from "@opencode/plugin";
|
|
21
|
-
import { Message } from "@opencode/ai";
|
|
21
|
+
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";
|
|
@@ -145,6 +145,9 @@ const SUBAGENT_SUFFIX =
|
|
|
145
145
|
// server registry, so this enrichment cannot reach them.
|
|
146
146
|
// - V2.1.x adds a builtin `todowrite` behind the `experimental.todo` config
|
|
147
147
|
// key, so the candidate below reaches a real registry entry there.
|
|
148
|
+
// - V2.2.x exposes `todowrite` through that same `experimental.todo` opt-in.
|
|
149
|
+
// The guard probes the registry rather than inferring capability from the
|
|
150
|
+
// version, so 2.1.x and 2.2.x need no version check here.
|
|
148
151
|
// - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
|
|
149
152
|
// candidates for the same reason.
|
|
150
153
|
const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
|
|
@@ -583,17 +586,22 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
583
586
|
},
|
|
584
587
|
{ workspaceRoot: effectiveRoot, client },
|
|
585
588
|
);
|
|
586
|
-
if (seat.decision) {
|
|
589
|
+
if (seat.effect === "deny" && seat.decision) {
|
|
587
590
|
event.effect = "deny";
|
|
588
591
|
event.message = seat.decision.message;
|
|
589
592
|
}
|
|
593
|
+
// An abstention leaves the configured effect and message untouched,
|
|
594
|
+
// and still journals. `execute.before` runs the real check, which
|
|
595
|
+
// includes the remote PR probe that simulation skips. It records
|
|
596
|
+
// `abstain` rather than `allow`, so the deferral stays visible.
|
|
597
|
+
const abstained = seat.effect === "abstain";
|
|
590
598
|
audit({
|
|
591
599
|
ts: new Date().toISOString(),
|
|
592
600
|
sessionID: event.sessionID,
|
|
593
601
|
tool: "permission.ask",
|
|
594
|
-
decision: seat.effect === "deny" ? "block" : "allow",
|
|
602
|
+
decision: seat.effect === "deny" ? "block" : abstained ? "abstain" : "allow",
|
|
595
603
|
phase: "decision",
|
|
596
|
-
reason: seat.decision?.code,
|
|
604
|
+
reason: abstained ? "deferred_to_execute_before" : seat.decision?.code,
|
|
597
605
|
policyDecision: seat.decision,
|
|
598
606
|
input: {
|
|
599
607
|
action: event.action,
|
|
@@ -601,6 +609,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
601
609
|
agent: event.agent,
|
|
602
610
|
configuredEffect,
|
|
603
611
|
mappedTool: seat.mappedTool,
|
|
612
|
+
...(abstained ? { deferred: "execute.before" } : {}),
|
|
604
613
|
},
|
|
605
614
|
});
|
|
606
615
|
} catch (error) {
|
package/src/lib/verify.ts
CHANGED
|
@@ -231,11 +231,26 @@ export function resolveVerifyTimeoutMs(root: string): number {
|
|
|
231
231
|
return typeof configured === "number" && Number.isFinite(configured) && configured > 0 ? configured : 30_000;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
export interface VerifyRun {
|
|
235
|
+
passed: boolean;
|
|
236
|
+
output: string;
|
|
237
|
+
durationMs: number;
|
|
238
|
+
/**
|
|
239
|
+
* Set when the command was killed at the timeout rather than exiting on its
|
|
240
|
+
* own. A timeout is not a failing assertion: it says nothing about whether
|
|
241
|
+
* the change is correct, and the remedy is a longer limit, not a code fix.
|
|
242
|
+
* Callers that report a reason must attribute it, or the agent chases a
|
|
243
|
+
* failure that does not exist.
|
|
244
|
+
*/
|
|
245
|
+
timedOut?: boolean;
|
|
246
|
+
timeoutMs?: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
234
249
|
export async function runVerify(
|
|
235
250
|
command: string,
|
|
236
251
|
root: string,
|
|
237
252
|
timeoutMs = 30_000,
|
|
238
|
-
): Promise<
|
|
253
|
+
): Promise<VerifyRun> {
|
|
239
254
|
const start = Date.now();
|
|
240
255
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
241
256
|
|
|
@@ -294,6 +309,8 @@ export async function runVerify(
|
|
|
294
309
|
passed: false,
|
|
295
310
|
output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
|
|
296
311
|
durationMs: Date.now() - start,
|
|
312
|
+
timedOut: true,
|
|
313
|
+
timeoutMs,
|
|
297
314
|
});
|
|
298
315
|
});
|
|
299
316
|
}, timeoutMs);
|
package/test/test-e2e.mts
CHANGED
|
@@ -225,6 +225,16 @@ const runV2Probes = async (ctx: any) => {
|
|
|
225
225
|
\tconst guardWhy = byId("guard_why");
|
|
226
226
|
\tconst pick = (r: any) => (typeof r?.content === "string" ? r.content : JSON.stringify(r));
|
|
227
227
|
\tconst statusRes = guardStatus ? await guardStatus.execute({}, headlessToolContext) : undefined;
|
|
228
|
+
\t// Permission-seat canary. The seat runs inside the real host plugin setup,
|
|
229
|
+
\t// with the workspace taken from the host's own location.directory, so a
|
|
230
|
+
\t// typed-only seat cannot pass. No client is passed: this probe makes no
|
|
231
|
+
\t// remote call, and the push case is the one that must abstain precisely
|
|
232
|
+
\t// because simulation skips the remote PR probe.
|
|
233
|
+
\tconst { evaluatePermissionSeat } = await import("../workflow-guard-source/lib/permission-seat.ts");
|
|
234
|
+
\tconst workspaceRoot = ctx?.location?.directory ?? process.cwd();
|
|
235
|
+
\tconst seatEvent = (action: string, resources: string[]) => ({ sessionID: "headless-e2e-seat", agent: "build", action, resources, effect: "allow" as const });
|
|
236
|
+
\tconst seatDeny = await evaluatePermissionSeat(seatEvent("read", [".env"]), { workspaceRoot, client: undefined });
|
|
237
|
+
\tconst seatPush = await evaluatePermissionSeat(seatEvent("shell", ["git push origin feat/install-verification"]), { workspaceRoot, client: undefined });
|
|
228
238
|
\tconst whyProbes: Record<string, { tool: string; input: Record<string, unknown> }> = {
|
|
229
239
|
\t\tgit: { tool: "bash", input: { command: "git push origin main" } },
|
|
230
240
|
\t\t"secrets-read": { tool: "read", input: { filePath: ".env" } },
|
|
@@ -244,6 +254,7 @@ const runV2Probes = async (ctx: any) => {
|
|
|
244
254
|
\t\tstatus: JSON.parse(pick(statusRes)),
|
|
245
255
|
\t\twhy: whyResults.git,
|
|
246
256
|
\t\twhyProbes: whyResults,
|
|
257
|
+
\t\tseat: { deny: seatDeny, push: seatPush },
|
|
247
258
|
\t}) + "\\n");
|
|
248
259
|
\twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
|
|
249
260
|
};
|
|
@@ -432,6 +443,21 @@ if (isOpenCodeV2) {
|
|
|
432
443
|
check("V2 setup registers the full config-gated guard tool surface", missingGuardTools.length === 0);
|
|
433
444
|
if (missingGuardTools.length > 0) console.log(" missing guard tools: " + missingGuardTools.join(", "));
|
|
434
445
|
console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
|
|
446
|
+
// Provenance: `workspaceRoot` is the host's own `location.directory`, read
|
|
447
|
+
// inside the real setup call. The seat runs with no client, so nothing here
|
|
448
|
+
// depends on hand-built remote state.
|
|
449
|
+
check(
|
|
450
|
+
"V2 permission seat denies a real finding against the live host context",
|
|
451
|
+
headlessAccountability?.seat?.deny?.effect === "deny" &&
|
|
452
|
+
headlessAccountability?.seat?.deny?.decision?.status === "blocked" &&
|
|
453
|
+
headlessAccountability?.seat?.deny?.decision?.policy === "secrets" &&
|
|
454
|
+
headlessAccountability?.seat?.deny?.decision?.code === "secret_read",
|
|
455
|
+
);
|
|
456
|
+
check(
|
|
457
|
+
"V2 permission seat abstains on an undecidable push instead of denying it",
|
|
458
|
+
headlessAccountability?.seat?.push?.effect === "abstain" &&
|
|
459
|
+
headlessAccountability?.seat?.push?.decision?.code === "remote_state_unchecked",
|
|
460
|
+
);
|
|
435
461
|
}
|
|
436
462
|
|
|
437
463
|
// The todo capability probe is only meaningful against a host that can gate
|
package/test/test.mts
CHANGED
|
@@ -122,6 +122,7 @@ import {
|
|
|
122
122
|
import { prBodyIncludesChangelog } from "../src/policies/changelog.ts";
|
|
123
123
|
import { terminateProcessTree } from "../src/lib/verify.ts";
|
|
124
124
|
import { audit } from "../src/lib/audit.ts";
|
|
125
|
+
import { evaluatePermissionSeat } from "../src/lib/permission-seat.ts";
|
|
125
126
|
import { isProjectMemoryFreshAsync } from "../src/lib/project-memory.ts";
|
|
126
127
|
import { ToolOutcomeTracker } from "../src/lib/tool-outcomes.ts";
|
|
127
128
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, listRecoveryCheckpoints, restoreRecoveryCheckpoint, setCheckpointGitForTesting } from "../src/lib/checkpoint.ts";
|
|
@@ -2335,6 +2336,66 @@ check(
|
|
|
2335
2336
|
blocked(failFinalRes),
|
|
2336
2337
|
);
|
|
2337
2338
|
|
|
2339
|
+
// A timeout is not a failing test. Both block finalization, but the reason
|
|
2340
|
+
// must name the timeout and its remedy. Reporting a timeout as "verification
|
|
2341
|
+
// is failing" sends the agent looking for a failing assertion that does not
|
|
2342
|
+
// exist, and the real fix is a longer timeout rather than a code change.
|
|
2343
|
+
console.log("- Policy 10: verification timeout attribution -");
|
|
2344
|
+
// The verify env overrides beat project config, so a developer with either one
|
|
2345
|
+
// exported (the docs recommend doing exactly that for this repo's suite) would
|
|
2346
|
+
// otherwise make these checks measure their shell instead of the guard.
|
|
2347
|
+
const prevTimeoutVerifyEnv = process.env.WORKFLOW_GUARD_VERIFY;
|
|
2348
|
+
const prevTimeoutLimitEnv = process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
|
|
2349
|
+
delete process.env.WORKFLOW_GUARD_VERIFY;
|
|
2350
|
+
delete process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
|
|
2351
|
+
const verifyTimeoutRepo = mkdtempSync(join(tmpdir(), "wg-verify-timeout-finalize-"));
|
|
2352
|
+
mkdirSync(join(verifyTimeoutRepo, ".opencode"), { recursive: true });
|
|
2353
|
+
// The command prints far more than snipVerifyOutput's line budget, so the
|
|
2354
|
+
// tail exercises the snipper rather than a short literal output.
|
|
2355
|
+
writeFileSync(
|
|
2356
|
+
join(verifyTimeoutRepo, ".opencode", "workflow-guard.json"),
|
|
2357
|
+
JSON.stringify({
|
|
2358
|
+
verifyCommand: "node -e \"for (let i = 0; i < 60; i++) console.log('noise ' + i); setTimeout(() => {}, 5000)\"",
|
|
2359
|
+
verifyTimeoutMs: 50,
|
|
2360
|
+
}),
|
|
2361
|
+
);
|
|
2362
|
+
reloadProjectConfig(verifyTimeoutRepo);
|
|
2363
|
+
setWorkspaceRoot(verifyTimeoutRepo);
|
|
2364
|
+
resetVerifyState();
|
|
2365
|
+
todo("s-verify-timeout", item("timeout work", "in_progress"));
|
|
2366
|
+
await call(
|
|
2367
|
+
"edit",
|
|
2368
|
+
{ filePath: join(verifyTimeoutRepo, "code.ts"), content: "timeout" },
|
|
2369
|
+
{ sessionID: "s-verify-timeout" },
|
|
2370
|
+
);
|
|
2371
|
+
const timeoutFinalRes = await call(
|
|
2372
|
+
"todowrite",
|
|
2373
|
+
{ todos: [item("timeout work", "completed")] },
|
|
2374
|
+
{ sessionID: "s-verify-timeout" },
|
|
2375
|
+
);
|
|
2376
|
+
check("verification timeout blocks finalization", blocked(timeoutFinalRes));
|
|
2377
|
+
check(
|
|
2378
|
+
"verification timeout is not reported as a failing test",
|
|
2379
|
+
!String(timeoutFinalRes).includes("verification is failing"),
|
|
2380
|
+
);
|
|
2381
|
+
check(
|
|
2382
|
+
"a timed-out tail carries no failure header that restates the misattribution",
|
|
2383
|
+
!String(timeoutFinalRes).includes("Verification Failed"),
|
|
2384
|
+
);
|
|
2385
|
+
check(
|
|
2386
|
+
"verification timeout names the elapsed limit at the precision it was set",
|
|
2387
|
+
String(timeoutFinalRes).includes("timed out") && String(timeoutFinalRes).includes("50ms"),
|
|
2388
|
+
);
|
|
2389
|
+
check(
|
|
2390
|
+
"verification timeout names a remedy the agent can actually apply",
|
|
2391
|
+
String(timeoutFinalRes).includes("verifyTimeoutMs") && String(timeoutFinalRes).includes("WORKFLOW_GUARD_VERIFY_TIMEOUT_MS"),
|
|
2392
|
+
);
|
|
2393
|
+
setWorkspaceRoot(verifyRepo);
|
|
2394
|
+
reloadProjectConfig(verifyRepo);
|
|
2395
|
+
if (prevTimeoutVerifyEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY = prevTimeoutVerifyEnv;
|
|
2396
|
+
if (prevTimeoutLimitEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS = prevTimeoutLimitEnv;
|
|
2397
|
+
rmSync(verifyTimeoutRepo, { recursive: true, force: true });
|
|
2398
|
+
|
|
2338
2399
|
// A subagent mutation governed by the parent's todos must invalidate the
|
|
2339
2400
|
// parent's cached verification result.
|
|
2340
2401
|
writeFileSync(
|
|
@@ -5705,6 +5766,98 @@ if (prevDuplicateDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
|
5705
5766
|
else process.env.XDG_DATA_HOME = prevDuplicateDataHome;
|
|
5706
5767
|
rmSync(v2DuplicateRoot, { recursive: true, force: true });
|
|
5707
5768
|
|
|
5769
|
+
// ── Permission seat: an undecidable simulation abstains, a finding denies ──
|
|
5770
|
+
// The seat always runs the policy core with `simulate: true`, which skips the
|
|
5771
|
+
// remote GitHub/Azure PR probe on purpose. `needs_approval` means exactly
|
|
5772
|
+
// that: the core could not decide. Collapsing it into a denial blocked every
|
|
5773
|
+
// non-protected `git push`, so the seat must abstain and leave the configured
|
|
5774
|
+
// effect untouched while `execute.before` runs the real check. A genuine local
|
|
5775
|
+
// finding still denies, and an abstention never upgrades a configured `ask`.
|
|
5776
|
+
console.log("- V2 permission seat: abstention -");
|
|
5777
|
+
const makeSeatRepo = (branch: string): string => {
|
|
5778
|
+
const repo = mkdtempSync(join(tmpdir(), "wg-seat-"));
|
|
5779
|
+
spawnSync("git", ["init", "-b", branch], { cwd: repo });
|
|
5780
|
+
writeFileSync(join(repo, "tracked.txt"), "tracked\n");
|
|
5781
|
+
spawnSync("git", ["add", "tracked.txt"], { cwd: repo });
|
|
5782
|
+
spawnSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"], { cwd: repo });
|
|
5783
|
+
return repo;
|
|
5784
|
+
};
|
|
5785
|
+
const seatFeatureRepo = makeSeatRepo("feature/push-seat");
|
|
5786
|
+
const seatMainRepo = makeSeatRepo("main");
|
|
5787
|
+
const seatEvent = (resources: string[], effect: "allow" | "ask" | "deny" = "allow") => ({
|
|
5788
|
+
sessionID: "s-seat-abstain",
|
|
5789
|
+
agent: "build",
|
|
5790
|
+
action: "shell",
|
|
5791
|
+
resources,
|
|
5792
|
+
effect,
|
|
5793
|
+
});
|
|
5794
|
+
try {
|
|
5795
|
+
const pushSeat = await evaluatePermissionSeat(
|
|
5796
|
+
seatEvent(["git push origin feature/push-seat"]),
|
|
5797
|
+
{ workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
|
|
5798
|
+
);
|
|
5799
|
+
check(
|
|
5800
|
+
"seat abstains on a non-protected push whose remote PR state is unverified",
|
|
5801
|
+
pushSeat.effect !== "deny" && pushSeat.effect === "abstain",
|
|
5802
|
+
);
|
|
5803
|
+
check(
|
|
5804
|
+
"seat keeps the undecidable decision for audit attribution",
|
|
5805
|
+
pushSeat.decision?.status === "needs_approval" && pushSeat.decision?.code === "remote_state_unchecked",
|
|
5806
|
+
);
|
|
5807
|
+
const protectedPushSeat = await evaluatePermissionSeat(
|
|
5808
|
+
{ ...seatEvent(["git push origin feature/push-seat"]), sessionID: "s-seat-block" },
|
|
5809
|
+
{ workspaceRoot: seatMainRepo, client: fakeClient as unknown },
|
|
5810
|
+
);
|
|
5811
|
+
check(
|
|
5812
|
+
"seat still denies a real finding (push from a protected branch)",
|
|
5813
|
+
protectedPushSeat.effect === "deny" && protectedPushSeat.decision?.status === "blocked" && /protected branch/i.test(String(protectedPushSeat.decision?.message)),
|
|
5814
|
+
);
|
|
5815
|
+
const askedSeat = await evaluatePermissionSeat(
|
|
5816
|
+
{ ...seatEvent(["git push origin feature/push-seat"], "ask"), sessionID: "s-seat-ask" },
|
|
5817
|
+
{ workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
|
|
5818
|
+
);
|
|
5819
|
+
check("an abstaining seat never upgrades a configured ask to allow", askedSeat.effect === "abstain");
|
|
5820
|
+
} finally {
|
|
5821
|
+
rmSync(seatFeatureRepo, { recursive: true, force: true });
|
|
5822
|
+
rmSync(seatMainRepo, { recursive: true, force: true });
|
|
5823
|
+
}
|
|
5824
|
+
|
|
5825
|
+
// The end-to-end seat: the same undecidable push through the registered hook
|
|
5826
|
+
// must leave the configured effect and message untouched, and journal the
|
|
5827
|
+
// deferral so the skipped remote probe stays legible.
|
|
5828
|
+
{
|
|
5829
|
+
resetPluginInstancesForTesting();
|
|
5830
|
+
const seatHookRepo = makeSeatRepo("feature/push-seat-hook");
|
|
5831
|
+
const prevSeatDataHome = process.env.XDG_DATA_HOME;
|
|
5832
|
+
process.env.XDG_DATA_HOME = join(seatHookRepo, "data");
|
|
5833
|
+
const seatCtx = makeV2Context(seatHookRepo);
|
|
5834
|
+
const seatCleanup = (await defaultExport.setup(seatCtx.ctx as any)) as (() => void) | undefined;
|
|
5835
|
+
const seatAskEvent = { ...permissionEvent("shell", ["git push origin feature/push-seat-hook"], "s-seat-hook-ask"), effect: "ask" as "allow" | "deny" | "ask" };
|
|
5836
|
+
await seatCtx.state.permissionHooks[0]?.(seatAskEvent);
|
|
5837
|
+
check(
|
|
5838
|
+
"evaluate leaves a configured ask in place when the simulation abstains",
|
|
5839
|
+
seatAskEvent.effect === "ask" && seatAskEvent.message === undefined,
|
|
5840
|
+
);
|
|
5841
|
+
const seatPushEvent = permissionEvent("shell", ["git push origin feature/push-seat-hook"], "s-seat-hook");
|
|
5842
|
+
await seatCtx.state.permissionHooks[0]?.(seatPushEvent);
|
|
5843
|
+
check(
|
|
5844
|
+
"evaluate does not deny a non-protected push it cannot decide",
|
|
5845
|
+
seatPushEvent.effect === "allow" && seatPushEvent.message === undefined,
|
|
5846
|
+
);
|
|
5847
|
+
const abstainAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.reason === "deferred_to_execute_before");
|
|
5848
|
+
check(
|
|
5849
|
+
"an abstention is journaled as deferred to execute.before, not as a plain allow",
|
|
5850
|
+
abstainAudit !== undefined &&
|
|
5851
|
+
abstainAudit.policyDecision?.status === "needs_approval" &&
|
|
5852
|
+
abstainAudit.policyDecision?.code === "remote_state_unchecked" &&
|
|
5853
|
+
(abstainAudit.input as any)?.deferred === "execute.before",
|
|
5854
|
+
);
|
|
5855
|
+
seatCleanup?.();
|
|
5856
|
+
if (prevSeatDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
5857
|
+
else process.env.XDG_DATA_HOME = prevSeatDataHome;
|
|
5858
|
+
rmSync(seatHookRepo, { recursive: true, force: true });
|
|
5859
|
+
}
|
|
5860
|
+
|
|
5708
5861
|
// ── V2 adopt-now surfaces: command, read-only pruning, worktrees, MCP ──
|
|
5709
5862
|
// Four additive V2 API adoptions, each tested for its narrowing direction:
|
|
5710
5863
|
// the command adds an operator surface, pruning removes tools, worktree cleanup
|