feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
This commit is contained in:
@@ -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 (CGNAT,v0.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 CGNAT(v0.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;
|
||||
}
|
||||
|
||||
@@ -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 纵深校验。
|
||||
// 配置期已有 assertSafeConfigTarget(ipc/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;
|
||||
|
||||
Reference in New Issue
Block a user