feat: add brave search container and toolset

This commit is contained in:
William Valentin
2026-02-22 20:12:54 -08:00
parent 487f26e36d
commit ba6abfb078
13 changed files with 502 additions and 53 deletions
+47
View File
@@ -437,6 +437,53 @@ Audio persistence and diagnostics:
- Runbook: `docs/runbooks/VOICE_TRANSCRIPTION_DEBUG.md`. - Runbook: `docs/runbooks/VOICE_TRANSCRIPTION_DEBUG.md`.
- SQLite quick reference: `docs/runbooks/SQLITE_QUICK_REFERENCE.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 ### Text-to-Speech (TTS) Reply Audio
Flynn can attach synthesized voice replies (OpenAI-compatible `/v1/audio/speech`) alongside text responses. Flynn can attach synthesized voice replies (OpenAI-compatible `/v1/audio/speech`) alongside text responses.
+15
View File
@@ -245,6 +245,21 @@ models:
# - `tools.profile: coding` or `tools.profile: full` must allow browser.* tools. # - `tools.profile: coding` or `tools.profile: full` must allow browser.* tools.
# - `tools.deny` patterns can still block browser.* even when browser.enabled is true. # - `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: hooks:
confirm: confirm:
- shell.* - shell.*
+15
View File
@@ -61,6 +61,21 @@ services:
start_period: 15s start_period: 15s
retries: 3 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: volumes:
flynn-data: flynn-data:
whisper-models: whisper-models:
+55 -2
View File
@@ -871,9 +871,62 @@ Search the web.
"type": "string", "type": "string",
"description": "Search query" "description": "Search query"
}, },
"limit": { "count": {
"type": "number", "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"] "required": ["query"]
+32 -1
View File
@@ -3,6 +3,37 @@
"updated_at": "2026-02-23", "updated_at": "2026-02-23",
"description": "Tracks the status of all Flynn plans and implementation phases", "description": "Tracks the status of all Flynn plans and implementation phases",
"plans": { "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": { "dashboard-docker-dependency-controls": {
"status": "completed", "status": "completed",
"date": "2026-02-23", "date": "2026-02-23",
@@ -6132,7 +6163,7 @@
} }
}, },
"overall_progress": { "overall_progress": {
"total_test_count": 1942, "total_test_count": 1951,
"all_tests_passing": true, "all_tests_passing": true,
"p0_completion": "3/3 (100%)", "p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)", "p1_completion": "4/4 (100%)",
+22
View File
@@ -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']);
});
});
+7 -2
View File
@@ -13,6 +13,7 @@ export { createMemoryReadTool } from './memory-read.js';
export { createMemoryWriteTool } from './memory-write.js'; export { createMemoryWriteTool } from './memory-write.js';
export { createMemorySearchTool } from './memory-search.js'; export { createMemorySearchTool } from './memory-search.js';
export { createWebSearchTool } from './web-search.js'; export { createWebSearchTool } from './web-search.js';
export { createBraveNewsSearchTool } from './web-search.js';
export type { WebSearchConfig } from './web-search.js'; export type { WebSearchConfig } from './web-search.js';
export { createProcessTools, ProcessManager } from './process/index.js'; export { createProcessTools, ProcessManager } from './process/index.js';
export type { ProcessManagerConfig } 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 { createMemoryReadTool } from './memory-read.js';
import { createMemoryWriteTool } from './memory-write.js'; import { createMemoryWriteTool } from './memory-write.js';
import { createMemorySearchTool } from './memory-search.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. */ /** Static builtin tools that don't require runtime dependencies. */
export const allBuiltinTools: Tool[] = [ export const allBuiltinTools: Tool[] = [
@@ -80,5 +81,9 @@ export function createMemoryTools(store: MemoryStore, searchBackend?: MemorySear
/** Create the web search tool with provider config. */ /** Create the web search tool with provider config. */
export function createWebSearchTools(config: WebSearchConfig): Tool[] { export function createWebSearchTools(config: WebSearchConfig): Tool[] {
return [createWebSearchTool(config)]; const tools: Tool[] = [createWebSearchTool(config)];
if (config.provider === 'brave') {
tools.push(createBraveNewsSearchTool(config));
}
return tools;
} }
+88 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; 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'; import type { WebSearchConfig } from './web-search.js';
// Mock global fetch // 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 ──────────────────────────────────────────────────────── // ── SearXNG mock data ────────────────────────────────────────────────────────
const searxngResults = { const searxngResults = {
@@ -143,6 +150,25 @@ describe('web.search', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Failed to fetch'); 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 ──────────────────────────────────────────────────── // ── SearXNG provider ────────────────────────────────────────────────────
@@ -218,6 +244,16 @@ describe('web.search', () => {
expect(calledUrl).toContain('count=20'); 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 () => { it('uses default count when not specified', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults)); mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool(braveConfig); const tool = createWebSearchTool(braveConfig);
@@ -271,3 +307,54 @@ describe('web.search', () => {
expect(mockFetch).not.toHaveBeenCalled(); 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');
});
});
+199 -49
View File
@@ -14,12 +14,17 @@ export interface WebSearchConfig {
interface WebSearchArgs { interface WebSearchArgs {
query: string; query: string;
count?: number; count?: number;
country?: string;
searchLang?: string;
safeSearch?: 'off' | 'moderate' | 'strict';
freshness?: 'pd' | 'pw' | 'pm' | 'py';
} }
interface SearchResult { interface SearchResult {
title: string; title: string;
url: string; url: string;
snippet: string; snippet: string;
age?: string;
} }
/** Fetch timeout in milliseconds. */ /** Fetch timeout in milliseconds. */
@@ -37,38 +42,64 @@ const DEFAULT_RESULTS = 5;
function formatResults(results: SearchResult[]): string { function formatResults(results: SearchResult[]): string {
return results return results
.map( .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'); .join('\n\n');
} }
/** function resolveCount(requestedCount: number | undefined, defaultCount: number): number {
* Search using the Brave Search API. const normalized = Number.isFinite(requestedCount)
*/ ? Math.trunc(requestedCount as number)
async function searchBrave( : defaultCount;
query: string, return Math.min(Math.max(normalized, 1), MAX_RESULTS);
count: number, }
apiKey: string,
signal?: AbortSignal,
): Promise<SearchResult[]> {
const params = new URLSearchParams({
q: query,
count: String(count),
});
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); function createBraveHeaders(apiKey: string): Record<string, string> {
const fetchSignal = signal return {
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
const response = await fetch(
`https://api.search.brave.com/res/v1/web/search?${params.toString()}`,
{
signal: fetchSignal,
headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'X-Subscription-Token': apiKey, 'X-Subscription-Token': apiKey,
}, };
}
function createFetchSignal(signal?: AbortSignal): AbortSignal {
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
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<TResponse>(
endpoint: 'web' | 'news',
args: WebSearchArgs,
apiKey: string,
signal?: AbortSignal,
): Promise<TResponse> {
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/${endpoint}/search?${params.toString()}`,
{
signal: createFetchSignal(signal),
headers: createBraveHeaders(apiKey),
}, },
); );
@@ -77,15 +108,45 @@ async function searchBrave(
throw new Error(`Brave API HTTP ${response.status}: ${body}`); 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<SearchResult[]> {
const data = await fetchBraveEndpoint<{
web?: { results?: Array<{ title: string; url: string; description: string }> }; web?: { results?: Array<{ title: string; url: string; description: string }> };
}; }>('web', args, apiKey, signal);
const rawResults = data.web?.results ?? []; const rawResults = data.web?.results ?? [];
return rawResults.map((r) => ({ return rawResults.map((r) => ({
title: r.title, title: r.title,
url: r.url, url: r.url,
snippet: r.description, snippet: r.description ?? '',
}));
}
async function searchBraveNews(
args: WebSearchArgs,
apiKey: string,
signal?: AbortSignal,
): Promise<SearchResult[]> {
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', 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()}`, { const response = await fetch(`${baseUrl}/search?${params.toString()}`, {
signal: fetchSignal, signal: createFetchSignal(signal),
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
}, },
@@ -131,10 +187,28 @@ async function searchSearxng(
return rawResults.map((r) => ({ return rawResults.map((r) => ({
title: r.title, title: r.title,
url: r.url, 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. * Creates a web.search tool configured for the given search provider.
* *
@@ -156,6 +230,24 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
type: 'number', type: 'number',
description: 'Number of results to return (default 5, max 20)', 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'], required: ['query'],
}, },
@@ -165,10 +257,11 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
return { success: false, output: '', error: 'Operation aborted' }; return { success: false, output: '', error: 'Operation aborted' };
} }
// Clamp count: use provided value (capped at MAX_RESULTS), or fall back to default // 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 { try {
let results: SearchResult[]; let results: SearchResult[];
const braveArgs: WebSearchArgs = { ...args, count };
if (config.provider === 'brave') { if (config.provider === 'brave') {
if (!config.apiKey) { if (!config.apiKey) {
@@ -178,7 +271,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
error: 'Brave Search API key not configured', 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 { } else {
// SearXNG provider // SearXNG provider
if (!config.endpoint) { if (!config.endpoint) {
@@ -192,22 +285,79 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
} }
if (results.length === 0) { if (results.length === 0) {
return { return noResults(args.query);
success: true,
output: `No results found for: ${args.query}`,
};
} }
return { success: true, output: formatResults(results) }; return { success: true, output: formatResults(results) };
} catch (error) { } catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) { return normalizeToolError(error);
return { success: false, output: '', error: 'Operation aborted' }; }
} },
return { };
success: false, }
output: '',
error: error instanceof Error ? error.message : String(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<ToolResult> => {
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);
} }
}, },
}; };
+19
View File
@@ -391,6 +391,25 @@ describe('ToolExecutor', () => {
expect(result.error).toContain('refusing to pass'); 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 () => { it('denies host high-risk tools for sandboxed skills unless elevation is active', async () => {
const registry = new ToolRegistry(); const registry = new ToolRegistry();
registry.register({ registry.register({
+1 -1
View File
@@ -626,7 +626,7 @@ export class ToolExecutor {
} }
// When untrusted content is present, forbid passing secrets directly via tool args. // 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'; return 'refusing to pass secret-like fields to a network tool while untrusted content is present';
} }
+3
View File
@@ -14,6 +14,7 @@ const ALL_TOOL_NAMES = [
'file.list', 'file.list',
'web.fetch', 'web.fetch',
'web.search', 'web.search',
'web.search.news',
'memory.read', 'memory.read',
'memory.write', 'memory.write',
'memory.search', 'memory.search',
@@ -96,6 +97,7 @@ describe('PROFILE_TOOLS', () => {
} }
expect(PROFILE_TOOLS.messaging.has('memory.read')).toBe(true); 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')).toBe(true);
expect(PROFILE_TOOLS.messaging.has('web.search.news')).toBe(true);
}); });
it('coding is a superset of messaging', () => { it('coding is a superset of messaging', () => {
@@ -152,6 +154,7 @@ describe('ToolPolicy', () => {
expect(names).toContain('memory.read'); expect(names).toContain('memory.read');
expect(names).toContain('memory.write'); expect(names).toContain('memory.write');
expect(names).toContain('web.search'); expect(names).toContain('web.search');
expect(names).toContain('web.search.news');
expect(names).not.toContain('shell.exec'); expect(names).not.toContain('shell.exec');
expect(names).not.toContain('file.write'); expect(names).not.toContain('file.write');
}); });
+3 -1
View File
@@ -21,6 +21,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'memory.write', 'memory.write',
'memory.search', 'memory.search',
'web.search', 'web.search',
'web.search.news',
'gmail.list', 'gmail.list',
'gmail.search', 'gmail.search',
'gmail.read', 'gmail.read',
@@ -58,6 +59,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'memory.write', 'memory.write',
'memory.search', 'memory.search',
'web.search', 'web.search',
'web.search.news',
'gmail.list', 'gmail.list',
'gmail.search', 'gmail.search',
'gmail.read', 'gmail.read',
@@ -113,7 +115,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
export const TOOL_GROUPS: Record<string, string[]> = { export const TOOL_GROUPS: Record<string, string[]> = {
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'], '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: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:memory': ['memory.read', 'memory.write', 'memory.search'],
'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'], 'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'],
'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'], 'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],