From e628edc62c2a68f837f2f306f00066bf049b491a Mon Sep 17 00:00:00 2001 From: siracusa5 Date: Mon, 6 Apr 2026 02:44:02 -0400 Subject: [PATCH 1/4] feat(security): add permission mode selector with first-run modal and tool approval panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hardcoded --allowedTools flag with a user-configurable permission mode system. Three modes: Skip All (default, --dangerously- skip-permissions), Auto (--permission-mode auto, for paid plans), and Allowed Tools (--allowedTools with per-tool checklist). Security → Permissions now leads with a mode selector section featuring icon cards, flag badges, and a clear selected state. An allowed-tools checklist and per-harness overrides are available below. A one-time first-run modal surfaces before the first task execution. A slide-in ToolApprovalPanel lets users manage the allowed tools list mid-session from within the terminals view. All preferences persist in localStorage under harness-kit- keys. resetPermissionDefaults() clears tool list, ack flag, and overrides. 638/638 tests passing. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 2 + .../components/FirstRunPermissionModal.tsx | 157 +++ .../terminals/ToolApprovalPanel.tsx | 229 +++++ .../src/hooks/__tests__/useTerminals.test.ts | 13 +- apps/desktop/src/hooks/useTaskExecution.ts | 29 +- apps/desktop/src/hooks/useTerminals.ts | 25 +- .../lib/__tests__/harness-definitions.test.ts | 209 +++- apps/desktop/src/lib/harness-definitions.ts | 50 +- apps/desktop/src/lib/preferences.ts | 72 ++ apps/desktop/src/lib/tool-names.ts | 15 + .../src/pages/security/PermissionsPage.tsx | 911 +++++++++++++----- .../__tests__/PermissionsPage.test.tsx | 43 +- 12 files changed, 1438 insertions(+), 317 deletions(-) create mode 100644 apps/desktop/src/components/FirstRunPermissionModal.tsx create mode 100644 apps/desktop/src/components/terminals/ToolApprovalPanel.tsx create mode 100644 apps/desktop/src/lib/tool-names.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ded717..afb4d6b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ - **Desktop: Comparator v4** — Rebuilt from terminal multiplexer to structured evaluation workbench with 4-phase workflow (Setup → Execution → Results → Judge), session history rail, SQLite persistence, and shared Comparator types - **Marketplace: Ratings and reviews** — install counts, trust tiers, and user ratings on plugin detail pages. - **CLI: `detect` and `init` commands** — `harness-kit detect` shows which AI coding platforms are active in the current directory; `harness-kit init` scaffolds a new `harness.yaml` interactively. +- **Desktop: Permission mode selector** — Security → Permissions now has a Task Execution Mode section with three selectable modes: **Skip All** (`--dangerously-skip-permissions`, default), **Auto** (`--permission-mode auto`, requires Claude team/enterprise/API), and **Allowed Tools** (`--allowedTools `, user-curated checklist). Mode and tool list persist in localStorage and apply globally or per-harness via an expandable overrides section. A one-time first-run modal explains the active mode before the first task runs and links to the settings page. A slide-in Tool Approval panel lets users manage the allowed tools list mid-session from within the terminals view. +- **Desktop: Security → Permissions redesign** — Permission mode cards with icons, flag badges, and clear selected state. Chip remove buttons now use SVG icons. Improved section hierarchy with a clean divider between execution mode and Claude settings.json controls. ### Changed diff --git a/apps/desktop/src/components/FirstRunPermissionModal.tsx b/apps/desktop/src/components/FirstRunPermissionModal.tsx new file mode 100644 index 00000000..2f5ecf31 --- /dev/null +++ b/apps/desktop/src/components/FirstRunPermissionModal.tsx @@ -0,0 +1,157 @@ +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { useNavigate } from "react-router-dom"; +import { getPermissionMode, setPermissionModeAcked } from "../lib/preferences"; + +const MODE_LABELS: Record = { + skip: { + name: "Skip All Permissions", + description: + "Claude can write files, run shell commands, and access your system without any confirmation prompts.", + }, + auto: { + name: "Auto", + description: + "Claude uses AI classifiers to approve non-destructive actions automatically, while prompting for higher-risk ones.", + }, + "allowed-tools": { + name: "Allowed Tools", + description: + "Only tools on your allow list run without prompting. Everything else requires approval in the terminal.", + }, +}; + +interface FirstRunPermissionModalProps { + onProceed: () => void; +} + +export default function FirstRunPermissionModal({ onProceed }: FirstRunPermissionModalProps) { + const navigate = useNavigate(); + const mode = getPermissionMode(); + const info = MODE_LABELS[mode] ?? MODE_LABELS.skip; + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape" || e.key === "Enter") { + e.preventDefault(); + handleProceed(); + } + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function handleProceed() { + setPermissionModeAcked(); + onProceed(); + } + + function handleChange() { + navigate("/security/permissions"); + } + + return createPortal( +
+
e.stopPropagation()} + > + {/* Amber accent bar */} +
+ +
+ {/* Header */} +
+
+ Before your first task runs +
+
+ HarnessKit will use the following permission mode: +
+
+ + {/* Mode card */} +
+
+ {info.name} +
+
+ {info.description} +
+
+ + {/* Footer note */} +
+ You can change this at any time in{" "} + + . +
+ + {/* Actions */} +
+ + +
+
+
+
, + document.body, + ); +} diff --git a/apps/desktop/src/components/terminals/ToolApprovalPanel.tsx b/apps/desktop/src/components/terminals/ToolApprovalPanel.tsx new file mode 100644 index 00000000..30ee3234 --- /dev/null +++ b/apps/desktop/src/components/terminals/ToolApprovalPanel.tsx @@ -0,0 +1,229 @@ +import { useState } from "react"; +import { + getAllowedTools, + setAllowedTools, + getHarnessPermissionOverrides, + setHarnessPermissionOverrides, + DEFAULT_ALLOWED_TOOLS, +} from "../../lib/preferences"; +import { TOOL_NAMES } from "../../lib/tool-names"; + +// ── Close icon ──────────────────────────────────────────────── + +function CloseIcon() { + return ( + + + + + ); +} + +// ── Check icon ──────────────────────────────────────────────── + +function CheckIcon() { + return ( + + + + ); +} + +// ── Props ───────────────────────────────────────────────────── + +interface ToolApprovalPanelProps { + /** The harness ID of the active session — used to look up per-harness overrides. */ + harnessId?: string; + onClose: () => void; +} + +// ── Component ───────────────────────────────────────────────── + +export default function ToolApprovalPanel({ harnessId, onClose }: ToolApprovalPanelProps) { + // Resolve the effective tool list: harness override → global default + const [tools, setToolsState] = useState(() => { + if (harnessId) { + const overrides = getHarnessPermissionOverrides(); + if (overrides[harnessId]?.allowedTools) return overrides[harnessId].allowedTools!; + } + return getAllowedTools(); + }); + + const isHarnessOverride = harnessId + ? Boolean(getHarnessPermissionOverrides()[harnessId]?.allowedTools) + : false; + const [hasOverride, setHasOverride] = useState(isHarnessOverride); + + function toggle(tool: string) { + const next = tools.includes(tool) + ? tools.filter((t) => t !== tool) + : [...tools, tool]; + persist(next); + } + + function persist(next: string[]) { + setToolsState(next); + if (harnessId && hasOverride) { + const overrides = getHarnessPermissionOverrides(); + setHarnessPermissionOverrides({ + ...overrides, + [harnessId]: { ...overrides[harnessId], allowedTools: next }, + }); + } else { + setAllowedTools(next); + } + } + + function restoreDefaults() { + persist([...DEFAULT_ALLOWED_TOOLS]); + } + + function enableHarnessOverride() { + if (!harnessId) return; + const overrides = getHarnessPermissionOverrides(); + setHarnessPermissionOverrides({ + ...overrides, + [harnessId]: { ...overrides[harnessId], allowedTools: [...tools] }, + }); + setHasOverride(true); + } + + function clearHarnessOverride() { + if (!harnessId) return; + const overrides = getHarnessPermissionOverrides(); + const next = { ...overrides }; + if (next[harnessId]) { + delete next[harnessId].allowedTools; + if (Object.keys(next[harnessId]).length === 0) delete next[harnessId]; + } + setHarnessPermissionOverrides(next); + setHasOverride(false); + setToolsState(getAllowedTools()); + } + + return ( +
+ {/* Header */} +
+
+
+ Allowed Tools +
+
+ {hasOverride && harnessId ? "This harness" : "Global"} +
+
+ +
+ + {/* Tool list */} +
+ {TOOL_NAMES.map((tool) => { + const checked = tools.includes(tool.name); + return ( + + ); + })} +
+ + {/* Footer */} +
+ {harnessId && ( + hasOverride ? ( + + ) : ( + + ) + )} + +
+
+ ); +} diff --git a/apps/desktop/src/hooks/__tests__/useTerminals.test.ts b/apps/desktop/src/hooks/__tests__/useTerminals.test.ts index cef1d184..f9a421f7 100644 --- a/apps/desktop/src/hooks/__tests__/useTerminals.test.ts +++ b/apps/desktop/src/hooks/__tests__/useTerminals.test.ts @@ -17,6 +17,13 @@ vi.mock("@tauri-apps/api/event", () => ({ }), })); +// Mock preferences — return default (skip) mode so tests are deterministic. +vi.mock("../../lib/preferences", () => ({ + getPermissionMode: () => "skip", + getAllowedTools: () => ["Read", "Grep", "Glob"], + getHarnessPermissionOverrides: () => ({}), +})); + // ── Wire-format payloads ────────────────────────────────────── // These MUST match Rust's serde(rename_all = "camelCase") output. // If the Rust struct field names or serde config change, update @@ -159,11 +166,11 @@ describe("useTerminals", () => { await result.current.invokeInTerminal("term-1", "claude", "fix bug", "claude-opus-4-6"); }); - // Should call write_terminal (not invoke_in_terminal) with the built command - // Interactive mode (no -p) for full TUI with live streaming + // Should call write_terminal with the built command. + // Default permission mode is "skip" (--dangerously-skip-permissions). expect(mockInvoke).toHaveBeenCalledWith("write_terminal", { terminalId: "term-1", - data: "claude 'fix bug' --allowedTools Read,Grep,Glob,Agent,Skill --model claude-opus-4-6\n", + data: "claude 'fix bug' --dangerously-skip-permissions --model claude-opus-4-6\n", }); expect(result.current.sessions[0].status).toBe("running"); expect(result.current.sessions[0].harnessId).toBe("claude"); diff --git a/apps/desktop/src/hooks/useTaskExecution.ts b/apps/desktop/src/hooks/useTaskExecution.ts index a788499b..d820c709 100644 --- a/apps/desktop/src/hooks/useTaskExecution.ts +++ b/apps/desktop/src/hooks/useTaskExecution.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { api } from '../lib/board-api'; -import { buildInvokeCommand } from '../lib/harness-definitions'; +import { buildInvokeCommand, type PermissionConfig } from '../lib/harness-definitions'; +import { + getPermissionMode, + getAllowedTools, + getHarnessPermissionOverrides, +} from '../lib/preferences'; import type { Task, Project, ExecutionStatus } from '../lib/board-api'; // ── Types ──────────────────────────────────────────────────── @@ -40,6 +45,25 @@ export interface UseTaskExecutionReturn { const MAX_RAW_CHUNKS = 5000; const DEFAULT_MAX_CONCURRENT = 3; +// ── Permission config resolver ─────────────────────────────── + +/** + * Build the PermissionConfig for a given harness by merging the global mode + * with any harness-level override the user has configured. + */ +function resolvePermissionConfig(harnessId: string): PermissionConfig { + const overrides = getHarnessPermissionOverrides(); + const override = overrides[harnessId]; + const mode = override?.mode ?? getPermissionMode(); + const tools = override?.allowedTools ?? getAllowedTools(); + + switch (mode) { + case "auto": return { mode: "auto" }; + case "allowed-tools": return { mode: "allowed-tools", tools }; + default: return { mode: "skip" }; + } +} + // ── Prompt builder ─────────────────────────────────────────── function buildPrompt(task: Task): string { @@ -128,7 +152,8 @@ export function useTaskExecution(): UseTaskExecutionReturn { const terminalId = await invoke('create_terminal', { projectPath: workDir ?? '' }); const prompt = buildPrompt(task); - const command = buildInvokeCommand(resolvedHarness, prompt, resolvedModel); + const permConfig = resolvePermissionConfig(resolvedHarness); + const command = buildInvokeCommand(resolvedHarness, prompt, resolvedModel, permConfig); if (!command) throw new Error(`Unknown harness: ${resolvedHarness}`); executionsRef.current.set(task.id, { diff --git a/apps/desktop/src/hooks/useTerminals.ts b/apps/desktop/src/hooks/useTerminals.ts index f4fc7d42..d372eb06 100644 --- a/apps/desktop/src/hooks/useTerminals.ts +++ b/apps/desktop/src/hooks/useTerminals.ts @@ -1,7 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { buildInvokeCommand } from "../lib/harness-definitions"; +import { buildInvokeCommand, type PermissionConfig } from "../lib/harness-definitions"; +import { + getPermissionMode, + getAllowedTools, + getHarnessPermissionOverrides, +} from "../lib/preferences"; // ── Types ──────────────────────────────────────────────────── @@ -24,6 +29,21 @@ interface TerminalExitPayload { exitCode: number; } +// ── Permission config resolver ─────────────────────────────── + +function resolvePermissionConfig(harnessId: string): PermissionConfig { + const overrides = getHarnessPermissionOverrides(); + const override = overrides[harnessId]; + const mode = override?.mode ?? getPermissionMode(); + const tools = override?.allowedTools ?? getAllowedTools(); + + switch (mode) { + case "auto": return { mode: "auto" }; + case "allowed-tools": return { mode: "allowed-tools", tools }; + default: return { mode: "skip" }; + } +} + // ── Constants ──────────────────────────────────────────────── const MAX_TERMINALS = 12; @@ -143,7 +163,8 @@ export function useTerminals(): UseTerminalsReturn { async (id: string, harnessId: string, prompt: string, model?: string) => { // NOTE: write_terminal is an unrestricted shell stdin pipe. All command // construction MUST go through buildInvokeCommand to ensure proper quoting. - const command = buildInvokeCommand(harnessId, prompt, model); + const permConfig = resolvePermissionConfig(harnessId); + const command = buildInvokeCommand(harnessId, prompt, model, permConfig); if (!command) { console.error(`Unknown harness: ${harnessId}`); return; diff --git a/apps/desktop/src/lib/__tests__/harness-definitions.test.ts b/apps/desktop/src/lib/__tests__/harness-definitions.test.ts index e93d7c66..723fe52e 100644 --- a/apps/desktop/src/lib/__tests__/harness-definitions.test.ts +++ b/apps/desktop/src/lib/__tests__/harness-definitions.test.ts @@ -3,9 +3,10 @@ import { shellQuote, buildInvokeCommand, BUILTIN_HARNESSES, + type PermissionConfig, } from "../harness-definitions"; -// ── shellQuote ────────────────────────────────────────────── +// ── shellQuote ────────────────────────────────────────────────────────────── describe("shellQuote", () => { it("returns empty quotes for empty string", () => { @@ -48,83 +49,184 @@ describe("shellQuote", () => { it("quotes strings with pipes", () => { expect(shellQuote("a | b")).toBe("'a | b'"); }); + + it("strips null bytes before quoting", () => { + expect(shellQuote("hello\0world")).toBe("helloworld"); + expect(shellQuote("hello\0 world")).toBe("'hello world'"); + expect(shellQuote("\0")).toBe(""); + }); + + it("quotes strings containing newlines", () => { + expect(shellQuote("line1\nline2")).toBe("'line1\nline2'"); + }); + + it("quotes strings containing carriage returns", () => { + expect(shellQuote("line1\rline2")).toBe("'line1\rline2'"); + }); + + it("quotes compound injection attempts", () => { + const result = shellQuote("'; rm -rf /; echo '"); + expect(result).toBe("''\\''; rm -rf /; echo '\\'''"); + }); + + it("quotes strings with $() command substitution", () => { + expect(shellQuote("$(whoami)")).toBe("'$(whoami)'"); + }); }); -// ── buildInvokeCommand ────────────────────────────────────── +// ── buildInvokeCommand — default (skip) mode ──────────────────────────────── -describe("buildInvokeCommand", () => { - // ── Claude Code ───────────────────────────────────────── +describe("buildInvokeCommand — default (no config)", () => { + it("defaults to --dangerously-skip-permissions when no config given", () => { + const cmd = buildInvokeCommand("claude", "fix the bug"); + expect(cmd).toBe("claude 'fix the bug' --dangerously-skip-permissions"); + }); - // All harnesses use interactive mode (no -p) for full TUI with live streaming. + it("includes model when provided", () => { + const cmd = buildInvokeCommand("claude", "fix it", "claude-sonnet-4-6"); + expect(cmd).toBe("claude 'fix it' --dangerously-skip-permissions --model claude-sonnet-4-6"); + }); - it("builds claude command with prompt and allowedTools", () => { - expect(buildInvokeCommand("claude", "fix the bug")).toBe( - "claude 'fix the bug' --allowedTools Read,Grep,Glob,Agent,Skill", - ); + it("quotes prompts with special characters", () => { + const cmd = buildInvokeCommand("claude", "what's this?", "opus"); + expect(cmd).toBe("claude 'what'\\''s this?' --dangerously-skip-permissions --model opus"); }); +}); + +// ── buildInvokeCommand — skip mode ────────────────────────────────────────── + +describe("buildInvokeCommand — { mode: 'skip' }", () => { + const cfg: PermissionConfig = { mode: "skip" }; - it("builds claude command with prompt, allowedTools, and model", () => { - expect(buildInvokeCommand("claude", "fix it", "claude-sonnet-4-6")).toBe( - "claude 'fix it' --allowedTools Read,Grep,Glob,Agent,Skill --model claude-sonnet-4-6", - ); + it("uses --dangerously-skip-permissions", () => { + expect(buildInvokeCommand("claude", "do something", undefined, cfg)) + .toBe("claude 'do something' --dangerously-skip-permissions"); }); - it("quotes claude prompt with special characters", () => { - expect(buildInvokeCommand("claude", "what's this?", "opus")).toBe( - "claude 'what'\\''s this?' --allowedTools Read,Grep,Glob,Agent,Skill --model opus", - ); + it("includes model", () => { + expect(buildInvokeCommand("claude", "do it", "claude-opus-4-6", cfg)) + .toBe("claude 'do it' --dangerously-skip-permissions --model claude-opus-4-6"); }); +}); + +// ── buildInvokeCommand — auto mode ────────────────────────────────────────── - // ── Cursor Agent ──────────────────────────────────────── +describe("buildInvokeCommand — { mode: 'auto' }", () => { + const cfg: PermissionConfig = { mode: "auto" }; - it("builds cursor-agent command with prompt only", () => { - expect(buildInvokeCommand("cursor-agent", "refactor auth")).toBe( - "agent 'refactor auth'", - ); + it("uses --permission-mode auto", () => { + expect(buildInvokeCommand("claude", "do something", undefined, cfg)) + .toBe("claude 'do something' --permission-mode auto"); }); - it("builds cursor-agent command with model", () => { - expect(buildInvokeCommand("cursor-agent", "test", "gpt-5.2")).toBe( - "agent test --model gpt-5.2", - ); + it("includes model", () => { + expect(buildInvokeCommand("claude", "task", "claude-sonnet-4-6", cfg)) + .toBe("claude task --permission-mode auto --model claude-sonnet-4-6"); }); - // ── GitHub Copilot ────────────────────────────────────── + it("never contains --dangerously-skip-permissions", () => { + expect(buildInvokeCommand("claude", "task", undefined, cfg)) + .not.toContain("--dangerously-skip-permissions"); + }); +}); + +// ── buildInvokeCommand — allowed-tools mode ───────────────────────────────── - it("builds copilot command with -i flag for interactive mode", () => { - expect(buildInvokeCommand("copilot", "explain this repo")).toBe( - "copilot -i 'explain this repo'", - ); +describe("buildInvokeCommand — { mode: 'allowed-tools' }", () => { + it("uses --allowedTools with the provided list", () => { + const cfg: PermissionConfig = { mode: "allowed-tools", tools: ["Read", "Grep", "Glob"] }; + expect(buildInvokeCommand("claude", "fix bug", undefined, cfg)) + .toBe("claude 'fix bug' --allowedTools Read,Grep,Glob"); }); - it("builds copilot command with model", () => { - expect(buildInvokeCommand("copilot", "explain", "claude-sonnet-4")).toBe( - "copilot -i explain --model claude-sonnet-4", - ); + it("falls back to --dangerously-skip-permissions when tools list is empty", () => { + const cfg: PermissionConfig = { mode: "allowed-tools", tools: [] }; + expect(buildInvokeCommand("claude", "task", undefined, cfg)) + .toBe("claude task --dangerously-skip-permissions"); + }); + + it("includes model after the tools flag", () => { + const cfg: PermissionConfig = { mode: "allowed-tools", tools: ["Read", "Write"] }; + expect(buildInvokeCommand("claude", "task", "claude-opus-4-6", cfg)) + .toBe("claude task --allowedTools Read,Write --model claude-opus-4-6"); + }); + + it("never contains --dangerously-skip-permissions when tools are specified", () => { + const cfg: PermissionConfig = { mode: "allowed-tools", tools: ["Read"] }; + expect(buildInvokeCommand("claude", "task", undefined, cfg)) + .not.toContain("--dangerously-skip-permissions"); + }); +}); + +// ── Non-Claude harnesses ignore permission config ──────────────────────────── + +describe("buildInvokeCommand — non-Claude harnesses ignore permission config", () => { + const skipCfg: PermissionConfig = { mode: "skip" }; + const autoCfg: PermissionConfig = { mode: "auto" }; + + it("cursor-agent is unaffected by config", () => { + expect(buildInvokeCommand("cursor-agent", "refactor auth", undefined, skipCfg)) + .toBe("agent 'refactor auth'"); + expect(buildInvokeCommand("cursor-agent", "refactor auth", undefined, autoCfg)) + .toBe("agent 'refactor auth'"); + }); + + it("copilot is unaffected by config", () => { + expect(buildInvokeCommand("copilot", "explain this repo", undefined, skipCfg)) + .toBe("copilot -i 'explain this repo'"); }); - // ── Codex CLI ─────────────────────────────────────────── + it("codex is unaffected by config", () => { + expect(buildInvokeCommand("codex", "add tests", undefined, skipCfg)) + .toBe("codex 'add tests'"); + }); + + it("opencode is unaffected by config", () => { + expect(buildInvokeCommand("opencode", "add auth", undefined, skipCfg)) + .toBe("opencode 'add auth'"); + }); +}); + +// ── Other harnesses (no config) ────────────────────────────────────────────── + +describe("buildInvokeCommand — other harnesses", () => { + it("builds cursor-agent command with model", () => { + expect(buildInvokeCommand("cursor-agent", "test", "gpt-5.2")).toBe("agent test --model gpt-5.2"); + }); + + it("builds copilot command with -i flag", () => { + expect(buildInvokeCommand("copilot", "explain this repo")).toBe("copilot -i 'explain this repo'"); + }); + + it("builds copilot command with model", () => { + expect(buildInvokeCommand("copilot", "explain", "claude-sonnet-4")) + .toBe("copilot -i explain --model claude-sonnet-4"); + }); it("builds codex command with positional prompt", () => { - expect(buildInvokeCommand("codex", "add tests")).toBe( - "codex 'add tests'", - ); + expect(buildInvokeCommand("codex", "add tests")).toBe("codex 'add tests'"); }); it("builds codex command with model", () => { - expect(buildInvokeCommand("codex", "refactor", "o4-mini")).toBe( - "codex refactor --model o4-mini", - ); + expect(buildInvokeCommand("codex", "refactor", "o4-mini")).toBe("codex refactor --model o4-mini"); }); - // ── Unknown harness ───────────────────────────────────── + it("builds opencode command with positional prompt", () => { + expect(buildInvokeCommand("opencode", "add auth")).toBe("opencode 'add auth'"); + }); + + it("builds opencode command with model", () => { + expect(buildInvokeCommand("opencode", "refactor", "gpt-4o")).toBe("opencode refactor --model gpt-4o"); + }); it("returns null for unknown harness", () => { expect(buildInvokeCommand("unknown-harness", "hi")).toBeNull(); }); +}); - // ── Registry ──────────────────────────────────────────── +// ── Registry ───────────────────────────────────────────────────────────────── +describe("BUILTIN_HARNESSES registry", () => { it("has 5 built-in harnesses", () => { expect(BUILTIN_HARNESSES).toHaveLength(5); }); @@ -134,3 +236,22 @@ describe("buildInvokeCommand", () => { expect(new Set(ids).size).toBe(ids.length); }); }); + +// ── Model injection prevention ──────────────────────────────────────────────── + +describe("model injection prevention", () => { + it("model with embedded flags is shell-quoted, not treated as real flags", () => { + const cmd = buildInvokeCommand("claude", "task", "sonnet --dangerously-skip-permissions"); + expect(cmd).toContain("'sonnet --dangerously-skip-permissions'"); + }); + + it("model with spaces is shell-quoted", () => { + const cmd = buildInvokeCommand("claude", "task", "claude sonnet"); + expect(cmd).toContain("'claude sonnet'"); + }); + + it("model with dollar sign prevents variable expansion", () => { + const cmd = buildInvokeCommand("claude", "task", "$MODEL"); + expect(cmd).toContain("'$MODEL'"); + }); +}); diff --git a/apps/desktop/src/lib/harness-definitions.ts b/apps/desktop/src/lib/harness-definitions.ts index 1ee6775d..52693d87 100644 --- a/apps/desktop/src/lib/harness-definitions.ts +++ b/apps/desktop/src/lib/harness-definitions.ts @@ -1,4 +1,4 @@ -// ── Shell quoting ─────────────────────────────────────────── +// ── Shell quoting ─────────────────────────────────────────────────────────── /** Wrap a string in single quotes with proper escaping for POSIX shells. */ export function shellQuote(s: string): string { @@ -11,7 +11,32 @@ export function shellQuote(s: string): string { return "'" + clean.replace(/'/g, "'\\''") + "'"; } -// ── Harness definition type ───────────────────────────────── +// ── Permission configuration ──────────────────────────────────────────────── + +export type PermissionMode = "skip" | "auto" | "allowed-tools"; + +export type PermissionConfig = + | { mode: "skip" } + | { mode: "auto" } + | { mode: "allowed-tools"; tools: string[] }; + +const DEFAULT_PERMISSION_CONFIG: PermissionConfig = { mode: "skip" }; + +/** Build the Claude CLI permission flags for a given config. */ +function buildClaudePermissionFlags(config: PermissionConfig): string[] { + switch (config.mode) { + case "skip": + return ["--dangerously-skip-permissions"]; + case "auto": + return ["--permission-mode", "auto"]; + case "allowed-tools": + // Fall back to skip-all if the tools list is empty. + if (config.tools.length === 0) return ["--dangerously-skip-permissions"]; + return ["--allowedTools", config.tools.join(",")]; + } +} + +// ── Harness definition type ───────────────────────────────────────────────── export interface HarnessDefinition { id: string; @@ -19,10 +44,10 @@ export interface HarnessDefinition { /** CLI binary name (used for detection and display, not invocation). */ command: string; /** Build the full shell command to invoke this harness with a prompt. */ - buildCommand: (prompt: string, model?: string) => string; + buildCommand: (prompt: string, model?: string, config?: PermissionConfig) => string; } -// ── Built-in harness definitions ──────────────────────────── +// ── Built-in harness definitions ──────────────────────────────────────────── // Verified from official docs — see plan for references. export const BUILTIN_HARNESSES: HarnessDefinition[] = [ @@ -30,14 +55,9 @@ export const BUILTIN_HARNESSES: HarnessDefinition[] = [ id: "claude", name: "Claude Code", command: "claude", - buildCommand: (prompt, model) => { - // Interactive mode with pre-approved tools so the user doesn't have to - // click through permission prompts for every standard coding tool. - const allowedTools = [ - 'Read', 'Grep', 'Glob', - 'Agent', 'Skill', - ].join(','); - const parts = ["claude", shellQuote(prompt), "--allowedTools", allowedTools]; + buildCommand: (prompt, model, config = DEFAULT_PERMISSION_CONFIG) => { + const permFlags = buildClaudePermissionFlags(config); + const parts = ["claude", shellQuote(prompt), ...permFlags]; if (model) parts.push("--model", shellQuote(model)); return parts.join(" "); }, @@ -85,22 +105,24 @@ export const BUILTIN_HARNESSES: HarnessDefinition[] = [ }, ]; -// ── Lookup + command builder ──────────────────────────────── +// ── Lookup + command builder ──────────────────────────────────────────────── const harnessMap = new Map(BUILTIN_HARNESSES.map((h) => [h.id, h])); /** * Build the shell command string for a given harness invocation. * Returns `null` if the harness ID is unknown (caller should handle custom commands). + * Permission config applies to Claude Code only — other harnesses ignore it. */ export function buildInvokeCommand( harnessId: string, prompt: string, model?: string, + config?: PermissionConfig, ): string | null { const def = harnessMap.get(harnessId); if (!def) return null; - return def.buildCommand(prompt, model); + return def.buildCommand(prompt, model, config); } export function getHarness(id: string): HarnessDefinition | undefined { diff --git a/apps/desktop/src/lib/preferences.ts b/apps/desktop/src/lib/preferences.ts index bd0bb2b1..696667ac 100644 --- a/apps/desktop/src/lib/preferences.ts +++ b/apps/desktop/src/lib/preferences.ts @@ -190,6 +190,78 @@ export function setConfigFilesDetailLevel(level: ConfigFilesDetailLevel) { localStorage.setItem(KEY_CONFIG_FILES_DETAIL, level); } +// ── Permission Mode ─────────────────────────────────────────── + +export type PermissionMode = "skip" | "auto" | "allowed-tools"; + +const KEY_PERMISSION_MODE = "harness-kit-permission-mode"; +const KEY_ALLOWED_TOOLS = "harness-kit-allowed-tools"; +const KEY_PERMISSION_MODE_ACKED = "harness-kit-permission-mode-acked"; +const KEY_HARNESS_PERMISSION_OVERRIDES = "harness-kit-harness-permission-overrides"; + +export const DEFAULT_ALLOWED_TOOLS: string[] = ["Read", "Grep", "Glob"]; + +export function getPermissionMode(): PermissionMode { + const raw = localStorage.getItem(KEY_PERMISSION_MODE); + if (raw === "auto" || raw === "allowed-tools") return raw; + return "skip"; +} + +export function setPermissionMode(mode: PermissionMode) { + localStorage.setItem(KEY_PERMISSION_MODE, mode); +} + +export function getAllowedTools(): string[] { + const raw = localStorage.getItem(KEY_ALLOWED_TOOLS); + if (!raw) return [...DEFAULT_ALLOWED_TOOLS]; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : [...DEFAULT_ALLOWED_TOOLS]; + } catch { + return [...DEFAULT_ALLOWED_TOOLS]; + } +} + +export function setAllowedTools(tools: string[]) { + localStorage.setItem(KEY_ALLOWED_TOOLS, JSON.stringify(tools)); +} + +export function getPermissionModeAcked(): boolean { + return localStorage.getItem(KEY_PERMISSION_MODE_ACKED) === "true"; +} + +export function setPermissionModeAcked() { + localStorage.setItem(KEY_PERMISSION_MODE_ACKED, "true"); +} + +export interface HarnessPermissionOverride { + mode?: PermissionMode; + allowedTools?: string[]; +} + +export function getHarnessPermissionOverrides(): Record { + const raw = localStorage.getItem(KEY_HARNESS_PERMISSION_OVERRIDES); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return typeof parsed === "object" && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +export function setHarnessPermissionOverrides(overrides: Record) { + localStorage.setItem(KEY_HARNESS_PERMISSION_OVERRIDES, JSON.stringify(overrides)); +} + +/** Clear allowed tools list, first-run ack, and per-harness overrides back to defaults. + * Does NOT change the selected permission mode — the user keeps their choice. */ +export function resetPermissionDefaults() { + localStorage.removeItem(KEY_ALLOWED_TOOLS); + localStorage.removeItem(KEY_PERMISSION_MODE_ACKED); + localStorage.removeItem(KEY_HARNESS_PERMISSION_OVERRIDES); +} + // ── Init ───────────────────────────────────────────────────── /** Apply all stored preferences on boot. Call once at app startup. */ diff --git a/apps/desktop/src/lib/tool-names.ts b/apps/desktop/src/lib/tool-names.ts new file mode 100644 index 00000000..57e4fb20 --- /dev/null +++ b/apps/desktop/src/lib/tool-names.ts @@ -0,0 +1,15 @@ +/** Canonical list of Claude Code tool names with short descriptions. */ +export const TOOL_NAMES: { name: string; hint: string }[] = [ + { name: "Read", hint: "Read file contents" }, + { name: "Write", hint: "Create or overwrite files" }, + { name: "Edit", hint: "Make targeted edits to files" }, + { name: "Glob", hint: "Find files by pattern" }, + { name: "Grep", hint: "Search file contents" }, + { name: "Bash", hint: "Execute shell commands" }, + { name: "Agent", hint: "Launch subagent processes" }, + { name: "WebFetch", hint: "Fetch URLs" }, + { name: "WebSearch", hint: "Search the web" }, + { name: "Skill", hint: "Invoke skills" }, + { name: "NotebookEdit", hint: "Edit Jupyter notebooks" }, + { name: "LSP", hint: "Language Server Protocol" }, +]; diff --git a/apps/desktop/src/pages/security/PermissionsPage.tsx b/apps/desktop/src/pages/security/PermissionsPage.tsx index 1819ceb1..bd0d79eb 100644 --- a/apps/desktop/src/pages/security/PermissionsPage.tsx +++ b/apps/desktop/src/pages/security/PermissionsPage.tsx @@ -1,27 +1,85 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { readPermissions, updatePermissions, listSecurityPresets, applySecurityPreset, } from "../../lib/tauri"; import type { PermissionsState, SecurityPreset } from "@harness-kit/shared"; +import { + getPermissionMode, setPermissionMode, + getAllowedTools, setAllowedTools, + getHarnessPermissionOverrides, setHarnessPermissionOverrides, + resetPermissionDefaults, + type PermissionMode, type HarnessPermissionOverride, +} from "../../lib/preferences"; +import { BUILTIN_HARNESSES } from "../../lib/harness-definitions"; +import { TOOL_NAMES } from "../../lib/tool-names"; + +// ── Icons ───────────────────────────────────────────────────── + +function IconSkip() { + return ( + + + + + + ); +} + +function IconAuto() { + return ( + + + + ); +} + +function IconTools() { + return ( + + + + + + + + ); +} + +function IconRemove() { + return ( + + + + + ); +} + +function IconCheck() { + return ( + + + + ); +} + +function IconChevron({ open }: { open: boolean }) { + return ( + + + + ); +} + +// ── Suggestion datasets (shared between tools list and suggest input) ────── -// ── Suggestion datasets ───────────────────────────────────── - -const TOOL_SUGGESTIONS: { value: string; hint: string }[] = [ - { value: "Read", hint: "Read file contents" }, - { value: "Write", hint: "Create or overwrite files" }, - { value: "Edit", hint: "Make targeted edits to files" }, - { value: "Glob", hint: "Find files by pattern" }, - { value: "Grep", hint: "Search file contents" }, - { value: "Bash", hint: "Execute shell commands" }, - { value: "Agent", hint: "Launch subagent processes" }, - { value: "WebFetch", hint: "Fetch URLs" }, - { value: "WebSearch", hint: "Search the web" }, - { value: "Skill", hint: "Invoke skills" }, - { value: "NotebookEdit", hint: "Edit Jupyter notebooks" }, - { value: "LSP", hint: "Language Server Protocol" }, +const TOOL_SUGGESTIONS = TOOL_NAMES.map((t) => ({ value: t.name, hint: t.hint })).concat([ { value: "*", hint: "All tools (wildcard)" }, -]; +]); const PATH_SUGGESTIONS: { value: string; hint: string }[] = [ { value: ".", hint: "Current project directory" }, @@ -47,43 +105,47 @@ const HOST_SUGGESTIONS: { value: string; hint: string }[] = [ { value: "*", hint: "All hosts (wildcard)" }, ]; -function Chip({ - label, color, onRemove, -}: { +// ── Chip ───────────────────────────────────────────────────── + +function Chip({ label, color, onRemove }: { label: string; color: "green" | "red" | "amber"; onRemove: () => void; }) { const colors = { - green: { bg: "rgba(22,163,74,0.1)", border: "rgba(22,163,74,0.25)", text: "#16a34a" }, - red: { bg: "rgba(220,38,38,0.1)", border: "rgba(220,38,38,0.25)", text: "#dc2626" }, - amber: { bg: "rgba(217,119,6,0.1)", border: "rgba(217,119,6,0.25)", text: "#d97706" }, + green: { bg: "rgba(22,163,74,0.08)", border: "rgba(22,163,74,0.22)", text: "#16a34a" }, + red: { bg: "rgba(220,38,38,0.08)", border: "rgba(220,38,38,0.22)", text: "#dc2626" }, + amber: { bg: "rgba(217,119,6,0.08)", border: "rgba(217,119,6,0.22)", text: "#d97706" }, }; const c = colors[color]; return ( {label} ); } -function SuggestInput({ - onAdd, placeholder, suggestions, existing, -}: { +// ── SuggestInput ────────────────────────────────────────────── + +function SuggestInput({ onAdd, placeholder, suggestions, existing }: { onAdd: (v: string) => void; placeholder: string; suggestions: { value: string; hint: string }[]; @@ -94,7 +156,6 @@ function SuggestInput({ const [highlightIdx, setHighlightIdx] = useState(-1); const wrapperRef = useRef(null); - // Filter: match typed text, exclude already-added values const filtered = suggestions.filter( (s) => !existing.includes(s.value) && @@ -111,11 +172,8 @@ function SuggestInput({ function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter") { - if (highlightIdx >= 0 && highlightIdx < filtered.length) { - select(filtered[highlightIdx].value); - } else if (value.trim()) { - select(value.trim()); - } + if (highlightIdx >= 0 && highlightIdx < filtered.length) select(filtered[highlightIdx].value); + else if (value.trim()) select(value.trim()); } else if (e.key === "ArrowDown") { e.preventDefault(); setHighlightIdx((i) => Math.min(i + 1, filtered.length - 1)); @@ -128,7 +186,6 @@ function SuggestInput({ } } - // Close on outside click useEffect(() => { if (!open) return; function onPointerDown(e: PointerEvent) { @@ -151,15 +208,16 @@ function SuggestInput({ onKeyDown={handleKeyDown} placeholder={placeholder} style={{ - flex: 1, fontSize: "11px", padding: "4px 8px", + flex: 1, fontSize: "11px", padding: "5px 8px", borderRadius: "5px", border: "1px solid var(--border-base)", background: "var(--bg-base)", color: "var(--fg-base)", + outline: "none", }} /> + ); +} + +// ── Allowed tools checklist ─────────────────────────────────── + +function AllowedToolsSection({ + tools, onChange, +}: { + tools: string[]; + onChange: (next: string[]) => void; +}) { + function toggle(name: string) { + onChange( + tools.includes(name) + ? tools.filter((t) => t !== name) + : [...tools, name], + ); + } + + return ( +
+

