fix: align claude cli permissions with exec policy

Derive Claude CLI bypass mode from OpenClaw exec YOLO policy, preserve raw Claude permission-mode overrides, update docs/changelog, and cover global/per-agent policy behavior.
This commit is contained in:
Peter Steinberger
2026-04-23 23:11:34 +01:00
committed by GitHub
parent 999caf530b
commit f523bbfcd1
19 changed files with 352 additions and 56 deletions
+172 -27
View File
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import type { CliBackendAuthEpochMode, CliBundleMcpMode } from "../plugins/types.js";
import type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBundleMcpMode,
} from "../plugins/types.js";
import {
__testing as cliBackendsTesting,
resolveCliBackendConfig,
@@ -27,7 +31,10 @@ function createBackendEntry(params: {
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
prepareExecution?: () => Promise<null>;
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
normalizeConfig?: (
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
) => CliBackendConfig;
}) {
return {
pluginId: params.pluginId,
@@ -111,12 +118,33 @@ const NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS = [
"user",
];
function normalizeTestClaudeArgs(args?: string[]): string[] | undefined {
function isTestYoloConfig(context?: CliBackendNormalizeConfigContext): boolean {
const agentExec = context?.agentId
? context.config?.agents?.list?.find((agent) => agent.id === context.agentId)?.tools?.exec
: undefined;
const exec = agentExec ?? context?.config?.tools?.exec;
return (exec?.security ?? "full") === "full" && (exec?.ask ?? "off") === "off";
}
function normalizeTestPermissionMode(context?: CliBackendNormalizeConfigContext): {
mode?: string;
overrideExisting: boolean;
} {
return isTestYoloConfig(context)
? { mode: "bypassPermissions", overrideExisting: false }
: { overrideExisting: false };
}
function normalizeTestClaudeArgs(
args: string[] | undefined,
permission: { mode?: string; overrideExisting: boolean },
): string[] | undefined {
if (!args) {
return args;
return permission.mode ? ["--permission-mode", permission.mode] : args;
}
const normalized: string[] = [];
let hasSettingSources = false;
let hasPermissionMode = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--dangerously-skip-permissions") {
@@ -139,7 +167,10 @@ function normalizeTestClaudeArgs(args?: string[]): string[] | undefined {
if (arg === "--permission-mode") {
const maybeValue = args[i + 1];
if (maybeValue && !maybeValue.startsWith("-")) {
normalized.push(arg, maybeValue);
hasPermissionMode = true;
if (!permission.overrideExisting) {
normalized.push(arg, maybeValue);
}
i += 1;
}
continue;
@@ -147,7 +178,10 @@ function normalizeTestClaudeArgs(args?: string[]): string[] | undefined {
if (arg.startsWith("--permission-mode=")) {
const maybeValue = arg.slice("--permission-mode=".length).trim();
if (maybeValue.length > 0 && !maybeValue.startsWith("-")) {
normalized.push(`--permission-mode=${maybeValue}`);
hasPermissionMode = true;
if (!permission.overrideExisting) {
normalized.push(`--permission-mode=${maybeValue}`);
}
}
continue;
}
@@ -156,14 +190,21 @@ function normalizeTestClaudeArgs(args?: string[]): string[] | undefined {
if (!hasSettingSources) {
normalized.push("--setting-sources", "user");
}
if (permission.mode && (!hasPermissionMode || permission.overrideExisting)) {
normalized.push("--permission-mode", permission.mode);
}
return normalized;
}
function normalizeTestClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
function normalizeTestClaudeBackendConfig(
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
): CliBackendConfig {
const permission = normalizeTestPermissionMode(context);
return {
...config,
args: normalizeTestClaudeArgs(config.args),
resumeArgs: normalizeTestClaudeArgs(config.resumeArgs),
args: normalizeTestClaudeArgs(config.args, permission),
resumeArgs: normalizeTestClaudeArgs(config.resumeArgs, permission),
};
}
@@ -186,6 +227,8 @@ beforeEach(() => {
"--verbose",
"--setting-sources",
"user",
"--allowedTools",
"mcp__openclaw__*",
],
resumeArgs: [
"stream-json",
@@ -193,11 +236,15 @@ beforeEach(() => {
"--verbose",
"--setting-sources",
"user",
"--allowedTools",
"mcp__openclaw__*",
"--resume",
"{sessionId}",
],
output: "jsonl",
input: "stdin",
imageArg: "@",
imagePathScope: "workspace",
clearEnv: [
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEY_OLD",
@@ -410,7 +457,7 @@ describe("resolveCliBackendLiveTest", () => {
});
describe("resolveCliBackendConfig claude-cli defaults", () => {
it("keeps user-only setting sources without forcing a permission-mode default", () => {
it("derives bypassPermissions from OpenClaw's default YOLO exec policy", () => {
const resolved = resolveCliBackendConfig("claude-cli");
expect(resolved).not.toBeNull();
@@ -422,18 +469,102 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.args).toContain("--verbose");
expect(resolved?.config.args).toContain("--setting-sources");
expect(resolved?.config.args).toContain("user");
expect(resolved?.config.args).not.toContain("--permission-mode");
expect(resolved?.config.args).toContain("--allowedTools");
expect(resolved?.config.args).toContain("mcp__openclaw__*");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.input).toBe("stdin");
expect(resolved?.config.imageArg).toBe("@");
expect(resolved?.config.imagePathScope).toBe("workspace");
expect(resolved?.config.resumeArgs).toContain("stream-json");
expect(resolved?.config.resumeArgs).toContain("--include-partial-messages");
expect(resolved?.config.resumeArgs).toContain("--verbose");
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
expect(resolved?.config.resumeArgs).toContain("user");
expect(resolved?.config.resumeArgs).not.toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("--allowedTools");
expect(resolved?.config.resumeArgs).toContain("mcp__openclaw__*");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
});
it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => {
const resolved = resolveCliBackendConfig("claude-cli", {
tools: { exec: { security: "allowlist", ask: "on-miss" } },
});
expect(resolved).not.toBeNull();
expect(resolved?.config.args).not.toContain("--permission-mode");
expect(resolved?.config.args).not.toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("--permission-mode");
expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions");
});
it("derives Claude permission mode from per-agent exec policy when an agent id is known", () => {
const cfg = {
tools: { exec: { security: "full", ask: "off" } },
agents: {
list: [
{
id: "reviewer",
tools: { exec: { security: "allowlist", ask: "on-miss" } },
},
{
id: "builder",
tools: { exec: { security: "full", ask: "off" } },
},
],
},
} satisfies OpenClawConfig;
const reviewer = resolveCliBackendConfig("claude-cli", cfg, { agentId: "reviewer" });
const builder = resolveCliBackendConfig("claude-cli", cfg, { agentId: "builder" });
expect(reviewer?.config.args).not.toContain("--permission-mode");
expect(reviewer?.config.resumeArgs).not.toContain("--permission-mode");
expect(builder?.config.args).toContain("--permission-mode");
expect(builder?.config.args).toContain("bypassPermissions");
expect(builder?.config.resumeArgs).toContain("--permission-mode");
expect(builder?.config.resumeArgs).toContain("bypassPermissions");
});
it("uses existing exec policy and raw Claude args as permission overrides", () => {
const safe = resolveCliBackendConfig("claude-cli", {
tools: { exec: { security: "full", ask: "off" } },
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--permission-mode", "default"],
resumeArgs: ["-p", "--permission-mode=default", "--resume", "{sessionId}"],
},
},
},
},
});
const yolo = resolveCliBackendConfig("claude-cli", {
tools: { exec: { security: "deny", ask: "always" } },
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--permission-mode", "bypassPermissions"],
resumeArgs: ["-p", "--permission-mode=bypassPermissions", "--resume", "{sessionId}"],
},
},
},
},
});
expect(safe?.config.args).toContain("default");
expect(safe?.config.args).not.toContain("bypassPermissions");
expect(yolo?.config.args).toContain("--permission-mode");
expect(yolo?.config.args).toContain("bypassPermissions");
});
it("retains default claude safety args when only command is overridden", () => {
const cfg = {
agents: {
@@ -453,10 +584,12 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.command).toBe("/usr/local/bin/claude");
expect(resolved?.config.args).toContain("--setting-sources");
expect(resolved?.config.args).toContain("user");
expect(resolved?.config.args).not.toContain("--permission-mode");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
expect(resolved?.config.resumeArgs).toContain("user");
expect(resolved?.config.resumeArgs).not.toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
expect(resolved?.config.env).not.toHaveProperty("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST");
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
@@ -470,7 +603,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_USE_COWORK_PLUGINS");
});
it("drops legacy skip-permissions overrides without inventing bypassPermissions", () => {
it("drops legacy skip-permissions overrides without inventing bypassPermissions under safe policy", () => {
const cfg = {
agents: {
defaults: {
@@ -490,6 +623,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
},
},
},
tools: { exec: { security: "allowlist", ask: "on-miss" } },
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
@@ -587,11 +721,14 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
});
it("falls back to user-only setting sources when a custom override leaves the flag without a value", () => {
const cfg = createClaudeCliOverrideConfig({
command: "claude",
args: ["-p", "--setting-sources", "--output-format", "stream-json"],
resumeArgs: ["-p", "--setting-sources", "--resume", "{sessionId}"],
});
const cfg = {
...createClaudeCliOverrideConfig({
command: "claude",
args: ["-p", "--setting-sources", "--output-format", "stream-json"],
resumeArgs: ["-p", "--setting-sources", "--resume", "{sessionId}"],
}),
tools: { exec: { security: "allowlist", ask: "on-miss" } },
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
@@ -600,12 +737,15 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS);
});
it("drops malformed permission-mode overrides without adding bypassPermissions", () => {
const cfg = createClaudeCliOverrideConfig({
command: "claude",
args: ["-p", "--permission-mode", "--output-format", "stream-json"],
resumeArgs: ["-p", "--permission-mode=--resume", "--resume", "{sessionId}"],
});
it("drops malformed permission-mode overrides without adding bypassPermissions under safe policy", () => {
const cfg = {
...createClaudeCliOverrideConfig({
command: "claude",
args: ["-p", "--permission-mode", "--output-format", "stream-json"],
resumeArgs: ["-p", "--permission-mode=--resume", "--resume", "{sessionId}"],
}),
tools: { exec: { security: "allowlist", ask: "on-miss" } },
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
@@ -614,7 +754,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS);
});
it("leaves permission-mode unset when custom args omit it", () => {
it("leaves permission-mode unset when custom args omit it under safe policy", () => {
const cfg = {
agents: {
defaults: {
@@ -634,6 +774,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
},
},
},
tools: { exec: { security: "allowlist", ask: "on-miss" } },
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
@@ -711,6 +852,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
"json",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(resolved?.config.resumeArgs).toEqual([
"-p",
@@ -720,6 +863,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
"{sessionId}",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(resolved?.config.systemPromptArg).toBe("--append-system-prompt");
expect(resolved?.config.systemPromptWhen).toBe("first");
+16 -4
View File
@@ -5,6 +5,7 @@ import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js";
import { resolveRuntimeTextTransforms } from "../plugins/text-transforms.runtime.js";
import type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBundleMcpMode,
CliBackendPlugin,
PluginTextTransforms,
@@ -50,7 +51,10 @@ type FallbackCliBackendPolicy = {
bundleMcp: boolean;
bundleMcpMode?: CliBundleMcpMode;
baseConfig?: CliBackendConfig;
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
normalizeConfig?: (
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
) => CliBackendConfig;
transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"];
textTransforms?: PluginTextTransforms;
defaultAuthProfileId?: string;
@@ -188,15 +192,23 @@ export function resolveCliBackendLiveTest(provider: string): ResolvedCliBackendL
export function resolveCliBackendConfig(
provider: string,
cfg?: OpenClawConfig,
options: { agentId?: string } = {},
): ResolvedCliBackend | null {
const normalized = normalizeBackendKey(provider);
const normalizeContext: CliBackendNormalizeConfigContext = {
backendId: normalized,
...(options.agentId ? { agentId: options.agentId } : {}),
...(cfg ? { config: cfg } : {}),
};
const runtimeTextTransforms = resolveRuntimeTextTransforms();
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
const override = pickBackendConfig(configured, normalized);
const registered = resolveRegisteredBackend(normalized);
if (registered) {
const merged = mergeBackendConfig(registered.config, override);
const config = registered.normalizeConfig ? registered.normalizeConfig(merged) : merged;
const config = registered.normalizeConfig
? registered.normalizeConfig(merged, normalizeContext)
: merged;
const command = config.command?.trim();
if (!command) {
return null;
@@ -224,7 +236,7 @@ export function resolveCliBackendConfig(
return null;
}
const baseConfig = fallbackPolicy.normalizeConfig
? fallbackPolicy.normalizeConfig(fallbackPolicy.baseConfig)
? fallbackPolicy.normalizeConfig(fallbackPolicy.baseConfig, normalizeContext)
: fallbackPolicy.baseConfig;
const command = baseConfig.command?.trim();
if (!command) {
@@ -249,7 +261,7 @@ export function resolveCliBackendConfig(
? mergeBackendConfig(fallbackPolicy.baseConfig, override)
: override;
const config = fallbackPolicy?.normalizeConfig
? fallbackPolicy.normalizeConfig(mergedFallback)
? fallbackPolicy.normalizeConfig(mergedFallback, normalizeContext)
: mergedFallback;
const command = config.command?.trim();
if (!command) {
+3 -1
View File
@@ -95,7 +95,9 @@ export async function prepareCliRunContext(
}
const workspaceDir = resolvedWorkspace;
const backendResolved = resolveCliBackendConfig(params.provider, params.config);
const backendResolved = resolveCliBackendConfig(params.provider, params.config, {
agentId: params.agentId,
});
if (!backendResolved) {
throw new Error(`Unknown CLI backend: ${params.provider}`);
}
+1 -1
View File
@@ -515,7 +515,7 @@ describe("buildAgentSystemPrompt", () => {
workspaceDir: "/tmp/openclaw",
modelAliasLines: [
"- Opus: anthropic/claude-opus-4-5",
"- Sonnet: anthropic/claude-sonnet-4-5",
"- Sonnet: anthropic/claude-sonnet-4-6",
],
});
+1
View File
@@ -1,6 +1,7 @@
export type { CliBackendConfig } from "../config/types.js";
export type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBackendPlugin,
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
+10 -1
View File
@@ -35,6 +35,12 @@ export type CliBackendPreparedExecution = {
export type CliBackendAuthEpochMode = "combined" | "profile-only";
export type CliBackendNormalizeConfigContext = {
config?: OpenClawConfig;
backendId: string;
agentId?: string;
};
/** Plugin-owned CLI backend defaults used by the text-only CLI runner. */
export type CliBackendPlugin = {
/** Provider id used in model refs, for example `claude-cli/opus`. */
@@ -78,7 +84,10 @@ export type CliBackendPlugin = {
* Use this for backend-specific compatibility rewrites when old config
* shapes need to stay working.
*/
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
normalizeConfig?: (
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
) => CliBackendConfig;
/**
* Backend-owned final system-prompt transform.
*
+2
View File
@@ -69,6 +69,7 @@ import type { VideoGenerationProvider } from "../video-generation/types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
CliBackendPlugin,
@@ -148,6 +149,7 @@ export type {
} from "./conversation-binding.types.js";
export type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
CliBackendPlugin,