📄 tools.ts  •  32177 bytes
/**
 * CmdCode V0.5 - 工具系统(沙箱强化版)
 * 
 * 五重资源限制:
 * 1. CPU  — 单次执行硬性超时 + ulimit CPU时间配额
 * 2. 内存  — ulimit 虚拟内存上限 256MB
 * 3. 进程  — 禁止fork炸弹 + ulimit 子进程数上限4
 * 4. 磁盘  — 单文件10MB + 项目总容量1GB + 文件数上限5000
 * 5. 网络  — 禁止内网探测 + 禁止危险网络工具(nc/ssh/nmap等)
 */
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync, lstatSync } from 'node:fs'
import { dirname, join, resolve, relative } from 'node:path'
import { homedir } from 'node:os'
import { execSync } from 'node:child_process'

// QQ Bot access_token 内存缓存
const _qqTokenCache: { token: string; expiresAt: number } = { token: '', expiresAt: 0 }

// ═══════════════════════════════════════════
// 沙箱配置
// ═══════════════════════════════════════════

/** 用户工作区根目录(由cli.ts启动时设置) */
let PROJECT_ROOT = resolve(process.cwd())

/** bash_run 跨调用工作目录追踪 — 支持 `cd` 命令跨调用持久化 */
let _bashCwd: string = PROJECT_ROOT

/** 动态检查路径是否在允许范围内(避免硬编码用户名) */
function isPathAllowed(path: string): boolean {
  const home = homedir()
  const allowed = [
    '/tmp',
    PROJECT_ROOT,
    join(home, '.cmdcode', 'workspaces'),
  ]
  return allowed.some(prefix => path.startsWith(prefix + '/') || path === prefix)
}

/** 当前登录用户名 */
let CURRENT_USERNAME = ''

/** 超级用户列表 - 不受任何沙箱限制 */
export const SUPER_USERS = ['admin', 'root', 'administrator']

/** 设置用户工作区根目录(登录后调用) */
export function setUserWorkspace(workspaceDir: string): void {
  PROJECT_ROOT = resolve(workspaceDir)
  _bashCwd = PROJECT_ROOT
}

/** 设置当前用户名(登录后调用) */
export function setUsername(username: string): void {
  CURRENT_USERNAME = username.toLowerCase()
}

/** 获取当前用户名 */
export function getUsername(): string {
  return CURRENT_USERNAME
}

/** 检查当前用户是否为超级用户 */
export function isSuperUser(): boolean {
  return SUPER_USERS.includes(CURRENT_USERNAME)
}

// 资源限制常量
const LIMITS = {
  CPU_TIMEOUT_SEC: 30,          // bash单次执行最大30秒(用户可设更短,不可更长)
  CPU_ULIMIT_SEC: 10,           // ulimit -t: CPU时间10秒硬上限
  MEMORY_MB: 256,               // ulimit -v: 虚拟内存256MB
  MAX_PROCESSES: 4,             // ulimit -u: 最大子进程数
  MAX_FILE_SIZE_MB: 10,         // 单文件最大10MB
  MAX_PROJECT_SIZE_MB: 100,     // 用户工作区总容量100MB(admin不受此限制)
  MAX_FILE_COUNT: 5000,         // 项目内最大文件数
  MAX_WRITE_BYTES: 10 * 1024 * 1024, // 单次写入最大10MB(字节)
  OUTPUT_MAX_LEN: 10000,        // 输出截断长度
  BASH_MAX_BUFFER: 5 * 1024 * 1024,  // bash输出缓冲5MB
}

// 网络白名单域名(仅允许这些域名的HTTPS请求)
const NETWORK_WHITELIST = [
  'registry.npmjs.org',
  'registry.npmmirror.com',
  'api.github.com',
  'gitclone.com',
  'pypi.org',
  'pypi.tuna.tsinghua.edu.cn',
  'ark.cn-beijing.volces.com',
  'cmdcode.cn',
  'www.cmdcode.cn',
]

