From afc5284c7f92f6d3da6665e5a9a4ad4e130591f1 Mon Sep 17 00:00:00 2001 From: mariuszs Date: Tue, 6 Oct 2026 20:39:20 +0200 Subject: [PATCH 1/2] Model the permission modes CLI 2.1.291 accepts claude --help (2.1.291) lists the --permission-mode choices acceptEdits, auto, bypassPermissions, manual, dontAsk and plan. PermissionMode had only DEFAULT, ACCEPT_EDITS and BYPASS_PERMISSIONS, plus DANGEROUSLY_SKIP_PERMISSIONS, which is a separate flag. Add AUTO, DONT_ASK, PLAN and MANUAL after the existing constants, so the existing ordinals stay the same. DEFAULT stays: the CLI no longer lists "default" but still accepts it, and reports a "manual" session as "default" in its init message. On a model that does not support "auto" (Haiku), the CLI silently falls back to "default" instead of failing; the AUTO javadoc says so. CLIFlagParityIT now reads the --permission-mode choices from --help. It gates on every SDK mode being one of them, with DEFAULT recorded as accepted but unlisted, and it warns about a choice that has no constant. Like the unmodelled-flag check, that is a warning rather than a gate, because the mode stays reachable: extraArgs is emitted after the SDK's own --permission-mode, and the CLI applies the last one. Against the old enum it reports auto, dontAsk, manual and plan. --- .../agent/sdk/config/PermissionMode.java | 44 ++++++++++- .../agent/sdk/transport/CLIFlagParityIT.java | 78 +++++++++++++++++++ .../sdk/transport/CLIFlagParityTest.java | 16 ++++ 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/config/PermissionMode.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/config/PermissionMode.java index e4084d51..75ee1874 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/config/PermissionMode.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/config/PermissionMode.java @@ -19,11 +19,22 @@ /** * Permission modes for Claude Code tool usage. Corresponds to PermissionMode in Python * SDK. + * + *

