Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/superpowers/plans/2026-07-01-web-fetch-domain-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Web Fetch Domain Filters Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Expose Anthropic-compatible `allowed_domains` and `blocked_domains` parameters on the `web_fetch` MCP tool and route them to the existing engine host filters.

**Architecture:** Keep enforcement in the existing URL safety and fetch engine path. Extend only the MCP schema and handler adapter so `allowed_domains` maps to `allowHosts` and `blocked_domains` maps to `denyHosts`.

**Tech Stack:** TypeScript, Vitest, existing `@frontagent/mcp-web-fetch` package.

---

### Task 1: Add Schema and Handler Coverage

**Files:**
- Modify: `packages/mcp-web-fetch/src/tools.security.test.ts`

- [x] **Step 1: Add schema assertions**

Import `webFetchSchema` and `vi`, then add tests asserting the schema exposes `allowed_domains` and `blocked_domains` as optional string arrays.

- [x] **Step 2: Add handler no-network assertions**

Patch `globalThis.fetch` with a throwing mock for each domain-filter test, call `handleWebFetchTool('web_fetch', ...)`, assert the result fails with allow/block wording, and assert the fetch mock was not called.

- [x] **Step 3: Run focused test and confirm failure before implementation**

Run: `pnpm --dir packages/mcp-web-fetch test -- --run src/tools.security.test.ts`

Expected before implementation: schema/domain-filter tests fail because schema fields are missing and handler does not pass filters to the engine.

### Task 2: Map Tool Parameters to Engine Options

**Files:**
- Modify: `packages/mcp-web-fetch/src/tools.ts`

- [x] **Step 1: Extend `webFetchSchema`**

Add optional `allowed_domains` and `blocked_domains` properties with `type: 'array'`, string `items`, and descriptions noting their mapping to `allowHosts` and `denyHosts`.

- [x] **Step 2: Extend `handleWebFetchTool`**

Pass `allowHosts: args.allowed_domains as string[] | undefined` and `denyHosts: args.blocked_domains as string[] | undefined` to `fetchUrl`.

- [x] **Step 3: Run focused tests**

Run: `pnpm --dir packages/mcp-web-fetch test -- --run src/tools.security.test.ts`

Expected after implementation: all tests in `tools.security.test.ts` pass.

### Task 3: Verify and Prepare PR

**Files:**
- No additional planned code files.

- [x] **Step 1: Run mcp-web-fetch package tests**

Run: `pnpm --dir packages/mcp-web-fetch test`

Expected: package test suite passes.

- [x] **Step 2: Run required broader gate**

Run: `pnpm quality:precommit`

Expected: lint, typecheck, test, and workflow tests pass.

- [x] **Step 3: Run GitNexus final diff analysis**

Run GitNexus `detect_changes` for `/tmp/frontagent-issue-380` before committing and include the blast radius summary in the PR body.

- [ ] **Step 4: Commit, push, and open PR**

Commit with a focused message, push `feat/web-fetch-domain-filters-380`, and open a PR to `develop` with `Closes #380`.
81 changes: 79 additions & 2 deletions packages/mcp-web-fetch/src/tools.security.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { handleWebFetchTool } from './tools.js';
import { describe, expect, it, vi } from 'vitest';
import { handleWebFetchTool, webFetchSchema } from './tools.js';

describe('mcp-web-fetch SSRF guards (no network)', () => {
it('rejects loopback IPv4 addresses', async () => {
Expand Down Expand Up @@ -54,4 +54,81 @@ describe('mcp-web-fetch SSRF guards (no network)', () => {
expect(result.success).toBe(false);
expect(result.error).toMatch(/unknown/i);
});

it('exposes allowed_domains and blocked_domains schema parameters', () => {
expect(webFetchSchema.inputSchema.properties.allowed_domains).toMatchObject({
type: 'array',
items: { type: 'string' },
minItems: 1,
});
expect(webFetchSchema.inputSchema.properties.blocked_domains).toMatchObject({
type: 'array',
items: { type: 'string' },
minItems: 1,
});
expect(webFetchSchema.inputSchema.required).toEqual(['url']);
});

it('maps allowed_domains to allowHosts and rejects before fetch', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(() => {
throw new Error('network attempted');
});
globalThis.fetch = fetchMock as unknown as typeof fetch;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

/allow/i 过于宽泛,可能匹配无关错误文案;收紧为 /not in allowlist/i 以确保断言的是 allowlist 拒绝。

try {
const result = await handleWebFetchTool('web_fetch', {
url: 'https://example.com/',
allowed_domains: ['docs.example.com'],
});

expect(result.success).toBe(false);
expect(result.error).toMatch(/not in allowlist|allow/i);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});

it('maps blocked_domains to denyHosts and rejects before fetch', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(() => {
throw new Error('network attempted');
});
globalThis.fetch = fetchMock as unknown as typeof fetch;

try {
const result = await handleWebFetchTool('web_fetch', {
url: 'https://example.com/',
blocked_domains: ['example.com'],
});

expect(result.success).toBe(false);
expect(result.error).toMatch(/denylist|blocked|denied/i);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});

it('rejects an empty allowed_domains list before fetch', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(() => {
throw new Error('network attempted');
});
globalThis.fetch = fetchMock as unknown as typeof fetch;

try {
const result = await handleWebFetchTool('web_fetch', {
url: 'https://example.com/',
allowed_domains: [],
});

expect(result.success).toBe(false);
expect(result.error).toMatch(/allowed_domains.*at least one domain/i);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
});
30 changes: 30 additions & 0 deletions packages/mcp-web-fetch/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ export const webFetchSchema = {
maximum: 5000000,
description: '响应体最大字节数,超出后截断,默认 2000000,硬上限 5000000',
},
allowed_domains: {
type: 'array',
items: { type: 'string' },
minItems: 1,
description: '可选允许域名列表;映射到底层引擎 allowHosts,仅允许抓取这些主机名。',
},
Comment on lines +41 to +46

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 671cbd0: allowed_domains and blocked_domains now declare minItems: 1, and the handler rejects empty provided lists before any fetch attempt. Added a no-network regression test for empty allowed_domains.

blocked_domains: {
type: 'array',
items: { type: 'string' },
minItems: 1,
description: '可选阻止域名列表;映射到底层引擎 denyHosts,拒绝抓取这些主机名。',
},
},
required: ['url'],
},
Expand All @@ -56,10 +68,28 @@ export async function handleWebFetchTool(
try {
switch (toolName) {
case 'web_fetch': {
const 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 直接 cast,未校验数组元素为 string,非法输入(如 [123])会静默透传到 engine;建议在此处对元素类型做最小校验或依赖 schema 校验层明确生效。

const denyHosts = args.blocked_domains as string[] | undefined;

if (Array.isArray(allowHosts) && allowHosts.length === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

空数组被显式拒绝,但 schema 已声明 minItems: 1——若 MCP 层已按 schema 校验,此 handler 分支为冗余防御;若 schema 未强制校验,则说明不能依赖 schema,第 71 行的元素类型也同样不可信。请明确 schema 校验是否在调用前生效,并保持两处假设一致。

return {
success: false,
error: 'allowed_domains must contain at least one domain when provided',
};
}
if (Array.isArray(denyHosts) && denyHosts.length === 0) {
return {
success: false,
error: 'blocked_domains must contain at least one domain when provided',
};
}

const data = await fetchUrl(args.url as string, {
format: args.format as 'text' | 'html' | undefined,
timeoutMs: args.timeoutMs as number | undefined,
maxBytes: args.maxBytes as number | undefined,
allowHosts,
denyHosts,
});
return { success: true, data };
}
Expand Down