Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 7 additions & 5 deletions docs/plan/doing/opencode-v2-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.

Expand Down
9 changes: 7 additions & 2 deletions plugin/gateway-core/dist/v2/tool-output-redaction.d.ts
Original file line number Diff line number Diff line change
@@ -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<SecretRedactor, "redactMutableValueAsync">, createToolError: ToolErrorFactory, runtimeVersion: string): Promise<() => Promise<void>>;
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<SecretRedactor, "redactMutableValueAsync">, createToolError: ToolErrorFactory, runtimeVersion: string, createTrustedDecline: TrustedPermissionDeclineFactory): Promise<() => Promise<void>>;
export {};
106 changes: 44 additions & 62 deletions plugin/gateway-core/dist/v2/tool-output-redaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading