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..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 @@ -19,11 +19,22 @@ /** * Permission modes for Claude Code tool usage. Corresponds to PermissionMode in Python * SDK. + * + *

+ * 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 { /** * 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"); + 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. + */ + PLAN("plan"); private final String value; @@ -53,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 d4839e7b..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 @@ -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; @@ -73,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 { @@ -81,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. @@ -221,6 +233,7 @@ static void extractCliFlagsFromHelp() throws Exception { assertThat(exitCode).as("claude --help should succeed").isZero(); cliFlags = parseFlags(cliHelpOutput); + permissionModeChoices = parsePermissionModeChoices(cliHelpOutput); } /** @@ -350,6 +363,89 @@ void criticalSdkFlagsShouldBeInCli() { } } + /** + * 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("CLI accepts every SDK --permission-mode value") + void sdkPermissionModesShouldBeAcceptedByCli() throws Exception { + for (PermissionMode mode : PermissionMode.values()) { + 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(process.waitFor()) + .as("CLI should accept --permission-mode " + mode.getValue() + ", but printed: " + output) + .isZero(); + } + } + + /** + * 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).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. The search stops at the next option, so it never reads + * another option's choices; empty if the list is missing. + */ + 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)); + } + 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..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 @@ -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.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; @@ -29,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; @@ -221,6 +224,26 @@ 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}") + @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(); + 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() {