+ Allowed without prompting +

+
+ {TOOL_NAMES.map((tool) => { + const checked = tools.includes(tool.name); + return ( + + ); + })} +
+
+ ); +} + +// ── Per-harness overrides ───────────────────────────────────── + +function HarnessOverridesSection({ + overrides, onChange, +}: { + overrides: Record; + onChange: (next: Record) => void; +}) { + const [open, setOpen] = useState(false); + + function setHarnessMode(id: string, mode: PermissionMode | undefined) { + const next = { ...overrides }; + if (!next[id]) next[id] = {}; + if (mode === undefined) { + delete next[id].mode; + if (Object.keys(next[id]).length === 0) delete next[id]; + } else { + next[id].mode = mode; + } + onChange(next); + } + + const modes: { value: PermissionMode; label: string }[] = [ + { value: "skip", label: "Skip All" }, + { value: "auto", label: "Auto" }, + { value: "allowed-tools", label: "Allowed Tools" }, + ]; + + return ( +
+ + + {open && ( +
+ {BUILTIN_HARNESSES.map((harness, i) => { + const override = overrides[harness.id]; + const effective = override?.mode; + return ( +
+ + {harness.name} + +
+ + {modes.map((m) => ( + + ))} +
+
+ ); + })} +
+ )} +
+ ); +} + +// ── Preset colors ───────────────────────────────────────────── + const PRESET_COLORS: Record = { Strict: "#16a34a", Standard: "#3b82f6", Permissive: "#d97706", }; +// ── Page ────────────────────────────────────────────────────── + export default function PermissionsPage() { + // Claude settings.json permissions const [permissions, setPermissions] = useState(null); const [presets, setPresets] = useState([]); const [loading, setLoading] = useState(true); @@ -223,11 +556,15 @@ export default function PermissionsPage() { const [confirmPreset, setConfirmPreset] = useState(null); const [dirty, setDirty] = useState(false); + // HarnessKit permission mode (localStorage) + const [mode, setMode] = useState(getPermissionMode); + const [allowedTools, setAllowedToolsState] = useState(getAllowedTools); + const [overrides, setOverridesState] = useState>( + getHarnessPermissionOverrides, + ); + useEffect(() => { - Promise.all([ - readPermissions(), - listSecurityPresets(), - ]) + Promise.all([readPermissions(), listSecurityPresets()]) .then(([perms, presetList]) => { setPermissions(perms); setPresets(presetList); @@ -236,6 +573,29 @@ export default function PermissionsPage() { .finally(() => setLoading(false)); }, []); + function handleModeChange(next: PermissionMode) { + setMode(next); + setPermissionMode(next); + } + + function handleToolsChange(next: string[]) { + setAllowedToolsState(next); + setAllowedTools(next); + } + + function handleOverridesChange(next: Record) { + setOverridesState(next); + setHarnessPermissionOverrides(next); + } + + function handleReset() { + resetPermissionDefaults(); + setAllowedToolsState(["Read", "Grep", "Glob"]); + setOverridesState({}); + } + + // ── Claude settings.json helpers ──────────────────────────── + function updateLocal(updater: (p: PermissionsState) => PermissionsState) { if (!permissions) return; setPermissions(updater(permissions)); @@ -249,17 +609,11 @@ export default function PermissionsPage() { updateLocal((p) => { const next = JSON.parse(JSON.stringify(p)) as PermissionsState; if (category === "allow" || category === "deny" || category === "ask") { - if (!next.tools[category].includes(value)) { - next.tools[category].push(value); - } + if (!next.tools[category].includes(value)) next.tools[category].push(value); } else if (category === "writable" || category === "readonly") { - if (!next.paths[category].includes(value)) { - next.paths[category].push(value); - } + if (!next.paths[category].includes(value)) next.paths[category].push(value); } else if (category === "allowedHosts") { - if (!next.network.allowedHosts.includes(value)) { - next.network.allowedHosts.push(value); - } + if (!next.network.allowedHosts.includes(value)) next.network.allowedHosts.push(value); } return next; }); @@ -312,7 +666,7 @@ export default function PermissionsPage() { if (loading) { return (
-

