Files
metona-ai-desktop/electron/harness/tools/built-in/command.ts
T

433 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 命令工具(1 个)
*
* run_command — 在沙箱环境中执行 Shell 命令
*
* 安全增强(v0.2.0):
* 1. 接入 SandboxManager.scanCode 进行静态代码安全扫描
* 2. 通过 SandboxManager.validatePath 校验工作目录
* 3. 命令注入模式检测扩展
*
* C-4 修复(v0.3.1):双层命令注入防御
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则
*/
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import { resolve } from 'path';
import { parse as shellQuoteParse } from 'shell-quote';
import log from 'electron-log';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
import type { SandboxManager } from '../../sandbox/sandbox';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// ===== Windows 编码智能解码 =====
// 某些 Windows 程序(dir、systeminfo、ipconfig 等)不响应 chcp 65001
// 仍按系统代码页(GBK/CP936)输出字节流。
// 策略:先尝试严格 UTF-8 解码,失败则用 GBK 解码,最后降级为 UTF-8 宽松模式。
function decodeBuffer(buf: Buffer): string {
if (buf.length === 0) return '';
try {
// 严格模式:包含非法 UTF-8 字节序列时抛错
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
} catch {
// UTF-8 解码失败 → 用 GBK/CP936 解码(Windows 中文系统默认代码页)
try {
return new TextDecoder('gbk').decode(buf);
} catch {
// 最终降级:UTF-8 宽松模式(用 替换字符 代替非法字节)
return buf.toString('utf-8');
}
}
}
/**
* #9 修复 + 审查修复: 构建安全的子进程环境变量
*
* 审查修复: 原白名单方案遗漏了 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量,导致子进程功能破坏。
* 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
*/
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
// 敏感变量后缀黑名单
const SENSITIVE_SUFFIXES = [
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
];
// 敏感变量名黑名单(精确匹配)
const SENSITIVE_KEYS = new Set([
'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY',
'GITEA_PASSWORD', 'DATABASE_PASSWORD',
]);
const env: Record<string, string> = {};
for (const [key, val] of Object.entries(process.env)) {
if (!val) continue;
if (SENSITIVE_KEYS.has(key)) continue;
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
env[key] = val;
}
// 添加必要的运行时变量
env.NODE_ENV = 'production';
if (isWindows) {
env.PYTHONIOENCODING = 'utf-8';
env.LANG = 'zh_CN.UTF-8';
env.LC_ALL = 'zh_CN.UTF-8';
}
return env;
}
// ===== 9. run_command =====
export class RunCommandTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'run_command',
description: 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation. Passes through SandboxManager static code scan and path validation.',
parameters: {
type: 'object',
properties: {
command: { type: 'string', description: 'Shell command to execute' },
workdir: { type: 'string', description: 'Working directory (default: workspace root)' },
timeout: { type: 'number', description: 'Timeout in milliseconds (default 120000)' },
},
required: ['command'],
},
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 120_000,
};
/** v0.2.0: 可选注入 SandboxManager 进行双重安全校验 */
private sandboxManager: SandboxManager | null = null;
/** 注入 SandboxManager(由 main.ts 在注册时调用) */
setSandboxManager(manager: SandboxManager): void {
this.sandboxManager = manager;
}
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const command = args.command as string;
const workdir = (args.workdir as string) ?? context.workspacePath;
const timeout = Math.min(300_000, Math.max(1_000, (args.timeout as number) ?? 120_000));
// 安全校验:workdir 必须在工作空间内
const resolvedWorkdir = resolve(context.workspacePath, workdir);
if (!isPathWithinWorkspace(workdir, context.workspacePath)) {
return { success: false, error: `Working directory must be within workspace: ${workdir}`, command };
}
// v0.2.0: SandboxManager 双重安全校验 — fail-closed 设计
// 若 SandboxManager 未注入(配置错误),拒绝执行高风险命令
if (!this.sandboxManager) {
log.error('[RunCommandTool] SandboxManager not injected — refusing to execute (fail-closed)');
return {
success: false,
error: 'Security sandbox unavailable — command execution disabled',
command,
};
}
// SandboxManager 静态代码扫描
const scanResult = this.sandboxManager.scanCode(command);
if (!scanResult.safe) {
return { success: false, error: 'Command blocked by security policy', command };
}
// SandboxManager 路径校验
const pathResult = this.sandboxManager.validatePath(resolvedWorkdir);
if (!pathResult.allowed) {
return { success: false, error: 'Command blocked by security policy', command };
}
// 命令安全校验(内置硬阻止列表)
const validation = this.validateCommand(command);
if (!validation.allowed) {
return { success: false, error: validation.reason, command };
}
try {
// Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页
// 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码
const isWindows = process.platform === 'win32';
// #8 修复: 优先使用 execFile(不经过 shell,从根本上防止命令注入)
// 仅当命令为简单命令(无管道、重定向、&& 等 shell 运算符)时使用 execFile
// 复杂命令(含 shell 语法)降级到 exec(已有 SandboxManager + validateCommand 双层校验)
const simpleCmd = this.parseCommandSimple(command);
// 使用 encoding: 'buffer' 获取原始字节,手动智能解码
// 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流
// #9 修复: 不透传完整 process.env,仅保留子进程运行所需的最小环境变量集合
// 防止 API keys / tokens / passwords 通过环境变量泄露给子进程
const execEnv = buildSafeCommandEnv(isWindows);
const execOpts = {
cwd: resolvedWorkdir,
timeout,
maxBuffer: 1024 * 1024, // 1MB
encoding: 'buffer' as const, // 返回 Buffer 而非字符串,便于智能解码
env: execEnv,
};
let stdout: Buffer;
let stderr: Buffer;
// #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入)
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT
// 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验)
// 非 Windows 上对简单命令用 execFile
if (simpleCmd && !isWindows) {
const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts);
stdout = result.stdout;
stderr = result.stderr;
} else {
// 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec
// 已有 SandboxManager.scanCode + validateCommand 双层安全校验
const finalCommand = isWindows
? `chcp 65001 >nul 2>&1 && ${command}`
: command;
const result = await execAsync(finalCommand, execOpts);
stdout = result.stdout;
stderr = result.stderr;
}
return {
success: true,
stdout: decodeBuffer(stdout).slice(0, 50_000),
stderr: decodeBuffer(stderr).slice(0, 10_000),
command,
};
} catch (error) {
const err = error as { message: string; code?: number; stdout?: Buffer; stderr?: Buffer };
return {
success: false,
error: err.message,
exit_code: err.code,
stdout: decodeBuffer(err.stdout ?? Buffer.alloc(0)).slice(0, 10_000),
stderr: decodeBuffer(err.stderr ?? Buffer.alloc(0)).slice(0, 10_000),
command,
};
}
}
/**
* 命令安全校验
*
* C-4 修复: 采用双层防御
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
* 可防御所有字符串拼接绕过(如 `r"m" -rf /`、`$'rm'`、`$(echo rm)`、`r''m`
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法和 shell-quote 无法解析的场景
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — run_command 安全规则
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
*/
private validateCommand(command: string): { allowed: boolean; reason?: string } {
const cmd = command.trim().toLowerCase();
// 受保护文件检查:禁止通过命令行读写工作空间根目录的 MEMORY.md
if (commandTouchesProtectedFile(command)) {
return { allowed: false, reason: 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution' };
}
// ===== 主层: shell-quote token-level 检测 =====
// 解析失败(Windows cmd 语法等)时降级到正则补充层
const tokenBlock = this.checkTokens(command);
if (tokenBlock !== null) return tokenBlock;
// ===== 补充层: 原正则检测(保留所有原模式) =====
// 覆盖 shell-quote 无法解析的场景:
// - Windows cmd 语法(&、|、>nul、chcp 等)
// - 复杂管道序列(curl ... | sh
// - Fork bomb 等特殊语法
const hardBlocks = [
// 文件系统破坏
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
{ pattern: /\brm\s+-rf?\s+\/(?:[^|;&\s]*\s)*?(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/i, reason: 'rm on system directories is forbidden' },
{ pattern: /\b(sudo|su|doas)\b/, reason: 'Privilege escalation commands are forbidden' },
// 系统控制
{ pattern: /\b(shutdown|reboot|halt|poweroff)\b/, reason: 'System shutdown commands are forbidden' },
{ pattern: /\b(killall|pkill)\s+-9\b/, reason: 'Force kill all processes is forbidden' },
// 远程代码执行
{ pattern: /curl.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
{ pattern: /wget.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
{ pattern: /\bcurl\s+.*\s*-o\s+\/etc\//i, reason: 'Writing to system directories via curl is forbidden' },
// 设备文件
{ pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
// 磁盘格式化
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
// 权限滥用
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' },
{ pattern: /\bchown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: 'Recursive chown on root is forbidden' },
// 环境变量窃取
{ pattern: /\b(env|export|printenv)\s*\|.*\b(curl|wget|nc|ncat)\b/i, reason: 'Exfiltrating environment variables is forbidden' },
// 反向 shell
{ pattern: /\b(bash|sh|zsh)\s+-i\s+>\s*&\s*\/dev\/tcp\//i, reason: 'Reverse shell via /dev/tcp is forbidden' },
{ pattern: /\bnc\s+.*\s+-e\s+(bash|sh)/i, reason: 'Reverse shell via netcat is forbidden' },
// Windows 危险命令
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
{ pattern: /\bshutdown\s*\//i, reason: 'System shutdown commands are forbidden' },
{ pattern: /\breg\s+(add|delete|import|restore)/i, reason: 'Registry modification commands are forbidden' },
{ pattern: /\b(taskkill|kill)\s*\//i, reason: 'Process termination with system flags is forbidden' },
{ pattern: /\bpowershell\s+-enc\s+/i, reason: 'PowerShell encoded command execution is forbidden' },
// 后台进程与管道炸弹
{ pattern: /&\s*\(/, reason: 'Background subshell execution is forbidden' },
{ pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' },
{ pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, reason: 'Fork bomb is forbidden' },
];
for (const block of hardBlocks) {
if (block.pattern.test(cmd)) {
return { allowed: false, reason: block.reason };
}
}
return { allowed: true };
}
/**
* C-4 修复: shell-quote token-level 安全检测
*
* 将命令解析为 token 数组,提取所有命令名和参数(忽略 shell 运算符),
* 对每个 token 做精确匹配。可防御所有字符串拼接绕过:
* - `r"m" -rf /` → 解析为 ['rm', '-rf', '/'] → 命中 rm 检测
* - `$'rm'` → 解析为 ['rm'] → 命中 rm 检测
* - `$(echo rm) -rf /` → 命令替换会被 shell-quote 识别为运算符序列
*
* @returns null 表示通过检测;非 null 表示被阻止(含 reason
*/
private checkTokens(command: string): { allowed: boolean; reason?: string } | null {
let tokens: ReturnType<typeof shellQuoteParse>;
try {
tokens = shellQuoteParse(command);
} catch {
// 解析失败(Windows cmd 语法、不完整的引号等)— 降级到正则补充层
return null;
}
// 提取所有 word token(命令名和参数),忽略运算符(&&、|、; 等)
// 同时跟踪管道运算符,检测危险组合(如 `| sh`)
const words: string[] = [];
let prevWasPipe = false;
for (const entry of tokens) {
if (typeof entry === 'string') {
// 裸字符串 token — 若前一 token 是管道,检测是否为 sh/bash(远程代码执行)
if (prevWasPipe && /^(ba)?sh$/i.test(entry)) {
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
}
words.push(entry);
prevWasPipe = false;
} else if (typeof entry === 'object' && entry !== null) {
const obj = entry as { word?: unknown; op?: unknown };
if (typeof obj.word === 'string') {
// 引号包裹或变量替换后的 token
if (prevWasPipe && /^(ba)?sh$/i.test(obj.word)) {
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
}
words.push(obj.word);
prevWasPipe = false;
} else if (typeof obj.op === 'string') {
// 跟踪管道运算符,用于下一轮检测 `| sh`
prevWasPipe = (obj.op === '|');
}
}
}
// 危险命令名 token(精确匹配,大小写不敏感)
const dangerousCommands = new Set([
'sudo', 'su', 'doas',
'shutdown', 'reboot', 'halt', 'poweroff',
'mkfs', 'fdisk', 'format', 'diskpart',
]);
// 危险参数 token
const dangerousArgs = new Set([
'-enc', '-encodedcommand', // PowerShell 编码执行
]);
for (const word of words) {
const lower = word.toLowerCase();
// 1. 危险命令名(精确匹配,或 mkfs/fdisk 前缀匹配如 mkfs.ext4
if (dangerousCommands.has(lower) || /^(mkfs|fdisk)\b/.test(lower)) {
if (['sudo', 'su', 'doas'].includes(lower)) {
return { allowed: false, reason: 'Privilege escalation commands are forbidden' };
}
if (['shutdown', 'reboot', 'halt', 'poweroff'].includes(lower)) {
return { allowed: false, reason: 'System shutdown commands are forbidden' };
}
// mkfs/fdisk/format/diskpart + mkfs.ext4 等前缀匹配
return { allowed: false, reason: 'Disk formatting commands are forbidden' };
}
// 2. 危险参数(精确匹配)
if (dangerousArgs.has(lower)) {
return { allowed: false, reason: 'PowerShell encoded command execution is forbidden' };
}
// 3. dd 写设备文件:of=/dev/...(不依赖系统目录前置,独立检测)
if (/^of=\/dev\//.test(lower)) {
return { allowed: false, reason: 'Writing to device files is forbidden' };
}
}
// 4. 检测 rm 与系统目录的组合(token 序列检测)
// 例如: ['rm', '-rf', '/etc'] 应被拦截
let hasRm = false;
let hasSystemPath = false;
for (const word of words) {
const lower = word.toLowerCase();
if (lower === 'rm') hasRm = true;
if (/^\/(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/.test(lower)) {
hasSystemPath = true;
}
}
if (hasRm && hasSystemPath) {
return { allowed: false, reason: 'rm on system directories is forbidden' };
}
return null;
}
/**
* #8 修复: 将命令解析为简单的 command + args 数组
*
* 使用 shell-quote 解析命令,如果命令只包含 word tokens(无管道、重定向、&& 等 shell 运算符),
* 返回 { command, args } 供 execFile 使用(不经过 shell,从根本上防止命令注入)。
* 如果包含 shell 运算符或解析失败,返回 null,调用方降级到 exec(已有安全校验)。
*
* @returns 简单命令的 { command, args },或 null 表示复杂命令
*/
private parseCommandSimple(command: string): { command: string; args: string[] } | null {
let tokens: ReturnType<typeof shellQuoteParse>;
try {
tokens = shellQuoteParse(command);
} catch {
// 解析失败(Windows cmd 语法、不完整引号等)— 降级到 exec
return null;
}
const words: string[] = [];
for (const entry of tokens) {
if (typeof entry === 'string') {
words.push(entry);
} else if (typeof entry === 'object' && entry !== null) {
const obj = entry as { word?: unknown; op?: unknown };
if (typeof obj.word === 'string') {
words.push(obj.word);
} else if (typeof obj.op === 'string') {
// 遇到 shell 运算符(&&、|、;、> 等)— 复杂命令,降级到 exec
return null;
}
}
}
if (words.length === 0) return null;
return { command: words[0], args: words.slice(1) };
}
}