added session and always permissions

This commit is contained in:
Arjun 2026-02-19 15:36:09 +05:30
parent b238089e2d
commit 71a3d2ff91
7 changed files with 200 additions and 27 deletions

View file

@ -1527,9 +1527,15 @@ function App() {
} }
}, [runId, isStopping, stopClickedAt]) }, [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',
command?: string,
) => {
if (!runId) return if (!runId) return
// Optimistically update the UI immediately // Optimistically update the UI immediately
setPermissionResponses(prev => { setPermissionResponses(prev => {
const next = new Map(prev) const next = new Map(prev)
@ -1541,11 +1547,11 @@ function App() {
next.delete(toolCallId) next.delete(toolCallId)
return next return next
}) })
try { try {
await window.ipc.invoke('runs:authorizePermission', { await window.ipc.invoke('runs:authorizePermission', {
runId, runId,
authorization: { subflow, toolCallId, response } authorization: { subflow, toolCallId, response, scope, command }
}) })
} catch (error) { } catch (error) {
console.error('Failed to authorize permission:', error) console.error('Failed to authorize permission:', error)
@ -2945,6 +2951,14 @@ function App() {
<PermissionRequest <PermissionRequest
toolCall={permRequest.toolCall} toolCall={permRequest.toolCall}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => {
const cmd = permRequest.toolCall.toolName === 'executeCommand' && typeof permRequest.toolCall.arguments === 'object' && permRequest.toolCall.arguments !== null && 'command' in permRequest.toolCall.arguments ? String(permRequest.toolCall.arguments.command) : undefined
handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session', cmd)
}}
onApproveAlways={() => {
const cmd = permRequest.toolCall.toolName === 'executeCommand' && typeof permRequest.toolCall.arguments === 'object' && permRequest.toolCall.arguments !== null && 'command' in permRequest.toolCall.arguments ? String(permRequest.toolCall.arguments.command) : undefined
handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always', cmd)
}}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing} isProcessing={isActive && isProcessing}
response={response} response={response}

View file

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

View file

@ -101,7 +101,7 @@ interface ChatSidebarProps {
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
allPermissionRequests?: ChatTabViewState['allPermissionRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests']
permissionResponses?: ChatTabViewState['permissionResponses'] permissionResponses?: ChatTabViewState['permissionResponses']
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse) => void onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always', command?: string) => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
isToolOpenForTab?: (tabId: string, toolId: string) => boolean isToolOpenForTab?: (tabId: string, toolId: string) => boolean
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
@ -444,6 +444,14 @@ export function ChatSidebar({
<PermissionRequest <PermissionRequest
toolCall={permRequest.toolCall} toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => {
const cmd = permRequest.toolCall.toolName === 'executeCommand' && typeof permRequest.toolCall.arguments === 'object' && permRequest.toolCall.arguments !== null && 'command' in permRequest.toolCall.arguments ? String(permRequest.toolCall.arguments.command) : undefined
onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session', cmd)
}}
onApproveAlways={() => {
const cmd = permRequest.toolCall.toolName === 'executeCommand' && typeof permRequest.toolCall.arguments === 'object' && permRequest.toolCall.arguments !== null && 'command' in permRequest.toolCall.arguments ? String(permRequest.toolCall.arguments.command) : undefined
onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always', cmd)
}}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing} isProcessing={isActive && isProcessing}
response={response} response={response}

View file

@ -3,17 +3,78 @@ import { promisify } from 'util';
import { getSecurityAllowList } from '../../config/security.js'; import { getSecurityAllowList } from '../../config/security.js';
const execPromise = promisify(exec); const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
function sanitizeToken(token: string): string { function sanitizeToken(token: string): string {
return token.trim().replace(/^['"]+|['"]+$/g, ''); return token.trim().replace(/^['"()]+|['"()]+$/g, '');
} }
function extractCommandNames(command: string): string[] { /**
* Split a shell command string on command separators (||, &&, ;, |, \n)
* while respecting single and double quotes.
*/
function splitCommandSegments(command: string): string[] {
const segments: string[] = [];
let current = '';
let inSingle = false;
let inDouble = false;
for (let i = 0; i < command.length; i++) {
const ch = command[i];
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
current += ch;
continue;
}
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
current += ch;
continue;
}
if (inSingle || inDouble) {
current += ch;
continue;
}
// Outside quotes — check for separators
if (ch === '\n' || ch === ';') {
segments.push(current);
current = '';
continue;
}
if (ch === '|') {
if (command[i + 1] === '|') {
// ||
segments.push(current);
current = '';
i++; // skip second |
} else {
// single pipe
segments.push(current);
current = '';
}
continue;
}
if (ch === '&' && command[i + 1] === '&') {
segments.push(current);
current = '';
i++; // skip second &
continue;
}
current += ch;
}
if (current) segments.push(current);
return segments;
}
export function extractCommandNames(command: string): string[] {
const discovered = new Set<string>(); const discovered = new Set<string>();
const segments = command.split(COMMAND_SPLIT_REGEX); const segments = splitCommandSegments(command);
for (const segment of segments) { for (const segment of segments) {
const tokens = segment.trim().split(/\s+/).filter(Boolean); const tokens = segment.trim().split(/\s+/).filter(Boolean);

View file

@ -20,6 +20,33 @@ const DEFAULT_ALLOW_LIST = [
let cachedAllowList: string[] | null = null; let cachedAllowList: string[] | null = null;
let cachedMtimeMs: number | null = null; let cachedMtimeMs: number | null = null;
/** In-memory session allowlist — resets on app restart */
const sessionAllowSet = new Set<string>();
export function addToSessionAllowList(commands: string[]): void {
for (const cmd of commands) {
const normalized = cmd.trim().toLowerCase();
if (normalized) sessionAllowSet.add(normalized);
}
}
export async function addToSecurityConfig(commands: string[]): Promise<void> {
ensureSecurityConfigSync();
const current = readAllowList();
const merged = new Set(current);
for (const cmd of commands) {
const normalized = cmd.trim().toLowerCase();
if (normalized) merged.add(normalized);
}
await fsPromises.writeFile(
SECURITY_CONFIG_PATH,
JSON.stringify(Array.from(merged).sort(), null, 2) + "\n",
"utf8",
);
// Reset cache so next read picks up the new file
resetSecurityAllowListCache();
}
/** /**
* Async function to ensure security config file exists. * Async function to ensure security config file exists.
* Called explicitly at app startup via initConfigs(). * Called explicitly at app startup via initConfigs().
@ -99,18 +126,28 @@ export function getSecurityAllowList(): string[] {
ensureSecurityConfigSync(); ensureSecurityConfigSync();
try { try {
const stats = fs.statSync(SECURITY_CONFIG_PATH); const stats = fs.statSync(SECURITY_CONFIG_PATH);
let fileList: string[];
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) { if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
return cachedAllowList; fileList = cachedAllowList;
} else {
fileList = readAllowList();
cachedAllowList = fileList;
cachedMtimeMs = stats.mtimeMs;
} }
const allowList = readAllowList(); // Merge session allowlist
cachedAllowList = allowList; if (sessionAllowSet.size === 0) return fileList;
cachedMtimeMs = stats.mtimeMs; const merged = new Set(fileList);
return allowList; for (const cmd of sessionAllowSet) merged.add(cmd);
return Array.from(merged);
} catch { } catch {
cachedAllowList = null; cachedAllowList = null;
cachedMtimeMs = null; cachedMtimeMs = null;
return readAllowList(); const fileList = readAllowList();
if (sessionAllowSet.size === 0) return fileList;
const merged = new Set(fileList);
for (const cmd of sessionAllowSet) merged.add(cmd);
return Array.from(merged);
} }
} }

View file

@ -8,6 +8,8 @@ import { IBus } from "../application/lib/bus.js";
import { IAbortRegistry } from "./abort-registry.js"; import { IAbortRegistry } from "./abort-registry.js";
import { IRunsLock } from "./lock.js"; import { IRunsLock } from "./lock.js";
import { forceCloseAllMcpClients } from "../mcp/mcp.js"; import { forceCloseAllMcpClients } from "../mcp/mcp.js";
import { extractCommandNames } from "../application/lib/command-executor.js";
import { addToSessionAllowList, addToSecurityConfig } from "../config/security.js";
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> { export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
const repo = container.resolve<IRunsRepo>('runsRepo'); const repo = container.resolve<IRunsRepo>('runsRepo');
@ -26,9 +28,23 @@ export async function createMessage(runId: string, message: string): Promise<str
} }
export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> { export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
const { scope, command, ...rest } = ev;
// Handle scope side-effects when approving
if (rest.response === "approve" && command && scope && scope !== "once") {
const commandNames = extractCommandNames(command);
if (commandNames.length > 0) {
if (scope === "session") {
addToSessionAllowList(commandNames);
} else if (scope === "always") {
await addToSecurityConfig(commandNames);
}
}
}
const repo = container.resolve<IRunsRepo>('runsRepo'); const repo = container.resolve<IRunsRepo>('runsRepo');
const event: z.infer<typeof ToolPermissionResponseEvent> = { const event: z.infer<typeof ToolPermissionResponseEvent> = {
...ev, ...rest,
runId, runId,
type: "tool-permission-response", type: "tool-permission-response",
}; };

View file

@ -106,6 +106,9 @@ export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
subflow: true, subflow: true,
toolCallId: true, toolCallId: true,
response: true, response: true,
}).extend({
scope: z.enum(["once", "session", "always"]).optional(),
command: z.string().optional(),
}); });
export const AskHumanResponsePayload = AskHumanResponseEvent.pick({ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({