From ba6abfb078e8bd49e4407d9393d3fe10e3f6ccc3 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 22 Feb 2026 20:12:54 -0800 Subject: [PATCH] feat: add brave search container and toolset --- README.md | 47 ++++++ config/default.yaml | 15 ++ docker-compose.yml | 15 ++ docs/api/TOOLS.md | 57 ++++++- docs/plans/state.json | 33 +++- src/tools/builtin/index.test.ts | 22 +++ src/tools/builtin/index.ts | 9 +- src/tools/builtin/web-search.test.ts | 89 +++++++++- src/tools/builtin/web-search.ts | 240 ++++++++++++++++++++++----- src/tools/executor.test.ts | 19 +++ src/tools/executor.ts | 2 +- src/tools/policy.test.ts | 3 + src/tools/policy.ts | 4 +- 13 files changed, 502 insertions(+), 53 deletions(-) create mode 100644 src/tools/builtin/index.test.ts diff --git a/README.md b/README.md index 80dcd2d..bbab16d 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,53 @@ Audio persistence and diagnostics: - Runbook: `docs/runbooks/VOICE_TRANSCRIPTION_DEBUG.md`. - SQLite quick reference: `docs/runbooks/SQLITE_QUICK_REFERENCE.md`. +### Web Search + +Flynn provides web search tools through the `web_search` config: + +- `web.search` - general web results (Brave or SearXNG provider) +- `web.search.news` - news results (Brave provider only) + +Brave Search configuration: + +```yaml +web_search: + provider: brave + api_key: "${BRAVE_API_KEY}" + max_results: 5 +``` + +SearXNG configuration: + +```yaml +web_search: + provider: searxng + endpoint: "http://searxng:8080" + max_results: 5 +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `provider` | no | `brave` or `searxng` (default: `brave`) | +| `api_key` | yes (Brave) | Brave Search API key | +| `endpoint` | yes (SearXNG) | Base URL for self-hosted SearXNG | +| `max_results` | no | Default result count for `web.search*` tools (default: `5`, max: `20`) | + +Brave-specific query fields (supported by `web.search`, and by `web.search.news` except `safeSearch`): + +- `country` (for example `us`, `gb`) +- `searchLang` (for example `en`, `de`) +- `freshness` (`pd`, `pw`, `pm`, `py`) +- `safeSearch` (`off`, `moderate`, `strict`) - `web.search` only + +Optional local Brave Search container (for MCP-based workflows): + +```bash +docker compose --profile search up -d brave-search +``` + +Note: Flynn's built-in `web.search*` tools call Brave's HTTP API directly and use `web_search.api_key`; they do not route through the `brave-search` MCP container. + ### Text-to-Speech (TTS) Reply Audio Flynn can attach synthesized voice replies (OpenAI-compatible `/v1/audio/speech`) alongside text responses. diff --git a/config/default.yaml b/config/default.yaml index 5aac13b..7c655dc 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -245,6 +245,21 @@ models: # - `tools.profile: coding` or `tools.profile: full` must allow browser.* tools. # - `tools.deny` patterns can still block browser.* even when browser.enabled is true. +# ── Web Search ─────────────────────────────────────────────────────── +# Configure web.search tools. +# - provider: brave -> enables web.search + web.search.news (requires api_key) +# - provider: searxng -> enables web.search (requires endpoint) +# +# web_search: +# provider: brave +# api_key: "${BRAVE_API_KEY}" +# max_results: 5 +# # endpoint: "http://searxng:8080" # Used when provider=searxng +# +# Optional local Brave Search MCP container (for MCP workflows only): +# docker compose --profile search up -d brave-search +# Built-in web.search* tools call Brave/SearXNG HTTP endpoints directly. + hooks: confirm: - shell.* diff --git a/docker-compose.yml b/docker-compose.yml index 2d24e00..73d574d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,21 @@ services: start_period: 15s retries: 3 + # Optional local dependency: Brave Search MCP server (HTTP mode). + # Start with: docker compose --profile search up -d brave-search + brave-search: + image: mcp/brave-search:latest + container_name: brave-search + restart: unless-stopped + profiles: ["search"] + ports: + - "18802:8000" + environment: + - BRAVE_API_KEY=${BRAVE_API_KEY:?BRAVE_API_KEY is required} + - BRAVE_MCP_TRANSPORT=http + - BRAVE_MCP_HOST=0.0.0.0 + - BRAVE_MCP_PORT=8000 + volumes: flynn-data: whisper-models: diff --git a/docs/api/TOOLS.md b/docs/api/TOOLS.md index a0f894d..11b5da2 100644 --- a/docs/api/TOOLS.md +++ b/docs/api/TOOLS.md @@ -871,9 +871,62 @@ Search the web. "type": "string", "description": "Search query" }, - "limit": { + "count": { "type": "number", - "description": "Number of results to return (default: 10)" + "description": "Number of results to return (default: 5, max: 20)" + }, + "country": { + "type": "string", + "description": "Optional country code (Brave provider)" + }, + "searchLang": { + "type": "string", + "description": "Optional language code (Brave provider)" + }, + "safeSearch": { + "type": "string", + "description": "Brave-only safesearch mode: off | moderate | strict" + }, + "freshness": { + "type": "string", + "description": "Brave freshness filter: pd | pw | pm | py" + } + }, + "required": ["query"] + } +} +``` + +#### `web.search.news` + +Search Brave News results (available when `web_search.provider: brave`). + +```json +{ + "name": "web.search.news", + "description": "Search Brave News and return results", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "News search query" + }, + "count": { + "type": "number", + "description": "Number of results to return (default: 5, max: 20)" + }, + "country": { + "type": "string", + "description": "Optional country code" + }, + "searchLang": { + "type": "string", + "description": "Optional language code" + }, + "freshness": { + "type": "string", + "description": "Freshness filter: pd | pw | pm | py" } }, "required": ["query"] diff --git a/docs/plans/state.json b/docs/plans/state.json index 33f5217..c015ba2 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,37 @@ "updated_at": "2026-02-23", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "brave-search-tooling-docs": { + "status": "completed", + "date": "2026-02-23", + "updated": "2026-02-23", + "summary": "Documented Brave web-search tooling in operator-facing docs: added README guidance for `web.search`/`web.search.news` provider config (Brave + SearXNG), Brave-specific query parameters, and clarified how the optional `brave-search` compose container relates to built-in tools. Added matching commented config block in `config/default.yaml`.", + "files_modified": [ + "README.md", + "config/default.yaml", + "docs/plans/state.json" + ], + "test_status": "docs/config comments update only; no runtime code changes" + }, + "brave-search-toolset-expansion": { + "status": "completed", + "date": "2026-02-23", + "updated": "2026-02-23", + "summary": "Expanded Brave web tooling with richer query controls (`country`, `searchLang`, `safeSearch`, `freshness`) and added dedicated `web.search.news` mapped to Brave's news endpoint. Updated tool policy/profile allowlists and prompt-injection secret-arg safeguards so `web.search.*` inherits network secret protections.", + "files_modified": [ + "src/tools/builtin/web-search.ts", + "src/tools/builtin/web-search.test.ts", + "src/tools/builtin/index.test.ts", + "src/tools/builtin/index.ts", + "src/tools/policy.ts", + "src/tools/policy.test.ts", + "src/tools/executor.ts", + "src/tools/executor.test.ts", + "docs/api/TOOLS.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/tools/builtin/index.test.ts src/tools/builtin/web-search.test.ts src/tools/policy.test.ts src/tools/executor.test.ts + pnpm typecheck passing" + }, "dashboard-docker-dependency-controls": { "status": "completed", "date": "2026-02-23", @@ -6132,7 +6163,7 @@ } }, "overall_progress": { - "total_test_count": 1942, + "total_test_count": 1951, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/tools/builtin/index.test.ts b/src/tools/builtin/index.test.ts new file mode 100644 index 0000000..ff18817 --- /dev/null +++ b/src/tools/builtin/index.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { createWebSearchTools } from './index.js'; + +describe('createWebSearchTools', () => { + it('registers web + news search tools for Brave provider', () => { + const tools = createWebSearchTools({ + provider: 'brave', + apiKey: 'test-key', + }); + + expect(tools.map((tool) => tool.name)).toEqual(['web.search', 'web.search.news']); + }); + + it('registers only web.search for SearXNG provider', () => { + const tools = createWebSearchTools({ + provider: 'searxng', + endpoint: 'http://searxng:8080', + }); + + expect(tools.map((tool) => tool.name)).toEqual(['web.search']); + }); +}); diff --git a/src/tools/builtin/index.ts b/src/tools/builtin/index.ts index 3cad4b6..cf087d4 100644 --- a/src/tools/builtin/index.ts +++ b/src/tools/builtin/index.ts @@ -13,6 +13,7 @@ export { createMemoryReadTool } from './memory-read.js'; export { createMemoryWriteTool } from './memory-write.js'; export { createMemorySearchTool } from './memory-search.js'; export { createWebSearchTool } from './web-search.js'; +export { createBraveNewsSearchTool } from './web-search.js'; export type { WebSearchConfig } from './web-search.js'; export { createProcessTools, ProcessManager } from './process/index.js'; export type { ProcessManagerConfig } from './process/index.js'; @@ -53,7 +54,7 @@ import { screenCaptureTool, cameraCaptureTool } from './capture.js'; import { createMemoryReadTool } from './memory-read.js'; import { createMemoryWriteTool } from './memory-write.js'; import { createMemorySearchTool } from './memory-search.js'; -import { createWebSearchTool } from './web-search.js'; +import { createBraveNewsSearchTool, createWebSearchTool } from './web-search.js'; /** Static builtin tools that don't require runtime dependencies. */ export const allBuiltinTools: Tool[] = [ @@ -80,5 +81,9 @@ export function createMemoryTools(store: MemoryStore, searchBackend?: MemorySear /** Create the web search tool with provider config. */ export function createWebSearchTools(config: WebSearchConfig): Tool[] { - return [createWebSearchTool(config)]; + const tools: Tool[] = [createWebSearchTool(config)]; + if (config.provider === 'brave') { + tools.push(createBraveNewsSearchTool(config)); + } + return tools; } diff --git a/src/tools/builtin/web-search.test.ts b/src/tools/builtin/web-search.test.ts index a70af27..392f290 100644 --- a/src/tools/builtin/web-search.test.ts +++ b/src/tools/builtin/web-search.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createWebSearchTool } from './web-search.js'; +import { createBraveNewsSearchTool, createWebSearchTool } from './web-search.js'; import type { WebSearchConfig } from './web-search.js'; // Mock global fetch @@ -36,6 +36,13 @@ const braveResults = { }, }; +const braveNewsResults = { + results: [ + { title: 'News 1', url: 'https://news.example.com/1', description: 'News Description 1', age: '2h' }, + { title: 'News 2', url: 'https://news.example.com/2', description: 'News Description 2', age: '1d' }, + ], +}; + // ── SearXNG mock data ──────────────────────────────────────────────────────── const searxngResults = { @@ -143,6 +150,25 @@ describe('web.search', () => { expect(result.success).toBe(false); expect(result.error).toContain('Failed to fetch'); }); + + it('passes optional Brave query controls to the API', async () => { + mockFetch.mockResolvedValue(mockJsonResponse(braveResults)); + const tool = createWebSearchTool(braveConfig); + + await tool.execute({ + query: 'privacy browsers', + country: 'us', + searchLang: 'en', + safeSearch: 'moderate', + freshness: 'pw', + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('country=us'); + expect(calledUrl).toContain('search_lang=en'); + expect(calledUrl).toContain('safesearch=moderate'); + expect(calledUrl).toContain('freshness=pw'); + }); }); // ── SearXNG provider ──────────────────────────────────────────────────── @@ -218,6 +244,16 @@ describe('web.search', () => { expect(calledUrl).toContain('count=20'); }); + it('clamps count minimum to 1', async () => { + mockFetch.mockResolvedValue(mockJsonResponse(braveResults)); + const tool = createWebSearchTool(braveConfig); + + await tool.execute({ query: 'test', count: 0 }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('count=1'); + }); + it('uses default count when not specified', async () => { mockFetch.mockResolvedValue(mockJsonResponse(braveResults)); const tool = createWebSearchTool(braveConfig); @@ -271,3 +307,54 @@ describe('web.search', () => { expect(mockFetch).not.toHaveBeenCalled(); }); }); + +describe('web.search.news', () => { + it('returns formatted news results from Brave news endpoint', async () => { + mockFetch.mockResolvedValue(mockJsonResponse(braveNewsResults)); + const tool = createBraveNewsSearchTool(braveConfig); + + const result = await tool.execute({ query: 'flynn project updates', count: 2 }); + + expect(result.success).toBe(true); + expect(result.output).toContain('**News 1**'); + expect(result.output).toContain('https://news.example.com/1'); + expect(result.output).toContain('News Description 1'); + expect(result.output).toContain('(2h)'); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('api.search.brave.com/res/v1/news/search'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Subscription-Token': 'test-brave-key', + }), + }), + ); + }); + + it('returns error when no API key configured', async () => { + const tool = createBraveNewsSearchTool({ provider: 'brave' }); + + const result = await tool.execute({ query: 'test' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Brave Search API key not configured'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('supports country, language, and freshness filters', async () => { + mockFetch.mockResolvedValue(mockJsonResponse(braveNewsResults)); + const tool = createBraveNewsSearchTool(braveConfig); + + await tool.execute({ + query: 'ai policy', + country: 'gb', + searchLang: 'en', + freshness: 'pd', + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('country=gb'); + expect(calledUrl).toContain('search_lang=en'); + expect(calledUrl).toContain('freshness=pd'); + }); +}); diff --git a/src/tools/builtin/web-search.ts b/src/tools/builtin/web-search.ts index ef7f19c..48141d9 100644 --- a/src/tools/builtin/web-search.ts +++ b/src/tools/builtin/web-search.ts @@ -14,12 +14,17 @@ export interface WebSearchConfig { interface WebSearchArgs { query: string; count?: number; + country?: string; + searchLang?: string; + safeSearch?: 'off' | 'moderate' | 'strict'; + freshness?: 'pd' | 'pw' | 'pm' | 'py'; } interface SearchResult { title: string; url: string; snippet: string; + age?: string; } /** Fetch timeout in milliseconds. */ @@ -37,38 +42,64 @@ const DEFAULT_RESULTS = 5; function formatResults(results: SearchResult[]): string { return results .map( - (r, i) => `${i + 1}. **${r.title}** \u2014 ${r.url}\n ${r.snippet}`, + (r, i) => `${i + 1}. **${r.title}** \u2014 ${r.url}${r.age ? ` (${r.age})` : ''}\n ${r.snippet}`, ) .join('\n\n'); } -/** - * Search using the Brave Search API. - */ -async function searchBrave( - query: string, - count: number, - apiKey: string, - signal?: AbortSignal, -): Promise { - const params = new URLSearchParams({ - q: query, - count: String(count), - }); +function resolveCount(requestedCount: number | undefined, defaultCount: number): number { + const normalized = Number.isFinite(requestedCount) + ? Math.trunc(requestedCount as number) + : defaultCount; + return Math.min(Math.max(normalized, 1), MAX_RESULTS); +} +function createBraveHeaders(apiKey: string): Record { + return { + 'Accept': 'application/json', + 'X-Subscription-Token': apiKey, + }; +} + +function createFetchSignal(signal?: AbortSignal): AbortSignal { const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); - const fetchSignal = signal + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +function appendBraveCommonQueryParams(params: URLSearchParams, args: WebSearchArgs): void { + params.set('q', args.query); + params.set('count', String(args.count)); + + if (args.country) { + params.set('country', args.country); + } + if (args.searchLang) { + params.set('search_lang', args.searchLang); + } + if (args.freshness) { + params.set('freshness', args.freshness); + } +} + +async function fetchBraveEndpoint( + endpoint: 'web' | 'news', + args: WebSearchArgs, + apiKey: string, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams(); + appendBraveCommonQueryParams(params, args); + if (endpoint === 'web' && args.safeSearch) { + params.set('safesearch', args.safeSearch); + } const response = await fetch( - `https://api.search.brave.com/res/v1/web/search?${params.toString()}`, + `https://api.search.brave.com/res/v1/${endpoint}/search?${params.toString()}`, { - signal: fetchSignal, - headers: { - 'Accept': 'application/json', - 'X-Subscription-Token': apiKey, - }, + signal: createFetchSignal(signal), + headers: createBraveHeaders(apiKey), }, ); @@ -77,15 +108,45 @@ async function searchBrave( throw new Error(`Brave API HTTP ${response.status}: ${body}`); } - const data = (await response.json()) as { + return (await response.json()) as TResponse; +} + +/** + * Search using the Brave Search API. + */ +async function searchBraveWeb( + args: WebSearchArgs, + apiKey: string, + signal?: AbortSignal, +): Promise { + const data = await fetchBraveEndpoint<{ web?: { results?: Array<{ title: string; url: string; description: string }> }; - }; + }>('web', args, apiKey, signal); const rawResults = data.web?.results ?? []; return rawResults.map((r) => ({ title: r.title, url: r.url, - snippet: r.description, + snippet: r.description ?? '', + })); +} + +async function searchBraveNews( + args: WebSearchArgs, + apiKey: string, + signal?: AbortSignal, +): Promise { + const data = await fetchBraveEndpoint<{ + results?: Array<{ title: string; url: string; description?: string; age?: string }>; + news?: { results?: Array<{ title: string; url: string; description?: string; age?: string }> }; + }>('news', args, apiKey, signal); + + const rawResults = data.results ?? data.news?.results ?? []; + return rawResults.map((r) => ({ + title: r.title, + url: r.url, + snippet: r.description ?? '', + age: r.age, })); } @@ -106,13 +167,8 @@ async function searchSearxng( categories: 'general', }); - const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); - const fetchSignal = signal - ? AbortSignal.any([signal, timeoutSignal]) - : timeoutSignal; - const response = await fetch(`${baseUrl}/search?${params.toString()}`, { - signal: fetchSignal, + signal: createFetchSignal(signal), headers: { 'Accept': 'application/json', }, @@ -131,10 +187,28 @@ async function searchSearxng( return rawResults.map((r) => ({ title: r.title, url: r.url, - snippet: r.content, + snippet: r.content ?? '', })); } +function normalizeToolError(error: unknown): ToolResult { + if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) { + return { success: false, output: '', error: 'Operation aborted' }; + } + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; +} + +function noResults(query: string): ToolResult { + return { + success: true, + output: `No results found for: ${query}`, + }; +} + /** * Creates a web.search tool configured for the given search provider. * @@ -156,6 +230,24 @@ export function createWebSearchTool(config: WebSearchConfig): Tool { type: 'number', description: 'Number of results to return (default 5, max 20)', }, + country: { + type: 'string', + description: 'Optional country code for regionalized results (for example: us, gb, de)', + }, + searchLang: { + type: 'string', + description: 'Optional language code for search results (for example: en, fr, de)', + }, + safeSearch: { + type: 'string', + enum: ['off', 'moderate', 'strict'], + description: 'Brave-only safesearch level', + }, + freshness: { + type: 'string', + enum: ['pd', 'pw', 'pm', 'py'], + description: 'Brave freshness filter: past day/week/month/year', + }, }, required: ['query'], }, @@ -165,10 +257,11 @@ export function createWebSearchTool(config: WebSearchConfig): Tool { return { success: false, output: '', error: 'Operation aborted' }; } // Clamp count: use provided value (capped at MAX_RESULTS), or fall back to default - const count = Math.min(args.count ?? defaultCount, MAX_RESULTS); + const count = resolveCount(args.count, defaultCount); try { let results: SearchResult[]; + const braveArgs: WebSearchArgs = { ...args, count }; if (config.provider === 'brave') { if (!config.apiKey) { @@ -178,7 +271,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool { error: 'Brave Search API key not configured', }; } - results = await searchBrave(args.query, count, config.apiKey, context?.signal); + results = await searchBraveWeb(braveArgs, config.apiKey, context?.signal); } else { // SearXNG provider if (!config.endpoint) { @@ -192,22 +285,79 @@ export function createWebSearchTool(config: WebSearchConfig): Tool { } if (results.length === 0) { - return { - success: true, - output: `No results found for: ${args.query}`, - }; + return noResults(args.query); } return { success: true, output: formatResults(results) }; } catch (error) { - if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) { - return { success: false, output: '', error: 'Operation aborted' }; - } - return { - success: false, - output: '', - error: error instanceof Error ? error.message : String(error), - }; + return normalizeToolError(error); + } + }, + }; +} + +/** + * Brave Search API: dedicated news search endpoint. + */ +export function createBraveNewsSearchTool(config: WebSearchConfig): Tool { + const defaultCount = config.maxResults ?? DEFAULT_RESULTS; + + return { + name: 'web.search.news', + description: + 'Search recent news via Brave Search and return titles, URLs, snippets, and age metadata when available.', + requiredSecretScopes: ['web_search'], + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'News search query' }, + count: { + type: 'number', + description: 'Number of results to return (default 5, max 20)', + }, + country: { + type: 'string', + description: 'Optional country code for regionalized news results (for example: us, gb, de)', + }, + searchLang: { + type: 'string', + description: 'Optional language code for news results (for example: en, fr, de)', + }, + freshness: { + type: 'string', + enum: ['pd', 'pw', 'pm', 'py'], + description: 'Freshness filter: past day/week/month/year', + }, + }, + required: ['query'], + }, + execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { + const args = rawArgs as WebSearchArgs; + if (context?.signal?.aborted) { + return { success: false, output: '', error: 'Operation aborted' }; + } + if (!config.apiKey) { + return { + success: false, + output: '', + error: 'Brave Search API key not configured', + }; + } + + try { + const results = await searchBraveNews( + { ...args, count: resolveCount(args.count, defaultCount) }, + config.apiKey, + context?.signal, + ); + + if (results.length === 0) { + return noResults(args.query); + } + + return { success: true, output: formatResults(results) }; + } catch (error) { + return normalizeToolError(error); } }, }; diff --git a/src/tools/executor.test.ts b/src/tools/executor.test.ts index 2c11f85..e2ad37a 100644 --- a/src/tools/executor.test.ts +++ b/src/tools/executor.test.ts @@ -391,6 +391,25 @@ describe('ToolExecutor', () => { expect(result.error).toContain('refusing to pass'); }); + it('blocks passing secret-like args to web.search.* tools when untrusted content is present', async () => { + const registry = new ToolRegistry(); + registry.register({ + name: 'web.search.news', + description: 'search news', + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ success: true, output: 'ok' }), + }); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const result = await executor.execute('web.search.news', { query: 'test', apiKey: 'secret-token' }, { + untrustedContent: true, + executionEnvironment: 'host', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('refusing to pass'); + }); + it('denies host high-risk tools for sandboxed skills unless elevation is active', async () => { const registry = new ToolRegistry(); registry.register({ diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 5e2e32a..ba5a0f0 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -626,7 +626,7 @@ export class ToolExecutor { } // When untrusted content is present, forbid passing secrets directly via tool args. - if ((toolName === 'web.fetch' || toolName === 'web.search') && containsSecretLikeKeys(args)) { + if ((toolName === 'web.fetch' || toolName.startsWith('web.search')) && containsSecretLikeKeys(args)) { return 'refusing to pass secret-like fields to a network tool while untrusted content is present'; } diff --git a/src/tools/policy.test.ts b/src/tools/policy.test.ts index 90e3321..6c5ef2c 100644 --- a/src/tools/policy.test.ts +++ b/src/tools/policy.test.ts @@ -14,6 +14,7 @@ const ALL_TOOL_NAMES = [ 'file.list', 'web.fetch', 'web.search', + 'web.search.news', 'memory.read', 'memory.write', 'memory.search', @@ -96,6 +97,7 @@ describe('PROFILE_TOOLS', () => { } expect(PROFILE_TOOLS.messaging.has('memory.read')).toBe(true); expect(PROFILE_TOOLS.messaging.has('web.search')).toBe(true); + expect(PROFILE_TOOLS.messaging.has('web.search.news')).toBe(true); }); it('coding is a superset of messaging', () => { @@ -152,6 +154,7 @@ describe('ToolPolicy', () => { expect(names).toContain('memory.read'); expect(names).toContain('memory.write'); expect(names).toContain('web.search'); + expect(names).toContain('web.search.news'); expect(names).not.toContain('shell.exec'); expect(names).not.toContain('file.write'); }); diff --git a/src/tools/policy.ts b/src/tools/policy.ts index c4ae444..072f30f 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -21,6 +21,7 @@ const PROFILE_TOOLS: Record> = { 'memory.write', 'memory.search', 'web.search', + 'web.search.news', 'gmail.list', 'gmail.search', 'gmail.read', @@ -58,6 +59,7 @@ const PROFILE_TOOLS: Record> = { 'memory.write', 'memory.search', 'web.search', + 'web.search.news', 'gmail.list', 'gmail.search', 'gmail.read', @@ -113,7 +115,7 @@ const PROFILE_TOOLS: Record> = { export const TOOL_GROUPS: Record = { 'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'], 'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list', 'screen.capture', 'camera.capture'], - 'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'], + 'group:web': ['web.fetch', 'web.search', 'web.search.news', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'], 'group:memory': ['memory.read', 'memory.write', 'memory.search'], 'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'], 'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],