security: scope /opt mount to /opt/projects

Splits the previous /opt:/opt:rw bind into two mounts to narrow the
writable scope of the container:

- /opt:/opt:ro — read-only mount for legacy/existing project
  add-existing flow. resolveProjectPath still uses
  PROJECT_ROOT_WHITELIST (/opt by default) so existing projects under
  /opt/<name> (analytics, boolab, boocode itself) continue to resolve
  and serve their file-tree via the read-only tools.
- /opt/projects:/opt/projects:rw — writable mount targeted at the
  create-new-project bootstrap path.

Picked Option B from the spec (simpler than two scan roots):
PROJECT_ROOT_WHITELIST stays /opt, new BOOTSTRAP_ROOT env var defaults
to /opt/projects and is used by project_bootstrap.ts as the mkdir
target. Bootstrap path-escape check now compares against
BOOTSTRAP_ROOT.

Prereq: host must `mkdir -p /opt/projects` before next container
restart. Documented in CLAUDE.md and .env.example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 04:35:59 +00:00
parent 2f6be39efd
commit 7f0fd1281b
5 changed files with 15 additions and 7 deletions

View File

@@ -82,11 +82,13 @@ export async function bootstrapProject(
throw new BootstrapNameError(`invalid name after sanitization: "${folder}"`);
}
// Whitelist resolution
const whitelistReal = await realpath(config.PROJECT_ROOT_WHITELIST);
const fullPath = resolve(whitelistReal, folder);
if (!fullPath.startsWith(whitelistReal + sep)) {
throw new BootstrapPathError('path escapes whitelist');
// Bootstrap target resolution. Uses BOOTSTRAP_ROOT (writable), not
// PROJECT_ROOT_WHITELIST (which may be a wider read-only scope for
// add-existing flow).
const bootstrapReal = await realpath(config.BOOTSTRAP_ROOT);
const fullPath = resolve(bootstrapReal, folder);
if (!fullPath.startsWith(bootstrapReal + sep)) {
throw new BootstrapPathError('path escapes bootstrap root');
}
if (existsSync(fullPath)) {
throw new BootstrapCollisionError(`path already exists: ${fullPath}`);