Skip to content
Closed
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
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ Compaction replaces older turns with a structured, workflow-aware summary rather

### Web Tools and Providers (`src/web/`)

`web_search` and `web_fetch` are always-on core tools (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`) — no plugin or API key required. `web_fetch` runs in-process on Bun's native `fetch()`: it resolves and rejects private/loopback/link-local targets before every request and again after each redirect hop (`src/tools/ssrf-guard.ts`), caps responses at 5 MB, and converts HTML to markdown by default (`text`/`markdown`/`html` formats). It opens with a browser User-Agent and retries once with an honest `corbits/<version>` UA if the first attempt is rejected as bot traffic. `web_search` calls a keyless hosted MCP search provider (Exa's public endpoint by default, `https://mcp.exa.ai/mcp`; Parallel selectable via `CORBITS_WEB_SEARCH_PROVIDER=parallel`) through the same MCP client used for configured MCP servers (`src/mcp/client.ts`), with an optional bearer key via `CORBITS_WEB_SEARCH_API_KEY`. Both tools get their own permission classes — `webfetch` (approval scoped to the requested URL) and `websearch` (scoped to the tool as a whole) — distinct from shell and file classes, defaulting to ask-with-persistable-allow like any other consequential tool. SSRF protection lives in the tool boundary itself rather than being delegated to external provider infrastructure.
`web_search` and `web_fetch` are always-on core tools (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`) — no plugin or API key required. `web_fetch` runs in-process on Bun's native `fetch()`: it resolves and rejects private/loopback/link-local targets before every request and again after each redirect hop (`src/tools/ssrf-guard.ts`), caps responses at 5 MB, and converts HTML to markdown by default (`text`/`markdown`/`html` formats). It opens with a browser User-Agent and retries once with an honest `corbits/<version>` UA if the first attempt is rejected as bot traffic. `web_search` calls a keyless hosted MCP search provider (Exa's public endpoint by default, `https://mcp.exa.ai/mcp`; Parallel selectable via `CORBITS_WEB_SEARCH_PROVIDER=parallel`) through the same MCP client used for configured MCP servers (`src/mcp/client.ts`), with an optional bearer key via `CORBITS_WEB_SEARCH_API_KEY`. When a `kind: "web"` plugin is selected (`settings.web` override or the single enabled web plugin), its `WebProvider` backs both tools instead — resolved in `src/tui/runner/session.ts` via `resolveWebProviderFromPlugins` and injected through `createAgentToolset({ webProvider })` — and tool calls render under its brand (e.g. "Exa Search"). Both tools get their own permission classes — `webfetch` (approval scoped to the requested URL) and `websearch` (scoped to the tool as a whole) — distinct from shell and file classes, defaulting to ask-with-persistable-allow like any other consequential tool. SSRF protection lives in the tool boundary itself rather than being delegated to external provider infrastructure.

### Director-Layer Tools (`src/agent/director.ts`)

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ See `docs/PLUGINS.md` for the full design. Summary:
- Every installable plugin exports a `manifest` (`{ id, name, kind, description?, credentials? }`) with `kind` one of `web | command | workflow | tool | agent`.
- Plugins are auto-discovered from `plugins/`, `<cwd>/.corbits/plugins/`, and `~/.corbits/plugins/`, plus any explicit file/dir paths in `settings.pluginPaths`. When `settings.discoverClaudePlugins` is true, plugins listed in `~/.claude/plugins/installed_plugins.json` are also loaded (install paths only; still require enable). The `/plugins` UI's "add by path" action (Alt+A) loads a plugin from anywhere on disk, validates its manifest, and persists the path. Alt+X removes by stamped origin: owned user/project installs (and path-origin plugins whose directory sits under those roots) delete the discovered directory under `~/.corbits/plugins` or `<cwd>/.corbits/plugins` after confirmation and write `settings.plugins[id].enabled: false` (the key is never dropped, so in-session command gating holds); path plugins revoke trust and drop unique `pluginPaths` entries (a shared marketplace root is kept); bundled repo plugins write `enabled: false` and stay listed; Claude marketplace installs write `enabled: false` and never touch `~/.claude`. Disk/path entries are still removed so the plugin is gone after restart. Removal takes effect in-session (commands re-gate live; tools stay until restart). Discovery resolves relative imports to absolute first (`loadPluginEntry`). Project-local plugins require per-cwd trust (`~/.corbits/trust/<hash>.json`); path plugins use global path trust (`~/.corbits/trust/path-plugins.json`) so they keep working across project directories. Untrusted origins load metadata-only until granted.

- **Explicit enable:** nothing is wired in until `settings.plugins[id].enabled` is true, except the repo-origin `defaultEnabled` case: when `origin === "repo"` AND `manifest.defaultEnabled` is true AND `settings.plugins[id]` is missing, the plugin auto-enables (this is how first-party `corbits-skills` is on out of the gate). An explicit `enabled: false` still disables. Marketplace (user / project / path / claude) `defaultEnabled` is ignored — those plugins stay opt-in. `command` → `registerCommandPlugins` registers slash commands (live on enable); `tool` → `resolveToolPlugins` instantiates `createToolPlugin(credentials)` and appends the tools to the posix toolset assembled in `src/tui/runner.ts` (via `tools.ts` helpers). `web` → `web_search`/`web_fetch` are now always-on core built-ins (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`), not plugin-backed; a discovered `kind: "web"` plugin is retained for brand-display resolution only (`resolveWebProviderFromPlugins`/`webBrand` in `src/web/plugin-provider.ts`) and no longer supplies the tool implementation.
- **Explicit enable:** nothing is wired in until `settings.plugins[id].enabled` is true, except the repo-origin `defaultEnabled` case: when `origin === "repo"` AND `manifest.defaultEnabled` is true AND `settings.plugins[id]` is missing, the plugin auto-enables (this is how first-party `corbits-skills` is on out of the gate). An explicit `enabled: false` still disables. Marketplace (user / project / path / claude) `defaultEnabled` is ignored — those plugins stay opt-in. `command` → `registerCommandPlugins` registers slash commands (live on enable); `tool` → `resolveToolPlugins` instantiates `createToolPlugin(credentials)` and appends the tools to the posix toolset assembled in `src/tui/runner.ts` (via `tools.ts` helpers). `web` → `web_search`/`web_fetch` are always-on core built-ins (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`); when a `kind: "web"` plugin is selected (`settings.web` override or the single enabled web plugin), its `WebProvider` backs both tools (`resolveWebProviderFromPlugins` in `src/web/plugin-provider.ts`, injected via `createAgentToolset({ webProvider })`), and tool calls render under its brand (`webBrand`, e.g. "Exa Search"). With no web plugin selected the core backends serve the tools.
- **Tool consent:** a `tool` plugin runs in-process, so it is wired in only when enabled AND `consented`. The `/plugins` UI prompts a one-time y/n consent recorded in `settings.plugins[id].consented`.
- Configure via `/plugins`, which writes `settings.plugins` (enabled / consented / credentials), `settings.web`, and `settings.pluginPaths` to the global settings file. Credentials live in the global file because it carries secrets — the project-local settings file rejects credential keys. When a web plugin is active its tool calls render under its brand (e.g. "Exa Search"). Example: `{ "web": "exa", "plugins": { "exa": { "enabled": true, "credentials": { "apiKey": "..." } } } }`.

Expand Down
12 changes: 10 additions & 2 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,17 @@ plugin slash commands in the picker.
### Web providers

Manifest type, `kind`, `settings.plugins` / `web` / `pluginPaths`, web
resolution, tool-name branding, `/plugins` UI, and add-by-path. Web is the
resolution, provider-backed tools, tool-name branding, `/plugins` UI, and add-by-path. Web is the
first kind-selector surface; other kinds share the same discovery and settings
shape.
shape. When a web plugin is selected its `WebProvider` backs `web_search`/`web_fetch`
(injected via `createAgentToolset({ webProvider })`); with none selected the
always-on core backends serve the tools. The provider path takes query/url
only: `web_search` forwards just the query (numResults/type/livecrawl/
contextMaxCharacters are ignored) and `web_fetch` forwards just the URL
(format/timeout are ignored); the SSRF guard still runs before delegating.
`WebProvider.fetch` receives the pre-checked URL; server-side redirects
inside the provider are provider trust (no per-hop re-check, unlike the
core `runWebFetch` fallback, which re-checks every redirect hop).

### Command plugins and enable gating

Expand Down
21 changes: 18 additions & 3 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
createWebSearchTool,
disposeWebSearchClients,
} from "../tools/web-search.js";
import type { WebProvider } from "../web/types.js";
import { createApplyPatchTool } from "./apply-patch-tool.js";
import { createUseSkillTool } from "./use-skill.js";
import { searchSkillCatalog } from "./skill-search.js";
Expand Down Expand Up @@ -275,6 +276,11 @@ export interface AgentToolsetArgs {
outerRetryDelayMs?: number;
retryPolicy?: RetryPolicy;
};
// Selected kind:"web" plugin backend. When present, web_search/web_fetch
// delegate to it (CL-9885); when omitted the always-on core backends serve
// them. TUI sessions pass the resolved active provider; every other caller
// omits it and keeps core behavior.
webProvider?: WebProvider;
/**
* Retained so callers that still pass the Codex family flag do not break.
* Proxies are no longer mounted; hidden aliases dispatch onto engine tools.
Expand Down Expand Up @@ -673,10 +679,14 @@ export async function createAgentToolset(
? { extraDeniedPaths: args.secretGuardExtraDeniedPaths }
: {}),
}),
builtinExaEnabled
builtinExaEnabled && args.webProvider === undefined
? createExaMCPWebFetchTool({ connect: waitForBuiltinExaConnection })
: createWebFetchTool(),
createWebSearchTool(),
: createWebFetchTool(
args.webProvider !== undefined
? { provider: args.webProvider }
: undefined,
),
createWebSearchTool(args.webProvider),
...orchestratorTools,
stringTool({
definition: manageTasksDefinition,
Expand Down Expand Up @@ -974,11 +984,16 @@ export async function createAgentToolset(
};

const swapBuiltinExaToNative = (): void => {
// A selected web plugin owns web_fetch — the Exa/native swap must not
// clobber the plugin-backed tool.
if (args.webProvider !== undefined) return;
failBuiltinExaWaiters("built-in Exa MCP was disconnected");
mountWebFetch(createWebFetchTool());
};

const remountBuiltinExaAlias = (): void => {
// A selected web plugin owns web_fetch — see swapBuiltinExaToNative.
if (args.webProvider !== undefined) return;
failBuiltinExaWaiters("built-in Exa MCP was disconnected");
builtinExaConnection = new Promise<MCPConnectResult>((resolve) => {
resolveBuiltinExaConnection = resolve;
Expand Down
12 changes: 12 additions & 0 deletions src/tools/ssrf-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ describe("checkUrlForSsrf", () => {
expect((await checkUrlForSsrf(url)).ok).toBe(false);
}
});
test("bracketed IPv6 loopback refuses as a private address, not a DNS error", async () => {
expect(isPrivateAddress("[::1]")).toBe(true);
expect(isPrivateAddress("[::ffff:127.0.0.1]")).toBe(true);
for (const url of ["http://[::1]/", "http://[::ffff:127.0.0.1]/"]) {
const result = await checkUrlForSsrf(url);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toMatch(/private\/loopback\/link-local/);
expect(result.reason).not.toMatch(/Could not resolve/);
}
}
});
test("allows the eval fixture URL exactly when EVAL_HTTP_URL is set", async () => {
const prior = process.env.EVAL_HTTP_URL;
process.env.EVAL_HTTP_URL = "http://127.0.0.1:54321/";
Expand Down
54 changes: 47 additions & 7 deletions src/tools/ssrf-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,48 @@ function isPrivateIPv6(ip: string): boolean {
if (normalized.startsWith("fe80:")) return true; // link-local
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // unique local
if (normalized.startsWith("::ffff:")) {
// IPv4-mapped IPv6; re-check the embedded v4 address.
// IPv4-mapped IPv6; re-check the embedded v4 address. WHATWG URL
// parsing renders the tail hex ("::ffff:7f00:1") rather than dotted
// ("::ffff:127.0.0.1"), so decode either form.
const mapped = normalized.slice("::ffff:".length);
if (isIP(mapped) === 4) return isPrivateIPv4(mapped);
if (mapped.includes(".")) {
if (isIP(mapped) === 4) return isPrivateIPv4(mapped);
} else {
const decoded = mappedTailToIPv4(mapped);
if (decoded !== undefined) return isPrivateIPv4(decoded);
}
}
return false;
}

// Decodes the hex tail of a "::ffff:" mapped address (one or two groups,
// the low 32 bits) back to dotted IPv4. Returns undefined when the tail is
// not plain hex, leaving the caller to fall through.
function mappedTailToIPv4(tail: string): string | undefined {
const groups = tail.split(":");
if (groups.length < 1 || groups.length > 2) return undefined;
if (
groups.some(
(g) => g.length === 0 || g.length > 4 || !/^[0-9a-f]+$/.test(g),
)
)
return undefined;
const hi = groups.length === 2 ? parseInt(groups[0] as string, 16) : 0;
const lo = parseInt(groups[groups.length - 1] as string, 16);
return `${(hi >>> 8) & 255}.${hi & 255}.${(lo >>> 8) & 255}.${lo & 255}`;
}

export function isPrivateAddress(ip: string): boolean {
const version = isIP(ip);
if (version === 4) return isPrivateIPv4(ip);
if (version === 6) return isPrivateIPv6(ip);
// URL.hostname keeps IPv6 brackets ("[::1]"), which isIP rejects; strip
// them so bracketed literals classify directly instead of falling through
// to the DNS fail-closed path.
const host =
ip.length >= 2 && ip.startsWith("[") && ip.endsWith("]")
? ip.slice(1, -1)
: ip;
const version = isIP(host);
if (version === 4) return isPrivateIPv4(host);
if (version === 6) return isPrivateIPv6(host);
return true; // not a literal IP; caller must resolve first
}

Expand Down Expand Up @@ -88,9 +119,18 @@ export async function checkUrlForSsrf(
return { ok: false, reason: "Requests to localhost are not allowed." };
}

const literalVersion = isIP(hostname);
// URL.hostname preserves IPv6 brackets ("[::1]"); strip them before isIP
// so bracketed literals classify as private directly, not via DNS
// fail-closed. The refusal message keeps the original hostname.
const literalHost =
hostname.length >= 2 &&
hostname.startsWith("[") &&
hostname.endsWith("]")
? hostname.slice(1, -1)
: hostname;
const literalVersion = isIP(literalHost);
if (literalVersion !== 0) {
if (isPrivateAddress(hostname)) {
if (isPrivateAddress(literalHost)) {
return {
ok: false,
reason: `Requests to private/loopback/link-local address ${hostname} are not allowed.`,
Expand Down
28 changes: 26 additions & 2 deletions src/tools/web-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { checkUrlForSsrf } from "./ssrf-guard.js";
import { htmlToMarkdown, htmlToText } from "./html-convert.js";
import { COMMAND_NAME } from "../branding.js";
import type { MCPClient } from "../mcp/client.js";
import type { WebProvider } from "../web/types.js";
import pkg from "../../package.json" with { type: "json" };

export const MAX_FETCH_BYTES = 5 * 1024 * 1024; // 5 MB
Expand Down Expand Up @@ -260,12 +261,35 @@ export async function runWebFetch(
};
}

export function createWebFetchTool(options?: WebFetchTimingOptions): AgentTool {
export function createWebFetchTool(
options?: WebFetchTimingOptions & { provider?: WebProvider },
): AgentTool {
return stringTool({
definition: webFetchDefinition,
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
handler: async (
rawArgs: Record<string, unknown>,
signal: AbortSignal,
): Promise<string> => {
const parsed = parseWebFetchArgs(rawArgs);
if (!parsed.ok) return parsed.error;
// A selected kind:"web" plugin backs the tool; the in-process native
// fetch below is the fallback to core backends when none is selected.
// The provider path takes query/url only: format/timeout options are
// ignored there (core fallback honors them). The SSRF guard runs on
// both paths so a blocked URL never reaches the provider.
// provider.fetch is opaque: unlike runWebFetch below (manual redirects
// with a per-hop SSRF re-check), server-side redirects inside the
// provider are provider trust — it receives the pre-checked URL only.
if (options?.provider !== undefined) {
const provider = options.provider;
const ssrf = await checkUrlForSsrf(parsed.url);
if (!ssrf.ok) return `Error: ${ssrf.reason}`;
try {
return await provider.fetch(parsed.url, signal);
} catch (err) {
return `Error: web_fetch (${provider.name}) failed: ${err instanceof Error ? err.message : String(err)}`;
}
}
const outcome = await runWebFetch(
parsed.url,
parsed.format,
Expand Down
32 changes: 26 additions & 6 deletions src/tools/web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ToolDefinition } from "@intx/types/runtime";
import { connectMCPServer, type MCPClient } from "../mcp/client.js";
import type { MCPServerConfig } from "../config/settings.js";
import { EXA_MCP_URL } from "../mcp/exa.js";
import type { WebProvider, WebResult } from "../web/types.js";

export { EXA_MCP_URL } from "../mcp/exa.js";

Expand Down Expand Up @@ -144,7 +145,16 @@ function toolNameFor(provider: WebSearchProviderId): string {
return provider === "parallel" ? "web_search" : "web_search_exa";
}

export function createWebSearchTool(): AgentTool {
// Render plugin provider results in the same plain-text shape the core MCP
// backends return, so callers cannot tell which backend served the query.
export function formatWebResults(results: WebResult[]): string {
if (results.length === 0) return "No results.";
return results
.map((r) => `- ${r.title} (${r.url})\n ${r.snippet}`)
.join("\n");
}

export function createWebSearchTool(provider?: WebProvider): AgentTool {
return stringTool({
definition: webSearchDefinition,
handler: async (
Expand All @@ -155,15 +165,25 @@ export function createWebSearchTool(): AgentTool {
if (parsed instanceof type.errors) {
return "Error: web_search requires a non-empty query.";
}
const provider = resolveWebSearchProvider();
// A selected kind:"web" plugin backs the tool (query only; the other
// search options are ignored on the provider path); with none
// selected the tools fall back to core backends.
if (provider !== undefined) {
try {
return formatWebResults(await provider.search(parsed.query, signal));
} catch (err) {
return `Error: web_search (${provider.name}) failed: ${err instanceof Error ? err.message : String(err)}`;
}
}
const backend = resolveWebSearchProvider();
try {
const client = await getClient(provider);
const client = await getClient(backend);
const args =
provider === "parallel" ? parallelArgs(parsed) : exaArgs(parsed);
const result = await client.call(toolNameFor(provider), args, signal);
backend === "parallel" ? parallelArgs(parsed) : exaArgs(parsed);
const result = await client.call(toolNameFor(backend), args, signal);
return result.length > 0 ? result : "No results.";
} catch (err) {
return `Error: web_search (${provider}) failed: ${err instanceof Error ? err.message : String(err)}`;
return `Error: web_search (${backend}) failed: ${err instanceof Error ? err.message : String(err)}`;
}
},
});
Expand Down
Loading
Loading