fix: mount project root read-only to prevent container escape (#392)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,10 @@ private_key, .secret
|
|||||||
- Container path validation (rejects `..` and absolute paths)
|
- Container path validation (rejects `..` and absolute paths)
|
||||||
- `nonMainReadOnly` option forces read-only for non-main groups
|
- `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
|
### 3. Session Isolation
|
||||||
|
|
||||||
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
|
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 |
|
| 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) |
|
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
||||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
||||||
| Additional mounts | Configurable | Read-only unless allowed |
|
| Additional mounts | Configurable | Read-only unless allowed |
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ This is the **main channel**, which has elevated privileges.
|
|||||||
|
|
||||||
## Container Mounts
|
## 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 |
|
| Container Path | Host Path | Access |
|
||||||
|----------------|-----------|--------|
|
|----------------|-----------|--------|
|
||||||
| `/workspace/project` | Project root | read-write |
|
| `/workspace/project` | Project root | read-only |
|
||||||
| `/workspace/group` | `groups/main/` | read-write |
|
| `/workspace/group` | `groups/main/` | read-write |
|
||||||
|
|
||||||
Key paths inside the container:
|
Key paths inside the container:
|
||||||
|
|||||||
@@ -67,11 +67,15 @@ function buildVolumeMounts(
|
|||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
|
|
||||||
if (isMain) {
|
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({
|
mounts.push({
|
||||||
hostPath: projectRoot,
|
hostPath: projectRoot,
|
||||||
containerPath: '/workspace/project',
|
containerPath: '/workspace/project',
|
||||||
readonly: false,
|
readonly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Main also gets its group folder as the working directory
|
// Main also gets its group folder as the working directory
|
||||||
@@ -155,13 +159,18 @@ function buildVolumeMounts(
|
|||||||
readonly: false,
|
readonly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mount agent-runner source from host — recompiled on container startup.
|
// Copy agent-runner source into a per-group writable location so agents
|
||||||
// Bypasses sticky build cache for code changes.
|
// 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 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({
|
mounts.push({
|
||||||
hostPath: agentRunnerSrc,
|
hostPath: groupAgentRunnerDir,
|
||||||
containerPath: '/app/src',
|
containerPath: '/app/src',
|
||||||
readonly: true,
|
readonly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Additional mounts validated against external allowlist (tamper-proof from containers)
|
// Additional mounts validated against external allowlist (tamper-proof from containers)
|
||||||
|
|||||||
Reference in New Issue
Block a user