feat: 添加权限管理系统

- 实现 tree-sitter 解析 bash 命令,准确识别管道、&&、子shell 等复杂命令
- 新增权限检查器模式,支持 allow/deny/ask 三级权限控制
- BashPermissionChecker: 支持命令模式匹配和外部目录访问检测
- FilePermissionChecker: 支持文件操作分级(read/write/edit/list/search/delete)
- 敏感路径规则:系统目录拒绝,SSH/AWS 等凭证目录需确认
- 会话级权限记忆,用户决定可在当前会话内生效
- 所有工具(bash、read_file、write_file、edit_file、list_directory、search_files)已集成权限检查
This commit is contained in:
2025-12-10 18:07:50 +08:00
parent af1185c4d7
commit 60a046357b
19 changed files with 1560 additions and 16 deletions
+55
View File
@@ -16,6 +16,8 @@
"commander": "^12.1.0",
"inquirer": "^12.0.0",
"ora": "^8.1.0",
"tree-sitter-bash": "^0.25.1",
"web-tree-sitter": "^0.25.10",
"zod": "^4.1.13"
},
"bin": {
@@ -1268,6 +1270,26 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
@@ -1412,6 +1434,25 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/tree-sitter-bash": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/tree-sitter-bash/-/tree-sitter-bash-0.25.1.tgz",
"integrity": "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.2.1",
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.25.0"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1459,6 +1500,20 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/web-tree-sitter": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz",
"integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==",
"license": "MIT",
"peerDependencies": {
"@types/emscripten": "^1.40.0"
},
"peerDependenciesMeta": {
"@types/emscripten": {
"optional": true
}
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+2
View File
@@ -30,6 +30,8 @@
"commander": "^12.1.0",
"inquirer": "^12.0.0",
"ora": "^8.1.0",
"tree-sitter-bash": "^0.25.1",
"web-tree-sitter": "^0.25.10",
"zod": "^4.1.13"
},
"devDependencies": {
+9
View File
@@ -5,6 +5,7 @@ import { Agent } from './core/agent.js';
import { TerminalUI } from './ui/terminal.js';
import { loadConfig, initConfig } from './utils/config.js';
import { allTools } from './tools/index.js';
import { getPermissionManager, promptPermission } from './permission/index.js';
const program = new Command();
@@ -21,11 +22,18 @@ program
await initConfig();
});
// 初始化权限系统
function setupPermissions(): void {
const permissionManager = getPermissionManager();
permissionManager.setAskCallback(promptPermission);
}
// 单次查询命令
program
.command('ask <question>')
.description('单次提问(不进入交互模式)')
.action(async (question: string) => {
setupPermissions();
const config = loadConfig();
const agent = new Agent(config);
@@ -48,6 +56,7 @@ program
// 默认:交互模式
program.action(async () => {
setupPermissions();
const config = loadConfig();
const agent = new Agent(config);
+225
View File
@@ -0,0 +1,225 @@
import { Parser, Language, type Node } from 'web-tree-sitter';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 解析后的命令结构
export interface ParsedCommand {
name: string; // 命令名,如 "git"
subcommand?: string; // 子命令,如 "push"
args: string[]; // 参数列表
text: string; // 原始命令文本
}
// 解析结果
export interface ParseResult {
commands: ParsedCommand[]; // 所有解析出的命令
success: boolean;
error?: string;
}
// 单例解析器
let parserInstance: Parser | null = null;
let bashLanguage: Language | null = null;
let initPromise: Promise<void> | null = null;
/**
* 获取 wasm 文件路径
*/
function getWasmPath(filename: string): string {
// 从 node_modules 加载
const nodeModulesPath = path.resolve(__dirname, '../../node_modules');
if (filename === 'tree-sitter.wasm') {
return path.join(nodeModulesPath, 'web-tree-sitter', filename);
} else if (filename === 'tree-sitter-bash.wasm') {
return path.join(nodeModulesPath, 'tree-sitter-bash', filename);
}
throw new Error(`Unknown wasm file: ${filename}`);
}
/**
* 初始化解析器(懒加载,只初始化一次)
*/
async function initParser(): Promise<void> {
if (parserInstance && bashLanguage) {
return;
}
if (initPromise) {
return initPromise;
}
initPromise = (async () => {
try {
// 初始化 tree-sitter
await Parser.init({
locateFile: (scriptName: string) => {
return getWasmPath(scriptName);
},
});
// 创建解析器实例
parserInstance = new Parser();
// 加载 bash 语言
const bashWasmPath = getWasmPath('tree-sitter-bash.wasm');
bashLanguage = await Language.load(bashWasmPath);
parserInstance.setLanguage(bashLanguage);
} catch (error) {
initPromise = null;
throw error;
}
})();
return initPromise;
}
/**
* 从语法树节点中提取命令信息
*/
function extractCommandFromNode(node: Node): ParsedCommand {
const parts: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
// 提取命令名和参数
if (
child.type === 'command_name' ||
child.type === 'word' ||
child.type === 'string' ||
child.type === 'raw_string' ||
child.type === 'concatenation' ||
child.type === 'simple_expansion' ||
child.type === 'expansion'
) {
// 对于字符串类型,提取内部文本(去掉引号)
if (child.type === 'string' || child.type === 'raw_string') {
const text = child.text;
if ((text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))) {
parts.push(text.slice(1, -1));
} else {
parts.push(text);
}
} else {
parts.push(child.text);
}
}
}
const name = parts[0] || '';
// 找到第一个非 flag 参数作为子命令
let subcommand: string | undefined;
const args: string[] = [];
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
if (!subcommand && !part.startsWith('-')) {
subcommand = part;
} else {
args.push(part);
}
}
return {
name,
subcommand,
args,
text: node.text,
};
}
/**
* 递归查找所有命令节点
*/
function findCommandNodes(node: Node): Node[] {
const commands: Node[] = [];
if (node.type === 'command') {
commands.push(node);
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) {
commands.push(...findCommandNodes(child));
}
}
return commands;
}
/**
* 解析 bash 命令字符串
*/
export async function parseBashCommand(command: string): Promise<ParseResult> {
try {
await initParser();
if (!parserInstance) {
return {
commands: [],
success: false,
error: 'Parser not initialized',
};
}
const tree = parserInstance.parse(command);
if (!tree) {
return {
commands: [],
success: false,
error: 'Failed to parse command',
};
}
const commandNodes = findCommandNodes(tree.rootNode);
const commands = commandNodes.map(extractCommandFromNode);
return {
commands,
success: true,
};
} catch (error) {
return {
commands: [],
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* 简单解析(用于降级,当 tree-sitter 不可用时)
*/
export function parseCommandSimple(command: string): ParsedCommand {
const parts = command.trim().split(/\s+/);
const name = parts[0] || '';
let subcommand: string | undefined;
const args: string[] = [];
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
if (!subcommand && !part.startsWith('-')) {
subcommand = part;
} else {
args.push(part);
}
}
return { name, subcommand, args, text: command };
}
/**
* 检查解析器是否已初始化
*/
export function isParserInitialized(): boolean {
return parserInstance !== null && bashLanguage !== null;
}
+34
View File
@@ -0,0 +1,34 @@
import type { PermissionCheckResult, PermissionContext } from '../types.js';
/**
* 权限检查器基础接口
* 所有工具的权限检查器都应该实现此接口
*/
export interface PermissionChecker {
/**
* 检查器名称
*/
readonly name: string;
/**
* 检查权限
* @param ctx 权限检查上下文
* @returns 权限检查结果
*/
check(ctx: PermissionContext): Promise<PermissionCheckResult>;
/**
* 清除会话级别的临时权限
*/
clearSessionPermissions(): void;
}
/**
* 权限检查器配置基类
*/
export interface BasePermissionConfig {
/**
* 默认权限动作
*/
default: 'allow' | 'deny' | 'ask';
}
+305
View File
@@ -0,0 +1,305 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import type {
BashPermissionConfig,
PermissionContext,
PermissionCheckResult,
PermissionDecision,
PermissionRule,
} from '../types.js';
import type { PermissionChecker } from './base.js';
import { matchRulesAsync } from '../wildcard.js';
import { parseBashCommand } from '../bash-parser.js';
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
const PERMISSION_FILE = path.join(CONFIG_DIR, 'permissions.json');
// 默认权限配置
const DEFAULT_CONFIG: BashPermissionConfig = {
rules: [
// 默认允许的安全命令
{ pattern: 'ls *', action: 'allow' },
{ pattern: 'cat *', action: 'allow' },
{ pattern: 'head *', action: 'allow' },
{ pattern: 'tail *', action: 'allow' },
{ pattern: 'grep *', action: 'allow' },
{ pattern: 'find *', action: 'allow' },
{ pattern: 'echo *', action: 'allow' },
{ pattern: 'pwd', action: 'allow' },
{ pattern: 'which *', action: 'allow' },
{ pattern: 'type *', action: 'allow' },
{ pattern: 'git status', action: 'allow' },
{ pattern: 'git log *', action: 'allow' },
{ pattern: 'git diff *', action: 'allow' },
{ pattern: 'git branch *', action: 'allow' },
{ pattern: 'npm list *', action: 'allow' },
{ pattern: 'npm run *', action: 'ask' },
// 需要确认的命令
{ pattern: 'git push *', action: 'ask' },
{ pattern: 'git commit *', action: 'ask' },
{ pattern: 'git checkout *', action: 'ask' },
{ pattern: 'git reset *', action: 'ask' },
{ pattern: 'npm install *', action: 'ask' },
{ pattern: 'npm uninstall *', action: 'ask' },
{ pattern: 'yarn *', action: 'ask' },
{ pattern: 'pnpm *', action: 'ask' },
// 危险命令 - 默认拒绝
{ pattern: 'rm -rf *', action: 'deny' },
{ pattern: 'rm -r *', action: 'ask' },
{ pattern: 'sudo *', action: 'deny' },
{ pattern: 'chmod 777 *', action: 'deny' },
{ pattern: 'mkfs *', action: 'deny' },
{ pattern: 'dd *', action: 'deny' },
{ pattern: '> /dev/*', action: 'deny' },
],
externalDirectory: 'ask',
default: 'ask',
};
/**
* Bash 命令权限检查器
* 使用 tree-sitter 解析命令并检查权限
*/
export class BashPermissionChecker implements PermissionChecker {
readonly name = 'bash';
private config: BashPermissionConfig;
private projectRoot: string;
private askCallback?: (ctx: PermissionContext) => Promise<PermissionDecision>;
private sessionPermissions = new Map<string, 'allow' | 'deny'>();
constructor(projectRoot: string = process.cwd()) {
this.projectRoot = path.resolve(projectRoot);
this.config = this.loadConfig();
}
/**
* 设置权限询问回调
*/
setAskCallback(callback: (ctx: PermissionContext) => Promise<PermissionDecision>): void {
this.askCallback = callback;
}
/**
* 加载权限配置
*/
private loadConfig(): BashPermissionConfig {
try {
if (fs.existsSync(PERMISSION_FILE)) {
const content = fs.readFileSync(PERMISSION_FILE, 'utf-8');
const stored = JSON.parse(content) as Partial<BashPermissionConfig>;
return {
rules: stored.rules || DEFAULT_CONFIG.rules,
externalDirectory: stored.externalDirectory || DEFAULT_CONFIG.externalDirectory,
default: stored.default || DEFAULT_CONFIG.default,
};
}
} catch {
// 忽略加载错误
}
return { ...DEFAULT_CONFIG };
}
/**
* 保存权限配置
*/
saveConfig(): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
fs.writeFileSync(PERMISSION_FILE, JSON.stringify(this.config, null, 2));
}
/**
* 添加权限规则
*/
addRule(rule: PermissionRule): void {
const existingIndex = this.config.rules.findIndex(r => r.pattern === rule.pattern);
if (existingIndex >= 0) {
this.config.rules[existingIndex] = rule;
} else {
this.config.rules.unshift(rule);
}
this.saveConfig();
}
/**
* 检查路径是否在项目目录内
*/
private isInProjectDirectory(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
return resolved.startsWith(this.projectRoot + path.sep) || resolved === this.projectRoot;
}
/**
* 从解析后的命令中提取可能的外部路径
*/
private async extractPathsFromCommand(command: string, workdir: string): Promise<string[]> {
const externalPaths: string[] = [];
const parseResult = await parseBashCommand(command);
const pathCommands = new Set(['cd', 'rm', 'cp', 'mv', 'mkdir', 'touch', 'chmod', 'chown', 'cat', 'ls']);
for (const cmd of parseResult.commands) {
if (!pathCommands.has(cmd.name)) continue;
const pathsToCheck = [cmd.subcommand, ...cmd.args].filter(Boolean) as string[];
for (const arg of pathsToCheck) {
if (arg.startsWith('-')) continue;
let resolved: string | null = null;
if (arg.startsWith('/')) {
resolved = arg;
} else if (arg.startsWith('~')) {
resolved = arg.replace('~', os.homedir());
} else if (arg.includes('..') || arg.includes('/')) {
resolved = path.resolve(workdir, arg);
}
if (resolved && !this.isInProjectDirectory(resolved)) {
externalPaths.push(resolved);
}
}
}
return [...new Set(externalPaths)];
}
/**
* 检查命令权限
*/
async check(ctx: PermissionContext): Promise<PermissionCheckResult> {
const { command, workdir } = ctx;
// 1. 使用 tree-sitter 解析命令并检查权限
const parseResult = await matchRulesAsync(command, this.config.rules, this.config.default);
// 2. 检查会话级别的临时权限
for (const pattern of parseResult.askPatterns) {
const sessionPerm = this.sessionPermissions.get(pattern);
if (sessionPerm === 'deny') {
return {
allowed: false,
action: 'deny',
reason: `本次会话已拒绝此类命令: ${pattern}`,
};
}
if (sessionPerm === 'allow') {
const allAllowed = parseResult.askPatterns.every(p => this.sessionPermissions.get(p) === 'allow');
if (allAllowed && parseResult.action === 'ask') {
return {
allowed: true,
action: 'allow',
reason: '本次会话已允许此类命令',
};
}
}
}
// 3. 检查外部目录访问
const externalPaths = await this.extractPathsFromCommand(command, workdir);
if (externalPaths.length > 0) {
const extAction = this.config.externalDirectory;
if (extAction === 'deny') {
return {
allowed: false,
action: 'deny',
reason: `命令访问项目目录外的路径: ${externalPaths.join(', ')}`,
};
}
if (extAction === 'ask') {
if (!this.askCallback) {
return {
allowed: false,
action: 'ask',
needsConfirmation: true,
reason: `命令访问项目目录外的路径`,
patterns: externalPaths,
};
}
const decision = await this.askCallback({
...ctx,
externalPaths,
});
if (!decision.allow) {
return {
allowed: false,
action: 'deny',
reason: '用户拒绝访问外部目录',
};
}
}
}
// 4. 根据解析结果处理权限
const { action, matchedPattern, allCommands, askPatterns } = parseResult;
if (action === 'allow') {
return {
allowed: true,
action: 'allow',
reason: matchedPattern ? `匹配规则: ${matchedPattern}` : '默认允许',
};
}
if (action === 'deny') {
return {
allowed: false,
action: 'deny',
reason: matchedPattern
? `匹配规则: ${matchedPattern}`
: `包含被拒绝的命令: ${allCommands.map(c => c.name).join(', ')}`,
};
}
// action === 'ask'
if (!this.askCallback) {
return {
allowed: false,
action: 'ask',
needsConfirmation: true,
patterns: askPatterns,
};
}
const decision = await this.askCallback({
...ctx,
patterns: askPatterns,
});
if (decision.remember) {
for (const pattern of askPatterns) {
this.sessionPermissions.set(pattern, decision.allow ? 'allow' : 'deny');
}
}
return {
allowed: decision.allow,
action: decision.allow ? 'allow' : 'deny',
reason: decision.allow ? '用户允许' : '用户拒绝',
};
}
/**
* 清除会话权限
*/
clearSessionPermissions(): void {
this.sessionPermissions.clear();
}
/**
* 获取当前配置
*/
getConfig(): BashPermissionConfig {
return { ...this.config };
}
}
+315
View File
@@ -0,0 +1,315 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import type {
FilePermissionConfig,
FilePermissionContext,
PermissionCheckResult,
PermissionDecision,
PermissionContext,
} from '../types.js';
import type { PermissionChecker } from './base.js';
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
const FILE_PERMISSION_FILE = path.join(CONFIG_DIR, 'file-permissions.json');
// 默认文件权限配置
const DEFAULT_CONFIG: FilePermissionConfig = {
externalDirectory: 'ask',
operations: {
read: 'allow', // 读取默认允许
write: 'ask', // 写入需要确认
edit: 'ask', // 编辑需要确认
list: 'allow', // 列目录默认允许
search: 'allow', // 搜索默认允许
delete: 'ask', // 删除需要确认
},
sensitivePaths: [
// 系统关键路径 - 拒绝
{ pattern: '/etc/*', action: 'deny' },
{ pattern: '/usr/*', action: 'deny' },
{ pattern: '/bin/*', action: 'deny' },
{ pattern: '/sbin/*', action: 'deny' },
{ pattern: '/System/*', action: 'deny' },
{ pattern: '/var/*', action: 'deny' },
{ pattern: 'C:\\Windows\\*', action: 'deny' },
{ pattern: 'C:\\Program Files\\*', action: 'deny' },
// 用户敏感文件 - 需要确认
{ pattern: '*/.ssh/*', action: 'ask' },
{ pattern: '*/.gnupg/*', action: 'ask' },
{ pattern: '*/.aws/*', action: 'ask' },
{ pattern: '*/.kube/*', action: 'ask' },
{ pattern: '*/.env', action: 'ask' },
{ pattern: '*/.env.*', action: 'ask' },
{ pattern: '*/credentials*', action: 'ask' },
{ pattern: '*/secrets*', action: 'ask' },
{ pattern: '*/.git/config', action: 'ask' },
],
};
/**
* 文件操作权限检查器
*/
export class FilePermissionChecker implements PermissionChecker {
readonly name = 'file';
private config: FilePermissionConfig;
private projectRoot: string;
private askCallback?: (ctx: PermissionContext) => Promise<PermissionDecision>;
private sessionPermissions = new Map<string, 'allow' | 'deny'>();
constructor(projectRoot: string = process.cwd()) {
this.projectRoot = path.resolve(projectRoot);
this.config = this.loadConfig();
}
/**
* 设置权限询问回调
*/
setAskCallback(callback: (ctx: PermissionContext) => Promise<PermissionDecision>): void {
this.askCallback = callback;
}
/**
* 加载权限配置
*/
private loadConfig(): FilePermissionConfig {
try {
if (fs.existsSync(FILE_PERMISSION_FILE)) {
const content = fs.readFileSync(FILE_PERMISSION_FILE, 'utf-8');
const stored = JSON.parse(content) as Partial<FilePermissionConfig>;
return {
externalDirectory: stored.externalDirectory || DEFAULT_CONFIG.externalDirectory,
operations: { ...DEFAULT_CONFIG.operations, ...stored.operations },
sensitivePaths: stored.sensitivePaths || DEFAULT_CONFIG.sensitivePaths,
};
}
} catch {
// 忽略加载错误
}
return { ...DEFAULT_CONFIG, operations: { ...DEFAULT_CONFIG.operations } };
}
/**
* 保存权限配置
*/
saveConfig(): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
fs.writeFileSync(FILE_PERMISSION_FILE, JSON.stringify(this.config, null, 2));
}
/**
* 检查路径是否在项目目录内
*/
private isInProjectDirectory(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
return resolved.startsWith(this.projectRoot + path.sep) || resolved === this.projectRoot;
}
/**
* 将通配符模式转换为正则表达式
*/
private patternToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
return new RegExp(`^${escaped}$`, 'i');
}
/**
* 检查路径是否匹配敏感路径规则
*/
private matchSensitivePath(targetPath: string): { matched: boolean; action?: 'allow' | 'deny' | 'ask' } {
const normalizedPath = targetPath.replace(/\\/g, '/');
for (const rule of this.config.sensitivePaths) {
const regex = this.patternToRegex(rule.pattern);
if (regex.test(normalizedPath)) {
return { matched: true, action: rule.action };
}
}
return { matched: false };
}
/**
* 展开路径中的 ~ 符号
*/
private expandTilde(targetPath: string): string {
if (targetPath.startsWith('~/')) {
return path.join(os.homedir(), targetPath.slice(2));
}
if (targetPath === '~') {
return os.homedir();
}
return targetPath;
}
/**
* 检查文件操作权限
*/
async checkFilePermission(ctx: FilePermissionContext): Promise<PermissionCheckResult> {
const { operation, path: targetPath, workdir } = ctx;
// 展开 ~ 并解析绝对路径
const expandedPath = this.expandTilde(targetPath);
const absolutePath = path.isAbsolute(expandedPath)
? expandedPath
: path.resolve(workdir, expandedPath);
// 生成会话权限 key
const sessionKey = `${operation}:${absolutePath}`;
// 1. 检查会话级别的临时权限
const sessionPerm = this.sessionPermissions.get(sessionKey);
if (sessionPerm === 'deny') {
return {
allowed: false,
action: 'deny',
reason: `本次会话已拒绝此操作: ${operation} ${absolutePath}`,
};
}
if (sessionPerm === 'allow') {
return {
allowed: true,
action: 'allow',
reason: '本次会话已允许此操作',
};
}
// 2. 检查敏感路径规则
const sensitiveMatch = this.matchSensitivePath(absolutePath);
if (sensitiveMatch.matched && sensitiveMatch.action) {
if (sensitiveMatch.action === 'deny') {
return {
allowed: false,
action: 'deny',
reason: `路径匹配敏感路径规则,禁止访问: ${absolutePath}`,
};
}
if (sensitiveMatch.action === 'ask') {
return this.handleAskPermission(ctx, absolutePath, sessionKey, '敏感路径需要确认');
}
// allow 则继续检查
}
// 3. 检查外部目录访问
if (!this.isInProjectDirectory(absolutePath)) {
const extAction = this.config.externalDirectory;
if (extAction === 'deny') {
return {
allowed: false,
action: 'deny',
reason: `禁止访问项目目录外的路径: ${absolutePath}`,
};
}
if (extAction === 'ask') {
return this.handleAskPermission(ctx, absolutePath, sessionKey, '访问项目外部路径需要确认');
}
}
// 4. 根据操作类型的默认策略处理
const operationAction = this.config.operations[operation];
if (operationAction === 'allow') {
return {
allowed: true,
action: 'allow',
reason: `${operation} 操作默认允许`,
};
}
if (operationAction === 'deny') {
return {
allowed: false,
action: 'deny',
reason: `${operation} 操作默认拒绝`,
};
}
// operationAction === 'ask'
return this.handleAskPermission(ctx, absolutePath, sessionKey, `${operation} 操作需要确认`);
}
/**
* 处理需要询问的权限
*/
private async handleAskPermission(
ctx: FilePermissionContext,
absolutePath: string,
sessionKey: string,
reason: string
): Promise<PermissionCheckResult> {
if (!this.askCallback) {
return {
allowed: false,
action: 'ask',
needsConfirmation: true,
reason,
patterns: [absolutePath],
};
}
// 构造兼容的 PermissionContext
const permCtx: PermissionContext = {
command: `${ctx.operation} ${ctx.path}`,
workdir: ctx.workdir,
patterns: [ctx.operation],
externalPaths: this.isInProjectDirectory(absolutePath) ? undefined : [absolutePath],
};
const decision = await this.askCallback(permCtx);
if (decision.remember) {
this.sessionPermissions.set(sessionKey, decision.allow ? 'allow' : 'deny');
}
return {
allowed: decision.allow,
action: decision.allow ? 'allow' : 'deny',
reason: decision.allow ? '用户允许' : '用户拒绝',
};
}
/**
* 实现 PermissionChecker 接口的 check 方法
* 从 PermissionContext 提取文件操作信息
*/
async check(ctx: PermissionContext): Promise<PermissionCheckResult> {
// 从 command 解析操作类型和路径
// 格式: "operation path" 如 "read /path/to/file"
const parts = ctx.command.split(' ');
const operation = parts[0] as FilePermissionContext['operation'];
const targetPath = parts.slice(1).join(' ');
return this.checkFilePermission({
operation,
path: targetPath,
workdir: ctx.workdir,
});
}
/**
* 清除会话权限
*/
clearSessionPermissions(): void {
this.sessionPermissions.clear();
}
/**
* 获取当前配置
*/
getConfig(): FilePermissionConfig {
return {
...this.config,
operations: { ...this.config.operations },
sensitivePaths: [...this.config.sensitivePaths],
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export type { PermissionChecker, BasePermissionConfig } from './base.js';
export { BashPermissionChecker } from './bash.js';
export { FilePermissionChecker } from './file.js';
+22
View File
@@ -0,0 +1,22 @@
export type {
PermissionAction,
PermissionRule,
BashPermissionConfig,
PermissionContext,
PermissionCheckResult,
PermissionDecision,
FileOperation,
FilePermissionContext,
FilePermissionConfig,
} from './types.js';
export { matchPattern, matchRules, parseCommand, generateAskPattern } from './wildcard.js';
export { PermissionManager, getPermissionManager, resetPermissionManager } from './manager.js';
export { promptPermission, showPermissionDenied, showPermissionAllowed } from './prompt.js';
// Checker pattern exports
export type { PermissionChecker, BasePermissionConfig } from './checkers/base.js';
export { BashPermissionChecker } from './checkers/bash.js';
export { FilePermissionChecker } from './checkers/file.js';
+135
View File
@@ -0,0 +1,135 @@
import type {
PermissionContext,
PermissionCheckResult,
PermissionDecision,
FilePermissionContext,
} from './types.js';
import type { PermissionChecker } from './checkers/base.js';
import { BashPermissionChecker } from './checkers/bash.js';
import { FilePermissionChecker } from './checkers/file.js';
/**
* 权限管理器
* 统一管理所有工具的权限检查
*/
export class PermissionManager {
private checkers = new Map<string, PermissionChecker>();
private askCallback?: (ctx: PermissionContext) => Promise<PermissionDecision>;
constructor(projectRoot: string = process.cwd()) {
// 注册默认的检查器
this.registerChecker(new BashPermissionChecker(projectRoot));
this.registerChecker(new FilePermissionChecker(projectRoot));
}
/**
* 注册权限检查器
*/
registerChecker(checker: PermissionChecker): void {
this.checkers.set(checker.name, checker);
// 如果检查器支持设置回调,传递当前的回调
if (this.askCallback && 'setAskCallback' in checker) {
(checker as BashPermissionChecker).setAskCallback(this.askCallback);
}
}
/**
* 获取权限检查器
*/
getChecker<T extends PermissionChecker>(name: string): T | undefined {
return this.checkers.get(name) as T | undefined;
}
/**
* 设置权限询问回调
*/
setAskCallback(callback: (ctx: PermissionContext) => Promise<PermissionDecision>): void {
this.askCallback = callback;
// 传递给所有支持回调的检查器
for (const checker of this.checkers.values()) {
if ('setAskCallback' in checker) {
(checker as BashPermissionChecker).setAskCallback(callback);
}
}
}
/**
* 检查权限(使用指定的检查器)
*/
async checkPermission(
checkerName: string,
ctx: PermissionContext
): Promise<PermissionCheckResult> {
const checker = this.checkers.get(checkerName);
if (!checker) {
// 未注册的检查器,默认需要确认
return {
allowed: false,
action: 'ask',
needsConfirmation: true,
reason: `未找到检查器: ${checkerName}`,
};
}
return checker.check(ctx);
}
/**
* 检查 bash 命令权限(便捷方法)
*/
async checkBashPermission(ctx: PermissionContext): Promise<PermissionCheckResult> {
return this.checkPermission('bash', ctx);
}
/**
* 检查文件操作权限(便捷方法)
*/
async checkFilePermission(ctx: FilePermissionContext): Promise<PermissionCheckResult> {
const fileChecker = this.getChecker<FilePermissionChecker>('file');
if (!fileChecker) {
return {
allowed: false,
action: 'ask',
needsConfirmation: true,
reason: '文件权限检查器未注册',
};
}
return fileChecker.checkFilePermission(ctx);
}
/**
* 清除所有检查器的会话权限
*/
clearAllSessionPermissions(): void {
for (const checker of this.checkers.values()) {
checker.clearSessionPermissions();
}
}
/**
* 清除指定检查器的会话权限
*/
clearSessionPermissions(checkerName: string): void {
const checker = this.checkers.get(checkerName);
if (checker) {
checker.clearSessionPermissions();
}
}
}
// 全局实例
let globalManager: PermissionManager | null = null;
export function getPermissionManager(projectRoot?: string): PermissionManager {
if (!globalManager) {
globalManager = new PermissionManager(projectRoot);
}
return globalManager;
}
export function resetPermissionManager(): void {
globalManager = null;
}
+79
View File
@@ -0,0 +1,79 @@
import * as readline from 'readline';
import chalk from 'chalk';
import type { PermissionContext, PermissionDecision } from './types.js';
/**
* 在终端中提示用户确认权限
*/
export async function promptPermission(ctx: PermissionContext): Promise<PermissionDecision> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
console.log('');
console.log(chalk.yellow('⚠️ 权限确认'));
console.log(chalk.cyan('命令: ') + chalk.white(ctx.command));
console.log(chalk.cyan('目录: ') + chalk.gray(ctx.workdir));
if (ctx.externalPaths && ctx.externalPaths.length > 0) {
console.log(chalk.red('⚠️ 此命令访问项目目录外的路径:'));
ctx.externalPaths.forEach(p => {
console.log(chalk.red(' • ') + chalk.gray(p));
});
}
if (ctx.patterns && ctx.patterns.length > 0) {
console.log(chalk.gray('匹配模式: ') + ctx.patterns.join(', '));
}
console.log('');
console.log(chalk.white('选择操作:'));
console.log(chalk.green(' [y] ') + '允许执行');
console.log(chalk.green(' [Y] ') + '允许执行,并记住此类命令(本次会话)');
console.log(chalk.red(' [n] ') + '拒绝执行');
console.log(chalk.red(' [N] ') + '拒绝执行,并记住此类命令(本次会话)');
console.log('');
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
rl.close();
const choice = answer.trim();
switch (choice) {
case 'y':
resolve({ allow: true, remember: false });
break;
case 'Y':
resolve({ allow: true, remember: true });
break;
case 'N':
resolve({ allow: false, remember: true });
break;
case 'n':
default:
resolve({ allow: false, remember: false });
break;
}
});
});
}
/**
* 显示权限被拒绝的消息
*/
export function showPermissionDenied(command: string, reason: string): void {
console.log('');
console.log(chalk.red('🚫 权限被拒绝'));
console.log(chalk.cyan('命令: ') + chalk.white(command));
console.log(chalk.cyan('原因: ') + chalk.gray(reason));
console.log('');
}
/**
* 显示权限允许的消息
*/
export function showPermissionAllowed(command: string): void {
console.log(chalk.green('✓ ') + chalk.gray(`执行: ${command}`));
}
+71
View File
@@ -0,0 +1,71 @@
// 权限动作类型
export type PermissionAction = 'allow' | 'deny' | 'ask';
// 单条权限规则
export interface PermissionRule {
pattern: string; // 匹配模式,如 "git push *", "rm *"
action: PermissionAction;
}
// Bash 命令权限配置
export interface BashPermissionConfig {
// 命令规则列表,按顺序匹配
rules: PermissionRule[];
// 外部目录访问策略
externalDirectory: PermissionAction;
// 默认策略(没有规则匹配时)
default: PermissionAction;
}
// 权限请求上下文
export interface PermissionContext {
command: string;
workdir: string;
patterns?: string[]; // 匹配到的模式
externalPaths?: string[]; // 访问的外部路径
}
// 文件操作类型
export type FileOperation = 'read' | 'write' | 'edit' | 'list' | 'search' | 'delete';
// 文件权限请求上下文
export interface FilePermissionContext {
operation: FileOperation;
path: string; // 目标路径
workdir: string; // 当前工作目录
}
// 文件权限配置
export interface FilePermissionConfig {
// 外部目录访问策略
externalDirectory: PermissionAction;
// 各操作的默认策略
operations: {
read: PermissionAction;
write: PermissionAction;
edit: PermissionAction;
list: PermissionAction;
search: PermissionAction;
delete: PermissionAction;
};
// 敏感路径规则(优先于操作默认策略)
sensitivePaths: {
pattern: string;
action: PermissionAction;
}[];
}
// 权限检查结果
export interface PermissionCheckResult {
allowed: boolean;
action: PermissionAction;
reason?: string;
needsConfirmation?: boolean;
patterns?: string[];
}
// 用户权限决定(用于 ask 时的回调)
export interface PermissionDecision {
allow: boolean;
remember?: boolean; // 是否记住这个决定
}
+157
View File
@@ -0,0 +1,157 @@
import type { PermissionAction, PermissionRule } from './types.js';
import { parseBashCommand, parseCommandSimple, type ParsedCommand } from './bash-parser.js';
/**
* 将通配符模式转换为正则表达式
* 支持 * 匹配任意字符
*/
function patternToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // 转义特殊字符
.replace(/\*/g, '.*') // * -> .*
.replace(/\?/g, '.'); // ? -> .
return new RegExp(`^${escaped}$`, 'i');
}
/**
* 检查命令是否匹配模式
*/
export function matchPattern(command: string, pattern: string): boolean {
const regex = patternToRegex(pattern);
return regex.test(command);
}
/**
* 从命令中提取命令名和子命令(简单版本,用于向后兼容)
*/
export function parseCommand(command: string): { head: string; sub?: string; args: string[] } {
const parsed = parseCommandSimple(command);
return {
head: parsed.name,
sub: parsed.subcommand,
args: parsed.args,
};
}
/**
* 生成用于权限请求的模式
* 例如: "git push origin" -> "git push *"
*/
export function generateAskPattern(command: string): string {
const parsed = parseCommandSimple(command);
if (parsed.subcommand) {
return `${parsed.name} ${parsed.subcommand} *`;
}
return `${parsed.name} *`;
}
/**
* 生成用于权限请求的模式(从 ParsedCommand
*/
export function generateAskPatternFromParsed(parsed: ParsedCommand): string {
if (parsed.subcommand) {
return `${parsed.name} ${parsed.subcommand} *`;
}
return `${parsed.name} *`;
}
/**
* 检查单个命令是否匹配规则
*/
function matchSingleCommand(
parsed: ParsedCommand,
rules: PermissionRule[],
defaultAction: PermissionAction
): { action: PermissionAction; matchedPattern?: string } {
// 构建可能的匹配字符串
const candidates = [
parsed.text, // 完整命令
parsed.subcommand
? `${parsed.name} ${parsed.subcommand}`
: parsed.name, // 命令 + 子命令
parsed.name, // 仅命令名
];
for (const rule of rules) {
for (const candidate of candidates) {
if (matchPattern(candidate, rule.pattern)) {
return { action: rule.action, matchedPattern: rule.pattern };
}
}
}
return { action: defaultAction };
}
/**
* 检查命令是否匹配规则列表,返回对应的动作(同步版本,使用简单解析)
*/
export function matchRules(
command: string,
rules: PermissionRule[],
defaultAction: PermissionAction
): { action: PermissionAction; matchedPattern?: string } {
const parsed = parseCommandSimple(command);
return matchSingleCommand(parsed, rules, defaultAction);
}
/**
* 使用 tree-sitter 解析并检查所有命令的权限(异步版本)
* 返回最严格的权限要求
*/
export async function matchRulesAsync(
command: string,
rules: PermissionRule[],
defaultAction: PermissionAction
): Promise<{
action: PermissionAction;
matchedPattern?: string;
allCommands: ParsedCommand[];
askPatterns: string[];
}> {
const result = await parseBashCommand(command);
// 如果解析失败,降级到简单解析
if (!result.success || result.commands.length === 0) {
const parsed = parseCommandSimple(command);
const match = matchSingleCommand(parsed, rules, defaultAction);
return {
...match,
allCommands: [parsed],
askPatterns: match.action === 'ask' ? [generateAskPatternFromParsed(parsed)] : [],
};
}
// 检查所有命令,收集结果
let finalAction: PermissionAction = 'allow';
let finalPattern: string | undefined;
const askPatterns: string[] = [];
for (const parsed of result.commands) {
// 跳过 cd 命令(如果通过了外部目录检查)
if (parsed.name === 'cd') {
continue;
}
const match = matchSingleCommand(parsed, rules, defaultAction);
// 权限优先级: deny > ask > allow
if (match.action === 'deny') {
finalAction = 'deny';
finalPattern = match.matchedPattern;
break; // deny 直接终止
} else if (match.action === 'ask' && finalAction === 'allow') {
finalAction = 'ask';
finalPattern = match.matchedPattern;
askPatterns.push(generateAskPatternFromParsed(parsed));
}
// allow 不改变已有的更严格权限
}
return {
action: finalAction,
matchedPattern: finalPattern,
allCommands: result.commands,
askPatterns: [...new Set(askPatterns)], // 去重
};
}
+18 -11
View File
@@ -2,6 +2,7 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import type { Tool, ToolResult } from '../types/index.js';
import { loadDescription } from './load_description.js';
import { getPermissionManager } from '../permission/index.js';
const execAsync = promisify(exec);
@@ -16,7 +17,7 @@ export const bashTool: Tool = {
},
cwd: {
type: 'string',
description: '工作目录(可选)',
description: '工作目录(可选,默认为当前目录',
required: false,
},
},
@@ -24,22 +25,28 @@ export const bashTool: Tool = {
const command = params.command as string;
const cwd = (params.cwd as string) || process.cwd();
// 安全检查:禁止危险命令
const dangerousPatterns = [
/rm\s+-rf\s+\//, // rm -rf /
/mkfs/, // 格式化磁盘
/dd\s+if=.*of=\/dev/, // 直接写入设备
/>\s*\/dev\/sd/, // 重定向到磁盘设备
];
// 权限检查
const permissionManager = getPermissionManager();
const permResult = await permissionManager.checkBashPermission({
command,
workdir: cwd,
});
for (const pattern of dangerousPatterns) {
if (pattern.test(command)) {
if (!permResult.allowed) {
// 如果需要用户确认但没有设置回调,返回等待确认的状态
if (permResult.needsConfirmation) {
return {
success: false,
output: '',
error: '检测到危险命令,已阻止执行',
error: `需要用户确认: ${command}\n原因: ${permResult.reason || '需要权限确认'}\n模式: ${permResult.patterns?.join(', ') || ''}`,
};
}
return {
success: false,
output: '',
error: `权限被拒绝: ${permResult.reason || '命令不被允许执行'}`,
};
}
try {
+26 -1
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import type { Tool, ToolResult } from '../types/index.js';
import { loadDescription } from './load_description.js';
import { getPermissionManager } from '../permission/index.js';
export const editFileTool: Tool = {
name: 'edit_file',
@@ -27,9 +28,33 @@ export const editFileTool: Tool = {
const filePath = params.path as string;
const oldString = params.old_string as string;
const newString = params.new_string as string;
const cwd = process.cwd();
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.join(process.cwd(), filePath);
: path.join(cwd, filePath);
// 权限检查
const permissionManager = getPermissionManager();
const permResult = await permissionManager.checkFilePermission({
operation: 'edit',
path: absolutePath,
workdir: cwd,
});
if (!permResult.allowed) {
if (permResult.needsConfirmation) {
return {
success: false,
output: '',
error: `需要用户确认: 编辑 ${absolutePath}\n原因: ${permResult.reason || '需要权限确认'}`,
};
}
return {
success: false,
output: '',
error: `权限被拒绝: ${permResult.reason || '不允许编辑此文件'}`,
};
}
try {
const content = await fs.readFile(absolutePath, 'utf-8');
+26 -1
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import type { Tool, ToolResult } from '../types/index.js';
import { loadDescription } from './load_description.js';
import { getPermissionManager } from '../permission/index.js';
export const listDirTool: Tool = {
name: 'list_directory',
@@ -15,9 +16,33 @@ export const listDirTool: Tool = {
},
execute: async (params: Record<string, unknown>): Promise<ToolResult> => {
const dirPath = params.path as string;
const cwd = process.cwd();
const absolutePath = path.isAbsolute(dirPath)
? dirPath
: path.join(process.cwd(), dirPath);
: path.join(cwd, dirPath);
// 权限检查
const permissionManager = getPermissionManager();
const permResult = await permissionManager.checkFilePermission({
operation: 'list',
path: absolutePath,
workdir: cwd,
});
if (!permResult.allowed) {
if (permResult.needsConfirmation) {
return {
success: false,
output: '',
error: `需要用户确认: 列出目录 ${absolutePath}\n原因: ${permResult.reason || '需要权限确认'}`,
};
}
return {
success: false,
output: '',
error: `权限被拒绝: ${permResult.reason || '不允许列出此目录'}`,
};
}
try {
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
+26 -1
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import type { Tool, ToolResult } from '../types/index.js';
import { loadDescription } from './load_description.js';
import { getPermissionManager } from '../permission/index.js';
export const readFileTool: Tool = {
name: 'read_file',
@@ -15,9 +16,33 @@ export const readFileTool: Tool = {
},
execute: async (params: Record<string, unknown>): Promise<ToolResult> => {
const filePath = params.path as string;
const cwd = process.cwd();
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.join(process.cwd(), filePath);
: path.join(cwd, filePath);
// 权限检查
const permissionManager = getPermissionManager();
const permResult = await permissionManager.checkFilePermission({
operation: 'read',
path: absolutePath,
workdir: cwd,
});
if (!permResult.allowed) {
if (permResult.needsConfirmation) {
return {
success: false,
output: '',
error: `需要用户确认: 读取 ${absolutePath}\n原因: ${permResult.reason || '需要权限确认'}`,
};
}
return {
success: false,
output: '',
error: `权限被拒绝: ${permResult.reason || '不允许读取此文件'}`,
};
}
try {
const content = await fs.readFile(absolutePath, 'utf-8');
+26 -1
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import type { Tool, ToolResult } from '../types/index.js';
import { loadDescription } from './load_description.js';
import { getPermissionManager } from '../permission/index.js';
export const searchFilesTool: Tool = {
name: 'search_files',
@@ -21,9 +22,33 @@ export const searchFilesTool: Tool = {
execute: async (params: Record<string, unknown>): Promise<ToolResult> => {
const directory = params.directory as string;
const pattern = params.pattern as string;
const cwd = process.cwd();
const absolutePath = path.isAbsolute(directory)
? directory
: path.join(process.cwd(), directory);
: path.join(cwd, directory);
// 权限检查
const permissionManager = getPermissionManager();
const permResult = await permissionManager.checkFilePermission({
operation: 'search',
path: absolutePath,
workdir: cwd,
});
if (!permResult.allowed) {
if (permResult.needsConfirmation) {
return {
success: false,
output: '',
error: `需要用户确认: 搜索目录 ${absolutePath}\n原因: ${permResult.reason || '需要权限确认'}`,
};
}
return {
success: false,
output: '',
error: `权限被拒绝: ${permResult.reason || '不允许搜索此目录'}`,
};
}
const matches: string[] = [];
const regex = new RegExp(
+26 -1
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import type { Tool, ToolResult } from '../types/index.js';
import { loadDescription } from './load_description.js';
import { getPermissionManager } from '../permission/index.js';
export const writeFileTool: Tool = {
name: 'write_file',
@@ -21,9 +22,33 @@ export const writeFileTool: Tool = {
execute: async (params: Record<string, unknown>): Promise<ToolResult> => {
const filePath = params.path as string;
const content = params.content as string;
const cwd = process.cwd();
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.join(process.cwd(), filePath);
: path.join(cwd, filePath);
// 权限检查
const permissionManager = getPermissionManager();
const permResult = await permissionManager.checkFilePermission({
operation: 'write',
path: absolutePath,
workdir: cwd,
});
if (!permResult.allowed) {
if (permResult.needsConfirmation) {
return {
success: false,
output: '',
error: `需要用户确认: 写入 ${absolutePath}\n原因: ${permResult.reason || '需要权限确认'}`,
};
}
return {
success: false,
output: '',
error: `权限被拒绝: ${permResult.reason || '不允许写入此文件'}`,
};
}
try {
await fs.mkdir(path.dirname(absolutePath), { recursive: true });