summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/agent
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/core/src/agent
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/core/src/agent')
-rw-r--r--packages/core/src/agent/agent.ts302
1 files changed, 247 insertions, 55 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 8e89bb9..2fd6746 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -1,7 +1,10 @@
import type { CoreMessage } from "ai";
import { streamText } from "ai";
+import { dirname, isAbsolute, relative, resolve } from "node:path";
+import { realpathSync } from "node:fs";
import { createProvider } from "../llm/provider.js";
import { createToolRegistry } from "../tools/registry.js";
+import { analyzeCommand } from "../tools/shell-analyze.js";
import type {
AgentConfig,
AgentEvent,
@@ -23,7 +26,7 @@ function toCoreMessages(messages: ChatMessage[]): CoreMessage[] {
}
result.push({ role: "assistant", content: parts });
for (const tr of msg.toolResults ?? []) {
- result.push({ role: "tool", content: [{ type: "tool-result", toolCallId: tr.toolCallId, toolName: "", result: tr.result }] });
+ result.push({ role: "tool", content: [{ type: "tool-result", toolCallId: tr.toolCallId, toolName: tr.toolName, result: tr.result }] });
}
}
}
@@ -51,6 +54,8 @@ function formatError(err: unknown, config: AgentConfig): string {
return `${String(err)} ${context}`;
}
+const MAX_STEPS = 10;
+
export class Agent {
status: AgentStatus = "idle";
messages: ChatMessage[] = [];
@@ -61,6 +66,131 @@ export class Agent {
this.config = config;
}
+ private async executeToolWithStreaming(
+ tc: ToolCall,
+ shellOutputQueue: Array<{ data: string; stream: "stdout" | "stderr" }>,
+ ): Promise<ToolResult> {
+ const registry = createToolRegistry(this.config.tools);
+ const tool = registry.getTool(tc.name);
+ if (!tool) {
+ return { toolCallId: tc.id, toolName: tc.name, result: `Unknown tool: ${tc.name}`, isError: true };
+ }
+
+ // Permission check for shell commands — only prompt for external directory access.
+ // Commands that stay within the working directory are auto-allowed.
+ if (tc.name === "run_shell" && this.config.permissionChecker) {
+ const command = typeof tc.arguments.command === "string" ? tc.arguments.command : "";
+ const workingDirectory = this.config.workingDirectory;
+ const analysis = await analyzeCommand(command, workingDirectory);
+ const ruleset = this.config.ruleset ?? [];
+
+ // Check for external directory access from shell command
+ if (analysis.dirs.length > 0) {
+ const dirRequest = {
+ permission: "external_directory",
+ patterns: analysis.dirs.map((d) => `${d}/*`),
+ always: analysis.dirs.map((d) => `${d}/*`),
+ description: `Shell command accesses external directory: ${analysis.dirs.join(", ")}`,
+ metadata: { command, dirs: analysis.dirs },
+ };
+ try {
+ const dirReply = await this.config.permissionChecker.ask(dirRequest, [ruleset]);
+ if (dirReply === "reject") {
+ return {
+ toolCallId: tc.id,
+ toolName: tc.name,
+ result: `Permission denied: access to external directories rejected`,
+ isError: true,
+ };
+ }
+ } catch {
+ return {
+ toolCallId: tc.id,
+ toolName: tc.name,
+ result: `Permission denied: external directory access not allowed`,
+ isError: true,
+ };
+ }
+ }
+ }
+
+ // Permission check for file tools accessing paths outside workspace
+ if (
+ this.config.permissionChecker &&
+ (tc.name === "read_file" || tc.name === "write_file" || tc.name === "list_files")
+ ) {
+ const pathArg = typeof tc.arguments.path === "string" ? tc.arguments.path : ".";
+ let resolvedPath: string;
+ try {
+ resolvedPath = realpathSync(resolve(this.config.workingDirectory, pathArg));
+ } catch {
+ // Path doesn't exist yet (e.g. write_file creating a new file) — fall back
+ resolvedPath = resolve(this.config.workingDirectory, pathArg);
+ }
+
+ // Check if outside workspace
+ const rel = relative(this.config.workingDirectory, resolvedPath);
+ const isOutside = rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel);
+
+ if (isOutside) {
+ const permissionType =
+ tc.name === "read_file" ? "read" : tc.name === "write_file" ? "edit" : "list";
+
+ const parentDir = dirname(resolvedPath);
+ const request = {
+ permission: "external_directory",
+ patterns: [`${parentDir}/*`],
+ always: [`${parentDir}/*`],
+ description: `${permissionType} file outside workspace: ${resolvedPath}`,
+ metadata: { filepath: resolvedPath, parentDir, operation: permissionType },
+ };
+
+ const ruleset = this.config.ruleset ?? [];
+ try {
+ const reply = await this.config.permissionChecker.ask(request, [ruleset]);
+ if (reply === "reject") {
+ return {
+ toolCallId: tc.id,
+ toolName: tc.name,
+ result: `Permission denied: ${permissionType} access to ${resolvedPath} rejected`,
+ isError: true,
+ };
+ }
+ } catch {
+ return {
+ toolCallId: tc.id,
+ toolName: tc.name,
+ result: `Permission denied: ${permissionType} access to ${resolvedPath} not allowed`,
+ isError: true,
+ };
+ }
+ }
+ }
+
+ try {
+ const execPromise = tool.execute(tc.arguments, {
+ onOutput: (data: string, stream: "stdout" | "stderr") => {
+ shellOutputQueue.push({ data, stream });
+ },
+ });
+
+ const rawResult = await execPromise;
+ return {
+ toolCallId: tc.id,
+ toolName: tc.name,
+ result: typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult),
+ isError: false,
+ };
+ } catch (err) {
+ return {
+ toolCallId: tc.id,
+ toolName: tc.name,
+ result: err instanceof Error ? err.message : String(err),
+ isError: true,
+ };
+ }
+ }
+
async *run(userMessage: string): AsyncGenerator<AgentEvent> {
this.status = "running";
yield { type: "status", status: "running" };
@@ -74,70 +204,132 @@ export class Agent {
});
try {
- const result = streamText({
- model: providerFactory(this.config.model),
- system: this.config.systemPrompt,
- messages: toCoreMessages(this.messages),
- tools: registry.getAISDKTools(),
- maxSteps: 10,
- providerOptions: {
- openaiCompatible: { reasoningEffort: "max" },
- },
- });
+ // Track the final assistant message across all steps
+ let finalText = "";
+ const allToolCalls: ToolCall[] = [];
+ const allToolResults: ToolResult[] = [];
- let fullText = "";
- const toolCalls: ToolCall[] = [];
- const toolResults: ToolResult[] = [];
-
- for await (const event of result.fullStream) {
- if (event.type === "text-delta") {
- fullText += event.textDelta;
- yield { type: "text-delta", delta: event.textDelta };
- } else if (event.type === "reasoning") {
- yield { type: "reasoning-delta", delta: event.textDelta };
- } else if (event.type === "tool-call") {
- const toolCall: ToolCall = {
- id: event.toolCallId,
- name: event.toolName,
- arguments: event.args as Record<string, unknown>,
- };
- toolCalls.push(toolCall);
- yield { type: "tool-call", toolCall };
- } else if (event.type === "error") {
- const errorMsg = formatError(event.error, this.config);
- yield { type: "error", error: errorMsg };
- this.status = "error";
- yield { type: "status", status: "error" };
- return;
+ // We build up a local message list for multi-turn within one run() call
+ // that includes tool results fed back to the LLM
+ const stepMessages: ChatMessage[] = [...this.messages];
+
+ for (let step = 0; step < MAX_STEPS; step++) {
+ const result = streamText({
+ model: providerFactory(this.config.model),
+ system: this.config.systemPrompt,
+ messages: toCoreMessages(stepMessages),
+ tools: registry.getAISDKTools(),
+ providerOptions: {
+ openaiCompatible: { reasoningEffort: "max" },
+ },
+ });
+
+ let stepText = "";
+ const stepToolCalls: ToolCall[] = [];
+
+ for await (const event of result.fullStream) {
+ if (event.type === "text-delta") {
+ stepText += event.textDelta;
+ finalText += event.textDelta;
+ yield { type: "text-delta", delta: event.textDelta };
+ } else if (event.type === "reasoning") {
+ yield { type: "reasoning-delta", delta: event.textDelta };
+ } else if (event.type === "tool-call") {
+ const toolCall: ToolCall = {
+ id: event.toolCallId,
+ name: event.toolName,
+ arguments: event.args as Record<string, unknown>,
+ };
+ stepToolCalls.push(toolCall);
+ allToolCalls.push(toolCall);
+ yield { type: "tool-call", toolCall };
+ } else if (event.type === "error") {
+ const errorMsg = formatError(event.error, this.config);
+ yield { type: "error", error: errorMsg };
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
+ }
+ }
+
+ // No tool calls means the agent is done
+ if (stepToolCalls.length === 0) {
+ // Add the final assistant message to step messages (for history)
+ stepMessages.push({
+ role: "assistant",
+ content: stepText,
+ });
+ break;
+ }
+
+ // Add assistant message with tool calls to step messages
+ stepMessages.push({
+ role: "assistant",
+ content: stepText,
+ toolCalls: stepToolCalls,
+ });
+
+ // Execute tool calls manually
+ const stepToolResults: ToolResult[] = [];
+ for (const tc of stepToolCalls) {
+ const shellOutputQueue: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
+
+ const execPromise = this.executeToolWithStreaming(tc, shellOutputQueue);
+
+ // Poll for shell output while the tool is running, using Promise.race
+ // so we can yield shell-output events as they arrive rather than buffering
+ // them all until tool completion.
+ let toolResult: ToolResult | undefined;
+ while (toolResult === undefined) {
+ if (shellOutputQueue.length > 0) {
+ const item = shellOutputQueue.shift()!;
+ yield { type: "shell-output", data: item.data, stream: item.stream };
+ continue;
+ }
+ const raceResult = await Promise.race([
+ execPromise.then((r) => ({ done: true as const, value: r })),
+ new Promise<{ done: false }>((resolve) => setImmediate(() => resolve({ done: false }))),
+ ]);
+ if (raceResult.done) {
+ toolResult = raceResult.value;
+ }
+ }
+
+ // Drain any remaining shell output emitted before we read the result
+ while (shellOutputQueue.length > 0) {
+ const item = shellOutputQueue.shift()!;
+ yield { type: "shell-output", data: item.data, stream: item.stream };
+ }
+
+ stepToolResults.push(toolResult);
+ allToolResults.push(toolResult);
+ yield { type: "tool-result", toolResult };
+ }
+
+ // Add tool results back to step messages so LLM can see them
+ // We append them to the last assistant message's toolResults
+ const lastMsg = stepMessages[stepMessages.length - 1];
+ if (lastMsg) {
+ lastMsg.toolResults = stepToolResults;
}
}
- // Tool results are available from completed steps after streaming.
- // The generic TOOLS type resolves to never[] at compile time, so
- // we cast through unknown to access the runtime shape.
- const steps = await result.steps;
- for (const step of steps) {
- const stepToolResults = step.toolResults as unknown as Array<{
- toolCallId: string;
- result: unknown;
- isError?: boolean;
- }>;
- for (const tr of stepToolResults) {
- const toolResult: ToolResult = {
- toolCallId: tr.toolCallId,
- result: typeof tr.result === "string" ? tr.result : JSON.stringify(tr.result),
- isError: tr.isError ?? false,
+ // If we exhausted MAX_STEPS and there were pending tool calls, surface an error
+ if (stepMessages.length > 0) {
+ const lastMsg = stepMessages[stepMessages.length - 1];
+ if (lastMsg?.toolCalls && lastMsg.toolCalls.length > 0 && !lastMsg.toolResults) {
+ yield {
+ type: "error",
+ error: `Agent reached MAX_STEPS (${MAX_STEPS}) with unresolved tool calls`,
};
- toolResults.push(toolResult);
- yield { type: "tool-result", toolResult };
}
}
const assistantMessage: ChatMessage = {
role: "assistant",
- content: fullText,
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
- toolResults: toolResults.length > 0 ? toolResults : undefined,
+ content: finalText,
+ toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
+ toolResults: allToolResults.length > 0 ? allToolResults : undefined,
};
this.messages.push(assistantMessage);
yield { type: "done", message: assistantMessage };