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:
Generated
+55
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { PermissionChecker, BasePermissionConfig } from './base.js';
|
||||
export { BashPermissionChecker } from './bash.js';
|
||||
export { FilePermissionChecker } from './file.js';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`));
|
||||
}
|
||||
@@ -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; // 是否记住这个决定
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user