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:
2025-12-12 10:42:20 +08:00
parent 59dbed926e
commit 5e32375f0e
301 changed files with 3281 additions and 43 deletions
+225
View File
@@ -0,0 +1,225 @@
import { Parser, Language, type Node } from 'web-tree-sitter';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 解析后的命令结构
export interface ParsedCommand {
name: string; // 命令名,如 "git"
subcommand?: string; // 子命令,如 "push"
args: string[]; // 参数列表
text: string; // 原始命令文本
}
// 解析结果
export interface ParseResult {
commands: ParsedCommand[]; // 所有解析出的命令
success: boolean;
error?: string;
}
// 单例解析器
let parserInstance: Parser | null = null;
let bashLanguage: Language | null = null;
let initPromise: Promise<void> | null = null;
/**
* 获取 wasm 文件路径
*/
function getWasmPath(filename: string): string {
// 从 node_modules 加载
const nodeModulesPath = path.resolve(__dirname, '../../node_modules');
if (filename === 'tree-sitter.wasm') {
return path.join(nodeModulesPath, 'web-tree-sitter', filename);
} else if (filename === 'tree-sitter-bash.wasm') {
return path.join(nodeModulesPath, 'tree-sitter-bash', filename);
}
throw new Error(`Unknown wasm file: ${filename}`);
}
/**
* 初始化解析器(懒加载,只初始化一次)
*/
async function initParser(): Promise<void> {
if (parserInstance && bashLanguage) {
return;
}
if (initPromise) {
return initPromise;
}
initPromise = (async () => {
try {
// 初始化 tree-sitter
await Parser.init({
locateFile: (scriptName: string) => {
return getWasmPath(scriptName);
},
});
// 创建解析器实例
parserInstance = new Parser();
// 加载 bash 语言
const bashWasmPath = getWasmPath('tree-sitter-bash.wasm');
bashLanguage = await Language.load(bashWasmPath);
parserInstance.setLanguage(bashLanguage);
} catch (error) {
initPromise = null;
throw error;
}
})();
return initPromise;
}
/**
* 从语法树节点中提取命令信息
*/
function extractCommandFromNode(node: Node): ParsedCommand {
const parts: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
// 提取命令名和参数
if (
child.type === 'command_name' ||
child.type === 'word' ||
child.type === 'string' ||
child.type === 'raw_string' ||
child.type === 'concatenation' ||
child.type === 'simple_expansion' ||
child.type === 'expansion'
) {
// 对于字符串类型,提取内部文本(去掉引号)
if (child.type === 'string' || child.type === 'raw_string') {
const text = child.text;
if ((text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))) {
parts.push(text.slice(1, -1));
} else {
parts.push(text);
}
} else {
parts.push(child.text);
}
}
}
const name = parts[0] || '';
// 找到第一个非 flag 参数作为子命令
let subcommand: string | undefined;
const args: string[] = [];
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
if (!subcommand && !part.startsWith('-')) {
subcommand = part;
} else {
args.push(part);
}
}
return {
name,
subcommand,
args,
text: node.text,
};
}
/**
* 递归查找所有命令节点
*/
function findCommandNodes(node: Node): Node[] {
const commands: Node[] = [];
if (node.type === 'command') {
commands.push(node);
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) {
commands.push(...findCommandNodes(child));
}
}
return commands;
}
/**
* 解析 bash 命令字符串
*/
export async function parseBashCommand(command: string): Promise<ParseResult> {
try {
await initParser();
if (!parserInstance) {
return {
commands: [],
success: false,
error: 'Parser not initialized',
};
}
const tree = parserInstance.parse(command);
if (!tree) {
return {
commands: [],
success: false,
error: 'Failed to parse command',
};
}
const commandNodes = findCommandNodes(tree.rootNode);
const commands = commandNodes.map(extractCommandFromNode);
return {
commands,
success: true,
};
} catch (error) {
return {
commands: [],
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* 简单解析(用于降级,当 tree-sitter 不可用时)
*/
export function parseCommandSimple(command: string): ParsedCommand {
const parts = command.trim().split(/\s+/);
const name = parts[0] || '';
let subcommand: string | undefined;
const args: string[] = [];
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
if (!subcommand && !part.startsWith('-')) {
subcommand = part;
} else {
args.push(part);
}
}
return { name, subcommand, args, text: command };
}
/**
* 检查解析器是否已初始化
*/
export function isParserInitialized(): boolean {
return parserInstance !== null && bashLanguage !== null;
}
@@ -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 };
}
}
+186
View File
@@ -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);
}
}
+24
View File
@@ -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';
+173
View File
@@ -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;
}
+79
View File
@@ -0,0 +1,79 @@
import * as readline from 'readline';
import chalk from 'chalk';
import type { PermissionContext, PermissionDecision } from './types.js';
/**
* 在终端中提示用户确认权限
*/
export async function promptPermission(ctx: PermissionContext): Promise<PermissionDecision> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
console.log('');
console.log(chalk.yellow('⚠️ 权限确认'));
console.log(chalk.cyan('命令: ') + chalk.white(ctx.command));
console.log(chalk.cyan('目录: ') + chalk.gray(ctx.workdir));
if (ctx.externalPaths && ctx.externalPaths.length > 0) {
console.log(chalk.red('⚠️ 此命令访问项目目录外的路径:'));
ctx.externalPaths.forEach(p => {
console.log(chalk.red(' • ') + chalk.gray(p));
});
}
if (ctx.patterns && ctx.patterns.length > 0) {
console.log(chalk.gray('匹配模式: ') + ctx.patterns.join(', '));
}
console.log('');
console.log(chalk.white('选择操作:'));
console.log(chalk.green(' [y] ') + '允许执行');
console.log(chalk.green(' [Y] ') + '允许执行,并记住此类命令(本次会话)');
console.log(chalk.red(' [n] ') + '拒绝执行');
console.log(chalk.red(' [N] ') + '拒绝执行,并记住此类命令(本次会话)');
console.log('');
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
rl.close();
const choice = answer.trim();
switch (choice) {
case 'y':
resolve({ allow: true, remember: false });
break;
case 'Y':
resolve({ allow: true, remember: true });
break;
case 'N':
resolve({ allow: false, remember: true });
break;
case 'n':
default:
resolve({ allow: false, remember: false });
break;
}
});
});
}
/**
* 显示权限被拒绝的消息
*/
export function showPermissionDenied(command: string, reason: string): void {
console.log('');
console.log(chalk.red('🚫 权限被拒绝'));
console.log(chalk.cyan('命令: ') + chalk.white(command));
console.log(chalk.cyan('原因: ') + chalk.gray(reason));
console.log('');
}
/**
* 显示权限允许的消息
*/
export function showPermissionAllowed(command: string): void {
console.log(chalk.green('✓ ') + chalk.gray(`执行: ${command}`));
}
+149
View File
@@ -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;
}
+157
View File
@@ -0,0 +1,157 @@
import type { PermissionAction, PermissionRule } from './types.js';
import { parseBashCommand, parseCommandSimple, type ParsedCommand } from './bash-parser.js';
/**
* 将通配符模式转换为正则表达式
* 支持 * 匹配任意字符
*/
function patternToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // 转义特殊字符
.replace(/\*/g, '.*') // * -> .*
.replace(/\?/g, '.'); // ? -> .
return new RegExp(`^${escaped}$`, 'i');
}
/**
* 检查命令是否匹配模式
*/
export function matchPattern(command: string, pattern: string): boolean {
const regex = patternToRegex(pattern);
return regex.test(command);
}
/**
* 从命令中提取命令名和子命令(简单版本,用于向后兼容)
*/
export function parseCommand(command: string): { head: string; sub?: string; args: string[] } {
const parsed = parseCommandSimple(command);
return {
head: parsed.name,
sub: parsed.subcommand,
args: parsed.args,
};
}
/**
* 生成用于权限请求的模式
* 例如: "git push origin" -> "git push *"
*/
export function generateAskPattern(command: string): string {
const parsed = parseCommandSimple(command);
if (parsed.subcommand) {
return `${parsed.name} ${parsed.subcommand} *`;
}
return `${parsed.name} *`;
}
/**
* 生成用于权限请求的模式(从 ParsedCommand
*/
export function generateAskPatternFromParsed(parsed: ParsedCommand): string {
if (parsed.subcommand) {
return `${parsed.name} ${parsed.subcommand} *`;
}
return `${parsed.name} *`;
}
/**
* 检查单个命令是否匹配规则
*/
function matchSingleCommand(
parsed: ParsedCommand,
rules: PermissionRule[],
defaultAction: PermissionAction
): { action: PermissionAction; matchedPattern?: string } {
// 构建可能的匹配字符串
const candidates = [
parsed.text, // 完整命令
parsed.subcommand
? `${parsed.name} ${parsed.subcommand}`
: parsed.name, // 命令 + 子命令
parsed.name, // 仅命令名
];
for (const rule of rules) {
for (const candidate of candidates) {
if (matchPattern(candidate, rule.pattern)) {
return { action: rule.action, matchedPattern: rule.pattern };
}
}
}
return { action: defaultAction };
}
/**
* 检查命令是否匹配规则列表,返回对应的动作(同步版本,使用简单解析)
*/
export function matchRules(
command: string,
rules: PermissionRule[],
defaultAction: PermissionAction
): { action: PermissionAction; matchedPattern?: string } {
const parsed = parseCommandSimple(command);
return matchSingleCommand(parsed, rules, defaultAction);
}
/**
* 使用 tree-sitter 解析并检查所有命令的权限(异步版本)
* 返回最严格的权限要求
*/
export async function matchRulesAsync(
command: string,
rules: PermissionRule[],
defaultAction: PermissionAction
): Promise<{
action: PermissionAction;
matchedPattern?: string;
allCommands: ParsedCommand[];
askPatterns: string[];
}> {
const result = await parseBashCommand(command);
// 如果解析失败,降级到简单解析
if (!result.success || result.commands.length === 0) {
const parsed = parseCommandSimple(command);
const match = matchSingleCommand(parsed, rules, defaultAction);
return {
...match,
allCommands: [parsed],
askPatterns: match.action === 'ask' ? [generateAskPatternFromParsed(parsed)] : [],
};
}
// 检查所有命令,收集结果
let finalAction: PermissionAction = 'allow';
let finalPattern: string | undefined;
const askPatterns: string[] = [];
for (const parsed of result.commands) {
// 跳过 cd 命令(如果通过了外部目录检查)
if (parsed.name === 'cd') {
continue;
}
const match = matchSingleCommand(parsed, rules, defaultAction);
// 权限优先级: deny > ask > allow
if (match.action === 'deny') {
finalAction = 'deny';
finalPattern = match.matchedPattern;
break; // deny 直接终止
} else if (match.action === 'ask' && finalAction === 'allow') {
finalAction = 'ask';
finalPattern = match.matchedPattern;
askPatterns.push(generateAskPatternFromParsed(parsed));
}
// allow 不改变已有的更严格权限
}
return {
action: finalAction,
matchedPattern: finalPattern,
allCommands: result.commands,
askPatterns: [...new Set(askPatterns)], // 去重
};
}