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/shell-analyze.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/shell-analyze.ts')
| -rw-r--r-- | packages/core/src/tools/shell-analyze.ts | 147 |
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; +} |