// ═══════════════════════════════════════════
// 预编译正则(避免 checkBashCommand 每次调用重建)
// ═══════════════════════════════════════════
const FORK_BOMB_PATTERNS = [
  /\bfork\s*\(/,                          // fork() 系统调用
  /\b:\s*\(\)\s*\{.*:\s*\|\s*:\s*&/,     // :(){:|:&}: 经典fork炸弹
  /\bwhile\s+true\b.*\b(?:fork|spawn|child_process)\b/, // while true + fork/spawn(不含exec,避免误伤内置函数)
  /\bfor\b.*\bfork\b/,                    // for循环直接调用fork
  /\bxargs\s+(?:-I\s+?.*)?\s*(?:bash|sh)\b(?!\s+-c\s)/, // xargs bash/sh:排除 xargs -I {} bash -c(需配合后台符才算危险)
  /\bfind\s+.*-exec\s+(?:bash|sh|python|node|perl|ruby)\b/, // find -exec 运行解释器(危险)
  /\bparallel\b/,                          // GNU parallel 并行执行
  /\bnohup\s+(?!ttyd).*\s*&\s*$/,        // nohup后台运行(排除ttyd)
  /\b(?:bash|sh|node|python|perl|ruby)\s+.*\s*&\s*$/, // 脚本后台运行
]

const NETWORK_BLOCK_PATTERNS = [
  /\bnc\s+/,                   // netcat
  /\bncat\s+/,                 // ncat
  /\bnetcat\s+/,               // netcat
  /\bssh\s+/,                  // SSH
  /\bscp\s+/,                  // SCP
  /\brsync\s+/,                // rsync
  /\btraceroute\s+/,           // traceroute
  /\bnslookup\s+/,             // nslookup
  /\bwhois\s+/,                // whois
  /\biperf\b/,                 // iperf带宽测试
  /\betherwake\b/,             // etherwake
  /\barp(?:ing)?\s+/,          // ARP工具
  /\bnmap\b/,                  // 端口扫描
  /\bmasscan\b/,               // 批量扫描
  /\bzmap\b/,                  // 批量扫描
  /\b(?:python|node|perl|ruby)\s+.*(?:socket|Server|listen)\b/, // 服务器脚本
  /\bgunicorn\b/,              // WSGI服务器
  /\buvicorn\b/,               // ASGI服务器
  /\bflask\s+run\b/,           // Flask开发服务器
  /\bdjango\b.*\brunserver\b/, // Django开发服务器
]

const INTERNAL_NETWORK_PATTERNS = [
  /\b(?:curl|wget|ping)\s+.*(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)/,
  /\b(?:curl|wget)\s+.*(?:192\.168\.|10\.|172\.(?:1[6-9]|2\d|3[01])\.)/,
  /\bping\s+.*-(?:f|c\s+[0-9]{4,})/, // ping洪水或超多次数
]

const DANGEROUS_PATTERNS = [
  /\brm\s+-(?:rf|fr)\s+\//,
  /\b(?:cat|less|more|head|tail|vim|nano|vi)\s+\/etc\//,
  /\bchmod\s+[0-7]{3,4}\s+\//,
  /\bchown\s+/,
  /\bsudo\b/,
  /\bsu\s+/,
  /\bcurl\s+.*\|\s*(?:ba)?sh/,
  /\bwget\s+.*\|\s*(?:ba)?sh/,
  /\beval\s+/,
  /\b(?:bash|sh|zsh)\s+-c\s+/,
]

const CURL_WGET_RE = /\b(curl|wget)\s+/
const CURL_URL_RE = /\bcurl(?:\s+-[^\s]*)*\s+(?:https?:\/\/)?([a-zA-Z0-9.-]+)/
const WGET_URL_RE = /\bwget(?:\s+-[^\s]*)*\s+(?:https?:\/\/)?([a-zA-Z0-9.-]+)/
const CD_PATH_RE = /\bcd\s+(\/[^\s;|&]+)/
const READ_PATH_RE = /\b(?:cat|less|more|head|tail)\s+(\/[^\s]+)/

// ═══════════════════════════════════════════
// ═══════════════════════════════════════════

function safePath(inputPath: string): string | null {
  // 超级用户不受路径限制
  if (isSuperUser()) {
    return resolve(inputPath)
  }
  const resolved = resolve(inputPath)
  // 允许所有用户访问 /tmp/ 临时目录
  if (resolved.startsWith('/tmp/') || resolved === '/tmp') {
    return resolved
  }
  // 检查路径是否逃逸到 PROJECT_ROOT 之外
  const rel = relative(PROJECT_ROOT, resolved)
  const goesOutside = rel.startsWith('..') || resolve(resolved) !== resolve(join(PROJECT_ROOT, rel))

  if (goesOutside) {
    // 区分 symlink 和 bind mount:symlink 逃逸必须拦截,bind mount 可以放行
    // (bind mount 是管理员设置的合法工作区扩展,如 SWE-bench 实例仓库)
    try {
      const stat = lstatSync(resolved)
      if (stat.isSymbolicLink()) {
        // symlink 逃逸:拒绝
        return null
      }
      // 非 symlink 的外部路径(如 bind mount):放行,但记录警告
      console.warn(`[safePath] 放行非 symlink 外部路径: ${inputPath} → ${resolved}`)
      return resolved
    } catch {
      // lstat 失败(概率极低),默认拒绝
      return null
    }
  }
  return resolved
}

function checkPath(inputPath: string): { path: string } | { error: string } {
  const safe = safePath(inputPath)
  if (!safe) {
    return { error: `⛔ 安全限制:路径 "${inputPath}" 超出项目目录 ${PROJECT_ROOT},操作被拒绝` }
  }
  return { path: safe }
}

// ═══════════════════════════════════════════
// 4. 磁盘配额检查
// ═══════════════════════════════════════════

/** 磁盘统计缓存(5分钟有效) */
let diskStatsCache: { path: string; stats: { size: number; files: number }; timestamp: number } | null = null
const DISK_CACHE_TTL = 5 * 60 * 1000 // 5分钟缓存

// P1 #31: 文件写入并发锁 - 防止同时写入同一文件导致损坏
const fileLocks = new Map<string, Promise<void>>()

/** 安全写入文件(带并发锁) */
async function safeWriteFile(filePath: string, content: string): Promise<void> {
  // 等待该文件的任何正在进行的写入完成
  const existingLock = fileLocks.get(filePath)
  if (existingLock) {
    await existingLock
  }
  
  // 创建新的锁
  const writePromise = new Promise<void>((resolve) => {
    writeFileSync(filePath, content, 'utf-8')
    resolve()
  })
  
  fileLocks.set(filePath, writePromise)
  
  try {
    await writePromise
  } finally {
    fileLocks.delete(filePath)
  }
}

/** 计算目录总大小(字节)和文件数 */
function getDirStats(dirPath: string): { size: number; files: number } {
  // 检查缓存
  const now = Date.now()
  if (diskStatsCache && 
      diskStatsCache.path === dirPath && 
      now - diskStatsCache.timestamp < DISK_CACHE_TTL) {
    return diskStatsCache.stats
  }
  
  // 计算新值
  let totalSize = 0
  let totalFiles = 0
  try {
    const entries = readdirSync(dirPath, { withFileTypes: true })
    for (const entry of entries) {
      // 跳过 node_modules 和 .git(不计入配额)
      if (entry.name === 'node_modules' || entry.name === '.git') continue
      const fullPath = join(dirPath, entry.name)
      if (entry.isFile()) {
        try {
          totalSize += statSync(fullPath).size
          totalFiles++
        } catch { /* ignore */ }
      } else if (entry.isDirectory()) {
        const sub = getDirStats(fullPath)
        totalSize += sub.size
        totalFiles += sub.files
      }
    }
  } catch { /* ignore */ }
  
  // 更新缓存
  diskStatsCache = { path: dirPath, stats: { size: totalSize, files: totalFiles }, timestamp: now }
  
  return { size: totalSize, files: totalFiles }
}

/** 清除磁盘缓存(写入后调用) */
export function invalidateDiskCache(): void {
  diskStatsCache = null
}

/** 检查磁盘配额,写入前调用 */
function checkDiskQuota(additionalBytes: number): string | null {
  // 超级用户不受磁盘配额限制
  if (isSuperUser()) {
    return null
  }
  const stats = getDirStats(PROJECT_ROOT)
  
  // 文件数检查
  if (stats.files >= LIMITS.MAX_FILE_COUNT) {
    return `⛔ 磁盘限制:项目文件数已达上限 ${LIMITS.MAX_FILE_COUNT},无法创建新文件`
  }
  
  // 总容量检查
  const maxBytes = LIMITS.MAX_PROJECT_SIZE_MB * 1024 * 1024
  if (stats.size + additionalBytes > maxBytes) {
    const usedMB = (stats.size / 1024 / 1024).toFixed(1)
    return `⛔ 磁盘限制:项目已用 ${usedMB}MB / ${LIMITS.MAX_PROJECT_SIZE_MB}MB,写入将超出配额`
  }
  
  return null
}

// ═══════════════════════════════════════════
// 2. + 3. + 5. Bash命令安全检查
// ═══════════════════════════════════════════

function checkBashCommand(command: string): string | null {
  // 超级用户不受命令限制
  if (isSuperUser()) {
    return null
  }
  // ── 3. 进程爆炸防护 ──
  for (const pattern of FORK_BOMB_PATTERNS) {
    if (pattern.test(command)) {
      return `⛔ 进程限制:命令可能引发进程爆炸,被拒绝。匹配规则: ${pattern.source}`
    }
  }

  // ── 5. 网络滥用防护 ──
  // 完全禁止的网络命令
  for (const pattern of NETWORK_BLOCK_PATTERNS) {
    if (pattern.test(command)) {
      return `⛔ 网络限制:命令包含禁止的网络操作,被拒绝。匹配规则: ${pattern.source}`
    }
  }

  // ── curl/wget 域名白名单检查 ──
  // 已放开:所有用户均可使用 curl/wget 访问任意外网域名
  // 注意:危险网络工具(nc/ssh/nmap 等)仍被下方 NETWORK_BLOCK_PATTERNS 禁止

  // 禁止连接内网/本地
  for (const pattern of INTERNAL_NETWORK_PATTERNS) {
    if (pattern.test(command)) {
      return `⛔ 网络限制:禁止访问内网/本地地址,被拒绝`
    }
  }

  // ── 原有安全规则 ──
  for (const pattern of DANGEROUS_PATTERNS) {
    if (pattern.test(command)) {
      return `⛔ 安全限制:命令包含越界操作,被拒绝。匹配规则: ${pattern.source}`
    }
  }

  // 动态路径检查(替代硬编码正则)
  const cdMatch = command.match(CD_PATH_RE)
  if (cdMatch && !isPathAllowed(cdMatch[1])) {
    return `⛔ 安全限制:禁止 cd 到项目目录外: ${cdMatch[1]}`
  }
  const readFileMatch = command.match(READ_PATH_RE)
  if (readFileMatch && !isPathAllowed(readFileMatch[1])) {
    return `⛔ 安全限制:禁止读取项目目录外的文件: ${readFileMatch[1]}`
  }

  return null
}

// ═══════════════════════════════════════════
// Bash执行:注入ulimit资源限制
// ═══════════════════════════════════════════

/** 生成带ulimit限制的命令包装 */
function wrapWithResourceLimits(command: string, userTimeoutSec: number): string {
  // 超级用户不受资源限制
  if (isSuperUser()) {
    return command
  }
  const timeoutSec = Math.min(userTimeoutSec, LIMITS.CPU_TIMEOUT_SEC)
  // ulimit包装:
  // -t: CPU时间(秒) -v: 虚拟内存(KB) -u: 最大进程数 -f: 单文件大小(KB)
  const memKB = LIMITS.MEMORY_MB * 1024
  const fileSizeKB = LIMITS.MAX_FILE_SIZE_MB * 1024
  return `ulimit -t ${LIMITS.CPU_ULIMIT_SEC} -v ${memKB} -u ${LIMITS.MAX_PROCESSES} -f ${fileSizeKB} 2>/dev/null; timeout ${timeoutSec} bash -c ${JSON.stringify(command)}`
}

// ═══════════════════════════════════════════
// Tool 定义
// ═══════════════════════════════════════════

export interface ToolDef {
  name: string
  description: string
  parameters: {
    type: 'object'
    properties: Record<string, any>
    required: string[]
  }
}

export interface ToolCall {
  id: string
  name: string
  arguments: string
}

export interface ToolResult {
  tool_call_id: string
  content: string
}

const FILE_READ: ToolDef = {
  name: 'file_read',
  description: '读取文件内容。只能读取项目目录内的文件。单次最多读取1MB。',
  parameters: {
    type: 'object',
    properties: {
      path: { type: 'string', description: '要读取的文件路径' },
      offset: { type: 'number', description: '起始行号(1-based),默认1' },
      limit: { type: 'number', description: '最多读取行数,默认500' },
    },
    required: ['path'],
  },
}

const FILE_WRITE: ToolDef = {
  name: 'file_write',
  description: '写入文件。只能写入项目目录内。单文件最大10MB,项目总容量1GB。如果文件不存在会自动创建(包括父目录)。会完全覆盖文件内容。',
  parameters: {
    type: 'object',
    properties: {
      path: { type: 'string', description: '要写入的文件路径' },
      content: { type: 'string', description: '要写入的完整内容' },
    },
    required: ['path', 'content'],
  },
}

const FILE_EDIT: ToolDef = {
  name: 'file_edit',
  description: '编辑文件中的部分内容。只能编辑项目目录内的文件。查找old_text并替换为new_text。',
  parameters: {
    type: 'object',
    properties: {
      path: { type: 'string', description: '要编辑的文件路径' },
      old_text: { type: 'string', description: '要查找的文本' },
      new_text: { type: 'string', description: '替换后的文本' },
    },
    required: ['path', 'old_text', 'new_text'],
  },
}

const BASH_RUN: ToolDef = {
  name: 'bash_run',
  description: `执行Bash命令并返回输出。支持 cd 跨调用持久化(后续命令从 last cd 目录开始)。五重资源限制:
- CPU:单次最长${LIMITS.CPU_TIMEOUT_SEC}秒,CPU时间上限${LIMITS.CPU_ULIMIT_SEC}秒
- 内存:${LIMITS.MEMORY_MB}MB虚拟内存上限
- 进程:最多${LIMITS.MAX_PROCESSES}个子进程
- 磁盘:单文件${LIMITS.MAX_FILE_SIZE_MB}MB
- 网络:外网全放开,禁止内网探测 + 禁止危险工具
禁止:sudo/fork炸弹/端口扫描/内网探测/远程执行等。注意:heredoc语法(cat << 'EOF')不可用,请用python3 -c替代。`,
  parameters: {
    type: 'object',
    properties: {
      command: { type: 'string', description: '要执行的Bash命令' },
      timeout: { type: 'number', description: `超时时间(秒),最大${LIMITS.CPU_TIMEOUT_SEC},默认30` },
    },
    required: ['command'],
  },
}

const GREP_SEARCH: ToolDef = {
  name: 'grep_search',
  description: '在项目目录内搜索文本模式。只能在项目目录内搜索。',
  parameters: {
    type: 'object',
    properties: {
      pattern: { type: 'string', description: '要搜索的正则表达式模式' },
      path: { type: 'string', description: '搜索的目录路径,默认当前目录' },
      glob: { type: 'string', description: '文件名过滤,如 *.ts' },
    },
    required: ['pattern'],
  },
}

const LIST_DIR: ToolDef = {
  name: 'list_dir',
  description: '列出项目目录内的文件和子目录。',
  parameters: {
    type: 'object',
    properties: {
      path: { type: 'string', description: '要列出的目录路径,默认当前目录' },
    },
    required: [],
  },
}

const SEND_QQ_MESSAGE: ToolDef = {
  name: 'send_qq_message',
  description: '发送 QQ 消息(仅管理员可用)。可以发送私聊消息或群聊消息。注意:私聊 target_id 必须用 QQ openid(不是QQ号!),群聊用 group_openid。管理员 openid=A944BF9BCFFFAD5F639FB046242608D5',
  parameters: {
    type: 'object',
    properties: {
      target_id: { type: 'string', description: '私聊=QQ openid(不是QQ号!如 A944BF9BCFFFAD5F639FB046242608D5),群聊=group_openid' },
      message: { type: 'string', description: '要发送的文本内容' },
      type: { type: 'string', enum: ['private', 'group'], description: '消息类型:private=私聊, group=群聊' },
      at_qq: { type: 'number', description: '群聊中 @某人 的 QQ 号(可选)' },
    },
    required: ['target_id', 'message', 'type'],
  },
}

export const ALL_TOOLS: ToolDef[] = [FILE_READ, FILE_WRITE, FILE_EDIT, BASH_RUN, GREP_SEARCH, LIST_DIR, SEND_QQ_MESSAGE]

// ═══════════════════════════════════════════
// 工具执行
// ═══════════════════════════════════════════

function truncateOutput(output: string, maxLen = LIMITS.OUTPUT_MAX_LEN): string {
  if (output.length <= maxLen) return output
  return output.slice(0, maxLen) + `\n... (truncated, ${output.length} chars total)`
}

export async function executeTool(call: ToolCall): Promise<ToolResult> {
  let args: any
  try {
    args = JSON.parse(call.arguments)
  } catch (e: any) {
    return { tool_call_id: call.id, content: `Error: invalid JSON arguments: ${e.message}` }
  }

  try {
    let result: string

    switch (call.name) {
      case 'file_read': {
        const rawPath = args.path as string
        const check = checkPath(rawPath)
        if ('error' in check) { result = check.error; break }
        if (!existsSync(check.path)) {
          result = `Error: file not found: ${rawPath}`
          break
        }

        // 4.磁盘:单文件大小检查
        const fileSize = statSync(check.path).size
        const maxReadBytes = 1024 * 1024 // 1MB读取上限
        if (fileSize > maxReadBytes) {
          result = `⛔ 磁盘限制:文件 ${rawPath} 大小 ${(fileSize / 1024 / 1024).toFixed(1)}MB 超过读取上限 1MB`
          break
        }

        const content = readFileSync(check.path, 'utf-8')
        const lines = content.split('\n')
        const offset = Math.max(1, (args.offset as number) || 1)
        const limit = (args.limit as number) || 500
        const selected = lines.slice(offset - 1, offset - 1 + limit)
        result = selected.map((line, i) => `${offset + i}|${line}`).join('\n')
        if (lines.length > offset - 1 + limit) {
          result += `\n... (${lines.length} total lines, showing ${offset}-${offset - 1 + limit})`
        }
        break
      }

      case 'file_write': {
        const rawPath = args.path as string
        const check = checkPath(rawPath)
        if ('error' in check) { result = check.error; break }

        const content = args.content as string

        // 4.磁盘:单文件大小检查(字节)
        const contentBytes = Buffer.byteLength(content, 'utf-8')
        if (contentBytes > LIMITS.MAX_WRITE_BYTES) {
          result = `⛔ 磁盘限制:写入内容 ${(contentBytes / 1024 / 1024).toFixed(1)}MB 超过单文件上限 ${LIMITS.MAX_FILE_SIZE_MB}MB`
          break
        }

        // 4.磁盘:项目总容量检查
        const diskError = checkDiskQuota(contentBytes)
        if (diskError) { result = diskError; break }

        const dir = dirname(check.path)
        if (!existsSync(dir)) {
          mkdirSync(dir, { recursive: true })
        }
        writeFileSync(check.path, content, 'utf-8')
        invalidateDiskCache() // 清除缓存,下次检查时重新计算
        result = `Successfully wrote ${content.length} chars to ${rawPath}`
        break
      }

      case 'file_edit': {
        const rawPath = args.path as string
        const check = checkPath(rawPath)
        if ('error' in check) { result = check.error; break }
        if (!existsSync(check.path)) {
          result = `Error: file not found: ${rawPath}`
          break
        }
        const content = readFileSync(check.path, 'utf-8')
        const oldText = args.old_text as string
        const newText = args.new_text as string
        if (!content.includes(oldText)) {
          result = `Error: old_text not found in ${rawPath}`
          break
        }

        // 4.磁盘:编辑后大小检查(字节)
        const newContent = content.replace(oldText, newText)
        if (Buffer.byteLength(newContent, 'utf-8') > LIMITS.MAX_WRITE_BYTES) {
          result = `⛔ 磁盘限制:编辑后文件大小超过单文件上限 ${LIMITS.MAX_FILE_SIZE_MB}MB`
          break
        }

        // 4.磁盘:如果编辑使文件变大,检查项目总容量
        const sizeDelta = Buffer.byteLength(newText, 'utf-8') - Buffer.byteLength(oldText, 'utf-8')
        if (sizeDelta > 0) {
          const diskError = checkDiskQuota(sizeDelta)
          if (diskError) { result = diskError; break }
        }

        writeFileSync(check.path, newContent, 'utf-8')
        invalidateDiskCache() // 清除缓存,下次检查时重新计算
        result = `Successfully edited ${rawPath}`
        break
      }

      case 'bash_run': {
        const command = args.command as string
        const userTimeout = (args.timeout as number) || 30

        // 1.CPU:超时上限强制封顶
        const timeoutSec = Math.min(userTimeout, LIMITS.CPU_TIMEOUT_SEC)

        // 安全检查(含进程/网络/原有规则)
        const cmdError = checkBashCommand(command)
        if (cmdError) { result = cmdError; break }

        // 追踪 cd 命令:如果命令以 cd 开头或包含 cd,提取目标目录
        const cdMatch = command.match(/(?:^|&&|;)\s*cd\s+([^\s;&|]+)/)
        if (cdMatch) {
          const targetDir = cdMatch[1].replace(/^['"]|['"]$/g, '')
          const resolved = resolve(_bashCwd, targetDir)
          // 验证目录存在
          try {
            const stat = statSync(resolved)
            if (stat.isDirectory()) {
              _bashCwd = resolved
            }
          } catch { /* 目录不存在,不更新 _bashCwd */ }
        }

        // 注入ulimit资源限制
        const wrappedCmd = wrapWithResourceLimits(command, timeoutSec)

        try {
          const output = execSync(wrappedCmd, {
            timeout: (timeoutSec + 5) * 1000, // 父进程超时比子进程多5秒缓冲
            encoding: 'utf-8',
            maxBuffer: LIMITS.BASH_MAX_BUFFER,
            stdio: ['pipe', 'pipe', 'pipe'],
            cwd: _bashCwd,  // 使用追踪的工作目录而非固定的 PROJECT_ROOT
          })
          result = output || '(no output)'
        } catch (e: any) {
          // 区分不同类型的失败
          if (e.signal === 'SIGKILL' || e.status === 137) {
            result = `⛔ 内存限制:进程因超出 ${LIMITS.MEMORY_MB}MB 内存上限被强制终止`
          } else if (e.signal === 'SIGXCPU') {
            result = `⛔ CPU限制:进程因超出 ${LIMITS.CPU_ULIMIT_SEC}秒 CPU时间被强制终止`
          } else if (e.killed) {
            result = `⛔ 超时限制:命令执行超过 ${timeoutSec}秒 被强制终止`
          } else {
            result = `Exit code ${e.status || 'unknown'}\nstdout: ${e.stdout || ''}\nstderr: ${e.stderr || ''}`
          }
        }
        result = truncateOutput(result)
        break
      }

      case 'grep_search': {
        const pattern = args.pattern as string
        const rawSearchPath = (args.path as string) || '.'
        const check = checkPath(rawSearchPath)
        if ('error' in check) { result = check.error; break }
        const glob = args.glob ? ` --glob '${args.glob}'` : ''
        try {
          const cmd = `rg --line-number --max-count 50 ${glob} '${pattern.replace(/'/g, "'\\''")}' ${check.path} 2>/dev/null || grep -rn '${pattern.replace(/'/g, "'\\''")}' ${check.path} 2>/dev/null | head -50`
          const output = execSync(cmd, { encoding: 'utf-8', timeout: 10000, cwd: PROJECT_ROOT })
          result = output || 'No matches found'
        } catch {
          result = 'No matches found'
        }
        result = truncateOutput(result)
        break
      }

      case 'list_dir': {
        const rawDirPath = (args.path as string) || '.'
        const check = checkPath(rawDirPath)
        if ('error' in check) { result = check.error; break }
        try {
          const output = execSync(`ls -la ${check.path}`, { encoding: 'utf-8', timeout: 5000, cwd: PROJECT_ROOT })
          result = output
        } catch (e: any) {
          result = `Error: ${e.message}`
        }
        result = truncateOutput(result)
        break
      }

      case 'send_qq_message': {
        if (!isSuperUser()) {
          result = '⛔ 安全限制:send_qq_message 仅限管理员使用'
          break
        }
        const targetId = args.target_id as string
        const message = args.message as string
        const msgType = args.type as string

        // 通过本地桥接服务发送(使用正确的 Bot 凭据)
        let bridgeOk = false
        try {
          const bridgeRes = await fetch('http://localhost:5001/send', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              type: msgType,
              target_id: targetId,
              content: message,
            }),
            signal: AbortSignal.timeout(10000)
          })
          if (bridgeRes.ok) {
            const bridgeData = await bridgeRes.json() as any
            if (bridgeData.ok !== false) {
              result = `✅ QQ 消息已发送至 ${msgType}:${targetId}`
              bridgeOk = true
            }
          }
        } catch {
          // fallback
        }
        if (bridgeOk) break

        // 降级:通过 Hermes Gateway 的 Bot 凭据直接调用 QQ API
        // 从 .hermes/.env 读取机器人凭据
        const envPath = join(homedir(), '.hermes', '.env')
        let appId = ''
        let secret = ''
        try {
          const envContent = readFileSync(envPath, 'utf-8')
          const appIdMatch = envContent.match(/QQ_APP_ID=(.+)/)
          const secretMatch = envContent.match(/QQ_CLIENT_SECRET=(.+)/)
          if (appIdMatch) appId = appIdMatch[1].trim()
          if (secretMatch) secret = secretMatch[1].trim()
        } catch {}

        if (!appId || !secret) {
          result = '❌ QQ 发送失败: 无法读取 Hermes QQ Bot 凭据,且桥接服务不可用'
          break
        }

        // 从缓存文件读取上次的 msg_id(用于被动回复)
        const cachePath = join(homedir(), '.cmdcode', 'qq_msg_cache.json')
        let lastMsgId = ''
        let lastMsgSeq = 0
        try {
          const cacheRaw = readFileSync(cachePath, 'utf-8')
          const cache = JSON.parse(cacheRaw)
          const userCache = cache[targetId]
          if (userCache) {
            lastMsgId = userCache.msg_id || ''
            lastMsgSeq = userCache.msg_seq || 0
          }
        } catch {}

        // 获取 access_token
        const tokenRes = await fetch('https://bots.qq.com/app/getAppAccessToken', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ appId, clientSecret: secret })
        })
        const tokenData = await tokenRes.json() as any
        if (!tokenData.access_token) {
          result = `❌ 获取 QQ Bot 鉴权失败`
          break
        }

        // 构造请求体(带 msg_id 做被动回复)
        const body: any = { msg_type: 0, content: message, msg_seq: lastMsgSeq + 1 }
        if (lastMsgId) {
          body.msg_id = lastMsgId
        }

        const sendUrl = msgType === 'private'
          ? `https://api.sgroup.qq.com/v2/users/${targetId}/messages`
          : `https://api.sgroup.qq.com/v2/groups/${targetId}/messages`
        const sendRes = await fetch(sendUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `QQBot ${tokenData.access_token}`
          },
          body: JSON.stringify(body)
        })
        const sendData = await sendRes.json() as any
        if (sendData.id) {
          result = `✅ QQ 消息已发送至 ${msgType}:${targetId}`
          // 缓存返回的 msg_id 供下次使用
          try {
            let cache: any = {}
            try { cache = JSON.parse(readFileSync(cachePath, 'utf-8')) } catch {}
            cache[targetId] = { msg_id: sendData.id, msg_seq: lastMsgSeq + 1 }
            writeFileSync(cachePath, JSON.stringify(cache, null, 2))
          } catch {}
        } else {
          // 如果是 11255 且没有 msg_id,提示用户先发一条消息
          const errMsg = JSON.stringify(sendData)
          if (sendData.code === 11255 && !lastMsgId) {
            result = `❌ QQ 发送失败: 机器人不能主动发起私聊,请先给机器人发一条消息,然后重试 (${errMsg})`
          } else if (sendData.code === 11255) {
            result = `❌ QQ 发送失败: msg_id 过期(5分钟窗口),请重新给机器人发一条消息 (${errMsg})`
          } else {
            result = `❌ QQ 发送失败: ${errMsg}`
          }
        }
        break
      }

      default:
        result = `Error: unknown tool: ${call.name}`
    }

    return { tool_call_id: call.id, content: result }
  } catch (e: any) {
    return { tool_call_id: call.id, content: `Error: ${e.message}` }
  }
}