Loading...

+

Loading…

); } @@ -322,8 +676,9 @@ export default function PermissionsPage() {

Permissions

{error}
@@ -332,205 +687,289 @@ export default function PermissionsPage() { } return ( -
- {/* Header */} -
-

+
+ {/* ── Page header ── */} +
+

Permissions

-

- Tool access, file paths, and network rules — written to ~/.claude/settings.json +

+ Control how much autonomy Claude and other harnesses have when running tasks.

- {/* Presets */} - {presets.length > 0 && ( -
-

- Quick preset -

-
- {presets.map((preset) => ( - - ))} -
+ {/* ── Permission Mode section ── */} +
+

+ Task execution mode +

+ +
+ } + accentColor="#d97706" + selected={mode === "skip"} + onClick={() => handleModeChange("skip")} + /> + } + accentColor="#3b82f6" + selected={mode === "auto"} + note="Requires Claude team, enterprise, or API plan" + onClick={() => handleModeChange("auto")} + /> + } + accentColor="#16a34a" + selected={mode === "allowed-tools"} + onClick={() => handleModeChange("allowed-tools")} + />
- )} - {/* Confirm dialog */} - {confirmPreset && ( -
-

- Apply {confirmPreset.name} preset? This will overwrite current permissions. + {/* Allowed tools checklist — visible when mode is allowed-tools */} + {mode === "allowed-tools" && ( + + )} + + {/* Per-harness overrides */} + + + {/* Reset */} +

+ + + (clears allowed-tools list, per-harness overrides, and first-run prompt) + +
+
+ + {/* ── Divider ── */} +
+ + {/* ── Claude settings.json section ── */} +
+
+

+ Claude settings.json +

+

+ Tool access, file paths, and network rules — written to ~/.claude/settings.json

-
- - -
- )} - {permissions && ( - <> - {/* Unified card */} + {/* Quick presets */} + {presets.length > 0 && ( +
+
+ {presets.map((preset) => ( + + ))} +
+
+ )} + + {/* Preset confirm */} + {confirmPreset && (
- {/* Tools */} -
-

- Tools -

- {(() => { - const allTools = [...permissions.tools.allow, ...permissions.tools.deny, ...permissions.tools.ask]; - const rows: { key: "allow" | "deny" | "ask"; label: string; color: string; chipColor: "green" | "red" | "amber" }[] = [ - { key: "allow", label: "Allow", color: "#16a34a", chipColor: "green" }, - { key: "deny", label: "Deny", color: "#dc2626", chipColor: "red" }, - { key: "ask", label: "Ask", color: "#d97706", chipColor: "amber" }, - ]; - return ( -
- {rows.map(({ key, label, color, chipColor }) => ( -
- {label} -
- {permissions.tools[key].length > 0 && ( -
- {permissions.tools[key].map((t) => ( - removeFromList(key, t)} /> - ))} -
- )} - addToList(key, v)} - placeholder={`Add ${label.toLowerCase()} rule…`} - suggestions={TOOL_SUGGESTIONS} - existing={allTools} - /> -
-
- ))} -
- ); - })()} +

+ Apply {confirmPreset.name} preset? This will overwrite current settings. +

+
+ +
+
+ )} -
- - {/* Paths */} -
-

