diff --git a/package-lock.json b/package-lock.json index 3d8e739..76e2739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4ec8b28..67644ba 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/index.ts b/src/index.ts index 94aa6c6..b63ac04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 ') .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); diff --git a/src/permission/bash-parser.ts b/src/permission/bash-parser.ts new file mode 100644 index 0000000..b2cc1cf --- /dev/null +++ b/src/permission/bash-parser.ts @@ -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 | 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 { + 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 { + 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; +} diff --git a/src/permission/checkers/base.ts b/src/permission/checkers/base.ts new file mode 100644 index 0000000..52deefa --- /dev/null +++ b/src/permission/checkers/base.ts @@ -0,0 +1,34 @@ +import type { PermissionCheckResult, PermissionContext } from '../types.js'; + +/** + * 权限检查器基础接口 + * 所有工具的权限检查器都应该实现此接口 + */ +export interface PermissionChecker { + /** + * 检查器名称 + */ + readonly name: string; + + /** + * 检查权限 + * @param ctx 权限检查上下文 + * @returns 权限检查结果 + */ + check(ctx: PermissionContext): Promise; + + /** + * 清除会话级别的临时权限 + */ + clearSessionPermissions(): void; +} + +/** + * 权限检查器配置基类 + */ +export interface BasePermissionConfig { + /** + * 默认权限动作 + */ + default: 'allow' | 'deny' | 'ask'; +} diff --git a/src/permission/checkers/bash.ts b/src/permission/checkers/bash.ts new file mode 100644 index 0000000..d15715d --- /dev/null +++ b/src/permission/checkers/bash.ts @@ -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; + private sessionPermissions = new Map(); + + constructor(projectRoot: string = process.cwd()) { + this.projectRoot = path.resolve(projectRoot); + this.config = this.loadConfig(); + } + + /** + * 设置权限询问回调 + */ + setAskCallback(callback: (ctx: PermissionContext) => Promise): 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; + 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 { + 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 { + 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 }; + } +} diff --git a/src/permission/checkers/file.ts b/src/permission/checkers/file.ts new file mode 100644 index 0000000..c8fae6d --- /dev/null +++ b/src/permission/checkers/file.ts @@ -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; + private sessionPermissions = new Map(); + + constructor(projectRoot: string = process.cwd()) { + this.projectRoot = path.resolve(projectRoot); + this.config = this.loadConfig(); + } + + /** + * 设置权限询问回调 + */ + setAskCallback(callback: (ctx: PermissionContext) => Promise): 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; + 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 { + 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 { + 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 { + // 从 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], + }; + } +} diff --git a/src/permission/checkers/index.ts b/src/permission/checkers/index.ts new file mode 100644 index 0000000..8a655ab --- /dev/null +++ b/src/permission/checkers/index.ts @@ -0,0 +1,3 @@ +export type { PermissionChecker, BasePermissionConfig } from './base.js'; +export { BashPermissionChecker } from './bash.js'; +export { FilePermissionChecker } from './file.js'; diff --git a/src/permission/index.ts b/src/permission/index.ts new file mode 100644 index 0000000..f2c281a --- /dev/null +++ b/src/permission/index.ts @@ -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'; diff --git a/src/permission/manager.ts b/src/permission/manager.ts new file mode 100644 index 0000000..f2db086 --- /dev/null +++ b/src/permission/manager.ts @@ -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(); + private askCallback?: (ctx: PermissionContext) => Promise; + + 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(name: string): T | undefined { + return this.checkers.get(name) as T | undefined; + } + + /** + * 设置权限询问回调 + */ + setAskCallback(callback: (ctx: PermissionContext) => Promise): 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 { + 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 { + return this.checkPermission('bash', ctx); + } + + /** + * 检查文件操作权限(便捷方法) + */ + async checkFilePermission(ctx: FilePermissionContext): Promise { + const fileChecker = this.getChecker('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; +} diff --git a/src/permission/prompt.ts b/src/permission/prompt.ts new file mode 100644 index 0000000..38ce81e --- /dev/null +++ b/src/permission/prompt.ts @@ -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 { + 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}`)); +} diff --git a/src/permission/types.ts b/src/permission/types.ts new file mode 100644 index 0000000..486bf7a --- /dev/null +++ b/src/permission/types.ts @@ -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; // 是否记住这个决定 +} diff --git a/src/permission/wildcard.ts b/src/permission/wildcard.ts new file mode 100644 index 0000000..e0eb5ee --- /dev/null +++ b/src/permission/wildcard.ts @@ -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)], // 去重 + }; +} diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 438f9f5..f912ffa 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -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 { diff --git a/src/tools/edit_file.ts b/src/tools/edit_file.ts index b9659ff..534a00d 100644 --- a/src/tools/edit_file.ts +++ b/src/tools/edit_file.ts @@ -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'); diff --git a/src/tools/list_directory.ts b/src/tools/list_directory.ts index b6e1060..dc879a0 100644 --- a/src/tools/list_directory.ts +++ b/src/tools/list_directory.ts @@ -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): Promise => { 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 }); diff --git a/src/tools/read_file.ts b/src/tools/read_file.ts index e715261..a7a6473 100644 --- a/src/tools/read_file.ts +++ b/src/tools/read_file.ts @@ -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): Promise => { 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'); diff --git a/src/tools/search_files.ts b/src/tools/search_files.ts index 26bbc21..31b745b 100644 --- a/src/tools/search_files.ts +++ b/src/tools/search_files.ts @@ -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): Promise => { 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( diff --git a/src/tools/write_file.ts b/src/tools/write_file.ts index e7d269c..9d3b9bf 100644 --- a/src/tools/write_file.ts +++ b/src/tools/write_file.ts @@ -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): Promise => { 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 });