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() {