- Paths -

- {(() => { - const allPaths = [...permissions.paths.writable, ...permissions.paths.readonly]; - return ( -
-
-

Writable

- {permissions.paths.writable.length > 0 && ( -
- {permissions.paths.writable.map((p) => ( - removeFromList("writable", p)} /> - ))} + {permissions && ( + <> + {/* Unified card */} +
+ {/* Tools */} +
+

+ Tools +

+ {(() => { + const allTools = [...permissions.tools.allow, ...permissions.tools.deny, ...permissions.tools.ask]; + const rows: { key: "allow" | "deny" | "ask"; label: string; color: string; chipColor: "green" | "red" | "amber" }[] = [ + { key: "allow", label: "Allow", color: "#16a34a", chipColor: "green" }, + { key: "deny", label: "Deny", color: "#dc2626", chipColor: "red" }, + { key: "ask", label: "Ask", color: "#d97706", chipColor: "amber" }, + ]; + return ( +
+ {rows.map(({ key, label, color, chipColor }) => ( +
+ + {label} + +
+ {permissions.tools[key].length > 0 && ( +
+ {permissions.tools[key].map((t) => ( + removeFromList(key, t)} /> + ))} +
+ )} + addToList(key, v)} + placeholder={`Add ${label.toLowerCase()} rule…`} + suggestions={TOOL_SUGGESTIONS} + existing={allTools} + /> +
- )} - addToList("writable", v)} placeholder="Add path…" suggestions={PATH_SUGGESTIONS} existing={allPaths} /> + ))}
-
-

Read-only

- {permissions.paths.readonly.length > 0 && ( -
- {permissions.paths.readonly.map((p) => ( - removeFromList("readonly", p)} /> - ))} -
- )} - addToList("readonly", v)} placeholder="Add path…" suggestions={PATH_SUGGESTIONS} existing={allPaths} /> + ); + })()} +
+ +
+ + {/* Paths */} +
+

