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/run-shell.ts | |
| 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/run-shell.ts')
| -rw-r--r-- | packages/core/src/tools/run-shell.ts | 74 |
1 files changed, 74 insertions, 0 deletions
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"]]; +} |
