summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
committerAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
commita38d5b1279db6f9de5228c173019fc2ac08daec3 (patch)
tree32c3a535d0b74872ef952b4a44d4d5ba2ec9d638 /packages/core/src
parent0ae805b28b5160b8d9fb43635fa172961f6550cc (diff)
downloaddispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.tar.gz
dispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.zip
feat: Phase 2 — shell permissions, tree-sitter analysis, permission UI
Permission engine: - Rule-based engine: wildcard matching, last-match-wins, reject cascade - PermissionService with pending/approved state, PermissionChecker interface - dispatch.yaml config loader with per-permission pattern rules Shell tool: - run_shell tool with child_process spawn, timeout, streaming output - Tree-sitter static analysis (web-tree-sitter + tree-sitter-bash WASM) - BashArity command normalization for 'always allow' patterns - FILE_COMMANDS set: rm, cp, mv, mkdir, ls, find, grep, cat, etc. Agent loop refactored: - Removed maxSteps, manual step loop with tool execution - Permission checks on shell commands (external_directory only) - Permission checks on file tools outside workspace boundary - Symlink bypass fix (realpathSync), .. false positive fix - Shell output streaming via Promise.race + setImmediate polling API layer: - PermissionManager wraps PermissionService, broadcasts via WebSocket - WebSocket handles permission-reply messages from frontend - Config loaded from dispatch.yaml, converted to ruleset Frontend: - Permission prompt modal (native dialog, focus trap, ARIA) - Always-allow confirmation flow with pattern preview - Shell output display (live streaming + final parsed result) - Permission log panel (fixed bottom-right overlay) - Exit code badge (green 0, red non-zero) 134 tests, typecheck clean on all 3 packages
Diffstat (limited to 'packages/core/src')
-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
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;
}