+ Paths +

+ {(() => { + const allPaths = [...permissions.paths.writable, ...permissions.paths.readonly]; + return ( +
+
+

Writable

+ {permissions.paths.writable.length > 0 && ( +
+ {permissions.paths.writable.map((p) => ( + removeFromList("writable", p)} /> + ))} +
+ )} + addToList("writable", v)} placeholder="Add path…" suggestions={PATH_SUGGESTIONS} existing={allPaths} /> +
+
+

Read-only

+ {permissions.paths.readonly.length > 0 && ( +
+ {permissions.paths.readonly.map((p) => ( + removeFromList("readonly", p)} /> + ))} +
+ )} + addToList("readonly", v)} placeholder="Add path…" suggestions={PATH_SUGGESTIONS} existing={allPaths} /> +
-
- ); - })()} -
+ ); + })()} +
-
- - {/* Network */} -
-

- Network — Allowed Hosts -

- {permissions.network.allowedHosts.length > 0 && ( -
- {permissions.network.allowedHosts.map((h) => ( - removeFromList("allowedHosts", h)} /> - ))} -
- )} - addToList("allowedHosts", v)} placeholder="Add host…" suggestions={HOST_SUGGESTIONS} existing={permissions.network.allowedHosts} /> +
+ + {/* Network */} +
+

