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/api/src/index.ts | |
| 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/api/src/index.ts')
| -rw-r--r-- | packages/api/src/index.ts | 48 |
1 files changed, 42 insertions, 6 deletions
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 02b04b6..bf0f7f9 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,27 +1,63 @@ import { createBunWebSocket } from "hono/bun"; -import { agentManager, app } from "./app.js"; +import { agentManager, app, permissionManager } from "./app.js"; +import type { PermissionReply } from "@dispatch/core"; const { upgradeWebSocket, websocket } = createBunWebSocket(); +let clientIdCounter = 0; + app.get( "/ws", upgradeWebSocket((_c) => { + const clientId = String(++clientIdCounter); + return { onOpen(_event, ws) { // Send current status immediately ws.send(JSON.stringify({ type: "status", status: agentManager.getStatus() })); + // Send any pending permission prompts + const pending = permissionManager.getPending(); + if (pending.length > 0) { + ws.send(JSON.stringify({ type: "permission-prompt", pending })); + } + const unsubscribe = agentManager.onEvent((event) => { ws.send(JSON.stringify(event)); }); - // Store unsubscribe fn on the raw socket for cleanup - (ws as unknown as { _unsub?: () => void })._unsub = unsubscribe; + permissionManager.registerClient(clientId, (data) => { + ws.send(JSON.stringify(data)); + }); + + // Store cleanup on the raw socket + (ws as unknown as { _unsub?: () => void; _clientId?: string })._unsub = unsubscribe; + (ws as unknown as { _unsub?: () => void; _clientId?: string })._clientId = clientId; + }, + onMessage(event, _ws) { + try { + const message = JSON.parse(String(event.data)) as { + type?: string; + id?: string; + reply?: string; + }; + if (message.type === "permission-reply" && typeof message.id === "string" && typeof message.reply === "string") { + const validReplies: PermissionReply[] = ["once", "always", "reject"]; + if (validReplies.includes(message.reply as PermissionReply)) { + permissionManager.reply(message.id, message.reply as PermissionReply); + } + } + } catch { + // ignore malformed messages + } }, onClose(_event, ws) { - const unsub = (ws as unknown as { _unsub?: () => void })._unsub; - if (unsub) { - unsub(); + const raw = ws as unknown as { _unsub?: () => void; _clientId?: string }; + if (raw._unsub) { + raw._unsub(); + } + if (raw._clientId) { + permissionManager.unregisterClient(raw._clientId); } }, }; |
