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/config | |
| 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/config')
| -rw-r--r-- | packages/core/src/config/loader.ts | 138 |
1 files changed, 138 insertions, 0 deletions
diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts new file mode 100644 index 0000000..0e58ad2 --- /dev/null +++ b/packages/core/src/config/loader.ts @@ -0,0 +1,138 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { PermissionRule, Ruleset } from "../permission/index.js"; + +// Strip inline comments that appear outside of quoted strings. +// Handles both single- and double-quoted values. +function stripInlineComment(line: string): string { + // Walk character by character; track whether we're inside quotes. + let inQuote: string | null = null; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuote) { + if (ch === inQuote) inQuote = null; + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === "#") { + return line.slice(0, i).trimEnd(); + } + } + return line; +} + +const VALID_ACTIONS = new Set(["allow", "deny", "ask"]); + +function validateAction(raw: string): "allow" | "deny" | "ask" { + if (VALID_ACTIONS.has(raw)) return raw as "allow" | "deny" | "ask"; + console.warn(`dispatch: unrecognized action "${raw}", defaulting to "ask"`); + return "ask"; +} + +export interface DispatchConfig { + permissions: Record<string, string | Record<string, string>>; +} + +// Load dispatch.yaml from the given directory +export function loadConfig(dir: string): DispatchConfig { + const yamlPath = join(dir, "dispatch.yaml"); + try { + const content = readFileSync(yamlPath, "utf-8"); + return parseYaml(content); + } catch { + return { permissions: {} }; + } +} + +function expandHome(value: string): string { + const home = homedir(); + return value.replace(/^\$HOME(?=[\/\\]|$)/, home).replace(/^~(?=[\/\\]|$)/, home); +} + +function stripQuotes(s: string): string { + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + return s; +} + +// Parse simple YAML for the permissions structure +function parseYaml(content: string): DispatchConfig { + const permissions: Record<string, string | Record<string, string>> = {}; + const lines = content.split("\n"); + + let inPermissions = false; + let currentKey: string | null = null; + + for (const raw of lines) { + // Skip comments and blank lines + const trimmed = raw.trimEnd(); + const stripped = trimmed.trimStart(); + if (stripped === "" || stripped.startsWith("#")) continue; + + const indent = trimmed.length - stripped.length; + + if (indent === 0) { + // Top-level key + inPermissions = stripped.startsWith("permissions:"); + currentKey = null; + continue; + } + + if (!inPermissions) continue; + + if (indent === 2) { + // permission key line: " key: value" or " key:" + const colonIdx = stripped.indexOf(":"); + if (colonIdx === -1) continue; + const key = stripQuotes(stripped.slice(0, colonIdx).trim()); + const valueRaw = stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim(); + if (valueRaw === "" || valueRaw === null) { + // nested map follows + currentKey = key; + permissions[currentKey] = {}; + } else { + // inline value + const value = stripQuotes(valueRaw); + permissions[key] = value; + currentKey = null; + } + continue; + } + + if (indent >= 4 && currentKey !== null) { + // sub-key line: " "pattern": action" + const colonIdx = stripped.indexOf(":"); + if (colonIdx === -1) continue; + const pattern = expandHome(stripQuotes(stripped.slice(0, colonIdx).trim())); + const actionRaw = stripQuotes(stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim()); + const action = validateAction(actionRaw); + (permissions[currentKey] as Record<string, string>)[pattern] = action; + } + } + + return { permissions }; +} + +// Convert the config's permission block to a Ruleset +export function configToRuleset(config: DispatchConfig): Ruleset { + const home = homedir(); + const rules: PermissionRule[] = []; + + for (const [permission, value] of Object.entries(config.permissions)) { + if (typeof value === "string") { + const action = validateAction(value); + rules.push({ permission, pattern: "*", action }); + } else { + for (const [rawPattern, rawAction] of Object.entries(value)) { + const pattern = rawPattern + .replace(/^\$HOME(?=[\/\\]|$)/, home) + .replace(/^~(?=[\/\\]|$)/, home); + const action = validateAction(rawAction); + rules.push({ permission, pattern, action }); + } + } + } + + return rules; +} |
