From 9357b0044726e7bf1cd1db27c5919db9bcf07741 Mon Sep 17 00:00:00 2001 From: Roy Padina <4943097+roypadina@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:43:32 +0300 Subject: [PATCH] Default to Claude Code auto permission mode; permission_mode config opts into bypass (0.11.2) --- CHANGELOG.md | 10 ++++++++++ CLAUDE.md | 2 +- README.md | 11 ++++++----- config.example.toml | 10 ++++++++-- package-lock.json | 4 ++-- package.json | 2 +- src/cli/resume.ts | 6 +++++- src/cli/screens/NewScreen.tsx | 2 +- src/cli/tui.tsx | 4 +++- src/core/config/defaults.ts | 22 ++++++++++++++++------ src/core/config/loadConfig.ts | 14 +++++++++++--- src/core/config/types.ts | 7 ++++++- src/core/config/validate.ts | 11 ++++++++++- tests/cli/resume.test.ts | 14 +++++++++++--- tests/cli/screens.test.tsx | 1 + tests/core/config.test.ts | 19 ++++++++++++++++++- 16 files changed, 110 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c8f58..94728ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to Agentctl are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com); versions follow [SemVer](https://semver.org). +## [0.11.2] — 2026-10-23 + +### Changed + +- Claude sessions now start and resume in Claude Code's **auto** permission mode (`--permission-mode auto`) instead of `--dangerously-skip-permissions`. + +### Added + +- Config key `permission_mode = "bypassPermissions"` opts back in to `--dangerously-skip-permissions` (Resume and the built-in `claude` tool). + ## [0.11.1] — 2026-10-23 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 57a11a1..610b1c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ bin/agentctl Node shebang shim → dist/cli.js - **No defensive code at boundaries that aren't boundaries**. Internal calls trust each other. Validate only at FS reads / external JSON / process.argv. - **TDD where it helps**. New `src/core/` modules ship with `tests/core/.test.ts`. UI work in `tui.tsx` gets smoke tests only — full UX validation is manual in a real terminal. - **Cwd encoding is ambiguous**. `~/.claude/projects//` uses `-` as both the leading marker and the path separator. Real `-` in segment names (e.g. `My-App-Repo`) collides. `decode.ts` recursively walks candidates with `existsSync` pruning. When no match, returns `cwdDecodeConfident: false` — UI must surface this and `resume` refuses without `--cwd `. -- **`--dangerously-skip-permissions` is hardcoded** into `cli/resume.ts`. Never expose a config flag to remove it. Only `resume` adds it; `peek` / read paths never do. +- **Permission mode comes from config `permission_mode`** (`auto` default → `--permission-mode auto`; `bypassPermissions` opt-in → `--dangerously-skip-permissions`), via `permissionArgs` in `core/config/defaults.ts`. Only `resume` adds it; `peek` / read paths never do. - **Resume pins `CLAUDE_CONFIG_DIR`** to the session's own profile (`resumeEnv`). Inheriting the ambient value resumes the transcript under whichever profile launched the menu; when two profiles share a `projects/` dir that *succeeds silently as the wrong account* rather than diff --git a/README.md b/README.md index 7d7ccd6..7002c77 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ add one tool per profile: ```toml [[tool]] name = "work" -runs = "CLAUDE_CONFIG_DIR=~/.claude2 claude --dangerously-skip-permissions" +runs = "CLAUDE_CONFIG_DIR=~/.claude2 claude --permission-mode auto" label = " ⚡ Work account " ``` @@ -259,7 +259,7 @@ color = "#6C91BF" [[tool]] # an agent launcher: `runs` is executed in the chosen dir name = "claude" -runs = "claude --dangerously-skip-permissions" +runs = "claude --permission-mode auto" [[ide]] # an fzf-style ^key that opens an editor, then the tool key = "ctrl-v" @@ -288,9 +288,10 @@ names by walking the filesystem — and flagged when the result isn't certain. - **Open source (MIT).** Read or build every line yourself. - **Local-only. No network calls of its own, no analytics, no accounts.** It reads `~/.claude/` and the project folders you configure, and launches your terminal. That's it. -- **`--dangerously-skip-permissions`.** The default tool commands include Claude's - `--dangerously-skip-permissions` (and Codex's sandbox-bypass) flag, because this is a launcher for your - own machine. You can change `runs` in your config to drop it. +- **Permissions.** Claude sessions start and resume in Claude Code's **auto** permission mode + (`--permission-mode auto`). To opt into bypass, set `permission_mode = "bypassPermissions"` at the top of + your config (adds `--dangerously-skip-permissions` to Resume and the built-in `claude` tool). The default + Codex tool still uses Codex's sandbox-bypass flag; change its `runs` to drop it. ## Uninstall diff --git a/config.example.toml b/config.example.toml index 4c5a308..d46e78c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -3,6 +3,12 @@ # Optional. Which tool's launcher is primary (informational). default_tool = "claude" +# Claude Code permission mode for the built-in claude tool and for Resume. +# "auto" (default) — Claude Code auto mode (--permission-mode auto) +# "bypassPermissions" — opt in to --dangerously-skip-permissions +# A [[tool]] `runs` line below is used verbatim; this key does not rewrite it. +# permission_mode = "auto" + # ─── dir groups ────────────────────────────────────────────────────────────── # Each group is a section in the New screen. `path` is scanned one level deep for # project dirs, sorted by `z` frecency (falls back to mtime). ~ and $HOME expand. @@ -21,7 +27,7 @@ color = "#A855F7" # executed in the chosen dir. Adding/removing a tool takes effect in a NEW shell. [[tool]] name = "claude" -runs = "claude --dangerously-skip-permissions" +runs = "claude --permission-mode auto" label = " ⚡ Projects " color = "#6C91BF" @@ -36,7 +42,7 @@ color = "#A855F7" # with ⇧⇥. Resume already picks the right profile on its own. # [[tool]] # name = "work" -# runs = "CLAUDE_CONFIG_DIR=~/.claude2 claude --dangerously-skip-permissions" +# runs = "CLAUDE_CONFIG_DIR=~/.claude2 claude --permission-mode auto" # label = " ⚡ Work account " # color = "#4EA37A" diff --git a/package-lock.json b/package-lock.json index 1400356..f1cd20f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@roypadina/agentctl", - "version": "0.11.1", + "version": "0.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@roypadina/agentctl", - "version": "0.11.1", + "version": "0.11.2", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index 35553a7..53c5de8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@roypadina/agentctl", - "version": "0.11.1", + "version": "0.11.2", "description": "Start and resume Claude Code & Codex sessions from one terminal menu — fuzzy-resume past sessions with transcript preview.", "type": "module", "license": "MIT", diff --git a/src/cli/resume.ts b/src/cli/resume.ts index e13e1af..1ef308f 100644 --- a/src/cli/resume.ts +++ b/src/cli/resume.ts @@ -1,6 +1,9 @@ import { existsSync } from 'node:fs'; import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; import { isPrimaryHome } from '../core/profiles.js'; +import { loadPermissionMode } from '../core/config/loadConfig.js'; +import { permissionArgs } from '../core/config/defaults.js'; +import type { PermissionMode } from '../core/config/types.js'; import type { SessionRecord } from '../core/types.js'; export class ResumeError extends Error { @@ -23,6 +26,7 @@ export interface ResumeDeps { opts: { cwd: string; stdio: 'inherit'; env: NodeJS.ProcessEnv }, ) => SpawnSyncReturns; exit?: (code: number) => never; + permissionMode?: () => PermissionMode; } /** @@ -63,7 +67,7 @@ export function resume( const bin = process.env.AGENTCTL_CLAUDE_BIN ?? 'claude'; const child = spawn( bin, - ['--resume', s.id, '--dangerously-skip-permissions'], + ['--resume', s.id, ...permissionArgs((deps.permissionMode ?? loadPermissionMode)())], { cwd: targetCwd, stdio: 'inherit', env: resumeEnv(s) }, ); const err = child.error as NodeJS.ErrnoException | undefined; diff --git a/src/cli/screens/NewScreen.tsx b/src/cli/screens/NewScreen.tsx index 7b7efa1..a95d8f9 100644 --- a/src/cli/screens/NewScreen.tsx +++ b/src/cli/screens/NewScreen.tsx @@ -44,7 +44,7 @@ export function NewScreen({ config, warnings, projects, configError, onSwitchTab const i = tools.findIndex((t) => t.name === (config?.defaultTool ?? 'claude')); return i >= 0 ? i : 0; }); - const tool = tools[toolIdx] ?? (config ? getTool(config, config.defaultTool) : { name: 'claude', runs: 'claude --dangerously-skip-permissions', label: '', color: '#6C91BF' }); + const tool = tools[toolIdx] ?? (config ? getTool(config, config.defaultTool) : { name: 'claude', runs: 'claude --permission-mode auto', label: '', color: '#6C91BF' }); const [query, setQuery] = useState(''); const [cursor, setCursor] = useState(0); diff --git a/src/cli/tui.tsx b/src/cli/tui.tsx index 8bfa5b9..de6a462 100644 --- a/src/cli/tui.tsx +++ b/src/cli/tui.tsx @@ -12,6 +12,8 @@ import { copyToClipboard, resumeCommand } from '../core/clipboard.js'; import { listProfiles, profileAccount, type Profile } from '../core/profiles.js'; import { fuzzyRank } from '../core/fuzzy.js'; import { getRecap, readCachedRecap, spawnRun } from '../core/recap.js'; +import { loadPermissionMode } from '../core/config/loadConfig.js'; +import { permissionArgs } from '../core/config/defaults.js'; import { windowFor, scrollbar } from './viewport.js'; import { useKeyChunk, upCount, downCount } from './useKeyChunk.js'; import { @@ -63,7 +65,7 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps if (onResume) { onResume(s); return; } exit(); const bin = process.env.AGENTCTL_CLAUDE_BIN ?? 'claude'; - spawnSync(bin, ['--resume', s.id, '--dangerously-skip-permissions'], { + spawnSync(bin, ['--resume', s.id, ...permissionArgs(loadPermissionMode())], { cwd: s.cwd, stdio: 'inherit', env: resumeEnv(s), }); }; diff --git a/src/core/config/defaults.ts b/src/core/config/defaults.ts index 550b15d..6f26fbe 100644 --- a/src/core/config/defaults.ts +++ b/src/core/config/defaults.ts @@ -1,13 +1,22 @@ -import type { AgentctlConfig, ThemeConfig, ToolConfig } from './types.js'; +import type { AgentctlConfig, PermissionMode, ThemeConfig, ToolConfig } from './types.js'; /** Hex colour fallback. */ export const DEFAULT_COLOR = '#8888aa'; +export const DEFAULT_PERMISSION_MODE: PermissionMode = 'auto'; + +/** CLI args that start Claude Code in the given permission mode. */ +export function permissionArgs(mode: PermissionMode): string[] { + return mode === 'bypassPermissions' ? ['--dangerously-skip-permissions'] : ['--permission-mode', 'auto']; +} + /** Built-in tools. Used when config omits a [[tool]] of this name. */ -export const DEFAULT_TOOLS: Record = { - claude: { name: 'claude', runs: 'claude --dangerously-skip-permissions', label: ' ⚡ Projects ', color: '#6C91BF' }, - cdx: { name: 'cdx', runs: 'codex --dangerously-bypass-approvals-and-sandbox', label: ' ✦ Codex ', color: '#A855F7' }, -}; +export function defaultTools(mode: PermissionMode = DEFAULT_PERMISSION_MODE): Record { + return { + claude: { name: 'claude', runs: ['claude', ...permissionArgs(mode)].join(' '), label: ' ⚡ Projects ', color: '#6C91BF' }, + cdx: { name: 'cdx', runs: 'codex --dangerously-bypass-approvals-and-sandbox', label: ' ✦ Codex ', color: '#A855F7' }, + }; +} /** Colors duplicated (NOT imported) from tui.tsx to keep core ink-free. */ export const DEFAULT_THEME: ThemeConfig = { @@ -24,8 +33,9 @@ export const DEFAULT_RESERVED_KEYS = new Set(['enter', 'ctrl-f', 'ctrl-p', 'ctrl export const DEFAULT_CONFIG: AgentctlConfig = { groups: [], - tools: [DEFAULT_TOOLS.claude, DEFAULT_TOOLS.cdx], + tools: Object.values(defaultTools()), ides: [], defaultTool: 'claude', + permissionMode: DEFAULT_PERMISSION_MODE, theme: DEFAULT_THEME, }; diff --git a/src/core/config/loadConfig.ts b/src/core/config/loadConfig.ts index cd5e6cd..4dc2eae 100644 --- a/src/core/config/loadConfig.ts +++ b/src/core/config/loadConfig.ts @@ -2,11 +2,12 @@ import { readFileSync, statSync } from 'node:fs'; import { parse, TomlError } from 'smol-toml'; import { configPath } from './paths.js'; import { validateConfig } from './validate.js'; -import { DEFAULT_CONFIG, DEFAULT_RESERVED_KEYS, DEFAULT_TOOLS, DEFAULT_COLOR } from './defaults.js'; +import { DEFAULT_CONFIG, DEFAULT_RESERVED_KEYS, DEFAULT_PERMISSION_MODE, defaultTools, DEFAULT_COLOR } from './defaults.js'; import { ConfigError, type AgentctlConfig, type LoadConfigResult, + type PermissionMode, type ToolConfig, } from './types.js'; @@ -75,7 +76,7 @@ export function loadConfig(opts: LoadConfigOptions = {}): LoadConfigResult { const { config, warnings } = validateConfig(raw, { reservedKeys }); // Always guarantee claude/cdx exist as launchers. if (config.tools.length === 0) { - config.tools = [DEFAULT_TOOLS.claude, DEFAULT_TOOLS.cdx]; + config.tools = Object.values(defaultTools(config.permissionMode)); } const result: LoadConfigResult = { @@ -94,7 +95,8 @@ export function loadConfig(opts: LoadConfigOptions = {}): LoadConfigResult { export function getTool(config: AgentctlConfig, name: string): ToolConfig { const found = config.tools.find(t => t.name === name); if (found) return found; - if (DEFAULT_TOOLS[name]) return DEFAULT_TOOLS[name]; + const builtin = defaultTools(config.permissionMode)[name]; + if (builtin) return builtin; return { name, runs: name, label: ` ${name} `, color: DEFAULT_COLOR }; } @@ -104,6 +106,12 @@ function structuredCloneConfig(c: AgentctlConfig): AgentctlConfig { tools: c.tools.map(t => ({ ...t })), ides: c.ides.map(i => ({ ...i })), defaultTool: c.defaultTool, + permissionMode: c.permissionMode, theme: { ...c.theme }, }; } + +/** Permission mode for resume paths. A broken config must not block resuming → safe default. */ +export function loadPermissionMode(): PermissionMode { + try { return loadConfig().config.permissionMode; } catch { return DEFAULT_PERMISSION_MODE; } +} diff --git a/src/core/config/types.ts b/src/core/config/types.ts index 4a331d0..0769cd0 100644 --- a/src/core/config/types.ts +++ b/src/core/config/types.ts @@ -13,7 +13,7 @@ export interface GroupConfig { export interface ToolConfig { /** Generates a launcher command of this name (claude, cdx, …). */ name: string; - /** Shell command line run in the chosen dir (e.g. "claude --dangerously-skip-permissions"). */ + /** Shell command line run in the chosen dir (e.g. "claude --permission-mode auto"). */ runs: string; /** fzf-style border label. */ label: string; @@ -37,11 +37,15 @@ export interface ThemeConfig { statusInactive: string; } +/** Claude Code permission mode agentctl launches/resumes with. */ +export type PermissionMode = 'auto' | 'bypassPermissions'; + export interface AgentctlConfig { groups: GroupConfig[]; tools: ToolConfig[]; ides: IdeConfig[]; defaultTool: string; + permissionMode: PermissionMode; theme: ThemeConfig; } @@ -53,6 +57,7 @@ export type ConfigWarningCode = | 'missing-group-path' | 'dup-tool-name' | 'unknown-default-tool' + | 'bad-permission-mode' | 'unknown-var'; export interface ConfigWarning { diff --git a/src/core/config/validate.ts b/src/core/config/validate.ts index bd25737..064c07f 100644 --- a/src/core/config/validate.ts +++ b/src/core/config/validate.ts @@ -1,11 +1,12 @@ import { expandPath } from './paths.js'; -import { DEFAULT_COLOR, DEFAULT_THEME } from './defaults.js'; +import { DEFAULT_COLOR, DEFAULT_PERMISSION_MODE, DEFAULT_THEME } from './defaults.js'; import { ConfigError, type AgentctlConfig, type ConfigWarning, type GroupConfig, type IdeConfig, + type PermissionMode, type ThemeConfig, type ToolConfig, } from './types.js'; @@ -135,11 +136,19 @@ export function validateConfig( defaultTool = tools[0].name; } + let permissionMode: PermissionMode = DEFAULT_PERMISSION_MODE; + if (raw.permission_mode === 'auto' || raw.permission_mode === 'bypassPermissions') { + permissionMode = raw.permission_mode; + } else if (raw.permission_mode !== undefined) { + warnings.push({ code: 'bad-permission-mode', message: `permission_mode "${String(raw.permission_mode)}" must be "auto" or "bypassPermissions" → "auto"` }); + } + const config: AgentctlConfig = { groups, tools, ides, defaultTool, + permissionMode, theme: buildTheme(raw.theme), }; return { config, warnings }; diff --git a/tests/cli/resume.test.ts b/tests/cli/resume.test.ts index e2d2e36..29d4e5d 100644 --- a/tests/cli/resume.test.ts +++ b/tests/cli/resume.test.ts @@ -45,22 +45,30 @@ describe('resume', () => { ).toThrow(/busy/); }); - it('spawns claude with --resume and --dangerously-skip-permissions', () => { + it('spawns claude with --resume and --permission-mode auto by default', () => { const spawn = vi.fn(() => ({ status: 0, error: undefined })) as never; const exit = vi.fn(); - resume(record(), {}, { exists: () => true, spawn, exit: exit as never }); + resume(record(), {}, { exists: () => true, spawn, exit: exit as never, permissionMode: () => 'auto' }); expect(spawn).toHaveBeenCalledOnce(); const call = (spawn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]; expect(call[0]).toBe('claude'); expect(call[1]).toEqual([ '--resume', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - '--dangerously-skip-permissions', + '--permission-mode', + 'auto', ]); expect((call[2] as { cwd: string }).cwd).toBe('/tmp'); expect(exit).toHaveBeenCalledWith(0); }); + it('opt-in bypassPermissions resumes with --dangerously-skip-permissions', () => { + const spawn = vi.fn(() => ({ status: 0, error: undefined })) as never; + resume(record(), {}, { exists: () => true, spawn, exit: vi.fn() as never, permissionMode: () => 'bypassPermissions' }); + const call = (spawn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]; + expect(call[1]).toEqual(['--resume', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '--dangerously-skip-permissions']); + }); + it('throws ResumeError(127) when claude binary missing', () => { const enoent = new Error('ENOENT') as NodeJS.ErrnoException; enoent.code = 'ENOENT'; diff --git a/tests/cli/screens.test.tsx b/tests/cli/screens.test.tsx index e8f6ed1..6f59c99 100644 --- a/tests/cli/screens.test.tsx +++ b/tests/cli/screens.test.tsx @@ -37,6 +37,7 @@ const config: AgentctlConfig = { tools: [{ name: 'claude', runs: 'claude --x', label: ' c ', color: '#6C91BF' }], ides: [{ key: 'ctrl-v', label: 'code', cmd: 'code "$dir"' }], defaultTool: 'claude', + permissionMode: 'auto', theme: { accent: '#FF9F43', border: '#6C91BF', pointer: '#FF9F43', statusBusy: 'green', statusIdle: 'yellow', statusInactive: 'gray' }, }; const dir = (name: string, group: string, color: string): ProjectDir => diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts index 13f3553..7b4fb8c 100644 --- a/tests/core/config.test.ts +++ b/tests/core/config.test.ts @@ -127,7 +127,24 @@ describe('getTool', () => { const p = cfg(`[[tool]]\nname="foo"\nruns="foo --go"\n`); const { config } = loadConfig({ path: p }); expect(getTool(config, 'foo').runs).toBe('foo --go'); - expect(getTool(config, 'claude').runs).toBe('claude --dangerously-skip-permissions'); + expect(getTool(config, 'claude').runs).toBe('claude --permission-mode auto'); expect(getTool(config, 'bar').runs).toBe('bar'); // synthesized }); }); + +describe('permission_mode', () => { + it('defaults to auto', () => { + const { config } = loadConfig({ path: cfg('') }); + expect(config.permissionMode).toBe('auto'); + }); + it('bypassPermissions opts in and drives the built-in claude tool', () => { + const { config } = loadConfig({ path: cfg('permission_mode = "bypassPermissions"\n') }); + expect(config.permissionMode).toBe('bypassPermissions'); + expect(getTool(config, 'claude').runs).toBe('claude --dangerously-skip-permissions'); + }); + it('invalid value warns and falls back to auto', () => { + const { config, warnings } = loadConfig({ path: cfg('permission_mode = "yolo"\n') }); + expect(config.permissionMode).toBe('auto'); + expect(warnings.map(w => w.code)).toContain('bad-permission-mode'); + }); +});