summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/config
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/config
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/config')
-rw-r--r--packages/core/src/config/loader.ts138
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;
+}