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 | |
| 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')
| -rw-r--r-- | packages/core/src/agent/agent.ts | 302 | ||||
| -rw-r--r-- | packages/core/src/config/loader.ts | 138 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 6 | ||||
| -rw-r--r-- | packages/core/src/permission/evaluate.ts | 15 | ||||
| -rw-r--r-- | packages/core/src/permission/index.ts | 26 | ||||
| -rw-r--r-- | packages/core/src/permission/service.ts | 76 | ||||
| -rw-r--r-- | packages/core/src/permission/wildcard.ts | 10 | ||||
| -rw-r--r-- | packages/core/src/tools/bash-arity.ts | 48 | ||||
| -rw-r--r-- | packages/core/src/tools/run-shell.ts | 74 | ||||
| -rw-r--r-- | packages/core/src/tools/shell-analyze.ts | 147 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 12 |
11 files changed, 798 insertions, 56 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 }; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts new file mode 100644 index 0000000..0e58ad2 --- /dev/null +++ b/packages/core/src/config/loader.ts @@ -0,0 +1,138 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { PermissionRule, Ruleset } from "../permission/index.js"; + +// Strip inline comments that appear outside of quoted strings. +// Handles both single- and double-quoted values. +function stripInlineComment(line: string): string { + // Walk character by character; track whether we're inside quotes. + let inQuote: string | null = null; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuote) { + if (ch === inQuote) inQuote = null; + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === "#") { + return line.slice(0, i).trimEnd(); + } + } + return line; +} + +const VALID_ACTIONS = new Set(["allow", "deny", "ask"]); + +function validateAction(raw: string): "allow" | "deny" | "ask" { + if (VALID_ACTIONS.has(raw)) return raw as "allow" | "deny" | "ask"; + console.warn(`dispatch: unrecognized action "${raw}", defaulting to "ask"`); + return "ask"; +} + +export interface DispatchConfig { + permissions: Record<string, string | Record<string, string>>; +} + +// Load dispatch.yaml from the given directory +export function loadConfig(dir: string): DispatchConfig { + const yamlPath = join(dir, "dispatch.yaml"); + try { + const content = readFileSync(yamlPath, "utf-8"); + return parseYaml(content); + } catch { + return { permissions: {} }; + } +} + +function expandHome(value: string): string { + const home = homedir(); + return value.replace(/^\$HOME(?=[\/\\]|$)/, home).replace(/^~(?=[\/\\]|$)/, home); +} + +function stripQuotes(s: string): string { + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + return s; +} + +// Parse simple YAML for the permissions structure +function parseYaml(content: string): DispatchConfig { + const permissions: Record<string, string | Record<string, string>> = {}; + const lines = content.split("\n"); + + let inPermissions = false; + let currentKey: string | null = null; + + for (const raw of lines) { + // Skip comments and blank lines + const trimmed = raw.trimEnd(); + const stripped = trimmed.trimStart(); + if (stripped === "" || stripped.startsWith("#")) continue; + + const indent = trimmed.length - stripped.length; + + if (indent === 0) { + // Top-level key + inPermissions = stripped.startsWith("permissions:"); + currentKey = null; + continue; + } + + if (!inPermissions) continue; + + if (indent === 2) { + // permission key line: " key: value" or " key:" + const colonIdx = stripped.indexOf(":"); + if (colonIdx === -1) continue; + const key = stripQuotes(stripped.slice(0, colonIdx).trim()); + const valueRaw = stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim(); + if (valueRaw === "" || valueRaw === null) { + // nested map follows + currentKey = key; + permissions[currentKey] = {}; + } else { + // inline value + const value = stripQuotes(valueRaw); + permissions[key] = value; + currentKey = null; + } + continue; + } + + if (indent >= 4 && currentKey !== null) { + // sub-key line: " "pattern": action" + const colonIdx = stripped.indexOf(":"); + if (colonIdx === -1) continue; + const pattern = expandHome(stripQuotes(stripped.slice(0, colonIdx).trim())); + const actionRaw = stripQuotes(stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim()); + const action = validateAction(actionRaw); + (permissions[currentKey] as Record<string, string>)[pattern] = action; + } + } + + return { permissions }; +} + +// Convert the config's permission block to a Ruleset +export function configToRuleset(config: DispatchConfig): Ruleset { + const home = homedir(); + const rules: PermissionRule[] = []; + + for (const [permission, value] of Object.entries(config.permissions)) { + if (typeof value === "string") { + const action = validateAction(value); + rules.push({ permission, pattern: "*", action }); + } else { + for (const [rawPattern, rawAction] of Object.entries(value)) { + const pattern = rawPattern + .replace(/^\$HOME(?=[\/\\]|$)/, home) + .replace(/^~(?=[\/\\]|$)/, home); + const action = validateAction(rawAction); + rules.push({ permission, pattern, action }); + } + } + } + + return rules; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b3907fa..233cb10 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,13 @@ export { Agent } from "./agent/agent.js"; export { createProvider } from "./llm/provider.js"; export { createListFilesTool } from "./tools/list-files.js"; +export { createRunShellTool } from "./tools/run-shell.js"; +export { analyzeCommand } from "./tools/shell-analyze.js"; +export { prefix as bashArityPrefix } from "./tools/bash-arity.js"; export { createReadFileTool } from "./tools/read-file.js"; export { createToolRegistry } from "./tools/registry.js"; export { createWriteFileTool } from "./tools/write-file.js"; export * from "./types/index.js"; +export * from "./permission/index.js"; +export { loadConfig, configToRuleset } from "./config/loader.js"; +export type { DispatchConfig } from "./config/loader.js"; diff --git a/packages/core/src/permission/evaluate.ts b/packages/core/src/permission/evaluate.ts new file mode 100644 index 0000000..7a0d235 --- /dev/null +++ b/packages/core/src/permission/evaluate.ts @@ -0,0 +1,15 @@ +import type { PermissionRule, Ruleset } from "./index.js"; +import { Wildcard } from "./wildcard.js"; + +export function evaluate( + permission: string, + pattern: string, + ...rulesets: Ruleset[] +): PermissionRule { + const flat = ([] as PermissionRule[]).concat(...rulesets); + const match = flat.findLast( + (rule) => Wildcard.match(rule.permission, permission) && Wildcard.match(rule.pattern, pattern), + ); + if (match) return match; + return { action: "ask", permission, pattern }; +} diff --git a/packages/core/src/permission/index.ts b/packages/core/src/permission/index.ts new file mode 100644 index 0000000..bf6efaf --- /dev/null +++ b/packages/core/src/permission/index.ts @@ -0,0 +1,26 @@ +export interface PermissionRule { + permission: string; + pattern: string; + action: "allow" | "deny" | "ask"; +} + +export type Ruleset = PermissionRule[]; + +export type PermissionReply = "once" | "always" | "reject"; + +export interface PermissionRequest { + permission: string; + patterns: string[]; + always: string[]; + description: string; + metadata: Record<string, unknown>; +} + +export interface PermissionChecker { + ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply>; + getPending(): Array<{ id: string; request: PermissionRequest }>; +} + +export { evaluate } from "./evaluate.js"; +export { PermissionService } from "./service.js"; +export { Wildcard } from "./wildcard.js"; diff --git a/packages/core/src/permission/service.ts b/packages/core/src/permission/service.ts new file mode 100644 index 0000000..1c8c6f0 --- /dev/null +++ b/packages/core/src/permission/service.ts @@ -0,0 +1,76 @@ +import { evaluate } from "./evaluate.js"; +import type { PermissionReply, PermissionRequest, PermissionRule, Ruleset } from "./index.js"; + +export class PermissionService { + private pending: Map< + string, + { + request: PermissionRequest; + resolve: (reply: PermissionReply) => void; + reject: (err: Error) => void; + } + > = new Map(); + private approved: Ruleset = []; + private idCounter = 0; + + approve(rules: PermissionRule[]): void { + this.approved.push(...rules); + } + + async ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply> { + // Evaluate against all rulesets + approved (approved overrides) for ALL patterns + const patterns = request.always.length > 0 ? request.always : ["*"]; + const results = patterns.map((pattern) => + evaluate(request.permission, pattern, ...rulesets, this.approved), + ); + + // Any deny → deny + const denied = results.find((r) => r.action === "deny"); + if (denied) { + throw new Error(`Permission denied: ${request.permission} ${denied.pattern}`); + } + + // All allow → allow + if (results.every((r) => r.action === "allow")) { + return "once"; + } + + // action === "ask" — create a pending request + const id = String(++this.idCounter); + return new Promise<PermissionReply>((resolve, reject) => { + this.pending.set(id, { request, resolve, reject }); + }); + } + + reply(id: string, reply: PermissionReply): void { + if (reply === "reject") { + // Reject cascade — reject all pending + for (const [pendingId, entry] of this.pending) { + entry.reject(new Error(`Permission rejected: ${entry.request.permission}`)); + this.pending.delete(pendingId); + } + return; + } + + this.resolvePending(id, reply); + } + + getPending(): Array<{ id: string; request: PermissionRequest }> { + return Array.from(this.pending.entries()).map(([id, { request }]) => ({ id, request })); + } + + private resolvePending(id: string, reply: PermissionReply): void { + const entry = this.pending.get(id); + if (!entry) return; + + if (reply === "always") { + // Add rules for each pattern in request.patterns + for (const pattern of entry.request.patterns) { + this.approved.push({ permission: entry.request.permission, pattern, action: "allow" }); + } + } + + entry.resolve(reply); + this.pending.delete(id); + } +} diff --git a/packages/core/src/permission/wildcard.ts b/packages/core/src/permission/wildcard.ts new file mode 100644 index 0000000..7874680 --- /dev/null +++ b/packages/core/src/permission/wildcard.ts @@ -0,0 +1,10 @@ +export const Wildcard = { + match(pattern: string, value: string): boolean { + // Escape regex special chars except * and ? + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + // Convert wildcards + const regexStr = escaped.replace(/\*/g, ".*").replace(/\?/g, "."); + const regex = new RegExp(`^${regexStr}$`, "s"); + return regex.test(value); + }, +}; diff --git a/packages/core/src/tools/bash-arity.ts b/packages/core/src/tools/bash-arity.ts new file mode 100644 index 0000000..5dde955 --- /dev/null +++ b/packages/core/src/tools/bash-arity.ts @@ -0,0 +1,48 @@ +// Hardcoded dictionary of well-known commands and their arity +// (number of tokens that form the "human-understandable" prefix) +const ARITY: Record<string, number> = { + "git": 2, // "git checkout", "git commit", etc. + "npm": 3, // "npm run dev", "npm install -g" + "docker": 2, // "docker compose", "docker build" + "kubectl": 2, // "kubectl get", "kubectl apply" + "bun": 2, // "bun install", "bun test" + "cargo": 2, // "cargo build", "cargo test" + "go": 2, // "go build", "go test" + "python": 2, // "python -m", "python script.py" + "python3": 2, + "pip": 2, + "pip3": 2, + "brew": 2, + "apt": 2, + "apt-get": 2, + "dnf": 2, + "yum": 2, + "pacman": 2, + "systemctl": 2, + "journalctl": 2, + "ssh": 2, + "scp": 2, + "rsync": 2, + "curl": 2, // "curl -X", "curl https://" + "wget": 2, + "tar": 2, // "tar -xzf", "tar -czf" + "zip": 2, + "unzip": 2, + "chown": 2, + "chmod": 2, + "mount": 2, + "umount": 2, + // Default: all other commands are arity 1 +}; + +// Get the normalized prefix for a list of tokens +export function prefix(tokens: string[]): string[] { + if (tokens.length === 0) return []; + const first = tokens[0]; + if (!first) return []; + const arity = ARITY[first.toLowerCase()]; + if (arity === undefined) { + return [first]; + } + return tokens.slice(0, arity); +} diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts new file mode 100644 index 0000000..d549316 --- /dev/null +++ b/packages/core/src/tools/run-shell.ts @@ -0,0 +1,74 @@ +import { spawn } from "node:child_process"; +import { z } from "zod"; +import type { ToolDefinition, ToolExecuteContext } from "../types/index.js"; + +const DEFAULT_TIMEOUT = 2 * 60 * 1000; // 2 minutes + +export function createRunShellTool(workingDirectory: string): ToolDefinition { + return { + name: "run_shell", + description: + "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks.", + parameters: z.object({ + command: z.string().describe("The shell command to execute"), + timeout: z + .number() + .optional() + .describe("Timeout in milliseconds (default 2 minutes)"), + }), + execute: async (args: Record<string, unknown>, context?: ToolExecuteContext): Promise<string> => { + const command = args.command as string; + const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT; + + const [shell, shellArgs] = getShell(); + // NOTE (MVP limitation): `spawn` timeout sends SIGTERM only to the shell + // process itself, not to any child processes it may have spawned. If the + // command forks sub-processes they will continue running after timeout. + // A full fix would require spawning with `detached: true` and killing the + // entire process group (process.kill(-child.pid, "SIGTERM")). + const child = spawn(shell, [...shellArgs, command], { + cwd: workingDirectory, + env: process.env, + timeout, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + const result = await new Promise<{ + stdout: string; + stderr: string; + exitCode: number; + error?: string; + }>((resolve) => { + child.stdout?.on("data", (data: Buffer) => { + const chunk = data.toString(); + stdout += chunk; + context?.onOutput?.(chunk, "stdout"); + }); + child.stderr?.on("data", (data: Buffer) => { + const chunk = data.toString(); + stderr += chunk; + context?.onOutput?.(chunk, "stderr"); + }); + + child.on("close", (exitCode) => { + resolve({ stdout, stderr, exitCode: exitCode ?? 1 }); + }); + + child.on("error", (err) => { + resolve({ stdout, stderr, exitCode: 1, error: err.message }); + }); + }); + + return JSON.stringify(result); + }, + }; +} + +function getShell(): [string, string[]] { + return process.platform === "win32" + ? ["powershell", ["-Command"]] + : ["bash", ["-c"]]; +} diff --git a/packages/core/src/tools/shell-analyze.ts b/packages/core/src/tools/shell-analyze.ts new file mode 100644 index 0000000..23bbfc9 --- /dev/null +++ b/packages/core/src/tools/shell-analyze.ts @@ -0,0 +1,147 @@ +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import * as BashArity from "./bash-arity.js"; + +// Commands that touch files — triggers external_directory check. +// Includes any command that takes file paths as arguments and could leak +// information about external directories. +// +// Known gaps (not currently checked): +// - Redirections: `echo x > /etc/file` — the redirect target is not inspected +// - `cd` state changes: we don't track cwd mutations across pipeline stages +// - Interpreter escapes: `python -c "open('/etc/passwd')"`, `node -e "..."` bypass this entirely +const FILE_COMMANDS = new Set([ + "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", + "cat", "ls", "find", "grep", + "head", "tail", "less", "more", "wc", "diff", "file", "stat", "du", "df", +]); + +// Lazy-initialized parser +let parserPromise: Promise<Parsers> | null = null; + +interface Parsers { + bash: import("web-tree-sitter").Parser; +} + +async function getParser(): Promise<Parsers> { + if (parserPromise) return parserPromise; + parserPromise = initParser(); + return parserPromise; +} + +async function initParser(): Promise<Parsers> { + const { Parser, Language } = await import("web-tree-sitter"); + + // Load the main WASM binary from node_modules + const require = createRequire(import.meta.url); + const webTreeSitterPath = require.resolve("web-tree-sitter/web-tree-sitter.wasm"); + const wasmBinary = await readFile(webTreeSitterPath); + await Parser.init({ wasmBinary }); + + const bashWasmPath = require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"); + const bashLang = await Language.load(bashWasmPath); + + const bash = new Parser(); + bash.setLanguage(bashLang); + + return { bash }; +} + +// Analyze a shell command and return permission patterns +export async function analyzeCommand( + command: string, + workingDirectory: string, +): Promise<{ dirs: string[]; patterns: string[]; always: string[] }> { + try { + const parsers = await getParser(); + const tree = parsers.bash.parse(command); + if (!tree) return { dirs: [], patterns: [command], always: [] }; + + return collect(tree.rootNode, command, workingDirectory); + } catch { + // Parse failure — return basic patterns + return { dirs: [], patterns: [command], always: [] }; + } +} + +function collect( + node: import("web-tree-sitter").Node, + _source: string, + wd: string, +): { dirs: string[]; patterns: string[]; always: string[] } { + const dirs: string[] = []; + const patterns: string[] = []; + const always: string[] = []; + + // Walk all command nodes + const commands = node.descendantsOfType("command"); + + for (const cmd of commands) { + const parts = extractParts(cmd); + const name = parts[0]?.toLowerCase(); + if (!name) continue; + + // Get the command source text + const cmdText = cmd.text; + patterns.push(cmdText); + + // Normalize to always pattern + always.push(`${BashArity.prefix(parts).join(" ")} *`); + + // Check if this is a file-touching command + if (FILE_COMMANDS.has(name)) { + // Extract path arguments (skip flags starting with -) + const pathArgs = parts.slice(1).filter((a) => !a.startsWith("-")); + for (const arg of pathArgs) { + const resolved = resolvePath(arg, wd); + if (resolved && !isInsideWorkspace(resolved, wd)) { + const parent = dirname(resolved); + dirs.push(parent); + } + } + } + } + + return { + dirs: [...new Set(dirs)], + patterns: [...new Set(patterns)], + always: [...new Set(always)], + }; +} + +// Helper to extract command parts from a command AST node +function extractParts(cmd: import("web-tree-sitter").Node): string[] { + const parts: string[] = []; + for (const child of cmd.children) { + if ( + child.type === "command_name" || + child.type === "word" || + child.type === "string" || + child.type === "raw_string" + ) { + const text = child.text.replace(/^['"]|['"]$/g, ""); + if (text) parts.push(text); + } + } + return parts; +} + +function resolvePath(arg: string, wd: string): string | null { + try { + if (isAbsolute(arg)) return arg; + return resolve(wd, arg); + } catch { + return null; + } +} + +function isInsideWorkspace(filePath: string, wd: string): boolean { + const normalizedWd = resolve(wd); + const rel = relative(normalizedWd, filePath); + // rel === "" means filePath IS the workspace root — that is inside. + // If relative path starts with "../" or is ".." exactly, or is an absolute path + // (on Windows when drives differ), the file is outside the workspace. + const isOutside = rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel); + return !isOutside; +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 28e79ae..6262d2b 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,4 +1,5 @@ import type { ZodType } from "zod"; +import type { PermissionChecker, Ruleset } from "../permission/index.js"; // Message types for the agent conversation export type MessageRole = "user" | "assistant" | "tool"; @@ -18,6 +19,7 @@ export interface ToolCall { export interface ToolResult { toolCallId: string; + toolName: string; result: string; isError: boolean; } @@ -32,15 +34,21 @@ export type AgentEvent = | { type: "reasoning-delta"; delta: string } | { type: "tool-call"; toolCall: ToolCall } | { type: "tool-result"; toolResult: ToolResult } + | { type: "shell-output"; data: string; stream: "stdout" | "stderr" } | { type: "error"; error: string } | { type: "done"; message: ChatMessage }; +// Context passed to tool execute functions +export interface ToolExecuteContext { + onOutput?: (data: string, stream: "stdout" | "stderr") => void; +} + // Tool definition interface export interface ToolDefinition { name: string; description: string; parameters: ZodType; - execute: (args: Record<string, unknown>) => Promise<string>; + execute: (args: Record<string, unknown>, context?: ToolExecuteContext) => Promise<string>; } // Agent configuration @@ -51,4 +59,6 @@ export interface AgentConfig { systemPrompt: string; tools: ToolDefinition[]; workingDirectory: string; + permissionChecker?: PermissionChecker; + ruleset?: Ruleset; } |
