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; 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) ); };