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:
@@ -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.
|
||||||
|
|||||||
25
config-examples/mount-allowlist.json
Normal file
25
config-examples/mount-allowlist.json
Normal 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
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
384
src/mount-security.ts
Normal 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);
|
||||||
|
}
|
||||||
23
src/types.ts
23
src/types.ts
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user