UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

449 lines (448 loc) 18.2 kB
/** * system-interaction.js — 系统交互工具 (3) * * 为 Agent 提供与本地操作系统交互的能力: * * 1. run_safe_command 安全执行终端命令 (受 SafetyPolicy 约束) * 2. write_project_file 写入/创建项目文件 (受文件范围约束) * 3. get_environment_info 获取运行环境信息 * * ⚠️ 安全设计: * - run_safe_command 在工具层即执行命令黑名单/白名单检查 * - write_project_file 在工具层即执行文件路径范围检查 * - 两者均依赖 AgentRuntime 注入的 safetyPolicy 上下文 * - 即使 safetyPolicy 未注入,工具自身也有基础安全兜底 * * @module system-interaction */ import { execFile } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); // ─── 常量 ──────────────────────────────────────────── /** 工具层兜底: 始终拒绝的危险命令模式 (无论 SafetyPolicy 是否注入) */ const HARDCODED_BLACKLIST = [ /\brm\s+-rf\s+[/~]/, /\bsudo\b/, /\bmkfs\b/, /\bdd\s+if=/, /\b(shutdown|reboot|halt)\b/, />\s*\/dev\//, /\bcurl\b.*\|\s*(bash|sh)/, /\bchmod\s+777/, /\bpasswd\b/, /\bkillall\b/, /\bfork\s*bomb/i, /:\(\)\s*\{\s*:\|:\s*&\s*\}\s*;/, // fork bomb pattern ]; /** 工具层兜底: 无 SafetyPolicy 时仅允许的安全命令前缀 */ const FALLBACK_SAFE_PREFIXES = [ 'ls', 'cat', 'head', 'tail', 'grep', 'find', 'wc', 'echo', 'pwd', 'date', 'which', 'file', 'stat', 'git log', 'git status', 'git diff', 'git branch', 'git show', 'npm list', 'npm outdated', 'node -v', 'npm -v', 'python --version', 'python3 --version', 'env', 'printenv', ]; /** 命令执行超时 (ms) */ const COMMAND_TIMEOUT = 30_000; /** 输出截断长度 (bytes) */ const MAX_OUTPUT_LENGTH = 16_000; /** 文件写入最大尺寸 (bytes) */ const MAX_WRITE_SIZE = 512 * 1024; // ─── 内部工具函数 ──────────────────────────────────── /** 硬编码黑名单检查 — 工具层兜底, 无论是否有 SafetyPolicy 都生效 */ function _isHardBlacklisted(command) { for (const pattern of HARDCODED_BLACKLIST) { if (pattern.test(command)) { return true; } } return false; } /** 无 SafetyPolicy 时的白名单兜底 */ function _isFallbackSafe(command) { const trimmed = command.trim(); return FALLBACK_SAFE_PREFIXES.some((prefix) => trimmed.startsWith(prefix)); } /** 截断过长输出 */ function _truncate(text, max = MAX_OUTPUT_LENGTH) { if (!text || text.length <= max) { return text; } return `${text.slice(0, max)}\n\n... [输出已截断, 共 ${text.length} 字符]`; } /** 获取 projectRoot — 优先从 context 获取, 兜底用 cwd */ function _getProjectRoot(ctx) { return ctx.projectRoot || ctx.container?.get?.('projectRoot') || process.cwd(); } // ═══════════════════════════════════════════════════════ // 1. run_safe_command — 安全执行终端命令 // ═══════════════════════════════════════════════════════ export const runSafeCommand = { name: 'run_safe_command', description: '在项目目录下安全执行终端命令。' + '命令受安全策略约束: 危险命令(sudo/rm -rf/shutdown 等)被自动拦截。' + '适用于: 查看 git 状态、运行测试、检查依赖版本、执行构建等。' + '超时 30 秒, 输出超过 16KB 会被截断。' + '如果需要管道或重定向, 请用 sh -c "..." 包装。', parameters: { type: 'object', properties: { command: { type: 'string', description: '要执行的终端命令, 如 "git status" 或 "npm test"', }, cwd: { type: 'string', description: '工作目录 (相对于项目根目录), 缺省为项目根目录', }, timeout: { type: 'number', description: '超时时间(毫秒), 默认 30000', }, }, required: ['command'], }, handler: async (params, ctx) => { const { command, cwd, timeout } = params; const projectRoot = _getProjectRoot(ctx); if (!command || typeof command !== 'string' || command.trim().length === 0) { return { error: '命令不能为空' }; } // ── 安全检查 Layer 1: 硬编码黑名单 (无条件拦截) ── if (_isHardBlacklisted(command)) { return { error: `安全拦截: 命令 "${command}" 匹配危险模式, 已被阻止执行` }; } // ── 安全检查 Layer 2: SafetyPolicy (如果注入) ── const safetyPolicy = ctx.safetyPolicy || null; if (safetyPolicy) { const check = safetyPolicy.checkCommand(command); if (!check.safe) { return { error: `SafetyPolicy 拦截: ${check.reason}` }; } } else { // 无 SafetyPolicy 时使用白名单兜底 if (!_isFallbackSafe(command)) { return { error: `无安全策略: 命令 "${command}" 不在安全白名单中。` + `允许的命令前缀: ${FALLBACK_SAFE_PREFIXES.join(', ')}`, }; } } // ── 解析工作目录 ── let workDir = projectRoot; if (cwd) { workDir = path.isAbsolute(cwd) ? cwd : path.resolve(projectRoot, cwd); // 范围检查 if (!workDir.startsWith(path.resolve(projectRoot))) { return { error: `工作目录 "${cwd}" 超出项目范围 "${projectRoot}"` }; } } if (!fs.existsSync(workDir)) { return { error: `工作目录 "${workDir}" 不存在` }; } // ── 执行命令 ── const effectiveTimeout = timeout || COMMAND_TIMEOUT; try { const { stdout, stderr } = await execFileAsync('sh', ['-c', command], { cwd: workDir, timeout: effectiveTimeout, maxBuffer: 1024 * 1024, // 1MB 缓冲 env: { ...process.env, // 禁用交互式 pager GIT_PAGER: 'cat', PAGER: 'cat', LESS: '-FRX', }, }); return { exitCode: 0, stdout: _truncate(stdout), stderr: _truncate(stderr), command, cwd: workDir, }; } catch (err) { const execErr = err; // 超时 if (execErr.killed) { return { error: `命令执行超时 (${effectiveTimeout}ms)`, command, stdout: _truncate(execErr.stdout || ''), stderr: _truncate(execErr.stderr || ''), }; } // 非零退出 return { exitCode: execErr.code ?? 1, stdout: _truncate(execErr.stdout || ''), stderr: _truncate(execErr.stderr || execErr.message || ''), command, cwd: workDir, }; } }, }; // ═══════════════════════════════════════════════════════ // 2. write_project_file — 写入项目文件 // ═══════════════════════════════════════════════════════ export const writeProjectFile = { name: 'write_project_file', description: '在项目目录内创建或覆盖写入文件。' + '自动创建不存在的中间目录。文件路径必须在项目范围内。' + '适用于: 生成配置文件、创建代码文件、写入分析报告等。' + '最大写入 512KB。', parameters: { type: 'object', properties: { filePath: { type: 'string', description: '目标文件路径 (相对于项目根目录或绝对路径)', }, content: { type: 'string', description: '要写入的文件内容', }, append: { type: 'boolean', description: '是否追加模式 (默认 false = 覆盖写入)', }, }, required: ['filePath', 'content'], }, handler: async (params, ctx) => { const { filePath, content, append } = params; const projectRoot = _getProjectRoot(ctx); if (!filePath || typeof filePath !== 'string') { return { error: '文件路径不能为空' }; } if (typeof content !== 'string') { return { error: '文件内容必须为字符串' }; } // ── 大小限制 ── if (Buffer.byteLength(content, 'utf-8') > MAX_WRITE_SIZE) { return { error: `文件内容超过大小限制 (${MAX_WRITE_SIZE / 1024}KB)` }; } // ── 路径解析与安全检查 ── const resolved = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(projectRoot, filePath); const scopeRoot = path.resolve(projectRoot); if (!resolved.startsWith(scopeRoot + path.sep) && resolved !== scopeRoot) { return { error: `文件路径 "${filePath}" 超出项目范围 "${projectRoot}"` }; } // SafetyPolicy 路径检查 const safetyPolicy = ctx.safetyPolicy || null; if (safetyPolicy) { const check = safetyPolicy.checkFilePath(resolved); if (!check.safe) { return { error: `SafetyPolicy 拦截: ${check.reason}` }; } } // ── 危险路径兜底 ── const dangerousPatterns = [ /node_modules\//, /\.git\//, /\.env$/, /\.env\.local$/, /package-lock\.json$/, /yarn\.lock$/, /pnpm-lock\.yaml$/, ]; const relPath = path.relative(scopeRoot, resolved); for (const p of dangerousPatterns) { if (p.test(relPath)) { return { error: `安全拦截: 不允许写入 "${relPath}" (匹配受保护路径模式)` }; } } // ── 写入文件 ── try { // 确保目录存在 const dir = path.dirname(resolved); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } if (append) { fs.appendFileSync(resolved, content, 'utf-8'); } else { fs.writeFileSync(resolved, content, 'utf-8'); } const stat = fs.statSync(resolved); return { success: true, filePath: relPath, absolutePath: resolved, size: stat.size, mode: append ? 'append' : 'overwrite', }; } catch (err) { return { error: `写入文件失败: ${err.message}` }; } }, }; // ═══════════════════════════════════════════════════════ // 3. get_environment_info — 获取运行环境信息 // ═══════════════════════════════════════════════════════ export const getEnvironmentInfo = { name: 'get_environment_info', description: '获取当前运行环境的系统信息。' + '包括: 操作系统、Node.js 版本、项目路径、Git 分支、依赖管理器等。' + '适用于: 环境诊断、构建问题排查、项目状态检查。', parameters: { type: 'object', properties: { sections: { type: 'array', items: { type: 'string', enum: ['os', 'node', 'git', 'project', 'all'], }, description: '要获取的信息部分, 默认 ["all"]', }, }, required: [], }, handler: async (params, ctx) => { const sections = params.sections || ['all']; const all = sections.includes('all'); const projectRoot = _getProjectRoot(ctx); const info = {}; // ── OS 信息 ── if (all || sections.includes('os')) { info.os = { platform: os.platform(), arch: os.arch(), release: os.release(), hostname: os.hostname(), uptime: `${Math.floor(os.uptime() / 3600)}h ${Math.floor((os.uptime() % 3600) / 60)}m`, memory: { total: `${Math.round(os.totalmem() / (1024 * 1024 * 1024))}GB`, free: `${Math.round(os.freemem() / (1024 * 1024 * 1024))}GB`, }, cpus: os.cpus().length, shell: process.env.SHELL || process.env.COMSPEC || 'unknown', }; } // ── Node 信息 ── if (all || sections.includes('node')) { info.node = { version: process.version, execPath: process.execPath, pid: process.pid, env: { NODE_ENV: process.env.NODE_ENV || 'unset', npm_package_version: process.env.npm_package_version || 'N/A', }, }; // npm/pnpm/yarn 版本 for (const pm of ['npm', 'pnpm', 'yarn']) { try { const { stdout } = await execFileAsync(pm, ['--version'], { timeout: 5000, }); info.node[`${pm}_version`] = stdout.trim(); } catch { // 未安装, 跳过 } } } // ── Git 信息 ── if (all || sections.includes('git')) { info.git = {}; try { const { stdout: branch } = await execFileAsync('git', ['branch', '--show-current'], { cwd: projectRoot, timeout: 5000, }); info.git.branch = branch.trim(); const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], { cwd: projectRoot, timeout: 5000, }); const lines = status.trim().split('\n').filter(Boolean); info.git.dirty = lines.length > 0; info.git.changedFiles = lines.length; const { stdout: lastCommit } = await execFileAsync('git', ['log', '-1', '--format=%h %s (%cr)'], { cwd: projectRoot, timeout: 5000 }); info.git.lastCommit = lastCommit.trim(); const { stdout: remoteUrl } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd: projectRoot, timeout: 5000, }); info.git.remote = remoteUrl.trim(); } catch { info.git.error = '非 Git 仓库或 Git 未安装'; } } // ── 项目信息 ── if (all || sections.includes('project')) { info.project = { root: projectRoot, }; // package.json const pkgPath = path.join(projectRoot, 'package.json'); if (fs.existsSync(pkgPath)) { try { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); info.project.name = pkg.name; info.project.version = pkg.version; info.project.type = pkg.type || 'commonjs'; info.project.dependencies = Object.keys(pkg.dependencies || {}).length; info.project.devDependencies = Object.keys(pkg.devDependencies || {}).length; } catch { /* invalid package.json */ } } // Podfile / Cartfile / build.gradle / CMakeLists / Makefile 检测 const projectIndicators = [ { file: 'Podfile', type: 'CocoaPods (iOS)' }, { file: 'Cartfile', type: 'Carthage (iOS)' }, { file: 'Package.swift', type: 'Swift Package Manager' }, { file: 'build.gradle', type: 'Gradle (Android/Java)' }, { file: 'pom.xml', type: 'Maven (Java)' }, { file: 'CMakeLists.txt', type: 'CMake (C/C++)' }, { file: 'Makefile', type: 'Make' }, { file: 'Cargo.toml', type: 'Cargo (Rust)' }, { file: 'go.mod', type: 'Go Modules' }, { file: 'requirements.txt', type: 'pip (Python)' }, { file: 'pyproject.toml', type: 'Python project' }, { file: 'Gemfile', type: 'Bundler (Ruby)' }, ]; info.project.buildSystems = projectIndicators .filter(({ file }) => fs.existsSync(path.join(projectRoot, file))) .map(({ type }) => type); } return info; }, };