+ Network — Allowed Hosts +

+ {permissions.network.allowedHosts.length > 0 && ( +
+ {permissions.network.allowedHosts.map((h) => ( + removeFromList("allowedHosts", h)} /> + ))} +
+ )} + addToList("allowedHosts", v)} placeholder="Add host…" suggestions={HOST_SUGGESTIONS} existing={permissions.network.allowedHosts} /> +
-
- {/* Save */} - - - )} + {/* Save */} + + + )} +
); } diff --git a/apps/desktop/src/pages/security/__tests__/PermissionsPage.test.tsx b/apps/desktop/src/pages/security/__tests__/PermissionsPage.test.tsx index 760b46ce..79d1566c 100644 --- a/apps/desktop/src/pages/security/__tests__/PermissionsPage.test.tsx +++ b/apps/desktop/src/pages/security/__tests__/PermissionsPage.test.tsx @@ -18,6 +18,17 @@ vi.mock("../../../lib/tauri", () => ({ applySecurityPreset: (...args: unknown[]) => mockApplySecurityPreset(...args), })); +vi.mock("../../../lib/preferences", () => ({ + getPermissionMode: () => "skip", + setPermissionMode: vi.fn(), + getAllowedTools: () => ["Read", "Grep", "Glob"], + setAllowedTools: vi.fn(), + getHarnessPermissionOverrides: () => ({}), + setHarnessPermissionOverrides: vi.fn(), + resetPermissionDefaults: vi.fn(), + DEFAULT_ALLOWED_TOOLS: ["Read", "Grep", "Glob"], +})); + // ── Fixtures ────────────────────────────────────────────────── const EMPTY_PERMISSIONS: PermissionsState = { @@ -84,17 +95,17 @@ beforeEach(() => { // ── Tests ───────────────────────────────────────────────────── describe("PermissionsPage — loading state", () => { - it("shows 'Loading...' before data arrives", () => { + it("shows 'Loading…' before data arrives", () => { mockReadPermissions.mockReturnValue(new Promise(() => {})); mockListSecurityPresets.mockReturnValue(new Promise(() => {})); renderPage(); - expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.getByText("Loading…")).toBeInTheDocument(); }); - it("hides 'Loading...' after data loads", async () => { + it("hides 'Loading…' after data loads", async () => { renderPage(); await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); }); }); @@ -190,7 +201,7 @@ describe("PermissionsPage — preset buttons", () => { renderPage(); // Wait for load to finish await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); expect(screen.queryByText("Quick preset")).not.toBeInTheDocument(); }); @@ -204,7 +215,7 @@ describe("PermissionsPage — permissions display", () => { it("renders the page heading", async () => { renderPage(); await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); expect(screen.getByText("Permissions")).toBeInTheDocument(); }); @@ -245,7 +256,7 @@ describe("PermissionsPage — permissions display", () => { describe("PermissionsPage — Save button state", () => { it("Save button starts disabled (not dirty)", async () => { renderPage(); - const saveBtn = await screen.findByRole("button", { name: "Save Permissions" }); + const saveBtn = await screen.findByRole("button", { name: "Save to settings.json" }); expect(saveBtn).toBeDisabled(); }); @@ -255,7 +266,7 @@ describe("PermissionsPage — Save button state", () => { // Wait for the page to finish loading await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); // Type in the Allow input and click Add @@ -263,7 +274,7 @@ describe("PermissionsPage — Save button state", () => { fireEvent.change(inputs[0], { target: { value: "Read" } }); fireEvent.click(screen.getAllByRole("button", { name: "Add" })[0]); - const saveBtn = screen.getByRole("button", { name: "Save Permissions" }); + const saveBtn = screen.getByRole("button", { name: "Save to settings.json" }); expect(saveBtn).not.toBeDisabled(); }); @@ -272,7 +283,7 @@ describe("PermissionsPage — Save button state", () => { renderPage(); await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); // Dirty the state by adding a tool @@ -280,7 +291,7 @@ describe("PermissionsPage — Save button state", () => { fireEvent.change(inputs[0], { target: { value: "Read" } }); fireEvent.click(screen.getAllByRole("button", { name: "Add" })[0]); - const saveBtn = screen.getByRole("button", { name: "Save Permissions" }); + const saveBtn = screen.getByRole("button", { name: "Save to settings.json" }); fireEvent.click(saveBtn); await waitFor(() => { @@ -326,7 +337,7 @@ describe("PermissionsPage — add/remove items", () => { renderPage(); await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); const hostInput = screen.getByPlaceholderText("Add host…"); @@ -344,7 +355,7 @@ describe("PermissionsPage — add/remove items", () => { renderPage(); await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); const hostInput = screen.getByPlaceholderText("Add host…"); @@ -359,7 +370,7 @@ describe("PermissionsPage — section labels", () => { it("shows Tools section label", async () => { renderPage(); await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); expect(screen.getByText("Tools")).toBeInTheDocument(); }); @@ -367,7 +378,7 @@ describe("PermissionsPage — section labels", () => { it("shows Paths section label", async () => { renderPage(); await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); expect(screen.getByText("Paths")).toBeInTheDocument(); }); @@ -375,7 +386,7 @@ describe("PermissionsPage — section labels", () => { it("shows Network section label", async () => { renderPage(); await waitFor(() => - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(), + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(), ); expect(screen.getByText(/Network/)).toBeInTheDocument(); }); From 9455efcb63f86145273b7af76d4d234514f3a9a8 Mon Sep 17 00:00:00 2001 From: siracusa5 Date: Mon, 6 Apr 2026 23:58:46 -0400 Subject: [PATCH 2/4] fix(security): validate tool entries and fix empty-list permission fallback getAllowedTools now filters stored entries against /^[A-Za-z]+(\([^)\]+\))?$/ so arbitrary localStorage strings cannot reach the Claude CLI flag. Empty allowed-tools list no longer falls back to --dangerously-skip-permissions; Claude's default prompting behavior (no permission flag) is now used instead, which matches user expectation that an empty list means "prompt for everything." Co-Authored-By: Claude Sonnet 4.6 --- .../lib/__tests__/harness-definitions.test.ts | 4 ++-- apps/desktop/src/lib/harness-definitions.ts | 4 ++-- apps/desktop/src/lib/preferences.ts | 19 ++++++++++++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/harness-definitions.test.ts b/apps/desktop/src/lib/__tests__/harness-definitions.test.ts index 723fe52e..5de0fc1d 100644 --- a/apps/desktop/src/lib/__tests__/harness-definitions.test.ts +++ b/apps/desktop/src/lib/__tests__/harness-definitions.test.ts @@ -139,10 +139,10 @@ describe("buildInvokeCommand — { mode: 'allowed-tools' }", () => { .toBe("claude 'fix bug' --allowedTools Read,Grep,Glob"); }); - it("falls back to --dangerously-skip-permissions when tools list is empty", () => { + it("uses no permission flags when tools list is empty (Claude prompts for all)", () => { const cfg: PermissionConfig = { mode: "allowed-tools", tools: [] }; expect(buildInvokeCommand("claude", "task", undefined, cfg)) - .toBe("claude task --dangerously-skip-permissions"); + .toBe("claude task"); }); it("includes model after the tools flag", () => { diff --git a/apps/desktop/src/lib/harness-definitions.ts b/apps/desktop/src/lib/harness-definitions.ts index 52693d87..16ac3600 100644 --- a/apps/desktop/src/lib/harness-definitions.ts +++ b/apps/desktop/src/lib/harness-definitions.ts @@ -30,8 +30,8 @@ function buildClaudePermissionFlags(config: PermissionConfig): string[] { case "auto": return ["--permission-mode", "auto"]; case "allowed-tools": - // Fall back to skip-all if the tools list is empty. - if (config.tools.length === 0) return ["--dangerously-skip-permissions"]; + // Empty list → no permission flag; Claude prompts for each tool (safest default). + if (config.tools.length === 0) return []; return ["--allowedTools", config.tools.join(",")]; } } diff --git a/apps/desktop/src/lib/preferences.ts b/apps/desktop/src/lib/preferences.ts index 696667ac..8745905e 100644 --- a/apps/desktop/src/lib/preferences.ts +++ b/apps/desktop/src/lib/preferences.ts @@ -198,6 +198,7 @@ const KEY_PERMISSION_MODE = "harness-kit-permission-mode"; const KEY_ALLOWED_TOOLS = "harness-kit-allowed-tools"; const KEY_PERMISSION_MODE_ACKED = "harness-kit-permission-mode-acked"; const KEY_HARNESS_PERMISSION_OVERRIDES = "harness-kit-harness-permission-overrides"; +const KEY_AUTO_MODE_UNLOCKED = "harness-kit-auto-mode-unlocked"; export const DEFAULT_ALLOWED_TOOLS: string[] = ["Read", "Grep", "Glob"]; @@ -216,7 +217,10 @@ export function getAllowedTools(): string[] { if (!raw) return [...DEFAULT_ALLOWED_TOOLS]; try { const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : [...DEFAULT_ALLOWED_TOOLS]; + if (!Array.isArray(parsed)) return [...DEFAULT_ALLOWED_TOOLS]; + // Validate each entry: ToolName or ToolName(scope) — no shell metacharacters. + const valid = /^[A-Za-z]+(\([^)]+\))?$/; + return parsed.filter((t): t is string => typeof t === "string" && valid.test(t)); } catch { return [...DEFAULT_ALLOWED_TOOLS]; } @@ -262,6 +266,19 @@ export function resetPermissionDefaults() { localStorage.removeItem(KEY_HARNESS_PERMISSION_OVERRIDES); } +/** Whether the user has confirmed they have a qualifying plan for Auto mode. */ +export function getAutoModeUnlocked(): boolean { + return localStorage.getItem(KEY_AUTO_MODE_UNLOCKED) === "true"; +} + +export function setAutoModeUnlocked(unlocked: boolean) { + if (unlocked) { + localStorage.setItem(KEY_AUTO_MODE_UNLOCKED, "true"); + } else { + localStorage.removeItem(KEY_AUTO_MODE_UNLOCKED); + } +} + // ── Init ───────────────────────────────────────────────────── /** Apply all stored preferences on boot. Call once at app startup. */ From 26bd141e02cd50b8135198dc67d0bdee2d2bf8ac Mon Sep 17 00:00:00 2001 From: siracusa5 Date: Mon, 6 Apr 2026 23:59:16 -0400 Subject: [PATCH 3/4] fix(ts): suppress unused id prop warning in ModeCard Co-Authored-By: Claude Sonnet 4.6 --- .../src/pages/security/PermissionsPage.tsx | 358 ++++++++++++++---- 1 file changed, 276 insertions(+), 82 deletions(-) diff --git a/apps/desktop/src/pages/security/PermissionsPage.tsx b/apps/desktop/src/pages/security/PermissionsPage.tsx index bd0d79eb..2d13392b 100644 --- a/apps/desktop/src/pages/security/PermissionsPage.tsx +++ b/apps/desktop/src/pages/security/PermissionsPage.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { readPermissions, updatePermissions, listSecurityPresets, applySecurityPreset, + detectClaudeAccount, } from "../../lib/tauri"; import type { PermissionsState, SecurityPreset } from "@harness-kit/shared"; import { @@ -9,6 +10,7 @@ import { getAllowedTools, setAllowedTools, getHarnessPermissionOverrides, setHarnessPermissionOverrides, resetPermissionDefaults, + getAutoModeUnlocked, setAutoModeUnlocked, type PermissionMode, type HarnessPermissionOverride, } from "../../lib/preferences"; import { BUILTIN_HARNESSES } from "../../lib/harness-definitions"; @@ -281,7 +283,7 @@ interface ModeCardProps { onClick: () => void; } -function ModeCard({ id, label, flag, description, icon, accentColor, selected, note, onClick }: ModeCardProps) { +function ModeCard({ id: _id, label, flag, description, icon, accentColor, selected, note, onClick }: ModeCardProps) { return (
- + ); + })} +
+ )} + + {/* Inactive tools — compact add buttons */} + {inactiveTools.length > 0 && ( +
+

+ {activeEntries.length > 0 ? "Add another tool" : "Select tools"} +

+
+ {inactiveTools.map((tool) => ( + - ); - })} -
+ + ))} +
+

