feat: 升级至 v0.3.2 — 工具体系扩展至 26 个 + Context Window 可配置化

## 主要变更

### 1. Context Window 可配置化(v0.3.1 延续)
- DeepSeek/Agnes contextWindow 不再写死 1M,可在设置中配置(min 4096)
- 修复 Engine 128K vs Adapter 1M 不一致 bug,Engine 从 adapter.getContextWindow() 读取
- 热重载 configSig 加入 contextWindow,配置变化即时生效

### 2. 新增 11 个工具(15 → 26 个)
- Git 工具集(4):git_status, git_diff, git_log, git_commit
- 开发工具集(3):lint_code, run_tests, project_info
- HTTP 请求(1):http_request(Node 18+ fetch + AbortController)
- TODO 管理(1):todo_write(会话级内存 + LRU 淘汰)
- 结构化思考(1):think(无副作用思考空间)
- 图片查看(1):view_image(base64 data URL,多模态 LLM 支持)

### 3. 审计修复(2 FAIL + 14 WARN)
- FAIL-1: registry.ts truncateResult 添加 dataUrl 白名单(图片不被截断)
- FAIL-2: todo.ts 添加 LRU 策略 + clearSession 静态方法
- WARN-1: run_tests filter 字符白名单校验,防 cmd 元字符注入
- WARN-2: dataUrl 检测前置到 stringify 之前,避免大图片无意义序列化
- WARN-3: todo.ts 实现真正 LRU(访问刷新位置,非 FIFO)
- WARN-4: git_diff maxBuffer 提升至 5MB,支持超大变更集
- WARN-5: log.info 移至 DelegateTaskTool 注册后,输出正确的 26
- WARN-6: riskColors 添加 critical: 'error' 键
- WARN-7: 所有 execFileAsync 显式设置 encoding: 'utf-8'
- WARN-8: git_log --author 拆分为独立参数
- WARN-9: todo_write 权限从 WRITE 改为 READ
- WARN-10: description 区分与 task_manager 的不同用途

