From 5fb10645cda10484c412c262066fd9ea6ef2e5d7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 22 Feb 2026 20:57:57 +0200 Subject: [PATCH] fix: mount project root read-only to prevent container escape (#392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main group's project root was mounted read-write, allowing the container agent to modify host application code (e.g. dist/container-runner.js) to inject arbitrary mounts on next restart — a full sandbox escape. Fix: mount the project root read-only. Writable paths the agent needs (group folder, IPC, .claude/) are already mounted separately. The agent-runner source is now copied into a per-group writable location so agents can still customize container-side behavior without affecting host code or other groups. Co-authored-by: Claude Opus 4.6 --- docs/SECURITY.md | 6 +++++- groups/main/CLAUDE.md | 4 ++-- src/container-runner.ts | 21 +++++++++++++++------ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 578b041..7fcee1b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -40,6 +40,10 @@ private_key, .secret - Container path validation (rejects `..` and absolute paths) - `nonMainReadOnly` option forces read-only for non-main groups +**Read-Only Project Root:** + +The main group's project root is mounted read-only. Writable paths the agent needs (group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. + ### 3. Session Isolation Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`: @@ -82,7 +86,7 @@ const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; | Capability | Main Group | Non-Main Group | |------------|------------|----------------| -| Project root access | `/workspace/project` (rw) | None | +| Project root access | `/workspace/project` (ro) | None | | Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) | | Global memory | Implicit via project | `/workspace/global` (ro) | | Additional mounts | Configurable | Read-only unless allowed | diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index ea56441..9ae1b13 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -61,11 +61,11 @@ This is the **main channel**, which has elevated privileges. ## Container Mounts -Main has access to the entire project: +Main has read-only access to the project and read-write access to its group folder: | Container Path | Host Path | Access | |----------------|-----------|--------| -| `/workspace/project` | Project root | read-write | +| `/workspace/project` | Project root | read-only | | `/workspace/group` | `groups/main/` | read-write | Key paths inside the container: diff --git a/src/container-runner.ts b/src/container-runner.ts index 6f66708..6da0a87 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -67,11 +67,15 @@ function buildVolumeMounts( const projectRoot = process.cwd(); if (isMain) { - // Main gets the entire project root mounted + // Main gets the project root read-only. Writable paths the agent needs + // (group folder, IPC, .claude/) are mounted separately below. + // Read-only prevents the agent from modifying host application code + // (src/, dist/, package.json, etc.) which would bypass the sandbox + // entirely on next restart. mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', - readonly: false, + readonly: true, }); // Main also gets its group folder as the working directory @@ -155,13 +159,18 @@ function buildVolumeMounts( readonly: false, }); - // Mount agent-runner source from host — recompiled on container startup. - // Bypasses sticky build cache for code changes. + // Copy agent-runner source into a per-group writable location so agents + // can customize it (add tools, change behavior) without affecting other + // groups. Recompiled on container startup via entrypoint.sh. const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src'); + if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { + fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + } mounts.push({ - hostPath: agentRunnerSrc, + hostPath: groupAgentRunnerDir, containerPath: '/app/src', - readonly: true, + readonly: false, }); // Additional mounts validated against external allowlist (tamper-proof from containers)