diff options
| author | Adam Malczewski <[email protected]> | 2026-05-19 23:20:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-19 23:20:41 +0900 |
| commit | a38d5b1279db6f9de5228c173019fc2ac08daec3 (patch) | |
| tree | 32c3a535d0b74872ef952b4a44d4d5ba2ec9d638 /packages/frontend/src/lib | |
| parent | 0ae805b28b5160b8d9fb43635fa172961f6550cc (diff) | |
| download | dispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.tar.gz dispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.zip | |
feat: Phase 2 — shell permissions, tree-sitter analysis, permission UI
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
Diffstat (limited to 'packages/frontend/src/lib')
| -rw-r--r-- | packages/frontend/src/lib/chat.svelte.ts | 83 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/PermissionLog.svelte | 28 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/PermissionPrompt.svelte | 97 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ToolCallDisplay.svelte | 93 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 23 | ||||
| -rw-r--r-- | packages/frontend/src/lib/ws.svelte.ts | 7 |
6 files changed, 309 insertions, 22 deletions
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 @@ +<script lang="ts"> +import type { LogEntry } from "../types.js"; + +const { entries }: { entries: LogEntry[] } = $props(); +</script> + +<div class="collapse collapse-arrow bg-base-200 mt-4"> + <input type="checkbox" /> + <div class="collapse-title text-sm font-medium"> + Permission Log ({entries.length}) + </div> + <div class="collapse-content text-xs max-h-40 overflow-y-auto"> + {#if entries.length === 0} + <p class="text-base-content/50 italic">No permissions granted or denied yet.</p> + {:else} + {#each entries as entry (entry.id)} + <div class="flex items-center gap-2 py-1 border-b border-base-300"> + <span class="badge badge-sm {entry.action === 'reject' ? 'badge-error' : 'badge-success'}"> + {entry.action} + </span> + <span class="text-base-content/70">{entry.permission}</span> + <span class="text-base-content/50 ml-auto text-xs">{entry.timestamp}</span> + </div> + <p class="text-base-content/60 pl-2 pb-1">{entry.description}</p> + {/each} + {/if} + </div> +</div> 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 @@ +<script lang="ts"> +import type { PermissionPrompt } from "../types.js"; + +const { pending, onReply }: { + pending: PermissionPrompt[]; + onReply: (id: string, reply: "once" | "always" | "reject") => void; +} = $props(); + +let current = $derived(pending[0]); + +let showAlwaysConfirmation = $state(false); + +let dialogEl: HTMLDialogElement | undefined = $state(); + +$effect(() => { + if (!dialogEl) return; + if (current) { + if (!dialogEl.open) dialogEl.showModal(); + } else { + if (dialogEl.open) dialogEl.close(); + } +}); + +function handleAlways() { + showAlwaysConfirmation = true; +} + +function confirmAlways() { + if (current) onReply(current.id, "always"); + showAlwaysConfirmation = false; +} + +function handleOnce() { + if (current) onReply(current.id, "once"); +} + +function handleReject() { + showAlwaysConfirmation = false; + if (current) onReply(current.id, "reject"); +} +</script> + +<dialog class="modal" bind:this={dialogEl} oncancel={handleReject}> + {#if current} + {#if !showAlwaysConfirmation} + <div class="modal-box"> + <h3 class="text-lg font-bold"> + {#if current.permission === "bash"} + Run command + {:else if current.permission === "external_directory"} + Access external directory + {:else if current.permission === "read"} + Read file + {:else if current.permission === "edit"} + Edit file + {:else} + Permission required + {/if} + </h3> + + <p class="py-2 text-sm opacity-70">{current.description}</p> + + {#if current.permission === "bash" && current.metadata.command} + <div class="mockup-code my-2"> + <pre><code>$ {current.metadata.command as string}</code></pre> + </div> + {/if} + + {#if current.metadata.filepath} + <p class="text-sm font-mono">{current.metadata.filepath as string}</p> + {/if} + + <div class="modal-action gap-2"> + <button class="btn btn-sm btn-ghost" aria-label="Deny permission" onclick={handleReject}>Deny</button> + <button class="btn btn-sm" aria-label="Allow once" onclick={handleOnce}>Allow once</button> + <button class="btn btn-sm btn-primary" aria-label="Always allow" onclick={handleAlways}>Always allow</button> + </div> + </div> + {:else} + <div class="modal-box"> + <h3 class="text-lg font-bold">Always allow?</h3> + <p class="py-2 text-sm"> + The following patterns will be permanently allowed until you restart Dispatch: + </p> + <div class="mockup-code my-2"> + {#each current.always as pattern} + <pre><code>{pattern}</code></pre> + {/each} + </div> + <div class="modal-action gap-2"> + <button class="btn btn-sm btn-ghost" aria-label="Go back" onclick={() => showAlwaysConfirmation = false}>Back</button> + <button class="btn btn-sm btn-primary" aria-label="Confirm always allow" onclick={confirmAlways}>Confirm</button> + </div> + </div> + {/if} + {/if} +</dialog> 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<string, unknown>).stdout ?? ""), + stderr: String((parsed as Record<string, unknown>).stderr ?? ""), + exitCode: Number((parsed as Record<string, unknown>).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, +); </script> <div class="collapse collapse-arrow bg-base-200 my-1 rounded-lg border border-base-300 {isExpanded ? 'collapse-open' : ''}"> @@ -20,7 +53,11 @@ function toggle() { <span class="badge badge-neutral badge-sm">tool</span> <span class="font-mono">{toolCall.name}</span> {#if toolCall.result !== undefined} - {#if toolCall.isError} + {#if isShell && shellResult !== null} + <span class="badge badge-sm ml-auto {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}"> + exit {shellResult.exitCode} + </span> + {:else if toolCall.isError} <span class="badge badge-error badge-sm ml-auto">error</span> {:else} <span class="badge badge-success badge-sm ml-auto">done</span> @@ -36,15 +73,51 @@ function toggle() { <p class="font-semibold text-base-content/70 mb-1">Arguments</p> <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all">{JSON.stringify(toolCall.arguments, null, 2)}</pre> </div> - {#if toolCall.result !== undefined} - <div class="mt-2"> - <p class="font-semibold text-base-content/70 mb-1">Result</p> - <pre - class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError - ? 'bg-error/20 text-error' - : 'bg-base-300'}">{toolCall.result}</pre> - </div> - {/if} + {#if isShell && toolCall.result !== undefined} + {#if shellResult !== null} + <div class="mt-2"> + <p class="font-semibold text-base-content/70 mb-1">stdout:</p> + <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stdout || "(empty)"}</pre> + </div> + {#if shellResult.stderr} + <div class="mt-2"> + <p class="font-semibold text-error/80 mb-1">stderr:</p> + <pre class="bg-error/10 text-error rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stderr}</pre> + </div> + {/if} + <div class="mt-2 flex items-center gap-2"> + <span class="font-semibold text-base-content/70">exit code:</span> + <span class="badge badge-sm {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}">{shellResult.exitCode}</span> + </div> + {:else} + <div class="mt-2"> + <p class="font-semibold text-base-content/70 mb-1">Result</p> + <pre class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError ? 'bg-error/20 text-error' : 'bg-base-300'}">{toolCall.result}</pre> + </div> + {/if} + {:else if isShell && toolCall.shellOutput} + {#if toolCall.shellOutput.stdout} + <div class="mt-2"> + <p class="font-semibold text-base-content/70 mb-1">stdout</p> + <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs">{toolCall.shellOutput.stdout}</pre> + </div> + {/if} + {#if toolCall.shellOutput.stderr} + <div class="mt-2"> + <p class="font-semibold text-error/70 mb-1">stderr</p> + <pre class="bg-error/10 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs text-error">{toolCall.shellOutput.stderr}</pre> + </div> + {/if} + <span class="text-xs text-base-content/50 italic">Running...</span> + {:else if toolCall.result !== undefined} + <div class="mt-2"> + <p class="font-semibold text-base-content/70 mb-1">Result</p> + <pre + class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError + ? 'bg-error/20 text-error' + : 'bg-base-300'}">{toolCall.result}</pre> + </div> + {/if} </div> {/if} </div> 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<string, unknown>; +} + +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, }; } |
