Skip to main content

Overview

NanoClaw Pro runs every agent invocation inside an isolated Linux container (lightweight VM). This provides OS-level security that’s impossible with application-level permission checks.
Unlike other AI assistants that rely on allowlists and permission checks, NanoClaw Pro uses true filesystem and process isolation. Agents literally cannot access files or processes outside their container.

Why Containers?

From the README:
Secure by isolation. Agents run in Linux containers and they can only see what’s explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
Traditional AI assistants run in the same process as your application, relying on:
  • Allowlists (“agent can only access these files”)
  • Permission checks (“is this action allowed?”)
  • Trust (“agent won’t do anything malicious”)
The problem: These are all bypassable via prompt injection or bugs. NanoClaw Pro uses OS-level isolation:
  • Agents run in separate Linux VMs
  • Filesystem is isolated (only mounted directories are visible)
  • Process tree is isolated (can’t see or kill host processes)
  • Network can be isolated (optional)

Security Model

Filesystem Isolation

Agents only see explicitly mounted directories. The host filesystem is invisible.

Process Isolation

Agents can’t see or interact with host processes. Container processes are sandboxed.

Non-Root User

Container runs as unprivileged node user (uid 1000), not root.

Safe Bash Access

bash commands run inside the container, so rm -rf / only affects the container.

Container Runtimes

NanoClaw Pro supports two container runtimes:

Docker (Default)

Platforms: macOS, Linux, Windows (WSL2) Installation:
brew install docker  # macOS
# or download Docker Desktop
Why Docker:
  • Cross-platform support
  • Mature ecosystem
  • Easy to install
  • Well-documented

Apple Container (macOS)

Platforms: macOS only Installation:
brew install apple/apple/apple-container
Why Apple Container:
  • Native macOS runtime (no Docker daemon)
  • Lighter weight
  • Faster startup
Switching to Apple Container:
/convert-to-apple-container

What Gets Mounted

Main Channel Mounts

From src/container-runner.ts:60:
function buildVolumeMounts(group: RegisteredGroup, isMain: boolean) {
  const mounts = [];
  
  if (isMain) {
    // Main gets the project root read-only
    mounts.push({
      hostPath: projectRoot,
      containerPath: '/workspace/project',
      readonly: true,
    });
    
    // Shadow .env to prevent secret access
    mounts.push({
      hostPath: '/dev/null',
      containerPath: '/workspace/project/.env',
      readonly: true,
    });
    
    // Main also gets its group folder
    mounts.push({
      hostPath: groupDir,  // groups/whatsapp_main/
      containerPath: '/workspace/group',
      readonly: false,
    });
  }
}
Main channel sees:
  • Project root at /workspace/project (read-only)
  • Its group folder at /workspace/group (read-write)
  • .env shadowed with /dev/null (secrets passed via stdin instead)
Main channel has read-only access to the NanoClaw codebase. This is intentional — it allows the agent to modify its own code, but changes only take effect after restart (giving you time to review).

Non-Main Group Mounts

From src/container-runner.ts:94:
else {
  // Other groups only get their own folder
  mounts.push({
    hostPath: groupDir,  // groups/whatsapp_dev-team/
    containerPath: '/workspace/group',
    readonly: false,
  });
  
  // Global memory directory (read-only)
  const globalDir = path.join(GROUPS_DIR, 'global');
  if (fs.existsSync(globalDir)) {
    mounts.push({
      hostPath: globalDir,
      containerPath: '/workspace/global',
      readonly: true,
    });
  }
}
Non-main groups see:
  • Their group folder at /workspace/group (read-write)
  • Global memory at /workspace/global (read-only)
  • Nothing else

Per-Group Session Directory

From src/container-runner.ts:114:
// Per-group Claude sessions directory (isolated from other groups)
const groupSessionsDir = path.join(
  DATA_DIR,
  'sessions',
  group.folder,
  '.claude',
);
fs.mkdirSync(groupSessionsDir, { recursive: true });

mounts.push({
  hostPath: groupSessionsDir,
  containerPath: '/home/node/.claude',
  readonly: false,
});
Purpose: Each group gets its own .claude/ directory for session transcripts. Groups cannot access each other’s session history.

IPC Directory

From src/container-runner.ts:166:
// Per-group IPC namespace
const groupIpcDir = resolveGroupIpcPath(group.folder);
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });

mounts.push({
  hostPath: groupIpcDir,
  containerPath: '/workspace/ipc',
  readonly: false,
});
Purpose: Container communicates with the host via files in /workspace/ipc. Each group has its own IPC directory to prevent cross-group privilege escalation.

