feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
+49 -17
View File
@@ -14,11 +14,7 @@ const MAX_DIFF_FILE_BYTES = 10 * 1024 * 1024;
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import {
safeResolvePath,
extractErrorMessage,
decodeBufferWithDetection,
} from './file-guard';
import { safeResolvePath, extractErrorMessage, decodeBufferWithDetection } from './file-guard';
interface DiffLine {
type: 'context' | 'added' | 'removed';
@@ -53,12 +49,14 @@ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
// 回溯生成 diff
const result: DiffLine[] = [];
let i = sm, j = sn;
let i = sm,
j = sn;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldSliced[i - 1] === newSliced[j - 1]) {
result.unshift({ type: 'context', oldLineNo: i, newLineNo: j, content: oldSliced[i - 1] });
i--; j--;
i--;
j--;
} else if (j > 0 && (i === 0 || lcs[idx(i, j - 1)] >= lcs[idx(i - 1, j)])) {
result.unshift({ type: 'added', oldLineNo: null, newLineNo: j, content: newSliced[j - 1] });
j--;
@@ -72,7 +70,12 @@ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
}
/** 生成 unified diff 格式字符串 */
function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: string, contextLines: number = 3): string {
function formatUnifiedDiff(
diffLines: DiffLine[],
oldLabel: string,
newLabel: string,
contextLines: number = 3,
): string {
const lines: string[] = [];
lines.push(`--- ${oldLabel}`);
lines.push(`+++ ${newLabel}`);
@@ -89,7 +92,11 @@ function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: st
if (hunkLines.length > 0) {
// 移除尾部多余的 context 行
const trimmed: string[] = [...hunkLines];
while (trimmed.length > 0 && trimmed[trimmed.length - 1].startsWith(' ') && contextSinceChange > 0) {
while (
trimmed.length > 0 &&
trimmed[trimmed.length - 1].startsWith(' ') &&
contextSinceChange > 0
) {
trimmed.pop();
contextSinceChange--;
}
@@ -142,10 +149,29 @@ function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: st
return lines.join('\n');
}
/**
* v0.8.2 P2-1: 文本级 unified diff 计算导出(file_editor dry_run 复用)。
* dry_run 预览此前返回 {original, modified} 两段裸文本 —— 模型与用户都要自行
* 比对差异。现与 diff_viewer 同源(LCS + unified 格式),渲染端可统一以
* diff 视图展示。
*/
export function computeUnifiedDiffText(
oldText: string,
newText: string,
oldLabel: string,
newLabel: string,
contextLines: number = 3,
): string {
const oldLines = oldText.length > 0 ? oldText.split('\n') : [];
const newLines = newText.length > 0 ? newText.split('\n') : [];
return formatUnifiedDiff(computeDiff(oldLines, newLines), oldLabel, newLabel, contextLines);
}
export class DiffViewerTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'diff_viewer',
description: 'Compare two files or two text snippets and show differences. Generates unified diff format output. Useful for reviewing changes before applying or comparing configurations.',
description:
'Compare two files or two text snippets and show differences. Generates unified diff format output. Useful for reviewing changes before applying or comparing configurations.',
parameters: {
type: 'object',
properties: {
@@ -158,7 +184,10 @@ export class DiffViewerTool implements IMetonaTool {
file_b: { type: 'string', description: 'Second file path (for files mode)' },
text_a: { type: 'string', description: 'First text content (for text mode)' },
text_b: { type: 'string', description: 'Second text content (for text mode)' },
context_lines: { type: 'number', description: 'Context lines around changes (default 3, max 10)' },
context_lines: {
type: 'number',
description: 'Context lines around changes (default 3, max 10)',
},
},
required: ['mode'],
},
@@ -254,16 +283,19 @@ export class DiffViewerTool implements IMetonaTool {
lines_removed: removedCount,
lines_unchanged: contextCount,
total_changes: addedCount + removedCount,
similarity: (oldSlicedLen + newSlicedLen) > 0
? Math.round((contextCount * 2 / (oldSlicedLen + newSlicedLen)) * 100) / 100
: 1,
similarity:
oldSlicedLen + newSlicedLen > 0
? Math.round(((contextCount * 2) / (oldSlicedLen + newSlicedLen)) * 100) / 100
: 1,
truncated,
};
// D4.6: unifiedDiff 大小限制
const MAX_DIFF_CHARS = 50_000; const truncatedDiff = unifiedDiff.length > MAX_DIFF_CHARS
? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)'
: unifiedDiff;
const MAX_DIFF_CHARS = 50_000;
const truncatedDiff =
unifiedDiff.length > MAX_DIFF_CHARS
? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)'
: unifiedDiff;
return {
success: true,
@@ -30,6 +30,8 @@ import {
FILE_TOOL_TIMEOUT_MS,
isPotentiallyCatastrophicRegex,
} from './file-guard';
// v0.8.2 P2-1: dry_run 预览补 unified diff(与 diff_viewer 同源 LCS
import { computeUnifiedDiffText } from './diff-viewer';
/**
* v0.7.4 P2-7: 灾难性正则检测从 file-guard 导入(共享模块),
@@ -403,6 +405,15 @@ export class FileEditorTool implements IMetonaTool {
preview: {
original: originalPreview,
modified: modifiedPreview,
// v0.8.2 P2-1: 与 diff_viewer 同源的 unified diff —— 渲染端统一以
// diff 视图展示 dry_run 预览(旧形态是两段裸文本,模型/用户需自行比对)
diff: computeUnifiedDiffText(
originalPreview,
modifiedPreview,
`${args.file_path} (original)`,
`${args.file_path} (modified)`,
3,
),
},
};
}
@@ -803,6 +803,16 @@ export class SearchFilesTool implements IMetonaTool {
} else {
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx"
if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue;
// v0.8.2 P1-6: 文件符号链接边界复检 —— 目录 symlink 已不跟随,但文件
// symlink 会进入 callback 直接读取内容:工作空间内 `ln -s /etc/passwd
// leak.txt` 后内容搜索即可回显外部文件(read_file 有双 realpath 校验,
// 此路径此前无防线)。realpath 越出工作空间边界即跳过。
if (entry.isSymbolicLink() && workspacePath) {
const realFile = await realpath(fullPath).catch(() => null);
if (!realFile || !isPathWithinWorkspace(realFile, workspacePath)) {
continue;
}
}
await callback(fullPath, entry.name);
}
}
@@ -29,7 +29,8 @@ export const __dnsLookup: { current: typeof lookup } = { current: lookup };
* 覆盖:
* - IPv4: 127.0.0.0/8 (回环)、10.0.0.0/8、192.168.0.0/16、172.16.0.0/12、
* 169.254.0.0/16 (链路本地,含云元数据 169.254.169.254)、0.0.0.0/8、
* 224.0.0.0/4 (组播)、240.0.0.0/4 (保留)
* 224.0.0.0/4 (组播)、240.0.0.0/4 (保留)
* 100.64.0.0/10 (CGNATv0.8.2 P3-1)、198.18.0.0/15 (基准测试段,P3-1)
* - IPv6: ::1 (回环)、fe80::/10 (链路本地)、fc00::/7 (唯一本地)、::ffff: 映射的 IPv4
*/
export function isPrivateIP(ip: string): boolean {
@@ -42,6 +43,8 @@ export function isPrivateIP(ip: string): boolean {
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网
if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据)
if (parts[0] === 0) return true; // 0.0.0.0/8
if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true; // 100.64/10 CGNATv0.8.2 P3-1
if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) return true; // 198.18/15 基准测试段(v0.8.2 P3-1
if (parts[0] >= 224) return true; // 组播 + 保留
return false;
}
+17 -1
View File
@@ -27,7 +27,7 @@ import {
logTool,
} from './network-utils';
// v0.7.3 P2-1: 可达性预检经 SSRF 校验 + DNS pinning(结果 URL 是不可信外部输入)
import { safeValidateSSRF } from './ssrf-guard';
import { safeValidateSSRF, assertSafeConfigTargetDeep, DeepCheckSoftFailure } from './ssrf-guard';
import { ssrfPinnedFetch } from './ssrf-dispatcher';
import type { WebFetchTool } from './web-fetch';
@@ -579,6 +579,22 @@ export class WebSearchTool implements IMetonaTool {
const headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type);
const tr = timeRange || config.time_range;
// v0.8.2 P1-6: SearXNG 运行时请求的 SSRF 纵深校验。
// 配置期已有 assertSafeConfigTargetipc/shared 写入链),但 DNS 记录可在配置
// 之后被切换(指向云元数据/链路本地)—— 运行时请求此前完全无校验。此处对
// 每次搜索会话做同口径静态校验 + DNS 深校验;本地回环/RFC1918 合法放行
// SearXNG 常部署本机/内网),DNS 解析失败按 DeepCheckSoftFailure 留痕放行
// (离线实例合法,与配置期深校验语义一致)。
try {
await assertSafeConfigTargetDeep(baseUrl);
} catch (err) {
if (err instanceof DeepCheckSoftFailure) {
logTool('web_search', `[SearXNG] DNS deep check skipped (soft-fail): ${err.message}`);
} else {
throw new Error(`SearXNG target blocked by security policy: ${(err as Error).message}`);
}
}
// SearXNG 标准分页:每页由实例配置决定(通常 10 条),用 pageno 翻页直到达到 maxResults
const maxPages = Math.ceil(maxResults / 5) + 1; // 保守估计,每页至少 5 条
let page = 1;