The HOME_DIR fallback '/Users/user' is macOS-specific and incorrect. Use os.homedir() from Node's os module which works cross-platform and returns the actual home directory from /etc/passwd on Linux. Co-authored-by: Cursor <cursoragent@cursor.com>
420 lines
10 KiB
TypeScript
420 lines
10 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|