Files
openclaw/src/acp/approval-classifier.ts
2026-04-02 10:45:41 -06:00

229 lines
6.7 KiB
TypeScript

import { homedir } from "node:os";
import path from "node:path";
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
import { isMutatingToolCall } from "../agents/tool-mutation.js";
import { resolveOwnerOnlyToolApprovalClass } from "../agents/tool-policy.js";
const SAFE_SEARCH_TOOL_IDS = new Set(["search", "web_search", "memory_search"]);
const TRUSTED_SAFE_TOOL_ALIASES = new Set(["search"]);
const EXEC_CAPABLE_TOOL_IDS = new Set([
"exec",
"spawn",
"shell",
"bash",
"process",
"code_execution",
]);
const CONTROL_PLANE_TOOL_IDS = new Set(["sessions_spawn", "sessions_send", "session_status"]);
export type AcpApprovalClass =
| "readonly_scoped"
| "readonly_search"
| "mutating"
| "exec_capable"
| "control_plane"
| "interactive"
| "other"
| "unknown";
export type AcpApprovalClassification = {
toolName?: string;
approvalClass: AcpApprovalClass;
autoApprove: boolean;
};
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 || normalized.length > 128) {
return undefined;
}
return /^[a-z0-9._-]+$/.test(normalized) ? normalized : undefined;
}
function parseToolNameFromTitle(title: string | undefined | null): string | undefined {
if (!title) {
return undefined;
}
const head = title.split(":", 1)[0]?.trim();
return head ? normalizeToolName(head) : undefined;
}
export function resolveToolNameForPermission(params: {
toolCall?: {
title?: string | null;
_meta?: unknown;
rawInput?: unknown;
};
}): 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);
const metaName = fromMeta ? normalizeToolName(fromMeta) : undefined;
const rawInputName = fromRawInput ? normalizeToolName(fromRawInput) : undefined;
const titleName = fromTitle;
if ((fromMeta && !metaName) || (fromRawInput && !rawInputName)) {
return undefined;
}
if (metaName && titleName && metaName !== titleName) {
return undefined;
}
if (rawInputName && metaName && rawInputName !== metaName) {
return undefined;
}
if (rawInputName && titleName && rawInputName !== titleName) {
return undefined;
}
return metaName ?? titleName ?? rawInputName;
}
function extractPathFromToolTitle(
toolTitle: string | undefined,
toolName: string | undefined,
): string | undefined {
if (!toolTitle) {
return undefined;
}
const separator = toolTitle.indexOf(":");
if (separator < 0) {
return undefined;
}
const tail = toolTitle.slice(separator + 1).trim();
if (!tail) {
return undefined;
}
const keyedMatch = tail.match(/(?:^|,\s*)(?:path|file_path|filePath)\s*:\s*([^,]+)/);
if (keyedMatch?.[1]) {
return keyedMatch[1].trim();
}
return toolName === "read" ? tail : undefined;
}
function resolveToolPathCandidate(
params: {
toolCall?: { rawInput?: unknown };
},
toolName: string | undefined,
toolTitle: string | undefined,
): string | undefined {
const rawInput = asRecord(params.toolCall?.rawInput);
return (
readFirstStringValue(rawInput, ["path", "file_path", "filePath"]) ??
extractPathFromToolTitle(toolTitle, toolName)
);
}
function resolveAbsoluteScopedPath(value: string, cwd: string): string | undefined {
let candidate = value.trim();
if (!candidate) {
return undefined;
}
if (candidate.startsWith("file://")) {
try {
const parsed = new URL(candidate);
candidate = decodeURIComponent(parsed.pathname || "");
} catch {
return undefined;
}
}
if (candidate === "~") {
candidate = homedir();
} else if (candidate.startsWith("~/")) {
candidate = path.join(homedir(), candidate.slice(2));
}
return path.isAbsolute(candidate) ? path.normalize(candidate) : path.resolve(cwd, candidate);
}
function isReadToolCallScopedToCwd(
params: { toolCall?: { rawInput?: unknown } },
toolName: string | undefined,
toolTitle: string | undefined,
cwd: string,
): boolean {
if (toolName !== "read") {
return false;
}
const rawPath = resolveToolPathCandidate(params, toolName, toolTitle);
if (!rawPath) {
return false;
}
const absolutePath = resolveAbsoluteScopedPath(rawPath, cwd);
if (!absolutePath) {
return false;
}
const root = path.resolve(cwd);
const relative = path.relative(root, absolutePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
export function classifyAcpToolApproval(params: {
toolCall?: {
title?: string | null;
_meta?: unknown;
rawInput?: unknown;
};
cwd: string;
}): AcpApprovalClassification {
const toolName = resolveToolNameForPermission(params);
if (!toolName) {
return { toolName: undefined, approvalClass: "unknown", autoApprove: false };
}
const isTrustedToolId = isKnownCoreToolId(toolName) || TRUSTED_SAFE_TOOL_ALIASES.has(toolName);
if (toolName === "read" && isTrustedToolId) {
const autoApprove = isReadToolCallScopedToCwd(
params,
toolName,
params.toolCall?.title ?? undefined,
params.cwd,
);
return {
toolName,
approvalClass: autoApprove ? "readonly_scoped" : "other",
autoApprove,
};
}
if (SAFE_SEARCH_TOOL_IDS.has(toolName) && isTrustedToolId) {
return { toolName, approvalClass: "readonly_search", autoApprove: true };
}
const ownerOnlyApprovalClass = resolveOwnerOnlyToolApprovalClass(toolName);
if (ownerOnlyApprovalClass) {
return { toolName, approvalClass: ownerOnlyApprovalClass, autoApprove: false };
}
if (EXEC_CAPABLE_TOOL_IDS.has(toolName)) {
return { toolName, approvalClass: "exec_capable", autoApprove: false };
}
if (CONTROL_PLANE_TOOL_IDS.has(toolName)) {
return { toolName, approvalClass: "control_plane", autoApprove: false };
}
if (isMutatingToolCall(toolName, params.toolCall?.rawInput)) {
return { toolName, approvalClass: "mutating", autoApprove: false };
}
return { toolName, approvalClass: "other", autoApprove: false };
}