Add Auto permission mode #63

Merged
jknapp merged 3 commits from feat/auto-permission-mode into main 2026-09-25 17:02:07 +00:00
12 changed files with 74 additions and 17 deletions
Showing only changes of commit 1a31cf4128 - Show all commits
+12 -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,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 > 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 and model; see Claude Code's documentation on permission modes.
### 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 +500,9 @@ 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. 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 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); 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,18 @@ 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, 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 () => { 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,17 @@ 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(--text-secondary)]">
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.
</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";
+4 -2
View File
@@ -61,12 +61,14 @@ TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
# project's permission setting. Keep this mapping in sync with # project's permission setting. Keep this mapping in sync with
# 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" or "auto" means the task may stop early when Claude
# for permission. Unset or unrecognized values pass no flag (Claude's default). # 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=() 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