From a38d5b1279db6f9de5228c173019fc2ac08daec3 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 19 May 2026 23:20:41 +0900 Subject: feat: Phase 2 — shell permissions, tree-sitter analysis, permission UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission engine: - Rule-based engine: wildcard matching, last-match-wins, reject cascade - PermissionService with pending/approved state, PermissionChecker interface - dispatch.yaml config loader with per-permission pattern rules Shell tool: - run_shell tool with child_process spawn, timeout, streaming output - Tree-sitter static analysis (web-tree-sitter + tree-sitter-bash WASM) - BashArity command normalization for 'always allow' patterns - FILE_COMMANDS set: rm, cp, mv, mkdir, ls, find, grep, cat, etc. Agent loop refactored: - Removed maxSteps, manual step loop with tool execution - Permission checks on shell commands (external_directory only) - Permission checks on file tools outside workspace boundary - Symlink bypass fix (realpathSync), .. false positive fix - Shell output streaming via Promise.race + setImmediate polling API layer: - PermissionManager wraps PermissionService, broadcasts via WebSocket - WebSocket handles permission-reply messages from frontend - Config loaded from dispatch.yaml, converted to ruleset Frontend: - Permission prompt modal (native dialog, focus trap, ARIA) - Always-allow confirmation flow with pattern preview - Shell output display (live streaming + final parsed result) - Permission log panel (fixed bottom-right overlay) - Exit code badge (green 0, red non-zero) 134 tests, typecheck clean on all 3 packages --- packages/frontend/src/lib/chat.svelte.ts | 83 +++++++++++++++--- .../src/lib/components/PermissionLog.svelte | 28 +++++++ .../src/lib/components/PermissionPrompt.svelte | 97 ++++++++++++++++++++++ .../src/lib/components/ToolCallDisplay.svelte | 93 ++++++++++++++++++--- packages/frontend/src/lib/types.ts | 23 ++++- packages/frontend/src/lib/ws.svelte.ts | 7 ++ 6 files changed, 309 insertions(+), 22 deletions(-) create mode 100644 packages/frontend/src/lib/components/PermissionLog.svelte create mode 100644 packages/frontend/src/lib/components/PermissionPrompt.svelte (limited to 'packages/frontend/src/lib') diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts index 78fb2db..c0f0a98 100644 --- a/packages/frontend/src/lib/chat.svelte.ts +++ b/packages/frontend/src/lib/chat.svelte.ts @@ -1,5 +1,5 @@ import { config } from "./config.js"; -import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo } from "./types.js"; +import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo, LogEntry, PermissionPrompt } from "./types.js"; import { wsClient } from "./ws.svelte.js"; function generateId() { @@ -69,6 +69,8 @@ function createChatStore() { let agentStatus: "idle" | "running" | "error" = $state("idle"); let isConnected = $state(false); let currentAssistantId: string | null = null; + let pendingPermissions: PermissionPrompt[] = $state([]); + let permissionLog: LogEntry[] = $state([]); wsClient.onEvent((event) => { handleEvent(event); @@ -112,16 +114,16 @@ function createChatStore() { } break; } - case "reasoning-delta": { - ensureCurrentAssistantMessage(); - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - return { ...m, thinking: (m.thinking ?? "") + event.delta }; - } - return m; - }); - break; - } + case "reasoning-delta": { + ensureCurrentAssistantMessage(); + messages = messages.map((m) => { + if (m.id === currentAssistantId) { + return { ...m, thinking: (m.thinking ?? "") + event.delta }; + } + return m; + }); + break; + } case "text-delta": { ensureCurrentAssistantMessage(); messages = messages.map((m) => { @@ -203,6 +205,37 @@ function createChatStore() { agentStatus = "error"; break; } + case "permission-prompt": { + pendingPermissions = event.pending; + break; + } + case "shell-output": { + messages = messages.map((m) => { + if (m.id === currentAssistantId) { + // Find the last tool-call segment + const segments = [...m.content]; + let found = false; + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + if (seg && seg.type === "tool-call") { + segments[i] = { + ...seg, + shellOutput: { + stdout: (seg.shellOutput?.stdout ?? "") + (event.stream === "stdout" ? event.data : ""), + stderr: (seg.shellOutput?.stderr ?? "") + (event.stream === "stderr" ? event.data : ""), + }, + }; + found = true; + break; + } + } + if (!found) return m; // no tool-call segment yet + return { ...m, content: segments }; + } + return m; + }); + break; + } } } @@ -258,6 +291,27 @@ function createChatStore() { return formatConversation(messages); } + function replyPermission(id: string, reply: "once" | "always" | "reject") { + if (wsClient.connectionStatus !== "connected") { + // WebSocket is not connected; skip optimistic removal to avoid losing the prompt + return; + } + const prompt = pendingPermissions.find((p) => p.id === id); + wsClient.send({ type: "permission-reply", id, reply }); + pendingPermissions = pendingPermissions.filter((p) => p.id !== id); + if (prompt) { + const entry: LogEntry = { + id: generateId(), + permission: prompt.permission, + patterns: prompt.patterns, + action: reply, + timestamp: new Date().toISOString(), + description: prompt.description, + }; + permissionLog = [...permissionLog, entry]; + } + } + function clear() { messages = []; currentAssistantId = null; @@ -274,8 +328,15 @@ function createChatStore() { get isConnected() { return isConnected; }, + get pendingPermissions() { + return pendingPermissions; + }, + get permissionLog() { + return permissionLog; + }, sendMessage, handleEvent, + replyPermission, copyConversation, clear, }; diff --git a/packages/frontend/src/lib/components/PermissionLog.svelte b/packages/frontend/src/lib/components/PermissionLog.svelte new file mode 100644 index 0000000..f6e07f6 --- /dev/null +++ b/packages/frontend/src/lib/components/PermissionLog.svelte @@ -0,0 +1,28 @@ + + +
+ +
+ Permission Log ({entries.length}) +
+
+ {#if entries.length === 0} +

No permissions granted or denied yet.

+ {:else} + {#each entries as entry (entry.id)} +
+ + {entry.action} + + {entry.permission} + {entry.timestamp} +
+

{entry.description}

+ {/each} + {/if} +
+
diff --git a/packages/frontend/src/lib/components/PermissionPrompt.svelte b/packages/frontend/src/lib/components/PermissionPrompt.svelte new file mode 100644 index 0000000..ce7afd7 --- /dev/null +++ b/packages/frontend/src/lib/components/PermissionPrompt.svelte @@ -0,0 +1,97 @@ + + + + {#if current} + {#if !showAlwaysConfirmation} + + {:else} + + {/if} + {/if} + diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte index 83d21ba..070a8e3 100644 --- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte +++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte @@ -8,6 +8,39 @@ let isExpanded = $state(toolCall.isExpanded); function toggle() { isExpanded = !isExpanded; } + +interface ShellResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function parseShellResult(result: string): ShellResult | null { + try { + const parsed = JSON.parse(result) as unknown; + if ( + parsed !== null && + typeof parsed === "object" && + "stdout" in parsed && + "stderr" in parsed && + "exitCode" in parsed + ) { + return { + stdout: String((parsed as Record).stdout ?? ""), + stderr: String((parsed as Record).stderr ?? ""), + exitCode: Number((parsed as Record).exitCode ?? 0), + }; + } + return null; + } catch { + return null; + } +} + +const isShell = $derived(toolCall.name === "run_shell"); +const shellResult = $derived( + isShell && toolCall.result !== undefined ? parseShellResult(toolCall.result) : null, +);
@@ -20,7 +53,11 @@ function toggle() { tool {toolCall.name} {#if toolCall.result !== undefined} - {#if toolCall.isError} + {#if isShell && shellResult !== null} + + exit {shellResult.exitCode} + + {:else if toolCall.isError} error {:else} done @@ -36,15 +73,51 @@ function toggle() {

Arguments

{JSON.stringify(toolCall.arguments, null, 2)}
- {#if toolCall.result !== undefined} -
-

Result

-
{toolCall.result}
-
- {/if} + {#if isShell && toolCall.result !== undefined} + {#if shellResult !== null} +
+

stdout:

+
{shellResult.stdout || "(empty)"}
+
+ {#if shellResult.stderr} +
+

stderr:

+
{shellResult.stderr}
+
+ {/if} +
+ exit code: + {shellResult.exitCode} +
+ {:else} +
+

Result

+
{toolCall.result}
+
+ {/if} + {:else if isShell && toolCall.shellOutput} + {#if toolCall.shellOutput.stdout} +
+

stdout

+
{toolCall.shellOutput.stdout}
+
+ {/if} + {#if toolCall.shellOutput.stderr} +
+

stderr

+
{toolCall.shellOutput.stderr}
+
+ {/if} + Running... + {:else if toolCall.result !== undefined} +
+

Result

+
{toolCall.result}
+
+ {/if} {/if} diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 93bc477..3626bbe 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -5,6 +5,7 @@ export interface ToolCallDisplay { result?: string; isError?: boolean; isExpanded: boolean; + shellOutput?: { stdout: string; stderr: string }; } export interface DebugInfo { @@ -59,4 +60,24 @@ export type AgentEvent = toolCalls?: unknown[]; toolResults?: unknown[]; }; - }; + } + | { type: "permission-prompt"; pending: PermissionPrompt[] } + | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }; + +export interface PermissionPrompt { + id: string; + permission: string; + patterns: string[]; + always: string[]; + description: string; + metadata: Record; +} + +export interface LogEntry { + id: string; + permission: string; + patterns: string[]; + action: "once" | "always" | "reject"; + timestamp: string; + description: string; +} diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts index 76c7ef5..2ca97be 100644 --- a/packages/frontend/src/lib/ws.svelte.ts +++ b/packages/frontend/src/lib/ws.svelte.ts @@ -76,6 +76,12 @@ function createWebSocketClient(url: string) { }; } + function send(data: unknown): void { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)); + } + } + return { get connectionStatus() { return connectionStatus; @@ -83,6 +89,7 @@ function createWebSocketClient(url: string) { connect, disconnect, onEvent, + send, }; } -- cgit v1.2.3