fix(cron): enforce caps across CLI backends

This commit is contained in:
joshavant
2026-07-22 22:02:51 -05:00
committed by Josh Avant
parent 51c222904e
commit 8287e21445
39 changed files with 836 additions and 389 deletions
+21 -18
View File
@@ -234,6 +234,7 @@ only for behavior that really belongs to the backend.
| `defaultAuthProfileId` | Prefer a specific OpenClaw auth profile |
| `authEpochMode` | Decide how auth changes invalidate stored CLI sessions |
| `nativeToolMode` | Declare whether native tools are absent, always on, or host-selectable |
| `toolAvailabilityEnforcement` | Declare whether exact tool caps are enforced in argv or execution staging |
| `sideQuestionToolMode` | Declare disabled native tools for `/btw` side questions |
| `bundleMcp` / `bundleMcpMode` | Opt into OpenClaw's loopback MCP tool bridge |
| `ownsNativeCompaction` | Backend owns its own compaction - OpenClaw defers |
@@ -271,25 +272,27 @@ side-question argv reliably disables those tools, also set
`sideQuestionToolMode: "disabled"`; otherwise OpenClaw fails closed when BTW
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 the exact backend-native tool list and
`ctx.toolAvailability.mcp` is the exact host-isolated MCP allowlist. The hook
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.
Set `nativeToolMode: "selectable"` only when the backend can disable every
backend-native tool for an individual run. Restricted runs receive a canonical
contract: `ctx.toolAvailability.native` is the exact backend-native list and
`ctx.toolAvailability.openClaw` is the exact list of OpenClaw tool names. The
host independently limits the generated MCP configuration and grant to that
OpenClaw list; plugins must not translate it in core or add transport prefixes.
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.
Declare how the backend enforces that contract:
- `toolAvailabilityEnforcement: "execution-args"` requires
`resolveExecutionArgs`. The hook must replace conflicting tool flags, disable
customization surfaces that can execute outside the selected tools, and
return enforcing argv for both fresh and resumed runs.
- `toolAvailabilityEnforcement: "prepare-execution"` requires
`prepareExecution`. The hook must stage an exact per-run policy and return
`toolAvailabilityEnforced: true`; missing acknowledgement fails closed and
OpenClaw cleans up the staged resources before launch.
Runtime caps such as cron `toolsAllow` are normalized and group-expanded by
OpenClaw before this contract is built. Native tools are disabled, and a
backend without a complete declared enforcement path fails before execution.
### `ownsNativeCompaction`: opting out of OpenClaw compaction
+9 -7
View File
@@ -588,15 +588,17 @@ AI CLI backend such as `claude-cli` or `my-cli`.
- Use `prepareExecution` for backend-owned launch environment or temporary
auth/config bridges. Its `ctx.contextTokenBudget` is the effective token
limit selected for the run, so native-compaction backends can align their
own threshold without provider-specific core branches.
own threshold without provider-specific core branches. It also receives the
core-prepared `ctx.env` when backend staging must extend bundled MCP settings.
- Backends that can disable all native tools for a specific run may declare
`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.
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.
`ctx.toolAvailability.native` list plus canonical
`ctx.toolAvailability.openClaw` names. Declare
`toolAvailabilityEnforcement: "execution-args"` and enforce the contract in
final fresh/resume argv, or declare `"prepare-execution"`, enforce it in
staged policy, and return `toolAvailabilityEnforced: true`. OpenClaw disables
native tools for runtime caps such as cron `toolsAllow` and fails closed when
the declared enforcement path is incomplete.
For an end-to-end authoring guide, see
[CLI backend plugins](/plugins/cli-backend-plugins).
+1 -2
View File
@@ -20,7 +20,6 @@ import {
normalizeClaudeBackendConfig,
resolveClaudeCliAutoCompactEnv,
resolveClaudeCliExecutionArgs,
resolveClaudeCliRuntimeToolAvailability,
} from "./cli-shared.js";
type ClaudeCliAuthCredential =
@@ -128,6 +127,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
sideQuestionToolMode: "disabled",
ownsNativeCompaction: true,
// Anthropic routes direct anthropic-messages calls on subscription OAuth
@@ -210,6 +210,5 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
: undefined;
},
resolveExecutionArgs: resolveClaudeCliExecutionArgs,
resolveRuntimeToolAvailability: resolveClaudeCliRuntimeToolAvailability,
};
}
+4 -32
View File
@@ -6,7 +6,6 @@ import {
normalizeClaudeBackendConfig,
resolveClaudeCliAutoCompactEnv,
resolveClaudeCliExecutionArgs,
resolveClaudeCliRuntimeToolAvailability,
} from "./cli-shared.js";
type ClaudePreparedExecutionWithSecret = {
@@ -80,33 +79,6 @@ 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);
@@ -280,7 +252,7 @@ describe("resolveClaudeCliExecutionArgs", () => {
],
toolAvailability: {
native: [],
mcp: ["mcp__openclaw__openclaw"],
openClaw: ["openclaw"],
},
}),
).toEqual([
@@ -355,7 +327,7 @@ describe("resolveClaudeCliExecutionArgs", () => {
],
toolAvailability: {
native: [],
mcp: ["mcp__openclaw__message"],
openClaw: ["message"],
},
}),
).toEqual([
@@ -419,7 +391,7 @@ describe("resolveClaudeCliExecutionArgs", () => {
"--disallowedTools",
"mcp__other__*",
],
toolAvailability: { native: [], mcp: [] },
toolAvailability: { native: [], openClaw: [] },
}),
).toEqual([
"-p",
@@ -695,7 +667,7 @@ describe("normalizeClaudeBackendConfig", () => {
expect(normalized?.resumeArgs).toContain("bypassPermissions");
expect(normalized?.liveSession).toBe("claude-stdio");
expect(backend.resolveExecutionArgs).toBe(resolveClaudeCliExecutionArgs);
expect(backend.resolveRuntimeToolAvailability).toBe(resolveClaudeCliRuntimeToolAvailability);
expect(backend.toolAvailabilityEnforcement).toBe("execution-args");
});
it("opts bundled Claude CLI into bounded raw transcript reseed without disabling native resume", () => {
+5 -13
View File
@@ -5,8 +5,6 @@ import type {
CliBackendConfig,
CliBackendNormalizeConfigContext,
CliBackendResolveExecutionArgsContext,
CliBackendResolveRuntimeToolAvailabilityContext,
CliBackendRuntimeToolAvailability,
} from "openclaw/plugin-sdk/cli-backend";
import { resolveExecModePolicy } from "openclaw/plugin-sdk/exec-approvals-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -450,8 +448,11 @@ function resolveClaudeCliRestrictedExecutionArgs(
CLAUDE_TOOLS_ARG,
availability.native.join(","),
);
if (availability.mcp.length > 0) {
normalized.push(CLAUDE_ALLOWED_TOOLS_ARG, availability.mcp.join(","));
if (availability.openClaw.length > 0) {
normalized.push(
CLAUDE_ALLOWED_TOOLS_ARG,
availability.openClaw.map((toolName) => `${OPENCLAW_MCP_TOOL_PREFIX}${toolName}`).join(","),
);
} else {
normalized.push(CLAUDE_DISALLOWED_TOOLS_ARG, CLAUDE_DENY_MCP_TOOLS_VALUE);
}
@@ -484,15 +485,6 @@ export function resolveClaudeCliExecutionArgs(
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. */
export function normalizeClaudeBackendConfig(
config: CliBackendConfig,
+109 -15
View File
@@ -1,7 +1,10 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { CliBackendPreparedExecution } from "openclaw/plugin-sdk/cli-backend";
import type {
CliBackendPreparedExecution,
CliBackendToolAvailability,
} from "openclaw/plugin-sdk/cli-backend";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import {
@@ -65,6 +68,7 @@ type GeminiCliAuthHomeContext = {
agentDir?: string;
authProfileId?: string;
systemSettingsPath?: string;
toolAvailability?: CliBackendToolAvailability;
};
type GeminiCliAuthSelectedType = "oauth-personal" | "gemini-api-key";
@@ -231,23 +235,91 @@ function buildGeminiCliAuthSettings(
async function buildGeminiCliSystemSettings(
ctx: GeminiCliAuthHomeContext,
selectedType: GeminiCliAuthSelectedType,
selectedType?: GeminiCliAuthSelectedType,
): Promise<Record<string, unknown>> {
const base = await readGeminiCliJsonObject(ctx.systemSettingsPath);
const security = isRecord(base.security) ? { ...base.security } : {};
const auth = isRecord(security.auth) ? { ...security.auth } : {};
const enforcedType = normalizeString(
typeof auth.enforcedType === "string" ? auth.enforcedType : undefined,
);
if (enforcedType && enforcedType !== selectedType) {
throw new Error(
`Gemini CLI system settings enforce ${enforcedType} auth, but the selected OpenClaw profile requires ${selectedType}.`,
let settings = base;
if (selectedType) {
const security = isRecord(base.security) ? { ...base.security } : {};
const auth = isRecord(security.auth) ? { ...security.auth } : {};
const enforcedType = normalizeString(
typeof auth.enforcedType === "string" ? auth.enforcedType : undefined,
);
if (enforcedType && enforcedType !== selectedType) {
throw new Error(
`Gemini CLI system settings enforce ${enforcedType} auth, but the selected OpenClaw profile requires ${selectedType}.`,
);
}
security.auth = { ...auth, selectedType };
settings = { ...base, security };
}
security.auth = { ...auth, selectedType };
return ctx.toolAvailability
? applyGeminiCliToolAvailability(settings, ctx.toolAvailability)
: settings;
}
function applyGeminiCliToolAvailability(
base: Record<string, unknown>,
availability: CliBackendToolAvailability,
): Record<string, unknown> {
if (availability.native.length > 0) {
throw new Error("Gemini CLI cannot expose backend-native tools in an exact restricted run.");
}
const mcpServers = isRecord(base.mcpServers) ? { ...base.mcpServers } : {};
if (!isRecord(mcpServers.openclaw)) {
throw new Error("Gemini CLI exact tool availability requires the OpenClaw MCP server.");
}
const tools = isRecord(base.tools) ? { ...base.tools } : {};
// `tools.allowed` has higher policy priority than the `tools.core` default
// deny. Drop it so inherited system settings cannot widen this exact run.
const {
allowed: _allowedTools,
core: _coreTools,
discoveryCommand: _discoveryCommand,
callCommand: _callCommand,
...nonAuthorityToolSettings
} = tools;
const mcp = isRecord(base.mcp) ? { ...base.mcp } : {};
const { serverCommand: _serverCommand, ...nonAuthorityMcpSettings } = mcp;
const experimental = isRecord(base.experimental) ? { ...base.experimental } : {};
const agents = isRecord(base.agents) ? { ...base.agents } : {};
const agentOverrides = isRecord(agents.overrides) ? { ...agents.overrides } : {};
const hooksConfig = isRecord(base.hooksConfig) ? { ...base.hooksConfig } : {};
const skills = isRecord(base.skills) ? { ...base.skills } : {};
return {
...base,
security,
tools: {
...nonAuthorityToolSettings,
core: ["mcp_openclaw_*"],
discoveryCommand: "",
callCommand: "",
},
mcp: { ...nonAuthorityMcpSettings, allowed: ["openclaw"], serverCommand: "" },
mcpServers: {
openclaw: {
...mcpServers.openclaw,
includeTools: [...availability.openClaw],
},
},
experimental: { ...experimental, enableAgents: false },
agents: {
...agents,
overrides: {
...agentOverrides,
codebase_investigator: {
...(isRecord(agentOverrides.codebase_investigator)
? agentOverrides.codebase_investigator
: {}),
enabled: false,
},
cli_help: {
...(isRecord(agentOverrides.cli_help) ? agentOverrides.cli_help : {}),
enabled: false,
},
},
},
hooksConfig: { ...hooksConfig, enabled: false },
skills: { ...skills, enabled: false },
};
}
@@ -388,7 +460,29 @@ async function prepareGeminiCliApiKeyHome(
};
}
export async function prepareGeminiCliAuthHome(
async function prepareGeminiCliRestrictedSystemSettings(
ctx: GeminiCliAuthHomeContext,
): Promise<CliBackendPreparedExecution> {
const settings = await buildGeminiCliSystemSettings(ctx);
const systemSettingsDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-gemini-cli-policy-"),
);
await fs.chmod(systemSettingsDir, 0o700);
const systemSettingsPath = path.join(systemSettingsDir, "settings.json");
return {
env: { GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath },
clearEnv: [...GEMINI_CLI_PROFILE_SETTINGS_ENV],
beforeExecution: async () => {
await writeGeminiCliJson(systemSettingsPath, settings);
},
cleanup: async () => {
await fs.rm(systemSettingsDir, { recursive: true, force: true });
},
toolAvailabilityEnforced: true,
};
}
export async function prepareGeminiCliExecution(
ctx: GeminiCliAuthHomeContext,
credential: unknown,
): Promise<CliBackendPreparedExecution | null> {
@@ -397,10 +491,10 @@ export async function prepareGeminiCliAuthHome(
(await prepareGeminiCliOAuthHome(ctx, authCredential)) ??
(await prepareGeminiCliApiKeyHome(ctx, authCredential));
if (prepared) {
return prepared;
return ctx.toolAvailability ? { ...prepared, toolAvailabilityEnforced: true } : prepared;
}
if (normalizeString(ctx.authProfileId)) {
throwUnstageableSelectedGeminiProfile(ctx, authCredential);
}
return null;
return ctx.toolAvailability ? await prepareGeminiCliRestrictedSystemSettings(ctx) : null;
}
+6 -5
View File
@@ -77,18 +77,19 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
},
bundleMcp: true,
bundleMcpMode: "gemini-system-settings",
nativeToolMode: "always-on",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
authEpochMode: "profile-only",
normalizeConfig: normalizeGeminiCliBackendConfig,
prepareExecution: async (ctx) => {
const { prepareGeminiCliAuthHome } = await import("./cli-backend-auth.runtime.js");
return await prepareGeminiCliAuthHome(
const { prepareGeminiCliExecution } = await import("./cli-backend-auth.runtime.js");
return await prepareGeminiCliExecution(
{
agentDir: ctx.agentDir,
authProfileId: ctx.authProfileId,
systemSettingsPath:
(ctx as typeof ctx & { env?: Record<string, string> }).env
?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH,
ctx.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH,
toolAvailability: ctx.toolAvailability,
},
(ctx as typeof ctx & { authCredential?: unknown }).authCredential,
);
+128 -1
View File
@@ -142,11 +142,14 @@ describe("google gemini cli backend config", () => {
});
it("declares its bundled package implementation boundary", () => {
expect(buildGoogleGeminiCliBackend().runtimeArtifact).toEqual({
const backend = buildGoogleGeminiCliBackend();
expect(backend.runtimeArtifact).toEqual({
kind: "bundled-package-tree",
packageName: "@google/gemini-cli",
entrypoint: "command",
});
expect(backend.nativeToolMode).toBe("selectable");
expect(backend.toolAvailabilityEnforcement).toBe("prepare-execution");
});
it("keeps legacy json output overrides on the json parser", () => {
@@ -191,6 +194,130 @@ describe("google gemini cli backend config", () => {
});
describe("google gemini cli backend auth bridge", () => {
it.each([
{ auth: "ambient", allowed: ["memory_search"] },
{ auth: "ambient", allowed: [] },
{ auth: "oauth", allowed: ["memory_search"] },
{ auth: "oauth", allowed: [] },
{ auth: "api-key", allowed: ["memory_search"] },
{ auth: "api-key", allowed: [] },
] as const)(
"enforces exact system policy for $auth auth with $allowed",
async ({ auth, allowed }) => {
await withTempDir("openclaw-test-workspace-", async (workspaceDir) => {
const backend = buildGoogleGeminiCliBackend();
const inheritedSettingsPath = path.join(workspaceDir, "generated-mcp-settings.json");
await fs.writeFile(
inheritedSettingsPath,
`${JSON.stringify({
tools: {
core: ["run_shell_command"],
allowed: ["*"],
discoveryCommand: "hostile-discovery",
callCommand: "hostile-call",
},
mcp: { allowed: ["openclaw", "hostile"], serverCommand: "hostile-mcp" },
mcpServers: {
openclaw: {
url: "http://127.0.0.1:23119/mcp",
headers: { authorization: "Bearer loopback-token" },
},
hostile: { command: "hostile-server" },
},
experimental: { enableAgents: true },
agents: {
overrides: {
codebase_investigator: { enabled: true, custom: "preserved" },
cli_help: { enabled: true },
},
},
hooksConfig: { enabled: true, marker: "preserved" },
skills: { enabled: true, marker: "preserved" },
})}\n`,
"utf8",
);
const context: GeminiPrepareContext =
auth === "oauth"
? buildGeminiOAuthPrepareContext(workspaceDir)
: auth === "api-key"
? buildGeminiApiKeyPrepareContext(workspaceDir)
: {
workspaceDir,
provider: "google-gemini-cli",
modelId: "gemini-3.1-pro-preview",
};
context.env = { GEMINI_CLI_SYSTEM_SETTINGS_PATH: inheritedSettingsPath };
context.toolAvailability = { native: [], openClaw: [...allowed] };
const prepared = await backend.prepareExecution?.(context);
try {
expect(prepared?.toolAvailabilityEnforced).toBe(true);
await stageGeminiPreparedExecution(prepared);
const systemSettingsPath = prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
expect(systemSettingsPath).toBeTruthy();
const settings = JSON.parse(await fs.readFile(systemSettingsPath ?? "", "utf8")) as {
tools?: {
core?: string[];
discoveryCommand?: string;
callCommand?: string;
};
mcp?: { allowed?: string[]; serverCommand?: string };
mcpServers?: Record<string, Record<string, unknown>>;
experimental?: { enableAgents?: boolean };
agents?: { overrides?: Record<string, Record<string, unknown>> };
hooksConfig?: Record<string, unknown>;
skills?: Record<string, unknown>;
security?: { auth?: { selectedType?: string } };
};
expect(settings.tools?.core).toEqual(["mcp_openclaw_*"]);
expect(settings.tools).not.toHaveProperty("allowed");
expect(settings.tools?.discoveryCommand).toBe("");
expect(settings.tools?.callCommand).toBe("");
expect(settings.mcp?.allowed).toEqual(["openclaw"]);
expect(settings.mcp?.serverCommand).toBe("");
expect(settings.mcpServers?.openclaw).toMatchObject({
url: "http://127.0.0.1:23119/mcp",
headers: { authorization: "Bearer loopback-token" },
includeTools: [...allowed],
});
expect(settings.mcpServers?.hostile).toBeUndefined();
expect(settings.experimental?.enableAgents).toBe(false);
expect(settings.agents?.overrides?.codebase_investigator).toEqual({
enabled: false,
custom: "preserved",
});
expect(settings.agents?.overrides?.cli_help?.enabled).toBe(false);
expect(settings.hooksConfig).toEqual({ enabled: false, marker: "preserved" });
expect(settings.skills).toEqual({ enabled: false, marker: "preserved" });
expect(settings.security?.auth?.selectedType).toBe(
auth === "oauth" ? "oauth-personal" : auth === "api-key" ? "gemini-api-key" : undefined,
);
} finally {
await prepared?.cleanup?.();
}
});
},
);
it("rejects native tools because Gemini exact policy only exposes OpenClaw MCP", async () => {
await withTempDir("openclaw-test-workspace-", async (workspaceDir) => {
const inheritedSettingsPath = path.join(workspaceDir, "generated-mcp-settings.json");
await fs.writeFile(
inheritedSettingsPath,
JSON.stringify({ mcpServers: { openclaw: { url: "http://127.0.0.1/mcp" } } }),
"utf8",
);
await expect(
buildGoogleGeminiCliBackend().prepareExecution?.({
workspaceDir,
provider: "google-gemini-cli",
modelId: "gemini-3.1-pro-preview",
env: { GEMINI_CLI_SYSTEM_SETTINGS_PATH: inheritedSettingsPath },
toolAvailability: { native: ["run_shell_command"], openClaw: [] },
}),
).rejects.toThrow("cannot expose backend-native tools");
});
});
it("materializes selected OpenClaw OAuth credentials into a persistent profile-scoped Gemini CLI home", async () => {
const backend = buildGoogleGeminiCliBackend();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-workspace-"));
@@ -52,6 +52,62 @@ describe("waitForCronRunCompletion", () => {
).rejects.toThrow(/timed out waiting for cron run completion/);
});
it("allows live CLI scenarios to extend the gateway call deadline", async () => {
const callGateway = vi
.fn<
(method: string, rpcParams?: unknown, opts?: { timeoutMs?: number }) => Promise<unknown>
>()
.mockResolvedValue({ entries: [{ ts: 180, status: "ok" }] });
await waitForCronRunCompletion({
callGateway,
jobId: "slow-cli-job",
afterTs: 150,
timeoutMs: 120_000,
gatewayCallTimeoutMs: 90_000,
});
expect(callGateway).toHaveBeenCalledWith(
"cron.runs",
{ id: "slow-cli-job", limit: 20, sortDir: "desc" },
{ timeoutMs: 90_000 },
);
});
it("caps each gateway call at the remaining overall deadline", async () => {
let now = 1_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
const callGateway = vi
.fn<
(method: string, rpcParams?: unknown, opts?: { timeoutMs?: number }) => Promise<unknown>
>()
.mockImplementationOnce(async () => {
now = 1_080;
return { entries: [{ ts: 100, status: "ok", summary: "older run" }] };
})
.mockResolvedValueOnce({ entries: [{ ts: 180, status: "ok" }] });
try {
await waitForCronRunCompletion({
callGateway,
jobId: "bounded-call-job",
afterTs: 150,
timeoutMs: 100,
intervalMs: 0,
gatewayCallTimeoutMs: 90,
});
expect(callGateway).toHaveBeenNthCalledWith(
2,
"cron.runs",
{ id: "bounded-call-job", limit: 20, sortDir: "desc" },
{ timeoutMs: 20 },
);
} finally {
nowSpy.mockRestore();
}
});
it("keeps oversized poll intervals within the overall timeout", async () => {
const callGateway = vi
.fn<
+11 -1
View File
@@ -29,12 +29,22 @@ export async function waitForCronRunCompletion(params: {
afterTs: number;
timeoutMs?: number;
intervalMs?: number;
gatewayCallTimeoutMs?: number;
}) {
const timeoutMs = params.timeoutMs ?? 90_000;
const intervalMs = resolveCronRunPollIntervalMs(params.intervalMs);
const gatewayCallTimeoutMs = resolveTimerTimeoutMs(
params.gatewayCallTimeoutMs ?? 30_000,
30_000,
1,
);
const startedAt = Date.now();
let lastEntries: QaCronRunLogEntry[] = [];
while (Date.now() - startedAt < timeoutMs) {
const remainingCallMs = timeoutMs - (Date.now() - startedAt);
if (remainingCallMs <= 0) {
break;
}
const page = (await params.callGateway(
"cron.runs",
{
@@ -42,7 +52,7 @@ export async function waitForCronRunCompletion(params: {
limit: 20,
sortDir: "desc",
},
{ timeoutMs: Math.min(timeoutMs, 30_000) },
{ timeoutMs: Math.min(remainingCallMs, gatewayCallTimeoutMs) },
)) as QaCronRunsPage;
const entries = Array.isArray(page.entries) ? page.entries : [];
lastEntries = entries;
@@ -44,7 +44,7 @@ scenario:
channel: qa-channel
retryCount: 0
timeoutMs: 420000
summary: Create one group-owned job, add a sender wildcard restriction, then prove its scheduled write authority survives requester-policy resolution.
summary: Create one group-owned job through the operator API, add a sender wildcard restriction, then prove its scheduled write authority survives requester-policy resolution.
config:
artifactFile: cron-explicit-authority-proof.txt
artifactText: CRON_EXPLICIT_AUTHORITY_OK
@@ -77,27 +77,33 @@ flow:
- set: jobName
value:
expr: "`qa-cron-explicit-authority-${randomUUID()}`"
- sendInbound:
conversation:
id:
ref: config.conversationId
kind: group
title: Cron execution authority QA
senderId: qa-cron-authority-operator
senderName: Cron Authority Operator
text:
expr: |-
`Use the cron tool once to create one enabled isolated agent-turn job. Do not run it yourself.
Name it '${jobName}', schedule it at '${scheduledFor}', use payload message 'Use the write tool to create ${config.artifactFile} in the workspace with exactly ${config.artifactText}. Then reply exactly ${config.artifactText}.', set toolsAllow to ["write"], and set delivery to none.
After the cron add call succeeds, reply exactly CRON-EXECUTION-JOB-CREATED.`
- call: waitForCondition
- call: env.gateway.call
saveAs: added
args:
- lambda:
async: true
expr: "env.gateway.call('cron.list', { includeDisabled: true }, { timeoutMs: 30000 }).then((page) => page.jobs.find((job) => job.name === jobName))"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- cron.add
- agentId: qa
owner:
agentId: qa
sessionKey:
ref: config.ownerSessionKey
name:
ref: jobName
enabled: true
schedule:
kind: at
at:
ref: scheduledFor
sessionTarget: isolated
wakeMode: now
payload:
kind: agentTurn
message:
expr: "`Use the write tool to create ${config.artifactFile} in the workspace with exactly ${config.artifactText}. Then reply exactly ${config.artifactText}.`"
toolsAllow:
- write
delivery:
mode: none
- timeoutMs: 30000
- set: jobId
value:
expr: added.id
@@ -142,7 +148,7 @@ flow:
message:
expr: "`expected cron.run to enqueue one run, got ${JSON.stringify(runResponse)}`"
- name: scheduled authority writes the workspace proof
- name: records the scheduled run as successful
actions:
- call: waitForCronRunCompletion
saveAs: completedRun
@@ -155,10 +161,16 @@ flow:
ref: runStartedAt
timeoutMs:
expr: liveTurnTimeoutMs(env, 120000)
gatewayCallTimeoutMs:
expr: liveTurnTimeoutMs(env, 120000)
- assert:
expr: "completedRun?.status === 'ok'"
message:
expr: "`expected cron run ok, got ${JSON.stringify(completedRun)}`"
detailsExpr: "`job=${jobId} status=${completedRun.status}`"
- name: scheduled authority writes the workspace proof
actions:
- call: waitForCondition
saveAs: artifact
args:
+12 -5
View File
@@ -339,6 +339,8 @@ type OpenClawCodingToolsOptions = {
abortSignal?: AbortSignal;
/** Disable hook-owned diagnostics when an outer runtime owns tool diagnostics. */
emitBeforeToolCallDiagnostics?: boolean;
/** Skip hook wrapping when an outer tool-call boundary owns hook execution. */
wrapBeforeToolCallHook?: boolean;
/**
* Provider of the currently selected model (used for provider-specific tool quirks).
* Example: "anthropic", "openai", "google", "openai".
@@ -1216,11 +1218,16 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
emitDiagnostics: options?.emitBeforeToolCallDiagnostics,
...(options?.swarmCollector ? { approvalMode: "deny" as const } : {}),
};
const withHooks = normalized.map((tool) =>
isToolWrappedWithBeforeToolCallHook(tool)
? rewrapToolWithBeforeToolCallHook(tool, hookContext, hookOptions)
: wrapToolWithBeforeToolCallHook(tool, hookContext, hookOptions),
);
// MCP and other outer dispatchers execute hooks at their shared call boundary.
// Rewrapping here would run plugin authorization and mutation hooks twice.
const withHooks =
options?.wrapBeforeToolCallHook === false
? normalized
: normalized.map((tool) =>
isToolWrappedWithBeforeToolCallHook(tool)
? rewrapToolWithBeforeToolCallHook(tool, hookContext, hookOptions)
: wrapToolWithBeforeToolCallHook(tool, hookContext, hookOptions),
);
options?.recordToolPrepStage?.("tool-hooks");
const withAbort = options?.abortSignal
? withHooks.map((tool) => wrapToolWithAbortSignal(tool, options.abortSignal))
+1
View File
@@ -478,6 +478,7 @@ export async function resolveCliRuntimeOwnerFingerprint(params: {
bundleMcpMode: backend.bundleMcpMode,
authEpochMode: backend.authEpochMode,
nativeToolMode: backend.nativeToolMode,
toolAvailabilityEnforcement: backend.toolAvailabilityEnforcement,
sideQuestionToolMode: backend.sideQuestionToolMode,
},
...(authProfileId ? { authProfileId } : {}),
+2
View File
@@ -202,6 +202,7 @@ describe("resolveCliBackendConfig", () => {
resolveExecutionArgs: resolveExecutionArgs as never,
ownsNativeCompaction: true,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
sideQuestionToolMode: "disabled",
}),
],
@@ -214,6 +215,7 @@ describe("resolveCliBackendConfig", () => {
expect(resolved.resolveExecutionArgs).toBe(resolveExecutionArgs);
expect(resolved.ownsNativeCompaction).toBe(true);
expect(resolved.nativeToolMode).toBe("selectable");
expect(resolved.toolAvailabilityEnforcement).toBe("execution-args");
expect(resolved.sideQuestionToolMode).toBe("disabled");
});
});
+6 -5
View File
@@ -21,6 +21,7 @@ import type {
CliBackendPlugin,
CliBackendNativeToolMode,
CliBackendSideQuestionToolMode,
CliBackendToolAvailabilityEnforcement,
PluginTextTransforms,
} from "../plugins/types.js";
import { mergePluginTextTransforms } from "./plugin-text-transforms.js";
@@ -56,7 +57,7 @@ export type ResolvedCliBackend = {
ownsNativeCompaction?: boolean;
prepareExecution?: CliBackendPlugin["prepareExecution"];
resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"];
resolveRuntimeToolAvailability?: CliBackendPlugin["resolveRuntimeToolAvailability"];
toolAvailabilityEnforcement?: CliBackendToolAvailabilityEnforcement;
nativeToolMode?: CliBackendNativeToolMode;
sideQuestionToolMode?: CliBackendSideQuestionToolMode;
runtimeArtifact?: CliBackendRuntimeArtifactPolicy;
@@ -95,7 +96,7 @@ type FallbackCliBackendPolicy = {
ownsNativeCompaction?: boolean;
prepareExecution?: CliBackendPlugin["prepareExecution"];
resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"];
resolveRuntimeToolAvailability?: CliBackendPlugin["resolveRuntimeToolAvailability"];
toolAvailabilityEnforcement?: CliBackendToolAvailabilityEnforcement;
nativeToolMode?: CliBackendNativeToolMode;
sideQuestionToolMode?: CliBackendSideQuestionToolMode;
runtimeArtifact?: CliBackendRuntimeArtifactPolicy;
@@ -140,7 +141,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic
ownsNativeCompaction: entry.backend.ownsNativeCompaction,
prepareExecution: entry.backend.prepareExecution,
resolveExecutionArgs: entry.backend.resolveExecutionArgs,
resolveRuntimeToolAvailability: entry.backend.resolveRuntimeToolAvailability,
toolAvailabilityEnforcement: entry.backend.toolAvailabilityEnforcement,
nativeToolMode: entry.backend.nativeToolMode,
sideQuestionToolMode: entry.backend.sideQuestionToolMode,
runtimeArtifact: entry.backend.runtimeArtifact,
@@ -381,7 +382,7 @@ export function resolveCliBackendConfig(
ownsNativeCompaction: registered.ownsNativeCompaction,
prepareExecution: registered.prepareExecution,
resolveExecutionArgs: registered.resolveExecutionArgs,
resolveRuntimeToolAvailability: registered.resolveRuntimeToolAvailability,
toolAvailabilityEnforcement: registered.toolAvailabilityEnforcement,
nativeToolMode: registered.nativeToolMode,
sideQuestionToolMode: registered.sideQuestionToolMode,
runtimeArtifact: registered.runtimeArtifact,
@@ -414,7 +415,7 @@ export function resolveCliBackendConfig(
ownsNativeCompaction: fallbackPolicy.ownsNativeCompaction,
prepareExecution: fallbackPolicy.prepareExecution,
resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs,
resolveRuntimeToolAvailability: fallbackPolicy.resolveRuntimeToolAvailability,
toolAvailabilityEnforcement: fallbackPolicy.toolAvailabilityEnforcement,
nativeToolMode: fallbackPolicy.nativeToolMode,
sideQuestionToolMode: fallbackPolicy.sideQuestionToolMode,
runtimeArtifact: fallbackPolicy.runtimeArtifact,
+24 -5
View File
@@ -159,6 +159,10 @@ afterEach(() => {
});
const CLAUDE_OK_JSONL = `${JSON.stringify({ type: "result", result: "ok" })}\n`;
const GEMINI_OK_JSONL = `${[
JSON.stringify({ type: "message", role: "assistant", content: "ok", delta: true }),
JSON.stringify({ type: "result", status: "success" }),
].join("\n")}\n`;
describe("runCliAgent spawn path", () => {
it("formats output digests without logging response content", () => {
@@ -280,7 +284,7 @@ describe("runCliAgent spawn path", () => {
toolAvailability = execution.toolAvailability;
return [...execution.baseArgs];
},
cliToolAvailability: { native: [], mcp: ["mcp__openclaw__message"] },
cliToolAvailability: { native: [], openClaw: ["message"] },
});
context.preparedBackend.secretInput = {
fd: 3,
@@ -297,8 +301,8 @@ describe("runCliAgent spawn path", () => {
expect(output).toMatchObject({ text: "node answer", sessionId: "forked-node-session" });
// Node runs keep the gateway's native tool policy; loopback MCP tools do
// not exist on the node so the mcp list is projected empty.
expect(toolAvailability).toEqual({ native: [], mcp: [] });
// not exist on the node so the OpenClaw list is projected empty.
expect(toolAvailability).toEqual({ native: [], openClaw: [] });
expect(writeSystemPrompt).not.toHaveBeenCalled();
expect(supervisorSpawnMock).not.toHaveBeenCalled();
expect(invokeNode).toHaveBeenCalledWith(
@@ -1180,7 +1184,7 @@ describe("runCliAgent spawn path", () => {
mockSuccessfulCliRun(CLAUDE_OK_JSONL);
const toolAvailability: NonNullable<PreparedCliRunContext["params"]["cliToolAvailability"]> = {
native: [],
mcp: ["mcp__openclaw__openclaw"],
openClaw: ["openclaw"],
};
const resolveExecutionArgs = vi.fn(({ baseArgs }) => baseArgs);
@@ -1205,7 +1209,7 @@ describe("runCliAgent spawn path", () => {
buildPreparedCliRunContext({
cliToolAvailability: {
native: [],
mcp: ["mcp__openclaw__openclaw"],
openClaw: ["openclaw"],
},
resolveExecutionArgs,
}),
@@ -1214,6 +1218,21 @@ describe("runCliAgent spawn path", () => {
expect(supervisorSpawnMock).not.toHaveBeenCalled();
});
it("does not require an argv rewrite after prepared-execution enforcement", async () => {
mockSuccessfulCliRun(GEMINI_OK_JSONL);
await executePreparedCliRun(
buildPreparedCliRunContext({
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
cliToolAvailability: { native: [], openClaw: ["openclaw"] },
toolAvailabilityEnforcement: "prepare-execution",
}),
);
expect(supervisorSpawnMock).toHaveBeenCalledOnce();
});
it("maps Ultra to the strongest generic CLI backend level", async () => {
mockSuccessfulCliRun(CLAUDE_OK_JSONL);
const resolveExecutionArgs = vi.fn(({ baseArgs }) => baseArgs);
+4
View File
@@ -120,6 +120,7 @@ export type PreparedCliRunContextOverrides = {
backend?: Partial<PreparedCliRunContext["preparedBackend"]["backend"]>;
preparedEnv?: PreparedCliRunContext["preparedBackend"]["env"];
resolveExecutionArgs?: PreparedCliRunContext["backendResolved"]["resolveExecutionArgs"];
toolAvailabilityEnforcement?: PreparedCliRunContext["backendResolved"]["toolAvailabilityEnforcement"];
config?: PreparedCliRunContext["params"]["config"];
mcpConfigHash?: string;
mcpDeliveryCapture?: boolean;
@@ -221,6 +222,9 @@ export function buildPreparedCliRunContext(
? "google"
: "openai",
resolveExecutionArgs: overrides.resolveExecutionArgs,
toolAvailabilityEnforcement:
overrides.toolAvailabilityEnforcement ??
(provider === "google-gemini-cli" ? "prepare-execution" : "execution-args"),
runtimeArtifact: overrides.runtimeArtifact,
},
preparedBackend: {
+6 -2
View File
@@ -523,12 +523,16 @@ export async function executePreparedCliRun(
// session run with the node's full native toolset.
toolAvailability:
nodePlacement && params.cliToolAvailability
? { native: params.cliToolAvailability.native, mcp: [] }
? { native: params.cliToolAvailability.native, openClaw: [] }
: params.cliToolAvailability,
useResume,
baseArgs: baseArgsWithSkills,
});
if (params.cliToolAvailability && !resolvedExecutionArgs) {
if (
params.cliToolAvailability &&
context.backendResolved.toolAvailabilityEnforcement === "execution-args" &&
!resolvedExecutionArgs
) {
throw new Error(
`CLI backend ${context.backendResolved.id} did not enforce exact per-run tool availability`,
);
@@ -120,6 +120,8 @@ export function buildCliMcpGrantContext(params: {
agentId: params.agentId,
sessionId: normalizeOptionalMcpContextValue(params.run.sessionId),
runId: normalizeOptionalMcpContextValue(params.run.runId),
workspaceDir: params.run.workspaceDir,
...(normalizeOptionalMcpContextValue(params.run.cwd) ? { cwd: params.run.cwd?.trim() } : {}),
// Restricted runs get their allowlist stamped into the grant; the
// loopback server enforces it on tools/list and tools/call.
...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}),
+106 -77
View File
@@ -2660,6 +2660,7 @@ describe("prepareCliRunContext", () => {
agentId: "worker",
sessionId: "session-test",
runId: "run-test-room-event-tools",
workspaceDir: context.workspaceDir,
modelProvider: "anthropic",
modelId: "test-model",
messageProvider: "discord",
@@ -2802,7 +2803,7 @@ describe("prepareCliRunContext", () => {
});
});
it("fails closed when a runtime toolsAllow is requested for CLI backends", async () => {
it("fails closed when a backend cannot enforce a runtime toolsAllow", async () => {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
@@ -2817,28 +2818,107 @@ describe("prepareCliRunContext", () => {
config: createCliBackendConfig({ bundleMcp: true }),
toolsAllow: ["read", "web_search"],
}),
).rejects.toThrow(
"CLI backend test-cli cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy",
);
).rejects.toThrow("CLI backend test-cli cannot enforce exact per-run tool availability");
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
});
it("translates runtime toolsAllow through a selectable backend and bounds its MCP grant", async () => {
it("requires prepared-execution backends to acknowledge exact enforcement and cleans up", async () => {
const cleanup = vi.fn(async () => {});
const prepareExecution = vi.fn(async () => ({ cleanup }));
setRawCliBackendForPrepareTest({
id: "settings-cli",
pluginId: "settings-plugin",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "settings-cli",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
await expect(
fixture.prepare({
provider: "settings-cli",
cliToolAvailability: { native: [], openClaw: [] },
}),
).rejects.toThrow(
"did not enforce exact per-run tool availability during execution preparation",
);
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({ toolAvailability: { native: [], openClaw: [] } }),
);
expect(cleanup).toHaveBeenCalledOnce();
});
it("accepts a positive prepared-execution enforcement acknowledgement", async () => {
const prepareExecution = vi.fn(async () => ({ toolAvailabilityEnforced: true as const }));
setRawCliBackendForPrepareTest({
id: "settings-cli",
pluginId: "settings-plugin",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "settings-cli",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
const context = await fixture.prepare({
provider: "settings-cli",
cliToolAvailability: { native: [], openClaw: [] },
});
expect(context.params.cliToolAvailability).toEqual({ native: [], openClaw: [] });
await context.preparedBackend.cleanup?.();
});
it("projects node-placed Claude availability before prepared-execution enforcement", async () => {
const prepareExecution = vi.fn(async () => ({ toolAvailabilityEnforced: true as const }));
setRawCliBackendForPrepareTest({
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "prepare-execution",
prepareExecution,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
});
const context = await fixture.prepare({
provider: "claude-cli",
sessionEntry: { execHost: "node", execNode: "node-a" } as never,
cliToolAvailability: { native: ["Read"], openClaw: ["message"] },
});
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({
toolAvailability: { native: ["Read"], openClaw: [] },
}),
);
expect(context.params.cliToolAvailability).toEqual({ native: ["Read"], openClaw: [] });
await context.preparedBackend.cleanup?.();
});
it("keeps runtime toolsAllow canonical and bounds the backend-independent MCP grant", async () => {
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);
setRawCliBackendForPrepareTest({
id: "claude-cli",
@@ -2846,8 +2926,8 @@ describe("prepareCliRunContext", () => {
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
resolveExecutionArgs,
resolveRuntimeToolAvailability,
config: {
command: "claude",
args: ["--print"],
@@ -2879,21 +2959,10 @@ describe("prepareCliRunContext", () => {
});
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",
],
openClaw: ["read", "write", "edit", "apply_patch", "exec", "browser", "image"],
});
expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.toolsAllow).toEqual([
"read",
@@ -2912,47 +2981,6 @@ describe("prepareCliRunContext", () => {
}
});
it("rejects a backend that expands runtime toolsAllow beyond the requested grant", async () => {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
setRawCliBackendForPrepareTest({
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,
});
await expect(
fixture.prepare({
sessionKey: "agent:main:main",
provider: "claude-cli",
toolsAllow: ["read"],
}),
).rejects.toThrow(
"CLI backend claude-cli expanded runtime toolsAllow outside the requested OpenClaw MCP grant: mcp__openclaw__exec",
);
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
});
it("bounds the loopback grant to the selectable MCP tool allowlist", async () => {
const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [
...context.baseArgs,
@@ -2964,6 +2992,7 @@ describe("prepareCliRunContext", () => {
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
resolveExecutionArgs,
config: {
command: "claude",
@@ -3001,13 +3030,12 @@ describe("prepareCliRunContext", () => {
},
cliToolAvailability: {
native: [],
mcp: ["mcp__openclaw__memory_search", "mcp__openclaw__memory_get", "mcp__other__thing"],
openClaw: ["memory_search", "memory_get"],
},
});
cleanup = context.preparedBackend.cleanup;
// Foreign-server entries are not loopback-governed; the grant carries
// only the gateway tool names the run may reach.
// The grant carries exactly the canonical gateway tool names.
const grantContext = mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context;
expect(grantContext?.toolsAllow).toEqual(["memory_search", "memory_get"]);
@@ -3030,13 +3058,13 @@ describe("prepareCliRunContext", () => {
const resolveExecutionArgs = vi.fn(
(context: {
baseArgs: readonly string[];
toolAvailability?: { native: readonly string[]; mcp: readonly string[] };
toolAvailability?: { native: readonly string[]; openClaw: readonly string[] };
}) => [
...context.baseArgs,
"--tools",
context.toolAvailability?.native.join(",") ?? "default",
"--allowedTools",
context.toolAvailability?.mcp.join(",") ?? "",
context.toolAvailability?.openClaw.join(",") ?? "",
],
);
setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime });
@@ -3046,6 +3074,7 @@ describe("prepareCliRunContext", () => {
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
resolveExecutionArgs,
config: {
command: "claude",
@@ -3071,7 +3100,7 @@ describe("prepareCliRunContext", () => {
systemAgentTool: { surface: "cli" },
cliToolAvailability: {
native: [],
mcp: ["mcp__openclaw__openclaw"],
openClaw: ["openclaw"],
},
};
const context = await prepareCliRunContext(params);
@@ -3089,7 +3118,7 @@ describe("prepareCliRunContext", () => {
expect(resolveExecutionArgs).not.toHaveBeenCalled();
expect(context.params.cliToolAvailability).toEqual({
native: [],
mcp: ["mcp__openclaw__openclaw"],
openClaw: ["openclaw"],
});
const mcpConfigPath = expectDefined(
args[args.indexOf("--mcp-config") + 1],
+34 -33
View File
@@ -92,7 +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 { expandToolGroups, normalizeToolName } from "../tool-policy.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
import {
DEFAULT_BOOTSTRAP_FILENAME,
@@ -121,10 +121,6 @@ import {
loadCliSessionReseedMessages,
resolveAutoCliSessionReseedHistoryChars,
} from "./session-history.js";
import {
OPENCLAW_MCP_TOOL_PREFIX,
resolveLoopbackToolsAllowFromMcpPermissions,
} from "./tool-policy.js";
import type {
CliReusableSession,
CliSecretInput,
@@ -372,34 +368,15 @@ export async function prepareCliRunContext(
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 canonicalToolsAllow = uniqueStrings(
normalizedToolsAllow.map((toolName) => normalizeToolName(toolName)).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,
openClaw: canonicalToolsAllow,
},
};
}
@@ -410,9 +387,25 @@ export async function prepareCliRunContext(
execHost: params.sessionEntry?.execHost,
execNode: params.sessionEntry?.execNode,
});
if (nodeClaudePlacement && params.cliToolAvailability) {
// Gateway-loopback MCP tools do not exist on the node. Project the policy
// before either backend enforcement phase so staged settings and argv agree.
params = {
...params,
cliToolAvailability: {
native: params.cliToolAvailability.native,
openClaw: [],
},
};
}
if (
params.cliToolAvailability !== undefined &&
(backendResolved.nativeToolMode !== "selectable" || !backendResolved.resolveExecutionArgs)
(backendResolved.nativeToolMode !== "selectable" ||
!backendResolved.toolAvailabilityEnforcement ||
(backendResolved.toolAvailabilityEnforcement === "execution-args" &&
!backendResolved.resolveExecutionArgs) ||
(backendResolved.toolAvailabilityEnforcement === "prepare-execution" &&
!backendResolved.prepareExecution))
) {
throw new Error(
`CLI backend ${backendResolved.id} cannot enforce exact per-run tool availability`,
@@ -521,7 +514,7 @@ export async function prepareCliRunContext(
JSON.stringify([
baseExtraSystemPromptHash ?? null,
params.cliToolAvailability.native.toSorted(),
params.cliToolAvailability.mcp.toSorted(),
params.cliToolAvailability.openClaw.toSorted(),
]),
)
: baseExtraSystemPromptHash;
@@ -726,9 +719,7 @@ export async function prepareCliRunContext(
// user/plugin MCP servers must not be merged into the run's config at all.
// The loopback server (scoped by the grant allowlist) becomes the complete
// tool universe for the run.
const restrictedLoopbackToolsAllow = resolveLoopbackToolsAllowFromMcpPermissions(
params.cliToolAvailability?.mcp,
);
const restrictedLoopbackToolsAllow = params.cliToolAvailability?.openClaw;
let cleanupPreparedResources: (() => Promise<void>) | undefined;
let preparedExecution: PrivateCliBackendPreparedExecution | undefined;
try {
@@ -826,8 +817,9 @@ export async function prepareCliRunContext(
contextTokenBudget: contextWindowInfo.tokens,
authProfileId: effectiveAuthProfileId,
executionMode,
toolAvailability: params.cliToolAvailability,
env: preparedBackend.env,
} as Parameters<NonNullable<typeof backendResolved.prepareExecution>>[0];
} satisfies Parameters<NonNullable<typeof backendResolved.prepareExecution>>[0];
preparedExecution =
(await backendResolved.prepareExecution?.(
(backendResolved.id === "google-gemini-cli" || backendResolved.id === "claude-cli"
@@ -853,6 +845,15 @@ export async function prepareCliRunContext(
}
: undefined;
cleanupPreparedResources = preparedBackendCleanup;
if (
params.cliToolAvailability &&
backendResolved.toolAvailabilityEnforcement === "prepare-execution" &&
preparedExecution?.toolAvailabilityEnforced !== true
) {
throw new Error(
`CLI backend ${backendResolved.id} did not enforce exact per-run tool availability during execution preparation`,
);
}
const skipLocalCredentialEpoch = shouldSkipLocalCliCredentialEpoch({
authEpochMode: backendResolved.authEpochMode,
authProfileId: effectiveAuthProfileId,
+1 -44
View File
@@ -1,48 +1,5 @@
import { describe, expect, it } from "vitest";
import {
resolveCliRuntimeToolsAllow,
resolveLoopbackToolsAllowFromMcpPermissions,
stripOpenClawMcpToolPrefix,
} from "./tool-policy.js";
describe("resolveLoopbackToolsAllowFromMcpPermissions", () => {
it("returns undefined when no MCP permission list is set", () => {
expect(resolveLoopbackToolsAllowFromMcpPermissions(undefined)).toBeUndefined();
});
it("maps prefixed loopback names to gateway tool names", () => {
expect(
resolveLoopbackToolsAllowFromMcpPermissions([
"mcp__openclaw__memory_search",
"mcp__openclaw__memory_get",
]),
).toEqual(["memory_search", "memory_get"]);
});
it("keeps the full surface on wildcard entries", () => {
expect(resolveLoopbackToolsAllowFromMcpPermissions(["mcp__openclaw__*"])).toBeUndefined();
expect(
resolveLoopbackToolsAllowFromMcpPermissions(["mcp__openclaw__memory_search", "*"]),
).toBeUndefined();
});
it("drops tools owned by other MCP servers and fails closed when none remain", () => {
expect(
resolveLoopbackToolsAllowFromMcpPermissions([
"mcp__other__thing",
"mcp__openclaw__memory_search",
]),
).toEqual(["memory_search"]);
// Only foreign-server entries: the loopback surface exposes nothing.
expect(resolveLoopbackToolsAllowFromMcpPermissions(["mcp__other__thing"])).toEqual([]);
});
it("normalizes and dedupes unprefixed entries", () => {
expect(
resolveLoopbackToolsAllowFromMcpPermissions([" Memory_Search ", "memory_search"]),
).toEqual(["memory_search"]);
});
});
import { resolveCliRuntimeToolsAllow, stripOpenClawMcpToolPrefix } from "./tool-policy.js";
describe("stripOpenClawMcpToolPrefix", () => {
it("strips only the loopback transport prefix", () => {
-32
View File
@@ -10,38 +10,6 @@ export function stripOpenClawMcpToolPrefix(toolName: string): string {
: toolName;
}
/**
* Derives the loopback MCP grant allowlist from a selectable-backend MCP
* permission list. Wildcards keep the full session-scoped surface; entries for
* other MCP servers are not loopback-governed and drop out. A non-wildcard
* list that leaves no loopback names fails closed (empty allowlist).
*/
export function resolveLoopbackToolsAllowFromMcpPermissions(
mcp: readonly string[] | undefined,
): string[] | undefined {
if (!mcp) {
return undefined;
}
const names = new Set<string>();
for (const entry of mcp) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
if (trimmed === "*" || trimmed === `${OPENCLAW_MCP_TOOL_PREFIX}*`) {
return undefined;
}
if (trimmed.startsWith("mcp__") && !trimmed.startsWith(OPENCLAW_MCP_TOOL_PREFIX)) {
continue;
}
const name = normalizeToolName(stripOpenClawMcpToolPrefix(trimmed));
if (name) {
names.add(name);
}
}
return [...names];
}
/** Keeps only explicit runtime caps for backend-owned exact translation. */
export function resolveCliRuntimeToolsAllow(
toolsAllow?: string[],
+2 -2
View File
@@ -188,10 +188,10 @@ export type RunCliAgentParams = {
toolsAllow?: string[];
/** Trusted server-stamped authority for an explicitly capped scheduled run. */
scheduledToolPolicy?: ScheduledToolPolicyContext;
/** Exact native surface plus host-isolated MCP permissions for a selectable CLI backend. */
/** Exact native plus canonical OpenClaw surface for a selectable CLI backend. */
cliToolAvailability?: {
native: string[];
mcp: string[];
openClaw: string[];
};
disableTools?: boolean;
abortSignal?: AbortSignal;
@@ -343,11 +343,7 @@ describe("runEmbeddedAgentViaCliBackendIfEligible execution", () => {
requireExplicitMessageTarget: true,
cliToolAvailability: {
native: [],
mcp: [
"mcp__openclaw__memory_search",
"mcp__openclaw__memory_get",
"mcp__openclaw__notes_retrieve_context",
],
openClaw: ["memory_search", "memory_get", "notes_retrieve_context"],
},
});
// Embedded toolsAllow must never reach the CLI runner: it fails closed.
@@ -14,7 +14,7 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { onAgentEvent } from "../../infra/agent-events.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { OPENCLAW_MCP_TOOL_PREFIX, stripOpenClawMcpToolPrefix } from "../cli-runner/tool-policy.js";
import { stripOpenClawMcpToolPrefix } from "../cli-runner/tool-policy.js";
import { normalizeToolName } from "../tool-policy.js";
import { isToolResultError } from "../tool-result-error.js";
import { resolveEmbeddedCliBackendDispatchEligibility } from "./cli-backend-dispatch-eligibility.js";
@@ -99,7 +99,7 @@ async function runEmbeddedAgentViaCliBackend(
// unreachable, matching disableMessageTool intent.
const cliToolAvailability = {
native: [] as [],
mcp: dispatch.toolsAllow.map((name) => `${OPENCLAW_MCP_TOOL_PREFIX}${name}`),
openClaw: dispatch.toolsAllow,
};
const onAgentToolResult = params.onAgentToolResult;
// The CLI backend writes no OpenClaw session records; mirror the run into
+3
View File
@@ -15,6 +15,9 @@ export type McpLoopbackRequestContext = {
agentId?: string;
sessionId?: string;
runId?: string;
/** Server-selected roots for mediated coding tools in this CLI run. */
workspaceDir?: string;
cwd?: string;
modelProvider?: string;
modelId?: string;
messageProvider?: string;
+2
View File
@@ -82,12 +82,14 @@ describe("resolveMcpLoopbackScopedTools", () => {
]);
const call = resolveGatewayScopedTools.mock.calls[0]?.[0] as {
excludeToolNames?: Set<string>;
mediatedToolNames?: 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);
expect(call.mediatedToolNames).toEqual(new Set(["read", "exec"]));
});
});
+7
View File
@@ -40,6 +40,9 @@ type McpLoopbackScopeParams = {
runtimePolicySessionKey?: string;
agentId?: string;
sessionId?: string;
runId?: string;
workspaceDir?: string;
cwd?: string;
modelProvider?: string;
modelId?: string;
yieldContextCacheKey?: string;
@@ -100,6 +103,7 @@ export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): {
conversationReadOrigin: "delegated",
surface: "loopback",
excludeToolNames,
mediatedToolNames: mediatedNativeTools,
includeNodeExecTool,
});
return {
@@ -140,6 +144,9 @@ export class McpLoopbackToolCache {
params.runtimePolicySessionKey ?? "",
params.agentId ?? "",
params.sessionId ?? "",
params.runId ?? "",
params.workspaceDir ?? "",
params.cwd ?? "",
params.modelProvider ?? "",
params.modelId ?? "",
params.yieldContextCacheKey ?? "",
+3
View File
@@ -294,6 +294,9 @@ async function startMcpLoopbackServer(port = 0): Promise<{
runtimePolicySessionKey: requestContext.runtimePolicySessionKey,
agentId: requestContext.agentId,
sessionId: requestContext.sessionId,
runId: requestContext.runId,
workspaceDir: requestContext.workspaceDir,
cwd: requestContext.cwd,
modelProvider: requestContext.modelProvider,
modelId: requestContext.modelId,
yieldContextCacheKey: yieldContext?.cacheKey,
@@ -19,6 +19,16 @@ type CreateOpenClawToolsArg = {
requesterAgentIdOverride?: string;
};
type CreateOpenClawCodingToolsArg = {
runtimeToolAllowlist?: string[];
sessionKey?: string;
runSessionKey?: string;
workspaceDir?: string;
cwd?: string;
wrapBeforeToolCallHook?: boolean;
scheduledToolPolicy?: { ownerSessionKey: string };
};
type LazyExecToolDefaults = {
host?: string;
allowBackground?: boolean;
@@ -57,6 +67,9 @@ const hoisted = vi.hoisted(() => {
makeTool,
createLazyExecToolMock,
getLoadedChannelPluginMock: vi.fn(),
createOpenClawCodingToolsMock: vi.fn(
(_args: CreateOpenClawCodingToolsArg): ReturnType<typeof makeTool>[] => [],
),
createOpenClawToolsMock: vi.fn((_args: CreateOpenClawToolsArg) => [
makeTool("read"),
makeTool("sessions_spawn"),
@@ -71,6 +84,11 @@ vi.mock("../agents/openclaw-tools.js", () => ({
createOpenClawTools: (args: CreateOpenClawToolsArg) => hoisted.createOpenClawToolsMock(args),
}));
vi.mock("../agents/agent-tools.js", () => ({
createOpenClawCodingTools: (args: CreateOpenClawCodingToolsArg) =>
hoisted.createOpenClawCodingToolsMock(args),
}));
vi.mock("../channels/plugins/index.js", () => ({
getLoadedChannelPlugin: (channel: string) => hoisted.getLoadedChannelPluginMock(channel),
}));
@@ -87,6 +105,8 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
beforeEach(() => {
hoisted.createOpenClawToolsMock.mockClear();
hoisted.createLazyExecToolMock.mockClear();
hoisted.createOpenClawCodingToolsMock.mockReset();
hoisted.createOpenClawCodingToolsMock.mockReturnValue([]);
hoisted.getLoadedChannelPluginMock.mockReset();
});
@@ -128,6 +148,54 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
expect(args.inheritedToolDenylist).toEqual([]);
});
it("constructs exact coding tools for a server-minted mediated grant", () => {
hoisted.createOpenClawCodingToolsMock.mockReturnValueOnce([hoisted.makeTool("write")]);
const result = resolveGatewayScopedTools({
cfg: { tools: { exec: { host: "node" } } } as OpenClawConfig,
sessionKey: "agent:main:cron:run-1",
runtimePolicySessionKey: "agent:main:qa-channel:group:ops",
runId: "run-1",
workspaceDir: "/workspace",
cwd: "/workspace/task",
surface: "loopback",
excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"],
mediatedToolNames: ["write"],
scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" },
});
expect(result.tools.map((tool) => tool.name)).toContain("write");
expect(hoisted.createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
runtimeToolAllowlist: ["write"],
sessionKey: "agent:main:qa-channel:group:ops",
runSessionKey: "agent:main:cron:run-1",
workspaceDir: "/workspace",
cwd: "/workspace/task",
wrapBeforeToolCallHook: false,
scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" },
}),
);
expect(hoisted.createLazyExecToolMock).not.toHaveBeenCalled();
});
it("does not fall back when policy removes a mediated coding tool", () => {
hoisted.createOpenClawToolsMock.mockReturnValueOnce([
hoisted.makeTool("write"),
hoisted.makeTool("cron"),
]);
const result = resolveGatewayScopedTools({
cfg: {} as OpenClawConfig,
sessionKey: "agent:main:cron:run-1",
surface: "loopback",
mediatedToolNames: ["write"],
excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"],
});
expect(result.tools.map((tool) => tool.name)).toEqual(["cron"]);
});
it("keeps owner-only core tools visible only for owner loopback callers", () => {
const ownerResult = resolveGatewayScopedTools({
cfg: {
+29
View File
@@ -1,6 +1,9 @@
/**
* Gateway tool-resolution tests.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGatewayScopedTools } from "./tool-resolution.js";
@@ -67,6 +70,32 @@ describe("resolveGatewayScopedTools", () => {
expect(result.tools.some((tool) => tool.name === "message")).toBe(false);
});
it("materializes an executable write tool on the mediated CLI surface", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mediated-write-"));
try {
const result = resolveGatewayScopedTools({
cfg: {} as OpenClawConfig,
sessionKey: "agent:main:cron:mediated-write",
surface: "loopback",
workspaceDir,
mediatedToolNames: ["write"],
excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"],
});
const writeTool = result.tools.find((tool) => tool.name === "write");
expect(writeTool).toBeDefined();
await writeTool?.execute?.("mediated-write-call", {
path: "proof.txt",
content: "mediated write ok",
});
await expect(fs.readFile(path.join(workspaceDir, "proof.txt"), "utf8")).resolves.toBe(
"mediated write ok",
);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("applies sandbox tool denies to sandboxed loopback turns", () => {
const result = resolveGatewayScopedTools({
cfg: {
+108 -22
View File
@@ -1,5 +1,6 @@
// Gateway-scoped tool resolution for HTTP and loopback tool surfaces.
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { createOpenClawCodingTools } from "../agents/agent-tools.js";
import { filterToolsByMessageProvider } from "../agents/agent-tools.message-provider-policy.js";
import { resolveEffectiveToolPolicy } from "../agents/agent-tools.policy.js";
import type { ExecElevatedDefaults } from "../agents/bash-tools.exec-types.js";
@@ -24,6 +25,7 @@ import {
collectExplicitDenylist,
hasRestrictiveAllowPolicy,
mergeAlsoAllowPolicy,
normalizeToolName,
replaceWithEffectiveToolAllowlist,
resolveToolProfilePolicy,
} from "../agents/tool-policy.js";
@@ -59,6 +61,9 @@ export function resolveGatewayScopedTools(params: {
runtimePolicySessionKey?: string;
agentId?: string;
sessionId?: string;
runId?: string;
workspaceDir?: string;
cwd?: string;
modelProvider?: string;
modelId?: string;
onYield?: (message: string) => Promise<void> | void;
@@ -81,6 +86,8 @@ export function resolveGatewayScopedTools(params: {
allowMediaInvokeCommands?: boolean;
surface?: GatewayScopedToolSurface;
excludeToolNames?: Iterable<string>;
/** Server-minted coding tools that must be mediated through the loopback surface. */
mediatedToolNames?: Iterable<string>;
disablePluginTools?: boolean;
gatewayRequestedTools?: string[];
/** Add the CLI-only, node-forced exec tool before applying the shared policy pipeline. */
@@ -179,6 +186,9 @@ export function resolveGatewayScopedTools(params: {
});
const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined;
const excludedToolNames = params.excludeToolNames ? Array.from(params.excludeToolNames) : [];
const mediatedToolNames = new Set(
Array.from(params.mediatedToolNames ?? [], (name) => normalizeToolName(name)).filter(Boolean),
);
const gatewayToolsCfg = params.cfg.gateway?.tools;
const defaultGatewayDeny =
surface === "http"
@@ -189,10 +199,9 @@ export function resolveGatewayScopedTools(params: {
? [...GATEWAY_OWNER_ONLY_CORE_TOOLS]
: [];
// HTTP callers start with additional surface denies because they cross auth only.
const workspaceDir = resolveAgentWorkspaceDir(
params.cfg,
agentId ?? resolveDefaultAgentId(params.cfg),
);
const workspaceDir =
params.workspaceDir?.trim() ||
resolveAgentWorkspaceDir(params.cfg, agentId ?? resolveDefaultAgentId(params.cfg));
const explicitDenylist = collectExplicitDenylist([
profilePolicy,
providerProfilePolicy,
@@ -275,36 +284,113 @@ export function resolveGatewayScopedTools(params: {
inheritedToolAllowlist,
inheritedToolDenylist,
});
const nodeExecCandidate = nodeExecSurface
? resolveExecDefaults({
cfg: params.cfg,
sessionEntry: params.execSession,
execOverrides: params.execOverrides,
agentId,
sessionKey: runtimePolicySessionKey,
sandboxAvailable: sandboxRuntime.sandboxed,
})
: undefined;
const includeNodeExecTool = nodeExecCandidate?.canRequestNode === true;
const execDefaults =
nodeExecSurface || mediatedToolNames.size > 0
? resolveExecDefaults({
cfg: params.cfg,
sessionEntry: params.execSession,
execOverrides: params.execOverrides,
agentId,
sessionKey: runtimePolicySessionKey,
sandboxAvailable: sandboxRuntime.sandboxed,
})
: undefined;
const nodeExecDefaults =
nodeExecSurface && execDefaults?.canRequestNode === true ? execDefaults : undefined;
const includeNodeExecTool = nodeExecDefaults !== undefined;
const execConfig = includeNodeExecTool
? resolveExecToolConfig({ cfg: params.cfg, agentId })
: undefined;
const includeMediatedBaseCodingTools = ["read", "write", "edit"].some((name) =>
mediatedToolNames.has(name),
);
const includeMediatedShellTools = ["apply_patch", "exec", "process"].some((name) =>
mediatedToolNames.has(name),
);
const mediatedCodingTools =
surface === "loopback" && (includeMediatedBaseCodingTools || includeMediatedShellTools)
? createOpenClawCodingTools({
config: params.cfg,
agentId,
sessionKey: runtimePolicySessionKey,
runSessionKey: params.sessionKey,
sessionId: params.sessionId,
runId: params.runId,
workspaceDir,
cwd: params.cwd?.trim() || workspaceDir,
modelProvider: params.modelProvider,
modelId: params.modelId,
messageProvider: params.messageProvider,
messageChannel: params.messageProvider,
clientCaps: params.clientCaps,
agentAccountId: params.accountId,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
currentInboundAudio: params.currentInboundAudio,
channelContext: params.channelContext,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.channelContext?.sender?.id,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
trigger: params.trigger,
approvalReviewerDeviceId: params.approvalReviewerDeviceId,
sourceReplyDeliveryMode,
taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode,
inboundEventKind: params.inboundEventKind,
requireExplicitMessageTarget: params.requireExplicitMessageTarget,
runtimeToolAllowlist: [...mediatedToolNames],
exec: execDefaults
? {
host: execDefaults.host,
mode: execDefaults.mode,
security: execDefaults.security,
ask: execDefaults.ask,
node: execDefaults.node,
elevated: params.bashElevated,
}
: undefined,
scheduledToolPolicy: params.scheduledToolPolicy,
toolConstructionPlan: {
includeBaseCodingTools: includeMediatedBaseCodingTools,
includeShellTools: includeMediatedShellTools,
includeChannelTools: false,
includeOpenClawTools: false,
includePluginTools: false,
},
// The MCP dispatcher is the shared hook and abort boundary for these tools.
wrapBeforeToolCallHook: false,
toolPolicyAuditLogLevel: "debug",
})
: [];
// CLI backends already own their local shell. This extra surface is deliberately
// fixed to node so it cannot become a second path to Gateway-local execution.
const baseTools = nodeExecSurface
? openClawTools.filter((tool) => tool.name.trim().toLowerCase() !== "exec")
: openClawTools;
const allTools = includeNodeExecTool
const toolsWithMediatedCoding = [
// Once a name is server-minted as mediated, only the canonical coding
// factory may supply it. A policy-filtered tool must not fall back to a
// coincidentally named Gateway/plugin implementation.
...baseTools.filter((tool) => !mediatedToolNames.has(normalizeToolName(tool.name))),
...mediatedCodingTools,
];
const allTools = nodeExecDefaults
? [
...baseTools,
...toolsWithMediatedCoding,
createLazyExecTool(
{
host: "node",
mode: nodeExecCandidate.mode,
security: nodeExecCandidate.security,
ask: nodeExecCandidate.ask,
mode: nodeExecDefaults.mode,
security: nodeExecDefaults.security,
ask: nodeExecDefaults.ask,
trigger: params.trigger,
node: nodeExecCandidate.node,
node: nodeExecDefaults.node,
pathPrepend: execConfig?.pathPrepend,
safeBins: execConfig?.safeBins,
strictInlineEval: execConfig?.strictInlineEval,
@@ -349,7 +435,7 @@ export function resolveGatewayScopedTools(params: {
},
),
]
: baseTools;
: toolsWithMediatedCoding;
const toolsForMessageProvider = filterToolsByMessageProvider(allTools, params.messageProvider);
const policyFiltered = applyToolPolicyPipeline({
+1 -3
View File
@@ -12,11 +12,9 @@ export type {
CliBackendPrepareExecutionContext,
CliBackendResolveExecutionArgs,
CliBackendResolveExecutionArgsContext,
CliBackendResolveRuntimeToolAvailability,
CliBackendResolveRuntimeToolAvailabilityContext,
CliBackendRuntimeToolAvailability,
CliBackendSideQuestionToolMode,
CliBackendToolAvailability,
CliBackendToolAvailabilityEnforcement,
CliBackendThinkingLevel,
} from "../plugins/types.js";
export type { CliBackendRuntimeArtifactPolicy } from "../plugins/cli-backend.types.js";
+16 -29
View File
@@ -113,6 +113,10 @@ export type CliBackendPrepareExecutionContext = {
contextTokenBudget?: number;
authProfileId?: string;
executionMode?: CliBackendExecutionMode;
/** Exact runtime tool surface the backend must enforce for this run. */
toolAvailability?: CliBackendToolAvailability;
/** Core-prepared environment, including any bundled MCP settings path. */
env?: Readonly<Record<string, string>>;
};
export type CliBackendPreparedExecution = {
@@ -124,6 +128,8 @@ export type CliBackendPreparedExecution = {
*/
beforeExecution?: () => Promise<void>;
cleanup?: () => Promise<void>;
/** Positive acknowledgement for `prepare-execution` tool enforcement. */
toolAvailabilityEnforced?: true;
};
export type CliBackendThinkingLevel =
@@ -138,27 +144,13 @@ export type CliBackendThinkingLevel =
export type CliBackendExecutionMode = "agent" | "side-question";
/** Exact backend-native surface plus host-isolated MCP permissions for one CLI run. */
/** Exact backend-native plus canonical OpenClaw tool surface 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[];
/** Canonical OpenClaw tool names served through the host-isolated transport. */
openClaw: 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;
@@ -180,6 +172,9 @@ export type CliBackendAuthEpochMode = "combined" | "profile-only";
export type CliBackendNativeToolMode = "none" | "always-on" | "selectable";
/** Backend-owned mechanism that enforces exact per-run tool availability. */
export type CliBackendToolAvailabilityEnforcement = "execution-args" | "prepare-execution";
export type CliBackendSideQuestionToolMode = "disabled";
export type CliBackendNormalizeConfigContext = {
@@ -333,20 +328,12 @@ 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;
/** How this backend enforces an exact per-run `toolAvailability` contract. */
toolAvailabilityEnforcement?: CliBackendToolAvailabilityEnforcement;
/**
* Whether this CLI backend can expose native tools outside OpenClaw's tool
* catalog. `selectable` backends must enforce `toolAvailability` through
* `resolveExecutionArgs`; `always-on` backends fail closed for restricted
* callers.
* catalog. Exact restricted runs require `selectable` plus a declared
* `toolAvailabilityEnforcement`; `always-on` backends fail closed.
*/
nativeToolMode?: CliBackendNativeToolMode;
/**
+1 -3
View File
@@ -17,11 +17,9 @@ export type {
CliBackendPrepareExecutionContext,
CliBackendResolveExecutionArgs,
CliBackendResolveExecutionArgsContext,
CliBackendResolveRuntimeToolAvailability,
CliBackendResolveRuntimeToolAvailabilityContext,
CliBackendRuntimeToolAvailability,
CliBackendSideQuestionToolMode,
CliBackendToolAvailability,
CliBackendToolAvailabilityEnforcement,
CliBackendThinkingLevel,
CliBundleMcpMode,
PluginTextTransforms,
+2 -1
View File
@@ -114,6 +114,7 @@ beforeEach(() => {
],
}),
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
sideQuestionToolMode: "disabled",
resolveExecutionArgs: (context) => context.baseArgs,
},
@@ -337,7 +338,7 @@ describe("runSystemAgentTurn", () => {
expect(call.cleanupCliLiveSessionOnRunEnd).toBe(true);
expect(call.cliToolAvailability).toEqual({
native: [],
mcp: ["mcp__openclaw__openclaw"],
openClaw: ["openclaw"],
});
expect(call.toolsAllow).toBeUndefined();
expect(requireValue(call.systemAgentTool, "missing CLI OpenClaw tool").proposalRef).toBe(
+9 -4
View File
@@ -33,7 +33,7 @@ import {
// calls, so even metered external routes need the full window, and 120s
// already covers local startup + generation (planner evidence).
const AGENT_TURN_TIMEOUT_MS = 120_000;
const SYSTEM_AGENT_MCP_TOOL_NAME = "mcp__openclaw__openclaw";
const SYSTEM_AGENT_TOOL_NAME = "openclaw";
export type SystemAgentTurnDirective =
import("../agents/tools/system-agent-tool.js").SystemAgentToolDirective;
@@ -186,6 +186,7 @@ function cliRouteKey(
bundleMcpMode: backend.bundleMcpMode,
authEpochMode: backend.authEpochMode,
nativeToolMode: backend.nativeToolMode,
toolAvailabilityEnforcement: backend.toolAvailabilityEnforcement,
sideQuestionToolMode: backend.sideQuestionToolMode,
}
: null,
@@ -211,12 +212,16 @@ function resolveSystemAgentCliBackend(
function resolveSystemAgentCliToolAvailability(
backend: ResolvedCliBackend | null,
): { native: []; mcp: string[] } | undefined {
): { native: []; openClaw: string[] } | undefined {
if (backend?.nativeToolMode === "none") {
return undefined;
}
if (backend?.nativeToolMode === "selectable" && backend.resolveExecutionArgs) {
return { native: [], mcp: [SYSTEM_AGENT_MCP_TOOL_NAME] };
if (
backend?.nativeToolMode === "selectable" &&
((backend.toolAvailabilityEnforcement === "execution-args" && backend.resolveExecutionArgs) ||
(backend.toolAvailabilityEnforcement === "prepare-execution" && backend.prepareExecution))
) {
return { native: [], openClaw: [SYSTEM_AGENT_TOOL_NAME] };
}
const backendId = backend?.id ?? "unknown";
throw new Error(`CLI backend ${backendId} cannot enforce OpenClaw's exact tool availability`);
@@ -136,6 +136,7 @@ export async function createSystemAgentVerifiedInferenceTestFixture(
bundleMcpMode: backend.bundleMcpMode,
authEpochMode: backend.authEpochMode,
nativeToolMode: backend.nativeToolMode,
toolAvailabilityEnforcement: backend.toolAvailabilityEnforcement,
sideQuestionToolMode: backend.sideQuestionToolMode,
},
...(profileId ? { authProfileId: profileId } : {}),