summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools/shell-analyze.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/shell-analyze.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/shell-analyze.ts')
-rw-r--r--packages/core/src/tools/shell-analyze.ts147
1 files changed, 147 insertions, 0 deletions
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;
+}