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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ All notable changes to CloudCLI UI will be documented in this file.

### Bug Fixes

* Claude chat now maps both skip-permissions and explicit `bypassPermissions` chat mode to `dontAsk` when CloudCLI runs as root, avoiding Claude SDK exit code 1 from forbidden bypass mode

* iOS scrolling main chat area ([3969135](https://github.com/siteboon/claudecodeui/commit/3969135bd427fbf48f29bb3dbfedb47791ca78dc))
* migrate PlanDisplay raw params from native details to Collapsible primitive ([fc3504e](https://github.com/siteboon/claudecodeui/commit/fc3504eaed8ca7ed9214838d148ea385b8352c31))
* precise Claude SDK denial message detection in deriveToolStatus ([09dcea0](https://github.com/siteboon/claudecodeui/commit/09dcea05fbc8c208d931aa1f08618f0e8087392f))
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,9 @@ CloudCLI UI - (https://cloudcli.ai).
<div align="center">
<strong>Made with care for the Claude Code, Cursor and Codex community.</strong>
</div>

## Root-Run Claude Chat

When CloudCLI runs as `root`, Claude chat now maps both the chat mode button's `bypassPermissions` setting and the Claude-specific skip-permissions setting to `dontAsk`. Claude rejects bypass mode under `root`/`sudo`, which previously caused chat requests to exit immediately with code 1.

Run `npm run test:claude-sdk-permissions` to rebuild the server bundle and verify the root permission-mode mapping.
4 changes: 4 additions & 0 deletions features.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Features

- Claude chat now uses `dontAsk` instead of `bypassPermissions` when CloudCLI is started as `root` and either skip-permissions or the chat mode button requests bypass mode, preventing Claude SDK chat exits with code 1.
- Regression coverage is available via `npm run test:claude-sdk-permissions`, which rebuilds `dist-server` before running the permission-mode test.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js",
"prepare": "husky",
"update:platform": "./update-platform.sh"
"update:platform": "./update-platform.sh",
"test:claude-sdk-permissions": "npm run build:server && node --test server/modules/providers/tests/claude-sdk-permission-mode.test.mjs"
},
"keywords": [
"claude code",
Expand Down
33 changes: 23 additions & 10 deletions server/claude-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ function createRequestId() {
return crypto.randomBytes(16).toString('hex');
}

function resolveSdkPermissionMode(permissionMode, toolsSettings = {}, options = {}) {
const isRoot = options.isRoot ?? (typeof process.getuid === 'function' && process.getuid() === 0);

if (permissionMode === 'bypassPermissions') {
return isRoot ? 'dontAsk' : 'bypassPermissions';
}

if (toolsSettings.skipPermissions && permissionMode !== 'plan') {
return isRoot ? 'dontAsk' : 'bypassPermissions';
}

if (permissionMode && permissionMode !== 'default') {
return permissionMode;
}

return undefined;
}
Comment on lines +45 to +61
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all callers of queryClaudeSDK to see whether they pre-approve tools or rely on bypass semantics.
rg -nP -C3 'queryClaudeSDK\s*\(' --type=js --type=ts
# And where permissionMode: 'bypassPermissions' is passed anywhere in the server.
rg -nP -C2 "permissionMode\s*:\s*['\"]bypassPermissions['\"]"

Repository: siteboon/claudecodeui

Length of output: 2786


🌐 Web query:

Claude Agent SDK permissionMode dontAsk canUseTool callback called

💡 Result:

In the Claude Agent SDK, when permissionMode is set to "dontAsk", the canUseTool callback is not called. Instead, tools that are not pre-approved by allowed_tools, hooks, or permission rules are automatically denied without prompting or invoking the callback. This creates a locked-down agent that only uses explicitly permitted tools, ideal for headless or secure environments.

Citations:


🏁 Script executed:

# Get full context of queryClaudeSDK calls in agent.js
sed -n '947,960p' server/routes/agent.js
sed -n '970,980p' server/routes/agent.js

# Get full context in git.js
sed -n '980,990p' server/routes/git.js

# Check how resolveSdkPermissionMode is used in queryClaudeSDK
sed -n '491,550p' server/claude-sdk.js | head -80

Repository: siteboon/claudecodeui

Length of output: 3240


🏁 Script executed:

# Search for canUseTool callback and how permissionMode is passed to SDK
rg -n 'canUseTool|permissionMode.*=' server/claude-sdk.js | head -20

Repository: siteboon/claudecodeui

Length of output: 646


🏁 Script executed:

# Look for any toolsSettings or allowedTools being set before queryClaudeSDK calls
rg -n 'toolsSettings|allowedTools' server/routes/agent.js server/routes/git.js

Repository: siteboon/claudecodeui

Length of output: 47


🏁 Script executed:

# Find mapCliOptionsToSDK implementation
sed -n '150,210p' server/claude-sdk.js

Repository: siteboon/claudecodeui

Length of output: 1878


🏁 Script executed:

# Also verify if there's any allowedTools handling in mapCliOptionsToSDK
rg -n 'mapCliOptionsToSDK' server/claude-sdk.js -A 30

Repository: siteboon/claudecodeui

Length of output: 2668


Fix: dontAsk semantics aren't equivalent to bypassPermissions and silently denies tools under root.

Per the Claude Agent SDK documentation, dontAsk denies any tool not pre-approved by allowedTools/settings/hooks; the canUseTool callback is never invoked at the permission-check step. This creates a critical mismatch in the current code:

The problem:

  • mapCliOptionsToSDK calls resolveSdkPermissionMode, which transforms bypassPermissionsdontAsk when running as root (line 45-49).
  • Calls at server/routes/agent.js:950–956 and server/routes/agent.js:974–977 pass only permissionMode: 'bypassPermissions' with no toolsSettings or allowedTools.
  • After transformation to dontAsk, toolsSettings defaults to {allowedTools: []} (empty list, line 186–190).
  • The canUseTool callback at line 546 checks for the literal string 'bypassPermissions', but since permissionMode is now 'dontAsk', the early-allow branch never fires.
  • Result: SDK denies every tool call silently, rather than bypassing permissions.

The same issue affects server/routes/git.js:983–987 and potentially other root-run scenarios.

Minimum required fixes:

  1. Either avoid the bypassPermissionsdontAsk transformation, or
  2. Populate allowedTools with the actual set of tools the agent may need before calling queryClaudeSDK, or
  3. Update canUseTool to also recognize 'dontAsk' mode and apply bypass logic (though this inverts the semantic intent of dontAsk).

Document the intended root-user behavior (if silent tool denial is desired, clarify that in the permission mode semantics; if bypass is intended, fix the transformation).


function waitForToolApproval(requestId, options = {}) {
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;

Expand Down Expand Up @@ -164,22 +182,16 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.cwd = cwd;
}

// Map permission mode
if (permissionMode && permissionMode !== 'default') {
sdkOptions.permissionMode = permissionMode;
}

// Map tool settings
const settings = toolsSettings || {
allowedTools: [],
disallowedTools: [],
skipPermissions: false
};

// Handle tool permissions
if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions';
const resolvedPermissionMode = resolveSdkPermissionMode(permissionMode, settings);
if (resolvedPermissionMode) {
sdkOptions.permissionMode = resolvedPermissionMode;
}

let allowedTools = [...(settings.allowedTools || [])];
Expand Down Expand Up @@ -828,5 +840,6 @@ export {
getActiveClaudeSDKSessions,
resolveToolApproval,
getPendingApprovalsForSession,
reconnectSessionWriter
reconnectSessionWriter,
resolveSdkPermissionMode
};
30 changes: 30 additions & 0 deletions server/modules/providers/tests/claude-sdk-permission-mode.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveSdkPermissionMode } from '../../../../dist-server/server/claude-sdk.js';

// This test validates the compiled server artifact, so run it through
// `npm run test:claude-sdk-permissions`, which rebuilds dist-server first.
Comment on lines +3 to +6
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check that CI / workflows reference the new npm script or build before tests.
fd -e yml -e yaml . .github/workflows 2>/dev/null | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"' | rg -n -C2 'test:claude-sdk-permissions|node --test|build:server'

Repository: siteboon/claudecodeui

Length of output: 47


🏁 Script executed:

# Check if .github/workflows exists and list its contents
ls -la .github/workflows 2>/dev/null || echo "No .github/workflows directory found"

Repository: siteboon/claudecodeui

Length of output: 367


🏁 Script executed:

# List top-level .github directory
ls -la .github 2>/dev/null || echo "No .github directory found"

Repository: siteboon/claudecodeui

Length of output: 299


🏁 Script executed:

# Check package.json for script definitions
head -100 package.json | grep -A 50 '"scripts"'

Repository: siteboon/claudecodeui

Length of output: 1980


🏁 Script executed:

# Look for the specific test file and verify it exists
find . -name "claude-sdk-permission-mode.test.mjs" -type f

Repository: siteboon/claudecodeui

Length of output: 134


🏁 Script executed:

cat .github/workflows/discord-release.yml

Repository: siteboon/claudecodeui

Length of output: 728


🏁 Script executed:

cat .github/workflows/docker.yml

Repository: siteboon/claudecodeui

Length of output: 1570


🏁 Script executed:

cat .github/workflows/release.yml

Repository: siteboon/claudecodeui

Length of output: 1423


🏁 Script executed:

# Look for test configuration files
find . -maxdepth 2 -type f \( -name "jest.config.*" -o -name "vitest.config.*" -o -name "mocha.opts" -o -name ".mocharc.*" \) 2>/dev/null

Repository: siteboon/claudecodeui

Length of output: 47


🏁 Script executed:

# Check if there are any other test files
find server/modules/providers/tests -type f -name "*.test.*" -o -name "*.spec.*" 2>/dev/null | head -20

Repository: siteboon/claudecodeui

Length of output: 175


🏁 Script executed:

# Search for "test" references in package.json scripts
grep -A 100 '"scripts"' package.json | grep -i test

Repository: siteboon/claudecodeui

Length of output: 205


CI workflows don't invoke the test:claude-sdk-permissions script yet.

The script is correctly defined in package.json with the necessary npm run build:server prerequisite, but none of the three CI workflows (discord-release.yml, docker.yml, release.yml) currently run this test. The release workflow publishes to npm without executing any tests, so this regression coverage won't run automatically. Add npm run test:claude-sdk-permissions to the appropriate workflow (likely release.yml before publishing) to ensure the test executes as part of your CI pipeline.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/modules/providers/tests/claude-sdk-permission-mode.test.mjs` around
lines 3 - 6, Add the missing CI step to run the compiled-server test by invoking
the npm script "npm run test:claude-sdk-permissions" in the release workflow
(release.yml) before the publish step; update the workflow to run that script
(it depends on build:server already) so the test file
server/modules/providers/tests/claude-sdk-permission-mode.test.mjs is executed
during CI prior to publishing.


test('maps skipPermissions to dontAsk for root Claude SDK sessions', () => {
assert.equal(resolveSdkPermissionMode('default', { skipPermissions: true }, { isRoot: true }), 'dontAsk');
});

test('maps skipPermissions to bypassPermissions for non-root Claude SDK sessions', () => {
assert.equal(resolveSdkPermissionMode('default', { skipPermissions: true }, { isRoot: false }), 'bypassPermissions');
});

test('preserves plan mode when skipPermissions is enabled', () => {
assert.equal(resolveSdkPermissionMode('plan', { skipPermissions: true }, { isRoot: true }), 'plan');
});

test('preserves explicit non-default permission modes when skipPermissions is disabled', () => {
assert.equal(resolveSdkPermissionMode('acceptEdits', { skipPermissions: false }, { isRoot: true }), 'acceptEdits');
});

test('maps explicit bypassPermissions to dontAsk for root Claude SDK sessions', () => {
assert.equal(resolveSdkPermissionMode('bypassPermissions', { skipPermissions: false }, { isRoot: true }), 'dontAsk');
});

test('preserves explicit bypassPermissions for non-root Claude SDK sessions', () => {
assert.equal(resolveSdkPermissionMode('bypassPermissions', { skipPermissions: false }, { isRoot: false }), 'bypassPermissions');
});