fix(agents): add Cursor auto-approve and always-allow permission UI (#1198)

This commit is contained in:
Carlos Florêncio
2026-05-31 17:13:00 +01:00
committed by GitHub
parent 700ff4199c
commit 036aee6e19
15 changed files with 332 additions and 124 deletions
@@ -226,6 +226,12 @@ func (c SessionConfig) SupportsRecovery() bool {
// filter chain across agents, so the literal lives in exactly one place.
const PermissionApplyMethodCLIFlag = "cli_flag"
// PermissionKeyAutoApprove is the PermissionSettings map key wired to the
// profile "Auto approve" toggle (see PermissionValues in buildAgentCommand).
// Centralised for the same reason as PermissionApplyMethodCLIFlag: a typo in
// any one agent silently disables its auto-approve flag.
const PermissionKeyAutoApprove = "auto_approve"
// PermissionSetting defines metadata for a permission setting option.
type PermissionSetting struct {
Supported bool `json:"supported"`
@@ -33,7 +33,7 @@ type CodexACP struct {
// codexPassthroughPermSettings maps profile toggles to @openai/codex CLI flags.
// The legacy --full-auto flag was removed; auto_approve uses --ask-for-approval never.
var codexPassthroughPermSettings = map[string]PermissionSetting{
"auto_approve": {
PermissionKeyAutoApprove: {
Supported: true,
Default: true,
Label: "Auto approve",
@@ -18,6 +18,23 @@ var cursorACPLogoDark []byte
const cursorACPBin = "cursor-agent"
// cursorPermSettings maps the profile "Auto approve" toggle to cursor-agent's
// --force flag. In ACP mode Cursor emits a session/request_permission for every
// command not on its internal allowlist; --force ("Run Everything") suppresses
// those prompts entirely. Empirically --force is the only flag that bypasses
// the allowlist over ACP — --yolo and --sandbox still prompt. Default off
// because --force runs everything unsandboxed.
var cursorPermSettings = map[string]PermissionSetting{
PermissionKeyAutoApprove: {
Supported: true,
Default: false,
Label: "Auto approve",
Description: "Run all commands without approval prompts (cursor-agent --force)",
ApplyMethod: PermissionApplyMethodCLIFlag,
CLIFlag: "--force",
},
}
var (
_ Agent = (*CursorACP)(nil)
_ PassthroughAgent = (*CursorACP)(nil)
@@ -34,7 +51,7 @@ type CursorACP struct {
func NewCursorACP() *CursorACP {
return &CursorACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
PermSettings: cursorPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
@@ -77,7 +94,9 @@ func (a *CursorACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
}
func (a *CursorACP) BuildCommand(opts CommandOptions) Command {
return Cmd(cursorACPBin, "acp").Build()
return Cmd(cursorACPBin, "acp").
Settings(a.PermissionSettings(), opts.PermissionValues).
Build()
}
func (a *CursorACP) Runtime() *RuntimeConfig {
@@ -123,7 +142,7 @@ grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null ||
}
func (a *CursorACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
return cursorPermSettings
}
func (a *CursorACP) InferenceConfig() *InferenceConfig {
@@ -22,6 +22,46 @@ func TestCursorACPRemoteAuth(t *testing.T) {
}
}
func TestCursorACPPermissionSettingsAutoApprove(t *testing.T) {
settings := NewCursorACP().PermissionSettings()
setting, ok := settings[PermissionKeyAutoApprove]
if !ok {
t.Fatal("PermissionSettings() missing auto_approve; cursor must expose an approval-bypass toggle")
}
if !setting.Supported {
t.Error("auto_approve must be Supported")
}
if setting.ApplyMethod != PermissionApplyMethodCLIFlag {
t.Errorf("ApplyMethod = %q, want %q", setting.ApplyMethod, PermissionApplyMethodCLIFlag)
}
if setting.CLIFlag != "--force" {
t.Errorf("CLIFlag = %q, want --force (the only flag that bypasses Cursor's ACP allowlist)", setting.CLIFlag)
}
}
func TestCursorACPBuildCommandAutoApprove(t *testing.T) {
c := NewCursorACP()
// Default: a bare `cursor-agent acp`, so Cursor keeps prompting per command.
plain := strings.Join(c.BuildCommand(CommandOptions{}).Args(), " ")
if plain != "cursor-agent acp" {
t.Fatalf("default BuildCommand = %q, want %q", plain, "cursor-agent acp")
}
if strings.Contains(plain, "--force") {
t.Error("default command must not include --force")
}
// auto_approve enabled: --force is appended so Cursor stops sending a
// session/request_permission for every non-allowlisted command.
approved := strings.Join(
c.BuildCommand(CommandOptions{PermissionValues: map[string]bool{PermissionKeyAutoApprove: true}}).Args(),
" ",
)
if !strings.Contains(approved, "--force") {
t.Errorf("auto_approve BuildCommand = %q, want it to contain --force", approved)
}
}
func TestCursorACPInstallScriptIsNativeInstaller(t *testing.T) {
script := NewCursorACP().InstallScript()
if !strings.Contains(script, "cursor.com/install") {
@@ -1269,7 +1269,7 @@ func (m *Manager) buildFreshAgentCommand(ctx context.Context, execution *AgentEx
if profileInfo != nil {
model = profileInfo.Model
autoApprove = profileInfo.AutoApprove
permissionValues["auto_approve"] = profileInfo.AutoApprove
permissionValues[agents.PermissionKeyAutoApprove] = profileInfo.AutoApprove
permissionValues["allow_indexing"] = profileInfo.AllowIndexing
permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions
}
@@ -153,7 +153,7 @@ func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfil
if profileInfo != nil {
model = profileInfo.Model
autoApprove = profileInfo.AutoApprove
permissionValues["auto_approve"] = profileInfo.AutoApprove
permissionValues[agents.PermissionKeyAutoApprove] = profileInfo.AutoApprove
permissionValues["allow_indexing"] = profileInfo.AllowIndexing
permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions
tokens, err := cliflags.Resolve(profileInfo.CLIFlags)
@@ -503,11 +503,12 @@ func profilePermissionValues(p *AgentProfileInfo) map[string]bool {
if p == nil {
return nil
}
return map[string]bool{
"auto_approve": p.AutoApprove,
values := map[string]bool{
"dangerously_skip_permissions": p.DangerouslySkipPermissions,
"allow_indexing": p.AllowIndexing,
}
values[agents.PermissionKeyAutoApprove] = p.AutoApprove
return values
}
// freshPassthroughCommand resolves the agent config and profile, and builds a
@@ -150,7 +150,7 @@ func resolvePermissionDefaults(permSettings map[string]agents.PermissionSetting)
if permSettings == nil {
return
}
if s, exists := permSettings["auto_approve"]; exists {
if s, exists := permSettings[agents.PermissionKeyAutoApprove]; exists {
autoApprove = s.Default
}
if s, exists := permSettings["allow_indexing"]; exists {
@@ -77,10 +77,11 @@ export const KandevToolMessage = memo(function KandevToolMessage({
const renderer = getKandevRenderer(kandevStemOf(comment));
const { permissionMetadata, permissionStatus, isPermissionPending } =
parsePermission(permissionMessage);
const { isResponding, handleApprove, handleReject } = usePermissionResponseHandlers({
permissionMetadata,
permissionMessage,
});
const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
usePermissionResponseHandlers({
permissionMetadata,
permissionMessage,
});
if (!renderer) return null;
// The ACP normalizer stores MCP tool args/result inside the Generic payload:
@@ -114,6 +115,7 @@ export const KandevToolMessage = memo(function KandevToolMessage({
<PermissionActionRow
onApprove={handleApprove}
onReject={handleReject}
onAllowAlways={hasAllowAlways ? handleAllowAlways : undefined}
isResponding={isResponding}
/>
</div>
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { PermissionActionRow } from "./permission-action-row";
describe("PermissionActionRow", () => {
// This vitest config does not auto-clean the DOM between tests, so unmount
// explicitly to avoid stale buttons leaking duplicate test ids.
afterEach(cleanup);
it("renders only Approve and Deny when onAllowAlways is not provided", () => {
render(<PermissionActionRow onApprove={vi.fn()} onReject={vi.fn()} />);
expect(screen.getByTestId("permission-approve")).toBeTruthy();
expect(screen.getByTestId("permission-reject")).toBeTruthy();
// The "Always allow" button is hidden for agents that don't offer it.
expect(screen.queryByTestId("permission-allow-always")).toBeNull();
});
it("renders the Always allow button and fires its callback when offered", () => {
const onAllowAlways = vi.fn();
render(
<PermissionActionRow onApprove={vi.fn()} onReject={vi.fn()} onAllowAlways={onAllowAlways} />,
);
const button = screen.getByTestId("permission-allow-always");
expect(button.textContent).toContain("Always allow");
fireEvent.click(button);
expect(onAllowAlways).toHaveBeenCalledOnce();
});
it("wires Approve and Deny to their handlers", () => {
const onApprove = vi.fn();
const onReject = vi.fn();
render(
<PermissionActionRow onApprove={onApprove} onReject={onReject} onAllowAlways={vi.fn()} />,
);
fireEvent.click(screen.getByTestId("permission-approve"));
fireEvent.click(screen.getByTestId("permission-reject"));
expect(onApprove).toHaveBeenCalledOnce();
expect(onReject).toHaveBeenCalledOnce();
});
it("disables every action while a response is in flight", () => {
render(
<PermissionActionRow
onApprove={vi.fn()}
onReject={vi.fn()}
onAllowAlways={vi.fn()}
isResponding
/>,
);
expect(screen.getByTestId<HTMLButtonElement>("permission-approve").disabled).toBe(true);
expect(screen.getByTestId<HTMLButtonElement>("permission-reject").disabled).toBe(true);
expect(screen.getByTestId<HTMLButtonElement>("permission-allow-always").disabled).toBe(true);
});
});
@@ -1,27 +1,35 @@
"use client";
import { memo } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { IconCheck, IconChecks, IconX } from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import { Button } from "@kandev/ui/button";
type PermissionActionRowProps = {
onApprove: () => void;
onReject: () => void;
// onAllowAlways is provided only when the agent offers an allow_always
// option (e.g. Cursor). When undefined the "Always allow" button is hidden,
// preserving the plain Approve/Deny row for agents without it.
onAllowAlways?: () => void;
isResponding?: boolean;
};
const ACTION_BUTTON_CLASS =
"h-6 px-3 text-foreground border-border bg-background hover:bg-muted hover:border-foreground/40 transition-colors cursor-pointer";
export const PermissionActionRow = memo(function PermissionActionRow({
onApprove,
onReject,
onAllowAlways,
isResponding = false,
}: PermissionActionRowProps) {
return (
<div
className="flex items-center gap-2 px-3 py-2 rounded-sm bg-amber-500/10"
className="flex flex-wrap items-center gap-2 px-3 py-2 rounded-sm bg-amber-500/10"
data-testid="permission-action-row"
>
<span className="text-xs text-amber-600 dark:text-amber-400 flex-1">
<span className="text-xs text-amber-600 dark:text-amber-400 flex-1 min-w-0">
Approve this action?
</span>
<Button
@@ -30,7 +38,7 @@ export const PermissionActionRow = memo(function PermissionActionRow({
onClick={onReject}
disabled={isResponding}
data-testid="permission-reject"
className="h-6 px-3 text-foreground border-border bg-background hover:bg-muted hover:border-foreground/40 transition-colors cursor-pointer"
className={ACTION_BUTTON_CLASS}
>
<IconX className="h-4 w-4 mr-1 text-red-500" />
Deny
@@ -41,7 +49,7 @@ export const PermissionActionRow = memo(function PermissionActionRow({
onClick={onApprove}
disabled={isResponding}
data-testid="permission-approve"
className="h-6 px-3 text-foreground border-border bg-background hover:bg-muted hover:border-foreground/40 transition-colors cursor-pointer"
className={ACTION_BUTTON_CLASS}
>
{isResponding ? (
<GridSpinner className="text-foreground mr-1" />
@@ -50,6 +58,19 @@ export const PermissionActionRow = memo(function PermissionActionRow({
)}
Approve
</Button>
{onAllowAlways && (
<Button
size="xs"
variant="outline"
onClick={onAllowAlways}
disabled={isResponding}
data-testid="permission-allow-always"
className={ACTION_BUTTON_CLASS}
>
<IconChecks className="h-4 w-4 mr-1 text-green-500" />
Always allow
</Button>
)}
</div>
);
});
@@ -46,10 +46,11 @@ type PermissionRequestMessageProps = {
export function PermissionRequestMessage({ comment }: PermissionRequestMessageProps) {
const { permissionMetadata, permissionStatus, isPermissionPending } = parsePermission(comment);
const { isResponding, handleApprove, handleReject } = usePermissionResponseHandlers({
permissionMetadata,
permissionMessage: comment,
});
const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
usePermissionResponseHandlers({
permissionMetadata,
permissionMessage: comment,
});
const statusBadge = getPermissionStatusBadge(permissionStatus);
const titleText = comment.content || "Permission Required";
@@ -96,6 +97,7 @@ export function PermissionRequestMessage({ comment }: PermissionRequestMessagePr
<PermissionActionRow
onApprove={handleApprove}
onReject={handleReject}
onAllowAlways={hasAllowAlways ? handleAllowAlways : undefined}
isResponding={isResponding}
/>
</div>
@@ -71,6 +71,7 @@ type ToolCallExpandedContentProps = {
isPermissionPending: boolean;
onApprove: () => void;
onReject: () => void;
onAllowAlways?: () => void;
isResponding: boolean;
};
@@ -80,6 +81,7 @@ function ToolCallExpandedContent({
isPermissionPending,
onApprove,
onReject,
onAllowAlways,
isResponding,
}: ToolCallExpandedContentProps) {
return (
@@ -99,6 +101,7 @@ function ToolCallExpandedContent({
<PermissionActionRow
onApprove={onApprove}
onReject={onReject}
onAllowAlways={onAllowAlways}
isResponding={isResponding}
/>
)}
@@ -179,10 +182,11 @@ export const ToolCallMessage = memo(function ToolCallMessage({
inlineOutput,
isSuccess,
} = parseToolCallMetadata(comment, permissionMessage);
const { isResponding, handleApprove, handleReject } = usePermissionResponseHandlers({
permissionMetadata,
permissionMessage,
});
const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
usePermissionResponseHandlers({
permissionMetadata,
permissionMessage,
});
const autoExpanded = status === "running" || isPermissionPending;
const { isExpanded, handleToggle } = useExpandState(status, autoExpanded);
@@ -234,6 +238,7 @@ export const ToolCallMessage = memo(function ToolCallMessage({
isPermissionPending={isPermissionPending}
onApprove={handleApprove}
onReject={handleReject}
onAllowAlways={hasAllowAlways ? handleAllowAlways : undefined}
isResponding={isResponding}
/>
</ExpandableRow>
@@ -1,8 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { renderHook, act, type RenderHookResult } from "@testing-library/react";
import { sessionId as toSessionId } from "@/lib/types/http";
import type { Message } from "@/lib/types/http";
import type { PermissionRequestMetadata } from "./use-permission-handlers";
import {
usePermissionResponseHandlers,
type PermissionOption,
type PermissionRequestMetadata,
} from "./use-permission-handlers";
const requestMock = vi.fn().mockResolvedValue({});
@@ -10,104 +14,137 @@ vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ request: requestMock }),
}));
describe("usePermissionResponseHandlers", () => {
let usePermissionResponseHandlers: typeof import("./use-permission-handlers").usePermissionResponseHandlers;
beforeEach(() => {
requestMock.mockReset().mockResolvedValue({});
});
beforeEach(async () => {
vi.resetModules();
requestMock.mockReset().mockResolvedValue({});
({ usePermissionResponseHandlers } = await import("./use-permission-handlers"));
const ALLOW_ONCE: PermissionOption = { option_id: "allow", name: "Allow", kind: "allow_once" };
const REJECT_ONCE: PermissionOption = { option_id: "deny", name: "Deny", kind: "reject_once" };
const ALLOW_ALWAYS: PermissionOption = {
option_id: "always",
name: "Allow always",
kind: "allow_always",
};
function makePermissionMessage(options: PermissionOption[] = [ALLOW_ONCE, REJECT_ONCE]): Message {
const meta: PermissionRequestMetadata = {
pending_id: "pend-1",
tool_call_id: "tc-1",
action_type: "mcp_tool",
action_details: {},
options,
};
return {
id: "msg-1",
session_id: toSessionId("sess-1"),
task_id: "task-1" as ReturnType<typeof import("@/lib/types/http").taskId>,
author_type: "agent",
content: "",
type: "permission_request",
created_at: "2026-05-25T00:00:00Z",
metadata: meta as unknown as Record<string, unknown>,
} as unknown as Message;
}
type Handlers = ReturnType<typeof usePermissionResponseHandlers>;
function renderHandlers(message: Message): RenderHookResult<Handlers, unknown>["result"] {
return renderHook(() =>
usePermissionResponseHandlers({
permissionMetadata: message.metadata as unknown as PermissionRequestMetadata,
permissionMessage: message,
}),
).result;
}
function firstPayload(): Record<string, unknown> {
return requestMock.mock.calls[0][1] as Record<string, unknown>;
}
describe("handleApprove", () => {
it("sends cancelled=false and rejected=false", async () => {
const result = renderHandlers(makePermissionMessage());
await act(async () => {
result.current.handleApprove();
});
expect(requestMock).toHaveBeenCalledOnce();
expect(firstPayload().option_id).toBe("allow");
expect(firstPayload().cancelled).toBeFalsy();
expect(firstPayload().rejected).toBeFalsy();
});
function makePermissionMessage(overrides: Partial<PermissionRequestMetadata> = {}): Message {
const meta: PermissionRequestMetadata = {
pending_id: "pend-1",
tool_call_id: "tc-1",
action_type: "mcp_tool",
action_details: {},
options: [
{ option_id: "allow", name: "Allow", kind: "allow_once" },
{ option_id: "deny", name: "Deny", kind: "reject_once" },
],
...overrides,
};
return {
id: "msg-1",
session_id: toSessionId("sess-1"),
task_id: "task-1" as ReturnType<typeof import("@/lib/types/http").taskId>,
author_type: "agent",
content: "",
type: "permission_request",
created_at: "2026-05-25T00:00:00Z",
metadata: meta as unknown as Record<string, unknown>,
} as unknown as Message;
}
describe("handleApprove", () => {
it("sends cancelled=false and rejected=false", async () => {
const permissionMessage = makePermissionMessage();
const { result } = renderHook(() =>
usePermissionResponseHandlers({
permissionMetadata: permissionMessage.metadata as unknown as PermissionRequestMetadata,
permissionMessage,
}),
);
await act(async () => {
result.current.handleApprove();
});
expect(requestMock).toHaveBeenCalledOnce();
const payload = requestMock.mock.calls[0][1] as Record<string, unknown>;
expect(payload.option_id).toBe("allow");
expect(payload.cancelled).toBeFalsy();
expect(payload.rejected).toBeFalsy();
it("prefers allow_once over allow_always so 'Always allow' stays distinct", async () => {
const once: PermissionOption = { option_id: "once", name: "Allow once", kind: "allow_once" };
const result = renderHandlers(makePermissionMessage([ALLOW_ALWAYS, once, REJECT_ONCE]));
await act(async () => {
result.current.handleApprove();
});
expect(firstPayload().option_id).toBe("once");
});
describe("handleReject", () => {
it("sends rejected=true and cancelled=false when a reject option exists", async () => {
const permissionMessage = makePermissionMessage();
const { result } = renderHook(() =>
usePermissionResponseHandlers({
permissionMetadata: permissionMessage.metadata as unknown as PermissionRequestMetadata,
permissionMessage,
}),
);
await act(async () => {
result.current.handleReject();
});
expect(requestMock).toHaveBeenCalledOnce();
const payload = requestMock.mock.calls[0][1] as Record<string, unknown>;
expect(payload.option_id).toBe("deny");
expect(payload.rejected).toBe(true);
// Must NOT set cancelled=true: that triggers EventTypePermissionCancelled
// which races against the orchestrator's UpdatePermissionMessage("rejected")
// and would overwrite the status to "expired".
expect(payload.cancelled).toBeFalsy();
it("falls back to allow_always when no allow_once is offered", async () => {
const result = renderHandlers(makePermissionMessage([ALLOW_ALWAYS]));
await act(async () => {
result.current.handleApprove();
});
it("falls back to cancelled=true when no reject option exists", async () => {
const permissionMessage = makePermissionMessage({
options: [{ option_id: "allow", name: "Allow", kind: "allow_once" }],
});
const { result } = renderHook(() =>
usePermissionResponseHandlers({
permissionMetadata: permissionMessage.metadata as unknown as PermissionRequestMetadata,
permissionMessage,
}),
);
await act(async () => {
result.current.handleReject();
});
expect(requestMock).toHaveBeenCalledOnce();
const payload = requestMock.mock.calls[0][1] as Record<string, unknown>;
expect(payload.cancelled).toBe(true);
expect(payload.option_id).toBeUndefined();
});
expect(firstPayload().option_id).toBe("always");
});
});
describe("handleAllowAlways", () => {
it("hasAllowAlways is true and responds with the allow_always option (not rejected)", async () => {
const result = renderHandlers(makePermissionMessage([ALLOW_ONCE, ALLOW_ALWAYS, REJECT_ONCE]));
expect(result.current.hasAllowAlways).toBe(true);
await act(async () => {
result.current.handleAllowAlways();
});
expect(requestMock).toHaveBeenCalledOnce();
expect(firstPayload().option_id).toBe("always");
expect(firstPayload().cancelled).toBeFalsy();
expect(firstPayload().rejected).toBeFalsy();
});
it("hasAllowAlways is false and handleAllowAlways is a no-op when not offered", async () => {
const result = renderHandlers(makePermissionMessage([ALLOW_ONCE, REJECT_ONCE]));
expect(result.current.hasAllowAlways).toBe(false);
await act(async () => {
result.current.handleAllowAlways();
});
expect(requestMock).not.toHaveBeenCalled();
});
});
describe("handleReject", () => {
it("sends rejected=true and cancelled=false when a reject option exists", async () => {
const result = renderHandlers(makePermissionMessage());
await act(async () => {
result.current.handleReject();
});
expect(requestMock).toHaveBeenCalledOnce();
expect(firstPayload().option_id).toBe("deny");
expect(firstPayload().rejected).toBe(true);
// Must NOT set cancelled=true: that triggers EventTypePermissionCancelled
// which races against the orchestrator's UpdatePermissionMessage("rejected")
// and would overwrite the status to "expired".
expect(firstPayload().cancelled).toBeFalsy();
});
it("falls back to cancelled=true when no reject option exists", async () => {
const result = renderHandlers(makePermissionMessage([ALLOW_ONCE]));
await act(async () => {
result.current.handleReject();
});
expect(requestMock).toHaveBeenCalledOnce();
expect(firstPayload().cancelled).toBe(true);
expect(firstPayload().option_id).toBeUndefined();
});
});
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { getWebSocketClient } from "@/lib/ws/connection";
import type { Message } from "@/lib/types/http";
import type { PermissionActionType, PermissionOptionKind } from "@/lib/types/permission";
@@ -85,13 +85,29 @@ export function usePermissionResponseHandlers({
[permissionMessage, permissionMetadata],
);
// "Approve" is the one-shot allow. Prefer an explicit allow_once option and
// only fall back to allow_always when the agent offers nothing else, so the
// dedicated "Always allow" button (handleAllowAlways) stays distinct.
const handleApprove = useCallback(() => {
const allowOption = permissionMetadata?.options.find(
(opt) => opt.kind === "allow_once" || opt.kind === "allow_always",
);
const options = permissionMetadata?.options ?? [];
const allowOption =
options.find((opt) => opt.kind === "allow_once") ??
options.find((opt) => opt.kind === "allow_always");
if (allowOption) handleRespond(allowOption.option_id);
}, [permissionMetadata, handleRespond]);
// "Always allow" maps to the agent's allow_always option, telling the agent
// to persist the decision so the same action is not re-prompted. Only some
// agents offer it (Cursor does); hasAllowAlways gates the button.
const allowAlwaysOption = useMemo(
() => permissionMetadata?.options.find((opt) => opt.kind === "allow_always"),
[permissionMetadata],
);
const hasAllowAlways = !!allowAlwaysOption;
const handleAllowAlways = useCallback(() => {
if (allowAlwaysOption) handleRespond(allowAlwaysOption.option_id);
}, [allowAlwaysOption, handleRespond]);
const handleReject = useCallback(() => {
const rejectOption = permissionMetadata?.options.find(
(opt) => opt.kind === "reject_once" || opt.kind === "reject_always",
@@ -106,5 +122,5 @@ export function usePermissionResponseHandlers({
}
}, [permissionMetadata, handleRespond]);
return { isResponding, handleApprove, handleReject };
return { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject };
}