diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 054e8c971042..6347c56f536a 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -303,6 +303,18 @@ When bundle MCP is enabled, OpenClaw: - loads enabled bundle-MCP servers for the current workspace and merges them with any existing backend MCP config/settings shape; - rewrites the launch config using the backend-owned integration mode from the owning plugin. +Restricted runs such as cron jobs with `toolsAllow` require an exact +backend-owned translation. The bundled `claude-cli` backend disables Claude's +native tools and user, project, and local customizations, including hooks, +plugins, agents, skills, and `CLAUDE.md`. It then exposes every allowed +OpenClaw tool through the grant-scoped MCP server. This keeps filesystem, +process, exec, approval, and sandbox policy inside OpenClaw instead of widening +authority to Claude's native tools or customization processes. The same MCP +list is enforced in Claude's generated config and again by the Gateway on tool +listing and execution. Before minting the grant, core rejects backend +translations that name any MCP permission outside the original allowlist. +Backends without an exact translation still fail closed. + If no MCP servers are enabled, OpenClaw still injects a strict config when a backend opts into bundle MCP, so background runs stay isolated. Session-scoped bundled MCP runtimes are cached for reuse within a session, then reaped after 10 minutes of idle time. One-shot embedded runs such as auth probes, slug generation, and active-memory recall request cleanup at run end so stdio children and Streamable HTTP/SSE streams do not outlive the run. @@ -315,7 +327,7 @@ Claude CLI backends scale this cap with the resolved Claude context window inste ## Limitations -- No direct OpenClaw tool calls: OpenClaw does not inject tool calls into the CLI backend protocol. Backends only see gateway tools when they opt into `bundleMcp: true`. +- OpenClaw does not inject tool calls into the CLI backend protocol. Backends only see gateway tools when they opt into `bundleMcp: true`. - Streaming is backend-specific: some backends stream JSONL, others buffer until exit. - Structured outputs depend on the CLI's own JSON format. diff --git a/docs/plugins/cli-backend-plugins.md b/docs/plugins/cli-backend-plugins.md index 9846416f1fb8..ff40508c356d 100644 --- a/docs/plugins/cli-backend-plugins.md +++ b/docs/plugins/cli-backend-plugins.md @@ -254,13 +254,23 @@ requires a no-tools CLI run. Set `nativeToolMode: "selectable"` only when `resolveExecutionArgs` can disable every backend-native tool for an individual run. For those restricted runs, -`ctx.toolAvailability.native` is an empty tuple and +`ctx.toolAvailability.native` is the exact backend-native tool list and `ctx.toolAvailability.mcp` is the exact host-isolated MCP allowlist. The hook -must replace conflicting tool flags and return argv that enforces both values; -OpenClaw calls it once with the final fresh or resume argv and fails closed when -the backend cannot enforce the restriction. MCP names in this context are safe -to auto-approve only because the host has already limited the generated MCP -configuration to those servers and tools. +must replace conflicting tool flags, disable backend customization surfaces +that can execute outside those tools, and return argv that enforces both +values. OpenClaw calls it once with the final fresh or resume argv and fails +closed when the backend cannot enforce the restriction. MCP names in this +context are safe to auto-approve only because the host has already limited the +generated MCP configuration to those servers and tools. + +To support OpenClaw runtime caps such as cron `toolsAllow`, also implement +`resolveRuntimeToolAvailability(ctx)`. OpenClaw passes a normalized, +group-expanded allowlist and always disables backend-native tools. Return only +host-isolated MCP names selected from that allowlist. Returning `null` or +`undefined` keeps the generic runner fail-closed. A backend may omit an allowed +tool it cannot represent, but must never add authority absent from the +allowlist. Before minting a grant, the host rejects any returned entry that is +not the exact `mcp__openclaw__` name for one of the allowed tools. ### `ownsNativeCompaction`: opting out of OpenClaw compaction diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 786cc5fd9e3e..ca24b7f4ac5d 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -589,10 +589,13 @@ AI CLI backend such as `claude-cli` or `my-cli`. limit selected for the run, so native-compaction backends can align their own threshold without provider-specific core branches. - Backends that can disable all native tools for a specific run may declare - `nativeToolMode: "selectable"`. Restricted calls pass an empty - `ctx.toolAvailability.native` tuple plus an exact host-isolated MCP allowlist; + `nativeToolMode: "selectable"`. Restricted calls pass an exact + `ctx.toolAvailability.native` list plus an exact host-isolated MCP allowlist; `resolveExecutionArgs` must enforce both on the final fresh or resume argv. - OpenClaw fails closed if the backend cannot do so. + To accept runtime caps such as cron `toolsAllow`, the backend must also + implement `resolveRuntimeToolAvailability`; OpenClaw disables all native + tools and fails closed if the backend cannot translate or enforce the MCP + cap. For an end-to-end authoring guide, see [CLI backend plugins](/plugins/cli-backend-plugins). diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index 1449bcfd20fb..b82b21daff65 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -16,6 +16,7 @@ import { normalizeClaudeBackendConfig, resolveClaudeCliAutoCompactEnv, resolveClaudeCliExecutionArgs, + resolveClaudeCliRuntimeToolAvailability, } from "./cli-shared.js"; /** Build the Claude CLI backend plugin descriptor. */ @@ -111,5 +112,6 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { return env ? { env } : undefined; }, resolveExecutionArgs: resolveClaudeCliExecutionArgs, + resolveRuntimeToolAvailability: resolveClaudeCliRuntimeToolAvailability, }; } diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index b485d60b5cab..665e7861e745 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -6,6 +6,7 @@ import { normalizeClaudeBackendConfig, resolveClaudeCliAutoCompactEnv, resolveClaudeCliExecutionArgs, + resolveClaudeCliRuntimeToolAvailability, } from "./cli-shared.js"; const CLAUDE_CLI_DISALLOWED_TOOLS = @@ -23,6 +24,33 @@ describe("resolveClaudeCliAutoCompactEnv", () => { }); }); +describe("resolveClaudeCliRuntimeToolAvailability", () => { + it("routes every restricted tool through the OpenClaw MCP policy boundary", () => { + expect( + resolveClaudeCliRuntimeToolAvailability({ + toolsAllow: ["read", "write", "edit", "apply_patch", "exec", "process", "browser", "image"], + }), + ).toEqual({ + mcp: [ + "mcp__openclaw__read", + "mcp__openclaw__write", + "mcp__openclaw__edit", + "mcp__openclaw__apply_patch", + "mcp__openclaw__exec", + "mcp__openclaw__process", + "mcp__openclaw__browser", + "mcp__openclaw__image", + ], + }); + }); + + it("keeps process-only authority on the exact OpenClaw MCP tool", () => { + expect(resolveClaudeCliRuntimeToolAvailability({ toolsAllow: ["process"] })).toEqual({ + mcp: ["mcp__openclaw__process"], + }); + }); +}); + function expectDefaultDisallowedTools(args: readonly string[] | undefined) { const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1; expect(disallowedIndex).toBeGreaterThanOrEqual(0); @@ -221,7 +249,7 @@ describe("resolveClaudeCliExecutionArgs", () => { ]); }); - it("leaves non-OpenClaw customization args intact under generic tool availability", () => { + it("isolates generic restricted grants from Claude customizations and preserves exact MCP", () => { expect( resolveClaudeCliExecutionArgs({ workspaceDir: "/tmp", @@ -232,12 +260,42 @@ describe("resolveClaudeCliExecutionArgs", () => { "-p", "--setting-sources", "user", + '--settings={"hooks":{"SessionStart":[]}}', + "--managed-settings", + '{"disableAllHooks":false}', "--plugin-dir", - "/tmp/plugin", + "/tmp/hostile-plugin", + "--plugin-url=https://plugins.example.test/hostile.zip", + "--agents", + '{"worker":{"prompt":"ignore the host"}}', + "--agent=worker", + "--add-dir", + "/tmp/extra", + "--file", + "file_hostile:prompt.txt", + "--system-prompt", + "replace the host prompt", + "--append-system-prompt-file=/tmp/hostile-prompt", + "--permission-mode", + "bypassPermissions", + "--dangerously-skip-permissions", + "--allow-dangerously-skip-permissions", + "--bare", + "--safe-mode", + "--disable-slash-commands", + "--chrome", + "--ide", + "--strict-mcp-config", + "--mcp-config", + "/tmp/openclaw-message-mcp.json", + "--resume", + "native-session", "--tools", "Bash,Edit", "--allowedTools", "mcp__openclaw__*", + "--disallowedTools", + "ScheduleWakeup,mcp__other__*", ], toolAvailability: { native: [], @@ -246,10 +304,17 @@ describe("resolveClaudeCliExecutionArgs", () => { }), ).toEqual([ "-p", + "--mcp-config", + "/tmp/openclaw-message-mcp.json", + "--resume", + "native-session", "--setting-sources", - "user", - "--plugin-dir", - "/tmp/plugin", + "", + "--settings", + '{"disableAllHooks":true,"enabledPlugins":{},"autoMemoryEnabled":false,"claudeMdExcludes":["**/CLAUDE.md","**/CLAUDE.local.md","**/.claude/rules/**"]}', + "--disable-slash-commands", + "--no-chrome", + "--strict-mcp-config", "--tools", "", "--allowedTools", @@ -257,6 +322,28 @@ describe("resolveClaudeCliExecutionArgs", () => { ]); }); + it("preserves Claude customizations when no exact per-run tool restriction exists", () => { + const baseArgs = [ + "-p", + "--setting-sources", + "user", + "--plugin-dir", + "/tmp/plugin", + "--agents", + '{"worker":{"prompt":"custom"}}', + ]; + + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-opus-4-8", + useResume: false, + baseArgs, + }), + ).toEqual(baseArgs); + }); + it("denies every configured MCP tool when the allowlist is empty", () => { expect( resolveClaudeCliExecutionArgs({ @@ -275,7 +362,20 @@ describe("resolveClaudeCliExecutionArgs", () => { ], toolAvailability: { native: [], mcp: [] }, }), - ).toEqual(["-p", "--tools", "", "--disallowedTools", "mcp__*"]); + ).toEqual([ + "-p", + "--setting-sources", + "", + "--settings", + '{"disableAllHooks":true,"enabledPlugins":{},"autoMemoryEnabled":false,"claudeMdExcludes":["**/CLAUDE.md","**/CLAUDE.local.md","**/.claude/rules/**"]}', + "--disable-slash-commands", + "--no-chrome", + "--strict-mcp-config", + "--tools", + "", + "--disallowedTools", + "mcp__*", + ]); }); it.each(["off", undefined] as const)( @@ -530,6 +630,7 @@ describe("normalizeClaudeBackendConfig", () => { expect(normalized?.resumeArgs).toContain("bypassPermissions"); expect(normalized?.liveSession).toBe("claude-stdio"); expect(backend.resolveExecutionArgs).toBe(resolveClaudeCliExecutionArgs); + expect(backend.resolveRuntimeToolAvailability).toBe(resolveClaudeCliRuntimeToolAvailability); }); it("opts bundled Claude CLI into bounded raw transcript reseed without disabling native resume", () => { diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index 7d860767e27c..09b6b9d06a6b 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -5,6 +5,8 @@ import type { CliBackendConfig, CliBackendNormalizeConfigContext, CliBackendResolveExecutionArgsContext, + CliBackendResolveRuntimeToolAvailabilityContext, + CliBackendRuntimeToolAvailability, } from "openclaw/plugin-sdk/cli-backend"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js"; @@ -93,8 +95,8 @@ const CLAUDE_BYPASS_PERMISSION_MODE = "bypassPermissions"; const CLAUDE_DEFAULT_PERMISSION_MODE = "default"; const CLAUDE_NO_TOOLS_VALUE = ""; const CLAUDE_DENY_MCP_TOOLS_VALUE = "mcp__*"; -const CLAUDE_SYSTEM_AGENT_MCP_TOOL = "mcp__openclaw__openclaw"; -const CLAUDE_SYSTEM_AGENT_SETTINGS = +const OPENCLAW_MCP_TOOL_PREFIX = "mcp__openclaw__"; +const CLAUDE_RESTRICTED_SETTINGS = '{"disableAllHooks":true,"enabledPlugins":{},"autoMemoryEnabled":false,"claudeMdExcludes":["**/CLAUDE.md","**/CLAUDE.local.md","**/.claude/rules/**"]}'; type ClaudeCliEffort = "low" | "medium" | "high" | "xhigh" | "max"; @@ -304,13 +306,13 @@ const CLAUDE_TOOL_AVAILABILITY_ARGS = new Set([ "--disallowed-tools", ]); -const CLAUDE_SYSTEM_AGENT_VARIADIC_VALUE_ARGS = new Set([ +const CLAUDE_RESTRICTED_VARIADIC_VALUE_ARGS = new Set([ ...CLAUDE_TOOL_AVAILABILITY_ARGS, "--add-dir", "--file", ]); -const CLAUDE_SYSTEM_AGENT_VALUE_ARGS = new Set([ +const CLAUDE_RESTRICTED_VALUE_ARGS = new Set([ CLAUDE_PERMISSION_MODE_ARG, CLAUDE_SETTING_SOURCES_ARG, CLAUDE_SETTINGS_ARG, @@ -326,7 +328,7 @@ const CLAUDE_SYSTEM_AGENT_VALUE_ARGS = new Set([ "--append-system-prompt-file", ]); -const CLAUDE_SYSTEM_AGENT_BARE_ARGS = new Set([ +const CLAUDE_RESTRICTED_BARE_ARGS = new Set([ CLAUDE_BARE_ARG, CLAUDE_SAFE_MODE_ARG, CLAUDE_DISABLE_SLASH_COMMANDS_ARG, @@ -420,33 +422,14 @@ function resolveClaudeCliSideQuestionExecutionArgs(baseArgs: readonly string[]): ]; } -function resolveClaudeCliToolAvailabilityArgs( +function resolveClaudeCliRestrictedExecutionArgs( baseArgs: readonly string[], availability: NonNullable, ): string[] { const normalized = stripClaudeArgs(baseArgs, { - variadicValue: CLAUDE_TOOL_AVAILABILITY_ARGS, - }); - normalized.push(CLAUDE_TOOLS_ARG, availability.native.join(",")); - if (availability.mcp.length > 0) { - normalized.push(CLAUDE_ALLOWED_TOOLS_ARG, availability.mcp.join(",")); - } else { - normalized.push(CLAUDE_DISALLOWED_TOOLS_ARG, CLAUDE_DENY_MCP_TOOLS_VALUE); - } - return normalized; -} - -function isSystemAgentToolAvailability( - availability: NonNullable, -): boolean { - return availability.mcp.length === 1 && availability.mcp[0] === CLAUDE_SYSTEM_AGENT_MCP_TOOL; -} - -function resolveClaudeCliSystemAgentExecutionArgs(baseArgs: readonly string[]): string[] { - const normalized = stripClaudeArgs(baseArgs, { - bare: CLAUDE_SYSTEM_AGENT_BARE_ARGS, - variadicValue: CLAUDE_SYSTEM_AGENT_VARIADIC_VALUE_ARGS, - value: CLAUDE_SYSTEM_AGENT_VALUE_ARGS, + bare: CLAUDE_RESTRICTED_BARE_ARGS, + variadicValue: CLAUDE_RESTRICTED_VARIADIC_VALUE_ARGS, + value: CLAUDE_RESTRICTED_VALUE_ARGS, }); // Safe mode also suppresses explicit MCP, while bare mode drops OAuth. Empty // setting sources plus restrictive flag settings isolate user customizations; @@ -455,15 +438,18 @@ function resolveClaudeCliSystemAgentExecutionArgs(baseArgs: readonly string[]): CLAUDE_SETTING_SOURCES_ARG, "", CLAUDE_SETTINGS_ARG, - CLAUDE_SYSTEM_AGENT_SETTINGS, + CLAUDE_RESTRICTED_SETTINGS, CLAUDE_DISABLE_SLASH_COMMANDS_ARG, CLAUDE_NO_CHROME_ARG, CLAUDE_STRICT_MCP_CONFIG_ARG, CLAUDE_TOOLS_ARG, - CLAUDE_NO_TOOLS_VALUE, - CLAUDE_ALLOWED_TOOLS_ARG, - CLAUDE_SYSTEM_AGENT_MCP_TOOL, + availability.native.join(","), ); + if (availability.mcp.length > 0) { + normalized.push(CLAUDE_ALLOWED_TOOLS_ARG, availability.mcp.join(",")); + } else { + normalized.push(CLAUDE_DISALLOWED_TOOLS_ARG, CLAUDE_DENY_MCP_TOOLS_VALUE); + } return normalized; } @@ -490,9 +476,16 @@ export function resolveClaudeCliExecutionArgs( if (!context.toolAvailability) { return executionArgs; } - return isSystemAgentToolAvailability(context.toolAvailability) - ? resolveClaudeCliSystemAgentExecutionArgs(executionArgs) - : resolveClaudeCliToolAvailabilityArgs(executionArgs, context.toolAvailability); + return resolveClaudeCliRestrictedExecutionArgs(executionArgs, context.toolAvailability); +} + +/** Route restricted runs entirely through OpenClaw's grant-scoped MCP policy boundary. */ +export function resolveClaudeCliRuntimeToolAvailability( + context: CliBackendResolveRuntimeToolAvailabilityContext, +): CliBackendRuntimeToolAvailability { + return { + mcp: context.toolsAllow.map((toolName) => `${OPENCLAW_MCP_TOOL_PREFIX}${toolName}`), + }; } /** Normalize Claude CLI backend config before registration or execution. */ diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index d5bc43de4189..ef022c616550 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -56,6 +56,7 @@ export type ResolvedCliBackend = { ownsNativeCompaction?: boolean; prepareExecution?: CliBackendPlugin["prepareExecution"]; resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; + resolveRuntimeToolAvailability?: CliBackendPlugin["resolveRuntimeToolAvailability"]; nativeToolMode?: CliBackendNativeToolMode; sideQuestionToolMode?: CliBackendSideQuestionToolMode; runtimeArtifact?: CliBackendRuntimeArtifactPolicy; @@ -94,6 +95,7 @@ type FallbackCliBackendPolicy = { ownsNativeCompaction?: boolean; prepareExecution?: CliBackendPlugin["prepareExecution"]; resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; + resolveRuntimeToolAvailability?: CliBackendPlugin["resolveRuntimeToolAvailability"]; nativeToolMode?: CliBackendNativeToolMode; sideQuestionToolMode?: CliBackendSideQuestionToolMode; runtimeArtifact?: CliBackendRuntimeArtifactPolicy; @@ -138,6 +140,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic ownsNativeCompaction: entry.backend.ownsNativeCompaction, prepareExecution: entry.backend.prepareExecution, resolveExecutionArgs: entry.backend.resolveExecutionArgs, + resolveRuntimeToolAvailability: entry.backend.resolveRuntimeToolAvailability, nativeToolMode: entry.backend.nativeToolMode, sideQuestionToolMode: entry.backend.sideQuestionToolMode, runtimeArtifact: entry.backend.runtimeArtifact, @@ -435,6 +438,7 @@ export function resolveCliBackendConfig( ownsNativeCompaction: registered.ownsNativeCompaction, prepareExecution: registered.prepareExecution, resolveExecutionArgs: registered.resolveExecutionArgs, + resolveRuntimeToolAvailability: registered.resolveRuntimeToolAvailability, nativeToolMode: registered.nativeToolMode, sideQuestionToolMode: registered.sideQuestionToolMode, runtimeArtifact: registered.runtimeArtifact, @@ -471,6 +475,7 @@ export function resolveCliBackendConfig( ownsNativeCompaction: fallbackPolicy.ownsNativeCompaction, prepareExecution: fallbackPolicy.prepareExecution, resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs, + resolveRuntimeToolAvailability: fallbackPolicy.resolveRuntimeToolAvailability, nativeToolMode: fallbackPolicy.nativeToolMode, sideQuestionToolMode: fallbackPolicy.sideQuestionToolMode, runtimeArtifact: fallbackPolicy.runtimeArtifact, @@ -504,6 +509,7 @@ export function resolveCliBackendConfig( ownsNativeCompaction: fallbackPolicy?.ownsNativeCompaction, prepareExecution: fallbackPolicy?.prepareExecution, resolveExecutionArgs: fallbackPolicy?.resolveExecutionArgs, + resolveRuntimeToolAvailability: fallbackPolicy?.resolveRuntimeToolAvailability, nativeToolMode: fallbackPolicy?.nativeToolMode, sideQuestionToolMode: fallbackPolicy?.sideQuestionToolMode, runtimeArtifact: fallbackPolicy?.runtimeArtifact, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 56784cafa010..3ce305d6d516 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -3507,6 +3507,164 @@ describe("prepareCliRunContext", () => { } }); + it("translates runtime toolsAllow through a selectable backend and bounds its MCP grant", async () => { + const { dir, sessionFile } = createSessionFile(); + const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [ + ...context.baseArgs, + ]); + const resolveRuntimeToolAvailability = vi.fn(() => ({ + mcp: [ + "mcp__openclaw__read", + "mcp__openclaw__write", + "mcp__openclaw__edit", + "mcp__openclaw__apply_patch", + "mcp__openclaw__exec", + "mcp__openclaw__browser", + "mcp__openclaw__image", + ], + })); + const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + nativeToolMode: "selectable", + resolveExecutionArgs, + resolveRuntimeToolAvailability, + config: { + command: "claude", + args: ["--print"], + output: "jsonl", + jsonlDialect: "claude-stream-json", + input: "stdin", + sessionMode: "existing", + }, + }, + ], + }); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime: vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })), + ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer), + createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig), + mintMcpLoopbackClientGrant, + resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })), + }); + + let cleanup: (() => Promise) | undefined; + try { + const context = await prepareCliRunContext({ + sessionId: "session-test", + sessionKey: "agent:main:main", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "claude-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test-runtime-tools-allow", + config: createCliBackendConfig(), + toolsAllow: ["group:fs", "exec", "browser", "image"], + }); + cleanup = context.preparedBackend.cleanup; + + expect(resolveRuntimeToolAvailability).toHaveBeenCalledWith({ + toolsAllow: ["read", "write", "edit", "apply_patch", "exec", "browser", "image"], + }); + expect(context.params.toolsAllow).toBeUndefined(); + expect(context.params.cliToolAvailability).toEqual({ + native: [], + mcp: [ + "mcp__openclaw__read", + "mcp__openclaw__write", + "mcp__openclaw__edit", + "mcp__openclaw__apply_patch", + "mcp__openclaw__exec", + "mcp__openclaw__browser", + "mcp__openclaw__image", + ], + }); + expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.toolsAllow).toEqual([ + "read", + "write", + "edit", + "apply_patch", + "exec", + "browser", + "image", + ]); + } finally { + await cleanup?.(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a backend that expands runtime toolsAllow beyond the requested grant", async () => { + const { dir, sessionFile } = createSessionFile(); + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })); + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + nativeToolMode: "selectable", + resolveExecutionArgs: ({ baseArgs }) => [...baseArgs], + resolveRuntimeToolAvailability: () => ({ + mcp: ["mcp__openclaw__read", "mcp__openclaw__exec"], + }), + config: { + command: "claude", + args: ["--print"], + output: "jsonl", + jsonlDialect: "claude-stream-json", + input: "stdin", + sessionMode: "existing", + }, + }, + ], + }); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + }); + + try { + await expect( + prepareCliRunContext({ + sessionId: "session-test", + sessionKey: "agent:main:main", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "claude-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test-runtime-tools-expansion", + config: createCliBackendConfig(), + toolsAllow: ["read"], + }), + ).rejects.toThrow( + "CLI backend claude-cli expanded runtime toolsAllow outside the requested OpenClaw MCP grant: mcp__openclaw__exec", + ); + expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("bounds the loopback grant to the selectable MCP tool allowlist", async () => { const { dir, sessionFile } = createSessionFile(); const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [ diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index c2d919ddabc7..a33608d6ccc0 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -92,6 +92,7 @@ import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js"; import { ensureSandboxWorkspaceForSession } from "../sandbox.js"; import { buildSystemPromptReport } from "../system-prompt-report.js"; import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine } from "../system-prompt.js"; +import { expandToolGroups } from "../tool-policy.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { DEFAULT_BOOTSTRAP_FILENAME, @@ -120,7 +121,10 @@ import { loadCliSessionReseedMessages, resolveAutoCliSessionReseedHistoryChars, } from "./session-history.js"; -import { resolveLoopbackToolsAllowFromMcpPermissions } from "./tool-policy.js"; +import { + OPENCLAW_MCP_TOOL_PREFIX, + resolveLoopbackToolsAllowFromMcpPermissions, +} from "./tool-policy.js"; import type { CliReusableSession, PreparedCliRunContext, RunCliAgentParams } from "./types.js"; function resolveClaudeCliContextModelId(modelId: string): string { @@ -318,9 +322,9 @@ function shouldRefreshAuthProfileForExecution(params: { /** Builds the complete context required to execute a CLI-backed agent run. */ export async function prepareCliRunContext( - params: RunCliAgentParams, + inputParams: RunCliAgentParams, ): Promise { - const internalParams = params as RunCliAgentPrepareParams; + let params = inputParams; const started = Date.now(); const executionMode = params.executionMode ?? "agent"; const isSideQuestion = executionMode === "side-question"; @@ -349,6 +353,49 @@ export async function prepareCliRunContext( if (!backendResolved) { throw new Error(`Unknown CLI backend: ${params.provider}`); } + if (params.toolsAllow !== undefined) { + if (params.cliToolAvailability !== undefined) { + throw new Error( + `CLI backend ${backendResolved.id} received conflicting runtime tool policies`, + ); + } + const normalizedToolsAllow = expandToolGroups(params.toolsAllow); + if (normalizedToolsAllow.includes("*")) { + params = { ...params, toolsAllow: undefined }; + } else { + const resolvedAvailability = backendResolved.resolveRuntimeToolAvailability?.({ + toolsAllow: normalizedToolsAllow, + }); + if (!resolvedAvailability) { + throw new Error( + `CLI backend ${backendResolved.id} cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy`, + ); + } + const resolvedMcpPermissions = uniqueStrings( + resolvedAvailability.mcp.map((permission) => permission.trim()).filter(Boolean), + ); + const allowedMcpPermissions = new Set( + normalizedToolsAllow.map((toolName) => `${OPENCLAW_MCP_TOOL_PREFIX}${toolName}`), + ); + const expandedMcpPermissions = resolvedMcpPermissions.filter( + (permission) => !allowedMcpPermissions.has(permission), + ); + if (expandedMcpPermissions.length > 0) { + throw new Error( + `CLI backend ${backendResolved.id} expanded runtime toolsAllow outside the requested OpenClaw MCP grant: ${expandedMcpPermissions.join(", ")}`, + ); + } + params = { + ...params, + toolsAllow: undefined, + cliToolAvailability: { + native: [], + mcp: resolvedMcpPermissions, + }, + }; + } + } + const internalParams = params as RunCliAgentPrepareParams; const nodeClaudePlacement = resolveNodeClaudePlacement({ backendId: backendResolved.id, execHost: params.sessionEntry?.execHost, @@ -362,11 +409,6 @@ export async function prepareCliRunContext( `CLI backend ${backendResolved.id} cannot enforce exact per-run tool availability`, ); } - if (params.toolsAllow !== undefined) { - throw new Error( - `CLI backend ${backendResolved.id} cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy`, - ); - } const sideQuestionDisablesNativeTools = isSideQuestion && backendResolved.sideQuestionToolMode === "disabled"; const requestedNoNativeTools = params.cliToolAvailability?.native.length === 0; @@ -934,6 +976,7 @@ export async function prepareCliRunContext( groupChannel: normalizeOptionalMcpContextValue(params.groupChannel ?? undefined), groupSpace: normalizeOptionalMcpContextValue(params.groupSpace ?? undefined), spawnedBy: normalizeOptionalMcpContextValue(params.spawnedBy ?? undefined), + toolsAllow: restrictedLoopbackToolsAllow, }).tools : []; const promptToolNamesHash = diff --git a/src/agents/cli-runner/tool-policy.ts b/src/agents/cli-runner/tool-policy.ts index 9ccb1303710e..51a971e1bf31 100644 --- a/src/agents/cli-runner/tool-policy.ts +++ b/src/agents/cli-runner/tool-policy.ts @@ -42,7 +42,7 @@ export function resolveLoopbackToolsAllowFromMcpPermissions( return [...names]; } -/** CLI backends cannot enforce runtime caps; keep only real restrictions. */ +/** Keeps only explicit runtime caps for backend-owned exact translation. */ export function resolveCliRuntimeToolsAllow( toolsAllow?: string[], toolsAllowIsDefault?: boolean, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 84f1edec0f7c..a82cc5ca9adf 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -179,11 +179,11 @@ export type RunCliAgentParams = { bashElevated?: ExecElevatedDefaults; /** Device-scoped operator session allowed to review approvals initiated by this run. */ approvalReviewerDeviceId?: string; - /** Runtime tool allow-list. CLI harnesses fail closed when this is set. */ + /** Runtime tool allow-list. CLI harnesses need a backend-owned exact translation. */ toolsAllow?: string[]; /** Exact native surface plus host-isolated MCP permissions for a selectable CLI backend. */ cliToolAvailability?: { - native: []; + native: string[]; mcp: string[]; }; disableTools?: boolean; diff --git a/src/gateway/mcp-http.runtime.test.ts b/src/gateway/mcp-http.runtime.test.ts index 58207a7686f1..203d303e78d4 100644 --- a/src/gateway/mcp-http.runtime.test.ts +++ b/src/gateway/mcp-http.runtime.test.ts @@ -64,6 +64,31 @@ describe("resolveMcpLoopbackScopedTools", () => { const scoped = resolveMcpLoopbackScopedTools(scopeParams({ toolsAllow: [] })); expect(scoped.tools).toEqual([]); }); + + it("exposes explicitly granted coding tools through the mediated loopback surface", () => { + resolveGatewayScopedTools.mockReturnValue(scopedToolFixture(["read", "exec", "browser"])); + + const scoped = resolveMcpLoopbackScopedTools( + scopeParams({ + toolsAllow: ["read", "exec", "browser"], + nodeExecAllowed: true, + }), + ); + + expect(scoped.tools.map((tool) => (tool as { name: string }).name)).toEqual([ + "read", + "exec", + "browser", + ]); + const call = resolveGatewayScopedTools.mock.calls[0]?.[0] as { + excludeToolNames?: Set; + includeNodeExecTool?: boolean; + }; + expect(call.includeNodeExecTool).toBe(false); + expect(call.excludeToolNames?.has("read")).toBe(false); + expect(call.excludeToolNames?.has("exec")).toBe(false); + expect(call.excludeToolNames?.has("write")).toBe(true); + }); }); describe("McpLoopbackToolCache", () => { diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index 85c84837c794..1a595d343253 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -79,7 +79,18 @@ export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): { tools: McpLoopbackTool[]; } { const excludeToolNames = new Set(NATIVE_TOOL_EXCLUDE); - if (params.nodeExecAllowed === true) { + // Restricted CLI grants use OpenClaw's implementations for coding tools; + // native CLI tools bypass path, approval, sandbox, and exec policy. + const mediatedNativeTools = new Set( + (params.toolsAllow ?? []) + .map((name) => normalizeToolName(name)) + .filter((name) => NATIVE_TOOL_EXCLUDE.has(name)), + ); + for (const toolName of mediatedNativeTools) { + excludeToolNames.delete(toolName); + } + const includeNodeExecTool = params.nodeExecAllowed === true && mediatedNativeTools.size === 0; + if (includeNodeExecTool) { excludeToolNames.delete("exec"); } const scoped = resolveGatewayScopedTools({ @@ -87,7 +98,7 @@ export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): { conversationReadOrigin: "delegated", surface: "loopback", excludeToolNames, - includeNodeExecTool: params.nodeExecAllowed === true, + includeNodeExecTool, }); return { agentId: scoped.agentId, diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index cea3bf71569f..8a9845b879ae 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -12,7 +12,11 @@ export type { CliBackendPrepareExecutionContext, CliBackendResolveExecutionArgs, CliBackendResolveExecutionArgsContext, + CliBackendResolveRuntimeToolAvailability, + CliBackendResolveRuntimeToolAvailabilityContext, + CliBackendRuntimeToolAvailability, CliBackendSideQuestionToolMode, + CliBackendToolAvailability, CliBackendThinkingLevel, } from "../plugins/types.js"; export type { CliBackendRuntimeArtifactPolicy } from "../plugins/cli-backend.types.js"; diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index 8ecba147a2e1..d2abab0c4ab7 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -55,13 +55,27 @@ export type CliBackendThinkingLevel = export type CliBackendExecutionMode = "agent" | "side-question"; -/** Host-isolated tool grant for a CLI run with every native tool disabled. */ -type CliBackendToolAvailability = { - native: readonly []; +/** Exact backend-native surface plus host-isolated MCP permissions for one CLI run. */ +export type CliBackendToolAvailability = { + native: readonly string[]; /** MCP tools already isolated by the host transport that may be auto-approved. */ mcp: readonly string[]; }; +export type CliBackendResolveRuntimeToolAvailabilityContext = { + /** Normalized, group-expanded OpenClaw runtime allowlist. */ + toolsAllow: readonly string[]; +}; + +export type CliBackendRuntimeToolAvailability = { + /** Host-isolated MCP tools selected from the OpenClaw runtime allowlist. */ + mcp: readonly string[]; +}; + +export type CliBackendResolveRuntimeToolAvailability = ( + ctx: CliBackendResolveRuntimeToolAvailabilityContext, +) => CliBackendRuntimeToolAvailability | null | undefined; + export type CliBackendResolveExecutionArgsContext = { config?: OpenClawConfig; workspaceDir: string; @@ -239,6 +253,15 @@ export type CliBackendPlugin = { * native effort flag. */ resolveExecutionArgs?: CliBackendResolveExecutionArgs; + /** + * Translate an OpenClaw runtime allowlist into the host-isolated MCP tools + * this backend can expose for one run. OpenClaw disables all native tools. + * + * Return null/undefined when the backend cannot enforce the requested cap. + * Omitting an allowed tool is safe; adding MCP authority absent from the + * allowlist is not. + */ + resolveRuntimeToolAvailability?: CliBackendResolveRuntimeToolAvailability; /** * Whether this CLI backend can expose native tools outside OpenClaw's tool * catalog. `selectable` backends must enforce `toolAvailability` through diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 04b1e821f8ce..967c22d7df00 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -16,7 +16,11 @@ export type { CliBackendPrepareExecutionContext, CliBackendResolveExecutionArgs, CliBackendResolveExecutionArgsContext, + CliBackendResolveRuntimeToolAvailability, + CliBackendResolveRuntimeToolAvailabilityContext, + CliBackendRuntimeToolAvailability, CliBackendSideQuestionToolMode, + CliBackendToolAvailability, CliBackendThinkingLevel, CliBundleMcpMode, PluginTextTransforms,