5e32375f0e
架构变更: - 采用 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 - 配置管理
306 lines
8.7 KiB
TypeScript
306 lines
8.7 KiB
TypeScript
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 };
|
|
}
|
|
}
|