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
18 changes: 18 additions & 0 deletions docs/plan/doing/opencode-v2-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ Experimental, unconfigured adapters cover V2 model-context and native transport

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.

An alternative, also unconfigured, `registerV2InterruptingToolOutputRedaction`
does not copy a decline prototype. It replaces the error with a generic host
tool error, interrupts the affected session, and blocks its model-request hooks
until a successful interruption settles. Failed, refused, timed-out or
unverifiable settlement leaves that session quarantined for the plugin's
lifetime; unknown identity or exhausted bounds closes the guard globally.
Interruption is session-wide and reports an interrupted outcome. On 2.2.5 and
2.2.6, isolated controls observed a stopped plain rejection, redacted feedback,
forged-cause containment, blocked retry, generate and compaction requests after
a simulated interruption failure, and an unaffected second session. A new
user prompt succeeded after a successful interruption settled. These tests
cover synthetic native HTTP requests, not every provider, title request,
concurrent in-flight operation or native question dismissal. Quarantine is in
memory and is lost on unload or restart; an unresolved host interrupt may also
outlive the plugin. The adapter
does not establish an independent enforcement boundary or authorize V2
activation.

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.
Expand Down
32 changes: 31 additions & 1 deletion plugin/gateway-core/dist/v2/tool-output-redaction.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { SecretRedactor } from "../hooks/shared/secret-redaction.js";
import type { ToolHooks } from "./types.js";
import type { Registration, ToolHooks } from "./types.js";
type ToolErrorFactory = (message: string, cause?: unknown) => unknown;
type TrustedPermissionDeclineFactory = () => unknown;
/**
Expand All @@ -8,4 +8,34 @@ type TrustedPermissionDeclineFactory = () => unknown;
* 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>>;
type InterruptEvent = {
sessionID: unknown;
status: unknown;
error?: unknown;
result?: unknown;
};
type GuardEvent = {
sessionID: unknown;
};
type GuardKind = "context" | "compaction" | "generate" | "title" | "http.request" | "experimental.ws.send";
interface InterruptToolHooks {
hook(name: "execute.after", callback: (event: InterruptEvent) => Promise<void>): Promise<Registration>;
}
interface InterruptSessionHooks {
hook(name: GuardKind, callback: (event: GuardEvent) => Promise<void>): Promise<Registration>;
interrupt(input: {
sessionID: string;
resume: false;
}): Promise<{
interrupted: boolean;
}>;
wait(input: {
sessionID: string;
}): Promise<unknown>;
}
/**
* Experimental alternative to copying a host decline prototype. It is not a
* complete provider boundary and must not be configured as a standalone guard.
*/
export declare function registerV2InterruptingToolOutputRedaction(session: InterruptSessionHooks, tool: InterruptToolHooks, redactor: Pick<SecretRedactor, "redactMutableValueAsync">, createToolError: ToolErrorFactory, runtimeVersion: string): Promise<() => Promise<void>>;
export {};
185 changes: 185 additions & 0 deletions plugin/gateway-core/dist/v2/tool-output-redaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,188 @@ export async function registerV2ToolOutputRedaction(tool, redactor, createToolEr
});
return () => registration.dispose();
}
const INTERRUPT_FAILURE = "Tool step withheld because session interruption is unresolved";
const INTERRUPT_TIMEOUT_MS = 2000;
const MAX_INTERRUPTS = 8;
const MAX_QUARANTINED = 64;
const BAD_SESSION_ID = /[\u0000-\u001f\u007f]/;
function validSessionID(id) {
return typeof id === "string" && id.length > 0 && id.length <= 160 && id === id.trim() && !BAD_SESSION_ID.test(id);
}
// Only inspect own data properties. An opaque cause cannot be classified safely.
function cancellationKind(error) {
try {
if (!error || typeof error !== "object")
return "unknown";
const descriptor = Object.getOwnPropertyDescriptor(error, "error");
if (!descriptor)
return "other";
if (!("value" in descriptor))
return "unknown";
const cause = descriptor.value;
if (cause === undefined || cause === null)
return "other";
if (typeof cause !== "object")
return "unknown";
const tag = Object.getOwnPropertyDescriptor(cause, "_tag");
if (!tag)
return "other";
if (!("value" in tag) || typeof tag.value !== "string")
return "unknown";
return tag.value === PERMISSION_DECLINED || tag.value === "QuestionTool.CancelledError" ? "stop" : "other";
}
catch {
return "unknown";
}
}
/**
* Experimental alternative to copying a host decline prototype. It is not a
* complete provider boundary and must not be configured as a standalone guard.
*/
export async function registerV2InterruptingToolOutputRedaction(session, tool, redactor, createToolError, runtimeVersion) {
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 createToolError !== "function")
throw new Error("V2 Tool.Error factory required");
const registrations = [];
const quarantined = new Map();
let globallyClosed = false;
let activeInterrupts = 0;
const maybeRelease = (id, entry) => {
if (!entry.failed && entry.idle && !entry.waiter && entry.inflight === 0 && quarantined.get(id) === entry) {
quarantined.delete(id);
}
};
const observeSettlement = (id, entry) => {
entry.idle = false;
if (entry.waiter || entry.failed)
return;
entry.waiter = true;
void Promise.resolve().then(() => session.wait({ sessionID: id })).then(() => {
entry.idle = true;
}, () => {
entry.failed = true;
}).finally(() => {
entry.waiter = false;
maybeRelease(id, entry);
});
};
const interrupt = async (event) => {
let id;
try {
id = event.sessionID;
}
catch {
globallyClosed = true;
throw new Error(INTERRUPT_FAILURE);
}
if (!validSessionID(id)) {
globallyClosed = true;
throw new Error(INTERRUPT_FAILURE);
}
let entry = quarantined.get(id);
if (!entry) {
if (quarantined.size >= MAX_QUARANTINED) {
globallyClosed = true;
throw new Error(INTERRUPT_FAILURE);
}
entry = { inflight: 0, idle: false, waiter: false, failed: false };
quarantined.set(id, entry);
}
entry.inflight += 1;
let assignmentFailed = false;
try {
try {
event.error = createToolError("Tool step stopped");
}
catch {
assignmentFailed = true;
entry.failed = true;
}
if (activeInterrupts >= MAX_INTERRUPTS) {
globallyClosed = true;
throw new Error(INTERRUPT_FAILURE);
}
activeInterrupts += 1;
const pending = Promise.resolve().then(() => session.interrupt({ sessionID: id, resume: false }));
void pending.finally(() => { activeInterrupts -= 1; }).catch(() => { });
let timer;
try {
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(INTERRUPT_FAILURE)), INTERRUPT_TIMEOUT_MS);
});
const outcome = await Promise.race([pending, timeout]);
if (!outcome || outcome.interrupted !== true || assignmentFailed)
throw new Error(INTERRUPT_FAILURE);
observeSettlement(id, entry);
}
finally {
if (timer)
clearTimeout(timer);
}
}
catch {
entry.failed = true;
throw new Error(INTERRUPT_FAILURE);
}
finally {
entry.inflight -= 1;
maybeRelease(id, entry);
}
};
try {
for (const kind of ["context", "compaction", "generate", "title", "http.request", "experimental.ws.send"]) {
registrations.push(await session.hook(kind, async (event) => {
try {
const id = event.sessionID;
if (globallyClosed || !validSessionID(id) || quarantined.has(id))
throw new Error();
}
catch {
throw new Error(INTERRUPT_FAILURE);
}
}));
}
registrations.push(await tool.hook("execute.after", async (event) => {
let status;
let original;
try {
status = event.status;
if (status === "error")
original = event.error;
}
catch {
globallyClosed = true;
throw new Error(REDACTION_FAILURE);
}
if (status === "error") {
if (cancellationKind(original) !== "other")
return interrupt(event);
try {
event.error = createToolError(await safeErrorMessage(original, redactor));
}
catch {
throw new Error(REDACTION_FAILURE);
}
return;
}
if (status !== "completed")
return interrupt(event);
try {
const wrapper = { result: event.result };
await redactor.redactMutableValueAsync(wrapper);
event.result = wrapper.result;
}
catch {
throw new Error(REDACTION_FAILURE);
}
}));
}
catch {
await Promise.allSettled(registrations.map(async (registration) => registration.dispose()));
throw new Error("Unable to register V2 tool-output redaction");
}
return async () => {
await Promise.allSettled(registrations.map(async (registration) => registration.dispose()));
};
}
Loading
Loading