diff --git a/CHANGELOG.md b/CHANGELOG.md index a187816c95d..6404071cf79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Docs: https://docs.openclaw.ai - Plugins/install: link the host OpenClaw package into external plugins that declare `openclaw` as a peer dependency, so peer-only plugin SDK imports resolve after install without bundling a duplicate host package. (#70462) Thanks @anishesg. - Teams/security: require shared Bot Framework audience tokens to name the configured Teams app via verified `appid` or `azp`, blocking cross-bot token replay on the global audience. (#70724) Thanks @vincentkoc. - Plugins/startup: resolve bundled plugin Jiti loads relative to the target plugin module instead of the central loader, so Bun global installs no longer hang while discovering bundled image providers. (#70073) Thanks @yidianyiko. -- Anthropic/CLI security: stop Claude CLI backend defaults from forcing `bypassPermissions`, and strip malformed permission-mode overrides instead of silently falling back to a bypass. (#70723) Thanks @vincentkoc. +- Anthropic/CLI security: derive Claude CLI `bypassPermissions` from OpenClaw's existing YOLO exec policy, preserve explicit raw Claude `--permission-mode` overrides, and strip malformed permission-mode args instead of silently falling back to a bypass. (#70723) Thanks @vincentkoc. - Android/security: require loopback-only cleartext gateway connections on Android manual and scanned routes, so private-LAN and link-local `ws://` endpoints now fail closed unless TLS is enabled. (#70722) Thanks @vincentkoc. - Pairing/security: require private-IP or loopback hosts for cleartext mobile pairing, and stop treating `.local` or dotless hostnames as safe cleartext endpoints. (#70721) Thanks @vincentkoc. - Plugins/security: stop setup-api lookup from falling back to the launch directory, so workspace-local `extensions//setup-api.*` files cannot be executed during provider setup resolution. (#70718) Thanks @drobison00. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 567b7a08ee1..535e21e9b1b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -bc55649a80027756f37892424598653a81fec4bff7b074358fe34d08c7696ebc plugin-sdk-api-baseline.json -312a29d50b4959e4a8e242bb7559548d895a2e03d5ed1b5a395b1133de090578 plugin-sdk-api-baseline.jsonl +f30c9e61b768ca10feca401aefca3cbc8d3a57c5020f85aa9106b4f1a61032c0 plugin-sdk-api-baseline.json +9e5e3e66ac23dddb80cceb8a785f167eec8a108c6c5abe77f3346b01895f6756 plugin-sdk-api-baseline.jsonl diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 9deb7127a8d..796f908fc4c 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -169,6 +169,15 @@ resolver sees the same filtered set that OpenClaw would otherwise advertise in the prompt. Skill env/API key overrides are still applied by OpenClaw to the child process environment for the run. +Claude CLI also has its own noninteractive permission mode. OpenClaw maps that +to the existing exec policy instead of adding Claude-specific config: when the +effective requested exec policy is YOLO (`tools.exec.security: "full"` and +`tools.exec.ask: "off"`), OpenClaw adds `--permission-mode bypassPermissions`. +Per-agent `agents.list[].tools.exec` settings override global `tools.exec` for +that agent. To force a different Claude mode, set explicit raw backend args +such as `--permission-mode default` or `--permission-mode acceptEdits` under +`agents.defaults.cliBackends.claude-cli.args` and matching `resumeArgs`. + Before OpenClaw can use the bundled `claude-cli` backend, Claude Code itself must already be logged in on the same host: diff --git a/docs/providers/cloudflare-ai-gateway.md b/docs/providers/cloudflare-ai-gateway.md index 4793a437198..f9768c1002a 100644 --- a/docs/providers/cloudflare-ai-gateway.md +++ b/docs/providers/cloudflare-ai-gateway.md @@ -12,7 +12,7 @@ Cloudflare AI Gateway sits in front of provider APIs and lets you add analytics, | ------------- | ---------------------------------------------------------------------------------------- | | Provider | `cloudflare-ai-gateway` | | Base URL | `https://gateway.ai.cloudflare.com/v1///anthropic` | -| Default model | `cloudflare-ai-gateway/claude-sonnet-4-5` | +| Default model | `cloudflare-ai-gateway/claude-sonnet-4-6` | | API key | `CLOUDFLARE_AI_GATEWAY_API_KEY` (your provider API key for requests through the Gateway) | @@ -39,7 +39,7 @@ For Anthropic models routed through Cloudflare AI Gateway, use your **Anthropic { agents: { defaults: { - model: { primary: "cloudflare-ai-gateway/claude-sonnet-4-5" }, + model: { primary: "cloudflare-ai-gateway/claude-sonnet-4-6" }, }, }, } diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 8bd9909f3e9..2a7c919d185 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -125,6 +125,11 @@ Important distinction: - `tools.exec.host=auto` chooses where exec runs: sandbox when available, otherwise gateway. - YOLO chooses how host exec is approved: `security=full` plus `ask=off`. +- CLI-backed providers that expose their own noninteractive permission mode can follow this policy. + Claude CLI adds `--permission-mode bypassPermissions` when OpenClaw's requested exec policy is + YOLO. Override that backend behavior with explicit Claude args under + `agents.defaults.cliBackends.claude-cli.args` / `resumeArgs`, for example + `--permission-mode default`, `acceptEdits`, or `bypassPermissions`. - In YOLO mode, OpenClaw does not add a separate heuristic command-obfuscation approval gate or script-preflight rejection layer on top of the configured host exec policy. - `auto` does not make gateway routing a free override from a sandboxed session. A per-call `host=node` request is allowed from `auto`, and `host=gateway` is only allowed from `auto` when no sandbox runtime is active. If you want a stable non-auto default, set `tools.exec.host` or use `/exec host=...` explicitly. diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index 26d0247e0e6..6f4b9eb7c13 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -36,6 +36,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { "--verbose", "--setting-sources", "user", + "--allowedTools", + "mcp__openclaw__*", ], resumeArgs: [ "-p", @@ -45,6 +47,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { "--verbose", "--setting-sources", "user", + "--allowedTools", + "mcp__openclaw__*", "--resume", "{sessionId}", ], @@ -53,6 +57,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { input: "stdin", modelArg: "--model", modelAliases: CLAUDE_CLI_MODEL_ALIASES, + imageArg: "@", + imagePathScope: "workspace", sessionArg: "--session-id", sessionMode: "always", sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS], diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index 57c6f14f185..4c4c577682d 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -5,6 +5,7 @@ import { normalizeClaudeBackendConfig, normalizeClaudePermissionArgs, normalizeClaudeSettingSourcesArgs, + resolveClaudePermissionMode, } from "./cli-shared.js"; describe("normalizeClaudePermissionArgs", () => { @@ -89,6 +90,8 @@ describe("normalizeClaudeBackendConfig", () => { "--verbose", "--setting-sources", "user", + "--permission-mode", + "bypassPermissions", ]); expect(normalized.resumeArgs).toEqual([ "-p", @@ -99,12 +102,67 @@ describe("normalizeClaudeBackendConfig", () => { "{sessionId}", "--setting-sources", "user", + "--permission-mode", + "bypassPermissions", ]); expect(normalized.output).toBe("jsonl"); expect(normalized.liveSession).toBe("claude-stdio"); expect(normalized.input).toBe("stdin"); }); + it("derives Claude bypass from OpenClaw YOLO policy and disables it for safer policy", () => { + expect(resolveClaudePermissionMode({ backendId: "claude-cli" })).toEqual({ + mode: "bypassPermissions", + overrideExisting: false, + }); + expect( + resolveClaudePermissionMode({ + backendId: "claude-cli", + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + }), + ).toEqual({ overrideExisting: false }); + }); + + it("derives Claude bypass from per-agent OpenClaw exec policy", () => { + expect( + resolveClaudePermissionMode({ + backendId: "claude-cli", + agentId: "safe-agent", + config: { + tools: { exec: { security: "full", ask: "off" } }, + agents: { + list: [ + { + id: "safe-agent", + tools: { exec: { security: "allowlist", ask: "on-miss" } }, + }, + ], + }, + }, + }), + ).toEqual({ overrideExisting: false }); + expect( + resolveClaudePermissionMode({ + backendId: "claude-cli", + agentId: "yolo-agent", + config: { + tools: { exec: { security: "allowlist", ask: "on-miss" } }, + agents: { + list: [ + { + id: "yolo-agent", + tools: { exec: { security: "full", ask: "off" } }, + }, + ], + }, + }, + }), + ).toEqual({ + mode: "bypassPermissions", + overrideExisting: false, + }); + }); + it("does not infer live stdio when explicit transport overrides are incompatible", () => { const normalized = normalizeClaudeBackendConfig({ command: "claude", @@ -131,8 +189,12 @@ describe("normalizeClaudeBackendConfig", () => { expect(normalized?.args).toContain("--setting-sources"); expect(normalized?.args).toContain("user"); + expect(normalized?.args).toContain("--permission-mode"); + expect(normalized?.args).toContain("bypassPermissions"); expect(normalized?.resumeArgs).toContain("--setting-sources"); expect(normalized?.resumeArgs).toContain("user"); + expect(normalized?.resumeArgs).toContain("--permission-mode"); + expect(normalized?.resumeArgs).toContain("bypassPermissions"); expect(normalized?.liveSession).toBe("claude-stdio"); }); diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index ef698e0f9e1..f73c2ccb142 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -1,4 +1,7 @@ -import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend"; +import type { + CliBackendConfig, + CliBackendNormalizeConfigContext, +} from "openclaw/plugin-sdk/cli-backend"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js"; export { @@ -58,16 +61,40 @@ const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions"; const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode"; const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources"; const CLAUDE_SAFE_SETTING_SOURCES = "user"; +const CLAUDE_BYPASS_PERMISSION_MODE = "bypassPermissions"; export function isClaudeCliProvider(providerId: string): boolean { return normalizeOptionalLowercaseString(providerId) === CLAUDE_CLI_BACKEND_ID; } -export function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined { +function isOpenClawRequestedYolo(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; + const security = exec?.security ?? "full"; + const ask = exec?.ask ?? "off"; + return security === "full" && ask === "off"; +} + +export function resolveClaudePermissionMode(context?: CliBackendNormalizeConfigContext): { + mode?: string; + overrideExisting: boolean; +} { + return isOpenClawRequestedYolo(context) + ? { mode: CLAUDE_BYPASS_PERMISSION_MODE, overrideExisting: false } + : { overrideExisting: false }; +} + +export function normalizeClaudePermissionArgs( + args?: string[], + options?: { mode?: string; overrideExisting?: boolean }, +): string[] | undefined { if (!args) { - return args; + return options?.mode ? [CLAUDE_PERMISSION_MODE_ARG, options.mode] : args; } const normalized: string[] = []; + let hasPermissionMode = false; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) { @@ -80,8 +107,11 @@ export function normalizeClaudePermissionArgs(args?: string[]): string[] | undef maybeValue.trim().length > 0 && !maybeValue.startsWith("-") ) { - normalized.push(arg); - normalized.push(maybeValue); + hasPermissionMode = true; + if (!options?.overrideExisting) { + normalized.push(arg); + normalized.push(maybeValue); + } i += 1; } continue; @@ -89,12 +119,18 @@ export function normalizeClaudePermissionArgs(args?: string[]): string[] | undef if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) { const maybeValue = arg.slice(`${CLAUDE_PERMISSION_MODE_ARG}=`.length).trim(); if (maybeValue.length > 0 && !maybeValue.startsWith("-")) { - normalized.push(`${CLAUDE_PERMISSION_MODE_ARG}=${maybeValue}`); + hasPermissionMode = true; + if (!options?.overrideExisting) { + normalized.push(`${CLAUDE_PERMISSION_MODE_ARG}=${maybeValue}`); + } } continue; } normalized.push(arg); } + if (options?.mode && (!hasPermissionMode || options.overrideExisting)) { + normalized.push(CLAUDE_PERMISSION_MODE_ARG, options.mode); + } return normalized; } @@ -132,13 +168,20 @@ export function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | u return normalized; } -export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig { +export function normalizeClaudeBackendConfig( + config: CliBackendConfig, + context?: CliBackendNormalizeConfigContext, +): CliBackendConfig { const output = config.output ?? "jsonl"; const input = config.input ?? "stdin"; + const permission = resolveClaudePermissionMode(context); return { ...config, - args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args)), - resumeArgs: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.resumeArgs)), + args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args), permission), + resumeArgs: normalizeClaudePermissionArgs( + normalizeClaudeSettingSourcesArgs(config.resumeArgs), + permission, + ), output, liveSession: config.liveSession ?? (output === "jsonl" && input === "stdin" ? "claude-stdio" : undefined), diff --git a/extensions/cloudflare-ai-gateway/models.ts b/extensions/cloudflare-ai-gateway/models.ts index 80efea10ecf..f49b57ff3ff 100644 --- a/extensions/cloudflare-ai-gateway/models.ts +++ b/extensions/cloudflare-ai-gateway/models.ts @@ -1,7 +1,7 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; export const CLOUDFLARE_AI_GATEWAY_PROVIDER_ID = "cloudflare-ai-gateway"; -export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID = "claude-sonnet-4-5"; +export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID = "claude-sonnet-4-6"; export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF = `${CLOUDFLARE_AI_GATEWAY_PROVIDER_ID}/${CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID}`; const CLOUDFLARE_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW = 200_000; @@ -22,7 +22,7 @@ export function buildCloudflareAiGatewayModelDefinition(params?: { const id = params?.id?.trim() || CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID; return { id, - name: params?.name ?? "Claude Sonnet 4.5", + name: params?.name ?? "Claude Sonnet 4.6", reasoning: params?.reasoning ?? true, input: params?.input ?? ["text", "image"], cost: CLOUDFLARE_AI_GATEWAY_DEFAULT_COST, diff --git a/extensions/qa-lab/src/model-catalog.runtime.test.ts b/extensions/qa-lab/src/model-catalog.runtime.test.ts index c812d36e1e5..5e8b19b01aa 100644 --- a/extensions/qa-lab/src/model-catalog.runtime.test.ts +++ b/extensions/qa-lab/src/model-catalog.runtime.test.ts @@ -6,8 +6,8 @@ describe("qa runner model catalog", () => { expect( selectQaRunnerModelOptions([ { - key: "anthropic/claude-sonnet-4-5", - name: "Claude Sonnet 4.5", + key: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6", input: "text", available: true, missing: false, @@ -27,6 +27,6 @@ describe("qa runner model catalog", () => { missing: false, }, ]).map((entry) => entry.key), - ).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-5"]); + ).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]); }); }); diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 0088a61a70b..cfede00062f 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -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; - 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"); diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index e6df69ad937..2d7880cf7dc 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -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) { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index a398bad9989..fc655959d90 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -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}`); } diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index a192ef83d27..37cba7aa853 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -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", ], }); diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index ee1ac1ba28f..f7d9d8afc3b 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -1,6 +1,7 @@ export type { CliBackendConfig } from "../config/types.js"; export type { CliBackendAuthEpochMode, + CliBackendNormalizeConfigContext, CliBackendPlugin, CliBackendPreparedExecution, CliBackendPrepareExecutionContext, diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index 9862a130a42..0ab793018f0 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -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. * diff --git a/src/plugins/types.ts b/src/plugins/types.ts index b1ebf91e9f5..47b9bcca476 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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, diff --git a/test/helpers/agents/prompt-composition-scenarios.ts b/test/helpers/agents/prompt-composition-scenarios.ts index cbcd4b95da1..ffcc44ae4f9 100644 --- a/test/helpers/agents/prompt-composition-scenarios.ts +++ b/test/helpers/agents/prompt-composition-scenarios.ts @@ -57,8 +57,8 @@ function buildCommonSystemParams(workspaceDir: string) { os: "Darwin 24.0.0", arch: "arm64", node: process.version, - model: "anthropic/claude-sonnet-4-5", - defaultModel: "anthropic/claude-sonnet-4-5", + model: "anthropic/claude-sonnet-4-6", + defaultModel: "anthropic/claude-sonnet-4-6", shell: "zsh", }, userTimezone: "America/Los_Angeles", diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index eb8e3e0fa85..791fa1c0473 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -636,7 +636,7 @@ export function describeCloudflareAiGatewayProviderDiscoveryContract( baseUrl: "https://gateway.ai.cloudflare.com/v1/acc-123/gw-456/anthropic", api: "anthropic-messages", apiKey: "CLOUDFLARE_AI_GATEWAY_API_KEY", - models: [expect.objectContaining({ id: "claude-sonnet-4-5" })], + models: [expect.objectContaining({ id: "claude-sonnet-4-6" })], }, }); });