Additional Mounts (Optional)

You can mount extra directories per group:
registerGroup("[email protected]", {
  name: "Dev Team",
  folder: "whatsapp_dev-team",
  containerConfig: {
    additionalMounts: [
      {
        hostPath: "~/projects/webapp",
        containerPath: "webapp",
        readonly: false,
      },
    ],
  },
});
Additional mounts appear at /workspace/extra/{containerPath}.
Additional mounts are validated against an external allowlist (mount-security.ts:26) to prevent privilege escalation.

Mount Security

Read-Only vs Read-Write

From src/container-runner.ts:246:
for (const mount of mounts) {
  if (mount.readonly) {
    args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
  } else {
    args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
  }
}
Read-write mounts: -v host:container Read-only mounts: --mount "type=bind,source=...,target=...,readonly"
The :ro suffix doesn’t work reliably on all container runtimes, so NanoClaw uses the explicit --mount syntax for read-only binds.

Shadowing .env

The main channel mounts the project root, which contains .env. To prevent the agent from reading secrets:
// Shadow .env so the agent cannot read secrets from the mounted project root
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
  mounts.push({
    hostPath: '/dev/null',
    containerPath: '/workspace/project/.env',
    readonly: true,
  });
}
Result: /workspace/project/.env appears as an empty file inside the container. Secrets are passed via stdin instead: From src/container-runner.ts:313:
input.secrets = readSecrets();  // Extract from .env
container.stdin.write(JSON.stringify(input));
container.stdin.end();
delete input.secrets;  // Remove from logs

Mount Allowlist

Additional mounts are validated against an allowlist: From src/mount-security.ts:26:
export function validateAdditionalMounts(
  mounts: AdditionalMount[],
  groupName: string,
  isMain: boolean,
): VolumeMount[] {
  const validatedMounts: VolumeMount[] = [];
  
  for (const mount of mounts) {
    const resolvedHost = resolvePath(mount.hostPath);
    
    // Prevent mounting sensitive system directories
    const forbidden = [
      '/etc',
      '/usr',
      '/bin',
      '/sbin',
      '/var',
      '/tmp',
      '/System',
      process.env.HOME + '/.ssh',
      process.env.HOME + '/.gnupg',
    ];
    
    if (forbidden.some((dir) => resolvedHost.startsWith(dir))) {
      logger.error(
        { mount, group: groupName },
        'Rejected forbidden mount',
      );
      continue;
    }
    
    validatedMounts.push({
      hostPath: resolvedHost,
      containerPath: `/workspace/extra/${mount.containerPath}`,
      readonly: mount.readonly,
    });
  }
  
  return validatedMounts;
}
Forbidden paths:
  • /etc, /usr, /bin, /sbin (system directories)
  • /var, /tmp (shared state)
  • ~/.ssh, ~/.gnupg (credentials)

Container Lifecycle

Spawning a Container

From src/container-runner.ts:301:
const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
  stdio: ['pipe', 'pipe', 'pipe'],
});

onProcess(container, containerName);

// Pass secrets via stdin
input.secrets = readSecrets();
container.stdin.write(JSON.stringify(input));
container.stdin.end();
delete input.secrets;
Container args:
docker run -i --rm --name nanoclaw-whatsapp_main-1738368000000 \
  -e TZ=America/New_York \
  --user 1000:1000 \
  -e HOME=/home/node \
  -v /path/to/groups/whatsapp_main:/workspace/group \
  -v /path/to/data/sessions/whatsapp_main/.claude:/home/node/.claude \
  --mount "type=bind,source=/path/to/groups/global,target=/workspace/global,readonly" \
  nanoclaw-agent:latest

Container Timeout

From src/container-runner.ts:400:
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);

const killOnTimeout = () => {
  timedOut = true;
  logger.error(
    { group: group.name, containerName },
    'Container timeout, stopping gracefully',
  );
  exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
    if (err) {
      container.kill('SIGKILL');
    }
  });
};

let timeout = setTimeout(killOnTimeout, timeoutMs);
Default timeout: 30 minutes (configurable via CONTAINER_TIMEOUT or group.containerConfig.timeout) Idle timeout: Container stays alive for 30 minutes after last output (allows piping new messages to the same session) Grace period: Hard timeout is at least IDLE_TIMEOUT + 30s to allow graceful shutdown

Container Cleanup