+ * Every constant except {@link #DANGEROUSLY_SKIP_PERMISSIONS} is passed to the CLI as + * {@code --permission-mode }. {@code CLIFlagParityIT} checks them against the + * choices {@code claude --help} lists. + *

*/ public enum PermissionMode { /** * Default permission mode - prompt for tool usage permissions. + * + *

+ * CLI 2.1.291 no longer lists {@code default} among the {@code --permission-mode} + * choices but still accepts it. {@link #MANUAL} is its new name. + *

*/ DEFAULT("default"), @@ -39,9 +50,38 @@ public enum PermissionMode { /** * Dangerously skip all permission checks. Recommended only for sandboxes with no - * internet access. + * internet access. Sent as {@code --dangerously-skip-permissions}, not as a + * {@code --permission-mode} value. + */ + DANGEROUSLY_SKIP_PERMISSIONS("dangerously-skip-permissions"), + + /** + * Prompt for tool usage permissions; the name newer CLIs list for {@link #DEFAULT}. + * The session's init message reports it as {@code default}. + */ + MANUAL("manual"), + + /** + * Let the CLI decide tool permissions automatically. + * + *

+ * Not every model supports it. On one that does not, the CLI silently falls back to + * {@code default}: the session's init message then reports + * {@code permissionMode: "default"} rather than failing (observed with Haiku on CLI + * 2.1.291). + *

+ */ + AUTO("auto"), + + /** + * Deny any tool use that is not pre-approved, instead of prompting. + */ + DONT_ASK("dontAsk"), + + /** + * Plan mode - Claude can analyze but not modify files or run commands. */ - DANGEROUSLY_SKIP_PERMISSIONS("dangerously-skip-permissions"); + PLAN("plan"); private final String value; diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityIT.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityIT.java index d4839e7b..678ae69e 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityIT.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityIT.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import io.github.markpollack.claude.agent.sdk.config.PermissionMode; import io.github.markpollack.claude.agent.sdk.test.ClaudeCliTestBase; import java.io.BufferedReader; @@ -181,6 +182,15 @@ private static Set notMappedByDesign() { return all; } + /** + * {@link PermissionMode} values the CLI accepts but no longer lists among the + * {@code --permission-mode} choices, each with what was observed. + */ + private static final java.util.Map UNLISTED_PERMISSION_MODES = java.util.Map.of( + PermissionMode.DEFAULT, + "Dropped from the --help choices in favour of 'manual' by CLI 2.1.291, which still accepts " + + "'--permission-mode default' and reports 'manual' sessions as 'default'."); + /** * Mapping from CLI flag names to CLIOptions builder method names. Only needed when * names don't match directly. @@ -350,6 +360,74 @@ void criticalSdkFlagsShouldBeInCli() { } } + /** + * A gate, like {@link #criticalSdkFlagsShouldBeInCli()}. Every + * {@link PermissionMode} the SDK passes as {@code --permission-mode } must be + * a choice the CLI lists, unless it is recorded in + * {@link #UNLISTED_PERMISSION_MODES}. A value the CLI rejects fails every session + * started with it. + */ + @Test + @DisplayName("SDK permission modes are CLI --permission-mode choices") + void sdkPermissionModesShouldBeCliChoices() { + Set choices = permissionModeChoices(); + + for (PermissionMode mode : PermissionMode.values()) { + if (mode == PermissionMode.DANGEROUSLY_SKIP_PERMISSIONS || UNLISTED_PERMISSION_MODES.containsKey(mode)) { + continue; // a separate flag, or accepted though not listed + } + assertThat(choices).as("CLI should accept --permission-mode " + mode.getValue()).contains(mode.getValue()); + } + } + + /** + * Reports {@code --permission-mode} choices with no {@link PermissionMode} constant. + * + *

+ * A warning, not a gate, for the reason given in the class javadoc. Such a mode is + * still reachable: {@code CLIOptions.extraArgs} is emitted after the SDK's own + * {@code --permission-mode}, and the CLI keeps the last one. + *

+ */ + @Test + @DisplayName("CLI --permission-mode choices without an SDK constant are reported (warning, not a gate)") + void cliPermissionModeChoicesShouldHaveSdkConstants() { + Set modelled = java.util.Arrays.stream(PermissionMode.values()) + .map(PermissionMode::getValue) + .collect(Collectors.toSet()); + Set missing = new java.util.TreeSet<>(permissionModeChoices()); + missing.removeAll(modelled); + + if (!missing.isEmpty()) { + System.out.println("=== WARNING: --permission-mode choices with no PermissionMode constant ==="); + missing.forEach(choice -> System.out.println(" " + choice)); + System.out.println("These are reachable today via CLIOptions.extraArgs (\"permission-mode\"), which " + + "the CLI applies over the SDK's own --permission-mode. Add a PermissionMode constant."); + } + } + + @Test + @DisplayName("CLI help lists --permission-mode choices") + void permissionModeChoicesShouldBeParseable() { + assertThat(permissionModeChoices()).contains("acceptEdits", "bypassPermissions"); + } + + /** + * The {@code (choices: ...)} list of {@code --permission-mode} in {@code --help}, + * which wraps across lines. + */ + private static Set permissionModeChoices() { + Matcher option = Pattern.compile("--permission-mode\\s+<[^>]+>[^(]*\\(choices:([^)]*)\\)") + .matcher(cliHelpOutput); + assertThat(option.find()).as("--permission-mode should list its choices in claude --help").isTrue(); + Set choices = new HashSet<>(); + Matcher quoted = Pattern.compile("\"([^\"]+)\"").matcher(option.group(1)); + while (quoted.find()) { + choices.add(quoted.group(1)); + } + return choices; + } + @Test @DisplayName("Report CLI flags found for documentation") void reportCliFlagsFound() { diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java index 9504a1bc..835e1ba1 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java @@ -20,6 +20,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import io.github.markpollack.claude.agent.sdk.config.PermissionMode; import io.github.markpollack.claude.agent.sdk.config.PluginConfig; import io.github.markpollack.claude.agent.sdk.mcp.McpServerConfig; @@ -221,6 +223,20 @@ void permissionModeBypass() { } } + @ParameterizedTest(name = "--permission-mode {0}") + @EnumSource(value = PermissionMode.class, names = "DANGEROUSLY_SKIP_PERMISSIONS", + mode = EnumSource.Mode.EXCLUDE) + @DisplayName("every --permission-mode value is emitted once, as the CLI spells it") + void permissionModeValues(PermissionMode mode) { + try (StreamingTransport transport = createTransport()) { + CLIOptions options = CLIOptions.builder().permissionMode(mode).build(); + List cmd = transport.buildStreamingCommand(options); + assertThat(cmd).containsSubsequence("--permission-mode", mode.getValue()); + assertThat(cmd.stream().filter("--permission-mode"::equals)).hasSize(1); + assertThat(PermissionMode.fromValue(mode.getValue())).isSameAs(mode); + } + } + @Test @DisplayName("--dangerously-skip-permissions flag") void dangerouslySkipPermissions() { From 2b1761e5c3a4c9e49fc5b8e6304658095d4be4b0 Mon Sep 17 00:00:00 2001 From: mariuszs Date: Tue, 6 Oct 2026 21:31:00 +0200 Subject: [PATCH 2/2] Check permission modes against what the CLI accepts The gate compared PermissionMode values with the choices claude --help lists, so it could not cover DEFAULT, which the SDK sends by default and the CLI still accepts without listing it, and it would fail on any other mode the CLI hides but keeps accepting. It now runs claude --permission-mode --version for every value, which the CLI validates while parsing arguments, without starting a session. PermissionMode.isPermissionModeValue() replaces the three separate special cases for DANGEROUSLY_SKIP_PERMISSIONS. The help choices are parsed once, and the parse stops at the next option. --- .../agent/sdk/config/PermissionMode.java | 14 +++- .../sdk/transport/StreamingTransport.java | 11 +-- .../agent/sdk/transport/CLIFlagParityIT.java | 74 ++++++++++++------- .../sdk/transport/CLIFlagParityTest.java | 15 +++- 4 files changed, 72 insertions(+), 42 deletions(-) diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/config/PermissionMode.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/config/PermissionMode.java index 75ee1874..4ccb8ad0 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/config/PermissionMode.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/config/PermissionMode.java @@ -21,9 +21,9 @@ * SDK. * *

- * Every constant except {@link #DANGEROUSLY_SKIP_PERMISSIONS} is passed to the CLI as - * {@code --permission-mode }. {@code CLIFlagParityIT} checks them against the - * choices {@code claude --help} lists. + * Every constant for which {@link #isPermissionModeValue()} holds is passed to the CLI as + * {@code --permission-mode }. {@code CLIFlagParityIT} checks that the CLI accepts + * each of them. *

*/ public enum PermissionMode { @@ -93,6 +93,14 @@ public String getValue() { return value; } + /** + * Whether this mode is passed as {@code --permission-mode }. Only + * {@link #DANGEROUSLY_SKIP_PERMISSIONS} is not: it is a flag of its own. + */ + public boolean isPermissionModeValue() { + return this != DANGEROUSLY_SKIP_PERMISSIONS; + } + /** * Creates PermissionMode from string value. */ diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/transport/StreamingTransport.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/transport/StreamingTransport.java index 24195d98..b8f402b7 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/transport/StreamingTransport.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/transport/StreamingTransport.java @@ -21,7 +21,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.github.markpollack.claude.agent.sdk.config.ClaudeCliDiscovery; -import io.github.markpollack.claude.agent.sdk.config.PermissionMode; import io.github.markpollack.claude.agent.sdk.exceptions.ClaudeSDKException; import io.github.markpollack.claude.agent.sdk.exceptions.SessionClosedException; import io.github.markpollack.claude.agent.sdk.exceptions.TransportException; @@ -463,15 +462,13 @@ List buildStreamingCommand(CLIOptions options) { } if (options.getPermissionMode() != null) { - if (options.getPermissionMode() == PermissionMode.DANGEROUSLY_SKIP_PERMISSIONS) { - // DANGEROUSLY_SKIP_PERMISSIONS uses a separate flag, not - // --permission-mode - command.add("--dangerously-skip-permissions"); - } - else { + if (options.getPermissionMode().isPermissionModeValue()) { command.add("--permission-mode"); command.add(options.getPermissionMode().getValue()); } + else { + command.add("--dangerously-skip-permissions"); + } } // Session resume options diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityIT.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityIT.java index 678ae69e..cf0a3014 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityIT.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityIT.java @@ -74,6 +74,15 @@ * reported as a warning for deliberate triage, and every flag remains reachable today * through {@code CLIOptions.extraArgs} regardless. *

+ * + *

+ * {@link #sdkPermissionModesShouldBeAcceptedByCli()} is a gate too, for + * the same reason: it asks the CLI whether it accepts each {@code --permission-mode} + * value the SDK sends, by validating it alongside {@code --version}. It deliberately does + * not compare against the choices {@code --help} lists, because the CLI accepts values it + * no longer lists ({@code default} since 2.1.291). Choices with no SDK constant are only + * reported, by {@link #cliPermissionModeChoicesShouldHaveSdkConstants()}. + *

*/ @DisplayName("CLI Flag Parity IT") class CLIFlagParityIT extends ClaudeCliTestBase { @@ -82,6 +91,8 @@ class CLIFlagParityIT extends ClaudeCliTestBase { private static String cliHelpOutput; + private static Set permissionModeChoices; + /** * Flags the SDK has permanently declined to model as builder methods, * each with the reason it was declined. @@ -182,15 +193,6 @@ private static Set notMappedByDesign() { return all; } - /** - * {@link PermissionMode} values the CLI accepts but no longer lists among the - * {@code --permission-mode} choices, each with what was observed. - */ - private static final java.util.Map UNLISTED_PERMISSION_MODES = java.util.Map.of( - PermissionMode.DEFAULT, - "Dropped from the --help choices in favour of 'manual' by CLI 2.1.291, which still accepts " - + "'--permission-mode default' and reports 'manual' sessions as 'default'."); - /** * Mapping from CLI flag names to CLIOptions builder method names. Only needed when * names don't match directly. @@ -231,6 +233,7 @@ static void extractCliFlagsFromHelp() throws Exception { assertThat(exitCode).as("claude --help should succeed").isZero(); cliFlags = parseFlags(cliHelpOutput); + permissionModeChoices = parsePermissionModeChoices(cliHelpOutput); } /** @@ -361,22 +364,33 @@ void criticalSdkFlagsShouldBeInCli() { } /** - * A gate, like {@link #criticalSdkFlagsShouldBeInCli()}. Every - * {@link PermissionMode} the SDK passes as {@code --permission-mode } must be - * a choice the CLI lists, unless it is recorded in - * {@link #UNLISTED_PERMISSION_MODES}. A value the CLI rejects fails every session - * started with it. + * A gate, like {@link #criticalSdkFlagsShouldBeInCli()}. The CLI + * must accept every {@code --permission-mode} value the SDK can send, including + * {@link PermissionMode#DEFAULT}, which {@code --help} no longer lists. A value the + * CLI rejects fails every session started with it. + * + *

+ * The CLI validates the value while parsing arguments, before acting on + * {@code --version}, so no session is started and no credentials are needed. + *

*/ @Test - @DisplayName("SDK permission modes are CLI --permission-mode choices") - void sdkPermissionModesShouldBeCliChoices() { - Set choices = permissionModeChoices(); - + @DisplayName("CLI accepts every SDK --permission-mode value") + void sdkPermissionModesShouldBeAcceptedByCli() throws Exception { for (PermissionMode mode : PermissionMode.values()) { - if (mode == PermissionMode.DANGEROUSLY_SKIP_PERMISSIONS || UNLISTED_PERMISSION_MODES.containsKey(mode)) { - continue; // a separate flag, or accepted though not listed + if (!mode.isPermissionModeValue()) { + continue; + } + ProcessBuilder pb = new ProcessBuilder("claude", "--permission-mode", mode.getValue(), "--version"); + pb.redirectErrorStream(true); + Process process = pb.start(); + String output; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + output = reader.lines().collect(Collectors.joining("\n")); } - assertThat(choices).as("CLI should accept --permission-mode " + mode.getValue()).contains(mode.getValue()); + assertThat(process.waitFor()) + .as("CLI should accept --permission-mode " + mode.getValue() + ", but printed: " + output) + .isZero(); } } @@ -395,7 +409,7 @@ void cliPermissionModeChoicesShouldHaveSdkConstants() { Set modelled = java.util.Arrays.stream(PermissionMode.values()) .map(PermissionMode::getValue) .collect(Collectors.toSet()); - Set missing = new java.util.TreeSet<>(permissionModeChoices()); + Set missing = new java.util.TreeSet<>(permissionModeChoices); missing.removeAll(modelled); if (!missing.isEmpty()) { @@ -409,18 +423,22 @@ void cliPermissionModeChoicesShouldHaveSdkConstants() { @Test @DisplayName("CLI help lists --permission-mode choices") void permissionModeChoicesShouldBeParseable() { - assertThat(permissionModeChoices()).contains("acceptEdits", "bypassPermissions"); + assertThat(permissionModeChoices).as("--permission-mode should list its choices in claude --help") + .contains("acceptEdits", "bypassPermissions"); } /** * The {@code (choices: ...)} list of {@code --permission-mode} in {@code --help}, - * which wraps across lines. + * which wraps across lines. The search stops at the next option, so it never reads + * another option's choices; empty if the list is missing. */ - private static Set permissionModeChoices() { - Matcher option = Pattern.compile("--permission-mode\\s+<[^>]+>[^(]*\\(choices:([^)]*)\\)") - .matcher(cliHelpOutput); - assertThat(option.find()).as("--permission-mode should list its choices in claude --help").isTrue(); + private static Set parsePermissionModeChoices(String helpOutput) { + Matcher option = Pattern.compile("--permission-mode\\s+<[^>]+>(?:(?!\\n\\s*-)[\\s\\S])*?\\(choices:([^)]*)\\)") + .matcher(helpOutput); Set choices = new HashSet<>(); + if (!option.find()) { + return choices; + } Matcher quoted = Pattern.compile("\"([^\"]+)\"").matcher(option.group(1)); while (quoted.find()) { choices.add(quoted.group(1)); diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java index 835e1ba1..5b61db58 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java @@ -21,7 +21,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import io.github.markpollack.claude.agent.sdk.config.PermissionMode; import io.github.markpollack.claude.agent.sdk.config.PluginConfig; import io.github.markpollack.claude.agent.sdk.mcp.McpServerConfig; @@ -31,6 +31,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -223,10 +224,16 @@ void permissionModeBypass() { } } + static Stream permissionModeValues() { + return Stream.of(PermissionMode.values()).filter(PermissionMode::isPermissionModeValue); + } + + /** + * Whether the CLI accepts each value is checked by {@code CLIFlagParityIT}. + */ @ParameterizedTest(name = "--permission-mode {0}") - @EnumSource(value = PermissionMode.class, names = "DANGEROUSLY_SKIP_PERMISSIONS", - mode = EnumSource.Mode.EXCLUDE) - @DisplayName("every --permission-mode value is emitted once, as the CLI spells it") + @MethodSource("permissionModeValues") + @DisplayName("every --permission-mode value is emitted once and parses back to its constant") void permissionModeValues(PermissionMode mode) { try (StreamingTransport transport = createTransport()) { CLIOptions options = CLIOptions.builder().permissionMode(mode).build();