diff --git a/.gitea/workflows/hub-check.yml b/.gitea/workflows/hub-check.yml index 7f49894..5216d10 100644 --- a/.gitea/workflows/hub-check.yml +++ b/.gitea/workflows/hub-check.yml @@ -44,7 +44,7 @@ jobs: run: npm ci - name: Install Linux sandbox dependency - run: sudo apt-get update && sudo apt-get install --yes bubblewrap + run: sudo apt-get update && sudo apt-get install --yes bubblewrap socat - name: Wait for Postgres run: | @@ -89,7 +89,11 @@ jobs: cargo install --path crates/cph-cli --locked - name: Prove real Claude SDK Bash sandbox boundary - run: /usr/bin/setpriv --no-new-privs npx vitest run test/integration/agent-sandbox-linux.test.ts + run: | + sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /var/lib/cph-test + CPH_SANDBOX_TEST_ROOT=/var/lib/cph-test \ + /usr/bin/setpriv --no-new-privs \ + npx vitest run test/integration/agent-sandbox-linux.test.ts - name: Run unit tests run: npx vitest run test/unit diff --git a/.scratch/saas-production-readiness/issues/10-provision-nonroot-service.md b/.scratch/saas-production-readiness/issues/10-provision-nonroot-service.md index 7fdd8f5..e3393e0 100644 --- a/.scratch/saas-production-readiness/issues/10-provision-nonroot-service.md +++ b/.scratch/saas-production-readiness/issues/10-provision-nonroot-service.md @@ -25,7 +25,7 @@ unit is written or enabled. Preflight validates the configured bind host/port, authenticated PostgreSQL access, Node/cph/Prisma, configured and canonical path traversal, and a real bubblewrap namespace. The post-provision phase runs the built Hub dependency -graph, a database query, cph and bubblewrap as the service user under +graph, a database query, cph, socat and bubblewrap as the service user under `no_new_privs`, matching the systemd security context. HTTP binding is awaited before the Feishu listener starts, so bind failure rejects startup instead of leaving a listener-only process alive. diff --git a/.scratch/saas-production-readiness/issues/16-confine-agent-and-protect-credentials.md b/.scratch/saas-production-readiness/issues/16-confine-agent-and-protect-credentials.md index b174d7f..a551b4b 100644 --- a/.scratch/saas-production-readiness/issues/16-confine-agent-and-protect-credentials.md +++ b/.scratch/saas-production-readiness/issues/16-confine-agent-and-protect-credentials.md @@ -1,7 +1,7 @@ # Confine the agent runtime and protect service credentials Type: task -Status: claimed +Status: resolved ## Question @@ -10,3 +10,29 @@ deny sibling tenant workspaces and service files, expose only the minimum safe process environment, use SDK credential protection for secrets, and add an actual Linux sandbox test proving another workspace and every non-provider credential are unreadable while required tools still work. + +## Answer + +The Agent subprocess now receives a replacement environment containing only +named runtime values and the selected provider protocol values; PostgreSQL, +Feishu and Hub session credentials never cross the process boundary. Provider +credentials are additionally denied inside Bash. The SDK must sandbox every +command (`failIfUnavailable=true`, `allowUnsandboxedCommands=false`), denies +host reads and writes from root, and re-opens only the canonical project +workspace plus the reviewed read-only system runtime needed by `cph`. + +SDK config/cache/home and general temp storage live inside the workspace. A +short relative `CLAUDE_CODE_TMPDIR` prevents the pinned SDK from falling back +to host `/tmp`, while absolute workspace-local `TMPDIR`/`TMP`/`TEMP` remain +stable after Bash changes directory. Linux production and CI now require both +bubblewrap and `socat`; preflight probes both as the service user. Bounded, +credential-redacted SDK stderr is logged with run and project correlation. + +The real Linux proof runs the actual Claude SDK, Bash, bubblewrap, `socat` and +release `cph` as UID 1000 with no capabilities and `no_new_privs`. It proves +parent, sibling-project, `/tmp` and `/var/tmp` writes are rejected and have no +host effect; sibling/service-secret reads fail; platform and provider secrets +are absent from Bash; workspace temp remains anchored after `cd`; and `cph` +still executes. Final local gates passed: TypeScript check/build, Prisma schema, +173 unit tests, 85 mock-provider integration tests, deployment shell syntax, +and the dedicated Linux sandbox test. diff --git a/.scratch/saas-production-readiness/issues/17-close-mcp-data-egress-escapes.md b/.scratch/saas-production-readiness/issues/17-close-mcp-data-egress-escapes.md index ca3a97e..dcbfa3d 100644 --- a/.scratch/saas-production-readiness/issues/17-close-mcp-data-egress-escapes.md +++ b/.scratch/saas-production-readiness/issues/17-close-mcp-data-egress-escapes.md @@ -1,7 +1,7 @@ # Close MCP context and file-delivery escape paths Type: task -Status: claimed +Status: resolved ## Question @@ -9,3 +9,20 @@ Restrict file delivery to a realpath/no-follow target inside the current project workspace, remove Git-root access, make inbound Hub writes symlink-safe, and validate every Feishu message/thread result against the run's bound chat before returning data to the model. + +## Answer + +All Agent-facing workspace file reads and writes are anchored to the configured +workspace root and current project. Linux uses descriptor-relative, no-follow +traversal so a symlink swap cannot escape after validation; unsupported unsafe +paths fail closed. Inbound Feishu files use the same boundary. Outbound file +delivery no longer accepts the repository Git root or arbitrary absolute +paths: it reads a no-follow snapshot from the current project and uploads that +snapshot, so the checked object cannot be swapped before delivery. + +The Feishu MCP read surface carries the run's project and bound-chat context. +Message and thread results are rejected unless their returned chat matches that +binding, and file-delivery MCP calls remain scoped to the same run/project/chat +tuple. Unit and integration regressions cover symlink paths, sibling projects, +Git-root delivery attempts, swapped files, and cross-chat results; they are +included in the final 173-unit/85-integration green gate reported by Issue 16. diff --git a/.scratch/saas-production-readiness/map.md b/.scratch/saas-production-readiness/map.md index 1e85a7e..d9e03ca 100644 --- a/.scratch/saas-production-readiness/map.md +++ b/.scratch/saas-production-readiness/map.md @@ -45,6 +45,8 @@ rollback and recovery, and no known critical security or data-integrity gaps. - [Decide the platform-administrator identity and audit boundary](issues/42-decide-platform-admin-identity-audit.md) — use a dedicated platform Feishu issuer, one peer administrator role, guarded bootstrap and identity-bound invitations, revocable server-side sessions, atomic append-only Platform Audit, and dual-factor offline recovery without a standing break-glass account. - [Restore the checker formatting gate](issues/09-restore-checker-format-gate.md) — pin local and CI Rust/rustfmt/clippy to 1.92.0, apply the canonical workspace format, and keep the full fmt + clippy + 53-test checker gate green. - [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit. +- [Confine the agent runtime and protect service credentials](issues/16-confine-agent-and-protect-credentials.md) — replace the Agent environment, deny host reads/writes from root, keep every writable SDK path inside the canonical project, require bubblewrap plus socat, emit bounded correlated diagnostics, and prove the boundary with the real Linux SDK under a capability-free non-root identity. +- [Close MCP context and file-delivery escape paths](issues/17-close-mcp-data-egress-escapes.md) — use no-follow project-rooted file operations and delivery snapshots, remove Git-root delivery, and bind every Feishu MCP read to the run's project and chat. ## Fog diff --git a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md index 9227f70..e85a22b 100644 --- a/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md +++ b/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md @@ -86,7 +86,7 @@ discipline). ### The mechanism: SDK sandbox (OS-level, per agent process) The boundary is enforced by the Claude Code SDK's built-in sandbox -(bubblewrap on Linux, seatbelt on macOS), configured via `query()` options in +(bubblewrap plus `socat` on Linux, seatbelt on macOS), configured via `query()` options in `hub/src/agent/runner.ts`: - `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and @@ -99,9 +99,16 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox in the run's workspace while `/bin`, shared libraries, CA certificates, fonts and the configured `cph` executable remain available as the external tool exception described above. -- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes are confined to the - canonical ADR-0007 workspace; SDK temp/config/cache/home paths are relocated - beneath `.cph/agent-runtime/` in that same workspace. +- `sandbox.filesystem.allowWrite: [workspaceDir]` confines every write to the + canonical ADR-0007 workspace. Config/cache/home stay beneath + `.cph/agent-runtime/`. `TMPDIR`, `TMP`, and `TEMP` use the absolute + workspace-local `.cph/t/` path so tools remain anchored after `cd`; + `CLAUDE_CODE_TMPDIR` uses the short relative `.cph/t` prefix, resolved from + the canonical workspace cwd, because the SDK's socat bridge otherwise falls + back to a host temp directory when its Unix-socket prefix is too long. + Root is denied for writes and only the canonical workspace is re-opened, so + `/tmp`, `/var/tmp`, sibling projects and every other host path are rejected. + There is no writable scratch exception outside the project directory. - The SDK subprocess environment replaces rather than spreads `process.env`. Only provider protocol variables and non-secret runtime variables cross the boundary; database, Feishu and Hub session credentials never enter it. diff --git a/hub/deploy/README.md b/hub/deploy/README.md index 2458257..337267e 100644 --- a/hub/deploy/README.md +++ b/hub/deploy/README.md @@ -1,8 +1,8 @@ # Hub service installation The initial production target is a supported Linux host with systemd, -PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap -and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not +PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap, +`socat` and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not create or run the unit as root. Build or deploy the Hub under `/srv/curriculum-project-hub`, then run: @@ -18,13 +18,13 @@ On a clean host the first invocation creates production key and exits with status 78. Fill every required blank, set `CPH_BIN` to an executable compatible `cph`, keep the file mode at `0600`, and run the same command again. No systemd unit is installed until configuration, -paths, Node, bubblewrap, `cph --version`, PostgreSQL connectivity and directory +paths, Node, bubblewrap, `socat`, `cph --version`, PostgreSQL connectivity and directory ownership all pass preflight. An existing service account is accepted only when its primary group, home and non-login shell match the requested configuration and it has no supplementary groups. After provisioning, the installer executes path-access, built Hub, -Prisma, an authenticated database query, `cph` and bubblewrap namespace probes +Prisma, an authenticated database query, `cph`, `socat` and bubblewrap namespace probes as that account with `no_new_privs` set. Inaccessible parents, UID-specific database/network policy, or a host that forbids unprivileged user namespaces therefore fail before unit installation. diff --git a/hub/deploy/cph-hub.service b/hub/deploy/cph-hub.service index 48b9ae2..2619f7e 100644 --- a/hub/deploy/cph-hub.service +++ b/hub/deploy/cph-hub.service @@ -6,6 +6,7 @@ Wants=network-online.target # after installation. Deployment preflight validates the executable itself; # this start-time assertion keeps later package/host changes fail-closed. AssertPathExists=__BWRAP_BIN__ +AssertPathExists=__SOCAT_BIN__ [Service] Type=simple diff --git a/hub/deploy/deploy_platform.sh b/hub/deploy/deploy_platform.sh index f18024f..7c79cd2 100755 --- a/hub/deploy/deploy_platform.sh +++ b/hub/deploy/deploy_platform.sh @@ -9,7 +9,7 @@ # PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub # PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz # The target host must have Node.js 20+, npm, rsync, PostgreSQL client tools -# (`pg_isready`), systemd, bubblewrap (`bwrap`, for the ADR-0018 agent sandbox), +# (`pg_isready`), systemd, bubblewrap (`bwrap`) and `socat` (for the ADR-0018 agent sandbox), # and a compatible `cph` binary named by platform.env. The service installer # provisions the dedicated non-root identity and persistent directories. # Use a dedicated `deploy` user that has passwordless sudo for systemctl and diff --git a/hub/deploy/install_service.sh b/hub/deploy/install_service.sh index b40256f..22ffec5 100755 --- a/hub/deploy/install_service.sh +++ b/hub/deploy/install_service.sh @@ -24,13 +24,14 @@ ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}" SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}" NODE_BIN="${NODE_BIN:-$(command -v node || true)}" BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}" +SOCAT_BIN="${SOCAT_BIN:-$(command -v socat || true)}" PG_ISREADY_BIN="${PG_ISREADY_BIN:-$(command -v pg_isready || true)}" RUNUSER_BIN="${RUNUSER_BIN:-$(command -v runuser || true)}" SETPRIV_BIN="${SETPRIV_BIN:-$(command -v setpriv || true)}" CPH_BIN_DEFAULT="${CPH_BIN_DEFAULT:-/usr/local/bin/cph}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PREFLIGHT_JS="$HUB_DIR/dist/deployment/preflight-cli.js" -RUNTIME_PATH="$(dirname "$NODE_BIN"):$(dirname "$BWRAP_BIN"):$(dirname "$SETPRIV_BIN"):/usr/local/bin:/usr/bin:/bin" +RUNTIME_PATH="$(dirname "$NODE_BIN"):$(dirname "$BWRAP_BIN"):$(dirname "$SOCAT_BIN"):$(dirname "$SETPRIV_BIN"):/usr/local/bin:/usr/bin:/bin" fail() { echo "[install] error: $*" >&2 @@ -68,6 +69,7 @@ done for pair in \ "NODE_BIN:$NODE_BIN" \ "BWRAP_BIN:$BWRAP_BIN" \ + "SOCAT_BIN:$SOCAT_BIN" \ "PG_ISREADY_BIN:$PG_ISREADY_BIN"; do label="${pair%%:*}" value="${pair#*:}" @@ -98,6 +100,7 @@ for pair in \ "SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \ "NODE_BIN:$NODE_BIN" \ "BWRAP_BIN:$BWRAP_BIN" \ + "SOCAT_BIN:$SOCAT_BIN" \ "RUNUSER_BIN:$RUNUSER_BIN" \ "SETPRIV_BIN:$SETPRIV_BIN" \ "RUNTIME_PATH:$RUNTIME_PATH"; do @@ -152,6 +155,7 @@ PREFLIGHT_ARGS=( --cache-dir "$CACHE_DIR" --workspace-root "$WORKSPACE_ROOT" --bwrap-bin "$BWRAP_BIN" + --socat-bin "$SOCAT_BIN" --pg-isready-bin "$PG_ISREADY_BIN" ) @@ -247,6 +251,7 @@ sed \ -e "s|__ENV_FILE__|$ENV_FILE|g" \ -e "s|__NODE_BIN__|$NODE_BIN|g" \ -e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \ + -e "s|__SOCAT_BIN__|$SOCAT_BIN|g" \ -e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \ "$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT" install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT" diff --git a/hub/src/agent/diagnostics.ts b/hub/src/agent/diagnostics.ts new file mode 100644 index 0000000..55f5acf --- /dev/null +++ b/hub/src/agent/diagnostics.ts @@ -0,0 +1,115 @@ +const MAX_LOGGED_CHARS = 8_000; +const MAX_LOGGED_LINES = 20; +const MAX_LINE_CHARS = 2_000; +const MAX_PENDING_LINE_CHARS = 16_000; + +interface DiagnosticLogger { + warn(bindings: Readonly>, message: string): void; +} + +export interface AgentSdkStderrSink { + readonly write: (data: string) => void; + readonly flush: () => void; +} + +/** + * Turn the SDK subprocess stderr stream into bounded, run-correlated logs. + * Complete lines are buffered so a credential split across chunks is still + * removed before anything reaches the logger. + */ +export function createAgentSdkStderrSink(input: { + readonly logger: DiagnosticLogger; + readonly runId: string; + readonly projectId: string; + readonly sensitiveValues: readonly (string | undefined)[]; +}): AgentSdkStderrSink { + const secrets = [...new Set(input.sensitiveValues.filter((value): value is string => value !== undefined && value !== ""))] + .sort((left, right) => right.length - left.length); + let pending = ""; + let droppingOversizedLine = false; + let remainingChars = MAX_LOGGED_CHARS; + let remainingLines = MAX_LOGGED_LINES; + let limitReported = false; + + const reportLimit = (): void => { + if (limitReported) return; + limitReported = true; + input.logger.warn( + { runId: input.runId, projectId: input.projectId }, + "agent SDK stderr log limit reached; further diagnostics omitted", + ); + }; + + const emit = (line: string): void => { + if (line.trim() === "") return; + if (remainingChars <= 0 || remainingLines <= 0) { + reportLimit(); + return; + } + const redacted = redactDiagnostic(line, secrets); + const allowed = Math.min(MAX_LINE_CHARS, remainingChars); + const diagnostic = boundedDiagnostic(redacted, allowed); + remainingChars -= diagnostic.length; + remainingLines--; + input.logger.warn( + { runId: input.runId, projectId: input.projectId, sdkStderr: diagnostic }, + "agent SDK stderr", + ); + if (redacted.length > allowed || remainingChars <= 0 || remainingLines <= 0) reportLimit(); + }; + + const append = (fragment: string): void => { + if (fragment === "" || droppingOversizedLine) return; + if (pending.length + fragment.length > MAX_PENDING_LINE_CHARS) { + pending = ""; + droppingOversizedLine = true; + emit("[oversized stderr line omitted]"); + return; + } + pending += fragment; + }; + + const write = (data: string): void => { + let lineStart = 0; + for (let index = 0; index < data.length; index++) { + const character = data[index]; + if (character !== "\n" && character !== "\r") continue; + append(data.slice(lineStart, index)); + if (!droppingOversizedLine) emit(pending); + pending = ""; + droppingOversizedLine = false; + if (character === "\r" && data[index + 1] === "\n") index++; + lineStart = index + 1; + } + append(data.slice(lineStart)); + }; + + const flush = (): void => { + if (droppingOversizedLine) { + droppingOversizedLine = false; + } else { + emit(pending); + } + pending = ""; + }; + + return { write, flush }; +} + +function boundedDiagnostic(value: string, limit: number): string { + if (value.length <= limit) return value; + const marker = "…[truncated]"; + if (limit <= marker.length) return marker.slice(0, limit); + return `${value.slice(0, limit - marker.length)}${marker}`; +} + +function redactDiagnostic(value: string, secrets: readonly string[]): string { + let redacted = value; + for (const secret of secrets) redacted = redacted.replaceAll(secret, "[REDACTED]"); + return redacted + .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ") + .replace(/\b(postgres(?:ql)?):\/\/([^:\s/@]+):([^@\s]+)@/gi, "$1://$2:[REDACTED]@") + .replace(/\b((?:api[_-]?key|token|secret|password)\s*[:=]\s*)\S+/gi, "$1[REDACTED]") + .trim(); +} diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index 0414999..80551bc 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -19,11 +19,12 @@ * * ADR-0018: the agent execution surface is bounded by the run's workspace. * The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude - * Code process and its subprocesses at the OS level — writes are confined to - * the workspace, host reads are denied by default and re-opened only for the - * workspace plus named system runtimes, and `failIfUnavailable` hard-fails if - * the sandbox can't start. The subprocess gets a minimal environment and SDK - * credential protection removes provider secrets from Bash. This upholds `AgentFileOp.Authorized` + * Code process and its subprocesses at the OS level — writes, including the + * SDK's Unix-socket scratch, are confined to the workspace. Host reads are + * denied by default and re-opened only for the workspace plus named system + * runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The + * subprocess gets a minimal environment and SDK credential protection removes + * provider secrets from Bash. This upholds `AgentFileOp.Authorized` * (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * is the mechanism, the contract pins the invariant. @@ -70,6 +71,8 @@ export interface RunRequest { readonly sessionId: string; readonly prisma: PrismaClient; readonly onStream?: StreamCallback; + /** Optional diagnostic sink for Claude SDK subprocess stderr. */ + readonly onSdkStderr?: ((data: string) => void) | undefined; /** * Abort controller for the run. Aborting the signal interrupts the SDK query * (it stops the agent loop and cleans up); the run resolves with status @@ -153,6 +156,7 @@ export async function runAgent(req: RunRequest): Promise { if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId; if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers; if (req.abortController !== undefined) options.abortController = req.abortController; + if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr; const conversation = query({ prompt: req.prompt, diff --git a/hub/src/agent/security.ts b/hub/src/agent/security.ts index f3de580..5c2b793 100644 --- a/hub/src/agent/security.ts +++ b/hub/src/agent/security.ts @@ -21,7 +21,10 @@ const SAFE_HOST_ENV_KEYS = [ "CPH_BIN", ] as const; -const PROVIDER_SECRET_ENV_KEYS = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const; +const SANDBOX_HIDDEN_ENV_KEYS = [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", +] as const; export interface AgentSecurityInput { readonly workspaceRoot: string; @@ -37,6 +40,7 @@ export interface AgentSandboxPolicy { readonly allowUnsandboxedCommands: false; readonly filesystem: { readonly allowWrite: string[]; + readonly denyWrite: string[]; readonly denyRead: string[]; readonly allowRead: string[]; }; @@ -61,12 +65,16 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom const hostEnv = input.hostEnv ?? process.env; const { workspaceRoot, workspaceDir } = await resolveProjectWorkspace(input.workspaceRoot, input.workspaceDir); - const runtimeRoot = await ensureDirectoryTree(workspaceDir, [".cph", "agent-runtime"]); + const cphRoot = await ensureDirectoryTree(workspaceDir, [".cph"]); + const runtimeRoot = await ensureDirectoryTree(cphRoot, ["agent-runtime"]); const agentHome = await ensureDirectoryTree(runtimeRoot, ["home"]); const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]); const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]); const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]); - const agentTmp = await ensureDirectoryTree(runtimeRoot, ["tmp"]); + // Keep the SDK temp root both short enough for Linux AF_UNIX sockets and + // physically inside the project boundary pinned by AgentFileOp.Authorized. + const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]); + const claudeCodeTmp = join(".cph", "t"); const path = hostEnv["PATH"]?.trim(); if (path === undefined || path === "") { @@ -82,11 +90,14 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom env.XDG_CACHE_HOME = agentCache; env.XDG_CONFIG_HOME = agentConfig; env.XDG_STATE_HOME = agentState; + // General subprocess temp paths stay absolute so tools continue to work + // after `cd`. Only the SDK's socket prefix is relative: Claude resolves it + // from the canonical workspace cwd before Bash commands can change cwd. env.TMPDIR = agentTmp; env.TMP = agentTmp; env.TEMP = agentTmp; env.CLAUDE_CONFIG_DIR = agentConfig; - env.CLAUDE_CODE_TMPDIR = agentTmp; + env.CLAUDE_CODE_TMPDIR = claudeCodeTmp; env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; env.DISABLE_TELEMETRY = "1"; env.DO_NOT_TRACK = "1"; @@ -112,6 +123,10 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom allowUnsandboxedCommands: false, filesystem: { allowWrite: [workspaceDir], + // Reject every write path by default, then re-open only the canonical + // workspace. This prevents bubblewrap's ordinary temp exceptions from + // turning an unauthorized path into a successful ephemeral write. + denyWrite: ["/"], // Deny host reads by default, then re-open exactly this run's // workspace plus the named system runtime needed to execute tools. // SDK allowRead takes precedence over matching denyRead paths. @@ -120,7 +135,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom }, credentials: { files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })), - envVars: PROVIDER_SECRET_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })), + envVars: SANDBOX_HIDDEN_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })), }, }, }; @@ -144,8 +159,10 @@ function hostRuntimeReadPaths(env: Readonly>) ] : [ "/bin", + "/sbin", "/usr/bin", "/usr/local/bin", + "/usr/sbin", "/lib", "/lib64", "/usr/lib", diff --git a/hub/src/deployment/preflight-cli.ts b/hub/src/deployment/preflight-cli.ts index 721b313..e23296a 100644 --- a/hub/src/deployment/preflight-cli.ts +++ b/hub/src/deployment/preflight-cli.ts @@ -29,6 +29,7 @@ interface Options { readonly cacheDir: string; readonly workspaceRoot: string; readonly bwrapBin: string; + readonly socatBin: string; readonly pgIsReadyBin: string; readonly serviceUid?: number; readonly serviceGid?: number; @@ -50,6 +51,7 @@ async function main(): Promise { await validateExecutable(options.runuserBin, "runuser"); await validateExecutable(options.setprivBin, "setpriv"); await validateExecutable(options.bwrapBin, "bubblewrap"); + await validateExecutable(options.socatBin, "socat"); await validateExecutable(result.cphBin, "cph"); await validateExecutable(options.pgIsReadyBin, "pg_isready"); await validateCph(result.cphBin); @@ -257,7 +259,7 @@ async function validateServiceRuntime( HOME: configuredInput.serviceHome, XDG_STATE_HOME: configuredInput.stateDir, XDG_CACHE_HOME: configuredInput.cacheDir, - PATH: [dirname(options.nodeBin), dirname(options.bwrapBin), dirname(result.cphBin), "/usr/bin", "/bin"].join(":"), + PATH: [dirname(options.nodeBin), dirname(options.bwrapBin), dirname(options.socatBin), dirname(result.cphBin), "/usr/bin", "/bin"].join(":"), }; const pathAccessRequirements = (input: DeploymentPreflightInput) => [ [input.hubDir, constants.R_OK | constants.X_OK], @@ -278,6 +280,7 @@ async function validateServiceRuntime( ...pathAccessRequirements(canonicalInput), [options.nodeBin, constants.X_OK], [options.bwrapBin, constants.X_OK], + [options.socatBin, constants.X_OK], [options.setprivBin, constants.X_OK], [result.cphBin, constants.X_OK], ] as const; @@ -315,6 +318,7 @@ async function validateServiceRuntime( "Hub dependency and authenticated database probe", ); await runAsService(options, serviceEnv, result.cphBin, ["--version"], "cph runtime probe"); + await runAsService(options, serviceEnv, options.socatBin, ["-V"], "socat runtime probe"); await runAsService( options, serviceEnv, @@ -390,6 +394,7 @@ function parseArgs(argv: readonly string[]): Options { "--cache-dir", "--workspace-root", "--bwrap-bin", + "--socat-bin", "--pg-isready-bin", "--service-uid", "--service-gid", @@ -417,6 +422,7 @@ function parseArgs(argv: readonly string[]): Options { cacheDir: required(values, "--cache-dir"), workspaceRoot: required(values, "--workspace-root"), bwrapBin: required(values, "--bwrap-bin"), + socatBin: required(values, "--socat-bin"), pgIsReadyBin: required(values, "--pg-isready-bin"), ...(serviceUid === undefined ? {} : { serviceUid, serviceGid: serviceGid! }), }; diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index fc87517..2f03699 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -35,6 +35,7 @@ import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js"; import { writeAudit } from "../audit.js"; import { formatRunCostLine } from "../agent/cost.js"; +import { createAgentSdkStderrSink } from "../agent/diagnostics.js"; import { StreamingAgentCard } from "./card/streaming-card.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; import { readFeishuContext } from "./read.js"; @@ -331,6 +332,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { // stop the SDK query. Cleared in the finally below. const abortController = new AbortController(); activeRuns.set(run.id, abortController); + const sdkStderr = createAgentSdkStderrSink({ + logger: deps.logger, + runId: run.id, + projectId, + sensitiveValues: Object.values(provider.sdkEnv), + }); runAgent({ prompt: promptForAgent, model, @@ -345,6 +352,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { sessionId: session.id, prisma: deps.prisma, abortController, + onSdkStderr: sdkStderr.write, onStream: (event) => { switch (event.type) { case "text-delta": @@ -433,6 +441,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } }); }) .finally(async () => { + sdkStderr.flush(); try { await releaseLock(deps.prisma, run.id); } catch (e) { diff --git a/hub/test/integration/agent-sandbox-linux.test.ts b/hub/test/integration/agent-sandbox-linux.test.ts index 50747df..6312f9b 100644 --- a/hub/test/integration/agent-sandbox-linux.test.ts +++ b/hub/test/integration/agent-sandbox-linux.test.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; -import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -25,25 +26,36 @@ describe("real Claude SDK sandbox boundary", () => { itOnLinux("denies sibling workspace, service files, credentials, and unsandboxed Bash while cph still works", async () => { await access("/usr/bin/bwrap", constants.X_OK); + await access("/usr/bin/socat", constants.X_OK); const noNewPrivilegesStatus = await readFile("/proc/self/status", "utf8"); expect(noNewPrivilegesStatus).toMatch(/^NoNewPrivs:\s+1$/m); + expect(noNewPrivilegesStatus).toMatch(/^CapEff:\s+0+$/m); + expect(process.getuid?.()).toBeGreaterThan(0); const { stdout: cphPathOutput } = await execFileAsync("/usr/bin/which", ["cph"]); const cphBin = cphPathOutput.trim(); await access(cphBin, constants.X_OK); - // Keep CLAUDE_CODE_TMPDIR short enough for the SDK's AF_UNIX socket paths; - // otherwise the SDK deliberately falls back to shared /tmp. - const root = await mkdtemp("/tmp/cph-"); - roots.push(root); + // CI provisions this runner-owned root below /var/lib before dropping into + // no_new_privs. Keeping the fixture out of /tmp proves that an SDK-wide + // temp exception cannot make a cross-project escape look contained. + const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim(); + if (configuredTestRoot === undefined || configuredTestRoot === "") { + throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof"); + } + const root = await realpath(configuredTestRoot); + if (!root.startsWith("/var/lib/")) { + throw new Error(`CPH_SANDBOX_TEST_ROOT must be below /var/lib: ${root}`); + } + const nonce = randomUUID().replaceAll("-", ""); const workspaceRoot = join(root, "workspaces"); - const workspace = join(workspaceRoot, "org-a", "project-a"); - const sibling = join(workspaceRoot, "org-b", "project-b"); - const serviceSecret = join(root, "service-secrets", "platform.env"); + const workspace = join(workspaceRoot, "org-a", `project_${nonce}`); + const sibling = join(workspaceRoot, "org-b", `project_${randomUUID().replaceAll("-", "")}`); + const serviceSecret = join(root, `service-secret-${nonce}`); + roots.push(workspace, sibling, serviceSecret); await Promise.all([ mkdir(workspace, { recursive: true }), mkdir(sibling, { recursive: true }), - mkdir(join(root, "service-secrets"), { recursive: true }), ]); await Promise.all([ writeFile(join(workspace, "allowed.txt"), "allowed\n"), @@ -57,8 +69,14 @@ describe("real Claude SDK sandbox boundary", () => { const canonicalSibling = await realpath(sibling); const canonicalServiceSecret = await realpath(serviceSecret); const resultPath = join(canonicalWorkspace, "sandbox-result.txt"); + const cphVersionPath = join(canonicalWorkspace, "cph-version.txt"); + const effectiveTempProbePath = join(canonicalWorkspace, ".cph", "t", "effective-temp.txt"); + const denialLogPath = join(canonicalWorkspace, ".cph", "t", "denials.log"); const siblingEscapePath = join(canonicalSibling, "escape.txt"); - const unsandboxedEscapePath = join(await realpath(root), "unsandboxed-escape.txt"); + const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`); + const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`; + const hostVarTmpEscapePath = `/var/tmp/cph-host-temp-${nonce}`; + roots.push(unsandboxedEscapePath, hostTmpEscapePath, hostVarTmpEscapePath); setHostEnv({ CPH_BIN: cphBin, @@ -70,21 +88,32 @@ describe("real Claude SDK sandbox boundary", () => { const bashCommand = [ "set -eu", + `if printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 31; fi`, `test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`, - `if cat ${shellQuote(join(canonicalSibling, "secret.txt"))} >/dev/null 2>&1; then exit 21; fi`, - `if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>/dev/null; then exit 22; fi`, - `if cat ${shellQuote(canonicalServiceSecret)} >/dev/null 2>&1; then exit 23; fi`, + `if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); then exit 21; fi`, + `if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 32; fi`, + `if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`, + `mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`, + `cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`, + `test "\${TMPDIR-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, + `test "\${TMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, + `test "\${TEMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, + 'test "${CLAUDE_CODE_TMPDIR-unset}" = ".cph/t"', + `test "$(realpath "$TMPDIR")" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, + `printf 'workspace-temp\\n' > "$TMPDIR/effective-temp.txt"`, + `if printf 'host-temp\\n' > ${shellQuote(hostTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 33; fi`, + `if printf 'host-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; fi`, 'test "${DATABASE_URL-unset}" = unset', 'test "${FEISHU_APP_SECRET-unset}" = unset', 'test "${HUB_SESSION_SECRET-unset}" = unset', 'test "${ANTHROPIC_AUTH_TOKEN-unset}" = unset', 'test "${ANTHROPIC_API_KEY-unset}" = unset', - "cph --version | grep '^cph '", + `cph --version > ${shellQuote(cphVersionPath)}`, `printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`, ].join("\n"); - const bypassCommand = `printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)}`; - const stub = await startAnthropicStub(bypassCommand, bashCommand); + const stub = await startAnthropicStub(bashCommand); const streamEvents: StreamEvent[] = []; + const sdkStderr: string[] = []; try { const result = await runAgent({ prompt: "Run the supplied sandbox boundary probe.", @@ -110,20 +139,33 @@ describe("real Claude SDK sandbox boundary", () => { agentMessage: { create: async () => ({}) }, } as unknown as import("@prisma/client").PrismaClient, onStream: (event) => streamEvents.push(event), + onSdkStderr: (data) => sdkStderr.push(data), }); - expect(result).toMatchObject({ status: "completed" }); - expect(stub.requestCount()).toBeGreaterThanOrEqual(3); + expect( + result.status, + [result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"), + ).toBe("completed"); + expect(stub.requestCount()).toBeGreaterThanOrEqual(2); const toolResults = streamEvents.filter((event) => event.type === "tool-result"); - expect(toolResults).toHaveLength(2); - expect(toolResults[0]).toMatchObject({ isError: true }); - const sandboxedResult = toolResults[1]; + expect(toolResults).toHaveLength(1); + const sandboxedResult = toolResults[0]; expect(sandboxedResult?.type).toBe("tool-result"); if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result"); - expect(sandboxedResult.isError, sandboxedResult.result).toBe(false); + expect(sandboxedResult.isError, `${sandboxedResult.result}\n${sdkStderr.join("")}`).toBe(false); await expect(access(unsandboxedEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); - await expect(readFile(resultPath, "utf8")).resolves.toBe("sandbox-ok\n"); + const resultContents = await readFile(resultPath, "utf8").catch((error: unknown) => { + throw new Error(`sandbox result file missing after tool success:\n${sandboxedResult.result}`, { cause: error }); + }); + expect(resultContents).toBe("sandbox-ok\n"); + await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /); + await expect(readFile(effectiveTempProbePath, "utf8")).resolves.toBe("workspace-temp\n"); + const denialLog = await readFile(denialLogPath, "utf8"); + expect(denialLog).not.toContain("sibling-secret"); + expect(denialLog).not.toContain("platform-secret"); await expect(access(siblingEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(hostTmpEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(hostVarTmpEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(join(canonicalSibling, "secret.txt"), "utf8")).resolves.toBe("sibling-secret\n"); await expect(readFile(canonicalServiceSecret, "utf8")).resolves.toBe("platform-secret\n"); } finally { @@ -139,7 +181,7 @@ function setHostEnv(values: Readonly>): void { } } -async function startAnthropicStub(bypassCommand: string, sandboxedCommand: string): Promise<{ +async function startAnthropicStub(bashCommand: string): Promise<{ readonly baseUrl: string; readonly requestCount: () => number; readonly close: () => Promise; @@ -156,10 +198,11 @@ async function startAnthropicStub(bypassCommand: string, sandboxedCommand: strin }; requests++; const events = requests === 1 - ? bashToolEvents(bypassCommand, requests, true) - : requests === 2 - ? bashToolEvents(sandboxedCommand, requests, false) - : finalTextEvents(requests); + // Deliberately request the SDK's bypass flag. Production sets + // allowUnsandboxedCommands=false, so the flag must be ignored and the + // host-side escape sentinels must remain absent. + ? bashToolEvents(bashCommand, requests, true) + : finalTextEvents(requests); writeAnthropicStream(response, events); } catch (error) { response.writeHead(500, { "content-type": "application/json" }); diff --git a/hub/test/integration/deployment-preflight-cli.test.ts b/hub/test/integration/deployment-preflight-cli.test.ts index 25caa71..5bd41e7 100644 --- a/hub/test/integration/deployment-preflight-cli.test.ts +++ b/hub/test/integration/deployment-preflight-cli.test.ts @@ -128,6 +128,7 @@ describe("deployment preflight CLI", () => { expect(probeLog).toContain(workspaceRoot); expect(probeLog).toContain("service-setpriv\n"); expect(probeLog).toContain("service-bwrap\n"); + expect(probeLog).toContain("service-socat\n"); expect(probeLog).toContain("service-prisma\n"); expect(probeLog).toContain("service-database\n"); }); @@ -141,6 +142,7 @@ describe("deployment preflight CLI", () => { const binDir = join(root, "bin"); const cphBin = join(binDir, "cph"); const bwrapBin = join(binDir, "bwrap"); + const socatBin = join(binDir, "socat"); const pgIsReadyBin = join(binDir, "pg_isready"); const runuserBin = join(binDir, "runuser"); const setprivBin = join(binDir, "setpriv"); @@ -155,6 +157,7 @@ describe("deployment preflight CLI", () => { await Promise.all([ executable(cphBin, `#!/bin/sh\nprintf 'cph\\n' >> '${options.marker}'\nprintf 'cph 0.0.2\\n'\n`), executable(bwrapBin, `#!/bin/sh\nprintf 'service-bwrap\\n' >> '${options.marker}'\nexit 0\n`), + executable(socatBin, `#!/bin/sh\nprintf 'service-socat\\n' >> '${options.marker}'\nexit 0\n`), executable( runuserBin, `#!/bin/sh\nprintf '%s\\n' "$*" >> '${options.marker}'\n[ "$1" = "-u" ] || exit 91\nshift 2\n[ "$1" = "--" ] || exit 92\nshift\nexec "$@"\n`, @@ -226,6 +229,8 @@ describe("deployment preflight CLI", () => { options.workspaceRoot, "--bwrap-bin", bwrapBin, + "--socat-bin", + socatBin, "--pg-isready-bin", pgIsReadyBin, ], diff --git a/hub/test/unit/agent-diagnostics.test.ts b/hub/test/unit/agent-diagnostics.test.ts new file mode 100644 index 0000000..5a3aced --- /dev/null +++ b/hub/test/unit/agent-diagnostics.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAgentSdkStderrSink } from "../../src/agent/diagnostics.js"; + +describe("agent SDK stderr diagnostics", () => { + it("redacts a credential split across chunks and correlates the log", () => { + const warn = vi.fn(); + const sink = createAgentSdkStderrSink({ + logger: { warn }, + runId: "run-1", + projectId: "project-1", + sensitiveValues: ["provider-secret"], + }); + + sink.write("sandbox startup token=provider-"); + sink.write("secret\nremaining diagnostic"); + sink.flush(); + + const serialized = JSON.stringify(warn.mock.calls); + expect(serialized).not.toContain("provider-secret"); + expect(serialized).toContain("[REDACTED]"); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ runId: "run-1", projectId: "project-1" }), + "agent SDK stderr", + ); + }); + + it("bounds line length, line count, and an unterminated line", () => { + const warn = vi.fn(); + const sink = createAgentSdkStderrSink({ + logger: { warn }, + runId: "run-2", + projectId: "project-2", + sensitiveValues: [], + }); + + sink.write(`${"x".repeat(3_000)}\n`); + sink.write(Array.from({ length: 30 }, (_, index) => `line-${index}`).join("\n")); + sink.flush(); + + expect(warn.mock.calls.length).toBeLessThanOrEqual(21); + const diagnostics = warn.mock.calls + .map(([bindings]) => (bindings as { sdkStderr?: string }).sdkStderr) + .filter((value): value is string => value !== undefined); + expect(diagnostics.every((value) => value.length <= 2_000)).toBe(true); + expect(warn.mock.calls.some(([, message]) => String(message).includes("limit reached"))).toBe(true); + }); +}); diff --git a/hub/test/unit/agent-security.test.ts b/hub/test/unit/agent-security.test.ts index 4574eff..9c49d7f 100644 --- a/hub/test/unit/agent-security.test.ts +++ b/hub/test/unit/agent-security.test.ts @@ -46,7 +46,7 @@ describe("agent subprocess security policy", () => { expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET"); expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); - expect(policy.env.CLAUDE_CODE_TMPDIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); + expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t")); expect(policy.sandbox).toMatchObject({ enabled: true, @@ -81,6 +81,24 @@ describe("agent subprocess security policy", () => { })).rejects.toThrow("unsupported provider environment variable: DATABASE_URL"); }); + it("keeps the short SDK socket directory inside the project workspace", async () => { + const { workspaceRoot, workspace } = await makeWorkspace(); + const policy = await createAgentSecurityPolicy({ + workspaceRoot, + workspaceDir: workspace, + hostEnv: { PATH: "/usr/bin:/bin" }, + }); + + expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t")); + const absoluteTemp = join(await realpath(workspace), ".cph", "t"); + expect(policy.env.TMPDIR).toBe(absoluteTemp); + expect(policy.env.TMP).toBe(absoluteTemp); + expect(policy.env.TEMP).toBe(absoluteTemp); + expect(policy.sandbox.filesystem.allowWrite).toEqual([await realpath(workspace)]); + expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]); + expect(policy.sandbox.filesystem.allowRead).not.toContain(expect.stringMatching(/^\/run\//)); + }); + it("rejects a project workspace whose real path escapes the configured workspace root", async () => { const { root, workspaceRoot } = await makeWorkspace(); const outside = join(root, "outside");