/** * 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 os from 'os'; import path from 'path'; import pino from 'pino'; import { MOUNT_ALLOWLIST_PATH } from './config.js'; import { AdditionalMount, AllowedRoot, MountAllowlist } 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 || os.homedir(); 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; resolvedContainerPath?: 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}`, }; } // Derive containerPath from hostPath basename if not specified const containerPath = mount.containerPath || path.basename(mount.hostPath); // Validate container path (cheap check) if (!isValidContainerPath(containerPath)) { return { allowed: false, reason: `Invalid container path: "${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, resolvedContainerPath: containerPath, 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/${result.resolvedContainerPath}`, readonly: result.effectiveReadonly!, }); logger.debug( { group: groupName, hostPath: result.realHostPath, containerPath: result.resolvedContainerPath, 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); }