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