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}

View file

@ -12,7 +12,7 @@ import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { CopilotAgent } from "../application/assistant/agent.js";
import { isBlocked } from "../application/lib/command-executor.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
@ -462,6 +462,7 @@ export class AgentState {
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
allowedToolCallIds: Record<string, true> = {};
deniedToolCallIds: Record<string, true> = {};
sessionAllowedCommands: Set<string> = new Set();
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
@ -598,6 +599,16 @@ export class AgentState {
switch (event.response) {
case "approve":
this.allowedToolCallIds[event.toolCallId] = true;
// For session scope, extract command names and add to session allowlist
if (event.scope === "session") {
const toolCall = this.toolCallIdMap[event.toolCallId];
if (toolCall && typeof toolCall.arguments === 'object' && toolCall.arguments !== null && 'command' in toolCall.arguments) {
const names = extractCommandNames(String(toolCall.arguments.command));
for (const name of names) {
this.sessionAllowedCommands.add(name);
}
}
}
break;
case "deny":
this.deniedToolCallIds[event.toolCallId] = true;
@ -882,7 +893,7 @@ export async function* streamAgent({
}
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
// if command is blocked, then seek permission
if (isBlocked(part.arguments.command)) {
if (isBlocked(part.arguments.command, state.sessionAllowedCommands)) {
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
yield* processEvent({
runId,

View file

@ -3,15 +3,15 @@ import { promisify } from 'util';
import { getSecurityAllowList } from '../../config/security.js';
const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\))/;
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
function sanitizeToken(token: string): string {
return token.trim().replace(/^['"]+|['"]+$/g, '');
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
}
function extractCommandNames(command: string): string[] {
export function extractCommandNames(command: string): string[] {
const discovered = new Set<string>();
const segments = command.split(COMMAND_SPLIT_REGEX);
@ -42,27 +42,21 @@ function extractCommandNames(command: string): string[] {
return Array.from(discovered);
}
function findBlockedCommands(command: string): string[] {
function findBlockedCommands(command: string, sessionAllowedCommands?: Set<string>): string[] {
const invoked = extractCommandNames(command);
if (!invoked.length) return [];
const allowList = getSecurityAllowList();
if (!allowList.length) return invoked;
if (!allowList.length && (!sessionAllowedCommands || sessionAllowedCommands.size === 0)) return invoked;
const allowSet = new Set(allowList);
if (allowSet.has('*')) return [];
return invoked.filter((cmd) => !allowSet.has(cmd));
return invoked.filter((cmd) => !allowSet.has(cmd) && !sessionAllowedCommands?.has(cmd));
}
// export const BlockedResult = {
// stdout: '',
// stderr: `Command blocked by security policy. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`,
// exitCode: 126,
// };
export function isBlocked(command: string): boolean {
const blocked = findBlockedCommands(command);
export function isBlocked(command: string, sessionAllowedCommands?: Set<string>): boolean {
const blocked = findBlockedCommands(command, sessionAllowedCommands);
return blocked.length > 0;
}

View file

@ -20,6 +20,23 @@ const DEFAULT_ALLOW_LIST = [
let cachedAllowList: string[] | null = null;
let cachedMtimeMs: number | null = null;
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.
* Called explicitly at app startup via initConfigs().
@ -102,11 +119,9 @@ export function getSecurityAllowList(): string[] {
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
return cachedAllowList;
}
const allowList = readAllowList();
cachedAllowList = allowList;
cachedAllowList = readAllowList();
cachedMtimeMs = stats.mtimeMs;
return allowList;
return cachedAllowList;
} catch {
cachedAllowList = null;
cachedMtimeMs = null;

View file

@ -1,13 +1,15 @@
import z from "zod";
import container from "../di/container.js";
import { IMessageQueue } from "../application/lib/message-queue.js";
import { AskHumanResponseEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
import { IBus } from "../application/lib/bus.js";
import { IAbortRegistry } from "./abort-registry.js";
import { IRunsLock } from "./lock.js";
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
import { extractCommandNames } from "../application/lib/command-executor.js";
import { addToSecurityConfig } from "../config/security.js";
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
const repo = container.resolve<IRunsRepo>('runsRepo');
@ -26,11 +28,32 @@ export async function createMessage(runId: string, message: string): Promise<str
}
export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
const { scope, ...rest } = ev;
// For "always" scope, derive command from the run log and persist to security config
if (rest.response === "approve" && scope === "always") {
const repo = container.resolve<IRunsRepo>('runsRepo');
const run = await repo.fetch(runId);
const permReqEvent = run.log.find(
(e): e is z.infer<typeof ToolPermissionRequestEvent> =>
e.type === "tool-permission-request"
&& e.toolCall.toolCallId === rest.toolCallId
&& JSON.stringify(e.subflow) === JSON.stringify(rest.subflow)
);
if (permReqEvent && typeof permReqEvent.toolCall.arguments === 'object' && permReqEvent.toolCall.arguments !== null && 'command' in permReqEvent.toolCall.arguments) {
const commandNames = extractCommandNames(String(permReqEvent.toolCall.arguments.command));
if (commandNames.length > 0) {
await addToSecurityConfig(commandNames);
}
}
}
const repo = container.resolve<IRunsRepo>('runsRepo');
const event: z.infer<typeof ToolPermissionResponseEvent> = {
...ev,
...rest,
runId,
type: "tool-permission-response",
scope,
};
await repo.appendEvents(runId, [event]);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');

View file

@ -73,6 +73,7 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({
type: z.literal("tool-permission-response"),
toolCallId: z.string(),
response: z.enum(["approve", "deny"]),
scope: z.enum(["once", "session", "always"]).optional(),
});
export const RunErrorEvent = BaseRunEvent.extend({
@ -106,6 +107,7 @@ export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
subflow: true,
toolCallId: true,
response: true,
scope: true,
});
export const AskHumanResponsePayload = AskHumanResponseEvent.pick({