diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 335a9b1..af82f9d 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -179,7 +179,114 @@ Ensure the groups folder exists: mkdir -p groups/main/logs ``` -## 8. Gmail Authentication (Optional) +## 8. Configure External Directory Access (Mount Allowlist) + +Ask the user: +> Do you want the agent to be able to access any directories **outside** the NanoClaw project? +> +> Examples: Git repositories, project folders, documents you want Claude to work on. +> +> **Note:** This is optional. Without configuration, agents can only access their own group folders. + +If **no**, create an empty allowlist to make this explicit: + +```bash +mkdir -p ~/.config/nanoclaw +cat > ~/.config/nanoclaw/mount-allowlist.json << 'EOF' +{ + "allowedRoots": [], + "blockedPatterns": [], + "nonMainReadOnly": true +} +EOF +echo "Mount allowlist created - no external directories allowed" +``` + +Skip to the next step. + +If **yes**, ask follow-up questions: + +### 8a. Collect Directory Paths + +Ask the user: +> Which directories do you want to allow access to? +> +> You can specify: +> - A parent folder like `~/projects` (allows access to anything inside) +> - Specific paths like `~/repos/my-app` +> +> List them one per line, or give me a comma-separated list. + +For each directory they provide, ask: +> Should `[directory]` be **read-write** (agents can modify files) or **read-only**? +> +> Read-write is needed for: code changes, creating files, git commits +> Read-only is safer for: reference docs, config examples, templates + +### 8b. Configure Non-Main Group Access + +Ask the user: +> Should **non-main groups** (other WhatsApp chats you add later) be restricted to **read-only** access even if read-write is allowed for the directory? +> +> Recommended: **Yes** - this prevents other groups from modifying files even if you grant them access to a directory. + +### 8c. Create the Allowlist + +Create the allowlist file based on their answers: + +```bash +mkdir -p ~/.config/nanoclaw +``` + +Then write the JSON file. Example for a user who wants `~/projects` (read-write) and `~/docs` (read-only) with non-main read-only: + +```bash +cat > ~/.config/nanoclaw/mount-allowlist.json << 'EOF' +{ + "allowedRoots": [ + { + "path": "~/projects", + "allowReadWrite": true, + "description": "Development projects" + }, + { + "path": "~/docs", + "allowReadWrite": false, + "description": "Reference documents" + } + ], + "blockedPatterns": [], + "nonMainReadOnly": true +} +EOF +``` + +Verify the file: + +```bash +cat ~/.config/nanoclaw/mount-allowlist.json +``` + +Tell the user: +> Mount allowlist configured. The following directories are now accessible: +> - `~/projects` (read-write) +> - `~/docs` (read-only) +> +> **Security notes:** +> - Sensitive paths (`.ssh`, `.gnupg`, `.aws`, credentials) are always blocked +> - This config file is stored outside the project, so agents cannot modify it +> - Changes require restarting the NanoClaw service +> +> To grant a group access to a directory, add it to their config in `data/registered_groups.json`: +> ```json +> "containerConfig": { +> "additionalMounts": [ +> { "hostPath": "~/projects/my-app", "containerPath": "my-app", "readonly": false } +> ] +> } +> ``` + +## 9. Gmail Authentication (Optional) Ask the user: > Do you want to enable Gmail integration for reading/sending emails? @@ -206,7 +313,7 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp This will open a browser for OAuth consent. After authorization, credentials are cached. -## 9. Configure launchd Service +## 10. Configure launchd Service Get the actual paths: @@ -265,7 +372,7 @@ Verify it's running: launchctl list | grep nanoclaw ``` -## 10. Test +## 11. Test Tell the user (using the assistant name they configured): > Send `@ASSISTANT_NAME hello` in your registered chat. diff --git a/config-examples/mount-allowlist.json b/config-examples/mount-allowlist.json new file mode 100644 index 0000000..e914883 --- /dev/null +++ b/config-examples/mount-allowlist.json @@ -0,0 +1,25 @@ +{ + "allowedRoots": [ + { + "path": "~/projects", + "allowReadWrite": true, + "description": "Development projects" + }, + { + "path": "~/repos", + "allowReadWrite": true, + "description": "Git repositories" + }, + { + "path": "~/Documents/work", + "allowReadWrite": false, + "description": "Work documents (read-only)" + } + ], + "blockedPatterns": [ + "password", + "secret", + "token" + ], + "nonMainReadOnly": true +} diff --git a/src/config.ts b/src/config.ts index a8f8c22..81598ed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,10 @@ export const SCHEDULER_POLL_INTERVAL = 60000; // Absolute paths needed for container mounts const PROJECT_ROOT = process.cwd(); +const HOME_DIR = process.env.HOME || '/Users/user'; + +// Mount security: allowlist stored OUTSIDE project root, never mounted into containers +export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json'); export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); diff --git a/src/container-runner.ts b/src/container-runner.ts index 58d14c8..1017eef 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -15,6 +15,7 @@ import { DATA_DIR } from './config.js'; import { RegisteredGroup } from './types.js'; +import { validateAdditionalMounts } from './mount-security.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', @@ -151,22 +152,14 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount } } + // Additional mounts validated against external allowlist (tamper-proof from containers) if (group.containerConfig?.additionalMounts) { - for (const mount of group.containerConfig.additionalMounts) { - const hostPath = mount.hostPath.startsWith('~') - ? path.join(homeDir, mount.hostPath.slice(1)) - : mount.hostPath; - - if (fs.existsSync(hostPath)) { - mounts.push({ - hostPath, - containerPath: `/workspace/extra/${mount.containerPath}`, - readonly: mount.readonly !== false // Default to readonly for safety - }); - } else { - logger.warn({ hostPath }, 'Additional mount path does not exist, skipping'); - } - } + const validatedMounts = validateAdditionalMounts( + group.containerConfig.additionalMounts, + group.name, + isMain + ); + mounts.push(...validatedMounts); } return mounts; diff --git a/src/mount-security.ts b/src/mount-security.ts new file mode 100644 index 0000000..5d71aa3 --- /dev/null +++ b/src/mount-security.ts @@ -0,0 +1,384 @@ +/** + * Mount Security Module for NanoClaw + * + * Validates additional mounts against an allowlist stored OUTSIDE the project root. + * This prevents container agents from modifying security configuration. + * + * Allowlist location: ~/.config/nanoclaw/mount-allowlist.json + */ + +import fs from 'fs'; +import path from 'path'; +import pino from 'pino'; +import { MOUNT_ALLOWLIST_PATH } from './config.js'; +import { AdditionalMount, MountAllowlist, AllowedRoot } from './types.js'; + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: { target: 'pino-pretty', options: { colorize: true } } +}); + +// Cache the allowlist in memory - only reloads on process restart +let cachedAllowlist: MountAllowlist | null = null; +let allowlistLoadError: string | null = null; + +/** + * Default blocked patterns - paths that should never be mounted + */ +const DEFAULT_BLOCKED_PATTERNS = [ + '.ssh', + '.gnupg', + '.gpg', + '.aws', + '.azure', + '.gcloud', + '.kube', + '.docker', + 'credentials', + '.env', + '.netrc', + '.npmrc', + '.pypirc', + 'id_rsa', + 'id_ed25519', + 'private_key', + '.secret', +]; + +/** + * Load the mount allowlist from the external config location. + * Returns null if the file doesn't exist or is invalid. + * Result is cached in memory for the lifetime of the process. + */ +export function loadMountAllowlist(): MountAllowlist | null { + if (cachedAllowlist !== null) { + return cachedAllowlist; + } + + if (allowlistLoadError !== null) { + // Already tried and failed, don't spam logs + return null; + } + + try { + if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) { + allowlistLoadError = `Mount allowlist not found at ${MOUNT_ALLOWLIST_PATH}`; + logger.warn({ path: MOUNT_ALLOWLIST_PATH }, + 'Mount allowlist not found - additional mounts will be BLOCKED. ' + + 'Create the file to enable additional mounts.'); + return null; + } + + const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8'); + const allowlist = JSON.parse(content) as MountAllowlist; + + // Validate structure + if (!Array.isArray(allowlist.allowedRoots)) { + throw new Error('allowedRoots must be an array'); + } + + if (!Array.isArray(allowlist.blockedPatterns)) { + throw new Error('blockedPatterns must be an array'); + } + + if (typeof allowlist.nonMainReadOnly !== 'boolean') { + throw new Error('nonMainReadOnly must be a boolean'); + } + + // Merge with default blocked patterns + const mergedBlockedPatterns = [ + ...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]) + ]; + allowlist.blockedPatterns = mergedBlockedPatterns; + + cachedAllowlist = allowlist; + logger.info({ + path: MOUNT_ALLOWLIST_PATH, + allowedRoots: allowlist.allowedRoots.length, + blockedPatterns: allowlist.blockedPatterns.length + }, 'Mount allowlist loaded successfully'); + + return cachedAllowlist; + } catch (err) { + allowlistLoadError = err instanceof Error ? err.message : String(err); + logger.error({ + path: MOUNT_ALLOWLIST_PATH, + error: allowlistLoadError + }, 'Failed to load mount allowlist - additional mounts will be BLOCKED'); + return null; + } +} + +/** + * Expand ~ to home directory and resolve to absolute path + */ +function expandPath(p: string): string { + const homeDir = process.env.HOME || '/Users/user'; + if (p.startsWith('~/')) { + return path.join(homeDir, p.slice(2)); + } + if (p === '~') { + return homeDir; + } + return path.resolve(p); +} + +/** + * Get the real path, resolving symlinks. + * Returns null if the path doesn't exist. + */ +function getRealPath(p: string): string | null { + try { + return fs.realpathSync(p); + } catch { + return null; + } +} + +/** + * Check if a path matches any blocked pattern + */ +function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null { + const pathParts = realPath.split(path.sep); + + for (const pattern of blockedPatterns) { + // Check if any path component matches the pattern + for (const part of pathParts) { + if (part === pattern || part.includes(pattern)) { + return pattern; + } + } + + // Also check if the full path contains the pattern + if (realPath.includes(pattern)) { + return pattern; + } + } + + return null; +} + +/** + * Check if a real path is under an allowed root + */ +function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null { + for (const root of allowedRoots) { + const expandedRoot = expandPath(root.path); + const realRoot = getRealPath(expandedRoot); + + if (realRoot === null) { + // Allowed root doesn't exist, skip it + continue; + } + + // Check if realPath is under realRoot + const relative = path.relative(realRoot, realPath); + if (!relative.startsWith('..') && !path.isAbsolute(relative)) { + return root; + } + } + + return null; +} + +/** + * Validate the container path to prevent escaping /workspace/extra/ + */ +function isValidContainerPath(containerPath: string): boolean { + // Must not contain .. to prevent path traversal + if (containerPath.includes('..')) { + return false; + } + + // Must not be absolute (it will be prefixed with /workspace/extra/) + if (containerPath.startsWith('/')) { + return false; + } + + // Must not be empty + if (!containerPath || containerPath.trim() === '') { + return false; + } + + return true; +} + +export interface MountValidationResult { + allowed: boolean; + reason: string; + realHostPath?: string; + effectiveReadonly?: boolean; +} + +/** + * Validate a single additional mount against the allowlist. + * Returns validation result with reason. + */ +export function validateMount( + mount: AdditionalMount, + isMain: boolean +): MountValidationResult { + const allowlist = loadMountAllowlist(); + + // If no allowlist, block all additional mounts + if (allowlist === null) { + return { + allowed: false, + reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}` + }; + } + + // Validate container path first (cheap check) + if (!isValidContainerPath(mount.containerPath)) { + return { + allowed: false, + reason: `Invalid container path: "${mount.containerPath}" - must be relative, non-empty, and not contain ".."` + }; + } + + // Expand and resolve the host path + const expandedPath = expandPath(mount.hostPath); + const realPath = getRealPath(expandedPath); + + if (realPath === null) { + return { + allowed: false, + reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")` + }; + } + + // Check against blocked patterns + const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns); + if (blockedMatch !== null) { + return { + allowed: false, + reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"` + }; + } + + // Check if under an allowed root + const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots); + if (allowedRoot === null) { + return { + allowed: false, + reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${ + allowlist.allowedRoots.map(r => expandPath(r.path)).join(', ') + }` + }; + } + + // Determine effective readonly status + const requestedReadWrite = mount.readonly === false; + let effectiveReadonly = true; // Default to readonly + + if (requestedReadWrite) { + if (!isMain && allowlist.nonMainReadOnly) { + // Non-main groups forced to read-only + effectiveReadonly = true; + logger.info({ + mount: mount.hostPath + }, 'Mount forced to read-only for non-main group'); + } else if (!allowedRoot.allowReadWrite) { + // Root doesn't allow read-write + effectiveReadonly = true; + logger.info({ + mount: mount.hostPath, + root: allowedRoot.path + }, 'Mount forced to read-only - root does not allow read-write'); + } else { + // Read-write allowed + effectiveReadonly = false; + } + } + + return { + allowed: true, + reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`, + realHostPath: realPath, + effectiveReadonly + }; +} + +/** + * Validate all additional mounts for a group. + * Returns array of validated mounts (only those that passed validation). + * Logs warnings for rejected mounts. + */ +export function validateAdditionalMounts( + mounts: AdditionalMount[], + groupName: string, + isMain: boolean +): Array<{ + hostPath: string; + containerPath: string; + readonly: boolean; +}> { + const validatedMounts: Array<{ + hostPath: string; + containerPath: string; + readonly: boolean; + }> = []; + + for (const mount of mounts) { + const result = validateMount(mount, isMain); + + if (result.allowed) { + validatedMounts.push({ + hostPath: result.realHostPath!, + containerPath: `/workspace/extra/${mount.containerPath}`, + readonly: result.effectiveReadonly! + }); + + logger.debug({ + group: groupName, + hostPath: result.realHostPath, + containerPath: mount.containerPath, + readonly: result.effectiveReadonly, + reason: result.reason + }, 'Mount validated successfully'); + } else { + logger.warn({ + group: groupName, + requestedPath: mount.hostPath, + containerPath: mount.containerPath, + reason: result.reason + }, 'Additional mount REJECTED'); + } + } + + return validatedMounts; +} + +/** + * Generate a template allowlist file for users to customize + */ +export function generateAllowlistTemplate(): string { + const template: MountAllowlist = { + allowedRoots: [ + { + path: '~/projects', + allowReadWrite: true, + description: 'Development projects' + }, + { + path: '~/repos', + allowReadWrite: true, + description: 'Git repositories' + }, + { + path: '~/Documents/work', + allowReadWrite: false, + description: 'Work documents (read-only)' + } + ], + blockedPatterns: [ + // Additional patterns beyond defaults + 'password', + 'secret', + 'token' + ], + nonMainReadOnly: true + }; + + return JSON.stringify(template, null, 2); +} diff --git a/src/types.ts b/src/types.ts index 869bd8f..de42c9c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,29 @@ export interface AdditionalMount { readonly?: boolean; // Default: true for safety } +/** + * Mount Allowlist - Security configuration for additional mounts + * This file should be stored at ~/.config/nanoclaw/mount-allowlist.json + * and is NOT mounted into any container, making it tamper-proof from agents. + */ +export interface MountAllowlist { + // Directories that can be mounted into containers + allowedRoots: AllowedRoot[]; + // Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg") + blockedPatterns: string[]; + // If true, non-main groups can only mount read-only regardless of config + nonMainReadOnly: boolean; +} + +export interface AllowedRoot { + // Absolute path or ~ for home (e.g., "~/projects", "/var/repos") + path: string; + // Whether read-write mounts are allowed under this root + allowReadWrite: boolean; + // Optional description for documentation + description?: string; +} + export interface ContainerConfig { additionalMounts?: AdditionalMount[]; timeout?: number; // Default: 300000 (5 minutes)