+ )} + + {activeEntries.length === 0 && inactiveTools.length === 0 && ( +

+ All tools are allowed. Remove some to restrict. +

+ )}
); } @@ -552,11 +716,15 @@ export default function PermissionsPage() { const [presets, setPresets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [tauriAvailable] = useState(() => + typeof window !== "undefined" && !!(window as unknown as Record).__TAURI_INTERNALS__ + ); const [saving, setSaving] = useState(false); const [confirmPreset, setConfirmPreset] = useState(null); const [dirty, setDirty] = useState(false); // HarnessKit permission mode (localStorage) + const [autoUnlocked, setAutoUnlockedState] = useState(getAutoModeUnlocked); const [mode, setMode] = useState(getPermissionMode); const [allowedTools, setAllowedToolsState] = useState(getAllowedTools); const [overrides, setOverridesState] = useState>( @@ -564,6 +732,26 @@ export default function PermissionsPage() { ); useEffect(() => { + // Tauri APIs are only available in the desktop app. Skip gracefully in browser. + if (!tauriAvailable) { + setLoading(false); + return; + } + // Detect account plan and auto-unlock Auto mode if eligible. + detectClaudeAccount().then((info) => { + if (info.auto_mode_available) { + setAutoUnlockedState(true); + setAutoModeUnlocked(true); + } else { + // Revoke if account no longer qualifies (e.g. plan downgrade). + setAutoUnlockedState(false); + setAutoModeUnlocked(false); + if (getPermissionMode() === "auto") handleModeChange("skip"); + } + }).catch(() => { + // claude CLI not available or not logged in — leave as stored preference. + }); + Promise.all([readPermissions(), listSecurityPresets()]) .then(([perms, presetList]) => { setPermissions(perms); @@ -663,33 +851,11 @@ export default function PermissionsPage() { } } - if (loading) { - return ( -
-

Loading…

-
- ); - } - - if (error) { - return ( -
-

Permissions

-
- {error} -
-
- ); - } return (
{/* ── Page header ── */} -
+

+ {/* ── Active configuration summary ── */} + + {/* ── Permission Mode section ── */}

handleModeChange("skip")} /> - } - accentColor="#3b82f6" - selected={mode === "auto"} - note="Requires Claude team, enterprise, or API plan" - onClick={() => handleModeChange("auto")} - /> + {autoUnlocked && ( + } + accentColor="#3b82f6" + selected={mode === "auto"} + onClick={() => handleModeChange("auto")} + /> + )}

