add session and always permission scopes for command execution

Session-scoped permissions are stored in the run log and rebuilt by
the state-builder, scoping them to a single run. Always-scoped
permissions persist to security.json. The backend derives command
names from the run log instead of receiving them from the frontend.
Uses regex-based command parsing with subshell/parenthesis support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-02-24 13:00:08 +05:30
parent 1f85bcf8ae
commit 53d48ab4f3
8 changed files with 126 additions and 38 deletions

View file

@ -1570,9 +1570,14 @@ function App() {
}
}, [runId, isStopping, stopClickedAt])
const handlePermissionResponse = useCallback(async (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => {
const handlePermissionResponse = useCallback(async (
toolCallId: string,
subflow: string[],
response: 'approve' | 'deny',
scope?: 'once' | 'session' | 'always',
) => {
if (!runId) return
// Optimistically update the UI immediately
setPermissionResponses(prev => {
const next = new Map(prev)
@ -1584,11 +1589,11 @@ function App() {
next.delete(toolCallId)
return next
})
try {
await window.ipc.invoke('runs:authorizePermission', {
runId,
authorization: { subflow, toolCallId, response }
authorization: { subflow, toolCallId, response, scope }
})
} catch (error) {
console.error('Failed to authorize permission:', error)
@ -3057,6 +3062,8 @@ function App() {
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}

View file

@ -2,8 +2,14 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, XCircleIcon, XIcon } from "lucide-react";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import z from "zod";
@ -11,6 +17,8 @@ import z from "zod";
export type PermissionRequestProps = ComponentProps<"div"> & {
toolCall: z.infer<typeof ToolCallPart>;
onApprove?: () => void;
onApproveSession?: () => void;
onApproveAlways?: () => void;
onDeny?: () => void;
isProcessing?: boolean;
response?: 'approve' | 'deny' | null;
@ -20,6 +28,8 @@ export const PermissionRequest = ({
className,
toolCall,
onApprove,
onApproveSession,
onApproveAlways,
onDeny,
isProcessing = false,
response = null,
@ -117,16 +127,40 @@ export const PermissionRequest = ({
</div>
{!isResponded && (
<div className="flex items-center gap-2 pt-2">
<Button
variant="default"
size="sm"
onClick={onApprove}
disabled={isProcessing}
className="flex-1"
>
<CheckIcon className="size-4" />
Approve
</Button>
<div className="flex flex-1 items-center">
<Button
variant="default"
size="sm"
onClick={onApprove}
disabled={isProcessing}
className={cn("flex-1", command && "rounded-r-none")}
>
<CheckIcon className="size-4" />
Approve
</Button>
{command && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
disabled={isProcessing}
className="rounded-l-none border-l border-l-primary-foreground/20 px-1.5"
>
<ChevronDownIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onApproveSession}>
Allow for Session
</DropdownMenuItem>
<DropdownMenuItem onClick={onApproveAlways}>
Always Allow
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Button
variant="destructive"
size="sm"

View file

@ -101,7 +101,7 @@ interface ChatSidebarProps {
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
permissionResponses?: ChatTabViewState['permissionResponses']
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse) => void
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
@ -450,6 +450,8 @@ export function ChatSidebar({
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}