Skip to content

feat(mcp-web-fetch): expose allowed_domains and blocked_domains on web_fetch schema (#380) - #393

Open
NanluQingshi wants to merge 3 commits into
ceilf6:developfrom
NanluQingshi:feat/mcp-web-fetch-domain-filters
Open

feat(mcp-web-fetch): expose allowed_domains and blocked_domains on web_fetch schema (#380)#393
NanluQingshi wants to merge 3 commits into
ceilf6:developfrom
NanluQingshi:feat/mcp-web-fetch-domain-filters

Conversation

@NanluQingshi

Copy link
Copy Markdown

Linked Issue Or Context

Closes #380

Repo-evolver iteration 24 found that @frontagent/mcp-web-fetch engine already
supports host filters via allowHosts/denyHosts, but the MCP tool schema did
not expose them — so the agent could not pass domain restrictions that the
engine already enforces.

Summary

Add optional allowed_domains: string[] and blocked_domains: string[] to the
web_fetch MCP tool schema, mapped to the engine's existing allowHosts/
denyHosts host filters. The agent can now pass domain restrictions aligned
with Anthropic web fetch semantics, enabling least-privilege fetches without
changing SSRF defaults.

  • allowed_domains → engine allowHosts (only listed domains permitted)
  • blocked_domains → engine denyHosts (listed domains rejected; takes
    precedence over allow list)
  • Both fields are optional and additive — omitting either preserves existing
    behavior

Impact Scope

Single file change in packages/mcp-web-fetch:

  • src/tools.ts — two new schema properties + two handler arg mappings (+14
    lines)
  • src/tools.domain-filters.test.ts — 4 focused no-network tests (+41 lines,
    new file)
  • CHANGELOG.md[Unreleased]### Added entry

No engine, SSRF, or url-safety logic changed. No other packages touched.

GitNexus Impact Summary

  • Risk level: LOW
  • Critical skeleton changes: None. Change is confined to packages/mcp-web- fetch (mcp-boundary), schema + handler only; no public MCP contract break —
    fields are additive optional.
  • GitNexus impact: detect_changes could not run locally (no indexed
    repository in this environment). The change adds optional schema fields and a
    passthrough mapping; no symbol signatures or call-graph edges are altered.
  • Verification: npx vitest run (48/48 pass), tsc --noEmit (clean), biome check (clean) on packages/mcp-web-fetch.

Verification

  • npx vitest run in packages/mcp-web-fetch → 7 test files, 48 tests passed
    (was 44; +4 new)
  • npx tsc --noEmit → exit 0
  • npx biome check on changed files → clean, no fixes applied

New tests (no network required — host filtering happens before any fetch):

  • blocked_domains rejects the listed host (asserts /denied/i)
  • allowed_domains rejects hosts not in the allow list (asserts /not in allow list/i)
  • schema exposes both fields as type: array
  • omitting both filters preserves SSRF guard behavior

Checklist

  • I have linked an issue or explained why this PR stands alone.
  • I have kept the diff focused on the stated change.
  • I have run pnpm quality:precommit, or explained why it could not run.
    (Ran equivalent focused gates manually — lint + typecheck + test on packages/ mcp-web-fetch; pre-commit hook env has a broken pnpm/corepack 9.0.0 self-
    update, CI remains authoritative.)
  • I have run pnpm quality:local for critical skeleton changes, or
    explained why it could not run. (Not a critical skeleton change; scoped to one
    MCP adapter.)
  • I have updated docs or tests when behavior, public APIs, or Harness
    contracts changed. (Added CHANGELOG entry + focused tests.)
  • For critical skeleton changes, I have filled the GitNexus impact summary
    with concrete results. (N/A — LOW risk, non-critical.)

…b_fetch schema (ceilf6#380)

The engine already supported allowHosts/denyHosts host filters, but the
MCP tool schema/handler did not expose them. Add optional allowed_domains
and blocked_domains to the web_fetch tool, mapped to the engine's
allowHosts/denyHosts, so the agent can pass domain restrictions aligned
with Anthropic web fetch semantics for least-privilege fetches.

SSRF defaults are unchanged; the filters are additive and optional.
@NanluQingshi
NanluQingshi requested a review from ceilf6 as a code owner July 25, 2026 10:04
@ceilf6

ceilf6 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

/review

1 similar comment
@ceilf6

ceilf6 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

/review

@ceilf6

ceilf6 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

@ceilf6/repo-guard

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ ceilf6/repo-guard

代码评审报告: feat(mcp-web-fetch): expose allowed_domains and blocked_domains on web_fetch schema (#380)

风险等级:
处理建议: 请求修改
决策摘要: 管线本身是安全的加法式改动(省略参数时行为不变,重定向每跳都会复用过滤器),但新增的 schema 描述把"精确主机名相等"包装成"与 Anthropic web fetch 一致"的域名过滤,blocked_domains: ['example.com'] 不会拦截 www.example.com(安全控制方向上是 fail-open);同时仓库内没有任何消费者读取 inputSchema,agent 事实上仍无法传这两个字段——即 issue 的 problem statement 未被真正解决。两处最小修复都在 diff 内(改描述 + 修正 CHANGELOG 声明或补上 planner 参数通路)。

级联分析

  • 变更符号: webFetchSchema.inputSchema.properties(新增 2 个属性)、handleWebFetchTool(新增 2 个 arg 映射)。函数签名、返回形状、错误行为均未变。
  • 受影响流程: handleWebFetchToolfetchUrl(engine.ts:107)→ parseAndValidateUrl(url-safety.ts:192)。首跳与每个重定向跳(engine.ts:119-122、156-159)都传入相同的 allowHosts/denyHosts,不存在重定向绕过过滤器的缺口。deny 先于 allow 检查(url-safety.ts:218 早于 222),schema 中"优先级高于 allowed_domains"的描述属实。
  • 变更集外调用方: 全仓仅 packages/runtime-node/src/mcp-clients.ts:24 导入本包,且只导入 handleWebFetchTool,在 mcp-clients.ts:147-148 原样透传 args——不会剥离新字段,无破坏。webFetchSchema / allWebFetchSchemas 虽从 index.ts:12-17 导出,但没有任何仓库内消费者(text search);packages/mcp-web-fetch 无 server/bin 入口(package.json 无 bin),也不作为独立 MCP server 对外提供 tools/list
  • 置信度: degraded — PR 自述 GitNexus detect_changes 未能运行,本次结论基于 Read/Grep 文本证据;调用面很窄(单一跨包导入),但无图谱交叉验证。

问题发现

  1. [高] 新增 schema 描述与引擎实际匹配语义不符,黑名单方向 fail-open

    • 证据: tools.ts:44-4550-51 向模型声明"与 Anthropic web fetch 的 allowed_domains/blocked_domains 一致";但 url-safety.ts:218223h.toLowerCase() === lowerHost 精确相等比较,无后缀/子域匹配、无 scheme 或端口归一化。因此 blocked_domains: ['example.com'] 不拦截 www.example.comevil.example.com;若调用方按 Anthropic 文档习惯写成 ['https://example.com'],黑名单将完全不生效且静默通过。白名单方向反过来是 fail-closed(example.com 不放行 docs.example.com),安全但会造成"配了却抓不到"的困惑。
    • 受影响调用方/流程: 任何直接调用 handleWebFetchTool 并依赖 blocked_domains 做最小权限约束的调用方;这是新增的公共工具契约文本,模型会按描述行事。
    • 最小可行修复: 保持行为不变(issue 非目标明确禁止改 URL safety policy),把两条 description 改成明确的"精确主机名匹配,子域需逐一列出,不含 scheme/端口"。若确实要 Anthropic 的子域语义,应作为独立 issue 修改 parseAndValidateUrl 并同步 url-safety 测试,不要在描述里先行承诺。
  2. [高] issue 的 problem statement 未被解决:agent 仍无法传这两个字段

    • 证据: issue #380 的问题陈述是"the agent cannot pass the domain restrictions"。但 (a) packages/core/src/executor/types.ts:15MCPClient.listTools() 返回类型只有 { name, description },全仓无任何位置读取 webFetchSchema.inputSchema;(b) packages/runtime-node/src/mcp-clients.ts:154-171 是手工维护的工具清单,只有名称和描述,不含参数 schema;(c) agent 的 web_fetch 步骤参数受 packages/core/src/llm/schemas.ts:19-37STEP_PARAMS_SCHEMA 约束,这是一个封闭对象(有 url,无 allowed_domains/blocked_domains),zod 默认 strip 未知键;(d) packages/core/src/executor/executor.ts:155 透传的正是这份被 strip 过的 step.params。全仓 allowed_domains|blocked_domains 仅出现在 tools.ts、新测试、CHANGELOG 三处(text search)。
    • 受影响调用方/流程: LLM 规划 → executor → mcp-clients → handleWebFetchTool 的唯一 agent 路径。当前该能力只对直接编程调用者可用。
    • 最小可行修复: 二选一,由维护者决定范围——要么在 STEP_PARAMS_SCHEMA 补上这两个可选数组字段(小改动,贴合 issue 的问题陈述);要么保持本 PR 的 schema-only 范围,但把 CHANGELOG 与 PR 描述中"The agent can now pass domain restrictions"改成准确表述(引擎能力已在工具契约层暴露,agent 侧接线待跟进),并开跟进 issue。不要让 CHANGELOG 宣称一个当前不成立的用户可见效果。
  3. [中] 空数组语义未文档化,allowed_domains: [] 等于"不限制"

    • 证据: url-safety.ts:222opts.allowHosts && opts.allowHosts.length > 0 判定,空数组直接跳过白名单校验。调用方程序化构造白名单、结果为空时,期待"什么都不许抓",实际得到"全部放行"。
    • 受影响调用方/流程: 任何动态构造 allowed_domains 的调用方;schema 描述目前对此只字未提。
    • 最小可行修复: 在 allowed_domains 描述中写明"空数组视为不启用白名单"。行为改动(空数组=全拒)会触及 URL safety policy,属 issue 非目标,不建议在本 PR 做。
  4. [低] 非数组入参只会抛出不透明的 TypeError

    • 证据: tools.ts:75-76 直接 as string[] | undefined,无运行时校验。模型误传 blocked_domains: "example.com" 时,url-safety.ts:218.someTypeError,被 tools.ts:83-88 兜住,返回 denyHosts.some is not a function 之类的错误文案。结果是 fail-closed(不会发出请求),所以不是安全缺口,但诊断信息很差,且与数值参数由 clampLimit(engine.ts:50-53)做防御的既有模式不一致。
    • 受影响调用方/流程: LLM 生成的工具调用参数(数组类型最易被写错)。
    • 最小可行修复: 在 handler 里做一次归一化——非数组丢弃、元素过滤为非空字符串后再传给引擎。

行级发现

  • [packages/mcp-web-fetch/src/tools.ts:51] 描述声称与 Anthropic blocked_domains 一致,但 url-safety.ts:218 是精确主机名相等,['example.com'] 不会拦截 www.example.com;改为明确说明"精确主机名匹配,子域需逐条列出,不含 scheme/端口"。
  • [packages/mcp-web-fetch/src/tools.ts:45] 同样问题 + 空数组语义:url-safety.ts:222[] 直接跳过白名单校验,描述里需写明"空数组视为不启用白名单",避免调用方误以为是"全部拒绝"。
  • [packages/mcp-web-fetch/src/tools.ts:75] as string[] | undefined 无运行时校验,非数组入参会在引擎内抛 .some is not a function;建议先归一化为字符串数组(非数组则视为未提供),与 clampLimit 的防御模式对齐。
  • [packages/mcp-web-fetch/src/tools.domain-filters.test.ts:33] 这条用例与 tools.security.test.ts:5-10 的 URL、断言正则完全相同,属重复覆盖;换成子域匹配用例(blocked_domains: ['example.com'] + https://www.example.com/)才能锁定真实语义。
  • [CHANGELOG.md:9] "The agent can now pass domain restrictions" 与当前代码不符:core/src/executor/types.ts:15listTools 不带 schema,core/src/llm/schemas.ts:19-37 的封闭 STEP_PARAMS_SCHEMA 会 strip 掉这两个键;请改为准确表述或在本 PR 补上 planner 侧字段。

Karpathy 评审

  • 假设: PR 假设"暴露 schema 字段 = agent 可用",该假设不成立(见发现 2);同时假设引擎的 host 过滤与 Anthropic 的 domain 语义等价,实际是精确主机名相等(见发现 1)。两个假设都未在代码或测试中被验证。
  • 简洁性: 良好。纯 passthrough 映射,无新抽象、无新层级、无配置膨胀,复用引擎既有的 allowHosts/denyHosts,没有重复造 helper。
  • 结构质量: 无退化。tools.ts 89 行,schema 属性与既有 format/timeoutMs/maxBytes 同构,没有新增分支、flag 或特例路径。snake_case 与既有 camelCase 参数混用属 issue 明确要求(对齐 Anthropic 命名),不作为 finding。
  • 变更范围: 精准。3 个文件、无无关重构、无格式噪声,每一行都能追溯到 issue 的 Scope;未触碰 SSRF、私网拦截、重定向、clamp 逻辑,符合非目标约束。
  • 验证: 强度不足以匹配契约文本。4 条测试只覆盖精确匹配的拒绝路径,没有一条覆盖最容易出错的子域行为;第 4 条与既有 SSRF 测试重复;没有正向用例证明白名单内的 URL 能通过过滤器(handler 层无 fetchImpl 注入口,可在 engine 层用 FetchOptions.fetchImpl 补,engine.ts:32)。

缺失覆盖

  • 子域语义用例:blocked_domains: ['example.com'] + https://www.example.com/,断言当前的精确匹配行为(会放行),把这一意外语义锁进测试并与修正后的描述对齐。
  • 白名单正向路径:在 engine 层用 fetchImpl 注入 mock,验证 allowHosts: ['example.com']https://example.com/ 能通过校验并完成抓取;当前 3 条行为测试全是拒绝断言。
  • 重定向 + 过滤器组合:首跳在白名单内、Location 指向白名单外主机时应被 engine.ts:156-159 拒绝——这是过滤器最有价值的防护场景,目前无覆盖。
  • 畸形入参:blocked_domains 传字符串或包含空串的数组时,应有可读的错误信息而非 .some is not a function
  • 若采纳发现 2 的接线方案:需要一条 planner/executor 层测试,证明 allowed_domains 能从计划步骤参数一路到达 handleWebFetchTool 而不被 zod strip。

Comment thread packages/mcp-web-fetch/src/tools.ts Outdated
type: 'array',
items: { type: 'string' },
description:
'可选域名黑名单。提供后拒绝抓取列表中的域名(与 Anthropic web fetch 的 blocked_domains 一致)。优先级高于 allowed_domains。',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

描述声称与 Anthropic blocked_domains 一致,但 url-safety.ts:218 是精确主机名相等,['example.com'] 不会拦截 www.example.com;改为明确说明"精确主机名匹配,子域需逐条列出,不含 scheme/端口"。

Comment thread packages/mcp-web-fetch/src/tools.ts Outdated
type: 'array',
items: { type: 'string' },
description:
'可选域名白名单。提供后仅允许抓取列表中的域名(与 Anthropic web fetch 的 allowed_domains 一致)。用于最小权限场景,限制 Agent 只能访问受信任的文档站点。',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同样问题 + 空数组语义:url-safety.ts:222[] 直接跳过白名单校验,描述里需写明"空数组视为不启用白名单",避免调用方误以为是"全部拒绝"。

format: args.format as 'text' | 'html' | undefined,
timeoutMs: args.timeoutMs as number | undefined,
maxBytes: args.maxBytes as number | undefined,
allowHosts: args.allowed_domains as string[] | undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as string[] | undefined 无运行时校验,非数组入参会在引擎内抛 .some is not a function;建议先归一化为字符串数组(非数组则视为未提供),与 clampLimit 的防御模式对齐。

expect(result.error).toMatch(/not in allow list/i);
});

it('omitting both filters preserves existing behavior (SSRF guard still applies)', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这条用例与 tools.security.test.ts:5-10 的 URL、断言正则完全相同,属重复覆盖;换成子域匹配用例(blocked_domains: ['example.com'] + https://www.example.com/)才能锁定真实语义。

Comment thread CHANGELOG.md Outdated

### Added

- **mcp-web-fetch**: Exposed optional `allowed_domains` and `blocked_domains` on the `web_fetch` MCP tool schema, mapped to the engine's existing `allowHosts`/`denyHosts` host filters. The agent can now pass domain restrictions aligned with Anthropic web fetch semantics, enabling least-privilege fetches without changing SSRF defaults.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"The agent can now pass domain restrictions" 与当前代码不符:core/src/executor/types.ts:15listTools 不带 schema,core/src/llm/schemas.ts:19-37 的封闭 STEP_PARAMS_SCHEMA 会 strip 掉这两个键;请改为准确表述或在本 PR 补上 planner 侧字段。

…ut (ceilf6#380)

Address PR ceilf6#393 review feedback so the agent can actually pass domain
restrictions end-to-end, with no new failure modes.

- Thread allowed_domains/blocked_domains through STEP_PARAMS_SCHEMA and
  ErrorRecoveryPlanSchema (optional z.array(z.string())). The closed zod
  object previously stripped these keys before they reached
  handleWebFetchTool, so the capability was only available to direct
  programmatic callers — the planner/executor wiring now preserves them.
- Rewrite schema descriptions to state exact-hostname matching semantics
  (no scheme/port; subdomains must be listed individually) and that an
  empty array disables the filter rather than denying all. Drops the
  misleading "aligned with Anthropic" claim.
- Add normalizeHostList in engine.ts (mirrors the clampLimit defensive
  pattern) so non-array input is normalized to undefined instead of
  throwing ".some is not a function" inside the engine. Applied on both
  the initial URL and every redirect hop.
- Tests: replace the redundant SSRF-omission case with subdomain-semantics,
  empty-array, and malformed-input coverage; add a url-safety exact-match
  subdomain test; add a core schema test proving the fields survive
  GeneratedPlanSchema.parse (not stripped).
- CHANGELOG: rephrase to accurately reflect agent-usable end-to-end behavior.

Quality gate run manually (pnpm is environmentally broken on Node 18:
corepack pins the unpublished pnpm@9.0.0). biome + tsc clean; affected
mcp-web-fetch / core llm suites pass. Pre-existing env-only failures
(langgraph crypto, zod z.file, mcp-filesense fixture cleanup) reproduce
identically on the baseline commit and are unrelated to this change.

Co-Authored-By: Claude <noreply@anthropic.com>
@ceilf6

ceilf6 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ ceilf6/repo-guard

代码评审报告: feat(mcp-web-fetch): expose allowed_domains and blocked_domains on web_fetch schema (#380)

风险等级:
处理建议: 请求修改
决策摘要: 功能映射与包内级联是正确的,但 PR 在两个 generateObject 结构化输出 schema 中首次引入了 .optional() 字段,而该文件既有约定是"全部必填 + 空值哨兵";这条链路完全没有验证证据(PR 只跑了 packages/mcp-web-fetch),建议改成必填空数组后再合。

级联分析

  • 变更符号: webFetchSchema / handleWebFetchTool(tools.ts)、fetchUrl + 新增 normalizeHostList(engine.ts)、STEP_PARAMS_SCHEMAErrorRecoveryPlanSchema.recoverySteps[].params(core/llm/schemas.ts)
  • 受影响流程: planner 结构化出计划 → normalizePlanPlanner.convertLLMSteps(planner.ts:323 直接透传 llmStep.params)→ Executor 展开 {...step.params}(executor/executor.ts:155,177)→ mcp-clients.ts:147handleWebFetchToolfetchUrlparseAndValidateUrl(url-safety.ts:218-226)。链路端到端成立,CHANGELOG 关于"此前被 zod 闭合对象剥离"的说明属实。
  • 变更集外调用方: allowHosts/denyHosts 仅由 fetchUrl 与包内测试使用(text search,无包外消费者);STEP_PARAMS_SCHEMAGeneratedPlanSchema/StepExpansionSchema 共用,generateObject 调用点为 plan-generation.ts:321、plan-generation.ts:468、code-generation.ts:398(ErrorRecoveryPlanSchema)。url-safety.ts 未改动,SSRF 默认行为保持不变。
  • 置信度: medium(基于文件阅读 + text search,无代码图谱;PR 自述 detect_changes 未能运行。provider 侧 JSON Schema 转换行为无法在本 checkout 验证——依赖未安装)

问题发现

  1. [高] 首次向结构化输出 schema 引入 optional 字段,与既有"全部必填"约定冲突且零验证

    • 证据: STEP_PARAMS_SCHEMA 其余 15 个字段全部必填并用哨兵值表达"不适用",描述文字明写"所有字段必填,不适用的字符串填空字符串"(schemas.ts:19-51)。本 PR 新增的 allowed_domains/blocked_domains 是该文件中唯二进入 generateObject 路径的 .optional() 字段(GeneratedPatchSchema 虽有 optional,但 grep 显示它没有任何 generateObject 调用点,因此不构成先例)。llm-service.ts:130-134 使用 openai.chat(model)(AI SDK v5,ai@^5.0.199 / @ai-sdk/openai@^2.0.106),OpenAI 严格 structured outputs 要求 required 覆盖全部 properties;optional 字段在该模式下是已知的不兼容点。本环境未安装依赖,无法实证,PR 也未提供任何 provider 侧或 core 侧验证。
    • 受影响调用方/流程: generatePlan(plan-generation.ts:468)、generatePlanInTwoPhases(plan-generation.ts:321)、generateErrorRecoveryPlan(code-generation.ts:398)——即 OpenAI provider 下的主计划生成路径。
    • 最小可行修复: 去掉 .optional(),改为必填 z.array(z.string()),描述沿用文件现有约定"不适用时填空数组"。行为完全等价:空数组在 normalizeHostList 中已折叠为 undefinedurl-safety.ts:222 也把空 allow 列表视为不启用。这样既消除 provider 兼容风险,又与同文件其余字段保持一致。若坚持保留 optional,请补一次真实 provider structured-output 调用或 JSON Schema 转换断言作为证据。
  2. [中] normalizeHostList 只挡住了类型错误,挡不住最常见的格式错误,deny 列表会静默失效

    • 证据: engine.ts:66-75 只做 Array.isArray + typeof string + trimurl-safety.ts:218 用完整 hostname 全等比较。模型按 schema 描述"不含 scheme/端口"是软约束,实际最常见的错误输入是 "https://evil.com""evil.com:443"。这类值进入 blocked_domains 后永远匹配不上,黑名单静默变成空操作(fail-open);进入 allowed_domains 则变成全量拒绝。该函数的注释自称是为"模型写错"兜底,但恰恰漏了这一类。
    • 受影响调用方/流程: 所有带域名过滤的 web_fetch 调用;SSRF 默认防护不受影响,但最小权限这一层会无声失效。
    • 最小可行修复: 在 normalizeHostList 中对每个条目做一次规范化——若含 ://new URL(entry).hostname,否则剥离尾部 :port、结尾 .,并 toLowerCase();无法解析的条目丢弃。同时为 "https://example.com" 形式补一条测试。
  3. [中] 精确主机名匹配与 Anthropic 同名参数的语义不一致,容易造成 agent 自我拒绝

    • 证据: tools.ts:41-52 的描述已明确说明"子域需逐条列出",url-safety.test.ts:97-115 也固化了该语义。但 issue #380 的显式约束是"Keep naming aligned with Anthropic's documented tool parameters"——采用同名参数却是更严格的精确匹配。模型对 allowed_domains: ["example.com"] 的自然预期是覆盖 docs.example.com,实际会在抓文档站时被自己设的白名单挡下(Host is not in allow list),且这是硬失败而非降级。
    • 受影响调用方/流程: planner 生成的最小权限 web_fetch 步骤;失败发生在网络请求前,会直接进入 executor 的错误路径。
    • 最小可行修复: 二选一并明确记录——(a) 保持精确匹配(符合 issue 的"不改 URL 安全策略"非目标),但在 STEP_PARAMS_SCHEMA 的描述里也把"子域不自动包含"写全(当前 core 侧描述已有一句,但比 tools.ts 弱),并在 CHANGELOG 中标注与 Anthropic 语义的差异;(b) 改为后缀匹配 host === d || host.endsWith('.' + d)。注意 (b) 会放宽 allow 列表语义,属于安全策略变更,需单独 PR。
  4. [中] 验证范围没有覆盖实际改动的包,PR 描述与 diff 不符

    • 证据: PR 描述称"Single file change in packages/mcp-web-fetch"、"No engine, SSRF, or url-safety logic changed"、"No other packages touched",并据此给出 LOW 风险与"非关键骨架变更"的结论。实际 diff 改了 packages/mcp-web-fetch/src/engine.tspackages/core/src/llm/schemas.tspackages/core/src/llm/plan-generation.test.tspackages/mcp-web-fetch/src/url-safety.test.ts。验证一节只列了 packages/mcp-web-fetch 的 48 个测试,packages/core 一次都没跑——而问题 1 正好落在 core 里。
    • 受影响调用方/流程: 评审者的风险判断依据;packages/core 的 planner/executor 测试集。
    • 最小可行修复: 更新 PR 描述与 GitNexus 影响摘要以反映真实范围,并补跑 packages/core 的测试(至少 plan-generationplannerexecutor)后把结果贴进验证一节。

行级发现

  • [packages/core/src/llm/schemas.ts:38] 该文件其余字段一律必填 + 哨兵值,这里的 .optional() 是进入 generateObject 的首个可选字段,OpenAI 严格 structured outputs 下有兼容风险;改成必填 z.array(z.string()) + "不适用时填空数组",行为等价(空数组已被 normalizeHostList 折叠为 undefined)。
  • [packages/core/src/llm/schemas.ts:140] 同上,ErrorRecoveryPlanSchemacode-generation.ts:398generateObject,需与 STEP_PARAMS_SCHEMA 采取一致处理,不要一处 optional 一处必填。
  • [packages/mcp-web-fetch/src/engine.ts:71] 只 trim() 无法处理 "https://example.com" / "example.com:443" 这类模型最常写错的格式,blocked_domains 会静默失效;此处补一次 hostname 规范化(含 :// 时取 new URL(entry).hostname,否则剥离端口/结尾点并小写)。
  • [packages/mcp-web-fetch/src/tools.ts:45] 与 Anthropic 同名参数的子域语义不一致,模型给出 example.com 却抓 docs.example.com 时会被自己的白名单硬拒;若保持精确匹配,请把这条约束同步写进 STEP_PARAMS_SCHEMA 的描述并在 CHANGELOG 标注差异。

Karpathy 评审

  • 假设: 隐含假设"planner 结构化输出可以接受 optional 字段",未验证(问题 1);隐含假设"模型会按描述给出裸 hostname",normalizeHostList 的兜底并未覆盖该假设失效的主要形态(问题 2)。
  • 简洁性: 映射本身足够薄——两个 schema 属性 + 两行透传,没有多余抽象。normalizeHostList 是合理的边界防御,放在 engine(安全边界)而非 tools 层是正确取舍。
  • 结构质量: 无退化。没有新增分支扩散、没有薄 wrapper、没有重复 canonical helper,文件规模均在可扫描范围内(tools.ts 89 行、engine.ts 273 行、schemas.ts 175 行)。ErrorRecoveryPlanSchema 内联复制 params 形状是本就存在的重复,本 PR 只是跟随,不算新增债务。
  • 变更范围: 基本外科手术式,但比 PR 自述宽(跨 2 个包 3 个源文件)。engine 的规范化与 core 的 schema 透传都是让功能真正端到端可用所必需,属于合理扩展,问题在于描述没有同步(问题 4)。
  • 验证: 包内测试设计得好——4 个 handler 级测试全部在网络请求前短路,确实无网络依赖,且反向覆盖了 mapping 是否写反(denied vs not in allow list)。缺口在 core 侧:新增的 GeneratedPlanSchema.parse 测试只证明 zod 运行时不剥离字段,没有证明 provider 侧 JSON Schema 转换可用,而后者才是问题 1 的风险面。

缺失覆盖

  • provider 侧结构化输出兼容性:对 GeneratedPlanSchema 做一次 JSON Schema 转换断言(例如断言 required 覆盖全部 properties,或按当前 provider 约定断言可选字段的表示形式),否则问题 1 无法被 CI 捕获。
  • packages/core 全量测试结果:schemas.ts 被改动但该包测试未在 PR 中报告运行。
  • 域名格式健壮性:allowed_domains: ['https://example.com']['example.com:443']['EXAMPLE.com '] 三类输入的行为断言(当前 normalizeHostList 对前两类会静默产生永不匹配的过滤器)。
  • 正向放行路径:目前只有"被拒绝"方向的 handler 级断言;建议补一条 allowed_domains 命中后仍能进入 fetch 阶段的断言(可经 fetchImpl 注入或直接在 url-safety 层断言,避免真实网络)。

Comment thread packages/core/src/llm/schemas.ts Outdated
changeDescription: z.string().describe('要做的修改描述(不适用时填空字符串)'),
allowed_domains: z
.array(z.string())
.optional()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

该文件其余字段一律必填 + 哨兵值,这里的 .optional() 是进入 generateObject 的首个可选字段,OpenAI 严格 structured outputs 下有兼容风险;改成必填 z.array(z.string()) + "不适用时填空数组",行为等价(空数组已被 normalizeHostList 折叠为 undefined)。

Comment thread packages/core/src/llm/schemas.ts Outdated
changeDescription: z.string().describe('要做的修改描述(不适用时填空字符串)'),
allowed_domains: z
.array(z.string())
.optional()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同上,ErrorRecoveryPlanSchemacode-generation.ts:398generateObject,需与 STEP_PARAMS_SCHEMA 采取一致处理,不要一处 optional 一处必填。

const cleaned: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string') continue;
const trimmed = entry.trim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trim() 无法处理 "https://example.com" / "example.com:443" 这类模型最常写错的格式,blocked_domains 会静默失效;此处补一次 hostname 规范化(含 :// 时取 new URL(entry).hostname,否则剥离端口/结尾点并小写)。

type: 'array',
items: { type: 'string' },
description:
'可选域名白名单,仅 web_fetch 适用,可省略。采用精确主机名匹配(不含 scheme/端口):提供后仅允许抓取主机名恰好出现在列表中的 URL,子域需逐条列出(如 example.com 不会自动放行 www.example.com)。空数组视为不启用白名单(而非全部拒绝)。用于最小权限场景,限制 Agent 只能访问受信任的文档站点。',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

与 Anthropic 同名参数的子域语义不一致,模型给出 example.com 却抓 docs.example.com 时会被自己的白名单硬拒;若保持精确匹配,请把这条约束同步写进 STEP_PARAMS_SCHEMA 的描述并在 CHANGELOG 标注差异。

Use z.array(z.string()).nullish() instead of .optional() for
allowed_domains/blocked_domains in STEP_PARAMS_SCHEMA and
ErrorRecoveryPlanSchema. Models sometimes emit null for optional fields;
.optional() only accepts undefined and would reject null, forcing an
object-repair retry. .nullish() accepts null/undefined, and the engine's
normalizeHostList already treats null identically to undefined (filter
disabled). Adds a regression test proving null survives
GeneratedPlanSchema.parse without error.

Co-Authored-By: Claude <noreply@anthropic.com>
@ceilf6

ceilf6 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ ceilf6/repo-guard

代码评审报告: feat(mcp-web-fetch): expose allowed_domains and blocked_domains on web_fetch schema (#380)

风险等级:
处理建议: 请求修改
决策摘要: 功能设计本身正确、契约是附加式的,主路径未发现破坏性问题;但畸形过滤器输入会让新加的安全限制静默失效(fail-open,并被新测试固化),且 PR 自述范围与实际 diff 不符——占一半改动的 packages/core LLM 契约没有任何验证证据,建议补这两项后合入。

级联分析

  • 变更符号: webFetchSchema.inputSchema.propertieshandleWebFetchToolfetchUrl、新增导出 normalizeHostList(engine.ts:66)、STEP_PARAMS_SCHEMAErrorRecoveryPlanSchema.recoverySteps.params
  • 受影响流程: planner 结构化输出(GeneratedPlanSchema / StepExpansionSchema / ErrorRecoveryPlanSchema,覆盖全部 13 个 action,不只 web_fetch)→ Executor.executeStep(executor.ts:155 {...step.params} 原样透传)→ ExecutorToolCallHandler.callToolWebMCPClient.callTool(mcp-clients.ts:147) → handleWebFetchToolfetchUrlparseAndValidateUrl(首跳 + 每个重定向跳)
  • 变更集外调用方: fetchUrl 仅被 tools.ts:71engine.test.ts 调用;parseAndValidateUrl 的 host filter 仅由 engine 传入——包内闭合,无外部签名破坏。FetchOptions 类型未变,既有编程式调用方兼容。models/frontagent-planner/prompts/schema.jsonGeneratedPlanSchema 的镜像副本,未同步(该副本本就已漂移,action enum 里连 web_fetch 都没有)。
  • 置信度: medium(无代码图谱:PR 自述 detect_changes 未能运行,本环境亦无索引。结论来自 grep + 直接读取全部调用点;@ai-sdk/openai 的 strict structured-output 行为因 node_modules 未安装而无法实测,仅通过仓库内既有先例交叉验证)

问题发现

  1. [中] 畸形过滤器输入让 deny list 静默失效(fail-open),且被新测试固化

    • 证据: normalizeHostList(engine.ts:66-75) 对任何非数组输入返回 undefined。改动前 blocked_domains: 'evil.com'(裸字符串)会在 opts?.denyHosts?.some(...)(url-safety.ts:218) 抛 TypeError,被 handleWebFetchTool 的 catch 转成失败——请求不会发出(fail-closed)。改动后同样输入被当作"未提供过滤器",抓取照常进行(fail-open)。tools.domain-filters.test.ts:46-57 把这一行为写成了期望。同理,['evil.com', 123] 中的非字符串项被静默丢弃。
    • 受影响调用方/流程: 任何直接调用 handleWebFetchTool 的 MCP host(runtime-node 内的 executor 路径参数未做校验,见 tool-call-handler.ts:74);FrontAgent 自身走 zod 校验,可达性较低,但这正是该特性存在意义的场景——调用方打算限制却被静默忽略。
    • 最小可行修复: normalizeHostList 区分"缺省"与"畸形":value == null 返回 undefined;非数组、或数组中含非字符串项时抛 UrlSafetyError('Invalid domain filter: ...')(handler 已有 catch,会转成 success:false)。空数组仍保留"不启用"语义。同步调整该测试断言为期望明确报错。
  2. [中] PR 自述范围与验证证据不覆盖实际 diff 的一半

    • 证据: PR 描述称 "Single file change in packages/mcp-web-fetch"、"No engine ... changed"、"No other packages touched"、风险 LOW;实际 diff 改了 engine.tspackages/core/src/llm/schemas.ts(planner 的 LLM 输出契约,被 GeneratedPlanSchema/StepExpansionSchema/ErrorRecoveryPlanSchema 共用,影响全部 action 的规划调用)以及 packages/core/src/llm/plan-generation.test.ts。Verification 段落只列了 packages/mcp-web-fetch 的 48 tests / tsc / biome,pnpm quality:precommit 因环境问题未跑。
    • 受影响调用方/流程: 所有 LLMService.generateObject 规划调用。注:STEP_PARAMS_SCHEMA 原本每个字段都必填(描述明写"所有字段必填"),本 PR 引入其首批可选字段;我检查后确认 code-quality-subagent.ts:160,163 已在同一 generateObject 通道上使用 .optional(),因此没有证据表明会触发 provider 的 strict-schema 报错——但这一判断依赖 provider 行为,需要真实跑一次核心包的规划测试来兜底。
    • 最小可行修复: 补跑并贴出 packages/core 的 vitest + tsc 结果(至少 packages/core/src/llm),并把 PR 描述/GitNexus 影响摘要更正为实际范围(含 core 的 LLM 契约变更),不要以 "single file / LOW" 的前提让人评审。
  3. [中] blocked_domains 采用精确匹配,对子域 fail-open,与其借用的 Anthropic 语义存在落差

    • 证据: parseAndValidateUrl(url-safety.ts:218) 只做全等比较,url-safety.test.ts:97-113 明确固化 denyHosts: ['example.com'] 不拦截 www.example.com。issue #380 要求"命名对齐 Anthropic 文档参数",同时非目标是"不改 URL safety policy"——所以取名对齐、语义不对齐是必然结果。白名单方向是 fail-closed(安全),黑名单方向是 fail-open(列了 evil.com 仍会抓 www.evil.com)。
    • 受影响调用方/流程: 使用该黑名单做最小权限约束的 agent/调用方。当前缓解手段只有文档——tools.ts:47-52schemas.ts:42-47 的描述都明确写了"子域需逐条列出",模型侧可见,缓解到位。
    • 最小可行修复: 本 PR 内不必改(符合 issue 非目标);建议在 CHANGELOG/issue 中留下明确的后续项:deny 侧改为后缀匹配 host === h || host.endsWith('.' + h),或提供显式的 *.example.com 语法。请维护者确认接受当前语义。
  4. [低] ErrorRecoveryPlanSchema 的 params 副本继续扩大重复

    • 证据: schemas.ts:126-147 内联了一份 STEP_PARAMS_SCHEMA 的近似副本(本就缺 query/filePattern/globOnly/maxResults),本 PR 又把两个新字段以不同措辞的 describe 复制了进去(schemas.ts:138-145 vs 36-47)。
    • 受影响调用方/流程: analyzeErrorsAndGenerateRecovery(code-generation.ts:398);两份描述会随时间继续漂移。
    • 最小可行修复: 恢复步骤的 params 直接复用 STEP_PARAMS_SCHEMA(或抽出共享 base),本次即可顺手收敛,diff 反而更小。
  5. [低] 规划提示词没提这两个参数,端到端能力实际难以被触发

    • 证据: 两阶段展开的"参数填写指南"里 plan-generation.ts:277 仍是"web_fetch: params 需要 url";WebMCPClient.listTools()(mcp-clients.ts:166-169) 只返回 name/description,不下发 webFetchSchema 的 inputSchema。模型能看到的只有 STEP_PARAMS_SCHEMA 的字段描述。
    • 受影响调用方/流程: CHANGELOG 声称"the agent can actually pass domain restrictions end-to-end"——链路确实通了,但没有任何提示引导模型使用。
    • 最小可行修复: 在 plan-generation.ts:277 追加一行,例如"抓取受信任文档站点时可加 allowed_domains(精确主机名)"。

行级发现

  • [packages/mcp-web-fetch/src/engine.ts:67] 非数组输入返回 undefined 会把调用方明确设置的 deny list 静默变成"无过滤"(改动前会抛错并 fail-closed);建议区分 value == null(视为未提供)与 present-but-malformed(抛 UrlSafetyError,由 handler 的 catch 转成失败结果)。
  • [packages/mcp-web-fetch/src/tools.domain-filters.test.ts:46] 该用例把"畸形安全过滤器被静默忽略后继续抓取"固化为期望;随 engine 改为 fail-closed 后,应改成断言返回明确的 Invalid domain filter 错误。
  • [packages/mcp-web-fetch/src/tools.ts:75] args.allowed_domains as string[] | undefined 对未经校验的 MCP 入参做了不成立的断言;边界处应显式校验并拒绝非法形状,而不是靠下游 normalizeHostList 把它抹掉。
  • [packages/mcp-web-fetch/src/url-safety.test.ts:100] 这条断言固化了"deny list 不覆盖子域";请在 CHANGELOG 或 issue 中显式记录该语义与 Anthropic 同名参数的差异,避免调用方按直觉误用黑名单。
  • [packages/core/src/llm/schemas.ts:138] 这里是 STEP_PARAMS_SCHEMA 的第二份副本,且新字段 describe 措辞与 36-47 行不一致;建议直接复用 STEP_PARAMS_SCHEMA 而不是继续维护两份。

Karpathy 评审

  • 假设: 隐含假设"到达 handler 的 allowed_domains 一定是数组"——在 FrontAgent 内部由 zod 保证,但作为 MCP 公共工具不成立,而兜底策略选择了 fail-open。另一处隐含假设是新的可选字段不会影响 provider 的结构化输出约束;仓库内已有 .optional() 先例(code-quality-subagent.ts:160),但本 PR 未提供核心包的运行证据。
  • 简洁性: normalizeHostListclampLimit 姿态一致,没有多余抽象;集中在 fetchUrl 内归一化、重定向跳复用同一份结果(engine.ts:140-141,182-183)是正确做法,避免了逐跳重复清洗。
  • 结构质量: 无新增分支缠绕、无薄 wrapper、无文件规模跨界(engine.ts 273 行,tools.ts 89 行)。唯一退化是 ErrorRecoveryPlanSchema 的 params 副本被进一步复制(见发现 4)。
  • 变更范围: 超出 PR 自述范围。core LLM 契约的改动是必要的(否则字段在 planner 侧就被 strip,特性无法端到端使用),但必须在描述与风险评估里如实反映,而不是以 "single file / LOW risk" 呈现。
  • 验证: mcp-web-fetch 侧覆盖扎实且确实无网络(host 过滤在任何 fetch 之前发生,127.0.0.1 用例由 isBlockedHostname 提前拒绝)。core 侧新增了两个 schema 用例但缺少运行证据;重定向跳的过滤器复检(engine.ts:181-184)这一安全相关路径没有任何测试。

缺失覆盖

  • 重定向场景下的域名过滤:白名单内 URL 302 跳到白名单外主机时必须被拒绝(engine.test.ts 已有 fetchImpl 重定向脚手架,可直接复用)。
  • normalizeHostList 的直接单测:null/undefined/非数组/含空白与非字符串项的混合数组/全空白数组,以及(按发现 1 修复后)畸形输入应报错的断言。
  • packages/core 的实际执行证据:至少 packages/core/src/llm 的 vitest 与 tsc --noEmit,确认新增可选字段未破坏既有规划路径。
  • ErrorRecoveryPlanSchema 侧未新增任何用例:恢复计划里的 allowed_domains/blocked_domains 是否同样能被解析并透传,目前完全未验证。

* "filter disabled" rather than "deny all" (see `url-safety.ts`).
*/
export function normalizeHostList(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

非数组输入返回 undefined 会把调用方明确设置的 deny list 静默变成"无过滤"(改动前会抛错并 fail-closed);建议区分 value == null(视为未提供)与 present-but-malformed(抛 UrlSafetyError,由 handler 的 catch 转成失败结果)。

expect(result.error).toMatch(/private|blocked|loopback|safety/i);
});

it('non-array filter input is normalized away instead of crashing the engine', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

该用例把"畸形安全过滤器被静默忽略后继续抓取"固化为期望;随 engine 改为 fail-closed 后,应改成断言返回明确的 Invalid domain filter 错误。

format: args.format as 'text' | 'html' | undefined,
timeoutMs: args.timeoutMs as number | undefined,
maxBytes: args.maxBytes as number | undefined,
allowHosts: args.allowed_domains as string[] | undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

args.allowed_domains as string[] | undefined 对未经校验的 MCP 入参做了不成立的断言;边界处应显式校验并拒绝非法形状,而不是靠下游 normalizeHostList 把它抹掉。

it('host filters use exact hostname matching — subdomains are not implied', () => {
// denyHosts: ['example.com'] must NOT deny www.example.com (exact match only).
const allowed = parseAndValidateUrl('https://www.example.com/', {
denyHosts: ['example.com'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这条断言固化了"deny list 不覆盖子域";请在 CHANGELOG 或 issue 中显式记录该语义与 Anthropic 同名参数的差异,避免调用方按直觉误用黑名单。

fullPage: z.boolean().describe('是否全页截图,不适用时填false'),
codeDescription: z.string().describe('要生成的代码的描述(不适用时填空字符串)'),
changeDescription: z.string().describe('要做的修改描述(不适用时填空字符串)'),
allowed_domains: z

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里是 STEP_PARAMS_SCHEMA 的第二份副本,且新字段 describe 措辞与 36-47 行不一致;建议直接复用 STEP_PARAMS_SCHEMA 而不是继续维护两份。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(mcp-web-fetch): expose allowed_domains and blocked_domains in web_fetch schema

2 participants