From src/container-runner.ts:430:
container.on('close', (code) => {
  clearTimeout(timeout);
  const duration = Date.now() - startTime;
  
  // Write container logs
  const logFile = path.join(logsDir, `container-${timestamp}.log`);
  fs.writeFileSync(logFile, [
    `=== Container Run Log ===`,
    `Timestamp: ${new Date().toISOString()}`,
    `Group: ${group.name}`,
    `Duration: ${duration}ms`,
    `Exit Code: ${code}`,
    `Stdout: ${stdout}`,
    `Stderr: ${stderr}`,
  ].join('\n'));
  
  // Resolve output
  if (code !== 0) {
    resolve({
      status: 'error',
      error: `Container exited with code ${code}`,
    });
  } else {
    resolve({ status: 'success', result });
  }
});
Logs location: groups/{group-name}/logs/container-{timestamp}.log

Real Code Examples

Building Mount Configuration

From src/container-runner.ts:57:
function buildVolumeMounts(
  group: RegisteredGroup,
  isMain: boolean,
): VolumeMount[] {
  const mounts: VolumeMount[] = [];
  const projectRoot = process.cwd();
  const groupDir = resolveGroupFolderPath(group.folder);

  if (isMain) {
    mounts.push({
      hostPath: projectRoot,
      containerPath: '/workspace/project',
      readonly: true,
    });

    const envFile = path.join(projectRoot, '.env');
    if (fs.existsSync(envFile)) {
      mounts.push({
        hostPath: '/dev/null',
        containerPath: '/workspace/project/.env',
        readonly: true,
      });
    }

    mounts.push({
      hostPath: groupDir,
      containerPath: '/workspace/group',
      readonly: false,
    });
  } else {
    mounts.push({
      hostPath: groupDir,
      containerPath: '/workspace/group',
      readonly: false,
    });

    const globalDir = path.join(GROUPS_DIR, 'global');
    if (fs.existsSync(globalDir)) {
      mounts.push({
        hostPath: globalDir,
        containerPath: '/workspace/global',
        readonly: true,
      });
    }
  }

  // Additional mounts validated against allowlist
  if (group.containerConfig?.additionalMounts) {
    const validatedMounts = validateAdditionalMounts(
      group.containerConfig.additionalMounts,
      group.name,
      isMain,
    );
    mounts.push(...validatedMounts);
  }

  return mounts;
}

Logging Mount Configuration

From src/container-runner.ts:274:
logger.debug(
  {
    group: group.name,
    containerName,
    mounts: mounts.map(
      (m) =>
        `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
    ),
    containerArgs: containerArgs.join(' '),
  },
  'Container mount configuration',
);
Example log:
{
  "group": "whatsapp_main",
  "containerName": "nanoclaw-whatsapp_main-1738368000000",
  "mounts": [
    "/Users/andy/nanoclaw-pro -> /workspace/project (ro)",
    "/dev/null -> /workspace/project/.env (ro)",
    "/Users/andy/nanoclaw-pro/groups/whatsapp_main -> /workspace/group",
    "/Users/andy/nanoclaw-pro/data/sessions/whatsapp_main/.claude -> /home/node/.claude",
    "/Users/andy/nanoclaw-pro/data/ipc/whatsapp_main -> /workspace/ipc"
  ]
}

Best Practices

Mount Minimal Directories

Only mount what the agent needs. More mounts = larger attack surface.

Use Read-Only When Possible

If the agent doesn’t need to write, mount read-only.

Review Additional Mounts

Carefully review any additionalMounts configuration before applying.

Monitor Container Logs

Check groups/{name}/logs/ regularly for errors or suspicious activity.

Troubleshooting

Container Won’t Start

Check runtime is running:
docker ps
# or
apple-container ps
Check container image exists:
docker images | grep nanoclaw-agent
Rebuild if missing:
./container/build.sh

Mount Errors

Check paths are absolute:
grep "hostPath" logs/nanoclaw.log
Paths must be absolute, not relative. Check src/config.ts for path resolution. Check paths exist:
ls -la /path/from/error

Permission Errors

Symptom: “Permission denied” inside container Cause: Container runs as uid 1000 but host files are owned by different user Solution: Run container as host user: From src/container-runner.ts:236:
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
  args.push('--user', `${hostUid}:${hostGid}`);
  args.push('-e', 'HOME=/home/node');
}

Container Timeout

Check timeout config:
grep CONTAINER_TIMEOUT .env
Increase timeout:
echo "CONTAINER_TIMEOUT=3600000" >> .env  # 1 hour
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
Or configure per-group:
registerGroup(jid, {
  name: "Dev Team",
  folder: "whatsapp_dev-team",
  containerConfig: {
    timeout: 3600000,  // 1 hour for this group
  },
});

Build docs developers (and LLMs) love