summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
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/components
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/components')
-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
3 files changed, 208 insertions, 10 deletions
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>