mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] }) => [
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,11 @@ export type {
|
||||
CliBackendPrepareExecutionContext,
|
||||
CliBackendResolveExecutionArgs,
|
||||
CliBackendResolveExecutionArgsContext,
|
||||
CliBackendResolveRuntimeToolAvailability,
|
||||
CliBackendResolveRuntimeToolAvailabilityContext,
|
||||
CliBackendRuntimeToolAvailability,
|
||||
CliBackendSideQuestionToolMode,
|
||||
CliBackendToolAvailability,
|
||||
CliBackendThinkingLevel,
|
||||
CliBundleMcpMode,
|
||||
PluginTextTransforms,
|
||||
|
||||
Reference in New Issue
Block a user