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/components | |
| 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/components')
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> |
