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
+421
View File
@@ -0,0 +1,421 @@
/**
* Git 工具集(4 个)
*
* git_status, git_diff, git_log, git_commit
*
* 使用 execFile 执行 git 命令,防止命令注入。
* 所有命令在 context.workspacePath 下执行。
*/
import { execFile } from 'child_process';
import { promisify } from 'util';
import { resolve } from 'path';
import { isPathWithinWorkspace } from './file-guard';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
const execFileAsync = promisify(execFile);
/** diff 输出截断阈值(50KB */
const MAX_DIFF_SIZE = 50 * 1024;
/**
* 执行 git 命令的统一入口
*
* 使用 execFile(非 exec)+ 参数数组,从底层杜绝命令注入。
* 显式设置 encoding: 'utf-8'git 默认输出 UTF-8。
*
* v0.3.1 修复 WARN-4: 允许调用方覆盖 maxBuffer,默认 1MB
* git_diff 传 5MB 以支持超大变更集(避免 maxBuffer exceeded 报错而非截断返回)。
*/
async function runGit(
args: string[],
cwd: string,
timeout = 10_000,
maxBuffer = 1024 * 1024, // 默认 1MB
): Promise<string> {
const { stdout } = await execFileAsync('git', args, {
cwd,
maxBuffer,
timeout,
encoding: 'utf-8',
});
return stdout;
}
/** 判断错误是否为 "不是 git 仓库" 或 git 不可用 */
function isNotGitRepoError(error: unknown): boolean {
if (error instanceof Error) {
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
const msg = `${error.message} ${stderr}`;
return /not a git repository/i.test(msg) || /git: not found|command not found/i.test(msg);
}
return false;
}
/** 提取错误信息字符串 */
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
return stderr ? `${error.message}\n${stderr}` : error.message;
}
return String(error);
}
/** 获取当前分支名(失败时返回 'unknown',不抛错) */
async function getCurrentBranch(cwd: string): Promise<string> {
try {
const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)).trim();
return branch;
} catch {
return 'unknown';
}
}
/** 处理 porcelain v1 重命名格式 "old -> new" → 返回 new */
function extractRenamedFile(file: string): string {
const idx = file.indexOf(' -> ');
return idx >= 0 ? file.slice(idx + 4) : file;
}
// ===== 1. git_status =====
export class GitStatusTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'git_status',
description: 'Show the working tree status. Returns current branch, ahead/behind counts, and lists of staged, unstaged, and untracked files.',
parameters: {
type: 'object',
properties: {
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/" or "package.json"' },
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const pathspec = args.pathspec as string | undefined;
const gitArgs = ['status', '--porcelain=v1', '-b'];
if (pathspec) {
gitArgs.push('--', pathspec);
}
try {
const output = await runGit(gitArgs, context.workspacePath);
return this.parseStatus(output);
} catch (error) {
if (isNotGitRepoError(error)) {
return { error: 'Not a git repository or git not available', success: false };
}
return { error: extractErrorMessage(error), success: false };
}
}
/**
* 解析 `git status --porcelain=v1 -b` 输出
*
* 分支行格式: `## <branch>...<upstream> [ahead N, behind M]`
* 文件行格式: `XY <space> <file>` (X=暂存区状态, Y=工作区状态)
*/
private parseStatus(output: string): unknown {
const lines = output.split('\n').filter((l) => l.length > 0);
const staged: Array<{ status: string; file: string }> = [];
const unstaged: Array<{ status: string; file: string }> = [];
const untracked: string[] = [];
let branch = '';
let ahead = 0;
let behind = 0;
for (const line of lines) {
// 分支行: ## main...origin/main [ahead 2, behind 1]
if (line.startsWith('## ')) {
const header = line.slice(3);
const bracketIdx = header.indexOf('[');
const branchPart = bracketIdx >= 0 ? header.slice(0, bracketIdx).trim() : header.trim();
// branchPart 形如 "main...origin/main" / "main" / "HEAD (no branch)"
const dotIdx = branchPart.indexOf('...');
branch = dotIdx >= 0 ? branchPart.slice(0, dotIdx) : branchPart.split(' ')[0];
if (bracketIdx >= 0) {
const meta = header.slice(bracketIdx + 1, header.lastIndexOf(']'));
const aheadMatch = meta.match(/ahead (\d+)/);
const behindMatch = meta.match(/behind (\d+)/);
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
if (behindMatch) behind = parseInt(behindMatch[1], 10);
}
continue;
}
// porcelain v1 文件行: XY <space> <file>
const x = line[0];
const y = line[1];
const file = line.slice(3);
// 未跟踪: ??
if (x === '?' && y === '?') {
untracked.push(file);
continue;
}
// 暂存区变更 (X 非 ' ' 且非 '?')
if (x !== ' ' && x !== '?') {
staged.push({ status: x, file: extractRenamedFile(file) });
}
// 工作区变更 (Y 非 ' ' 且非 '?')
if (y !== ' ' && y !== '?') {
unstaged.push({ status: y, file: extractRenamedFile(file) });
}
}
const clean = staged.length === 0 && unstaged.length === 0 && untracked.length === 0;
return {
branch,
ahead,
behind,
staged,
unstaged,
untracked,
clean,
};
}
}
// ===== 2. git_diff =====
export class GitDiffTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'git_diff',
description: 'Show changes between the working tree and the index (or staged changes). Returns unified diff text. Output is truncated at 50KB.',
parameters: {
type: 'object',
properties: {
cached: { type: 'boolean', description: 'Show only staged (cached) changes (default false)' },
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/"' },
contextLines: { type: 'number', description: 'Number of context lines (default 3)' },
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const cached = (args.cached as boolean) ?? false;
const pathspec = args.pathspec as string | undefined;
const contextLines = Math.max(0, (args.contextLines as number) ?? 3);
const gitArgs = ['diff'];
if (cached) gitArgs.push('--cached');
gitArgs.push(`--unified=${contextLines}`);
if (pathspec) gitArgs.push('--', pathspec);
try {
// v0.3.1 修复 WARN-4: 使用 5MB maxBuffer 支持超大 diff,避免 maxBuffer exceeded 报错
// 工具内部仍会截断到 50KB 返回,避免 LLM 上下文溢出
const diff = await runGit(gitArgs, context.workspacePath, 10_000, 5 * 1024 * 1024);
const truncated = diff.length > MAX_DIFF_SIZE;
const truncatedDiff = truncated ? diff.slice(0, MAX_DIFF_SIZE) : diff;
const filesChanged = this.countFilesChanged(diff);
return {
diff: truncatedDiff,
filesChanged,
truncated,
};
} catch (error) {
if (isNotGitRepoError(error)) {
return { error: 'Not a git repository or git not available', success: false };
}
return { error: extractErrorMessage(error), success: false };
}
}
/** 统计 diff 中变更的文件数(每变更文件一个 `diff --git` 头) */
private countFilesChanged(diff: string): number {
let count = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) count++;
}
return count;
}
}
// ===== 3. git_log =====
export class GitLogTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'git_log',
description: 'Show commit history. Returns commits with hash, message, and optionally author/date. Supports filtering by author and path.',
parameters: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Number of entries to return (default 20, max 100)' },
oneline: { type: 'boolean', description: 'Use compact one-line format (default true)' },
pathspec: { type: 'string', description: 'Limit to a path scope' },
author: { type: 'string', description: 'Filter by author name/email' },
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const limit = Math.min(100, Math.max(1, (args.limit as number) ?? 20));
const oneline = (args.oneline as boolean) ?? true;
const pathspec = args.pathspec as string | undefined;
const author = args.author as string | undefined;
const gitArgs = ['log', `-n${limit}`];
if (oneline) {
gitArgs.push('--oneline');
} else {
// 用 NULL 字节分隔字段,避免 author/message 含 '|' 导致解析错误
gitArgs.push('--format=%H%x00%an%x00%ad%x00%s');
}
// v0.3.1 修复 WARN-8: 拆分为独立参数,避免特殊字符干扰 git 解析
if (author) gitArgs.push('--author', author);
if (pathspec) gitArgs.push('--', pathspec);
try {
// 并行获取日志和当前分支名
const [output, branch] = await Promise.all([
runGit(gitArgs, context.workspacePath),
getCurrentBranch(context.workspacePath),
]);
const commits = this.parseLog(output, oneline);
return { commits, count: commits.length, branch };
} catch (error) {
if (isNotGitRepoError(error)) {
return { error: 'Not a git repository or git not available', success: false };
}
return { error: extractErrorMessage(error), success: false };
}
}
private parseLog(
output: string,
oneline: boolean,
): Array<{ hash: string; author?: string; date?: string; message: string }> {
const lines = output.split('\n').filter((l) => l.length > 0);
const commits: Array<{ hash: string; author?: string; date?: string; message: string }> = [];
for (const line of lines) {
if (oneline) {
// <short-hash> <space> <message>
const spaceIdx = line.indexOf(' ');
if (spaceIdx < 0) continue;
commits.push({
hash: line.slice(0, spaceIdx),
message: line.slice(spaceIdx + 1),
});
} else {
// <hash>\0<author>\0<date>\0<message>
const parts = line.split('\0');
if (parts.length < 4) continue;
commits.push({
hash: parts[0],
author: parts[1],
date: parts[2],
message: parts[3],
});
}
}
return commits;
}
}
// ===== 4. git_commit =====
export class GitCommitTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'git_commit',
description: 'Stage files and create a git commit. Supports amending the previous commit. Requires user confirmation.',
parameters: {
type: 'object',
properties: {
message: { type: 'string', description: 'Commit message' },
files: {
type: 'array',
items: { type: 'string', description: 'File path to stage' },
description: 'Files to stage (default: all changes via git add -A)',
},
amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' },
},
required: ['message'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const message = args.message as string;
const files = (args.files as string[] | undefined) ?? [];
const amend = (args.amend as boolean) ?? false;
if (!message || message.trim().length === 0) {
return { success: false, error: 'Commit message is required' };
}
try {
// 1. 暂存文件(指定文件 → git add -- <files>;否则 git add -A
// v0.3.1 修复 WARN-1: 校验每个 file 路径在工作空间内
if (files.length > 0) {
for (const f of files) {
const resolved = resolve(context.workspacePath, f);
if (!isPathWithinWorkspace(resolved, context.workspacePath)) {
return {
success: false,
error: `File path outside workspace: ${f}`,
commit: '', branch: '', message: '', filesChanged: 0,
};
}
}
await runGit(['add', '--', ...files], context.workspacePath);
} else {
await runGit(['add', '-A'], context.workspacePath);
}
// 2. 统计暂存文件数(commit 前取样)
const stagedOutput = await runGit(['diff', '--cached', '--name-only'], context.workspacePath);
const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length;
// 3. 提交(commit 可能触发 hooks,给予更长超时)
const commitArgs = ['commit'];
if (amend) commitArgs.push('--amend');
commitArgs.push('-m', message);
await runGit(commitArgs, context.workspacePath, 20_000);
// 4. 获取提交 hash 和当前分支
const [commit, branch] = await Promise.all([
runGit(['rev-parse', 'HEAD'], context.workspacePath).then((s) => s.trim()),
getCurrentBranch(context.workspacePath),
]);
return {
success: true,
commit,
branch,
message,
filesChanged,
};
} catch (error) {
if (isNotGitRepoError(error)) {
return { error: 'Not a git repository or git not available', success: false };
}
return { success: false, error: extractErrorMessage(error) };
}
}
}