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/core/src/agent | |
| 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/core/src/agent')
| -rw-r--r-- | packages/core/src/agent/agent.ts | 302 |
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 }; |
