Files
TJWaterAgent/src/routes/chatPermissionPolicy.ts
T
jiang ce04704af2
Generic Container CI/CD / test-build-publish (push) Successful in 2m2s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m2s
fix(agent): isolate conversation workspaces
2026-08-25 13:18:39 +08:00

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