diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 3236da7..f135150 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -469,6 +469,7 @@ replaces the old Full Permissions on/off switch. | **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` | | **Default** | Asks before each tool call | *(nothing — Claude Code's own default)* | | **Accept Edits** | Auto-approves file edits; other tools still prompt | `--permission-mode acceptEdits` | +| **Auto** | A safety classifier approves routine actions and blocks risky ones, without prompting | `--permission-mode auto` | | **Bypass** | Auto-approves every tool call | `--dangerously-skip-permissions` | New projects start in **Default**. Projects created before permission modes existed keep behaving @@ -480,12 +481,19 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that > has Docker socket access or reaches services on your network. The Overview tab tells you whether > the in-container sandbox is also on. +**Auto** sits between Accept Edits and Bypass: Claude Code's own classifier reviews each action, +lets routine work through and blocks things that look risky (such as destructive or +exfiltrating commands) — no prompts either way. Whether it is available depends on your Claude +Code account, model and backend (local and OpenAI-compatible backends usually won't +qualify). When it isn't available, Claude Code quietly starts in its normal prompting mode +instead. + ### When a change takes effect - **Terminals** — the mode is applied when a terminal is opened, so it affects terminals you open from then on. A Claude session that is already running keeps the permissions it started with; close the tab and open a new terminal to change it. The badge on each terminal tab shows the mode - that terminal was launched with (`plan`, `ask`, `edits`, `bypass`). + that terminal was launched with (`plan`, `ask`, `edits`, `auto`, `bypass`). - **Resumed sessions** — a session resumed from the **Sessions** tab uses the project's current mode. - **Scheduled tasks** — these now honour the permission mode too (they previously always ran with @@ -494,8 +502,10 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that mode change to reach the scheduler. > Scheduled tasks run headless (`claude -p`) and cannot answer a permission prompt. In any mode -> other than **Bypass**, a task may simply stop early when Claude Code asks for approval. Its run -> log records which mode it used. +> other than **Auto** or **Bypass**, a task may simply stop early when Claude Code asks for +> approval. In **Auto**, actions the classifier blocks are denied and the run carries on without +> them — but if Auto isn't available for the project's model or backend, Claude Code falls back +> to prompting and the task can stall the same way. Its run log records which mode it used. --- @@ -1349,7 +1359,9 @@ Scheduled runs use the project's [permission mode](#permission-modes) — they n with `--dangerously-skip-permissions`. Because the mode travels into the container as an environment variable, **stop and start the project** after changing it for the scheduler to see the change. Remember that a headless run cannot answer a permission prompt, so in any mode other than -**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode +**Auto** or **Bypass** a task may stop early when Claude Code asks for approval (in Auto, blocked +actions are denied instead, unless Auto is unavailable and Claude Code falls back to +prompting); the run log records the mode that was used. ### Creating Tasks diff --git a/README.md b/README.md index 7245a8c..ce6435f 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ progress modal. ## Permission Modes -`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states, +`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Five states, mapped to CLI flags by `PermissionMode::cli_args()`: | Mode | Serialized | CLI args passed to `claude` | @@ -122,6 +122,7 @@ mapped to CLI flags by `PermissionMode::cli_args()`: | **Plan** | `plan` | `--permission-mode plan` | | **Default** | `default` | *(none)* | | **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` | +| **Auto** | `auto` | `--permission-mode auto` | | **Bypass** | `bypass` | `--dangerously-skip-permissions` | `Project.permission_mode` is `Option`; `effective_permission_mode()` falls back to @@ -531,7 +532,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi | `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic | | `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls | | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | -| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control | +| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Auto / Bypass segmented control | | `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` | | `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` | | `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines | diff --git a/TECHNICAL.md b/TECHNICAL.md index 1911733..3111fd9 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -186,7 +186,7 @@ host keychain secrets. ### Permission Modes -`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions` +`PermissionMode` (`models/project.rs`) is a five-state enum replacing the earlier `full_permissions` boolean. It reaches Claude Code by two different routes: | Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler | @@ -194,6 +194,7 @@ boolean. It reaches Claude Code by two different routes: | `Plan` | `--permission-mode plan` | `plan` | | `Default` | *(no flag)* | `default` | | `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` | +| `Auto` | `--permission-mode auto` | `auto` | | `Bypass` | `--dangerously-skip-permissions` | `bypass` | `Project.permission_mode` is `Option`, and `effective_permission_mode()` resolves @@ -474,7 +475,7 @@ triple-c/ │ │ ├── ProjectRow.tsx # Select-only sidebar row │ │ ├── ProjectList.tsx # Sidebar project list │ │ ├── AddProjectDialog.tsx # New-project dialog - │ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass + │ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Auto/Bypass │ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation │ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings, │ │ # ClaudeInstructions, ClaudeCodeSettings — diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index f7e1cd1..1d94b87 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -469,6 +469,20 @@ mod tests { assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]); } + /// Auto mode is passed as a `--permission-mode` value, not its own flag. + #[test] + fn build_terminal_cmd_passes_auto_permission_mode() { + let mut p = project("anthropic", serde_json::Value::Null); + p.permission_mode = Some(crate::models::project::PermissionMode::Auto); + let cmd = build_claude_terminal_cmd(&p, None, None); + + assert!( + cmd[2].contains("exec claude '--permission-mode' 'auto'"), + "got: {}", + cmd[2] + ); + } + /// The Bedrock-profile path keeps its AWS validation *and* gains the /// prelude, immediately before the exec. #[test] diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index 70905fd..0f32161 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -166,6 +166,9 @@ pub enum PermissionMode { Default, /// Auto-accept file edits, prompt for everything else. AcceptEdits, + /// Claude Code's classifier approves safe actions and blocks risky ones, + /// without prompting. + Auto, /// Skip all permission prompts. Bypass, } @@ -180,6 +183,7 @@ impl PermissionMode { PermissionMode::AcceptEdits => { vec!["--permission-mode".to_string(), "acceptEdits".to_string()] } + PermissionMode::Auto => vec!["--permission-mode".to_string(), "auto".to_string()], PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()], } } @@ -191,6 +195,7 @@ impl PermissionMode { PermissionMode::Plan => "plan", PermissionMode::Default => "default", PermissionMode::AcceptEdits => "acceptEdits", + PermissionMode::Auto => "auto", PermissionMode::Bypass => "bypass", } } diff --git a/app/src/components/layout/MainTabs.tsx b/app/src/components/layout/MainTabs.tsx index ef90553..bf356e2 100644 --- a/app/src/components/layout/MainTabs.tsx +++ b/app/src/components/layout/MainTabs.tsx @@ -26,6 +26,7 @@ const MODE_BADGE: Record = plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" }, + auto: { text: "auto", className: "bg-[var(--accent-muted)] text-[var(--accent)]" }, bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" }, }; diff --git a/app/src/components/projects/PermissionModeControl.test.tsx b/app/src/components/projects/PermissionModeControl.test.tsx index 84f115e..5249904 100644 --- a/app/src/components/projects/PermissionModeControl.test.tsx +++ b/app/src/components/projects/PermissionModeControl.test.tsx @@ -75,11 +75,11 @@ describe("PermissionModeControl", () => { vi.clearAllMocks(); }); - it("renders all four modes as a radio group with the effective one checked", () => { + it("renders all five modes as a radio group with the effective one checked", () => { render(); const group = screen.getByRole("radiogroup", { name: "Permission mode" }); expect(group).toBeInTheDocument(); - expect(screen.getAllByRole("radio")).toHaveLength(4); + expect(screen.getAllByRole("radio")).toHaveLength(5); expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute( "aria-checked", "true", @@ -92,6 +92,14 @@ describe("PermissionModeControl", () => { expect(onChange).toHaveBeenCalledWith("acceptEdits"); }); + it("offers Auto between Accept Edits and Bypass", () => { + render(); + const labels = screen.getAllByRole("radio").map((r) => r.textContent); + expect(labels).toEqual(["Plan", "Default", "Accept Edits", "Auto", "Bypass"]); + fireEvent.click(screen.getByRole("radio", { name: "Auto" })); + expect(onChange).toHaveBeenCalledWith("auto"); + }); + it("moves selection with the arrow keys", () => { render(); fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), { diff --git a/app/src/components/projects/PermissionModeControl.tsx b/app/src/components/projects/PermissionModeControl.tsx index e72930f..8d32012 100644 --- a/app/src/components/projects/PermissionModeControl.tsx +++ b/app/src/components/projects/PermissionModeControl.tsx @@ -9,6 +9,11 @@ export const PERMISSION_MODES: Segment[] = [ label: "Accept Edits", hint: "File edits are auto-approved; other tools still prompt.", }, + { + value: "auto", + label: "Auto", + hint: "A safety classifier approves routine actions and blocks risky ones, without prompting.", + }, { value: "bypass", label: "Bypass", diff --git a/app/src/components/projects/home/TaskEditorModal.test.tsx b/app/src/components/projects/home/TaskEditorModal.test.tsx index d0c15ef..62e942e 100644 --- a/app/src/components/projects/home/TaskEditorModal.test.tsx +++ b/app/src/components/projects/home/TaskEditorModal.test.tsx @@ -159,12 +159,19 @@ describe("TaskEditorModal", () => { }); it("warns that a headless run cannot answer a permission prompt", async () => { - // Bypass is the only mode where an unattended run is safe from stalling. + // Bypass (and Auto, below) are the modes where an unattended run cannot stall. await renderEditor(null, { ...baseProject, permission_mode: "bypass" }); expect(screen.getByText(/headless/i)).toBeInTheDocument(); expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull(); }); + it("tells Auto mode that blocked actions are denied, and warns of the fallback", async () => { + await renderEditor(null, { ...baseProject, permission_mode: "auto" }); + expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull(); + expect(screen.getByText(/blocks are denied/i)).toBeInTheDocument(); + expect(screen.getByText(/falls back to prompting/i)).toBeInTheDocument(); + }); + it("spells out the stall risk in any non-Bypass mode", async () => { await renderEditor(null, { ...baseProject, permission_mode: "default" }); expect(screen.getByText(/cannot answer a permission prompt/i)).toBeInTheDocument(); diff --git a/app/src/components/projects/home/TaskEditorModal.tsx b/app/src/components/projects/home/TaskEditorModal.tsx index 09a57ca..90dc845 100644 --- a/app/src/components/projects/home/TaskEditorModal.tsx +++ b/app/src/components/projects/home/TaskEditorModal.tsx @@ -301,11 +301,18 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro terminal attached, using this project’s permission mode ( {modeLabel}).

- {mode !== "bypass" && ( + {mode === "auto" && ( +

+ In Auto mode, actions the safety classifier blocks are denied and the run carries on + without them. If Auto isn’t available for this project’s model or backend, + Claude Code falls back to prompting and the task may stall. +

+ )} + {mode !== "bypass" && mode !== "auto" && (

A headless run cannot answer a permission prompt. In {modeLabel} mode the task may - stall and produce an empty log; set the mode to Bypass in the Config tab for - unattended runs. + stall and produce an empty log; set the mode to Auto or Bypass in the Config tab + for unattended runs.

)} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 54bf911..849a5bf 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -126,7 +126,7 @@ export const CUSTOM_ENDPOINT_BACKENDS: readonly Backend[] = [ ]; /** Mirrors Rust `PermissionMode` (serde camelCase). */ -export type PermissionMode = "plan" | "default" | "acceptEdits" | "bypass"; +export type PermissionMode = "plan" | "default" | "acceptEdits" | "auto" | "bypass"; export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token"; diff --git a/container/triple-c-task-runner b/container/triple-c-task-runner index 7ce583b..cac8748 100644 --- a/container/triple-c-task-runner +++ b/container/triple-c-task-runner @@ -62,11 +62,15 @@ TASK_TYPE=$(jq -r '.type' "$TASK_FILE") # PermissionMode::cli_args() in app/src-tauri/src/models/project.rs. # NOTE: headless `claude -p` runs cannot answer a permission prompt, so any # mode other than "bypass" means the task may stop early when Claude Code asks -# for permission. Unset or unrecognized values pass no flag (Claude's default). +# for permission. In "auto", classifier-blocked actions are denied instead of +# prompted, but if auto mode is unavailable for the session's model/backend, +# Claude Code falls back to prompting and the same stall applies. +# Unset or unrecognized values pass no flag (Claude's default). PERMISSION_ARGS=() case "${TRIPLE_C_PERMISSION_MODE:-}" in plan) PERMISSION_ARGS=(--permission-mode plan) ;; acceptEdits) PERMISSION_ARGS=(--permission-mode acceptEdits) ;; + auto) PERMISSION_ARGS=(--permission-mode auto) ;; bypass) PERMISSION_ARGS=(--dangerously-skip-permissions) ;; *) PERMISSION_ARGS=() ;; esac