summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools
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/tools
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/tools')
-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
3 files changed, 269 insertions, 0 deletions
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;
+}