summaryrefslogtreecommitdiffhomepage
path: root/packages/core
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
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')
-rw-r--r--packages/core/package.json4
-rw-r--r--packages/core/src/agent/agent.ts302
-rw-r--r--packages/core/src/config/loader.ts138
-rw-r--r--packages/core/src/index.ts6
-rw-r--r--packages/core/src/permission/evaluate.ts15
-rw-r--r--packages/core/src/permission/index.ts26
-rw-r--r--packages/core/src/permission/service.ts76
-rw-r--r--packages/core/src/permission/wildcard.ts10
-rw-r--r--packages/core/src/tools/bash-arity.ts48
-rw-r--r--packages/core/src/tools/run-shell.ts74
-rw-r--r--packages/core/src/tools/shell-analyze.ts147
-rw-r--r--packages/core/src/types/index.ts12
-rw-r--r--packages/core/tests/agent/agent.test.ts130
-rw-r--r--packages/core/tests/config/loader.test.ts166
-rw-r--r--packages/core/tests/permission/evaluate.test.ts68
-rw-r--r--packages/core/tests/permission/service.test.ts95
-rw-r--r--packages/core/tests/permission/wildcard.test.ts50
-rw-r--r--packages/core/tests/tools/bash-arity.test.ts36
-rw-r--r--packages/core/tests/tools/run-shell.test.ts78
19 files changed, 1356 insertions, 125 deletions
diff --git a/packages/core/package.json b/packages/core/package.json
index 3741041..66c8726 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -11,8 +11,10 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "ai": "^4.0.0",
"@ai-sdk/openai-compatible": "^0.2.0",
+ "ai": "^4.0.0",
+ "tree-sitter-bash": "^0.25.1",
+ "web-tree-sitter": "^0.26.8",
"zod": "^3.23.0"
},
"devDependencies": {
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;
}
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index 92df90e..5be210a 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -40,15 +40,9 @@ async function* makeFullStream(
}
}
-interface MockStreamOptions {
- events: Array<{ type: string; [key: string]: unknown }>;
- steps?: Array<{ toolResults: Array<{ toolCallId: string; result: unknown }> }>;
-}
-
-function makeMockStreamResult(opts: MockStreamOptions) {
+function makeMockStreamResult(events: Array<{ type: string; [key: string]: unknown }>) {
return {
- fullStream: makeFullStream(opts.events),
- steps: Promise.resolve(opts.steps ?? []),
+ fullStream: makeFullStream(events),
} as ReturnType<typeof import("ai").streamText>;
}
@@ -66,18 +60,16 @@ describe("Agent", () => {
it("yields running then idle status events around a simple message", async () => {
const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
- { type: "text-delta", textDelta: "Hello!" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ],
- }),
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Hello!" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ]),
);
const agent = new Agent(makeConfig());
@@ -97,19 +89,17 @@ describe("Agent", () => {
it("yields text-delta events", async () => {
const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
- { type: "text-delta", textDelta: "Hello" },
- { type: "text-delta", textDelta: " world" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ],
- }),
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Hello" },
+ { type: "text-delta", textDelta: " world" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ]),
);
const agent = new Agent(makeConfig());
@@ -127,18 +117,16 @@ describe("Agent", () => {
it("adds user message and assistant message to history", async () => {
const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
- { type: "text-delta", textDelta: "Response" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ],
- }),
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Response" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ]),
);
const agent = new Agent(makeConfig());
@@ -160,18 +148,16 @@ describe("Agent", () => {
it("yields done event with final message", async () => {
const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
- { type: "text-delta", textDelta: "Done!" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ],
- }),
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Done!" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ]),
);
const agent = new Agent(makeConfig());
@@ -190,31 +176,39 @@ describe("Agent", () => {
it("yields tool-call and tool-result events", async () => {
const { streamText } = await import("ai");
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
+
+ // First call: LLM emits a tool-call
+ // Second call (after tool execution): LLM emits text response with no tool calls
+ vi.mocked(streamText)
+ .mockReturnValueOnce(
+ makeMockStreamResult([
{
type: "tool-call",
toolCallId: "tc1",
toolName: "read_file",
args: { path: "hello.txt" },
},
- { type: "text-delta", textDelta: "Here is the file." },
{
type: "finish",
- finishReason: "stop",
+ finishReason: "tool-calls",
usage: {},
providerMetadata: undefined,
response: {},
},
- ],
- steps: [
+ ]),
+ )
+ .mockReturnValueOnce(
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Here is the file." },
{
- toolResults: [{ toolCallId: "tc1", result: "file contents" }],
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
},
- ],
- }),
- );
+ ]),
+ );
const toolDef = {
name: "read_file",
diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts
new file mode 100644
index 0000000..9a358fe
--- /dev/null
+++ b/packages/core/tests/config/loader.test.ts
@@ -0,0 +1,166 @@
+import { homedir } from "node:os";
+import { join, sep } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { writeFileSync, mkdirSync, rmSync } from "node:fs";
+import { configToRuleset, loadConfig } from "../../src/config/loader.js";
+
+const TMP = join("/tmp/opencode", "dispatch-config-test");
+
+beforeEach(() => {
+ mkdirSync(TMP, { recursive: true });
+});
+
+afterEach(() => {
+ rmSync(TMP, { recursive: true, force: true });
+});
+
+function writeYaml(content: string): void {
+ writeFileSync(join(TMP, "dispatch.yaml"), content, "utf-8");
+}
+
+describe("loadConfig", () => {
+ it("returns empty permissions when dispatch.yaml is missing", () => {
+ const config = loadConfig(TMP);
+ expect(config.permissions).toEqual({});
+ });
+
+ it("parses simple string permissions", () => {
+ writeYaml(`permissions:\n read: allow\n edit: deny\n`);
+ const config = loadConfig(TMP);
+ expect(config.permissions["read"]).toBe("allow");
+ expect(config.permissions["edit"]).toBe("deny");
+ });
+
+ it("parses nested pattern permissions", () => {
+ writeYaml(`permissions:\n bash:\n "npm test": allow\n "*": ask\n`);
+ const config = loadConfig(TMP);
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash["npm test"]).toBe("allow");
+ expect(bash["*"]).toBe("ask");
+ });
+
+ it("ignores comment lines", () => {
+ writeYaml(`# this is a comment\npermissions:\n # another comment\n read: allow\n`);
+ const config = loadConfig(TMP);
+ expect(config.permissions["read"]).toBe("allow");
+ });
+
+ it("handles ~ expansion in nested keys", () => {
+ const home = homedir();
+ writeYaml(`permissions:\n read:\n "~/projects/*": allow\n`);
+ const config = loadConfig(TMP);
+ const read = config.permissions["read"] as Record<string, string>;
+ expect(read[`${home}/projects/*`]).toBe("allow");
+ });
+
+ it("handles $HOME expansion in nested keys", () => {
+ const home = homedir();
+ writeYaml(`permissions:\n read:\n "$HOME/docs/*": allow\n`);
+ const config = loadConfig(TMP);
+ const read = config.permissions["read"] as Record<string, string>;
+ expect(read[`${home}/docs/*`]).toBe("allow");
+ });
+
+ it("parses quoted keys", () => {
+ writeYaml(`permissions:\n bash:\n "git commit *": allow\n "rm *": deny\n`);
+ const config = loadConfig(TMP);
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash["git commit *"]).toBe("allow");
+ expect(bash["rm *"]).toBe("deny");
+ });
+
+ it("handles multiple permission groups", () => {
+ writeYaml(
+ `permissions:\n read: allow\n edit:\n "*": ask\n "src/**": allow\n bash:\n "npm test": allow\n "*": ask\n`,
+ );
+ const config = loadConfig(TMP);
+ expect(config.permissions["read"]).toBe("allow");
+ const edit = config.permissions["edit"] as Record<string, string>;
+ expect(edit["*"]).toBe("ask");
+ expect(edit["src/**"]).toBe("allow");
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash["npm test"]).toBe("allow");
+ expect(bash["*"]).toBe("ask");
+ });
+
+ it("preserves # inside quoted string values", () => {
+ writeYaml(`permissions:\n bash:\n '"file#1"': allow\n`);
+ const config = loadConfig(TMP);
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash['"file#1"']).toBe("allow");
+ });
+
+ it("strips inline comments on nested map keys (empty value after comment)", () => {
+ writeYaml(`permissions:\n bash: # scripts\n "*": allow\n`);
+ const config = loadConfig(TMP);
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash["*"]).toBe("allow");
+ });
+
+ it("expands ~ with platform path separator", () => {
+ const home = homedir();
+ // Simulate a path using the OS separator
+ const pattern = `~${sep}projects${sep}*`;
+ writeYaml(`permissions:\n read:\n "${pattern}": allow\n`);
+ const config = loadConfig(TMP);
+ const read = config.permissions["read"] as Record<string, string>;
+ expect(read[`${home}${sep}projects${sep}*`]).toBe("allow");
+ });
+});
+
+describe("configToRuleset — new validations", () => {
+ it("falls back to ask and warns for invalid action in string value", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const rules = configToRuleset({ permissions: { read: "allw" } });
+ expect(rules[0]?.action).toBe("ask");
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("allw"));
+ warn.mockRestore();
+ });
+
+ it("falls back to ask and warns for invalid action in nested pattern", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const rules = configToRuleset({ permissions: { bash: { "*": "INVALID" } } });
+ expect(rules[0]?.action).toBe("ask");
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("INVALID"));
+ warn.mockRestore();
+ });
+
+ it("expands ~ with backslash separator in patterns", () => {
+ const home = homedir();
+ // Force backslash path even on Linux to test the regex
+ const rules = configToRuleset({ permissions: { read: { "~\\foo\\*": "allow" } } });
+ expect(rules[0]?.pattern).toBe(`${home}\\foo\\*`);
+ });
+});
+
+describe("configToRuleset", () => {
+ it("produces a rule with pattern * for string value", () => {
+ const rules = configToRuleset({ permissions: { read: "allow" } });
+ expect(rules).toEqual([{ permission: "read", pattern: "*", action: "allow" }]);
+ });
+
+ it("produces rules for each pattern in an object value", () => {
+ const rules = configToRuleset({
+ permissions: { bash: { "npm test": "allow", "*": "ask" } },
+ });
+ expect(rules).toContainEqual({ permission: "bash", pattern: "npm test", action: "allow" });
+ expect(rules).toContainEqual({ permission: "bash", pattern: "*", action: "ask" });
+ });
+
+ it("expands ~ in patterns", () => {
+ const home = homedir();
+ const rules = configToRuleset({ permissions: { read: { "~/foo/*": "allow" } } });
+ expect(rules[0]?.pattern).toBe(`${home}/foo/*`);
+ });
+
+ it("expands $HOME in patterns", () => {
+ const home = homedir();
+ const rules = configToRuleset({ permissions: { read: { "$HOME/bar/*": "deny" } } });
+ expect(rules[0]?.pattern).toBe(`${home}/bar/*`);
+ });
+
+ it("handles empty permissions", () => {
+ const rules = configToRuleset({ permissions: {} });
+ expect(rules).toEqual([]);
+ });
+});
diff --git a/packages/core/tests/permission/evaluate.test.ts b/packages/core/tests/permission/evaluate.test.ts
new file mode 100644
index 0000000..c8f4541
--- /dev/null
+++ b/packages/core/tests/permission/evaluate.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import { evaluate } from "../../src/permission/evaluate.js";
+import type { Ruleset } from "../../src/permission/index.js";
+
+describe("evaluate", () => {
+ it("returns default ask when no rules match", () => {
+ const result = evaluate("bash", "ls -la");
+ expect(result.action).toBe("ask");
+ expect(result.permission).toBe("bash");
+ expect(result.pattern).toBe("ls -la");
+ });
+
+ it("returns allow when matching rule is allow", () => {
+ const rules: Ruleset = [{ permission: "bash", pattern: "ls *", action: "allow" }];
+ const result = evaluate("bash", "ls -la", rules);
+ expect(result.action).toBe("allow");
+ });
+
+ it("returns deny when matching rule is deny", () => {
+ const rules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
+ const result = evaluate("bash", "rm -rf /", rules);
+ expect(result.action).toBe("deny");
+ });
+
+ it("last-match-wins: later deny overrides earlier allow", () => {
+ const rules: Ruleset = [
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "rm *", action: "deny" },
+ ];
+ const result = evaluate("bash", "rm -rf /", rules);
+ expect(result.action).toBe("deny");
+ });
+
+ it("last-match-wins: later allow overrides earlier deny", () => {
+ const rules: Ruleset = [
+ { permission: "bash", pattern: "rm *", action: "deny" },
+ { permission: "bash", pattern: "*", action: "allow" },
+ ];
+ const result = evaluate("bash", "rm -rf /", rules);
+ expect(result.action).toBe("allow");
+ });
+
+ it("matches permission wildcard", () => {
+ const rules: Ruleset = [{ permission: "*", pattern: "*", action: "allow" }];
+ const result = evaluate("read", "anything", rules);
+ expect(result.action).toBe("allow");
+ });
+
+ it("multiple rulesets are concatenated, last match wins across rulesets", () => {
+ const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }];
+ const overrideRules: Ruleset = [{ permission: "bash", pattern: "git *", action: "allow" }];
+ const result = evaluate("bash", "git status", baseRules, overrideRules);
+ expect(result.action).toBe("allow");
+ });
+
+ it("second ruleset can deny what first ruleset allows", () => {
+ const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
+ const overrideRules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
+ const result = evaluate("bash", "rm -rf /", baseRules, overrideRules);
+ expect(result.action).toBe("deny");
+ });
+
+ it("non-matching permission returns default ask", () => {
+ const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
+ const result = evaluate("read", "/some/path", rules);
+ expect(result.action).toBe("ask");
+ });
+});
diff --git a/packages/core/tests/permission/service.test.ts b/packages/core/tests/permission/service.test.ts
new file mode 100644
index 0000000..d1b39d9
--- /dev/null
+++ b/packages/core/tests/permission/service.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+import type { PermissionRequest, Ruleset } from "../../src/permission/index.js";
+import { PermissionService } from "../../src/permission/service.js";
+
+function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
+ return {
+ permission: "bash",
+ patterns: ["git *"],
+ always: ["git status"],
+ description: "Run git status",
+ metadata: {},
+ ...overrides,
+ };
+}
+
+describe("PermissionService", () => {
+ it("resolves immediately with 'once' when rule is allow", async () => {
+ const svc = new PermissionService();
+ const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
+ const reply = await svc.ask(makeRequest(), [rules]);
+ expect(reply).toBe("once");
+ });
+
+ it("rejects immediately when rule is deny", async () => {
+ const svc = new PermissionService();
+ const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
+ await expect(svc.ask(makeRequest(), [rules])).rejects.toThrow("Permission denied");
+ });
+
+ it("creates pending request when rule is ask", () => {
+ const svc = new PermissionService();
+ svc.ask(makeRequest(), []);
+ expect(svc.getPending()).toHaveLength(1);
+ });
+
+ it("reply 'once' resolves the specific pending request", async () => {
+ const svc = new PermissionService();
+ const promise = svc.ask(makeRequest(), []);
+ const pending = svc.getPending();
+ expect(pending).toHaveLength(1);
+ svc.reply(pending[0].id, "once");
+ const result = await promise;
+ expect(result).toBe("once");
+ expect(svc.getPending()).toHaveLength(0);
+ });
+
+ it("reply 'always' adds approved rules and resolves", async () => {
+ const svc = new PermissionService();
+ const promise = svc.ask(makeRequest({ patterns: ["git *"] }), []);
+ const pending = svc.getPending();
+ svc.reply(pending[0].id, "always");
+ const result = await promise;
+ expect(result).toBe("always");
+
+ // Now the same permission should be immediately allowed
+ const reply2 = await svc.ask(makeRequest({ always: ["git commit"] }), []);
+ expect(reply2).toBe("once");
+ });
+
+ it("reply 'reject' rejects all pending requests (cascade)", async () => {
+ const svc = new PermissionService();
+ const p1 = svc.ask(makeRequest(), []);
+ const p2 = svc.ask(makeRequest({ permission: "read" }), []);
+
+ const pending = svc.getPending();
+ expect(pending).toHaveLength(2);
+
+ // Reject using the first id — should cascade to all
+ svc.reply(pending[0].id, "reject");
+
+ await expect(p1).rejects.toThrow("Permission rejected");
+ await expect(p2).rejects.toThrow("Permission rejected");
+ expect(svc.getPending()).toHaveLength(0);
+ });
+
+ it("approved rules override config rulesets", async () => {
+ const svc = new PermissionService();
+ svc.approve([{ permission: "bash", pattern: "git *", action: "allow" }]);
+
+ // Config says deny, but approved says allow — approved wins (last)
+ const configRules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
+ const reply = await svc.ask(makeRequest({ always: ["git status"] }), [configRules]);
+ expect(reply).toBe("once");
+ });
+
+ it("getPending returns all pending requests with id and request", () => {
+ const svc = new PermissionService();
+ const req = makeRequest();
+ svc.ask(req, []);
+ const pending = svc.getPending();
+ expect(pending).toHaveLength(1);
+ expect(pending[0].id).toBeDefined();
+ expect(pending[0].request.permission).toBe("bash");
+ });
+});
diff --git a/packages/core/tests/permission/wildcard.test.ts b/packages/core/tests/permission/wildcard.test.ts
new file mode 100644
index 0000000..8fa30a7
--- /dev/null
+++ b/packages/core/tests/permission/wildcard.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { Wildcard } from "../../src/permission/wildcard.js";
+
+describe("Wildcard.match", () => {
+ it("matches exact string", () => {
+ expect(Wildcard.match("bash", "bash")).toBe(true);
+ expect(Wildcard.match("bash", "read")).toBe(false);
+ });
+
+ it("matches * wildcard (any characters)", () => {
+ expect(Wildcard.match("*", "bash")).toBe(true);
+ expect(Wildcard.match("*", "anything")).toBe(true);
+ expect(Wildcard.match("ba*", "bash")).toBe(true);
+ expect(Wildcard.match("ba*", "ba")).toBe(true);
+ expect(Wildcard.match("ba*", "read")).toBe(false);
+ });
+
+ it("matches ? wildcard (single character)", () => {
+ expect(Wildcard.match("ba?h", "bash")).toBe(true);
+ expect(Wildcard.match("ba?h", "bath")).toBe(true);
+ expect(Wildcard.match("ba?h", "baXXh")).toBe(false);
+ expect(Wildcard.match("?", "a")).toBe(true);
+ expect(Wildcard.match("?", "ab")).toBe(false);
+ });
+
+ it("matches nested * patterns with path-like strings", () => {
+ expect(Wildcard.match("/home/*", "/home/user")).toBe(true);
+ expect(Wildcard.match("/home/*/file.txt", "/home/user/file.txt")).toBe(true);
+ expect(Wildcard.match("/home/*/file.txt", "/home/user/subdir/file.txt")).toBe(true);
+ expect(Wildcard.match("/home/*/file.txt", "/tmp/user/file.txt")).toBe(false);
+ });
+
+ it("escapes regex special characters in pattern", () => {
+ expect(Wildcard.match("git add .", "git add .")).toBe(true);
+ expect(Wildcard.match("git add .", "git add X")).toBe(false);
+ expect(Wildcard.match("foo(bar)", "foo(bar)")).toBe(true);
+ expect(Wildcard.match("foo(bar)", "fooXbar")).toBe(false);
+ });
+
+ it("is case-sensitive", () => {
+ expect(Wildcard.match("Bash", "bash")).toBe(false);
+ expect(Wildcard.match("BASH", "BASH")).toBe(true);
+ });
+
+ it("handles empty pattern and value", () => {
+ expect(Wildcard.match("", "")).toBe(true);
+ expect(Wildcard.match("", "x")).toBe(false);
+ expect(Wildcard.match("*", "")).toBe(true);
+ });
+});
diff --git a/packages/core/tests/tools/bash-arity.test.ts b/packages/core/tests/tools/bash-arity.test.ts
new file mode 100644
index 0000000..a01a6a5
--- /dev/null
+++ b/packages/core/tests/tools/bash-arity.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { prefix } from "../../src/tools/bash-arity.js";
+
+describe("BashArity.prefix", () => {
+ it("returns arity-2 prefix for known command 'git'", () => {
+ expect(prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]);
+ });
+
+ it("returns arity-3 prefix for npm", () => {
+ expect(prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]);
+ });
+
+ it("returns arity-2 prefix for bun", () => {
+ expect(prefix(["bun", "install", "--frozen-lockfile"])).toEqual(["bun", "install"]);
+ });
+
+ it("returns just the command for unknown command", () => {
+ expect(prefix(["unknowncmd", "arg1", "arg2"])).toEqual(["unknowncmd"]);
+ });
+
+ it("returns empty array for empty tokens", () => {
+ expect(prefix([])).toEqual([]);
+ });
+
+ it("handles single token for unknown command", () => {
+ expect(prefix(["ls"])).toEqual(["ls"]);
+ });
+
+ it("handles git with fewer tokens than arity", () => {
+ expect(prefix(["git"])).toEqual(["git"]);
+ });
+
+ it("handles case-insensitive matching", () => {
+ expect(prefix(["GIT", "checkout", "main"])).toEqual(["GIT", "checkout"]);
+ });
+});
diff --git a/packages/core/tests/tools/run-shell.test.ts b/packages/core/tests/tools/run-shell.test.ts
new file mode 100644
index 0000000..cb66d1c
--- /dev/null
+++ b/packages/core/tests/tools/run-shell.test.ts
@@ -0,0 +1,78 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createRunShellTool } from "../../src/tools/run-shell.js";
+
+describe("run_shell tool", () => {
+ let workDir: string;
+
+ beforeEach(async () => {
+ workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
+ });
+
+ afterEach(async () => {
+ await rm(workDir, { recursive: true, force: true });
+ });
+
+ it("executes a simple echo command", async () => {
+ const tool = createRunShellTool(workDir);
+ const raw = await tool.execute({ command: "echo hello" });
+ const result = JSON.parse(raw);
+ expect(result.stdout.trim()).toBe("hello");
+ expect(result.exitCode).toBe(0);
+ });
+
+ it("returns non-zero exit code on failure", async () => {
+ const tool = createRunShellTool(workDir);
+ const raw = await tool.execute({ command: "exit 42" });
+ const result = JSON.parse(raw);
+ expect(result.exitCode).toBe(42);
+ });
+
+ it("captures stderr", async () => {
+ const tool = createRunShellTool(workDir);
+ const raw = await tool.execute({ command: "echo errormsg >&2" });
+ const result = JSON.parse(raw);
+ expect(result.stderr.trim()).toBe("errormsg");
+ });
+
+ it("handles timeout", async () => {
+ const tool = createRunShellTool(workDir);
+ const raw = await tool.execute({ command: "sleep 10", timeout: 100 });
+ const result = JSON.parse(raw);
+ // Either times out (non-zero exit) or returns an error
+ expect(result.exitCode !== 0 || result.error !== undefined).toBe(true);
+ }, 5000);
+
+ it("executes in the working directory", async () => {
+ const tool = createRunShellTool(workDir);
+ const raw = await tool.execute({ command: "pwd" });
+ const result = JSON.parse(raw);
+ // On macOS /tmp is symlinked; use includes check
+ expect(result.stdout.trim()).toContain(workDir.replace(/^\/private/, ""));
+ });
+
+ it("calls onOutput callback with stdout chunks", async () => {
+ const tool = createRunShellTool(workDir);
+ const onOutput = vi.fn();
+ const raw = await tool.execute({ command: "echo streaming" }, { onOutput });
+ const result = JSON.parse(raw);
+ expect(result.stdout.trim()).toBe("streaming");
+ expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("streaming"), "stdout");
+ });
+
+ it("calls onOutput callback with stderr chunks", async () => {
+ const tool = createRunShellTool(workDir);
+ const onOutput = vi.fn();
+ await tool.execute({ command: "echo errdata >&2" }, { onOutput });
+ expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("errdata"), "stderr");
+ });
+
+ it("works without context (backward compatible)", async () => {
+ const tool = createRunShellTool(workDir);
+ const raw = await tool.execute({ command: "echo nocontext" });
+ const result = JSON.parse(raw);
+ expect(result.stdout.trim()).toBe("nocontext");
+ });
+});