summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
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/api/src
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/api/src')
-rw-r--r--packages/api/src/agent-manager.ts20
-rw-r--r--packages/api/src/app.ts4
-rw-r--r--packages/api/src/index.ts48
-rw-r--r--packages/api/src/permission-manager.ts48
4 files changed, 113 insertions, 7 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 45e195c..817ddbf 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -4,14 +4,19 @@ import {
type AgentStatus,
createListFilesTool,
createReadFileTool,
+ createRunShellTool,
createWriteFileTool,
+ loadConfig,
+ configToRuleset,
} from "@dispatch/core";
+import type { PermissionManager } from "./permission-manager.js";
const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have access to the following tools for working with files in the current working directory:
- read_file: Read the contents of a file
- write_file: Write content to a file (creates parent directories if needed)
- list_files: List files and directories
+- run_shell: Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. Do NOT run destructive or irreversible commands unless the user explicitly requests them.
When asked to work with files, use these tools. Always confirm what you did after completing an action. Be concise and helpful.`;
@@ -20,6 +25,15 @@ export class AgentManager {
private status: AgentStatus = "idle";
private messageCount = 0;
private eventListeners: Set<(event: AgentEvent) => void> = new Set();
+ private permissionManager: PermissionManager | undefined;
+
+ constructor(permissionManager?: PermissionManager) {
+ this.permissionManager = permissionManager;
+ }
+
+ getPermissionManager(): PermissionManager | undefined {
+ return this.permissionManager;
+ }
private getOrCreateAgent(): Agent {
if (!this.agent) {
@@ -31,8 +45,12 @@ export class AgentManager {
createReadFileTool(workingDirectory),
createWriteFileTool(workingDirectory),
createListFilesTool(workingDirectory),
+ createRunShellTool(workingDirectory),
];
+ const config = loadConfig(workingDirectory);
+ const ruleset = configToRuleset(config);
+
this.agent = new Agent({
model,
apiKey,
@@ -40,6 +58,8 @@ export class AgentManager {
systemPrompt: SYSTEM_PROMPT,
tools,
workingDirectory,
+ permissionChecker: this.permissionManager ?? undefined,
+ ruleset,
});
}
return this.agent;
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 9c31eab..b612c6a 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -1,8 +1,10 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { AgentManager } from "./agent-manager.js";
+import { PermissionManager } from "./permission-manager.js";
-export const agentManager = new AgentManager();
+export const permissionManager = new PermissionManager();
+export const agentManager = new AgentManager(permissionManager);
export const app = new Hono();
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);
}
},
};
diff --git a/packages/api/src/permission-manager.ts b/packages/api/src/permission-manager.ts
new file mode 100644
index 0000000..6a04d3d
--- /dev/null
+++ b/packages/api/src/permission-manager.ts
@@ -0,0 +1,48 @@
+import {
+ PermissionService,
+ type PermissionReply,
+ type PermissionRequest,
+ type Ruleset,
+} from "@dispatch/core";
+
+export class PermissionManager {
+ private service = new PermissionService();
+ private wsClients: Map<string, (data: unknown) => void> = new Map();
+
+ registerClient(id: string, send: (data: unknown) => void): void {
+ this.wsClients.set(id, send);
+ }
+
+ unregisterClient(id: string): void {
+ this.wsClients.delete(id);
+ }
+
+ private broadcastPending(pending: Array<{ id: string; request: PermissionRequest }>): void {
+ const message = {
+ type: "permission-prompt",
+ pending: pending.map((p) => ({ id: p.id, ...p.request })),
+ };
+ for (const send of this.wsClients.values()) {
+ send(message);
+ }
+ }
+
+ async ask(request: PermissionRequest, rulesets: Ruleset[] = []): Promise<PermissionReply> {
+ const promise = this.service.ask(request, rulesets);
+ this.broadcastPending(this.service.getPending());
+ return promise;
+ }
+
+ reply(id: string, reply: PermissionReply): void {
+ this.service.reply(id, reply);
+ this.broadcastPending(this.service.getPending());
+ }
+
+ getPending(): Array<{ id: string; request: PermissionRequest }> {
+ return this.service.getPending();
+ }
+
+ getService(): PermissionService {
+ return this.service;
+ }
+}