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:
@@ -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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user