From 0c0b5d42ecf3e5a760701c29c719d771e77559d1 Mon Sep 17 00:00:00 2001 From: Diego Marinho <4332052+dmoliveira@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:59:10 +1100 Subject: [PATCH] fix: isolate V2 permission decline prototypes --- .github/workflows/ci.yml | 33 ++++++ docs/plan/doing/opencode-v2-migration.md | 12 +- .../dist/v2/tool-output-redaction.d.ts | 9 +- .../dist/v2/tool-output-redaction.js | 106 ++++++++---------- .../src/v2/tool-output-redaction.ts | 89 +++++++-------- .../test/v2/tool-output-redaction.test.mjs | 69 +++++++++--- ...eway_v2_permission_rejection_live_smoke.py | 104 ++++++++++++++--- scripts/gateway_v2_shell_live_smoke.py | 9 +- scripts/gateway_v2_tool_error_live_smoke.py | 14 ++- 9 files changed, 293 insertions(+), 152 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bf8e969..cea29fb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,39 @@ jobs: npm --prefix plugin/gateway-sidebar-v2 test npm --prefix plugin/gateway-sidebar-v2 run test:live -- --opencode-bin "$(command -v opencode)" + gateway-v2-permission: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + opencode: [2.2.5, 2.2.6] + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install pinned test runtime and build gateway + run: | + npm install --global "@opencode/cli@${{ matrix.opencode }}" --no-audit --no-fund + opencode --version | grep -Fx "opencode v${{ matrix.opencode }}" + npm ci --prefix plugin/gateway-core --ignore-scripts --no-audit --no-fund + npm --prefix plugin/gateway-core run build + + - name: Exercise V2 permission, tool-error and shell boundaries + run: | + python scripts/gateway_v2_permission_rejection_live_smoke.py + python scripts/gateway_v2_tool_error_live_smoke.py + python scripts/gateway_v2_shell_live_smoke.py + windows-bounded-subprocess: runs-on: windows-latest steps: diff --git a/docs/plan/doing/opencode-v2-migration.md b/docs/plan/doing/opencode-v2-migration.md index 8176a775..3074bb5d 100644 --- a/docs/plan/doing/opencode-v2-migration.md +++ b/docs/plan/doing/opencode-v2-migration.md @@ -14,7 +14,7 @@ It neither ports the backend guard nor grants parent-session authority. | Gateway hooks | `plugin/gateway-core/src/index.ts` dispatches V1 tool, command, chat, transform and event hooks. | Map each hook to V2 semantics, explicitly document missing equivalents, test ordering and failure handling. | | Gateway client | `plugin/gateway-core/src/index.ts` and hooks including `session-recovery`, `continuation`, `preemptive-compaction`, and `todo-continuation-enforcer` use V1 session client methods and message shapes. | Port clients and verify session identity, cancellation, and message conversion. | | Security | The inactive gateway contains shell and tool guards, output secret filtering and provider-boundary redaction. | Test all relevant request kinds and transports against a controlled provider, including resume and compaction; do not claim protection based only on plugin loading. | -| Sidebar | `plugin/gateway-sidebar/src/index.tsx` requires 1.18.18 and uses the V1 TUI API. | Port slots, state, events, theme and cleanup to the V2 TUI API; test in a real terminal. | +| Sidebar | `plugin/gateway-sidebar/src/index.tsx` supports 1.18.18 and 1.18.33 using the V1 TUI API. | Port slots, state, events, theme and cleanup to the V2 TUI API; test in a real terminal. | | Live tests | `scripts/gateway_execution_status_live_smoke.py` and `scripts/gateway_resume_redaction_e2e.py` pin 1.18.18 and the former calls V1 endpoints. | Keep V1 coverage distinct and add V2 live tests with a controlled local provider. | ## Further audit required @@ -23,7 +23,7 @@ It neither ports the backend guard nor grants parent-session authority. - `scripts/tui_config.py`, `tui.json`, and installer/config tooling target V1 terminal settings. V2 uses global `cli.json`; check migration and preserve existing user settings. - Search scripts that invoke OpenCode or construct requests, including live smoke scripts and the LLM decision runtime, against V2 CLI and server contracts. - Verify effective agent permissions and tool names, slash commands, model selection, skills and MCP connections in V2. Supported V1 configuration should not be rewritten merely for syntax. -- Inspect the installed runtime separately: `~/.config/opencode/my_opencode` currently points to `my_opencode-runtime-main`, not this repository's main checkout. Do not overwrite local runtime state. +- Inspect the installed runtime separately: resolve `~/.config/opencode/my_opencode` and preserve any separate runtime state. ## Sequence and gates @@ -59,11 +59,13 @@ trusted later hook from changing the shell and `BASH_ENV` so that an owned startup file ran the same denied command. This is a process-binding limitation, not a claim that policies sandbox plugin code. -Experimental, unconfigured adapters cover V2 model-context and native transport redaction, tool-output and error redaction, two shell guards, and write/protected-worktree guards. `make gateway-v2-slice-smoke` builds and exercises them with isolated OpenCode processes, synthetic data, disposable Git worktrees and a localhost provider. The live checks cover a late context mutation before the HTTP hook, rejection of unsupported HTTP content, allowed and blocked shell commands, tool-output redaction, sanitized tool errors and redactor failures, explicit permission denial, interactive rejection with or without feedback, one running-tool interruption, and linked versus primary worktree writes. The error adapter accepts only OpenCode 2.2.5 and replaces the error with a host `Tool.Error` containing a redacted or generic message and no original metadata. For plain permission rejection it retains the decline prototype only after checking the internal 2.2.5 shape, and shadows common inherited fields with safe values. Prototype behavior is not fully audited, and this dependency on V2 internals needs security review and regression coverage for each supported runtime. Other cancellation and control-flow errors remain unverified. +Experimental, unconfigured adapters cover V2 model-context and native transport redaction, tool-output and error redaction, two shell guards, and write/protected-worktree guards. `make gateway-v2-slice-smoke` builds and exercises them with isolated OpenCode processes, synthetic data, disposable Git worktrees and a localhost provider. The live checks cover a late context mutation before the HTTP hook, rejection of unsupported HTTP content, allowed and blocked shell commands, tool-output redaction, sanitized tool errors and redactor failures, explicit permission denial, interactive rejection with or without feedback, one running-tool interruption, and linked versus primary worktree writes. The error adapter accepts only OpenCode 2.2.5 or 2.2.6. It replaces the error with a host `Tool.Error` containing a redacted or generic message and no original metadata. For a plain rejection, it builds a clean cause from a separately supplied trusted host-decline factory and never takes a prototype from the tool's error. Registration calls the factory once and refuses to install the hook if it fails or produces a structurally invalid sample. The structural check cannot authenticate a host type; the caller must establish that the sample and its prototype are trusted. -These are **not** complete gateway protection or parity. Prototype-shape checks do not authenticate inherited methods: a forged decline prototype matching the checked keys can expose a synthetic secret through custom inspection. This is a confirmed blocker to activating the error adapter; use a trusted host-provided decline type or otherwise establish safe inherited behavior before enabling it. Transport-hook ordering and non-native providers, WebSocket behavior, images and other encodings, large/resumed histories, all four request kinds live, other error paths, command and event lifecycles, and the remaining guards also require validation. The adapters are intentionally not registered in the user's configuration. +The permission smoke first captures a genuine decline from its own controlled `ctx.permission.assert` call, then registers the adapter with that independently obtained sample. On both tested versions, a forged matching prototype exposed a synthetic canary through custom inspection without the adapter; with the adapter, inspection was clean and the rejection still stopped the step. This fixture is not a production source of trusted types. The 2.2.6 public plugin API does not expose the host's decline constructor, and a factory that derives its prototype from an arbitrary tool error would reintroduce the vulnerability. A trusted production capability is still required; this adapter does not protect the trusted prototype against same-privilege mutation by other plugins. Other AFTER-hook failures can reach the model as tool errors rather than stop the step, so registration alone does not establish an independent fail-closed boundary. -The 2.2.5 permission smoke also measures three synthetic controls. A rejection caught directly from `ctx.permission.assert` in a tool owned by the fixture is the same cause later exposed to its after-hook; a fresh object with that observed prototype still stops the step. Dropping the cause or throwing a generic error from the after-hook instead allows a tool result to reach the model. These observations neither authenticate an arbitrary tool's cause nor establish that the observed prototype is safe to retain; they do not authorize the adapter. The smoke fails if the pinned host does not reproduce the controls, rather than assuming another version has the same behavior. +These are **not** complete gateway protection or parity. Transport-hook ordering and non-native providers, WebSocket behavior, images and other encodings, large/resumed histories, all four request kinds live, other error paths, command and event lifecycles, and the remaining guards also require validation. The adapters are intentionally not registered in the user's configuration. + +The 2.2.5 and 2.2.6 permission smokes also measure synthetic controls. A rejection caught directly from `ctx.permission.assert` in a tool owned by the fixture is the same cause later exposed to its after-hook; a fresh object with that observed prototype still stops the step. Dropping the cause or throwing a generic error from the after-hook instead allows a tool result to reach the model. An independently constructed Effect tagged-error class with the same name also fails the host's decline check on both versions. These observations neither authenticate an arbitrary tool's cause nor authorize the adapter. CI runs the permission, tool-error and shell smokes on each pinned version; the smoke fails if a supported host does not reproduce the controls. The isolated V2 runtime created `session_v2`, `session_message`, and `session_part` tables. The legacy stale-session scanner reports an incompatible schema and refuses repairs when a `session_v2` table or view exists, case-insensitively, even if old V1 tables remain. A scoped repair retains scan errors rather than reporting success, including an incomplete rescan after a committed repair round. Database path resolution honors `OPENCODE_DB` and explicit XDG data directories without falling back to an unrelated existing database; a separately started service may still have different environment variables. V2-native diagnosis and repair remain unimplemented. The standalone installer, local setup scripts, and gateway diagnostics run their executable-version probes in disposable homes; installer and setup refuse V2 or an unidentifiable executable before installation or configuration changes. Gateway enable also refuses incompatible executables even with `--force`. These probes do not establish the version or protection of a separate service. The local development setup refuses conflicting instruction and agent paths rather than replacing them. diff --git a/plugin/gateway-core/dist/v2/tool-output-redaction.d.ts b/plugin/gateway-core/dist/v2/tool-output-redaction.d.ts index fff0c05a..014e90a1 100644 --- a/plugin/gateway-core/dist/v2/tool-output-redaction.d.ts +++ b/plugin/gateway-core/dist/v2/tool-output-redaction.d.ts @@ -1,6 +1,11 @@ import type { SecretRedactor } from "../hooks/shared/secret-redaction.js"; import type { ToolHooks } from "./types.js"; type ToolErrorFactory = (message: string, cause?: unknown) => unknown; -/** Experimental output adapter. The factory must create the host's V2 Tool.Error. */ -export declare function registerV2ToolOutputRedaction(tool: ToolHooks, redactor: Pick, createToolError: ToolErrorFactory, runtimeVersion: string): Promise<() => Promise>; +type TrustedPermissionDeclineFactory = () => unknown; +/** + * Experimental: both factories must come from trusted host capabilities. + * The decline factory is sampled before registration and must not derive its + * result from a tool error. A matching tag cannot authenticate a host type. + */ +export declare function registerV2ToolOutputRedaction(tool: ToolHooks, redactor: Pick, createToolError: ToolErrorFactory, runtimeVersion: string, createTrustedDecline: TrustedPermissionDeclineFactory): Promise<() => Promise>; export {}; diff --git a/plugin/gateway-core/dist/v2/tool-output-redaction.js b/plugin/gateway-core/dist/v2/tool-output-redaction.js index 29703187..09ba6960 100644 --- a/plugin/gateway-core/dist/v2/tool-output-redaction.js +++ b/plugin/gateway-core/dist/v2/tool-output-redaction.js @@ -2,22 +2,11 @@ const REDACTION_FAILURE = "Tool output withheld because redaction failed"; const ERROR_WITHHELD = "Tool execution failed; details withheld by gateway"; const UNSAFE_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/; const PERMISSION_DECLINED = "Permission.DeclinedError"; -const DECLINED_SCHEMA_BRAND = `~effect/Schema/Class/${PERMISSION_DECLINED}`; -const VERIFIED_OPENCODE_VERSION = "2.2.5"; -function hasOnlyKeys(value, expected, accessor) { - const keys = Reflect.ownKeys(value); - if (keys.length !== expected.length || !keys.every((key) => expected.includes(key))) - return false; - return keys.every((key) => { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor) - return false; - return key === accessor ? typeof descriptor.get === "function" && !descriptor.set : "value" in descriptor; - }); -} -// OpenCode 2.2.5 uses the cause's class to stop a plain permission rejection. -// Preserve only that type and its tag, never the original cause's data or stack. -function copyPermissionDecline(error) { +const VERIFIED_OPENCODE_VERSIONS = ["2.2.5", "2.2.6"]; +// The prototype comes from a genuine host-created decline at registration, +// independently of the event being sanitized. The event's prototype is never used. +function copyPermissionDecline(error, trustedPrototype) { + let isDecline = false; try { if (!error || typeof error !== "object") return undefined; @@ -26,51 +15,24 @@ function copyPermissionDecline(error) { if (!cause || typeof cause !== "object") return undefined; const tag = Object.getOwnPropertyDescriptor(cause, "_tag"); - if (!tag || !("value" in tag) || tag.value !== PERMISSION_DECLINED) - return undefined; - const prototype = Object.getPrototypeOf(cause); - if (!prototype) - return undefined; - // Reject extra properties or accessors that a copied prototype could expose. - // This exact internal shape was observed in OpenCode 2.2.5 only. - if (!hasOnlyKeys(prototype, ["constructor"])) - return undefined; - const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor"); - if (!constructor || !("value" in constructor) || typeof constructor.value !== "function") - return undefined; - const schema = Object.getPrototypeOf(prototype); - if (!schema) - return undefined; - if (!hasOnlyKeys(schema, ["constructor", "name", DECLINED_SCHEMA_BRAND], DECLINED_SCHEMA_BRAND)) - return undefined; - const name = Object.getOwnPropertyDescriptor(schema, "name"); - const brand = Object.getOwnPropertyDescriptor(schema, DECLINED_SCHEMA_BRAND); - if (!name || !("value" in name) || name.value !== PERMISSION_DECLINED || !brand || typeof brand.get !== "function" || brand.set) - return undefined; - const schemaBase = Object.getPrototypeOf(schema); - if (!schemaBase || !hasOnlyKeys(schemaBase, ["constructor", "toJSON"])) - return undefined; - const effectBase = Object.getPrototypeOf(schemaBase); - if (!effectBase || !hasOnlyKeys(effectBase, [ - "constructor", "~effect/Effect", "pipe", "toJSON", "~effect/Effect/identifier", - "~effect/Effect/evaluate", "~effect/Effect/successCont", "~effect/Effect/failureCont", - "~effect/Effect/ensureCont", Symbol.for("nodejs.util.inspect.custom"), Symbol.iterator, - ]) || Object.getPrototypeOf(effectBase) !== Error.prototype) - return undefined; - const copy = Object.create(prototype); - Object.defineProperties(copy, { - _tag: { value: PERMISSION_DECLINED, enumerable: true }, - name: { value: PERMISSION_DECLINED }, - message: { value: "" }, - stack: { value: "" }, - toString: { value: () => PERMISSION_DECLINED }, - toJSON: { value: () => ({ _tag: PERMISSION_DECLINED }) }, - }); - return copy; + isDecline = !!tag && "value" in tag && tag.value === PERMISSION_DECLINED; } catch { return undefined; } + if (!isDecline) + return undefined; + const copy = Object.create(trustedPrototype); + Object.defineProperties(copy, { + _tag: { value: PERMISSION_DECLINED, enumerable: true }, + name: { value: PERMISSION_DECLINED }, + message: { value: "" }, + stack: { value: "" }, + toString: { value: () => PERMISSION_DECLINED }, + toJSON: { value: () => ({ _tag: PERMISSION_DECLINED }) }, + [Symbol.for("nodejs.util.inspect.custom")]: { value: () => PERMISSION_DECLINED }, + }); + return copy; } async function safeErrorMessage(error, redactor) { try { @@ -90,12 +52,32 @@ async function safeErrorMessage(error, redactor) { return ERROR_WITHHELD; } } -/** Experimental output adapter. The factory must create the host's V2 Tool.Error. */ -export async function registerV2ToolOutputRedaction(tool, redactor, createToolError, runtimeVersion) { +/** + * Experimental: both factories must come from trusted host capabilities. + * The decline factory is sampled before registration and must not derive its + * result from a tool error. A matching tag cannot authenticate a host type. + */ +export async function registerV2ToolOutputRedaction(tool, redactor, createToolError, runtimeVersion, createTrustedDecline) { if (typeof createToolError !== "function") throw new Error("V2 Tool.Error factory required"); - if (runtimeVersion !== VERIFIED_OPENCODE_VERSION) - throw new Error("V2 tool-output adapter requires verified OpenCode 2.2.5"); + if (!VERIFIED_OPENCODE_VERSIONS.includes(runtimeVersion)) + throw new Error("V2 tool-output adapter requires verified OpenCode 2.2.5 or 2.2.6"); + if (typeof createTrustedDecline !== "function") + throw new Error("V2 trusted permission-decline factory required"); + let trustedPrototype; + try { + const trusted = createTrustedDecline(); + if (!trusted || typeof trusted !== "object") + throw new Error(); + const tag = Object.getOwnPropertyDescriptor(trusted, "_tag"); + const prototype = Object.getPrototypeOf(trusted); + if (!tag || !("value" in tag) || tag.value !== PERMISSION_DECLINED || !prototype) + throw new Error(); + trustedPrototype = prototype; + } + catch { + throw new Error("V2 trusted permission-decline factory unavailable"); + } const registration = await tool.hook("execute.after", async (event) => { let status; try { @@ -113,7 +95,7 @@ export async function registerV2ToolOutputRedaction(tool, redactor, createToolEr const failed = event; const original = failed.error; message = await safeErrorMessage(original, redactor); - cause = copyPermissionDecline(original); + cause = copyPermissionDecline(original, trustedPrototype); failed.error = createToolError(message, cause); } catch { diff --git a/plugin/gateway-core/src/v2/tool-output-redaction.ts b/plugin/gateway-core/src/v2/tool-output-redaction.ts index 627585d1..5381aa69 100644 --- a/plugin/gateway-core/src/v2/tool-output-redaction.ts +++ b/plugin/gateway-core/src/v2/tool-output-redaction.ts @@ -5,65 +5,37 @@ const REDACTION_FAILURE = "Tool output withheld because redaction failed" const ERROR_WITHHELD = "Tool execution failed; details withheld by gateway" const UNSAFE_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ const PERMISSION_DECLINED = "Permission.DeclinedError" -const DECLINED_SCHEMA_BRAND = `~effect/Schema/Class/${PERMISSION_DECLINED}` -const VERIFIED_OPENCODE_VERSION = "2.2.5" +const VERIFIED_OPENCODE_VERSIONS = ["2.2.5", "2.2.6"] type ToolErrorFactory = (message: string, cause?: unknown) => unknown +type TrustedPermissionDeclineFactory = () => unknown -function hasOnlyKeys(value: object, expected: readonly PropertyKey[], accessor?: PropertyKey): boolean { - const keys = Reflect.ownKeys(value) - if (keys.length !== expected.length || !keys.every((key) => expected.includes(key))) return false - return keys.every((key) => { - const descriptor = Object.getOwnPropertyDescriptor(value, key) - if (!descriptor) return false - return key === accessor ? typeof descriptor.get === "function" && !descriptor.set : "value" in descriptor - }) -} - -// OpenCode 2.2.5 uses the cause's class to stop a plain permission rejection. -// Preserve only that type and its tag, never the original cause's data or stack. -function copyPermissionDecline(error: unknown): unknown | undefined { +// The prototype comes from a genuine host-created decline at registration, +// independently of the event being sanitized. The event's prototype is never used. +function copyPermissionDecline(error: unknown, trustedPrototype: object): unknown | undefined { + let isDecline = false try { if (!error || typeof error !== "object") return undefined const descriptor = Object.getOwnPropertyDescriptor(error, "error") const cause = descriptor && "value" in descriptor ? descriptor.value : undefined if (!cause || typeof cause !== "object") return undefined const tag = Object.getOwnPropertyDescriptor(cause, "_tag") - if (!tag || !("value" in tag) || tag.value !== PERMISSION_DECLINED) return undefined - const prototype = Object.getPrototypeOf(cause) - if (!prototype) return undefined - // Reject extra properties or accessors that a copied prototype could expose. - // This exact internal shape was observed in OpenCode 2.2.5 only. - if (!hasOnlyKeys(prototype, ["constructor"])) return undefined - const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor") - if (!constructor || !("value" in constructor) || typeof constructor.value !== "function") return undefined - const schema = Object.getPrototypeOf(prototype) - if (!schema) return undefined - if (!hasOnlyKeys(schema, ["constructor", "name", DECLINED_SCHEMA_BRAND], DECLINED_SCHEMA_BRAND)) return undefined - const name = Object.getOwnPropertyDescriptor(schema, "name") - const brand = Object.getOwnPropertyDescriptor(schema, DECLINED_SCHEMA_BRAND) - if (!name || !("value" in name) || name.value !== PERMISSION_DECLINED || !brand || typeof brand.get !== "function" || brand.set) return undefined - const schemaBase = Object.getPrototypeOf(schema) - if (!schemaBase || !hasOnlyKeys(schemaBase, ["constructor", "toJSON"])) return undefined - const effectBase = Object.getPrototypeOf(schemaBase) - if (!effectBase || !hasOnlyKeys(effectBase, [ - "constructor", "~effect/Effect", "pipe", "toJSON", "~effect/Effect/identifier", - "~effect/Effect/evaluate", "~effect/Effect/successCont", "~effect/Effect/failureCont", - "~effect/Effect/ensureCont", Symbol.for("nodejs.util.inspect.custom"), Symbol.iterator, - ]) || Object.getPrototypeOf(effectBase) !== Error.prototype) return undefined - const copy = Object.create(prototype) - Object.defineProperties(copy, { - _tag: { value: PERMISSION_DECLINED, enumerable: true }, - name: { value: PERMISSION_DECLINED }, - message: { value: "" }, - stack: { value: "" }, - toString: { value: () => PERMISSION_DECLINED }, - toJSON: { value: () => ({ _tag: PERMISSION_DECLINED }) }, - }) - return copy + isDecline = !!tag && "value" in tag && tag.value === PERMISSION_DECLINED } catch { return undefined } + if (!isDecline) return undefined + const copy = Object.create(trustedPrototype) + Object.defineProperties(copy, { + _tag: { value: PERMISSION_DECLINED, enumerable: true }, + name: { value: PERMISSION_DECLINED }, + message: { value: "" }, + stack: { value: "" }, + toString: { value: () => PERMISSION_DECLINED }, + toJSON: { value: () => ({ _tag: PERMISSION_DECLINED }) }, + [Symbol.for("nodejs.util.inspect.custom")]: { value: () => PERMISSION_DECLINED }, + }) + return copy } async function safeErrorMessage( @@ -85,15 +57,32 @@ async function safeErrorMessage( } } -/** Experimental output adapter. The factory must create the host's V2 Tool.Error. */ +/** + * Experimental: both factories must come from trusted host capabilities. + * The decline factory is sampled before registration and must not derive its + * result from a tool error. A matching tag cannot authenticate a host type. + */ export async function registerV2ToolOutputRedaction( tool: ToolHooks, redactor: Pick, createToolError: ToolErrorFactory, runtimeVersion: string, + createTrustedDecline: TrustedPermissionDeclineFactory, ): Promise<() => Promise> { if (typeof createToolError !== "function") throw new Error("V2 Tool.Error factory required") - if (runtimeVersion !== VERIFIED_OPENCODE_VERSION) throw new Error("V2 tool-output adapter requires verified OpenCode 2.2.5") + if (!VERIFIED_OPENCODE_VERSIONS.includes(runtimeVersion)) throw new Error("V2 tool-output adapter requires verified OpenCode 2.2.5 or 2.2.6") + if (typeof createTrustedDecline !== "function") throw new Error("V2 trusted permission-decline factory required") + let trustedPrototype: object + try { + const trusted = createTrustedDecline() + if (!trusted || typeof trusted !== "object") throw new Error() + const tag = Object.getOwnPropertyDescriptor(trusted, "_tag") + const prototype = Object.getPrototypeOf(trusted) + if (!tag || !("value" in tag) || tag.value !== PERMISSION_DECLINED || !prototype) throw new Error() + trustedPrototype = prototype + } catch { + throw new Error("V2 trusted permission-decline factory unavailable") + } const registration = await tool.hook("execute.after", async (event) => { let status: unknown try { @@ -110,7 +99,7 @@ export async function registerV2ToolOutputRedaction( const failed = event as { error: unknown } const original = failed.error message = await safeErrorMessage(original, redactor) - cause = copyPermissionDecline(original) + cause = copyPermissionDecline(original, trustedPrototype) failed.error = createToolError(message, cause) } catch { // Do not surface an exception raised by a host getter or error factory. diff --git a/plugin/gateway-core/test/v2/tool-output-redaction.test.mjs b/plugin/gateway-core/test/v2/tool-output-redaction.test.mjs index a044ec86..8e80ab02 100644 --- a/plugin/gateway-core/test/v2/tool-output-redaction.test.mjs +++ b/plugin/gateway-core/test/v2/tool-output-redaction.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict" import test from "node:test" +import { inspect } from "node:util" import { createSecretRedactor } from "../../dist/hooks/shared/secret-redaction.js" import { registerV2ToolOutputRedaction } from "../../dist/v2/tool-output-redaction.js" @@ -9,6 +10,13 @@ function createToolError(message, cause) { if (cause !== undefined) error.error = cause return error } +class TrustedDecline extends Error { + constructor() { + super("host-private-detail") + this._tag = "Permission.DeclinedError" + } +} +const createTrustedDecline = () => new TrustedDecline() const VERSION = "2.2.5" test("V2 tool results redact content and output and block secrets in metadata", async () => { @@ -22,7 +30,7 @@ test("V2 tool results redact content and output and block secrets in metadata", assert.equal(name, "execute.after") callback = handler return { async dispose() { disposed = true } } - } }, redactor, createToolError, VERSION) + } }, redactor, createToolError, VERSION, createTrustedDecline) const event = { status: "completed", result: { content: [{ type: "text", text: "secret-sentinel" }], output: { value: "secret-sentinel" }, metadata: { detail: "safe" } } } await callback(event) assert.equal(event.result.content[0].text, "[REDACTED]") @@ -43,7 +51,7 @@ test("V2 tool errors are replaced with sanitized messages and no original cause await registerV2ToolOutputRedaction({ async hook(_name, handler) { callback = handler return { async dispose() {} } - } }, redactor, createToolError, VERSION) + } }, redactor, createToolError, VERSION, createTrustedDecline) const original = new Error("safe prefix secret-sentinel", { cause: new Error("secret-sentinel") }) original.metadata = { secret: "secret-sentinel" } const event = { status: "error", error: original } @@ -58,14 +66,16 @@ test("V2 tool errors are replaced with sanitized messages and no original cause assert.equal(safe.error.message, "ordinary failure") }) -test("V2 permission rejection retains its host type without copying its data", async () => { +test("V2 permission rejection uses only the trusted host prototype", async () => { let callback + let factoryCalls = 0 + const trustedSample = createTrustedDecline() const effectBase = Object.create(Error.prototype) for (const key of [ "constructor", "~effect/Effect", "pipe", "toJSON", "~effect/Effect/identifier", "~effect/Effect/evaluate", "~effect/Effect/successCont", "~effect/Effect/failureCont", "~effect/Effect/ensureCont", Symbol.for("nodejs.util.inspect.custom"), Symbol.iterator, - ]) Object.defineProperty(effectBase, key, { value() {} }) + ]) Object.defineProperty(effectBase, key, { value() { return "secret-sentinel" } }) const schemaBase = Object.create(effectBase, { constructor: { value() {} }, toJSON: { value() {} } }) const schema = Object.create(schemaBase, { constructor: { value() {} }, @@ -84,19 +94,22 @@ test("V2 permission rejection retains its host type without copying its data", a } }, createSecretRedactor({ patterns: ["secret-sentinel"], redactionToken: "[REDACTED]", limits: { maxDepth: 32, maxNodes: 1000, maxChars: 10000 }, - }), createToolError, VERSION) + }), createToolError, VERSION, () => { factoryCalls += 1; return trustedSample }) + Object.setPrototypeOf(trustedSample, prototype) const original = new Error("secret-sentinel") original.error = cause const event = { status: "error", error: original } await callback(event) assert.equal(event.error.message, "[REDACTED]") - assert.equal(Object.getPrototypeOf(event.error.error), prototype) + assert.equal(Object.getPrototypeOf(event.error.error), TrustedDecline.prototype) assert.notEqual(event.error.error, original.error) assert.deepEqual(Object.getOwnPropertyNames(event.error.error), ["_tag", "name", "message", "stack", "toString", "toJSON"]) assert.equal(event.error.error._tag, "Permission.DeclinedError") assert.equal(event.error.error.message, "") assert.equal(event.error.error.stack, "") assert.equal(JSON.stringify(event.error.error), '{"_tag":"Permission.DeclinedError"}') + assert.equal(event.error.error instanceof TrustedDecline, true) + assert.doesNotMatch(inspect(event.error.error, { showHidden: true }), /secret-sentinel|host-private-detail/) class PoisonedDeclined extends Error { get message() { return "secret-sentinel" } @@ -107,7 +120,8 @@ test("V2 permission rejection retains its host type without copying its data", a const unsafe = { status: "error", error: poisoned } await callback(unsafe) assert.equal(unsafe.error.message, "safe") - assert.equal(unsafe.error.error, undefined) + assert.equal(Object.getPrototypeOf(unsafe.error.error), TrustedDecline.prototype) + assert.doesNotMatch(inspect(unsafe.error.error, { showHidden: true }), /secret-sentinel/) class PoisonBase extends Error { get message() { return "secret-sentinel" } } class ForgedSchema extends PoisonBase {} @@ -119,7 +133,17 @@ test("V2 permission rejection retains its host type without copying its data", a forged.error._tag = "Permission.DeclinedError" const inherited = { status: "error", error: forged } await callback(inherited) - assert.equal(inherited.error.error, undefined) + assert.equal(Object.getPrototypeOf(inherited.error.error), TrustedDecline.prototype) + assert.doesNotMatch(inspect(inherited.error.error, { showHidden: true }), /secret-sentinel/) + + const trapped = new Error("safe") + trapped.error = new Proxy({ _tag: "Permission.DeclinedError" }, { + getPrototypeOf() { throw new Error("secret-sentinel") }, + }) + const proxyEvent = { status: "error", error: trapped } + await callback(proxyEvent) + assert.equal(Object.getPrototypeOf(proxyEvent.error.error), TrustedDecline.prototype) + assert.equal(factoryCalls, 1) }) test("V2 tool errors fail closed for unsafe shapes, accessors, controls and redactor failures", async () => { @@ -132,7 +156,7 @@ test("V2 tool errors fail closed for unsafe shapes, accessors, controls and reda await registerV2ToolOutputRedaction({ async hook(_name, handler) { callback = handler return { async dispose() {} } - } }, redactor, createToolError, VERSION) + } }, redactor, createToolError, VERSION, createTrustedDecline) const accessor = Object.defineProperty({}, "message", { get() { getterCalled = true; throw new Error("secret-sentinel") } }) const proxy = new Proxy({}, { getOwnPropertyDescriptor() { throw new Error("secret-sentinel") } }) for (const error of [null, "secret-sentinel", accessor, proxy, new Error("x".repeat(100)), new Error("\u001b[31msecret-sentinel")]) { @@ -145,7 +169,7 @@ test("V2 tool errors fail closed for unsafe shapes, accessors, controls and reda await registerV2ToolOutputRedaction({ async hook(_name, handler) { throwingCallback = handler return { async dispose() {} } - } }, { async redactMutableValueAsync() { throw new Error("secret-sentinel") } }, createToolError, VERSION) + } }, { async redactMutableValueAsync() { throw new Error("secret-sentinel") } }, createToolError, VERSION, createTrustedDecline) const failedRedaction = { status: "error", error: new Error("safe") } await throwingCallback(failedRedaction) assert.equal(failedRedaction.error.message, "Tool execution failed; details withheld by gateway") @@ -165,11 +189,30 @@ test("V2 tool errors fail closed for unsafe shapes, accessors, controls and reda await registerV2ToolOutputRedaction({ async hook(_name, handler) { factoryCallback = handler return { async dispose() {} } - } }, redactor, () => { throw new Error("secret-sentinel") }, VERSION) + } }, redactor, () => { throw new Error("secret-sentinel") }, VERSION, createTrustedDecline) await assert.rejects(factoryCallback({ status: "error", error: new Error("safe") }), /^Error: Tool output withheld because redaction failed$/) await assert.rejects(registerV2ToolOutputRedaction({}, redactor), /V2 Tool.Error factory required/) + await assert.rejects(registerV2ToolOutputRedaction({}, redactor, createToolError, VERSION), /trusted permission-decline factory required/) for (const version of [undefined, "1.18.18", "2.3.0", "2.2.5-preview"]) { - await assert.rejects(registerV2ToolOutputRedaction({}, redactor, createToolError, version), /requires verified OpenCode 2.2.5/) + await assert.rejects(registerV2ToolOutputRedaction({}, redactor, createToolError, version, createTrustedDecline), /requires verified OpenCode 2.2.5 or 2.2.6/) + } +}) + +test("V2 decline capability failures prevent registration without exposing the failure", async () => { + const redactor = createSecretRedactor({ + patterns: ["secret-sentinel"], redactionToken: "[REDACTED]", + limits: { maxDepth: 8, maxNodes: 100, maxChars: 1000 }, + }) + for (const factory of [ + () => { throw new Error("secret-sentinel") }, + () => null, + () => ({ _tag: "unexpected" }), + () => Object.assign(Object.create(null), { _tag: "Permission.DeclinedError" }), + ]) { + let registered = false + const tool = { async hook() { registered = true } } + await assert.rejects(registerV2ToolOutputRedaction(tool, redactor, createToolError, "2.2.6", factory), /^Error: V2 trusted permission-decline factory unavailable$/) + assert.equal(registered, false) } }) @@ -182,7 +225,7 @@ test("V2 tool errors support isolated custom-pattern redaction", async () => { await registerV2ToolOutputRedaction({ async hook(_name, handler) { callback = handler return { async dispose() {} } - } }, redactor, createToolError, VERSION) + } }, redactor, createToolError, VERSION, createTrustedDecline) const event = { status: "error", error: new Error("safe secret-sentinel") } await callback(event) assert.equal(event.error.message, "safe [REDACTED]") diff --git a/scripts/gateway_v2_permission_rejection_live_smoke.py b/scripts/gateway_v2_permission_rejection_live_smoke.py index 1ca68c5c..b2bd8693 100644 --- a/scripts/gateway_v2_permission_rejection_live_smoke.py +++ b/scripts/gateway_v2_permission_rejection_live_smoke.py @@ -24,6 +24,7 @@ FEEDBACK_CANARY = "V2PermissionFeedbackSynthetic_123456789" REDACTOR_CANARY = "V2PermissionRedactorSynthetic_123456789" ABORT_CANARY = "V2CancellationSynthetic_123456789" +FORGED_CANARY = "V2ForgedPrototypeSynthetic_123456789" def request( @@ -86,18 +87,22 @@ def run_case( plugin.mkdir(parents=True) marker = root / "redactor-called" identity_marker = root / "host-cause.json" + registered_marker = root / "guard-registered" started, aborted, after = root / "started", root / "aborted", root / "after" dist = ROOT / "plugin/gateway-core/dist" (plugin / "index.ts").write_text( 'import { Plugin } from "@opencode/plugin"\n' + 'import { Schema } from "effect"\n' 'import { Error as ToolError } from "@opencode/plugin/promise/tool"\n' 'import { writeFileSync } from "node:fs"\n' + 'import { inspect } from "node:util"\n' f"import {{ registerV2ToolOutputRedaction }} from {json.dumps((dist / 'v2/tool-output-redaction.js').as_uri())}\n" f"import {{ createSecretRedactor }} from {json.dumps((dist / 'hooks/shared/secret-redaction.js').as_uri())}\n" 'export default Plugin.define({ id: "v2-permission-rejection-probe", async setup(ctx) {\n' - ' if (ctx.app.version !== "2.2.5") throw new Error("fixture requires OpenCode 2.2.5")\n' - ' let capturedDecline\n' + ' if (!["2.2.5", "2.2.6"].includes(ctx.app.version)) throw new Error("fixture requires OpenCode 2.2.5 or 2.2.6")\n' + ' let capturedDecline, registered = false, activeCase = false\n' ' await ctx.tool.hook("execute.after", event => {\n' + ' if (event.input?.kind === "bootstrap") return\n' f' writeFileSync({json.dumps(str(after))}, "after")\n' ' if (event.tool !== "synthetic_probe" || event.status !== "error") return\n' ' const mode = process.env.V2_SMOKE_CONTROL\n' @@ -109,6 +114,13 @@ def run_case( f' writeFileSync({json.dumps(str(identity_marker))}, JSON.stringify({{ mode, applied: true }}))\n' ' throw new Error("synthetic after-hook failure")\n' ' }\n' + ' if (mode === "schema_clone") {\n' + ' const LocalDecline = Schema.TaggedError()("Permission.DeclinedError", {})\n' + ' const fresh = new LocalDecline()\n' + ' const same = !!capturedDecline && fresh instanceof capturedDecline.constructor\n' + ' event.error = new ToolError({ message: "synthetic rejection", error: fresh })\n' + f' writeFileSync({json.dumps(str(identity_marker))}, JSON.stringify({{ mode, applied: true, same }}))\n' + ' }\n' ' if (mode === "host_clone") {\n' ' const cause = Object.getOwnPropertyDescriptor(event.error, "error")?.value\n' ' const tag = capturedDecline && Object.getOwnPropertyDescriptor(capturedDecline, "_tag")?.value\n' @@ -119,12 +131,39 @@ def run_case( ' event.error = new ToolError({ message: "synthetic rejection", error: clone })\n' f' writeFileSync({json.dumps(str(identity_marker))}, JSON.stringify({{ mode, applied: true, same }}))\n' ' }\n' + ' if (mode === "forged" || mode === "forged_unprotected") {\n' + ' const base = Object.create(Error.prototype)\n' + ' for (const key of ["constructor", "~effect/Effect", "pipe", "toJSON", "~effect/Effect/identifier", "~effect/Effect/evaluate", "~effect/Effect/successCont", "~effect/Effect/failureCont", "~effect/Effect/ensureCont", Symbol.for("nodejs.util.inspect.custom"), Symbol.iterator])\n' + f' Object.defineProperty(base, key, {{ value() {{ return {json.dumps(FORGED_CANARY)} }} }})\n' + ' const schemaBase = Object.create(base, { constructor: { value() {} }, toJSON: { value() {} } })\n' + ' const schema = Object.create(schemaBase, { constructor: { value() {} }, name: { value: "Permission.DeclinedError" }, "~effect/Schema/Class/Permission.DeclinedError": { get() { return true } } })\n' + ' const prototype = Object.create(schema, { constructor: { value() {} } })\n' + ' const forged = Object.create(prototype, { _tag: { value: "Permission.DeclinedError" } })\n' + ' event.error = new ToolError({ message: "synthetic rejection", error: forged })\n' + ' }\n' ' })\n' - f' const redactor = process.env.V2_SMOKE_REDACTOR_FAIL === "1" ? {{ async redactMutableValueAsync() {{ writeFileSync({json.dumps(str(marker))}, "called"); throw new Error({json.dumps(REDACTOR_CANARY)}) }} }} : createSecretRedactor({{ patterns: {json.dumps([FEEDBACK_CANARY, ABORT_CANARY])}, redactionToken: "[REDACTED]", limits: {{ maxDepth: 32, maxNodes: 10000, maxChars: 1000000 }} }})\n' - ' if (process.env.V2_SMOKE_GUARD !== "off") await registerV2ToolOutputRedaction(ctx.tool, redactor, (message, cause) => new ToolError({ message, ...(cause === undefined ? {} : { error: cause }) }), ctx.app.version)\n' + f' const redactor = process.env.V2_SMOKE_REDACTOR_FAIL === "1" ? {{ async redactMutableValueAsync() {{ if (activeCase) writeFileSync({json.dumps(str(marker))}, "called"); throw new Error({json.dumps(REDACTOR_CANARY)}) }} }} : createSecretRedactor({{ patterns: {json.dumps([FEEDBACK_CANARY, ABORT_CANARY])}, redactionToken: "[REDACTED]", limits: {{ maxDepth: 32, maxNodes: 10000, maxChars: 1000000 }} }})\n' + ' const inspectForged = event => {\n' + ' const mode = process.env.V2_SMOKE_CONTROL\n' + ' if ((mode !== "forged" && mode !== "forged_unprotected") || event.tool !== "synthetic_probe" || event.status !== "error" || event.input?.kind === "bootstrap") return\n' + ' const cause = Object.getOwnPropertyDescriptor(event.error, "error")?.value\n' + f' const exposed = inspect(cause, {{ showHidden: true }}).includes({json.dumps(FORGED_CANARY)})\n' + ' const same = !!capturedDecline && Object.getPrototypeOf(cause) === Object.getPrototypeOf(capturedDecline)\n' + f' writeFileSync({json.dumps(str(identity_marker))}, JSON.stringify({{ mode, applied: true, same, exposed }}))\n' + ' }\n' + ' if (process.env.V2_SMOKE_GUARD === "off") await ctx.tool.hook("execute.after", inspectForged)\n' + # Capture a trusted host decline through a fixture-owned assertion + # before registering the adapter. This is not a production bootstrap. + ' const registerGuard = async () => {\n' + ' if (registered) return\n' + ' await registerV2ToolOutputRedaction(ctx.tool, redactor, (message, cause) => new ToolError({ message, ...(cause === undefined ? {} : { error: cause }) }), ctx.app.version, () => capturedDecline)\n' + ' await ctx.tool.hook("execute.after", inspectForged)\n' + f' registered = true; writeFileSync({json.dumps(str(registered_marker))}, "registered")\n' + ' }\n' ' await ctx.tool.transform((editor) => editor.add({ name: "synthetic_probe", description: "permission rejection probe", options: { codemode: false }, input: { type: "object", properties: { kind: { type: "string" } }, required: ["kind"], additionalProperties: false }, async execute(input, tool) {\n' - f' if (process.env.V2_SMOKE_CANCEL === "1") {{ writeFileSync({json.dumps(str(started))}, "started"); await new Promise((_resolve, reject) => {{ const stop = () => {{ writeFileSync({json.dumps(str(aborted))}, "aborted"); reject(new Error({json.dumps(ABORT_CANARY)})) }}; if (tool.signal.aborted) stop(); else tool.signal.addEventListener("abort", stop, {{ once: true }}) }}); return {{ content: "CANCELLATION_BYPASSED" }} }}\n' - ' try { await ctx.permission.assert({ sessionID: tool.sessionID, agent: tool.agent, source: { type: "tool", messageID: tool.messageID, id: tool.id }, action: "synthetic_ask", resources: ["synthetic-resource"], save: ["synthetic-resource"] }) } catch (error) { capturedDecline = error; throw error }\n' + ' if (input.kind !== "bootstrap") activeCase = true\n' + f' if (process.env.V2_SMOKE_CANCEL === "1" && input.kind !== "bootstrap") {{ writeFileSync({json.dumps(str(started))}, "started"); await new Promise((_resolve, reject) => {{ const stop = () => {{ writeFileSync({json.dumps(str(aborted))}, "aborted"); reject(new Error({json.dumps(ABORT_CANARY)})) }}; if (tool.signal.aborted) stop(); else tool.signal.addEventListener("abort", stop, {{ once: true }}) }}); return {{ content: "CANCELLATION_BYPASSED" }} }}\n' + ' try { await ctx.permission.assert({ sessionID: tool.sessionID, agent: tool.agent, source: { type: "tool", messageID: tool.messageID, id: tool.id }, action: "synthetic_ask", resources: ["synthetic-resource"], save: ["synthetic-resource"] }) } catch (error) { capturedDecline = error; if (input.kind === "bootstrap" && process.env.V2_SMOKE_GUARD !== "off") await registerGuard(); throw error }\n' ' return { content: "PERMISSION_BYPASSED" }\n' " } }))\n} })\n", encoding="utf-8", @@ -155,9 +194,9 @@ def run_case( env.pop("OPENCODE_SESSION_ID", None) password = secrets.token_urlsafe(24) env["OPENCODE_PASSWORD"] = password - if mode in {"baseline", "host_clone", "drop_cause", "throw_after"}: + if mode in {"baseline", "host_clone", "drop_cause", "throw_after", "forged_unprotected", "schema_clone"}: env["V2_SMOKE_GUARD"] = "off" - if mode in {"host_clone", "drop_cause", "throw_after"}: + if mode in {"host_clone", "drop_cause", "throw_after", "forged", "forged_unprotected", "schema_clone"}: env["V2_SMOKE_CONTROL"] = mode if mode == "redactor_failure": env["V2_SMOKE_REDACTOR_FAIL"] = "1" @@ -165,8 +204,6 @@ def run_case( env["V2_SMOKE_CANCEL"] = "1" port = free_port() base = f"http://127.0.0.1:{port}" - with state.lock: - start = len(state.requests) with (root / "server.log").open("wb") as log: process = subprocess.Popen( ["opencode", "serve", "--hostname", "127.0.0.1", "--port", str(port)], @@ -181,15 +218,28 @@ def run_case( ) if info.get("pid") != process.pid: raise RuntimeError("isolated server identity mismatch") + session_body = { + "location": {"directory": str(project)}, + "model": {"providerID": "capture", "id": "mock"}, + } + if env.get("V2_SMOKE_GUARD") != "off": + bootstrap = request(base, password, "POST", "/api/session", session_body)["data"]["id"] + request(base, password, "POST", f"/api/session/{bootstrap}/prompt", {"text": "BOOTSTRAP_PERMISSION_PROBE"}) + pending = wait_for( + lambda: request(base, password, "GET", f"/api/session/{bootstrap}/permission").get("data"), + process, + ) + request(base, password, "POST", f"/api/session/{bootstrap}/permission/{pending[0]['id']}/reply", {"decision": "reject"}) + request(base, password, "POST", f"/api/experimental/session/{bootstrap}/wait", timeout=25) + wait_for(lambda: registered_marker.is_file(), process) + with state.lock: + start = len(state.requests) session = request( base, password, "POST", "/api/session", - { - "location": {"directory": str(project)}, - "model": {"providerID": "capture", "id": "mock"}, - }, + session_body, )["data"]["id"] request( base, @@ -235,7 +285,7 @@ def run_case( wire = json.dumps(requests) info = request(base, password, "GET", f"/api/session/{session}")["data"] persisted = persisted_markers( - home, (ABORT_CANARY, FEEDBACK_CANARY, REDACTOR_CANARY) + home, (ABORT_CANARY, FEEDBACK_CANARY, REDACTOR_CANARY, FORGED_CANARY) ) server_log = (root / "server.log").read_text( encoding="utf-8", errors="replace" @@ -270,6 +320,10 @@ def run_case( "control_applied": control_observation.get("mode") == mode and control_observation.get("applied") is True, "host_cause_is_captured": control_observation.get("same"), + "forged_exposed_to_inspect": control_observation.get("exposed"), + "forged_canary_on_wire": FORGED_CANARY in wire, + "forged_canary_in_database": persisted[FORGED_CANARY], + "forged_canary_in_server_log": FORGED_CANARY in server_log, "abort_canary_on_wire": ABORT_CANARY in wire, "abort_canary_in_database": persisted[ABORT_CANARY], } @@ -322,6 +376,11 @@ def main() -> int: mode: run_case(capture, state, mode, False) for mode in ("host_clone", "drop_cause", "throw_after") } + forged = { + mode: run_case(capture, state, mode, False) + for mode in ("forged", "forged_unprotected") + } + independent_schema = run_case(capture, state, "schema_clone", False) passed = ( all( row["permission_requested"] @@ -362,6 +421,19 @@ def main() -> int: for row in cancellation.values() ) and control_evidence_passed(controls) + and forged["forged"]["control_applied"] + and forged["forged"]["host_cause_is_captured"] is True + and forged["forged"]["forged_exposed_to_inspect"] is False + and not forged["forged"]["tool_returned_to_model"] + and not forged["forged"]["forged_canary_on_wire"] + and not forged["forged"]["forged_canary_in_database"] + and not forged["forged"]["forged_canary_in_server_log"] + and forged["forged_unprotected"]["control_applied"] + and forged["forged_unprotected"]["host_cause_is_captured"] is False + and forged["forged_unprotected"]["forged_exposed_to_inspect"] is True + and independent_schema["control_applied"] + and independent_schema["host_cause_is_captured"] is False + and independent_schema["tool_returned_to_model"] ) print( json.dumps( @@ -370,6 +442,8 @@ def main() -> int: "cases": cases, "cancellation": cancellation, "controls": controls, + "forged": forged, + "independent_schema": independent_schema, } ) ) diff --git a/scripts/gateway_v2_shell_live_smoke.py b/scripts/gateway_v2_shell_live_smoke.py index 39559fca..70a4d6fd 100644 --- a/scripts/gateway_v2_shell_live_smoke.py +++ b/scripts/gateway_v2_shell_live_smoke.py @@ -106,6 +106,7 @@ def main() -> int: project = root / "project" plugin = project / "v2-probe" plugin.mkdir(parents=True) + unexpected_decline = root / "unexpected-plain-decline" server.safe_marker = project / "safe-marker" server.blocked_marker = project / "blocked-marker" server.secret_file = project / "synthetic-tool-output.txt" @@ -113,12 +114,16 @@ def main() -> int: (plugin / "index.ts").write_text( 'import { Plugin } from "@opencode/plugin"\n' 'import { Error as ToolError } from "@opencode/plugin/promise/tool"\n' + 'import { writeFileSync } from "node:fs"\n' f"import {{ registerV2ShellGuards }} from {json.dumps(dist.as_uri())}\n" f"import {{ registerV2ToolOutputRedaction }} from {json.dumps(output_dist.as_uri())}\n" f"import {{ createSecretRedactor }} from {json.dumps(redactor_dist.as_uri())}\n" 'export default Plugin.define({ id: "v2-shell-probe", async setup(ctx) {\n' f' const redactor = createSecretRedactor({{ patterns: [{json.dumps(CANARY)}], redactionToken: "[REDACTED]", limits: {{ maxDepth: 32, maxNodes: 10000, maxChars: 1000000 }} }})\n' - " const disposeOutput = await registerV2ToolOutputRedaction(ctx.tool, redactor, (message, cause) => new ToolError({ message, ...(cause === undefined ? {} : { error: cause }) }), ctx.app.version)\n" + # This non-interactive fixture never exercises plain rejection. + ' const fixtureOnlyDecline = Object.assign(new Error(), { _tag: "Permission.DeclinedError" })\n' + f' await ctx.tool.hook("execute.after", event => {{ if (event.status === "error" && Object.getOwnPropertyDescriptor(event.error, "error")?.value?._tag === "Permission.DeclinedError") writeFileSync({json.dumps(str(unexpected_decline))}, "unexpected") }})\n' + " const disposeOutput = await registerV2ToolOutputRedaction(ctx.tool, redactor, (message, cause) => new ToolError({ message, ...(cause === undefined ? {} : { error: cause }) }), ctx.app.version, () => fixtureOnlyDecline)\n" " const disposeShell = await registerV2ShellGuards(ctx.tool, async (sessionID) => (await ctx.session.get({ sessionID })).location.directory, {\n" " directory: ctx.location.directory, noninteractive: { enabled: true, injectEnvPrefix: false, envPrefixes: [], prefixCommands: [], blockedPatterns: [] },\n" ' dangerous: { enabled: true, blockedPatterns: ["BLOCKED_SYNTHETIC"] }\n' @@ -185,7 +190,7 @@ def main() -> int: results[1].stdout + results[1].stderr ) output_redacted = CANARY not in captured and "[REDACTED]" in captured - passed = allowed and blocked and output_redacted and "shell" in tool_names + passed = allowed and blocked and output_redacted and "shell" in tool_names and not unexpected_decline.exists() print( json.dumps( { diff --git a/scripts/gateway_v2_tool_error_live_smoke.py b/scripts/gateway_v2_tool_error_live_smoke.py index 5d2a2f1d..8156e049 100644 --- a/scripts/gateway_v2_tool_error_live_smoke.py +++ b/scripts/gateway_v2_tool_error_live_smoke.py @@ -40,7 +40,9 @@ def do_POST(self) -> None: finished = any(item.get("role") == "tool" for item in messages) if "synthetic_probe" in names and not finished: kind = ( - "safe" + "bootstrap" + if "BOOTSTRAP_PERMISSION_PROBE" in text + else "safe" if "SAFE_ERROR_PROBE" in text else "completed" if "COMPLETED_FAILURE_PROBE" in text @@ -147,15 +149,20 @@ def run_case( home, project = root / "home", root / "project" plugin = project / "v2-probe" plugin.mkdir(parents=True) + unexpected_decline = root / "unexpected-plain-decline" dist = ROOT / "plugin/gateway-core/dist" (plugin / "index.ts").write_text( 'import { Plugin } from "@opencode/plugin"\n' 'import { Error as ToolError } from "@opencode/plugin/promise/tool"\n' + 'import { writeFileSync } from "node:fs"\n' f"import {{ registerV2ToolOutputRedaction }} from {json.dumps((dist / 'v2/tool-output-redaction.js').as_uri())}\n" f"import {{ createSecretRedactor }} from {json.dumps((dist / 'hooks/shared/secret-redaction.js').as_uri())}\n" 'export default Plugin.define({ id: "v2-tool-error-probe", async setup(ctx) {\n' f' const redactor = process.env.V2_SMOKE_REDACTOR_FAIL === "1" ? {{ async redactMutableValueAsync() {{ throw new Error({json.dumps(REDACTOR_CANARY)}) }} }} : createSecretRedactor({{ patterns: {json.dumps(list(CANARIES))}, redactionToken: "[REDACTED]", limits: {{ maxDepth: 32, maxNodes: 10000, maxChars: 1000000 }} }})\n' - ' if (process.env.V2_SMOKE_GUARD !== "off") await registerV2ToolOutputRedaction(ctx.tool, redactor, (message, cause) => new ToolError({ message, ...(cause === undefined ? {} : { error: cause }) }), ctx.app.version)\n' + # This non-interactive fixture never exercises plain rejection. + ' const fixtureOnlyDecline = Object.assign(new Error(), { _tag: "Permission.DeclinedError" })\n' + f' await ctx.tool.hook("execute.after", event => {{ if (event.status === "error" && Object.getOwnPropertyDescriptor(event.error, "error")?.value?._tag === "Permission.DeclinedError") writeFileSync({json.dumps(str(unexpected_decline))}, "unexpected") }})\n' + ' if (process.env.V2_SMOKE_GUARD !== "off") await registerV2ToolOutputRedaction(ctx.tool, redactor, (message, cause) => new ToolError({ message, ...(cause === undefined ? {} : { error: cause }) }), ctx.app.version, () => fixtureOnlyDecline)\n' ' await ctx.tool.transform((editor) => editor.add({ name: "synthetic_probe", description: "synthetic error probe", options: { codemode: false }, input: { type: "object", properties: { kind: { type: "string" } }, required: ["kind"], additionalProperties: false }, async execute(input, tool) {\n' ' if (input.kind === "permission") { await ctx.permission.assert({ sessionID: tool.sessionID, agent: tool.agent, source: { type: "tool", messageID: tool.messageID, id: tool.id }, action: "synthetic_blocked_action", resources: ["synthetic-resource"], save: ["synthetic-resource"] }); return { content: "PERMISSION_BYPASSED" } }\n' f' if (input.kind === "completed") return {{ content: "result-control {MESSAGE_CANARY}" }}\n' @@ -270,6 +277,7 @@ def run_case( in terminal, "permission_bypassed": "PERMISSION_BYPASSED" in wire or "PERMISSION_BYPASSED" in terminal, + "unexpected_plain_decline": unexpected_decline.is_file(), } return case_result @@ -324,7 +332,7 @@ def main() -> int: permission = cases["permission"] permission_failure = cases["permission_redactor_failure"] passed = ( - all(active(row) for row in cases.values()) + all(active(row) and not row["unexpected_plain_decline"] for row in cases.values()) and all( baseline[key] for key in (