fix(agent): stabilize large tool results
Generic Container CI/CD / test-build-publish (push) Successful in 1m53s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m53s

This commit is contained in:
2026-08-25 12:02:48 +08:00
parent 774f39cbbe
commit 004c9bb72d
17 changed files with 721 additions and 62 deletions
+168 -2
View File
@@ -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)
);
};