P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息 前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 + cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token 成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter 同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点 时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用 + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数, 未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控 [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键) P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构 resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup 钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式 退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接, 替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用 pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为 可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin; ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent 终结三处接线, 根治 rememberedDecisions 泄漏) P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→ Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs 死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收 管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个 run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页 手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 + 审计链一键校验) P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用 同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时 刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机, teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页 显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐 docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/ OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐) 测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context 分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control 四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向 解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、 trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机 (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动 (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试 前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与 并发去重的检查顺序竞态(去重先于幂等) 版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect) 回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过 (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
169 lines
7.3 KiB
TypeScript
169 lines
7.3 KiB
TypeScript
/**
|
||
* HTTP 请求工具(1 个)
|
||
*
|
||
* http_request — 发送 HTTP/REST API 请求
|
||
*
|
||
* 使用 Node.js 18+ 内置 fetch API。
|
||
* 响应体截断到 50KB 防止结果过大。
|
||
*
|
||
* #10 修复: SSRF 防护 — 解析 URL 域名并校验 IP,拒绝内网/回环/元数据地址。
|
||
*/
|
||
|
||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||
import type { MetonaToolDef } from '../../../harness/types';
|
||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||
// v0.6.4 P2-2: SSRF 校验收敛到共享模块 ssrf-guard.ts —— 原实现是本文件私有逻辑,
|
||
// web_fetch 无校验造成工具层最大的安全不对称。单源后所有网络工具行为一致。
|
||
// v0.7.3 P2-1 根治: 请求层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到
|
||
// 连接层(undici connect.lookup),校验与连接共用同一批 IP,DNS rebinding
|
||
// 窗口(M7 已知限制)就此关闭;代理激活时自动退化为仅入口校验(见模块注释)。
|
||
import { validateSSRF } from './ssrf-guard';
|
||
import { ssrfPinnedFetch } from './ssrf-dispatcher';
|
||
|
||
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const;
|
||
const MAX_BODY_BYTES = 50 * 1024; // 50KB
|
||
|
||
/**
|
||
* #10 修复: 检查 IP 是否为私有/内网/回环/元数据地址
|
||
*
|
||
* v0.6.4: 实现迁移到共享模块 ssrf-guard.ts(isPrivateIP / validateSSRF),
|
||
* 本文件仅保留使用方。实现细节与覆盖范围见 ssrf-guard.ts 注释:
|
||
* - IPv4: 127/8、10/8、192.168/16、172.16-31、169.254/16(云元数据)、0/8、224+/4
|
||
* - IPv6: ::1、fe80::/10、fc00::/7、::ffff: 映射 v4(递归检测)
|
||
*/
|
||
|
||
/**
|
||
* 审查修复 (M7) — 已知限制:DNS rebinding 窗口
|
||
* ---------------------------------------------------------------
|
||
* validateSSRF 在校验阶段 DNS 解析得到 IP,fetch 内部会再次 DNS 解析,
|
||
* 两次解析之间存在 DNS rebinding 攻击窗口(攻击者可在校验通过后切换
|
||
* DNS 记录到内网 IP)。
|
||
*
|
||
* 完全防护需要 "DNS pinning"(用校验通过的 IP 替换 URL hostname),
|
||
* 但在 Node.js fetch 实现下不可行:
|
||
* 1. HTTPS 请求时 fetch 会基于 URL hostname 校验证书 SAN,
|
||
* 用 IP 替换会导致证书校验失败(除非目标证书 SAN 包含该 IP)。
|
||
* 2. Node fetch 将 Host 列为 forbidden header,无法通过设置
|
||
* Host header 保留原始域名。
|
||
* 3. fetch API 不暴露 SNI 自定义入口。
|
||
*
|
||
* 当前实现的缓解措施:
|
||
* - 校验所有 DNS 返回的 IP(防只校验第一个 IP 的绕过)
|
||
* - redirect: 'manual' 禁用自动重定向(防重定向到内网)
|
||
* - web_fetch 场景下对重定向终态 URL 复检(v0.6.4)
|
||
*/
|
||
|
||
export class HttpRequestTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'http_request',
|
||
description:
|
||
'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
url: { type: 'string', description: 'Request URL (must start with http:// or https://)' },
|
||
method: {
|
||
type: 'string',
|
||
description: 'HTTP method (default GET)',
|
||
enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'],
|
||
},
|
||
headers: { type: 'object', description: 'Request headers as key-value pairs' },
|
||
body: { type: 'string', description: 'Request body (string)' },
|
||
timeout: {
|
||
type: 'number',
|
||
description: 'Timeout in milliseconds (default 30000, max 60000)',
|
||
},
|
||
},
|
||
required: ['url'],
|
||
},
|
||
category: MetonaToolCategory.NETWORK,
|
||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||
requiresPermission: true,
|
||
timeoutMs: 30_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const url = args.url as string;
|
||
const method = ((args.method as string) ?? 'GET').toUpperCase();
|
||
const headers = (args.headers as Record<string, string> | undefined) ?? undefined;
|
||
const body = args.body as string | undefined;
|
||
const timeout = Math.min(60_000, Math.max(1, (args.timeout as number) ?? 30_000));
|
||
|
||
// 校验 URL
|
||
if (!url || !/^https?:\/\//i.test(url)) {
|
||
return { error: 'Invalid URL', success: false };
|
||
}
|
||
|
||
// #10 修复: SSRF 校验 — 拒绝内网/回环/元数据地址
|
||
try {
|
||
await validateSSRF(url);
|
||
} catch (ssrfErr) {
|
||
return { error: (ssrfErr as Error).message, success: false };
|
||
}
|
||
|
||
// 校验 method
|
||
if (!(ALLOWED_METHODS as readonly string[]).includes(method)) {
|
||
return {
|
||
error: `Invalid method: ${method}. Must be one of: ${ALLOWED_METHODS.join(', ')}`,
|
||
success: false,
|
||
};
|
||
}
|
||
|
||
// 超时控制由 ssrfPinnedFetch 内部管理(超时 → ETIMEDOUT;
|
||
// 工具执行层的 abort signal 经 context 传入 registry 兜底)
|
||
{
|
||
const fetchOptions: RequestInit = {
|
||
method,
|
||
headers,
|
||
// #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验
|
||
// 重定向后的 URL 由用户自行处理(响应中会包含 Location 头)
|
||
redirect: 'manual',
|
||
};
|
||
// GET/HEAD 不应携带 body
|
||
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||
fetchOptions.body = body;
|
||
}
|
||
|
||
// v0.7.3 P2-1: pinned fetch —— 校验通过的 IP pin 到连接层,
|
||
// 关闭校验-连接之间的 DNS rebinding 窗口
|
||
const response = await ssrfPinnedFetch(url, fetchOptions, timeout);
|
||
const text = await response.text();
|
||
|
||
// 截断到 50KB
|
||
const truncated = text.length > MAX_BODY_BYTES;
|
||
const safeBody = truncated ? text.slice(0, MAX_BODY_BYTES) : text;
|
||
|
||
// 只返回 content-type 和 content-length
|
||
// 审查修复: redirect:'manual' 后需要返回 Location header,否则 LLM 无法知道重定向目标
|
||
const filteredHeaders: Record<string, string> = {};
|
||
const contentType = response.headers.get('content-type');
|
||
if (contentType) filteredHeaders['content-type'] = contentType;
|
||
const contentLength = response.headers.get('content-length');
|
||
if (contentLength) filteredHeaders['content-length'] = contentLength;
|
||
const location = response.headers.get('location');
|
||
if (location) filteredHeaders['location'] = location;
|
||
|
||
return {
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
headers: filteredHeaders,
|
||
body: safeBody,
|
||
truncated,
|
||
ok: response.ok,
|
||
success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段
|
||
};
|
||
}
|
||
} catch (error) {
|
||
// 区分超时与其他网络错误:AbortError(外部中断)与
|
||
// ETIMEDOUT(ssrfPinnedFetch 超时转译,v0.7.3 P2-1)均归为超时语义
|
||
const err = error as Error & { code?: string };
|
||
if (err?.name === 'AbortError' || err?.code === 'ETIMEDOUT') {
|
||
return { error: 'Request timeout', success: false };
|
||
}
|
||
const errMsg = error instanceof Error ? error.message : String(error);
|
||
return { error: errMsg, success: false };
|
||
}
|
||
}
|
||
}
|