summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools/run-shell.ts
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/run-shell.ts
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/run-shell.ts')
-rw-r--r--packages/core/src/tools/run-shell.ts74
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"]];
+}