mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-06 05:42:37 +02:00
added session and always permissions
This commit is contained in:
parent
b238089e2d
commit
71a3d2ff91
7 changed files with 200 additions and 27 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue