mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 01:01:13 +00:00
229 lines
6.7 KiB
TypeScript
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 };
|
|
}
|