fix: close OC-02 gaps in ACP permission + gateway HTTP deny config (#15390) (thanks @aether-ai-agent)

This commit is contained in:
Peter Steinberger
2026-02-13 14:28:50 +01:00
parent 749e28dec7
commit ee31cd47b4
9 changed files with 308 additions and 95 deletions

View File

@@ -3,6 +3,7 @@ import {
PROTOCOL_VERSION,
ndJsonStream,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
} from "@agentclientprotocol/sdk";
import { spawn, type ChildProcess } from "node:child_process";
@@ -28,30 +29,171 @@ const DANGEROUS_ACP_TOOLS = new Set([
"apply_patch",
]);
function promptUserPermission(toolName: string, toolTitle?: string): Promise<boolean> {
type PermissionOption = RequestPermissionRequest["options"][number];
type PermissionResolverDeps = {
prompt?: (toolName: string | undefined, toolTitle?: string) => Promise<boolean>;
log?: (line: string) => void;
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readFirstStringValue(
source: Record<string, unknown> | undefined,
keys: string[],
): string | undefined {
if (!source) {
return undefined;
}
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
}
function normalizeToolName(value: string): string | undefined {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
return normalized;
}
function parseToolNameFromTitle(title: string | undefined | null): string | undefined {
if (!title) {
return undefined;
}
const head = title.split(":", 1)[0]?.trim();
if (!head || !/^[a-zA-Z0-9._-]+$/.test(head)) {
return undefined;
}
return normalizeToolName(head);
}
function resolveToolNameForPermission(params: RequestPermissionRequest): string | undefined {
const toolCall = params.toolCall;
const toolMeta = asRecord(toolCall?._meta);
const rawInput = asRecord(toolCall?.rawInput);
const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]);
const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]);
const fromTitle = parseToolNameFromTitle(toolCall?.title);
return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? "");
}
function pickOption(
options: PermissionOption[],
kinds: PermissionOption["kind"][],
): PermissionOption | undefined {
for (const kind of kinds) {
const match = options.find((option) => option.kind === kind);
if (match) {
return match;
}
}
return undefined;
}
function selectedPermission(optionId: string): RequestPermissionResponse {
return { outcome: { outcome: "selected", optionId } };
}
function cancelledPermission(): RequestPermissionResponse {
return { outcome: { outcome: "cancelled" } };
}
function promptUserPermission(toolName: string | undefined, toolTitle?: string): Promise<boolean> {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(`[permission denied] ${toolName ?? "unknown"}: non-interactive terminal`);
return Promise.resolve(false);
}
return new Promise((resolve) => {
let settled = false;
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
});
const timeout = setTimeout(() => {
console.error(`\n[permission timeout] denied: ${toolName}`);
rl.close();
resolve(false);
}, 30_000);
const label = toolTitle ? `${toolTitle} (${toolName})` : toolName;
rl.question(`\n[permission] Allow "${label}"? (y/N) `, (answer) => {
const finish = (approved: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
rl.close();
const approved = answer.trim().toLowerCase() === "y";
console.error(`[permission ${approved ? "approved" : "denied"}] ${toolName}`);
resolve(approved);
};
const timeout = setTimeout(() => {
console.error(`\n[permission timeout] denied: ${toolName ?? "unknown"}`);
finish(false);
}, 30_000);
const label = toolTitle
? toolName
? `${toolTitle} (${toolName})`
: toolTitle
: (toolName ?? "unknown tool");
rl.question(`\n[permission] Allow "${label}"? (y/N) `, (answer) => {
const approved = answer.trim().toLowerCase() === "y";
console.error(`[permission ${approved ? "approved" : "denied"}] ${toolName ?? "unknown"}`);
finish(approved);
});
});
}
export async function resolvePermissionRequest(
params: RequestPermissionRequest,
deps: PermissionResolverDeps = {},
): Promise<RequestPermissionResponse> {
const log = deps.log ?? ((line: string) => console.error(line));
const prompt = deps.prompt ?? promptUserPermission;
const options = params.options ?? [];
const toolTitle = params.toolCall?.title ?? "tool";
const toolName = resolveToolNameForPermission(params);
if (options.length === 0) {
log(`[permission cancelled] ${toolName ?? "unknown"}: no options available`);
return cancelledPermission();
}
const allowOption = pickOption(options, ["allow_once", "allow_always"]);
const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
const promptRequired = !toolName || DANGEROUS_ACP_TOOLS.has(toolName);
if (!promptRequired) {
const option = allowOption ?? options[0];
if (!option) {
log(`[permission cancelled] ${toolName}: no selectable options`);
return cancelledPermission();
}
log(`[permission auto-approved] ${toolName}`);
return selectedPermission(option.optionId);
}
log(`\n[permission requested] ${toolTitle}${toolName ? ` (${toolName})` : ""}`);
const approved = await prompt(toolName, toolTitle);
if (approved && allowOption) {
return selectedPermission(allowOption.optionId);
}
if (!approved && rejectOption) {
return selectedPermission(rejectOption.optionId);
}
log(
`[permission cancelled] ${toolName ?? "unknown"}: missing ${approved ? "allow" : "reject"} option`,
);
return cancelledPermission();
}
export type AcpClientOptions = {
cwd?: string;
serverCommand?: string;
@@ -146,42 +288,7 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpC
printSessionUpdate(params);
},
requestPermission: async (params: RequestPermissionRequest) => {
// toolCall may include a `name` field not in the SDK type
const toolCall = params.toolCall as Record<string, unknown> | undefined;
const toolName = (typeof toolCall?.name === "string" ? toolCall.name : "") as string;
const toolTitle = (params.toolCall?.title ?? "tool") as string;
const options = params.options ?? [];
const allowOnce = options.find((o) => o.kind === "allow_once");
const rejectOption = options.find((o) => o.kind === "reject_once");
// No options available — deny by default (fixes empty-options exploit)
if (options.length === 0) {
console.error(`[permission denied] ${toolName}: no options available`);
return { outcome: { outcome: "selected", optionId: "deny" } };
}
// Safe tools: auto-approve (backward compatible)
if (!DANGEROUS_ACP_TOOLS.has(toolName)) {
console.error(`[permission auto-approved] ${toolName}`);
return {
outcome: {
outcome: "selected",
optionId: allowOnce?.optionId ?? options[0]?.optionId ?? "allow",
},
};
}
// Dangerous tools: require interactive confirmation
console.error(`\n[permission requested] ${toolTitle} (${toolName})`);
const approved = await promptUserPermission(toolName, toolTitle);
if (approved && allowOnce) {
return { outcome: { outcome: "selected", optionId: allowOnce.optionId } };
}
// Denied — use reject option if available, otherwise reject
const rejectId = rejectOption?.optionId ?? "deny";
return { outcome: { outcome: "selected", optionId: rejectId } };
return resolvePermissionRequest(params);
},
}),
stream,