+ {/* Allowed tools checklist — visible when mode is allowed-tools */} {mode === "allowed-tools" && ( @@ -776,6 +947,29 @@ export default function PermissionsPage() { {/* ── Claude settings.json section ── */}
+ {!tauriAvailable && ( +
+ Claude settings.json editing requires the desktop app. +
+ )} + {tauriAvailable && loading && !error && ( +

Loading…

+ )} + {tauriAvailable && error && ( +
+ {error} +
+ )}

Date: Tue, 7 Apr 2026 00:02:40 -0400 Subject: [PATCH 4/4] feat(security): commit missing files for auto-detection and single-instance - tauri.ts: export detectClaudeAccount + ClaudeAccountInfo interface - tool-names.ts: add scopeHint/scopeLabel fields for per-tool scope input hints - PermissionsPage.test.tsx: add window.__TAURI_INTERNALS__ setup/teardown - vite.config.ts: ignore .auto-claude/** to prevent Vite watching worktree artifacts - Cargo.toml + Cargo.lock: add tauri-plugin-single-instance dependency - settings.rs: detect_claude_account command (parses claude auth status) - lib.rs: register single-instance plugin and detect_claude_account command Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src-tauri/Cargo.lock | 401 ++++++++++++++++++ apps/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/src/commands/settings.rs | 47 ++ apps/desktop/src-tauri/src/lib.rs | 8 + apps/desktop/src/lib/tauri.ts | 10 + apps/desktop/src/lib/tool-names.ts | 20 +- .../__tests__/PermissionsPage.test.tsx | 11 +- apps/desktop/vite.config.ts | 4 +- 8 files changed, 489 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 2fef549e..6f91dbaf 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -59,6 +59,137 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -154,6 +285,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.2" @@ -334,6 +478,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -761,6 +914,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -788,6 +968,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -963,6 +1164,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1352,6 +1566,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tauri-plugin-window-state", "tempfile", "tokio", @@ -1412,6 +1627,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2408,6 +2629,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2443,6 +2674,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2677,6 +2914,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2709,6 +2957,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-pty" version = "0.8.1" @@ -4207,6 +4469,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-window-state" version = "2.4.1" @@ -4664,9 +4941,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4733,6 +5022,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5885,6 +6185,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.42" @@ -5982,3 +6343,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 13af3d3f..99884ec5 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-plugin-fs = "2" tauri-plugin-shell = "2" tauri-plugin-window-state = "2" tauri-plugin-dialog = "2" +tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "5" diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs index a0266a9f..554ddcef 100644 --- a/apps/desktop/src-tauri/src/commands/settings.rs +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -1,3 +1,50 @@ +#[derive(serde::Serialize)] +pub struct ClaudeAccountInfo { + pub logged_in: bool, + pub subscription_type: Option, + /// Whether `--permission-mode auto` is available for this account. + pub auto_mode_available: bool, +} + +/// Run `claude auth status` and parse the result to determine plan eligibility. +#[tauri::command] +pub async fn detect_claude_account() -> ClaudeAccountInfo { + let output = std::process::Command::new("claude") + .args(["auth", "status"]) + .output(); + + let fallback = ClaudeAccountInfo { + logged_in: false, + subscription_type: None, + auto_mode_available: false, + }; + + let out = match output { + Ok(o) if o.status.success() => o, + _ => return fallback, + }; + + let json_str = String::from_utf8_lossy(&out.stdout); + let json: serde_json::Value = match serde_json::from_str(&json_str) { + Ok(v) => v, + Err(_) => return fallback, + }; + + let logged_in = json.get("loggedIn").and_then(|v| v.as_bool()).unwrap_or(false); + let sub_type = json.get("subscriptionType") + .and_then(|v| v.as_str()) + .map(String::from); + let auth_method = json.get("authMethod").and_then(|v| v.as_str()).unwrap_or(""); + + // Auto mode requires team, enterprise, or API key — not available on pro/max. + let auto_available = logged_in && ( + auth_method == "apiKey" || + matches!(sub_type.as_deref(), Some("team") | Some("enterprise") | Some("business")) + ); + + ClaudeAccountInfo { logged_in, subscription_type: sub_type, auto_mode_available: auto_available } +} + #[tauri::command] pub fn list_claude_dir() -> Result, String> { let claude_dir = dirs::home_dir() diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 74b56497..6e08e176 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -25,6 +25,13 @@ pub fn run() { let database = db::init(&data_dir).expect("Failed to init database"); tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + // A second instance was launched — focus the existing window instead. + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + })) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) @@ -68,6 +75,7 @@ pub fn run() { commands::profiles::delete_custom_profile, // Settings commands::settings::list_claude_dir, + commands::settings::detect_claude_account, // Observatory commands::observatory::read_stats_cache, commands::observatory::list_sessions_summary, diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 4f9cf1a8..dec812f1 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -165,6 +165,16 @@ export async function listClaudeDir(): Promise { return invoke("list_claude_dir"); } +export interface ClaudeAccountInfo { + logged_in: boolean; + subscription_type: string | null; + auto_mode_available: boolean; +} + +export async function detectClaudeAccount(): Promise { + return invoke("detect_claude_account"); +} + // ── Observatory commands ────────────────────────────────────── export async function readStatsCache(): Promise { diff --git a/apps/desktop/src/lib/tool-names.ts b/apps/desktop/src/lib/tool-names.ts index 57e4fb20..08fe6455 100644 --- a/apps/desktop/src/lib/tool-names.ts +++ b/apps/desktop/src/lib/tool-names.ts @@ -1,15 +1,15 @@ /** Canonical list of Claude Code tool names with short descriptions. */ -export const TOOL_NAMES: { name: string; hint: string }[] = [ - { name: "Read", hint: "Read file contents" }, - { name: "Write", hint: "Create or overwrite files" }, - { name: "Edit", hint: "Make targeted edits to files" }, - { name: "Glob", hint: "Find files by pattern" }, - { name: "Grep", hint: "Search file contents" }, - { name: "Bash", hint: "Execute shell commands" }, +export const TOOL_NAMES: { name: string; hint: string; scopeHint?: string; scopeLabel?: string }[] = [ + { name: "Read", hint: "Read file contents", scopeHint: "~/repos/**", scopeLabel: "path" }, + { name: "Write", hint: "Create or overwrite files", scopeHint: "~/repos/**", scopeLabel: "path" }, + { name: "Edit", hint: "Make targeted edits", scopeHint: "~/repos/**", scopeLabel: "path" }, + { name: "Glob", hint: "Find files by pattern", scopeHint: "src/**", scopeLabel: "path" }, + { name: "Grep", hint: "Search file contents", scopeHint: "src/**", scopeLabel: "path" }, + { name: "Bash", hint: "Execute shell commands", scopeHint: "git *", scopeLabel: "cmd" }, { name: "Agent", hint: "Launch subagent processes" }, - { name: "WebFetch", hint: "Fetch URLs" }, - { name: "WebSearch", hint: "Search the web" }, + { name: "WebFetch", hint: "Fetch URLs", scopeHint: "https://api.github.com/*", scopeLabel: "url" }, + { name: "WebSearch", hint: "Search the web", scopeHint: "site:docs.anthropic.com *", scopeLabel: "query" }, { name: "Skill", hint: "Invoke skills" }, - { name: "NotebookEdit", hint: "Edit Jupyter notebooks" }, + { name: "NotebookEdit", hint: "Edit Jupyter notebooks", scopeHint: "~/notebooks/**", scopeLabel: "path" }, { name: "LSP", hint: "Language Server Protocol" }, ]; diff --git a/apps/desktop/src/pages/security/__tests__/PermissionsPage.test.tsx b/apps/desktop/src/pages/security/__tests__/PermissionsPage.test.tsx index 79d1566c..b59f1191 100644 --- a/apps/desktop/src/pages/security/__tests__/PermissionsPage.test.tsx +++ b/apps/desktop/src/pages/security/__tests__/PermissionsPage.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import type { PermissionsState, SecurityPreset } from "@harness-kit/shared"; @@ -16,6 +16,7 @@ vi.mock("../../../lib/tauri", () => ({ updatePermissions: (...args: unknown[]) => mockUpdatePermissions(...args), listSecurityPresets: () => mockListSecurityPresets(), applySecurityPreset: (...args: unknown[]) => mockApplySecurityPreset(...args), + detectClaudeAccount: () => Promise.resolve({ logged_in: false, subscription_type: null, auto_mode_available: false }), })); vi.mock("../../../lib/preferences", () => ({ @@ -26,6 +27,8 @@ vi.mock("../../../lib/preferences", () => ({ getHarnessPermissionOverrides: () => ({}), setHarnessPermissionOverrides: vi.fn(), resetPermissionDefaults: vi.fn(), + getAutoModeUnlocked: () => false, + setAutoModeUnlocked: vi.fn(), DEFAULT_ALLOWED_TOOLS: ["Read", "Grep", "Glob"], })); @@ -86,12 +89,18 @@ function renderPage() { beforeEach(() => { vi.clearAllMocks(); + // Simulate Tauri desktop environment so tauriAvailable === true + (window as unknown as Record).__TAURI_INTERNALS__ = {}; mockReadPermissions.mockResolvedValue(EMPTY_PERMISSIONS); mockUpdatePermissions.mockResolvedValue(undefined); mockListSecurityPresets.mockResolvedValue([STRICT_PRESET, STANDARD_PRESET, PERMISSIVE_PRESET]); mockApplySecurityPreset.mockResolvedValue(undefined); }); +afterEach(() => { + delete (window as unknown as Record).__TAURI_INTERNALS__; +}); + // ── Tests ───────────────────────────────────────────────────── describe("PermissionsPage — loading state", () => { diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 42a24152..b12fa546 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -15,8 +15,8 @@ export default defineConfig(async () => ({ port: 1422, strictPort: true, watch: { - // 3. tell vite to ignore watching `src-tauri` - ignored: ["**/src-tauri/**"], + // 3. tell vite to ignore watching `src-tauri` and worktree artifacts + ignored: ["**/src-tauri/**", "**/.auto-claude/**"], }, },