diff --git a/.env.example b/.env.example index d36e2b0e..bf75e529 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,11 @@ TMUX_SESSION_NAME=ccbot # Claude command to run in new windows (optional, defaults to "claude") CLAUDE_COMMAND=claude +# Claude permission mode (optional; unset = use claude's default). +# One of: default, acceptEdits, plan, auto, bypassPermissions. +# Prefer `auto` over `bypassPermissions` on headless hosts — auto keeps +# the safety classifier that blocks risky actions; bypass disables it. +# CLAUDE_PERMISSION_MODE=auto + # Monitor polling interval in seconds (optional, defaults to 2.0) MONITOR_POLL_INTERVAL=2.0 diff --git a/README.md b/README.md index e7ee01dc..b742539b 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ ALLOWED_USERS=your_telegram_user_id | `CCBOT_DIR` | `~/.ccbot` | Config/state directory (`.env` loaded from here) | | `TMUX_SESSION_NAME` | `ccbot` | Tmux session name | | `CLAUDE_COMMAND` | `claude` | Command to run in new windows | +| `CLAUDE_PERMISSION_MODE` | _(unset)_ | `default` / `acceptEdits` / `plan` / `auto` / `bypassPermissions`. Appended as `--permission-mode ` when set. | | `MONITOR_POLL_INTERVAL` | `2.0` | Polling interval in seconds | | `CCBOT_SHOW_HIDDEN_DIRS` | `false` | Show hidden (dot) directories in directory browser | | `OPENAI_API_KEY` | _(none)_ | OpenAI API key for voice message transcription | @@ -101,11 +102,13 @@ ALLOWED_USERS=your_telegram_user_id Message formatting is always HTML via `chatgpt-md-converter` (`chatgpt_md_converter` package). There is no runtime formatter switch to MarkdownV2. -> If running on a VPS where there's no interactive terminal to approve permissions, consider: +> If running on a VPS where there's no interactive terminal to approve permissions, consider using **auto mode** — Claude Code runs actions automatically but a background classifier blocks risky ones (exfiltration, force-pushes to main, arbitrary downloads, etc.): > > ``` -> CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions +> CLAUDE_PERMISSION_MODE=auto > ``` +> +> As a last resort, `CLAUDE_PERMISSION_MODE=bypassPermissions` skips all prompts but also disables the classifier — prefer `auto` when your plan/model supports it. ## Hook Setup (Recommended) diff --git a/README_CN.md b/README_CN.md index c8bd8bd3..6735fef1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -90,6 +90,7 @@ ALLOWED_USERS=your_telegram_user_id | `CCBOT_DIR` | `~/.ccbot` | 配置/状态目录(`.env` 从此目录加载) | | `TMUX_SESSION_NAME` | `ccbot` | tmux 会话名称 | | `CLAUDE_COMMAND` | `claude` | 新窗口中运行的命令 | +| `CLAUDE_PERMISSION_MODE` | _(未设置)_ | `default` / `acceptEdits` / `plan` / `auto` / `bypassPermissions`。设置后会在启动 claude 时追加 `--permission-mode `。 | | `MONITOR_POLL_INTERVAL` | `2.0` | 轮询间隔(秒) | | `CCBOT_SHOW_HIDDEN_DIRS` | `false` | 在目录浏览器中显示隐藏(点开头)目录 | | `OPENAI_API_KEY` | _(无)_ | OpenAI API 密钥,用于语音消息转录 | @@ -98,10 +99,13 @@ ALLOWED_USERS=your_telegram_user_id 消息格式化目前固定为 HTML,使用 `chatgpt-md-converter`(`chatgpt_md_converter` 包)。 不再提供运行时切换到 MarkdownV2 的开关。 -> 如果在 VPS 上运行且没有交互终端来批准权限,可以考虑: +> 如果在 VPS 上运行且没有交互终端来批准权限,建议使用 **auto 模式** —— Claude Code 会自动执行动作,同时后台分类器会拦截危险操作(数据外泄、强推 main、任意下载等): +> > ``` -> CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions +> CLAUDE_PERMISSION_MODE=auto > ``` +> +> 作为兜底方案,`CLAUDE_PERMISSION_MODE=bypassPermissions` 会跳过所有确认,但也会关闭分类器 —— 如果你的套餐/模型支持,优先使用 `auto`。 ## Hook 设置(推荐) diff --git a/README_RU.md b/README_RU.md index faaf2181..dc20d179 100644 --- a/README_RU.md +++ b/README_RU.md @@ -91,6 +91,7 @@ ALLOWED_USERS=your_telegram_user_id | `CCBOT_DIR` | `~/.ccbot` | Каталог конфигурации/состояния (`.env` грузится отсюда) | | `TMUX_SESSION_NAME` | `ccbot` | Имя tmux-сессии | | `CLAUDE_COMMAND` | `claude` | Команда запуска в новых окнах | +| `CLAUDE_PERMISSION_MODE` | _(не задано)_ | `default` / `acceptEdits` / `plan` / `auto` / `bypassPermissions`. Добавляется как `--permission-mode ` при запуске claude. | | `MONITOR_POLL_INTERVAL` | `2.0` | Интервал опроса в секундах | | `CCBOT_SHOW_HIDDEN_DIRS` | `false` | Показывать скрытые (dot) директории в браузере каталогов | | `OPENAI_API_KEY` | _(нет)_ | API-ключ OpenAI для транскрипции голосовых сообщений | @@ -99,11 +100,13 @@ ALLOWED_USERS=your_telegram_user_id Форматирование сообщений всегда HTML через `chatgpt-md-converter` (`chatgpt_md_converter`). Переключателя формата на MarkdownV2 во время выполнения нет. -> Если бот запущен на VPS без интерактивного терминала для подтверждений, можно использовать: +> Если бот запущен на VPS без интерактивного терминала для подтверждений, используйте **auto-режим** — Claude Code выполняет действия автоматически, а фоновый классификатор блокирует опасные (утечки, force-push в main, произвольные загрузки и т. п.): > > ``` -> CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions +> CLAUDE_PERMISSION_MODE=auto > ``` +> +> В крайнем случае `CLAUDE_PERMISSION_MODE=bypassPermissions` пропускает все подтверждения, но отключает и классификатор — предпочитайте `auto`, если ваш план/модель это поддерживают. ## Настройка Hook (рекомендуется) diff --git a/src/ccbot/config.py b/src/ccbot/config.py index 22d1de76..dc98efaa 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -21,6 +21,12 @@ # Env vars that must not leak to child processes (e.g. Claude Code via tmux) SENSITIVE_ENV_VARS = {"TELEGRAM_BOT_TOKEN", "ALLOWED_USERS", "OPENAI_API_KEY"} +# Valid values for CLAUDE_PERMISSION_MODE (mirrors `claude --permission-mode`). +# Empty string / unset means "don't pass the flag". +CLAUDE_PERMISSION_MODES = frozenset( + {"default", "acceptEdits", "plan", "auto", "bypassPermissions"} +) + class Config: """Application configuration loaded from environment variables.""" @@ -64,6 +70,19 @@ def __init__(self) -> None: # Claude command to run in new windows self.claude_command = os.getenv("CLAUDE_COMMAND", "claude") + # Optional Claude permission mode — appended as `--permission-mode ` + # when launching claude. Prefer `auto` over `bypassPermissions`: auto mode + # keeps a safety classifier that blocks risky actions, while bypass skips + # all checks. See https://code.claude.com/docs/en/permission-modes.md + permission_mode = os.getenv("CLAUDE_PERMISSION_MODE", "").strip() + if permission_mode and permission_mode not in CLAUDE_PERMISSION_MODES: + raise ValueError( + f"CLAUDE_PERMISSION_MODE={permission_mode!r} is invalid. " + f"Expected one of: {', '.join(sorted(CLAUDE_PERMISSION_MODES))}, " + "or leave unset." + ) + self.claude_permission_mode: str = permission_mode + # All state files live under config_dir self.state_file = self.config_dir / "state.json" self.session_map_file = self.config_dir / "session_map.json" diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index f05b4f3a..7b7252fd 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -25,6 +25,25 @@ logger = logging.getLogger(__name__) +def build_claude_command( + base_command: str, + *, + permission_mode: str = "", + resume_session_id: str | None = None, +) -> str: + """Compose the shell command used to launch Claude Code in a new window. + + `--permission-mode` is placed before `--resume` so mode applies to the + resumed session. Both args are only appended when set. + """ + cmd = base_command + if permission_mode: + cmd = f"{cmd} --permission-mode {permission_mode}" + if resume_session_id: + cmd = f"{cmd} --resume {resume_session_id}" + return cmd + + @dataclass class TmuxWindow: """Information about a tmux window.""" @@ -418,9 +437,11 @@ def _create_and_start() -> tuple[bool, str, str, str]: if start_claude: pane = window.active_pane if pane: - cmd = config.claude_command - if resume_session_id: - cmd = f"{cmd} --resume {resume_session_id}" + cmd = build_claude_command( + config.claude_command, + permission_mode=config.claude_permission_mode, + resume_session_id=resume_session_id, + ) pane.send_keys(cmd, enter=True) logger.info( diff --git a/tests/ccbot/test_config.py b/tests/ccbot/test_config.py index 95cf35f9..4fddf415 100644 --- a/tests/ccbot/test_config.py +++ b/tests/ccbot/test_config.py @@ -92,6 +92,43 @@ def test_ccbot_projects_path_takes_priority(self, monkeypatch): assert cfg.claude_projects_path == Path("/priority/path") +@pytest.mark.usefixtures("_base_env") +class TestConfigPermissionMode: + def test_default_is_empty(self, monkeypatch): + monkeypatch.delenv("CLAUDE_PERMISSION_MODE", raising=False) + cfg = Config() + assert cfg.claude_permission_mode == "" + + @pytest.mark.parametrize( + "mode", ["default", "acceptEdits", "plan", "auto", "bypassPermissions"] + ) + def test_accepts_valid_modes(self, monkeypatch, mode): + monkeypatch.setenv("CLAUDE_PERMISSION_MODE", mode) + cfg = Config() + assert cfg.claude_permission_mode == mode + + def test_whitespace_trimmed(self, monkeypatch): + monkeypatch.setenv("CLAUDE_PERMISSION_MODE", " auto ") + cfg = Config() + assert cfg.claude_permission_mode == "auto" + + def test_empty_string_is_no_op(self, monkeypatch): + monkeypatch.setenv("CLAUDE_PERMISSION_MODE", "") + cfg = Config() + assert cfg.claude_permission_mode == "" + + def test_rejects_unknown_mode(self, monkeypatch): + monkeypatch.setenv("CLAUDE_PERMISSION_MODE", "yolo") + with pytest.raises(ValueError, match="CLAUDE_PERMISSION_MODE"): + Config() + + def test_case_sensitive(self, monkeypatch): + # Claude CLI is case-sensitive (`acceptEdits` not `acceptedits`). + monkeypatch.setenv("CLAUDE_PERMISSION_MODE", "Auto") + with pytest.raises(ValueError, match="CLAUDE_PERMISSION_MODE"): + Config() + + @pytest.mark.usefixtures("_base_env") class TestConfigOpenAI: def test_openai_defaults(self, monkeypatch): diff --git a/tests/ccbot/test_tmux_command.py b/tests/ccbot/test_tmux_command.py new file mode 100644 index 00000000..174749cb --- /dev/null +++ b/tests/ccbot/test_tmux_command.py @@ -0,0 +1,46 @@ +"""Unit tests for build_claude_command — shell command string assembly.""" + +import pytest + +from ccbot.tmux_manager import build_claude_command + + +class TestBuildClaudeCommand: + def test_plain_command(self): + assert build_claude_command("claude") == "claude" + + def test_preserves_custom_base_command(self): + # Matches the README pattern `IS_SANDBOX=1 claude`. + assert build_claude_command("IS_SANDBOX=1 claude") == "IS_SANDBOX=1 claude" + + def test_permission_mode_appended(self): + assert ( + build_claude_command("claude", permission_mode="auto") + == "claude --permission-mode auto" + ) + + def test_empty_permission_mode_is_no_op(self): + assert build_claude_command("claude", permission_mode="") == "claude" + + def test_resume_only(self): + assert ( + build_claude_command("claude", resume_session_id="abc-123") + == "claude --resume abc-123" + ) + + def test_permission_mode_precedes_resume(self): + # Placing --permission-mode before --resume ensures the flag applies + # to the resumed session (CLI order matters for some claude versions). + assert ( + build_claude_command( + "claude", permission_mode="auto", resume_session_id="abc-123" + ) + == "claude --permission-mode auto --resume abc-123" + ) + + @pytest.mark.parametrize( + "mode", ["default", "acceptEdits", "plan", "auto", "bypassPermissions"] + ) + def test_each_mode_roundtrips(self, mode): + result = build_claude_command("claude", permission_mode=mode) + assert result == f"claude --permission-mode {mode}"