From 24fe070ddc181cacd821ac5fd4746167573727d0 Mon Sep 17 00:00:00 2001 From: Gary Banana Date: Thu, 5 Mar 2026 13:42:51 -0800 Subject: [PATCH] feat: add additionalDirs setting for cross-directory access When security is not "unrestricted", Claude sessions are scoped to the project directory. This adds an `additionalDirs` setting so the daemon can automatically pass `--add-dir` flags, and the scope prompt dynamically includes additional allowed directories. - Settings schema, parsing, and defaults in config.ts - Dynamic DIR_SCOPE_PROMPT and --add-dir injection in runner.ts - Startup logging and hot-reload detection in start.ts - Interactive setup question in start.md - dirs add/remove/clear/list sub-commands in config.md Co-Authored-By: Claude Opus 4.6 --- commands/config.md | 45 +++++++++++++++++++++++++++++++++++++++++-- commands/start.md | 8 +++++++- src/commands/start.ts | 8 ++++++++ src/config.ts | 7 +++++++ src/runner.ts | 30 +++++++++++++++++++++-------- 5 files changed, 87 insertions(+), 11 deletions(-) diff --git a/commands/config.md b/commands/config.md index c1e59fad..7c23f1e5 100644 --- a/commands/config.md +++ b/commands/config.md @@ -35,6 +35,10 @@ Parse `$ARGUMENTS` to identify what the user wants. If no arguments are given, s - Allowed tools: (list or "default") - Disallowed tools: (list or "none") + **Directories** + - Project: (current working directory) + - Additional: (list each path, or "none") + **Web UI** - Enabled: yes/no - Address: host:port @@ -187,6 +191,40 @@ Configure web UI bind address or port. 3. Set `web.port` (number) or `web.host` (string) accordingly. 4. Write and confirm. +### `dirs` / `dirs list` + +List current additional directories. + +1. Read `.claude/claudeclaw/settings.json`. +2. Display the `additionalDirs` array, or "none" if empty. + +### `dirs add ` + +Add directories to the additional directories list. + +1. Parse comma-separated absolute paths from `$ARGUMENTS`. +2. Validate each path is absolute and exists on disk. +3. Read `.claude/claudeclaw/settings.json`. +4. Append validated paths to `additionalDirs` (deduplicated). +5. Write and confirm. + +### `dirs remove ` + +Remove a directory from the additional directories list. + +1. Parse the path from `$ARGUMENTS`. +2. Read `.claude/claudeclaw/settings.json`. +3. Filter the path out of `additionalDirs`. +4. Write and confirm. + +### `dirs clear` + +Clear all additional directories. + +1. Read `.claude/claudeclaw/settings.json`. +2. Set `additionalDirs` to `[]`. +3. Write and confirm. + ### `reset` Reset all settings to defaults. @@ -222,7 +260,8 @@ Reset all settings to defaults. "enabled": false, "host": "127.0.0.1", "port": 4632 - } + }, + "additionalDirs": [] } ``` 3. Confirm the reset. Note: this does not delete cron jobs — use `/heartbeat:jobs delete` for that. @@ -264,7 +303,8 @@ Location: `.claude/claudeclaw/settings.json` "enabled": true, "host": "127.0.0.1", "port": 4632 - } + }, + "additionalDirs": ["/Users/morgan/.ssh"] } ``` @@ -291,5 +331,6 @@ Location: `.claude/claudeclaw/settings.json` | `web.enabled` | boolean | Whether the web UI is served | | `web.host` | string | Bind address (default `127.0.0.1`) | | `web.port` | number | Port number (default `4632`) | +| `additionalDirs` | string[] | Absolute paths to directories Claude can access beyond the project root | The daemon hot-reloads this file every 30 seconds. No restart needed after changes. diff --git a/commands/start.md b/commands/start.md index 20a632b5..4c41dfbc 100644 --- a/commands/start.md +++ b/commands/start.md @@ -93,6 +93,10 @@ Start the heartbeat daemon for this project. Follow these steps exactly: - "Allow any specific tools on top of the security level? (e.g. Bash(git:*) to allow only git commands)" (header: "Allow tools", options: "None — use level defaults (Recommended)", "Bash(git:*) — git only", "Bash(git:*) Bash(npm:*) — git + npm") - If they pick an option with tools or type custom ones, set `security.allowedTools` to the list. + - **If security is NOT "unrestricted"**: Use AskUserQuestion to ask: + - "Want to grant access to directories outside this project? (e.g. ~/.ssh, shared config)" (header: "Additional Directories", options: "None", "Yes, I'll list them") + - If yes, ask in normal free-form text for absolute directory paths (one per line or comma-separated). Validate each path exists and is absolute. Set `additionalDirs` to the validated list. + Update `.claude/claudeclaw/settings.json` with their answers. 6. **Launch/start action**: @@ -171,7 +175,8 @@ Defaults: `WEB_HOST=127.0.0.1`, `WEB_PORT=4632` unless changed via settings or ` "level": "moderate", "allowedTools": [], "disallowedTools": [] - } + }, + "additionalDirs": ["/Users/morgan/.ssh"] } ``` - `model` — Claude model to use (`opus`, `sonnet`, `haiku`, `glm`, or full model ID). Empty string uses default. @@ -187,6 +192,7 @@ Defaults: `WEB_HOST=127.0.0.1`, `WEB_PORT=4632` unless changed via settings or ` - `security.level` — one of: `locked`, `strict`, `moderate`, `unrestricted` - `security.allowedTools` — extra tools to allow on top of the level (e.g. `["Bash(git:*)"]`) - `security.disallowedTools` — tools to block on top of the level +- `additionalDirs` — absolute paths to directories Claude can access beyond the project root (only relevant when security is not `unrestricted`) ### Security Levels All levels run without permission prompts (headless). Security is enforced via tool restrictions and project-directory scoping. diff --git a/src/commands/start.ts b/src/commands/start.ts index de49ba88..31e9729d 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -323,6 +323,8 @@ export async function start(args: string[] = []) { console.log(` + allowed: ${settings.security.allowedTools.join(", ")}`); if (settings.security.disallowedTools.length > 0) console.log(` - blocked: ${settings.security.disallowedTools.join(", ")}`); + if (settings.additionalDirs.length > 0) + console.log(` Additional dirs: ${settings.additionalDirs.join(", ")}`); console.log(` Heartbeat: ${settings.heartbeat.enabled ? `every ${settings.heartbeat.interval}m` : "disabled"}`); console.log(` Web UI: ${webEnabled ? `http://${settings.web.host}:${webPort}` : "disabled"}`); if (debugFlag) console.log(" Debug: enabled"); @@ -576,6 +578,12 @@ export async function start(args: string[] = []) { console.log(`[${ts()}] Security level changed → ${newSettings.security.level}`); } + const dirsChanged = + newSettings.additionalDirs.join(",") !== currentSettings.additionalDirs.join(","); + if (dirsChanged) { + console.log(`[${ts()}] Additional directories changed → [${newSettings.additionalDirs.join(", ")}]`); + } + if (hbChanged) { console.log(`[${ts()}] Config change detected — heartbeat: ${newSettings.heartbeat.enabled ? `every ${newSettings.heartbeat.interval}m` : "disabled"}`); currentSettings = newSettings; diff --git a/src/config.ts b/src/config.ts index ad25d1a4..8d39ff3a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,7 @@ const DEFAULT_SETTINGS: Settings = { security: { level: "moderate", allowedTools: [], disallowedTools: [] }, web: { enabled: false, host: "127.0.0.1", port: 4632 }, stt: { baseUrl: "", model: "" }, + additionalDirs: [], }; export interface HeartbeatExcludeWindow { @@ -70,6 +71,7 @@ export interface Settings { security: SecurityConfig; web: WebConfig; stt: SttConfig; + additionalDirs: string[]; } export interface ModelConfig { @@ -157,6 +159,11 @@ function parseSettings(raw: Record): Settings { baseUrl: typeof raw.stt?.baseUrl === "string" ? raw.stt.baseUrl.trim() : "", model: typeof raw.stt?.model === "string" ? raw.stt.model.trim() : "", }, + additionalDirs: Array.isArray(raw.additionalDirs) + ? raw.additionalDirs + .filter((d: unknown) => typeof d === "string" && d.trim().length > 0 && isAbsolute(d.trim())) + .map((d: string) => d.trim()) + : [], }; } diff --git a/src/runner.ts b/src/runner.ts index 4dcf3d3e..4f31715a 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -93,12 +93,22 @@ async function runClaudeOnce( const PROJECT_DIR = process.cwd(); -const DIR_SCOPE_PROMPT = [ - `CRITICAL SECURITY CONSTRAINT: You are scoped to the project directory: ${PROJECT_DIR}`, - "You MUST NOT read, write, edit, or delete any file outside this directory.", - "You MUST NOT run bash commands that modify anything outside this directory (no cd /, no /etc, no ~/, no ../.. escapes).", - "If a request requires accessing files outside the project, refuse and explain why.", -].join("\n"); +function buildDirScopePrompt(additionalDirs: string[]): string { + const parts = [ + `CRITICAL SECURITY CONSTRAINT: You are scoped to the project directory: ${PROJECT_DIR}`, + ]; + if (additionalDirs.length > 0) { + parts.push( + `You also have access to these additional directories:\n${additionalDirs.map((d) => ` - ${d}`).join("\n")}` + ); + } + parts.push( + "You MUST NOT read, write, edit, or delete any file outside these allowed directories.", + "You MUST NOT run bash commands that modify anything outside these directories.", + "If a request requires accessing files outside the allowed directories, refuse and explain why.", + ); + return parts.join("\n"); +} export async function ensureProjectClaudeMd(): Promise { // Preflight-only initialization: never rewrite an existing project CLAUDE.md. @@ -210,7 +220,7 @@ async function execClaude(name: string, prompt: string): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const logFile = join(LOGS_DIR, `${name}-${timestamp}.log`); - const { security, model, api, fallback } = getSettings(); + const { security, model, api, fallback, additionalDirs } = getSettings(); const primaryConfig: ModelConfig = { model, api }; const fallbackConfig: ModelConfig = { model: fallback?.model ?? "", @@ -227,6 +237,10 @@ async function execClaude(name: string, prompt: string): Promise { const outputFormat = isNew ? "json" : "text"; const args = ["claude", "-p", prompt, "--output-format", outputFormat, ...securityArgs]; + for (const dir of additionalDirs) { + args.push("--add-dir", dir); + } + if (!isNew) { args.push("--resume", existing.sessionId); } @@ -250,7 +264,7 @@ async function execClaude(name: string, prompt: string): Promise { } } - if (security.level !== "unrestricted") appendParts.push(DIR_SCOPE_PROMPT); + if (security.level !== "unrestricted") appendParts.push(buildDirScopePrompt(additionalDirs)); if (appendParts.length > 0) { args.push("--append-system-prompt", appendParts.join("\n\n")); }