Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,22 @@
/**
* Permission modes for Claude Code tool usage. Corresponds to PermissionMode in Python
* SDK.
*
* <p>
* Every constant for which {@link #isPermissionModeValue()} holds is passed to the CLI as
* {@code --permission-mode <value>}. {@code CLIFlagParityIT} checks that the CLI accepts
* each of them.
* </p>
*/
public enum PermissionMode {

/**
* Default permission mode - prompt for tool usage permissions.
*
* <p>
* 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.
* </p>
*/
DEFAULT("default"),

Expand All @@ -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.
*
* <p>
* 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).
* </p>
*/
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;

Expand All @@ -53,6 +93,14 @@ public String getValue() {
return value;
}

/**
* Whether this mode is passed as {@code --permission-mode <value>}. 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -463,15 +462,13 @@ List<String> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -73,6 +74,15 @@
* reported as a warning for deliberate triage, and every flag remains reachable today
* through {@code CLIOptions.extraArgs} regardless.
* </p>
*
* <p>
* <strong>{@link #sdkPermissionModesShouldBeAcceptedByCli()} is a gate too</strong>, 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()}.
* </p>
*/
@DisplayName("CLI Flag Parity IT")
class CLIFlagParityIT extends ClaudeCliTestBase {
Expand All @@ -81,6 +91,8 @@ class CLIFlagParityIT extends ClaudeCliTestBase {

private static String cliHelpOutput;

private static Set<String> permissionModeChoices;

/**
* Flags the SDK has <strong>permanently declined</strong> to model as builder methods,
* each with the reason it was declined.
Expand Down Expand Up @@ -221,6 +233,7 @@ static void extractCliFlagsFromHelp() throws Exception {
assertThat(exitCode).as("claude --help should succeed").isZero();

cliFlags = parseFlags(cliHelpOutput);
permissionModeChoices = parsePermissionModeChoices(cliHelpOutput);
}

/**
Expand Down Expand Up @@ -350,6 +363,89 @@ void criticalSdkFlagsShouldBeInCli() {
}
}

/**
* <strong>A gate, like {@link #criticalSdkFlagsShouldBeInCli()}.</strong> 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.
*
* <p>
* The CLI validates the value while parsing arguments, before acting on
* {@code --version}, so no session is started and no credentials are needed.
* </p>
*/
@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.
*
* <p>
* 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.
* </p>
*/
@Test
@DisplayName("CLI --permission-mode choices without an SDK constant are reported (warning, not a gate)")
void cliPermissionModeChoicesShouldHaveSdkConstants() {
Set<String> modelled = java.util.Arrays.stream(PermissionMode.values())
.map(PermissionMode::getValue)
.collect(Collectors.toSet());
Set<String> 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<String> parsePermissionModeChoices(String helpOutput) {
Matcher option = Pattern.compile("--permission-mode\\s+<[^>]+>(?:(?!\\n\\s*-)[\\s\\S])*?\\(choices:([^)]*)\\)")
.matcher(helpOutput);
Set<String> 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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -221,6 +224,26 @@ void permissionModeBypass() {
}
}

static Stream<PermissionMode> 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<String> 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() {
Expand Down