diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md
index 3236da7..ce29584 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,17 @@ 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 and model; see Claude Code's documentation on permission modes.
+
### 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 +500,9 @@ 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. Its run log records which mode it used.
---
@@ -1349,7 +1356,8 @@ 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); 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..68336e7 100644
--- a/app/src/components/projects/home/TaskEditorModal.test.tsx
+++ b/app/src/components/projects/home/TaskEditorModal.test.tsx
@@ -159,12 +159,18 @@ 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, not prompted", 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();
+ });
+
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..4bed6cc 100644
--- a/app/src/components/projects/home/TaskEditorModal.tsx
+++ b/app/src/components/projects/home/TaskEditorModal.tsx
@@ -301,11 +301,17 @@ 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 nothing prompts: actions the safety classifier blocks are denied and the
+ run carries on without them, so check the log if a task seems to have skipped a step.
+
+ )}
+ {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..ceae1e8 100644
--- a/container/triple-c-task-runner
+++ b/container/triple-c-task-runner
@@ -61,12 +61,14 @@ TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
# project's permission setting. Keep this mapping in sync with
# 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).
+# mode other than "bypass" or "auto" means the task may stop early when Claude
+# Code asks for permission. In "auto", actions the classifier blocks are
+# denied rather than prompted, so the run continues without them. 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