From 5170c188c3758f35c88018ebebd4970d29f5ebf1 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 24 Feb 2026 15:52:55 +0100 Subject: [PATCH] fix: respect Claude CLI startup mode setting (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Startup Mode" setting (normal/bypass/allowedTools) was saved to settings but never read when spawning sessions. All code paths had --dangerously-skip-permissions hardcoded. Now reads claudeMode from ~/.claudeman/settings.json and passes it through Session → TmuxManager. Closes #12 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- package.json | 2 +- src/mux-interface.ts | 6 +++-- src/session.ts | 60 ++++++++++++++++++++++++++++++++++++++------ src/tmux-manager.ts | 35 +++++++++++++++++++++++--- src/types.ts | 8 ++++++ src/web/server.ts | 24 +++++++++++++++++- 7 files changed, 121 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2e64f7f0..8aa7752f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1617 (must match `package.json`) +**Version**: 0.1618 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index 6c568074..52cee7d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1617", + "version": "0.1618", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/mux-interface.ts b/src/mux-interface.ts index aaa401e7..456ad7bd 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -7,7 +7,7 @@ */ import type { EventEmitter } from 'node:events'; -import type { ProcessStats, PersistedRespawnConfig, NiceConfig } from './types.js'; +import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode } from './types.js'; /** * Multiplexer session metadata. @@ -71,6 +71,8 @@ export interface TerminalMultiplexer extends EventEmitter { name?: string, niceConfig?: NiceConfig, model?: string, + claudeMode?: ClaudeMode, + allowedTools?: string, ): Promise; /** @@ -163,5 +165,5 @@ export interface TerminalMultiplexer extends EventEmitter { isPaneDead(muxName: string): boolean; /** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */ - respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string): Promise; + respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string, claudeMode?: ClaudeMode, allowedTools?: string): Promise; } diff --git a/src/session.ts b/src/session.ts index 4a7190e1..d27db4ac 100644 --- a/src/session.ts +++ b/src/session.ts @@ -18,7 +18,7 @@ import { EventEmitter } from 'node:events'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; -import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js'; +import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode } from './types.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -321,6 +321,10 @@ export class Session extends EventEmitter { // Claude model override (e.g., 'opus', 'sonnet', 'haiku') private _model: string | undefined; + // Claude CLI startup permission mode + private _claudeMode: ClaudeMode = 'dangerously-skip-permissions'; + private _allowedTools: string | undefined; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -374,6 +378,10 @@ export class Session extends EventEmitter { niceConfig?: NiceConfig; // Nice prioritying configuration /** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */ model?: string; + /** Claude CLI startup permission mode */ + claudeMode?: ClaudeMode; + /** Comma-separated allowed tools (for 'allowedTools' mode) */ + allowedTools?: string; }) { super(); this.setMaxListeners(25); @@ -409,6 +417,14 @@ export class Session extends EventEmitter { this._model = config.model; } + // Apply Claude CLI permission mode + if (config.claudeMode) { + this._claudeMode = config.claudeMode; + } + if (config.allowedTools) { + this._allowedTools = config.allowedTools; + } + // Initialize task tracker and forward events (store handlers for cleanup) this._taskTracker = new TaskTracker(); this._taskTrackerHandlers = { @@ -574,6 +590,36 @@ export class Session extends EventEmitter { return { ...this._niceConfig }; } + /** Claude CLI startup permission mode */ + get claudeMode(): ClaudeMode { + return this._claudeMode; + } + + /** Allowed tools list (for 'allowedTools' mode) */ + get allowedTools(): string | undefined { + return this._allowedTools; + } + + /** + * Build Claude CLI permission flags based on the configured mode. + * Returns an array of args to pass to the CLI. + */ + private _buildPermissionArgs(): string[] { + switch (this._claudeMode) { + case 'dangerously-skip-permissions': + return ['--dangerously-skip-permissions']; + case 'allowedTools': + if (this._allowedTools) { + return ['--allowedTools', this._allowedTools]; + } + // Fall back to normal mode if no tools specified + return []; + case 'normal': + default: + return []; + } + } + /** * Set CPU priority configuration. * Note: This only affects new sessions; existing running processes won't be changed. @@ -829,9 +875,9 @@ export class Session extends EventEmitter { /** * Starts an interactive Claude CLI session with full terminal support. * - * This spawns Claude CLI with `--dangerously-skip-permissions` flag in - * interactive mode. If mux wrapping is enabled, the session runs inside - * a tmux session for persistence across disconnects. + * This spawns Claude CLI in interactive mode with the configured permission + * mode (default: `--dangerously-skip-permissions`). If mux wrapping is enabled, + * the session runs inside a tmux session for persistence across disconnects. * * @throws {Error} If a process is already running in this session * @@ -872,7 +918,7 @@ export class Session extends EventEmitter { let needsNewSession = false; if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) { console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); - const newPid = await this._mux.respawnPane(this.id, this.workingDir, 'claude', this._niceConfig, this._model); + const newPid = await this._mux.respawnPane(this.id, this.workingDir, 'claude', this._niceConfig, this._model, this._claudeMode, this._allowedTools); if (!newPid) { console.error('[Session] Failed to respawn pane, will create new session'); needsNewSession = true; @@ -888,7 +934,7 @@ export class Session extends EventEmitter { console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); } else { // Create a new mux session - this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'claude', this._name, this._niceConfig, this._model); + this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'claude', this._name, this._niceConfig, this._model, this._claudeMode, this._allowedTools); console.log('[Session] Created mux session:', this._muxSession.muxName); // No extra sleep — createSession() already waits for tmux readiness } @@ -961,7 +1007,7 @@ export class Session extends EventEmitter { try { // Pass --session-id to use the SAME ID as the Claudeman session // This ensures subagents can be directly matched to the correct tab - const args = ['--dangerously-skip-permissions', '--session-id', this.id]; + const args = [...this._buildPermissionArgs(), '--session-id', this.id]; if (this._model) args.push('--model', this._model); this.ptyProcess = pty.spawn('claude', args, { name: 'xterm-256color', diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 19c19ba1..253e8dde 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -30,7 +30,7 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs'; import { writeFile, rename } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; -import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG, type PaneInfo } from './types.js'; +import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode } from './types.js'; import { wrapWithNice } from './utils/nice-wrapper.js'; import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js'; import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js'; @@ -111,6 +111,31 @@ function isValidPath(path: string): boolean { return SAFE_PATH_PATTERN.test(path); } +/** + * Build Claude CLI permission flags for the tmux command string. + * Validates allowedTools to prevent command injection. + */ +function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: string): string { + const mode = claudeMode || 'dangerously-skip-permissions'; + switch (mode) { + case 'dangerously-skip-permissions': + return ' --dangerously-skip-permissions'; + case 'allowedTools': + if (allowedTools) { + // Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated + // Block shell metacharacters: ; & | $ ` \ { } < > ' " newlines + const hasDangerousChars = /[;&|$`\\{}<>'"[\]\n\r]/.test(allowedTools); + if (!hasDangerousChars) { + return ` --allowedTools "${allowedTools}"`; + } + } + // Fall back to normal mode if tools are invalid or missing + return ''; + case 'normal': + return ''; + } +} + /** * Manages tmux sessions that wrap Claude CLI or shell processes. * @@ -204,6 +229,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { name?: string, niceConfig?: NiceConfig, model?: string, + claudeMode?: ClaudeMode, + allowedTools?: string, ): Promise { const muxName = `claudeman-${sessionId.slice(0, 8)}`; @@ -250,7 +277,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const safeModel = (model && /^[a-zA-Z0-9._-]+$/.test(model)) ? model : undefined; const modelFlag = (mode === 'claude' && safeModel) ? ` --model ${safeModel}` : ''; const baseCmd = mode === 'claude' - ? `claude --dangerously-skip-permissions --session-id "${sessionId}"${modelFlag}` + ? `claude${buildClaudePermissionFlags(claudeMode, allowedTools)} --session-id "${sessionId}"${modelFlag}` : '$SHELL'; const config = niceConfig || DEFAULT_NICE_CONFIG; @@ -394,7 +421,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Uses `tmux respawn-pane -k` to restart the command in the same pane, * preserving the session and its scrollback buffer. */ - async respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string): Promise { + async respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string, claudeMode?: ClaudeMode, allowedTools?: string): Promise { const session = this.sessions.get(sessionId); if (!session) return null; const muxName = session.muxName; @@ -415,7 +442,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const safeModel = (model && /^[a-zA-Z0-9._-]+$/.test(model)) ? model : undefined; const modelFlag = (mode === 'claude' && safeModel) ? ` --model ${safeModel}` : ''; const baseCmd = mode === 'claude' - ? `claude --dangerously-skip-permissions --session-id "${sessionId}"${modelFlag}` + ? `claude${buildClaudePermissionFlags(claudeMode, allowedTools)} --session-id "${sessionId}"${modelFlag}` : '$SHELL'; const config = niceConfig || DEFAULT_NICE_CONFIG; diff --git a/src/types.ts b/src/types.ts index c257706d..4804b926 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,14 @@ export interface LifecycleEntry { // ========== Session Types ========== +/** + * Claude CLI startup permission mode. + * - `'dangerously-skip-permissions'`: Bypass all permission prompts (default) + * - `'normal'`: Standard mode with permission prompts + * - `'allowedTools'`: Only allow specific tools (requires allowedTools list) + */ +export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools'; + /** * Configuration for creating a new session */ diff --git a/src/web/server.ts b/src/web/server.ts index 8f6faf50..99a33b06 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -21,6 +21,7 @@ import { execSync } from 'node:child_process'; import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; import { EventEmitter } from 'node:events'; import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js'; +import type { ClaudeMode } from '../types.js'; import { fileStreamManager } from '../file-stream-manager.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; import type { TerminalMultiplexer } from '../mux-interface.js'; @@ -839,6 +840,7 @@ export class WebServer extends EventEmitter { const globalNice = await this.getGlobalNiceConfig(); const modelConfig = await this.getModelConfig(); const model = (body.mode !== 'shell') ? modelConfig?.defaultModel : undefined; + const claudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir, mode: body.mode || 'claude', @@ -847,6 +849,8 @@ export class WebServer extends EventEmitter { useMux: true, niceConfig: globalNice, model, + claudeMode: claudeModeConfig.claudeMode, + allowedTools: claudeModeConfig.allowedTools, }); this.sessions.set(session.id, session); @@ -2631,6 +2635,7 @@ export class WebServer extends EventEmitter { const niceConfig = await this.getGlobalNiceConfig(); const qsModelConfig = await this.getModelConfig(); const qsModel = (mode !== 'shell') ? qsModelConfig?.defaultModel : undefined; + const qsClaudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir: casePath, mux: this.mux, @@ -2638,6 +2643,8 @@ export class WebServer extends EventEmitter { mode: mode, niceConfig: niceConfig, model: qsModel, + claudeMode: qsClaudeModeConfig.claudeMode, + allowedTools: qsClaudeModeConfig.allowedTools, }); // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting @@ -4681,6 +4688,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return undefined; } + // Helper to get Claude CLI startup mode from settings + private async getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }> { + const settings = await this.readSettings(); + const claudeMode = settings.claudeMode as string | undefined; + const allowedTools = settings.allowedTools as string | undefined; + // Only return valid modes + if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') { + return { claudeMode, allowedTools }; + } + return {}; + } + // Helper to get model configuration from settings private async getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record } | null> { const settings = await this.readSettings(); @@ -5309,6 +5328,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const sessionName = savedState?.name || muxSession.name || muxSession.muxName; // Create a session object for this mux session + const recoveryClaudeMode = await this.getClaudeModeConfig(); const session = new Session({ id: muxSession.sessionId, // Preserve the original session ID workingDir: muxSession.workingDir, @@ -5316,7 +5336,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; name: sessionName, mux: this.mux, useMux: true, - muxSession: muxSession // Pass the existing session so startInteractive() can attach to it + muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it + claudeMode: recoveryClaudeMode.claudeMode, + allowedTools: recoveryClaudeMode.allowedTools, }); // Update session name if it was a "Restored:" placeholder or doesn't match saved name