Skip to content

Commit e2412e7

Browse files
authored
fix: allow permissionMode to override yolo in claude_code provider (#322)
* fix: allow permissionMode to override yolo in claude_code provider When running as root/sudo, Claude Code refuses --dangerously-skip-permissions. The yolo mode was unconditionally passing that flag, causing initialization to fail. This change lets profile.permissionMode take precedence over yolo, so users can configure 'auto' or 'bypassPermissions' to avoid the blocked flag. * fix: update test to match permissionMode-over-yolo behavior * fix(claude_code): only omit --dangerously-skip-permissions for yolo+root, not all yolo Previously the elif-yolo branch dropped --dangerously-skip-permissions for every yolo launch (allowedTools=["*"]), which broke non-root headless sessions by letting Claude prompt for tool approval inside tmux panes and silently block handoff/assign. Fix: add is_root guard so the flag is only omitted when running as root/sudo. Priority order: 1. profile.permissionMode set => claude --permission-mode <value> 2. yolo + is_root => claude (flag rejected by Claude under root) 3. everything else => claude --dangerously-skip-permissions Also adds three regression tests covering yolo/non-root, yolo/root, and yolo+permissionMode to address the Codecov patch coverage warning. Updates docs/claude-code.md and docs/agent-profile.md to reflect correct priority (permissionMode > yolo, root-only omission). Fixes: #322 * fix(claude_code): import os in claude_code.py to avoid NameError
1 parent a4708eb commit e2412e7

4 files changed

Lines changed: 82 additions & 8 deletions

File tree

docs/agent-profile.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Define the agent's role, responsibilities, and behavior here.
3232
- `toolAliases` (object): Map tool names to aliases
3333
- `toolsSettings` (object): Tool-specific configuration
3434
- `model` (string): AI model to use
35-
- `permissionMode` (string, `claude_code` only): One of `"default"`, `"acceptEdits"`, `"plan"`, `"auto"`, `"bypassPermissions"`. When set, the `claude_code` provider passes `--permission-mode <value>` instead of `--dangerously-skip-permissions`. `cao launch --yolo` overrides this and forces bypass. See [Claude Code permission modes](https://code.claude.com/docs/en/permission-modes).
35+
- `permissionMode` (string, `claude_code` only): One of `"default"`, `"acceptEdits"`, `"plan"`, `"auto"`, `"bypassPermissions"`. When set, the `claude_code` provider passes `--permission-mode <value>` instead of `--dangerously-skip-permissions`. `permissionMode` takes priority over `--yolo`; the provider always uses `--permission-mode <value>` when the field is set. See [Claude Code permission modes](https://code.claude.com/docs/en/permission-modes).
3636
- `native_agent` (string, `claude_code` only): Name of a native Claude Code agent (`~/.claude/agents/`). When set, the provider passes `--agent <name>` directly and skips system prompt / MCP config decomposition (thin-wrapper mode). See [Claude Code native agent routing](claude-code.md#native-agent-routing).
3737
- `codexProfile` (string, `codex` only): Names a `[profiles.<name>]` block in `~/.codex/config.toml`. When set, the provider drops `--yolo` and passes `--profile <name>` instead. See [Custom Codex Profile](codex-cli.md#custom-codex-profile).
3838
- `codexConfig` (object, `codex` only): Inline Codex config overrides passed as `-c key=value` at launch (e.g. `model_reasoning_effort`, `service_tier`, `features.fast_mode`). Keys may be dotted config paths; values become TOML scalars. See [Inline Codex Config Overrides](codex-cli.md#inline-codex-config-overrides).

docs/claude-code.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ By default, CAO launches Claude Code with `--dangerously-skip-permissions` to by
6767

6868
This is safe because CAO already confirms workspace trust during `cao launch` ("Do you trust all the actions in this folder?") or via `--yolo` flag. Without this flag, worker agents spawned via handoff/assign would block on the trust dialog with no way to accept it interactively.
6969

70-
Profiles can opt into a stricter behavior by setting the `permissionMode` field, which causes the provider to pass `--permission-mode <value>` instead of `--dangerously-skip-permissions`. See [Permission Mode Override](#permission-mode-override) below. `cao launch --yolo` always forces `--dangerously-skip-permissions` regardless of the profile's `permissionMode`.
70+
Profiles can opt into a stricter behavior by setting the `permissionMode` field, which causes the provider to pass `--permission-mode <value>` instead of `--dangerously-skip-permissions`. See [Permission Mode Override](#permission-mode-override) below. `permissionMode` takes priority over `--yolo`; when set, the provider always uses `--permission-mode <value>` regardless of yolo. When running as root/sudo, `--dangerously-skip-permissions` is omitted even in yolo mode because Claude Code rejects it under root.
7171

7272
A fallback `_handle_trust_prompt()` method also monitors for the trust dialog and sends Enter to accept it, in case the flag doesn't cover all scenarios.
7373

@@ -97,7 +97,7 @@ The `permissionMode` field on an agent profile lets you replace the default `--d
9797

9898
Allowed values: `default`, `acceptEdits`, `plan`, `auto`, `bypassPermissions`. See the [Claude Code permission modes reference](https://code.claude.com/docs/en/permission-modes) for what each tier does.
9999

100-
When set, the provider passes `--permission-mode <value>` instead of `--dangerously-skip-permissions`. `cao launch --yolo` always overrides this field and forces `--dangerously-skip-permissions` regardless of the profile setting.
100+
When set, the provider passes `--permission-mode <value>` instead of `--dangerously-skip-permissions`. `permissionMode` takes priority over `--yolo`; the provider always uses `--permission-mode <value>` when the field is set, even in yolo mode.
101101

102102
Example — a reviewer that runs under the `auto` permission classifier instead of unconditional bypass:
103103

src/cli_agent_orchestrator/providers/claude_code.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import os
56
import re
67
import shlex
78
import time
@@ -165,9 +166,20 @@ def _build_claude_command(self) -> str:
165166
except Exception as e:
166167
raise ProviderError(f"Failed to load agent profile '{self._agent_profile}': {e}")
167168

168-
# Determine permission mode for the base command
169-
if profile and profile.permissionMode and not yolo:
169+
# Determine permission mode for the base command.
170+
# Priority: explicit permissionMode > yolo/root detection > default yolo.
171+
#
172+
# Root/sudo guard: Claude Code rejects --dangerously-skip-permissions when
173+
# running as root. We only omit it for yolo+root; non-root yolo still needs
174+
# the flag so Claude won't prompt for tool approval inside a headless tmux
175+
# pane and silently block handoff/assign flows.
176+
is_root = getattr(os, "geteuid", lambda: -1)() == 0
177+
178+
if profile and profile.permissionMode:
170179
command_parts = ["claude", "--permission-mode", profile.permissionMode]
180+
elif yolo and is_root:
181+
# Root users cannot use --dangerously-skip-permissions; omit it entirely.
182+
command_parts = ["claude"]
171183
else:
172184
command_parts = ["claude", "--dangerously-skip-permissions"]
173185

test/providers/test_claude_code_unit.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,7 +1244,7 @@ def test_uses_permission_mode_when_set_and_not_yolo(self, mock_load):
12441244
assert "--dangerously-skip-permissions" not in command
12451245

12461246
@patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile")
1247-
def test_yolo_overrides_permission_mode(self, mock_load):
1247+
def test_permission_mode_takes_priority_over_yolo(self, mock_load):
12481248
mock_profile = MagicMock()
12491249
mock_profile.model = None
12501250
mock_profile.system_prompt = None
@@ -1255,8 +1255,8 @@ def test_yolo_overrides_permission_mode(self, mock_load):
12551255
provider = ClaudeCodeProvider("tid", "sess", "win", "agent", allowed_tools=["*"])
12561256
command = provider._build_claude_command()
12571257

1258-
assert "--dangerously-skip-permissions" in command
1259-
assert "--permission-mode" not in command
1258+
assert "--permission-mode auto" in command
1259+
assert "--dangerously-skip-permissions" not in command
12601260

12611261
@patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile")
12621262
def test_legacy_profile_without_permission_mode(self, mock_load):
@@ -1274,6 +1274,68 @@ def test_legacy_profile_without_permission_mode(self, mock_load):
12741274
assert "--permission-mode" not in command
12751275

12761276

1277+
class TestClaudeCodeProviderYoloRootRegression:
1278+
"""Regression tests for yolo + root/non-root --dangerously-skip-permissions logic.
1279+
1280+
Ensures that the root-user guard (PR #322) only omits --dangerously-skip-permissions
1281+
when running as root, and does not break normal non-root yolo launches.
1282+
"""
1283+
1284+
@patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile")
1285+
@patch("cli_agent_orchestrator.providers.claude_code.os")
1286+
def test_yolo_non_root_includes_dangerously_skip_permissions(self, mock_os, mock_load):
1287+
"""yolo + no permissionMode + non-root => includes --dangerously-skip-permissions."""
1288+
mock_os.geteuid.return_value = 1000 # non-root
1289+
mock_profile = MagicMock()
1290+
mock_profile.model = None
1291+
mock_profile.system_prompt = None
1292+
mock_profile.mcpServers = None
1293+
mock_profile.permissionMode = None
1294+
mock_load.return_value = mock_profile
1295+
1296+
provider = ClaudeCodeProvider("tid", "sess", "win", "agent", allowed_tools=["*"])
1297+
command = provider._build_claude_command()
1298+
1299+
assert "--dangerously-skip-permissions" in command
1300+
assert "--permission-mode" not in command
1301+
1302+
@patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile")
1303+
@patch("cli_agent_orchestrator.providers.claude_code.os")
1304+
def test_yolo_root_omits_dangerously_skip_permissions(self, mock_os, mock_load):
1305+
"""yolo + no permissionMode + root => omits --dangerously-skip-permissions."""
1306+
mock_os.geteuid.return_value = 0 # root
1307+
mock_profile = MagicMock()
1308+
mock_profile.model = None
1309+
mock_profile.system_prompt = None
1310+
mock_profile.mcpServers = None
1311+
mock_profile.permissionMode = None
1312+
mock_load.return_value = mock_profile
1313+
1314+
provider = ClaudeCodeProvider("tid", "sess", "win", "agent", allowed_tools=["*"])
1315+
command = provider._build_claude_command()
1316+
1317+
assert "--dangerously-skip-permissions" not in command
1318+
assert "--permission-mode" not in command
1319+
1320+
@patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile")
1321+
@patch("cli_agent_orchestrator.providers.claude_code.os")
1322+
def test_yolo_with_permission_mode_uses_permission_mode_flag(self, mock_os, mock_load):
1323+
"""yolo + permissionMode => uses --permission-mode <value>, omits --dangerously-skip-permissions."""
1324+
mock_os.geteuid.return_value = 1000 # non-root; permissionMode should still win
1325+
mock_profile = MagicMock()
1326+
mock_profile.model = None
1327+
mock_profile.system_prompt = None
1328+
mock_profile.mcpServers = None
1329+
mock_profile.permissionMode = "auto"
1330+
mock_load.return_value = mock_profile
1331+
1332+
provider = ClaudeCodeProvider("tid", "sess", "win", "agent", allowed_tools=["*"])
1333+
command = provider._build_claude_command()
1334+
1335+
assert "--permission-mode auto" in command
1336+
assert "--dangerously-skip-permissions" not in command
1337+
1338+
12771339
class TestClaudeCodeProviderStartupPrompts:
12781340
"""Tests for Claude Code startup prompt handling (trust + bypass)."""
12791341

0 commit comments

Comments
 (0)