Add mount security allowlist for external directory access (#14)

* Add secure mount allowlist validation

Addresses arbitrary host mount vulnerability by validating additional
mounts against an external allowlist stored at ~/.config/nanoclaw/.
This location is never mounted into containers, making it tamper-proof.

Security measures:
- Allowlist cached in memory (edits require process restart)
- Real path resolution (blocks symlink and .. traversal attacks)
- Blocked patterns for sensitive paths (.ssh, .gnupg, .aws, etc.)
- Non-main groups forced to read-only when nonMainReadOnly is true
- Container path validation prevents /workspace/extra escape

https://claude.ai/code/session_01BPqdNy4EAHHJcdtZ27TXkh

* Add mount allowlist setup to /setup skill

Interactive walkthrough that asks users:
- Whether they want agents to access external directories
- Which directories to allow (with paths)
- Read-write vs read-only for each
- Whether non-main groups should be restricted to read-only

Creates ~/.config/nanoclaw/mount-allowlist.json based on answers.

https://claude.ai/code/session_01BPqdNy4EAHHJcdtZ27TXkh

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-01 22:55:08 +02:00
committed by GitHub
parent 5760b75fa9
commit 48822ff67d
6 changed files with 554 additions and 18 deletions

View File

@@ -179,7 +179,114 @@ Ensure the groups folder exists:
mkdir -p groups/main/logs 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: Ask the user:
> Do you want to enable Gmail integration for reading/sending emails? > 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. 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: Get the actual paths:
@@ -265,7 +372,7 @@ Verify it's running:
launchctl list | grep nanoclaw launchctl list | grep nanoclaw
``` ```
## 10. Test ## 11. Test
Tell the user (using the assistant name they configured): Tell the user (using the assistant name they configured):
> Send `@ASSISTANT_NAME hello` in your registered chat. > Send `@ASSISTANT_NAME hello` in your registered chat.

View File

@@ -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
}

View File

@@ -6,6 +6,10 @@ export const SCHEDULER_POLL_INTERVAL = 60000;
// Absolute paths needed for container mounts // Absolute paths needed for container mounts
const PROJECT_ROOT = process.cwd(); 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 STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');

View File

@@ -15,6 +15,7 @@ import {
DATA_DIR DATA_DIR
} from './config.js'; } from './config.js';
import { RegisteredGroup } from './types.js'; import { RegisteredGroup } from './types.js';
import { validateAdditionalMounts } from './mount-security.js';
const logger = pino({ const logger = pino({
level: process.env.LOG_LEVEL || 'info', 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) { if (group.containerConfig?.additionalMounts) {
for (const mount of group.containerConfig.additionalMounts) { const validatedMounts = validateAdditionalMounts(
const hostPath = mount.hostPath.startsWith('~') group.containerConfig.additionalMounts,
? path.join(homeDir, mount.hostPath.slice(1)) group.name,
: mount.hostPath; isMain
);
if (fs.existsSync(hostPath)) { mounts.push(...validatedMounts);
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');
}
}
} }
return mounts; return mounts;

384
src/mount-security.ts Normal file
View File

@@ -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);
}

View File

@@ -4,6 +4,29 @@ export interface AdditionalMount {
readonly?: boolean; // Default: true for safety 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 { export interface ContainerConfig {
additionalMounts?: AdditionalMount[]; additionalMounts?: AdditionalMount[];
timeout?: number; // Default: 300000 (5 minutes) timeout?: number; // Default: 300000 (5 minutes)