Compare commits

..
Author SHA1 Message Date
jknappandClaude Opus 5.5 0d117e97fe Add Auto permission mode (#63)
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m58s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m4s
Build App / build-windows (push) Successful in 5m48s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 2m27s
Adds Claude Code's auto permission mode as a fifth option between Accept Edits and Bypass, across terminals, resumed sessions, the tab badge, the scheduler task runner and docs. Warns that Auto falls back to prompting when unavailable for the model/backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:02:06 +00:00
12 changed files with 81 additions and 16 deletions
+16 -4
View File
@@ -469,6 +469,7 @@ replaces the old Full Permissions on/off switch.
| **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` | | **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` |
| **Default** | Asks before each tool call | *(nothing — Claude Code's own default)* | | **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` | | **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` | | **Bypass** | Auto-approves every tool call | `--dangerously-skip-permissions` |
New projects start in **Default**. Projects created before permission modes existed keep behaving 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 > has Docker socket access or reaches services on your network. The Overview tab tells you whether
> the in-container sandbox is also on. > 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 ### When a change takes effect
- **Terminals** — the mode is applied when a terminal is opened, so it affects terminals you open - **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; 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 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 - **Resumed sessions** — a session resumed from the **Sessions** tab uses the project's current
mode. mode.
- **Scheduled tasks** — these now honour the permission mode too (they previously always ran with - **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. mode change to reach the scheduler.
> Scheduled tasks run headless (`claude -p`) and cannot answer a permission prompt. In any mode > 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 > other than **Auto** or **Bypass**, a task may simply stop early when Claude Code asks for
> log records which mode it used. > 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 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 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 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. that was used.
### Creating Tasks ### Creating Tasks
+3 -2
View File
@@ -114,7 +114,7 @@ progress modal.
## Permission Modes ## 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()`: mapped to CLI flags by `PermissionMode::cli_args()`:
| Mode | Serialized | CLI args passed to `claude` | | Mode | Serialized | CLI args passed to `claude` |
@@ -122,6 +122,7 @@ mapped to CLI flags by `PermissionMode::cli_args()`:
| **Plan** | `plan` | `--permission-mode plan` | | **Plan** | `plan` | `--permission-mode plan` |
| **Default** | `default` | *(none)* | | **Default** | `default` | *(none)* |
| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` | | **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` |
| **Auto** | `auto` | `--permission-mode auto` |
| **Bypass** | `bypass` | `--dangerously-skip-permissions` | | **Bypass** | `bypass` | `--dangerously-skip-permissions` |
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to `Project.permission_mode` is `Option<PermissionMode>`; `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/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/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/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/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/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 | | `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
+3 -2
View File
@@ -186,7 +186,7 @@ host keychain secrets.
### Permission Modes ### 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: boolean. It reaches Claude Code by two different routes:
| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler | | 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` | | `Plan` | `--permission-mode plan` | `plan` |
| `Default` | *(no flag)* | `default` | | `Default` | *(no flag)* | `default` |
| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` | | `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` |
| `Auto` | `--permission-mode auto` | `auto` |
| `Bypass` | `--dangerously-skip-permissions` | `bypass` | | `Bypass` | `--dangerously-skip-permissions` | `bypass` |
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves `Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
@@ -474,7 +475,7 @@ triple-c/
│ │ ├── ProjectRow.tsx # Select-only sidebar row │ │ ├── ProjectRow.tsx # Select-only sidebar row
│ │ ├── ProjectList.tsx # Sidebar project list │ │ ├── ProjectList.tsx # Sidebar project list
│ │ ├── AddProjectDialog.tsx # New-project dialog │ │ ├── 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 │ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings, │ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
│ │ # ClaudeInstructions, ClaudeCodeSettings — │ │ # ClaudeInstructions, ClaudeCodeSettings —
@@ -469,6 +469,20 @@ mod tests {
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]); 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 /// The Bedrock-profile path keeps its AWS validation *and* gains the
/// prelude, immediately before the exec. /// prelude, immediately before the exec.
#[test] #[test]
+5
View File
@@ -166,6 +166,9 @@ pub enum PermissionMode {
Default, Default,
/// Auto-accept file edits, prompt for everything else. /// Auto-accept file edits, prompt for everything else.
AcceptEdits, AcceptEdits,
/// Claude Code's classifier approves safe actions and blocks risky ones,
/// without prompting.
Auto,
/// Skip all permission prompts. /// Skip all permission prompts.
Bypass, Bypass,
} }
@@ -180,6 +183,7 @@ impl PermissionMode {
PermissionMode::AcceptEdits => { PermissionMode::AcceptEdits => {
vec!["--permission-mode".to_string(), "acceptEdits".to_string()] 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()], PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()],
} }
} }
@@ -191,6 +195,7 @@ impl PermissionMode {
PermissionMode::Plan => "plan", PermissionMode::Plan => "plan",
PermissionMode::Default => "default", PermissionMode::Default => "default",
PermissionMode::AcceptEdits => "acceptEdits", PermissionMode::AcceptEdits => "acceptEdits",
PermissionMode::Auto => "auto",
PermissionMode::Bypass => "bypass", PermissionMode::Bypass => "bypass",
} }
} }
+1
View File
@@ -26,6 +26,7 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
default: { text: "ask", 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)]" }, 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)]" }, bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
}; };
@@ -75,11 +75,11 @@ describe("PermissionModeControl", () => {
vi.clearAllMocks(); 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(<PermissionModeControl project={baseProject} onChange={onChange} />); render(<PermissionModeControl project={baseProject} onChange={onChange} />);
const group = screen.getByRole("radiogroup", { name: "Permission mode" }); const group = screen.getByRole("radiogroup", { name: "Permission mode" });
expect(group).toBeInTheDocument(); expect(group).toBeInTheDocument();
expect(screen.getAllByRole("radio")).toHaveLength(4); expect(screen.getAllByRole("radio")).toHaveLength(5);
expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute( expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute(
"aria-checked", "aria-checked",
"true", "true",
@@ -92,6 +92,14 @@ describe("PermissionModeControl", () => {
expect(onChange).toHaveBeenCalledWith("acceptEdits"); expect(onChange).toHaveBeenCalledWith("acceptEdits");
}); });
it("offers Auto between Accept Edits and Bypass", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
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", () => { it("moves selection with the arrow keys", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />); render(<PermissionModeControl project={baseProject} onChange={onChange} />);
fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), { fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), {
@@ -9,6 +9,11 @@ export const PERMISSION_MODES: Segment<PermissionMode>[] = [
label: "Accept Edits", label: "Accept Edits",
hint: "File edits are auto-approved; other tools still prompt.", 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", value: "bypass",
label: "Bypass", label: "Bypass",
@@ -159,12 +159,19 @@ describe("TaskEditorModal", () => {
}); });
it("warns that a headless run cannot answer a permission prompt", async () => { 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" }); await renderEditor(null, { ...baseProject, permission_mode: "bypass" });
expect(screen.getByText(/headless/i)).toBeInTheDocument(); expect(screen.getByText(/headless/i)).toBeInTheDocument();
expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull(); 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 () => { it("spells out the stall risk in any non-Bypass mode", async () => {
await renderEditor(null, { ...baseProject, permission_mode: "default" }); await renderEditor(null, { ...baseProject, permission_mode: "default" });
expect(screen.getByText(/cannot answer a permission prompt/i)).toBeInTheDocument(); expect(screen.getByText(/cannot answer a permission prompt/i)).toBeInTheDocument();
@@ -301,11 +301,18 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
terminal attached, using this project&rsquo;s permission mode ( terminal attached, using this project&rsquo;s permission mode (
<strong className="text-[var(--text-primary)]">{modeLabel}</strong>). <strong className="text-[var(--text-primary)]">{modeLabel}</strong>).
</p> </p>
{mode !== "bypass" && ( {mode === "auto" && (
<p className="text-xs text-[var(--warning)]">
In Auto mode, actions the safety classifier blocks are denied and the run carries on
without them. If Auto isn&rsquo;t available for this project&rsquo;s model or backend,
Claude Code falls back to prompting and the task may stall.
</p>
)}
{mode !== "bypass" && mode !== "auto" && (
<p className="text-xs text-[var(--warning)]"> <p className="text-xs text-[var(--warning)]">
A headless run cannot answer a permission prompt. In {modeLabel} mode the task may 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 stall and produce an empty log; set the mode to Auto or Bypass in the Config tab
unattended runs. for unattended runs.
</p> </p>
)} )}
</div> </div>
+1 -1
View File
@@ -126,7 +126,7 @@ export const CUSTOM_ENDPOINT_BACKENDS: readonly Backend[] = [
]; ];
/** Mirrors Rust `PermissionMode` (serde camelCase). */ /** 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"; export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
+5 -1
View File
@@ -62,11 +62,15 @@ TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
# PermissionMode::cli_args() in app/src-tauri/src/models/project.rs. # PermissionMode::cli_args() in app/src-tauri/src/models/project.rs.
# NOTE: headless `claude -p` runs cannot answer a permission prompt, so any # 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 # 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=() PERMISSION_ARGS=()
case "${TRIPLE_C_PERMISSION_MODE:-}" in case "${TRIPLE_C_PERMISSION_MODE:-}" in
plan) PERMISSION_ARGS=(--permission-mode plan) ;; plan) PERMISSION_ARGS=(--permission-mode plan) ;;
acceptEdits) PERMISSION_ARGS=(--permission-mode acceptEdits) ;; acceptEdits) PERMISSION_ARGS=(--permission-mode acceptEdits) ;;
auto) PERMISSION_ARGS=(--permission-mode auto) ;;
bypass) PERMISSION_ARGS=(--dangerously-skip-permissions) ;; bypass) PERMISSION_ARGS=(--dangerously-skip-permissions) ;;
*) PERMISSION_ARGS=() ;; *) PERMISSION_ARGS=() ;;
esac esac