// Whether a turn's agents proxy lists its tools through the tool bridge. // Security invariant: the default is no bridge. A use_tool call reaches the host and the engine as // use_tool, so a turn is bridged only when nothing judges an agents tool by its own name: the bot has // no tool selection and its engine pre-approves the whole agents server. import { readFileSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { parseToolScope } from "../shared/tool-scope.ts"; import { bridgedCallName } from "./agents-bridge-names.ts"; import { TOOL_BRIDGE_ENV } from "./drivers/agents-bridge.ts"; import { decodeInjectId } from "./drivers/local-inject.ts"; import { onThisNetwork } from "./drivers/openai-compat.ts"; import { claudeCliSupports, parseClaudeCliVersion } from "./drivers/claude.ts"; import { AGENTS_NODE_FLAG, SPAWNED_PROXIES } from "./proxy-paths.ts"; export { TOOL_BRIDGE_ENV }; /** The command that starts a turn's agents proxy. */ export const agentsProxyLaunch = () => ({ command: process.execPath, args: [SPAWNED_PROXIES.agents] }); /** Every agents proxy's starting environment. The bridge is always set, never inherited: an engine may * hand its MCP children this server's environment, where OMB_AGENTS_TOOL_BRIDGE=1 is the operator switch. */ export const AGENTS_PROXY_ENV = { ...AGENTS_NODE_FLAG, [TOOL_BRIDGE_ENV]: "0" } as const; type BridgeConfig = { features?: { agentsToolBridge?: boolean; claudeUserMcp?: boolean } }; /** The workspace switch: `{"features": {"agentsToolBridge": true}}` in ~/.openmausbot/config.json, or * OMB_AGENTS_TOOL_BRIDGE set to exactly "1" or "0", which wins over the saved setting. */ export function agentsToolBridgeEnabled(cfg: BridgeConfig, env: NodeJS.ProcessEnv = process.env): boolean { const override = env[TOOL_BRIDGE_ENV]; if (override === "1") return true; if (override === "0") return false; return cfg.features?.agentsToolBridge === true; } /** A model on this machine or network reads the whole list on every turn, out of a small window and at laptop speed: * about 15,000 tokens before the person has said anything. OMB_AGENTS_TOOL_BRIDGE=0 still wins. */ function runsLocalModel(bot: { modelSelection?: { model?: unknown } } | null | undefined, engine: BridgeEngine | null | undefined): boolean { const model = bot?.modelSelection?.model; if (typeof model === "string" && decodeInjectId(model)) return true; const url = (engine?.config as { url?: unknown } | undefined)?.url; return engine?.driverKind === "openai-compat" && typeof url === "string" && onThisNetwork(url); } /** The engine a turn runs on, as far as the decision reads it. */ export interface BridgeEngine { driverKind: string; instanceId?: string; config?: unknown; environment?: Record; /** The engine CLI's version from the instance's last snapshot; absent before the first one. */ version?: string | null; /** The turn's launch folder; absent when the engine falls back to its default. */ cwd?: string; } /** A turn's engine from its live instance, the instance configs, its last snapshot and its folder. */ export function turnEngine( instance: { instanceId: string; driverKind: string }, configs: Record } | undefined>, snapshot: { version?: string | null } | undefined, cwd: string | undefined, ): BridgeEngine { const { config, environment } = configs[instance.instanceId] ?? {}; return { driverKind: instance.driverKind, instanceId: instance.instanceId, ...(config !== undefined ? { config } : {}), ...(environment !== undefined ? { environment } : {}), ...(snapshot?.version != null ? { version: snapshot.version } : {}), ...(cwd !== undefined ? { cwd } : {}), }; } /** The person's home and managed Claude Code policy files; passed in so a test never reads the real ones. */ export interface EngineHost { home: string; managedPolicyFiles: readonly string[]; } const CHAT_RUNTIME_DRIVERS: ReadonlySet = new Set(["cerebras", "greenference", "modelgo", "grok", "groq", "meta", "minimax", "mistral", "openai-compat"]); /** Whether text may name the agents server: it contains `mcp__agents` (a server merely starting so counts * too) or `mcp__` then a wildcard. Security: plain substring tests; no pattern is ever built from config text. */ function namesAgentsServer(text: string): boolean { const lower = text.toLowerCase(); return lower.includes("mcp__agents") || lower.includes("mcp__*") || lower.includes("mcp__?"); } /** Whether a permission rule has a wildcard in its tool name. The Claude CLI globs a deny or ask rule's * whole tool name, so `mcp__a*__x` or `*` reaches an agents tool called directly and misses it as use_tool. * A wildcard in parentheses is the rule's specifier (`Bash(git *)`); "inside" is one flag set at `(` and * cleared at `)`, as the CLI splits a `--disallowedTools` list. */ function wildcardInToolName(rule: string): boolean { let inside = false; for (const char of rule) { if (char === "(") inside = true; else if (char === ")") inside = false; else if (!inside && (char === "*" || char === "?")) return true; } return false; } /** Whether a tool-pattern entry could match an agents tool. Entries are comma-joined for the CLI, so one * may hold several patterns and both tests read the whole entry; an entry that is not text counts. */ function namesAgentsTools(entry: unknown): boolean { return typeof entry !== "string" || namesAgentsServer(entry) || wildcardInToolName(entry); } /** Claude Code's documented managed-policy files for a platform; the CLI applies them whatever * --setting-sources says. */ export function claudeManagedPolicyFiles(platform: NodeJS.Platform = process.platform): string[] { if (platform === "darwin") return ["/Library/Application Support/ClaudeCode/managed-settings.json"]; if (platform === "win32") return ["C:\\Program Files\\ClaudeCode\\managed-settings.json", "C:\\ProgramData\\ClaudeCode\\managed-settings.json"]; return ["/etc/claude-code/managed-settings.json"]; } const SETTINGS_FILE_MAX_BYTES = 1024 * 1024; /** A settings file's text, null when absent, or "unreadable". Security: only a regular file of at most * 1 MB is opened, so a FIFO (which would hold the server's one thread), socket or device never is. */ function textOf(file: string): string | null | "unreadable" { try { const stat = statSync(file); if (!stat.isFile() || stat.size > SETTINGS_FILE_MAX_BYTES) return "unreadable"; return readFileSync(file, "utf8"); } catch (error) { const code = (error as NodeJS.ErrnoException).code; return code === "ENOENT" || code === "ENOTDIR" ? null : "unreadable"; } } /** The files of a managed policy's `managed-settings.d` drop-in folder, or "unreadable". */ function managedDropIns(policyFile: string): string[] | "unreadable" { const folder = join(dirname(policyFile), "managed-settings.d"); try { return readdirSync(folder).filter((name) => name.endsWith(".json")).sort().map((name) => join(folder, name)); } catch (error) { const code = (error as NodeJS.ErrnoException).code; return code === "ENOENT" ? [] : "unreadable"; } } /** Claude Code hook events that judge a call before it runs (lower case); they see a bridged call as use_tool. */ const JUDGING_HOOKS: ReadonlySet = new Set(["pretooluse", "permissionrequest"]); const record = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); /** The permission lists the CLI matches as globs (lower case); allow rules do not glob. */ const GLOBBING_RULE_LISTS: ReadonlySet = new Set(["deny", "ask"]); /** The managed-policy key that may void the `--allowedTools mcp__agents` pre-approval (lower case). */ const MANAGED_RULES_ONLY_KEY = "allowmanagedpermissionrulesonly"; interface SettingsFindings { names: boolean; hooks: boolean; wildcard: boolean; managedOnly: boolean } /** Walks every key and value of a parsed settings file, so a name the raw text hides behind JSON escapes is * seen. Keys match in any case and at any depth, on the cautious side; a hook list of none still counts. */ function walkSettings(value: unknown, found: SettingsFindings): void { if (typeof value === "string") { if (namesAgentsServer(value)) found.names = true; return; } if (Array.isArray(value)) { for (const item of value) walkSettings(item, found); return; } if (!record(value)) return; for (const [key, inner] of Object.entries(value)) { if (namesAgentsServer(key)) found.names = true; const lower = key.toLowerCase(); if (lower === "hooks" && record(inner)) { for (const [event, setting] of Object.entries(inner)) if (JUDGING_HOOKS.has(event.toLowerCase()) && setting !== null) found.hooks = true; } if (lower === "permissions" && record(inner)) { for (const [list, rules] of Object.entries(inner)) { if (!GLOBBING_RULE_LISTS.has(list.toLowerCase()) || !Array.isArray(rules)) continue; if (rules.some((rule) => typeof rule === "string" && wildcardInToolName(rule))) found.wildcard = true; } } if (lower === MANAGED_RULES_ONLY_KEY && inner !== null && inner !== false) found.managedOnly = true; walkSettings(inner, found); } } type SettingsReading = "silent" | "names" | "hooks" | "wildcard" | "managed-only" | "unreadable"; /** What one Claude settings file means for the bridge; the first reading that holds wins. Security: * OpenMausBot's own hooks reach the CLI through a private --settings file this never reads, so none of * them may be a PreToolUse or PermissionRequest hook. */ function claudeSettings(file: string, managed: boolean): SettingsReading { const text = textOf(file); if (text === null) return "silent"; if (text === "unreadable") return "unreadable"; if (namesAgentsServer(text)) return "names"; let parsed: unknown; try { parsed = JSON.parse(text); } catch { return "unreadable"; } if (!record(parsed)) return "unreadable"; const hooks = Object.entries(parsed).find(([key]) => key.toLowerCase() === "hooks")?.[1]; if (hooks !== undefined && hooks !== null && !record(hooks)) return "unreadable"; const found: SettingsFindings = { names: false, hooks: false, wildcard: false, managedOnly: false }; walkSettings(parsed, found); if (found.names) return "names"; if (found.hooks) return "hooks"; if (found.wildcard) return "wildcard"; if (managed && found.managedOnly) return "managed-only"; return "silent"; } /** Why a Claude Code turn is not bridged, or null. `--allowedTools mcp__agents` pre-allows the whole * agents server, so the harness asks nothing by name; what the CLI itself reads is checked here. */ function claudeRefusal(cfg: BridgeConfig, engine: BridgeEngine, env: NodeJS.ProcessEnv, host: EngineHost): string | null { if ({ ...env, ...engine.environment }.OMB_CLAUDE_INHERIT_USER_CONFIG === "1" || cfg.features?.claudeUserMcp === true) { return "the bot inherits this machine's own Claude Code setup"; } const config = engine.config && typeof engine.config === "object" ? engine.config as { tools?: unknown; disallowedTools?: unknown } : {}; for (const list of [config.tools, config.disallowedTools]) { if (list !== undefined && (!Array.isArray(list) || list.some(namesAgentsTools))) return "the engine's tool patterns name the agents server or match tools by pattern"; } // Unlike the driver, which takes an unreadable version for a current CLI, an unknown version is no bridge. const version = parseClaudeCliVersion(engine.version); if (version === null) return "the Claude CLI's version is not known"; if (!claudeCliSupports(version, "--setting-sources")) return "the Claude CLI is too old to leave the person's own settings out"; // With --setting-sources project the CLI still reads the launch folder's .claude settings, // and a managed policy with its drop-in folder always applies. const folder = join(engine.cwd ?? host.home, ".claude"); const read = [join(folder, "settings.json"), join(folder, "settings.local.json")].map((file) => claudeSettings(file, false)); for (const policy of host.managedPolicyFiles) { const dropIns = managedDropIns(policy); if (dropIns === "unreadable") return "a Claude settings file cannot be read"; read.push(...[policy, ...dropIns].map((file) => claudeSettings(file, true))); } if (read.includes("unreadable")) return "a Claude settings file cannot be read"; if (read.includes("names")) return "a Claude settings file names the agents server"; if (read.includes("hooks")) return "a Claude settings file sets a hook that judges tool calls"; if (read.includes("wildcard")) return "a Claude settings file denies or asks for tools by pattern"; if (read.includes("managed-only")) return "a managed Claude policy allows only its own permission rules"; return null; } /** Why this engine's turns are not bridged, or null. An engine qualifies only when the harness * pre-approves the whole agents server and everything the engine reads that could name an agents tool * can be read here: Claude Code (claudeRefusal) and the chat runtime, which never asks for an agents * tool and reads no per-tool configuration. Left out: * Codex reads layered config.toml files (CODEX_HOME or ~/.codex, a trusted project's .codex/, * /etc/codex, managed_config.toml) and PreToolUse/PermissionRequest hooks, any of which * can name a tool. * Pi loads the extensions in the person's home, and an extension's tool_call handler can * block a call by the name it sees. * ACP agents keep their own permissions: one "always allow" for use_tool would cover every hidden * tool, hidden reads would lose their read-only marks, and Grok has a native use_tool. * the rest have not been shown to qualify. */ export function engineBridgeRefusal( cfg: BridgeConfig, engine: BridgeEngine | null | undefined, env: NodeJS.ProcessEnv = process.env, host: EngineHost = { home: homedir(), managedPolicyFiles: claudeManagedPolicyFiles() }, ): string | null { if (!engine) return "no engine"; const { driverKind } = engine; if (driverKind === "claudeAgent") return claudeRefusal(cfg, engine, env, host); if (CHAT_RUNTIME_DRIVERS.has(driverKind)) return null; if (driverKind === "codex") return "Codex reads its own layered configuration and hooks, which this version does not examine"; if (driverKind === "piAgent") return "Pi loads the person's own extensions, which can block a tool by its name, and this version does not examine them"; return "this engine keeps its own tool permissions"; } /** Why a turn of `bot` on `engine` is not bridged, or null: the one decision. Security: anything thrown * means no bridge, and the log names only the error's class and code, never its message (it may hold a * path or file content). */ export function agentsToolBridgeRefusal( cfg: BridgeConfig, bot: { toolScope?: unknown; modelSelection?: { model?: unknown } } | null | undefined, engine: BridgeEngine | null | undefined, env: NodeJS.ProcessEnv = process.env, host?: EngineHost, ): string | null { try { if (!agentsToolBridgeEnabled(cfg, env) && (env[TOOL_BRIDGE_ENV] === "0" || !runsLocalModel(bot, engine))) return "the switch is off"; // Security: selection gates match the name in the call, so any selection, even one that permits // everything or cannot be read, means no bridge. const selection = bot ? parseToolScope(bot.toolScope) : undefined; if (selection?.ok !== true || selection.scope !== undefined) return "the bot has a tool selection"; return engineBridgeRefusal(cfg, engine, env, host); } catch (error) { const kind = logWord(() => (error instanceof Error ? error.name : typeof error)); const code = logWord(() => (error as { code?: unknown } | null)?.code, true); console.warn(`[agents] the tool bridge decision failed (${kind}${code === null ? "" : ` ${code}`}); the turn is not bridged`); return "the decision failed"; } } /** A thrown value's `name` or `code` if it is a short safe word, else "unknown"; with `absent`, a missing value is null. */ function logWord(read: () => unknown, absent = false): string | null { try { const value = read(); if (absent && (value === undefined || value === null)) return null; return typeof value === "string" && /^[A-Za-z0-9_.-]{1,64}$/.test(value) ? value : "unknown"; } catch { return "unknown"; } } /** The decision as the agents proxy's environment entry: "1" or "0", never left out. */ export function agentsToolBridgeEnv( cfg: BridgeConfig, bot: { toolScope?: unknown; modelSelection?: { model?: unknown } } | null | undefined, engine: BridgeEngine | null | undefined, env: NodeJS.ProcessEnv = process.env, host?: EngineHost, ): { [TOOL_BRIDGE_ENV]: "1" | "0" } { return { [TOOL_BRIDGE_ENV]: agentsToolBridgeRefusal(cfg, bot, engine, env, host) === null ? "1" : "0" }; } /** Caps the bridged-thread marks so the set cannot grow with every thread ever bridged. */ export const BRIDGED_THREADS_MAX = 5_000; /** Threads whose turn in flight was asked to bridge, oldest first; a still-running turn whose mark was * dropped shows its rows as use_tool. */ const BRIDGED_THREADS = new Set(); /** A turn's agents integration with its bridge decision written in; no agents tools clears the thread's mark. */ export function bridgedAgents }>( threadId: string, agents: Agents | undefined, cfg: BridgeConfig, bot: { toolScope?: unknown; modelSelection?: { model?: unknown } } | null | undefined, engine: BridgeEngine | null | undefined, env?: NodeJS.ProcessEnv, host?: EngineHost, ): Agents | undefined { BRIDGED_THREADS.delete(threadId); if (!agents) return undefined; const decided = agentsToolBridgeEnv(cfg, bot, engine, env, host); if (decided[TOOL_BRIDGE_ENV] === "1") { BRIDGED_THREADS.add(threadId); if (BRIDGED_THREADS.size > BRIDGED_THREADS_MAX) BRIDGED_THREADS.delete(BRIDGED_THREADS.values().next().value!); } return { ...agents, env: { ...agents.env, ...decided } }; } /** Decides a turn and writes the decision into its integrations in place; call once the turn's folder is * known and before its prompt is built. */ export function applyAgentsBridge }>( threadId: string, integrations: { agents?: Agents }, cfg: BridgeConfig, bot: { toolScope?: unknown; modelSelection?: { model?: unknown } } | null | undefined, engine: BridgeEngine | null | undefined, env?: NodeJS.ProcessEnv, host?: EngineHost, ): void { const agents = bridgedAgents(threadId, integrations.agents, cfg, bot, engine, env, host); if (agents !== undefined) integrations.agents = agents; } /** A tool call's row name: on a bridged turn an agents use_tool naming a catalog tool reads as that tool. * Elsewhere use_tool may be another server's (Grok's native one, or one on Codex, whose events omit the server). */ export function bridgedToolTitle(threadId: string, title: string, input: unknown): string { return BRIDGED_THREADS.has(threadId) ? bridgedCallName(title, input) : title; }