summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
committerAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
commita38d5b1279db6f9de5228c173019fc2ac08daec3 (patch)
tree32c3a535d0b74872ef952b4a44d4d5ba2ec9d638 /packages/frontend/src/lib
parent0ae805b28b5160b8d9fb43635fa172961f6550cc (diff)
downloaddispatch-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.ts83
-rw-r--r--packages/frontend/src/lib/components/PermissionLog.svelte28
-rw-r--r--packages/frontend/src/lib/components/PermissionPrompt.svelte97
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte93
-rw-r--r--packages/frontend/src/lib/types.ts23
-rw-r--r--packages/frontend/src/lib/ws.svelte.ts7
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,
};
}