server/agents-bridge-switch.ts
Rama main · réplica de lectura del repositorio original.
// 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<string, string | undefined>;
/** 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<string, { config?: unknown; environment?: Record<string, string> } | 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<string> = 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<string> = new Set(["pretooluse", "permissionrequest"]);
const record = (value: unknown): value is Record<string, unknown> => 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<string> = 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<string>();
/** A turn's agents integration with its bridge decision written in; no agents tools clears the thread's mark. */
export function bridgedAgents<Agents extends { env: Record<string, string> }>(
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<Agents extends { env: Record<string, string> }>(
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;
}