421 lines
11 KiB
TypeScript
421 lines
11 KiB
TypeScript
import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
|
|
import { basename, dirname, 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",
|
|
"locate_features",
|
|
"render_junctions",
|
|
"show_chart",
|
|
"view_history",
|
|
"view_scada",
|
|
"web_search",
|
|
"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,
|
|
context: PermissionApprovalContext = {},
|
|
): boolean => {
|
|
const normalized = normalizePermission(permission);
|
|
if (normalized === "skill") {
|
|
return true;
|
|
}
|
|
|
|
if (normalized === "glob" || normalized === "grep") {
|
|
return isSafeWorkspaceSearch(normalized, context);
|
|
}
|
|
|
|
if (normalized === "bash") {
|
|
return isConversationWorkspace(context.workspaceRoot);
|
|
}
|
|
|
|
if (normalized === "read" || normalized === "edit" || normalized === "write") {
|
|
return isSafeConversationFileAccess(context);
|
|
}
|
|
|
|
if (lowRiskToolPermissions.has(normalized)) {
|
|
return true;
|
|
}
|
|
|
|
if (normalized.startsWith("tjwater_")) {
|
|
return lowRiskToolPermissions.has(normalized.slice("tjwater_".length));
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
export const resolvePermissionApproval = (
|
|
approvalMode: ApprovalMode,
|
|
permission: string,
|
|
context: PermissionApprovalContext = {},
|
|
) => {
|
|
if (isDirectRecursiveForceRemove(permission, context)) {
|
|
return {
|
|
autoApprove: false,
|
|
autoReject: true,
|
|
title: "已拒绝递归强制删除",
|
|
detail: "当前安全策略禁止直接执行带 recursive 和 force 参数的 rm 命令。",
|
|
} as const;
|
|
}
|
|
|
|
if (approvalMode === "always") {
|
|
return {
|
|
autoApprove: true,
|
|
autoReject: false,
|
|
title: "已按始终允许模式放行",
|
|
detail:
|
|
"当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。",
|
|
} as const;
|
|
}
|
|
|
|
if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) {
|
|
return {
|
|
autoApprove: true,
|
|
autoReject: false,
|
|
title: "已自动批准低风险权限",
|
|
detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。",
|
|
} as const;
|
|
}
|
|
|
|
return {
|
|
autoApprove: false,
|
|
autoReject: false,
|
|
title: "等待权限确认",
|
|
detail: undefined,
|
|
} as const;
|
|
};
|
|
|
|
const isDirectRecursiveForceRemove = (
|
|
permission: string,
|
|
context: PermissionApprovalContext,
|
|
): boolean => {
|
|
if (normalizePermission(permission) !== "bash") {
|
|
return false;
|
|
}
|
|
const command =
|
|
typeof context.metadata?.command === "string"
|
|
? context.metadata.command
|
|
: context.patterns?.join("\n");
|
|
if (!command) {
|
|
return false;
|
|
}
|
|
|
|
return splitShellCommandSegments(command).some((segment) => {
|
|
const words = tokenizeShellSegment(segment);
|
|
let commandIndex = 0;
|
|
while (commandIndex < words.length) {
|
|
const word = words[commandIndex]!;
|
|
const executable = word.split("/").at(-1)?.toLowerCase();
|
|
if (word === "!" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
commandIndex += 1;
|
|
continue;
|
|
}
|
|
if (executable === "command") {
|
|
commandIndex += 1;
|
|
while (words[commandIndex]?.startsWith("-") && words[commandIndex] !== "--") {
|
|
commandIndex += 1;
|
|
}
|
|
if (words[commandIndex] === "--") commandIndex += 1;
|
|
continue;
|
|
}
|
|
if (executable === "env") {
|
|
commandIndex += 1;
|
|
while (commandIndex < words.length) {
|
|
const envWord = words[commandIndex]!;
|
|
if (envWord === "--") {
|
|
commandIndex += 1;
|
|
break;
|
|
}
|
|
if (envWord === "-u" || envWord === "--unset") {
|
|
commandIndex += 2;
|
|
continue;
|
|
}
|
|
if (
|
|
envWord.startsWith("-") ||
|
|
/^[A-Za-z_][A-Za-z0-9_]*=/.test(envWord)
|
|
) {
|
|
commandIndex += 1;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
if (executable === "sudo" || executable === "doas") {
|
|
commandIndex += 1;
|
|
while (words[commandIndex]?.startsWith("-")) {
|
|
commandIndex += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (executable === "busybox" && words[commandIndex + 1] === "rm") {
|
|
commandIndex += 1;
|
|
}
|
|
break;
|
|
}
|
|
|
|
const executable = words[commandIndex]?.split("/").at(-1)?.toLowerCase();
|
|
if (executable !== "rm") {
|
|
return false;
|
|
}
|
|
|
|
let recursive = false;
|
|
let force = false;
|
|
for (const word of words.slice(commandIndex + 1)) {
|
|
if (word === "--") {
|
|
break;
|
|
}
|
|
if (word === "--recursive") {
|
|
recursive = true;
|
|
} else if (word === "--force") {
|
|
force = true;
|
|
} else if (/^-[^-]/.test(word)) {
|
|
recursive ||= /[rR]/.test(word.slice(1));
|
|
force ||= word.slice(1).includes("f");
|
|
}
|
|
}
|
|
return recursive && force;
|
|
});
|
|
};
|
|
|
|
const splitShellCommandSegments = (command: string): string[] =>
|
|
command.split(/&&|\|\||[;|()\n]/u);
|
|
|
|
const tokenizeShellSegment = (segment: string): string[] =>
|
|
(segment.match(/(?:[^\s"'\\]+|"(?:\\.|[^"])*"|'[^']*')+/gu) ?? []).map(
|
|
(word) => {
|
|
const quoted = word.match(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u);
|
|
return (quoted ? (quoted[1] ?? quoted[2] ?? "") : word).replace(
|
|
/(["'])|\\(.)/gu,
|
|
"$2",
|
|
);
|
|
},
|
|
);
|
|
|
|
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, isConversationWorkspace(root)),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const conversationWorkspace = isConversationWorkspace(root);
|
|
if (!relativePath && !conversationWorkspace) {
|
|
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.startsWith(`..${sep}`) &&
|
|
!isAbsolute(relativePath) &&
|
|
!containsProtectedPath(relativePath, conversationWorkspace) &&
|
|
isSafeSearchTarget(searchRoot, relativePath, conversationWorkspace)
|
|
);
|
|
};
|
|
|
|
const isSafeSearchTarget = (
|
|
searchRoot: string,
|
|
relativePath: string,
|
|
conversationWorkspace: boolean,
|
|
): boolean => {
|
|
try {
|
|
const target = lstatSync(searchRoot);
|
|
if (target.isFile()) {
|
|
return true;
|
|
}
|
|
if (!target.isDirectory()) {
|
|
return false;
|
|
}
|
|
const topLevelName = relativePath.split(sep)[0];
|
|
if (
|
|
!conversationWorkspace &&
|
|
(!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, conversationWorkspace)
|
|
) {
|
|
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,
|
|
conversationWorkspace = false,
|
|
): boolean => {
|
|
const normalized = value.replaceAll("\\", "/").toLowerCase();
|
|
return (
|
|
normalized.includes(".env") ||
|
|
(!conversationWorkspace &&
|
|
/(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized))
|
|
);
|
|
};
|
|
|
|
const isConversationWorkspace = (workspaceRoot: string | undefined): boolean => {
|
|
if (!workspaceRoot?.trim()) return false;
|
|
try {
|
|
const root = realpathSync.native(resolve(workspaceRoot));
|
|
return (
|
|
basename(dirname(root)) === "conversation-workspaces" &&
|
|
basename(root).startsWith("conversation-") &&
|
|
lstatSync(root).isDirectory()
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const isSafeConversationFileAccess = (
|
|
context: PermissionApprovalContext,
|
|
): boolean => {
|
|
if (!isConversationWorkspace(context.workspaceRoot)) return false;
|
|
const requestedPath = [
|
|
context.metadata?.path,
|
|
context.metadata?.file_path,
|
|
context.metadata?.filePath,
|
|
context.metadata?.filepath,
|
|
context.metadata?.file,
|
|
context.patterns?.[0],
|
|
].find((value): value is string => typeof value === "string" && value.trim().length > 0);
|
|
if (!requestedPath || /[*?[\]{}]/.test(requestedPath)) return false;
|
|
try {
|
|
const root = realpathSync.native(resolve(context.workspaceRoot!));
|
|
const target = resolve(root, requestedPath);
|
|
let existingPath = target;
|
|
while (!existsSync(existingPath)) {
|
|
const parent = dirname(existingPath);
|
|
if (parent === existingPath) return false;
|
|
existingPath = parent;
|
|
}
|
|
const resolvedExistingPath = realpathSync.native(existingPath);
|
|
const relativeExisting = relative(root, resolvedExistingPath);
|
|
if (
|
|
relativeExisting === ".." ||
|
|
relativeExisting.startsWith(`..${sep}`) ||
|
|
isAbsolute(relativeExisting)
|
|
) {
|
|
return false;
|
|
}
|
|
const relativeTarget = relative(root, target);
|
|
return (
|
|
relativeTarget !== ".." &&
|
|
!relativeTarget.startsWith(`..${sep}`) &&
|
|
!isAbsolute(relativeTarget) &&
|
|
!containsProtectedPath(relativeTarget, true)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|