feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
This commit is contained in:
@@ -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,343 @@
|
||||
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';
|
||||
import { promptFilePermission } from '../file-prompt.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', // 搜索默认允许
|
||||
grep: 'allow', // 内容搜索默认允许
|
||||
info: 'allow', // 获取文件信息默认允许
|
||||
move: 'ask', // 移动需要确认
|
||||
copy: 'ask', // 复制需要确认
|
||||
delete: 'ask', // 删除需要确认
|
||||
mkdir: '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> {
|
||||
// 对于 write/edit 操作,如果有内容信息,使用 diff 显示
|
||||
if ((ctx.operation === 'write' || ctx.operation === 'edit') && ctx.newContent !== undefined) {
|
||||
// 更新 ctx 中的路径为绝对路径
|
||||
const ctxWithAbsPath: FilePermissionContext = {
|
||||
...ctx,
|
||||
path: absolutePath,
|
||||
};
|
||||
|
||||
const decision = await promptFilePermission(ctxWithAbsPath);
|
||||
|
||||
if (decision.remember) {
|
||||
this.sessionPermissions.set(sessionKey, decision.allow ? 'allow' : 'deny');
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: decision.allow,
|
||||
action: decision.allow ? 'allow' : 'deny',
|
||||
reason: decision.allow ? '用户允许' : '用户拒绝',
|
||||
};
|
||||
}
|
||||
|
||||
// 其他操作使用原有的回调
|
||||
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,236 @@
|
||||
import type {
|
||||
GitPermissionConfig,
|
||||
GitPermissionContext,
|
||||
GitOperation,
|
||||
PermissionCheckResult,
|
||||
PermissionDecision,
|
||||
PermissionContext,
|
||||
} from '../types.js';
|
||||
import type { PermissionChecker } from './base.js';
|
||||
|
||||
// 读操作列表
|
||||
const READ_OPERATIONS: GitOperation[] = [
|
||||
'status',
|
||||
'diff',
|
||||
'log',
|
||||
'branch_list',
|
||||
'show',
|
||||
];
|
||||
|
||||
// 写操作列表
|
||||
const WRITE_OPERATIONS: GitOperation[] = [
|
||||
'add',
|
||||
'commit',
|
||||
'push',
|
||||
'pull',
|
||||
'checkout',
|
||||
'branch_create',
|
||||
'branch_delete',
|
||||
'stash',
|
||||
'stash_pop',
|
||||
'merge',
|
||||
'rebase',
|
||||
];
|
||||
|
||||
// 危险操作(需要 force 参数的操作)
|
||||
const DANGEROUS_WHEN_FORCED: GitOperation[] = [
|
||||
'push',
|
||||
'reset',
|
||||
'checkout',
|
||||
'rebase',
|
||||
];
|
||||
|
||||
// 默认 Git 权限配置
|
||||
const DEFAULT_CONFIG: GitPermissionConfig = {
|
||||
readOperations: 'allow',
|
||||
writeOperations: 'ask',
|
||||
dangerousOperations: 'ask',
|
||||
};
|
||||
|
||||
/**
|
||||
* Git 操作权限检查器
|
||||
* 控制 Git 仓库操作的权限
|
||||
*/
|
||||
export class GitPermissionChecker implements PermissionChecker {
|
||||
readonly name = 'git';
|
||||
|
||||
private config: GitPermissionConfig;
|
||||
private askCallback?: (ctx: PermissionContext) => Promise<PermissionDecision>;
|
||||
private sessionPermissions = new Map<string, 'allow' | 'deny'>();
|
||||
|
||||
constructor() {
|
||||
this.config = { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置权限询问回调
|
||||
*/
|
||||
setAskCallback(callback: (ctx: PermissionContext) => Promise<PermissionDecision>): void {
|
||||
this.askCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断操作类型
|
||||
*/
|
||||
private getOperationType(operation: GitOperation, force?: boolean): 'read' | 'write' | 'dangerous' {
|
||||
// 强制操作属于危险操作
|
||||
if (force && DANGEROUS_WHEN_FORCED.includes(operation)) {
|
||||
return 'dangerous';
|
||||
}
|
||||
|
||||
// reset 总是危险操作
|
||||
if (operation === 'reset') {
|
||||
return 'dangerous';
|
||||
}
|
||||
|
||||
if (READ_OPERATIONS.includes(operation)) {
|
||||
return 'read';
|
||||
}
|
||||
|
||||
return 'write';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作的描述
|
||||
*/
|
||||
private getOperationDescription(ctx: GitPermissionContext): string {
|
||||
const { operation, target, remote, force, message } = ctx;
|
||||
|
||||
const parts: string[] = [`git ${operation.replace('_', ' ')}`];
|
||||
|
||||
if (force) {
|
||||
parts.push('--force');
|
||||
}
|
||||
|
||||
if (target) {
|
||||
parts.push(target);
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
parts.push(`(remote: ${remote})`);
|
||||
}
|
||||
|
||||
if (message) {
|
||||
parts.push(`"${message.substring(0, 50)}${message.length > 50 ? '...' : ''}"`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Git 操作权限
|
||||
*/
|
||||
async checkGitPermission(ctx: GitPermissionContext): Promise<PermissionCheckResult> {
|
||||
const { operation, force } = ctx;
|
||||
const operationType = this.getOperationType(operation, force);
|
||||
const description = this.getOperationDescription(ctx);
|
||||
|
||||
// 1. 检查会话级别的临时权限
|
||||
const sessionKey = `git_${operationType}`;
|
||||
const sessionPerm = this.sessionPermissions.get(sessionKey);
|
||||
if (sessionPerm === 'allow') {
|
||||
return {
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
reason: `本次会话已允许 Git ${operationType === 'read' ? '读' : operationType === 'write' ? '写' : '危险'}操作`,
|
||||
};
|
||||
}
|
||||
if (sessionPerm === 'deny') {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: `本次会话已拒绝 Git ${operationType === 'read' ? '读' : operationType === 'write' ? '写' : '危险'}操作`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 根据操作类型确定权限策略
|
||||
let action = this.config.writeOperations;
|
||||
if (operationType === 'read') {
|
||||
action = this.config.readOperations;
|
||||
} else if (operationType === 'dangerous') {
|
||||
action = this.config.dangerousOperations;
|
||||
}
|
||||
|
||||
// 3. 处理权限决策
|
||||
if (action === 'allow') {
|
||||
return {
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
reason: operationType === 'read' ? '读操作默认允许' : '配置允许',
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'deny') {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: operationType === 'dangerous' ? '危险操作默认拒绝' : '配置拒绝',
|
||||
};
|
||||
}
|
||||
|
||||
// action === 'ask'
|
||||
if (!this.askCallback) {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
reason: description,
|
||||
};
|
||||
}
|
||||
|
||||
// 调用回调询问用户
|
||||
const decision = await this.askCallback({
|
||||
command: description,
|
||||
workdir: process.cwd(),
|
||||
});
|
||||
|
||||
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 方法
|
||||
*/
|
||||
async check(ctx: PermissionContext): Promise<PermissionCheckResult> {
|
||||
// 从 command 中解析 Git 操作
|
||||
const match = ctx.command.match(/^git[_\s]+(\w+)/);
|
||||
if (!match) {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '无法解析 Git 操作',
|
||||
};
|
||||
}
|
||||
|
||||
const operation = match[1] as GitOperation;
|
||||
return this.checkGitPermission({ operation });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除会话权限
|
||||
*/
|
||||
clearSessionPermissions(): void {
|
||||
this.sessionPermissions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
*/
|
||||
getConfig(): GitPermissionConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
setConfig(config: Partial<GitPermissionConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { PermissionChecker, BasePermissionConfig } from './base.js';
|
||||
export { BashPermissionChecker } from './bash.js';
|
||||
export { FilePermissionChecker } from './file.js';
|
||||
export { WebPermissionChecker } from './web.js';
|
||||
export { GitPermissionChecker } from './git.js';
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
WebPermissionConfig,
|
||||
WebPermissionContext,
|
||||
PermissionCheckResult,
|
||||
PermissionDecision,
|
||||
PermissionContext,
|
||||
} from '../types.js';
|
||||
import type { PermissionChecker } from './base.js';
|
||||
|
||||
// 默认 Web 权限配置
|
||||
const DEFAULT_CONFIG: WebPermissionConfig = {
|
||||
default: 'ask', // 默认需要确认
|
||||
allowAdvancedSearch: true,
|
||||
allowedTopics: [], // 空数组表示允许所有主题
|
||||
};
|
||||
|
||||
/**
|
||||
* Web 搜索权限检查器
|
||||
* 控制网络搜索操作的权限
|
||||
*/
|
||||
export class WebPermissionChecker implements PermissionChecker {
|
||||
readonly name = 'web';
|
||||
|
||||
private config: WebPermissionConfig;
|
||||
private askCallback?: (ctx: PermissionContext) => Promise<PermissionDecision>;
|
||||
private sessionPermissions = new Map<string, 'allow' | 'deny'>();
|
||||
|
||||
constructor() {
|
||||
this.config = { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置权限询问回调
|
||||
*/
|
||||
setAskCallback(callback: (ctx: PermissionContext) => Promise<PermissionDecision>): void {
|
||||
this.askCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Web 搜索权限
|
||||
*/
|
||||
async checkWebPermission(ctx: WebPermissionContext): Promise<PermissionCheckResult> {
|
||||
const { query, searchDepth, topic } = ctx;
|
||||
|
||||
// 1. 检查深度搜索权限
|
||||
if (searchDepth === 'advanced' && !this.config.allowAdvancedSearch) {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许深度搜索',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 检查主题限制
|
||||
if (this.config.allowedTopics.length > 0 && topic) {
|
||||
if (!this.config.allowedTopics.includes(topic)) {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: `不允许搜索主题: ${topic}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查会话级别的临时权限
|
||||
const sessionKey = `web_search`;
|
||||
const sessionPerm = this.sessionPermissions.get(sessionKey);
|
||||
if (sessionPerm === 'allow') {
|
||||
return {
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
reason: '本次会话已允许网络搜索',
|
||||
};
|
||||
}
|
||||
if (sessionPerm === 'deny') {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '本次会话已拒绝网络搜索',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 根据默认策略处理
|
||||
const action = this.config.default;
|
||||
|
||||
if (action === 'allow') {
|
||||
return {
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
reason: '默认允许网络搜索',
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'deny') {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '默认拒绝网络搜索',
|
||||
};
|
||||
}
|
||||
|
||||
// action === 'ask'
|
||||
if (!this.askCallback) {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
reason: `搜索: ${query}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 调用回调询问用户
|
||||
const decision = await this.askCallback({
|
||||
command: `web_search: ${query}`,
|
||||
workdir: process.cwd(),
|
||||
});
|
||||
|
||||
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 中提取 Web 搜索信息
|
||||
*/
|
||||
async check(ctx: PermissionContext): Promise<PermissionCheckResult> {
|
||||
// 从 command 中提取搜索查询
|
||||
const query = ctx.command.replace(/^web_search:\s*/, '');
|
||||
return this.checkWebPermission({ query });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除会话权限
|
||||
*/
|
||||
clearSessionPermissions(): void {
|
||||
this.sessionPermissions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
*/
|
||||
getConfig(): WebPermissionConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
setConfig(config: Partial<WebPermissionConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 文件操作确认提示
|
||||
* 显示 diff 对比并让用户确认
|
||||
*/
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs/promises';
|
||||
import chalk from 'chalk';
|
||||
import type { FilePermissionContext, PermissionDecision } from './types.js';
|
||||
import { computeDiff, formatDiff, countChanges, formatEditDiff } from '../utils/diff.js';
|
||||
|
||||
/**
|
||||
* 显示文件写入的 diff 并请求确认
|
||||
*/
|
||||
export async function promptFileWrite(ctx: FilePermissionContext): Promise<PermissionDecision> {
|
||||
const { path: filePath, newContent } = ctx;
|
||||
|
||||
if (!newContent) {
|
||||
// 没有内容,使用简单确认
|
||||
return promptSimpleConfirm(ctx);
|
||||
}
|
||||
|
||||
// 读取原文件内容
|
||||
let oldContent: string | null = null;
|
||||
try {
|
||||
oldContent = await fs.readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
// 文件不存在,是新文件
|
||||
}
|
||||
|
||||
// 如果内容相同,直接允许
|
||||
if (oldContent === newContent) {
|
||||
return { allow: true, remember: false };
|
||||
}
|
||||
|
||||
// 计算 diff
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
const changes = countChanges(diff);
|
||||
|
||||
// 显示 diff
|
||||
console.log('');
|
||||
console.log(chalk.yellow('📝 文件写入预览'));
|
||||
console.log(chalk.cyan('文件: ') + chalk.white(filePath));
|
||||
|
||||
if (diff.isNew) {
|
||||
console.log(chalk.green('状态: ') + chalk.white('新文件'));
|
||||
console.log(chalk.green(`+${changes.additions} 行`));
|
||||
} else {
|
||||
console.log(chalk.green(`+${changes.additions} 行`) + ' / ' + chalk.red(`-${changes.deletions} 行`));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
|
||||
// 限制显示行数
|
||||
const diffOutput = formatDiff(diff, filePath);
|
||||
const lines = diffOutput.split('\n');
|
||||
const MAX_LINES = 50;
|
||||
|
||||
if (lines.length > MAX_LINES) {
|
||||
console.log(lines.slice(0, MAX_LINES).join('\n'));
|
||||
console.log(chalk.yellow(`\n... 省略 ${lines.length - MAX_LINES} 行 ...`));
|
||||
} else {
|
||||
console.log(diffOutput);
|
||||
}
|
||||
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
console.log('');
|
||||
|
||||
// 询问用户确认
|
||||
return promptConfirm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示文件编辑的 diff 并请求确认
|
||||
*/
|
||||
export async function promptFileEdit(ctx: FilePermissionContext): Promise<PermissionDecision> {
|
||||
const { path: filePath, oldContent, newContent } = ctx;
|
||||
|
||||
if (!oldContent || !newContent) {
|
||||
// 没有内容,使用简单确认
|
||||
return promptSimpleConfirm(ctx);
|
||||
}
|
||||
|
||||
// 显示编辑 diff
|
||||
console.log('');
|
||||
console.log(chalk.yellow('✏️ 文件编辑预览'));
|
||||
console.log(chalk.cyan('文件: ') + chalk.white(filePath));
|
||||
console.log('');
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
console.log(formatEditDiff(oldContent, newContent));
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
console.log('');
|
||||
|
||||
// 询问用户确认
|
||||
return promptConfirm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单确认(无 diff)
|
||||
*/
|
||||
async function promptSimpleConfirm(ctx: FilePermissionContext): 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.operation));
|
||||
console.log(chalk.cyan('文件: ') + chalk.white(ctx.path));
|
||||
console.log('');
|
||||
|
||||
showConfirmOptions();
|
||||
|
||||
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
|
||||
rl.close();
|
||||
resolve(parseAnswer(answer));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用确认提示
|
||||
*/
|
||||
async function promptConfirm(): Promise<PermissionDecision> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
showConfirmOptions();
|
||||
|
||||
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
|
||||
rl.close();
|
||||
resolve(parseAnswer(answer));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示确认选项
|
||||
*/
|
||||
function showConfirmOptions(): void {
|
||||
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('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析用户输入
|
||||
*/
|
||||
function parseAnswer(answer: string): PermissionDecision {
|
||||
const choice = answer.trim();
|
||||
|
||||
switch (choice) {
|
||||
case 'y':
|
||||
return { allow: true, remember: false };
|
||||
case 'Y':
|
||||
return { allow: true, remember: true };
|
||||
case 'N':
|
||||
return { allow: false, remember: true };
|
||||
case 'n':
|
||||
default:
|
||||
return { allow: false, remember: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据操作类型选择合适的确认提示
|
||||
*/
|
||||
export async function promptFilePermission(ctx: FilePermissionContext): Promise<PermissionDecision> {
|
||||
switch (ctx.operation) {
|
||||
case 'write':
|
||||
return promptFileWrite(ctx);
|
||||
case 'edit':
|
||||
return promptFileEdit(ctx);
|
||||
default:
|
||||
return promptSimpleConfirm(ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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';
|
||||
|
||||
export { promptFilePermission, promptFileWrite, promptFileEdit } from './file-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,173 @@
|
||||
import type {
|
||||
PermissionContext,
|
||||
PermissionCheckResult,
|
||||
PermissionDecision,
|
||||
FilePermissionContext,
|
||||
WebPermissionContext,
|
||||
GitPermissionContext,
|
||||
} from './types.js';
|
||||
import type { PermissionChecker } from './checkers/base.js';
|
||||
import { BashPermissionChecker } from './checkers/bash.js';
|
||||
import { FilePermissionChecker } from './checkers/file.js';
|
||||
import { WebPermissionChecker } from './checkers/web.js';
|
||||
import { GitPermissionChecker } from './checkers/git.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));
|
||||
this.registerChecker(new WebPermissionChecker());
|
||||
this.registerChecker(new GitPermissionChecker());
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册权限检查器
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Web 搜索权限(便捷方法)
|
||||
*/
|
||||
async checkWebPermission(ctx: WebPermissionContext): Promise<PermissionCheckResult> {
|
||||
const webChecker = this.getChecker<WebPermissionChecker>('web');
|
||||
if (!webChecker) {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
reason: 'Web 权限检查器未注册',
|
||||
};
|
||||
}
|
||||
return webChecker.checkWebPermission(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Git 操作权限(便捷方法)
|
||||
*/
|
||||
async checkGitPermission(ctx: GitPermissionContext): Promise<PermissionCheckResult> {
|
||||
const gitChecker = this.getChecker<GitPermissionChecker>('git');
|
||||
if (!gitChecker) {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
reason: 'Git 权限检查器未注册',
|
||||
};
|
||||
}
|
||||
return gitChecker.checkGitPermission(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,149 @@
|
||||
// 权限动作类型
|
||||
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' // 搜索文件
|
||||
| 'grep' // 搜索内容
|
||||
| 'info' // 获取文件信息
|
||||
| 'move' // 移动/重命名
|
||||
| 'copy' // 复制
|
||||
| 'delete' // 删除
|
||||
| 'mkdir'; // 创建目录
|
||||
|
||||
// 文件权限请求上下文
|
||||
export interface FilePermissionContext {
|
||||
operation: FileOperation;
|
||||
path: string; // 目标路径
|
||||
workdir: string; // 当前工作目录
|
||||
// 用于 diff 显示的内容(仅 write/edit 操作)
|
||||
newContent?: string; // 新内容
|
||||
oldContent?: string; // 原内容(edit 操作时,要被替换的部分)
|
||||
}
|
||||
|
||||
// 文件权限配置
|
||||
export interface FilePermissionConfig {
|
||||
// 外部目录访问策略
|
||||
externalDirectory: PermissionAction;
|
||||
// 各操作的默认策略
|
||||
operations: {
|
||||
read: PermissionAction;
|
||||
write: PermissionAction;
|
||||
edit: PermissionAction;
|
||||
list: PermissionAction;
|
||||
search: PermissionAction;
|
||||
grep: PermissionAction;
|
||||
info: PermissionAction;
|
||||
move: PermissionAction;
|
||||
copy: PermissionAction;
|
||||
delete: PermissionAction;
|
||||
mkdir: 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; // 是否记住这个决定
|
||||
}
|
||||
|
||||
// Web 搜索权限请求上下文
|
||||
export interface WebPermissionContext {
|
||||
query: string; // 搜索查询
|
||||
searchDepth?: 'basic' | 'advanced'; // 搜索深度
|
||||
topic?: 'general' | 'news' | 'finance'; // 搜索主题
|
||||
maxResults?: number; // 最大结果数
|
||||
}
|
||||
|
||||
// Web 权限配置
|
||||
export interface WebPermissionConfig {
|
||||
// 默认策略
|
||||
default: PermissionAction;
|
||||
// 是否允许深度搜索
|
||||
allowAdvancedSearch: boolean;
|
||||
// 搜索主题限制(空数组表示允许所有)
|
||||
allowedTopics: ('general' | 'news' | 'finance')[];
|
||||
}
|
||||
|
||||
// Git 操作类型
|
||||
export type GitOperation =
|
||||
// 读操作
|
||||
| 'status'
|
||||
| 'diff'
|
||||
| 'log'
|
||||
| 'branch_list'
|
||||
| 'show'
|
||||
// 写操作
|
||||
| 'add'
|
||||
| 'commit'
|
||||
| 'push'
|
||||
| 'pull'
|
||||
| 'checkout'
|
||||
| 'branch_create'
|
||||
| 'branch_delete'
|
||||
| 'stash'
|
||||
| 'stash_pop'
|
||||
| 'reset'
|
||||
| 'merge'
|
||||
| 'rebase';
|
||||
|
||||
// Git 权限请求上下文
|
||||
export interface GitPermissionContext {
|
||||
operation: GitOperation;
|
||||
target?: string; // 分支名、文件路径等
|
||||
remote?: string; // 远程仓库名
|
||||
force?: boolean; // 是否强制操作
|
||||
message?: string; // 提交信息等
|
||||
}
|
||||
|
||||
// Git 权限配置
|
||||
export interface GitPermissionConfig {
|
||||
// 读操作策略(默认 allow)
|
||||
readOperations: PermissionAction;
|
||||
// 写操作策略(默认 ask)
|
||||
writeOperations: PermissionAction;
|
||||
// 危险操作策略(force push, reset --hard 等,默认 ask)
|
||||
dangerousOperations: PermissionAction;
|
||||
}
|
||||
@@ -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)], // 去重
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user