fix(agent): stabilize large tool results
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
import { lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
export type ApprovalMode = "request" | "auto" | "always";
|
||||
|
||||
export type PermissionApprovalContext = {
|
||||
metadata?: Record<string, unknown>;
|
||||
patterns?: readonly string[];
|
||||
workspaceRoot?: string;
|
||||
};
|
||||
|
||||
const lowRiskToolPermissions = new Set([
|
||||
"apply_layer_style",
|
||||
"geocode",
|
||||
@@ -12,10 +21,31 @@ const lowRiskToolPermissions = new Set([
|
||||
"zoom_to_map",
|
||||
]);
|
||||
|
||||
const lowRiskSearchRootNames = new Set([
|
||||
".opencode",
|
||||
"cli",
|
||||
"contracts",
|
||||
"node-tests",
|
||||
"scripts",
|
||||
"src",
|
||||
"tests",
|
||||
]);
|
||||
|
||||
const normalizePermission = (permission: string) => permission.trim().toLowerCase();
|
||||
|
||||
export const canAutoApprovePermission = (permission: string): boolean => {
|
||||
export const canAutoApprovePermission = (
|
||||
permission: string,
|
||||
context: PermissionApprovalContext = {},
|
||||
): boolean => {
|
||||
const normalized = normalizePermission(permission);
|
||||
if (normalized === "skill") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === "glob" || normalized === "grep") {
|
||||
return isSafeWorkspaceSearch(normalized, context);
|
||||
}
|
||||
|
||||
if (lowRiskToolPermissions.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
@@ -30,6 +60,7 @@ export const canAutoApprovePermission = (permission: string): boolean => {
|
||||
export const resolvePermissionApproval = (
|
||||
approvalMode: ApprovalMode,
|
||||
permission: string,
|
||||
context: PermissionApprovalContext = {},
|
||||
) => {
|
||||
if (approvalMode === "always") {
|
||||
return {
|
||||
@@ -40,7 +71,7 @@ export const resolvePermissionApproval = (
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (approvalMode === "auto" && canAutoApprovePermission(permission)) {
|
||||
if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) {
|
||||
return {
|
||||
autoApprove: true,
|
||||
title: "已自动批准低风险权限",
|
||||
@@ -54,3 +85,138 @@ export const resolvePermissionApproval = (
|
||||
detail: undefined,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const isSafeWorkspaceSearch = (
|
||||
permission: "glob" | "grep",
|
||||
context: PermissionApprovalContext,
|
||||
): boolean => {
|
||||
const workspaceRoot = context.workspaceRoot?.trim();
|
||||
if (!workspaceRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestedPath =
|
||||
typeof context.metadata?.path === "string" && context.metadata.path.trim()
|
||||
? context.metadata.path
|
||||
: workspaceRoot;
|
||||
let root: string;
|
||||
let searchRoot: string;
|
||||
try {
|
||||
root = realpathSync.native(resolve(workspaceRoot));
|
||||
searchRoot = realpathSync.native(resolve(root, requestedPath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
let relativePath = relative(root, searchRoot);
|
||||
if (
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativePath)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expressions: string[] = [];
|
||||
if (permission === "glob" && typeof context.metadata?.pattern === "string") {
|
||||
expressions.push(context.metadata.pattern);
|
||||
}
|
||||
if (permission === "glob") {
|
||||
expressions.push(...(context.patterns ?? []));
|
||||
}
|
||||
if (typeof context.metadata?.include === "string") {
|
||||
expressions.push(context.metadata.include);
|
||||
}
|
||||
if (
|
||||
expressions.some(
|
||||
(expression) =>
|
||||
isAbsolute(expression) ||
|
||||
containsParentTraversal(expression) ||
|
||||
containsAmbiguousGlobSyntax(expression) ||
|
||||
containsProtectedPath(expression),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!relativePath) {
|
||||
if (permission !== "glob") {
|
||||
return false;
|
||||
}
|
||||
const literalPrefix = getLiteralGlobPrefix(expressions[0]);
|
||||
if (!literalPrefix) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
searchRoot = realpathSync.native(resolve(root, literalPrefix));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
relativePath = relative(root, searchRoot);
|
||||
}
|
||||
|
||||
return (
|
||||
relativePath !== "" &&
|
||||
relativePath !== ".." &&
|
||||
!relativePath.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(relativePath) &&
|
||||
!containsProtectedPath(relativePath) &&
|
||||
isSafeSearchTarget(searchRoot, relativePath)
|
||||
);
|
||||
};
|
||||
|
||||
const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean => {
|
||||
try {
|
||||
const target = lstatSync(searchRoot);
|
||||
if (target.isFile()) {
|
||||
return true;
|
||||
}
|
||||
if (!target.isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
const topLevelName = relativePath.split(sep)[0];
|
||||
if (!topLevelName || !lowRiskSearchRootNames.has(topLevelName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pending = [searchRoot];
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop()!;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isSymbolicLink() || containsProtectedPath(entry.name)) {
|
||||
return false;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(resolve(directory, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getLiteralGlobPrefix = (expression: string | undefined): string | null => {
|
||||
const firstSegment = expression
|
||||
?.replaceAll("\\", "/")
|
||||
.replace(/^\.\//, "")
|
||||
.split("/")[0];
|
||||
return firstSegment && !/[*?[\]{}()!+@]/.test(firstSegment)
|
||||
? firstSegment
|
||||
: null;
|
||||
};
|
||||
|
||||
const containsParentTraversal = (value: string): boolean =>
|
||||
value.replaceAll("\\", "/").split("/").includes("..");
|
||||
|
||||
const containsAmbiguousGlobSyntax = (value: string): boolean =>
|
||||
/[?[\]{}()!+@\\]/.test(value);
|
||||
|
||||
const containsProtectedPath = (value: string): boolean => {
|
||||
const normalized = value.replaceAll("\\", "/").toLowerCase();
|
||||
return (
|
||||
normalized.includes(".env") ||
|
||||
/(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized)
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user