371 lines
9.8 KiB
TypeScript
371 lines
9.8 KiB
TypeScript
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",
|
|
"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 (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" &&
|
|
normalizePermission(permission) === "bash" &&
|
|
containsPotentialRemoveCommand(context)
|
|
) {
|
|
return {
|
|
autoApprove: false,
|
|
autoReject: false,
|
|
title: "等待删除命令确认",
|
|
detail: "删除命令不会由始终允许模式代为批准,请确认本次具体操作。",
|
|
} 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 containsPotentialRemoveCommand = (
|
|
context: PermissionApprovalContext,
|
|
): boolean => {
|
|
const command =
|
|
typeof context.metadata?.command === "string"
|
|
? context.metadata.command
|
|
: context.patterns?.join("\n");
|
|
if (!command) {
|
|
return false;
|
|
}
|
|
const normalized = command
|
|
.replace(/\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu, "")
|
|
.replace(/["'\\]/gu, "");
|
|
return /(^|[^A-Za-z0-9_])(?:[^\s/]+\/)*rm(?=$|[^A-Za-z0-9_])/iu.test(normalized);
|
|
};
|
|
|
|
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),
|
|
)
|
|
) {
|
|
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)
|
|
);
|
|
};
|