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
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
Reference in New Issue
Block a user