### 4. PolicyEngine 策略
- 新增 11 条策略,26 个工具 + mcp_* 全覆盖
- git_commit: WRITE + requireConfirmation
- todo_write: READ(内存操作)
This commit is contained in:
thzxx
2026-07-14 21:15:54 +08:00
parent e4d81d8247
commit 0fc589e73b
19 changed files with 1680 additions and 30 deletions
@@ -0,0 +1,528 @@
/**
* 开发工具集(3 个)
*
* lint_code, run_tests, project_info
*
* 用于代码检查、测试运行和项目结构分析。
* 使用 execFile 执行命令,防止命令注入。
*/
import { execFile } from 'child_process';
import { promisify } from 'util';
import { readFile, readdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
const execFileAsync = promisify(execFile);
/** 输出最大长度(50KB */
const MAX_OUTPUT_LENGTH = 50 * 1024;
/** Windows 平台检测 */
const isWindows = process.platform === 'win32';
/** ESLint 配置文件列表 */
const ESLINT_CONFIG_FILES = [
'.eslintrc',
'.eslintrc.js',
'.eslintrc.json',
'.eslintrc.yml',
'.eslintrc.yaml',
'.eslintrc.cjs',
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs',
];
/** 检测工作空间是否存在 ESLint 配置 */
function hasEslintConfig(workspacePath: string): boolean {
return ESLINT_CONFIG_FILES.some((f) => existsSync(join(workspacePath, f)));
}
/** 读取并解析 package.json */
async function readPackageJson(workspacePath: string): Promise<Record<string, unknown> | null> {
try {
const content = await readFile(join(workspacePath, 'package.json'), 'utf-8');
return JSON.parse(content) as Record<string, unknown>;
} catch {
return null;
}
}
/** 截断输出到 50KB */
function truncateOutput(output: string): { output: string; truncated: boolean } {
if (output.length > MAX_OUTPUT_LENGTH) {
return { output: output.slice(0, MAX_OUTPUT_LENGTH), truncated: true };
}
return { output, truncated: false };
}
/** 从 execFile 错误对象中提取 stdout/stderr 字符串 */
function extractOutput(err: { stdout?: string | Buffer; stderr?: string | Buffer }): { stdout: string; stderr: string } {
return {
stdout: typeof err.stdout === 'string' ? err.stdout : '',
stderr: typeof err.stderr === 'string' ? err.stderr : '',
};
}
// ===== 1. lint_code =====
export class LintCodeTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'lint_code',
description:
'Run code linting: TypeScript type checking (tsc) or ESLint code style checking. Auto-detects project configuration when type is "auto".',
parameters: {
type: 'object',
properties: {
type: {
type: 'string',
description:
'Check type: "tsc" (TypeScript type check), "eslint" (ESLint), or "auto" (auto-detect, prioritizes tsc then eslint). Defaults to "tsc".',
enum: ['tsc', 'eslint', 'auto'],
},
},
},
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 60_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const type = (args.type as string) ?? 'tsc';
try {
let actualType: 'tsc' | 'eslint';
if (type === 'auto') {
// 自动检测:优先 tsc,然后 eslint
if (existsSync(join(context.workspacePath, 'tsconfig.json'))) {
actualType = 'tsc';
} else if (hasEslintConfig(context.workspacePath)) {
actualType = 'eslint';
} else {
return {
type: 'none',
success: true,
errorCount: 0,
warningCount: 0,
output: 'No TypeScript or ESLint configuration found in workspace.',
truncated: false,
};
}
} else if (type === 'tsc') {
if (!existsSync(join(context.workspacePath, 'tsconfig.json'))) {
return {
type: 'tsc',
success: true,
errorCount: 0,
warningCount: 0,
output: 'No tsconfig.json found, skipping TypeScript type check.',
truncated: false,
};
}
actualType = 'tsc';
} else {
// eslint
if (!hasEslintConfig(context.workspacePath)) {
return {
type: 'eslint',
success: true,
errorCount: 0,
warningCount: 0,
output: 'No ESLint configuration found, skipping ESLint check.',
truncated: false,
};
}
actualType = 'eslint';
}
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
if (actualType === 'tsc') {
const result = await execFileAsync('npx', ['tsc', '--noEmit'], {
cwd: context.workspacePath,
maxBuffer: 5 * 1024 * 1024,
timeout: 60_000,
shell: isWindows,
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
});
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execFileAsync('npx', ['eslint', '--ext', '.ts,.tsx', '.'], {
cwd: context.workspacePath,
maxBuffer: 5 * 1024 * 1024,
timeout: 60_000,
shell: isWindows,
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
});
stdout = result.stdout;
stderr = result.stderr;
}
} catch (error) {
// lint 命令退出码非 0 是正常情况(代码有错误时),需要捕获输出用于解析
const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number };
const extracted = extractOutput(err);
stdout = extracted.stdout;
stderr = extracted.stderr;
exitCode = err.code ?? 1;
}
const output = (stdout + (stderr ? '\n' + stderr : '')).trim();
const { errorCount, warningCount } = this.parseCounts(output, actualType);
const { output: finalOutput, truncated } = truncateOutput(output);
return {
type: actualType,
success: exitCode === 0,
errorCount,
warningCount,
output: finalOutput,
truncated,
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return { error: errMsg, success: false };
}
}
/** 解析 lint 输出中的错误和警告数量 */
private parseCounts(output: string, type: 'tsc' | 'eslint'): { errorCount: number; warningCount: number } {
if (type === 'tsc') {
// tsc 输出格式: "file.ts(line,col): error TS1234: message"
const errorMatches = output.match(/error TS\d+:/g);
return { errorCount: errorMatches?.length ?? 0, warningCount: 0 };
}
// eslint 输出末尾汇总: "✖ X problems (Y errors, Z warnings)"
const match = output.match(/(\d+)\s+problems?\s*\((\d+)\s+errors?,\s*(\d+)\s+warnings?\)/);
if (match) {
return {
errorCount: parseInt(match[2], 10),
warningCount: parseInt(match[3], 10),
};
}
return { errorCount: 0, warningCount: 0 };
}
}
// ===== 2. run_tests =====
export class RunTestsTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'run_tests',
description:
'Run the project test suite. Auto-detects the test command from package.json scripts, or uses a custom command. Supports test file filtering.',
parameters: {
type: 'object',
properties: {
filter: {
type: 'string',
description: 'Test file filter pattern (e.g., "auth" or "utils/*"). Passed to the test runner.',
},
watch: {
type: 'boolean',
description: 'Watch mode (not applicable in this context, ignored).',
},
},
},
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 120_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const filter = args.filter as string | undefined;
// v0.3.1 修复 WARN-2: 移除 customCommand — LLM 输出不可信,shell:true 有注入风险
// watch 参数不适用,忽略
// v0.3.1 修复 WARN-1: filter 字符白名单校验,防止 Windows shell:true 下的 cmd 元字符注入
// 允许字母/数字/连字符/下划线/斜杠/星号/点(常见测试文件路径模式)
const FILTER_ALLOWED_RE = /^[A-Za-z0-9_\-/. *]+$/;
if (filter && !FILTER_ALLOWED_RE.test(filter)) {
return {
command: 'npm test',
success: false,
passed: 0,
failed: 0,
duration: '0s',
output: `Invalid filter: contains disallowed characters. Only letters, digits, '-', '_', '/', '.', '*', ' ' are allowed.`,
truncated: false,
};
}
try {
let actualCommand: string;
let execCmd: string;
let execArgs: string[];
// 检测 package.json 的 scripts.test
const pkg = await readPackageJson(context.workspacePath);
const scripts = (pkg?.scripts as Record<string, string> | undefined) ?? {};
const testScript = scripts.test;
if (!testScript || testScript === 'no test specified') {
return {
command: 'npm test',
success: false,
passed: 0,
failed: 0,
duration: '0s',
output: 'No test script found in package.json.',
truncated: false,
};
}
actualCommand = filter ? `npm test -- ${filter}` : 'npm test';
execCmd = 'npm';
execArgs = filter ? ['test', '--', filter] : ['test'];
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
const result = await execFileAsync(execCmd, execArgs, {
cwd: context.workspacePath,
maxBuffer: 5 * 1024 * 1024,
timeout: 120_000,
shell: isWindows,
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
});
stdout = result.stdout;
stderr = result.stderr;
} catch (error) {
// 测试失败(exitCode !== 0)是正常情况,需要捕获输出解析结果
const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number };
const extracted = extractOutput(err);
stdout = extracted.stdout;
stderr = extracted.stderr;
exitCode = err.code ?? 1;
}
const output = (stdout + (stderr ? '\n' + stderr : '')).trim();
const { passed, failed, duration } = this.parseTestResults(output);
const { output: finalOutput, truncated } = truncateOutput(output);
return {
command: actualCommand,
success: exitCode === 0,
passed,
failed,
duration,
output: finalOutput,
truncated,
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return { error: errMsg, success: false };
}
}
/** 从测试输出中解析通过/失败数量和耗时(支持 jest/vitest/mocha 格式) */
private parseTestResults(output: string): { passed: number; failed: number; duration: string } {
let passed = 0;
let failed = 0;
let duration = '0s';
// jest/vitest 格式: "Tests: 5 passed, 2 failed, 7 total"
const jestMatch = output.match(/(\d+)\s+passed(?:,\s*(\d+)\s+failed)?/);
if (jestMatch) {
passed = parseInt(jestMatch[1], 10);
failed = jestMatch[2] ? parseInt(jestMatch[2], 10) : 0;
}
// mocha 格式: "X passing (Ys)" / "X failing"
const mochaPass = output.match(/(\d+)\s+passing/);
const mochaFail = output.match(/(\d+)\s+failing/);
if (mochaPass) passed = parseInt(mochaPass[1], 10);
if (mochaFail) failed = parseInt(mochaFail[1], 10);
// 耗时解析: "Time: 3.5 s" / "Duration: 120ms" / "(3.5s)"
const timeMatch = output.match(/(?:Time|Duration|耗时)[:\s]+([\d.]+\s*(?:ms|s|m|h|seconds?|minutes?|hours?)?)/i);
if (timeMatch) {
duration = timeMatch[1];
} else {
const parenTime = output.match(/\(([\d.]+\s*(?:ms|s|m|h))\)/);
if (parenTime) duration = parenTime[1];
}
return { passed, failed, duration };
}
}
// ===== 3. project_info =====
export class ProjectInfoTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'project_info',
description:
'Analyze project structure and dependencies. Returns package.json overview, full dependency list, npm scripts, or directory structure (2 levels deep).',
parameters: {
type: 'object',
properties: {
detail: {
type: 'string',
description:
'Detail level: "overview" (project summary), "dependencies" (full dependency list), "scripts" (npm scripts), or "structure" (directory tree, 2 levels). Defaults to "overview".',
enum: ['overview', 'dependencies', 'scripts', 'structure'],
},
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 10_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const detail = (args.detail as string) ?? 'overview';
try {
switch (detail) {
case 'dependencies':
return await this.getDependencies(context.workspacePath);
case 'scripts':
return await this.getScripts(context.workspacePath);
case 'structure':
return await this.getStructure(context.workspacePath);
default:
return await this.getOverview(context.workspacePath);
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return { error: errMsg, success: false };
}
}
private async getOverview(workspacePath: string): Promise<unknown> {
const pkg = await readPackageJson(workspacePath);
if (!pkg) {
return { error: 'package.json not found', success: false };
}
const deps = (pkg.dependencies as Record<string, string> | undefined) ?? {};
const devDeps = (pkg.devDependencies as Record<string, string> | undefined) ?? {};
const scripts = (pkg.scripts as Record<string, string> | undefined) ?? {};
return {
name: (pkg.name as string) ?? 'unknown',
version: (pkg.version as string) ?? '0.0.0',
description: (pkg.description as string) ?? '',
type: pkg.type === 'module' ? 'module' : 'commonjs',
mainEntry: (pkg.module as string) ?? (pkg.main as string) ?? 'index.js',
dependencyCount: Object.keys(deps).length,
devDependencyCount: Object.keys(devDeps).length,
scriptCount: Object.keys(scripts).length,
hasTypeScript: existsSync(join(workspacePath, 'tsconfig.json')),
hasEslint: hasEslintConfig(workspacePath),
};
}
private async getDependencies(workspacePath: string): Promise<unknown> {
const pkg = await readPackageJson(workspacePath);
if (!pkg) {
return { error: 'package.json not found', success: false };
}
const deps = (pkg.dependencies as Record<string, string> | undefined) ?? {};
const devDeps = (pkg.devDependencies as Record<string, string> | undefined) ?? {};
const dependencies = Object.entries(deps).map(([name, version]) => ({ name, version }));
const devDependencies = Object.entries(devDeps).map(([name, version]) => ({ name, version }));
return {
dependencies,
devDependencies,
total: dependencies.length + devDependencies.length,
};
}
private async getScripts(workspacePath: string): Promise<unknown> {
const pkg = await readPackageJson(workspacePath);
if (!pkg) {
return { error: 'package.json not found', success: false };
}
const scripts = (pkg.scripts as Record<string, string> | undefined) ?? {};
const scriptList = Object.entries(scripts).map(([name, command]) => ({ name, command }));
return {
scripts: scriptList,
count: scriptList.length,
};
}
private async getStructure(workspacePath: string): Promise<unknown> {
const directories = await this.listDirectory(workspacePath, 0, 2);
return {
root: workspacePath,
directories,
};
}
/** 递归列出目录(最多 maxDepth 层),跳过 node_modules/.git/dist */
private async listDirectory(
dirPath: string,
currentDepth: number,
maxDepth: number,
): Promise<Array<{ name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> }>> {
const results: Array<{ name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> }> = [];
try {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (this.shouldSkip(entry.name)) continue;
if (entry.isDirectory()) {
const item: { name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> } = {
name: entry.name,
type: 'dir',
};
if (currentDepth < maxDepth - 1) {
item.children = await this.listChildren(join(dirPath, entry.name));
}
results.push(item);
} else {
results.push({ name: entry.name, type: 'file' });
}
}
} catch {
// 目录读取失败,跳过
}
return results;
}
/** 列出子目录内容(仅 1 层,不再递归) */
private async listChildren(dirPath: string): Promise<Array<{ name: string; type: string }>> {
const results: Array<{ name: string; type: string }> = [];
try {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (this.shouldSkip(entry.name)) continue;
results.push({ name: entry.name, type: entry.isDirectory() ? 'dir' : 'file' });
}
} catch {
// 目录读取失败,跳过
}
return results;
}
/** 判断目录/文件是否应跳过(node_modules/.git/dist 不递归) */
private shouldSkip(name: string): boolean {
return ['node_modules', '.git', 'dist'].includes(name);
}
}