fix(agents): enforce Claude CLI cron tool policies (#112457)

* fix(agents): enforce Claude CLI tool policies

* fix(agents): bound CLI runtime tool grants

* fix(agents): isolate restricted Claude runs
This commit is contained in:
Jason (Json)
2026-07-21 19:38:21 -06:00
committed by GitHub
parent 1790d92f7c
commit 24c20eec76
16 changed files with 462 additions and 67 deletions
+13 -1
View File
@@ -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.
+16 -6
View File
@@ -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__<tool>` name for one of the allowed tools.
### `ownsNativeCompaction`: opting out of OpenClaw compaction
+6 -3
View File
@@ -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).
+2
View File
@@ -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,
};
}
+107 -6
View File
@@ -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", () => {
+28 -35
View File
@@ -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<CliBackendResolveExecutionArgsContext["toolAvailability"]>,
): 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<CliBackendResolveExecutionArgsContext["toolAvailability"]>,
): 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. */
+6
View File
@@ -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,
+158
View File
@@ -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<void>) | 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[] }) => [
+51 -8
View File
@@ -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<PreparedCliRunContext> {
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 =
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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;
+25
View File
@@ -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<string>;
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", () => {
+13 -2
View File
@@ -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,
+4
View File
@@ -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";
+26 -3
View File
@@ -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
+4
View File
@@ -16,7 +16,11 @@ export type {
CliBackendPrepareExecutionContext,
CliBackendResolveExecutionArgs,
CliBackendResolveExecutionArgsContext,
CliBackendResolveRuntimeToolAvailability,
CliBackendResolveRuntimeToolAvailabilityContext,
CliBackendRuntimeToolAvailability,
CliBackendSideQuestionToolMode,
CliBackendToolAvailability,
CliBackendThinkingLevel,
CliBundleMcpMode,
PluginTextTransforms,