Files
curriculum-project-hub/docs/adr/0018-agent-execution-surface-bounded-by-workspace.md
T
hongjr03 ecbc2c8666 fix(hub): 对齐 ADR-0018 agent 执行面边界
runner.ts: 启用 SDK 内置沙箱(bubblewrap/seatbelt),写入限制在 workspace,
denyRead 敏感路径,failIfUnavailable 硬拒非沙箱运行。bypassPermissions 保留
(headless 无交互),沙箱是硬边界而非权限提示层。

install_service.sh: 默认 service user 从 root 改为 cph-hub;env 模板修正
OPENROUTER_API_KEY → ANTHROPIC_AUTH_TOKEN/ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY
(与 server.ts 实际读取一致);补 __BASE_DIR__ sed 替换。

cph-hub.service: AssertPathExists=/usr/bin/bwrap 强制沙箱依赖;NoNewPrivileges。
不加 ProtectSystem/ProtectHome(会破坏 cph render-cache 与 bwrap user-namespace)。

trigger.ts: 权限闸门去掉 if (senderOpenId !== '') 短路——缺 open_id 一律
fail-closed 拒绝,不再静默跳过;role gate 同步去掉冗余守卫(已由上游保证)。
顺手把 JSON.parse(msg.content) 的 inline cast 换成 Zod schema parse
(FileMessageContentSchema / TextMessageContentSchema)。

ADR-0018: 状态改为 Accepted+implemented,机制段写定 SDK 沙箱,网络决定为开放,
Open Questions 更新。
2026-07-08 13:11:14 +08:00

9.2 KiB
Raw Blame History

ADR 0018: The Agent Execution Surface Is Bounded By The Project Workspace

Status

Accepted, and implemented. The mechanism is settled: the Claude Code SDK's built-in sandbox (bubblewrap on Linux, seatbelt on macOS) confines the agent process and its Bash subprocesses at the OS level. bypassPermissions remains (headless server — no interactive prompts), but the sandbox is the hard boundary, not the permission-prompt layer. Constitution rule 4 divergence (ADR-0017's unbounded tools) is resolved.

Builds on ADR-0001 (project group as collaboration space), ADR-0002 (project lock scoped to run_id), ADR-0004 (permission grants govern triggerAgent), ADR-0007 (the engineering file is a directory tree on disk), and ADR-0017 (Claude Code SDK as the agent runtime).

Context

ADR-0001/0002/0004 together cover collaboration governance up to the moment a teacher @Claudes: who may trigger an AgentRun, that the run holds a project-scoped lock, that the group is a long-lived space. They stop at triggerAgent. What the agent may do after the trigger — which files it may read or write, what commands it may run — is not named in any ADR, not modeled in spec/, and not enforced by any contract-level invariant.

The gap is not theoretical. ADR-0017 adopted the Claude Code SDK with allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"] and permissionMode: "bypassPermissions". Under that configuration the SDK's built-in tools operate over the whole host filesystem with no confinement, and Bash executes arbitrary shell with no approval gate. The Hub's hub/src/agent/workspace.ts was intended to be "the agent's only file surface" (its module doc says so) and ships a confine() path validator that rejects .. and absolute escapes. But ADR-0017's cutover left those custom tools unwired — the SDK's built-in tools replaced them — so confine() is dead code and the boundary it was meant to enforce does not exist at runtime. Compounding this, the systemd unit's default SERVICE_USER is root (hub/deploy/install_service.sh), so the unbounded agent runs as root.

This is a real divergence point by the constitution's test: "if unwritten, will the developer and the agent make different assumptions?" They already have. ADR-0017 assumed the boundary was an implementation concern; the workspace.ts author assumed it was a contract. The next person to touch the runner has nothing telling them the boundary must be enforced, and ADR-0017 silently overrode the one that existed.

Decision

The agent execution surface is bounded by the run's project workspace

During an AgentRun, every file operation the agent performs — read, write, glob, grep target — must fall within the run's project workspace directory (the on-disk engineering-file tree of ADR-0007). Operations whose target path escapes that tree are refused. This is a platform core invariant, pinned in Spec.System.AgentSurface.

The boundary is per-run, anchored to the run's project, the same scope as the Lock (ADR-0002). This keeps the two invariants composable:

  • Lock (ADR-0002) bounds concurrency — who may be modifying the project.
  • AgentSurface (this ADR) bounds blast radius — what the agent may touch.

Both scope to run × project. Acquiring the lock and entering the surface happen together at run start; releasing the lock and leaving the surface happen together at run termination.

Shell execution is subject to the same boundary, by effect

A shell command the agent runs executes with the workspace as its working directory (already true for the cph subprocess, ADR-0016) and its file effects — files read, written, created, or deleted — are subject to the same workspace boundary. The agent may invoke named external tools whose binaries live outside the workspace (notably cph via CPH_BIN); the boundary governs file effects, not binary location. Arbitrary cat /etc/shadow or curl … > /etc/... is out of bounds regardless of how it is invoked.

The Hub process runs as a non-privileged service user

The systemd unit must not run as root. The default in hub/deploy/install_service.sh changes from root to a dedicated unprivileged user. This is a deployment invariant, recorded here because the unbounded-agent risk is compounded by root; it is enforced in the unit, not in spec/ (it is not a semantic divergence between developer and agent — it is operational 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 hub/src/agent/runner.ts:

  • sandbox.enabled: true + failIfUnavailable: true — the agent process and its Bash subprocesses run sandboxed; if the sandbox can't start, query() emits an error and exits rather than running unsandboxed.
  • sandbox.filesystem.allowWrite: [workspaceDir] — writes confined to the workspace (the ADR-0007 directory tree). Reads outside are allowed by default (the agent may read system files for context), but sensitive host paths are denied via denyRead (/etc, /root, ~/.ssh, ~/.aws, …).
  • sandbox.filesystem.denyRead: SENSITIVE_READ_PATHS — defense-in-depth; extends via CPH_SANDBOX_EXTRA_DENY_READ for deployment-specific secrets.
  • Network: open (see Open Questions).

bypassPermissions is kept (headless server — no interactive prompts); the sandbox is the hard boundary, not the permission-prompt layer. This is subprocess-level, not process-level: the Hub host process is not sandboxed (it manages DB, Feishu ws, logs), only the Claude Code agent process and its children. This keeps the sandbox config surface small and lets future external CLIs (Gitea, Lark) run inside the sandbox by giving their binaries read access, without widening the Hub's own filesystem surface.

The contract pins the invariant (operations stay in the workspace), not the mechanism. The SDK sandbox is the current mechanism; switching to a different one (tool wrappers, container) would not be a spec change as long as it upholds AgentFileOp.Authorized.

Consequences

  • Spec.System.AgentSurface gains an AgentFileOp structure and an Authorized predicate mirroring Memory.McpReadRequest.Authorized: the op carries a run and a path; Authorized holds iff the path falls within the run's workspace, via platform-provided runWorkspace and pathWithin (representations OPEN).
  • hub/src/agent/workspace.ts's confine() intent is now contract-backed. Re-wiring it (or replacing it with an OS sandbox) is mandatory, not a preference. Its current dead-code status under ADR-0017 is a divergence to be addressed, not silently retained.
  • ADR-0017's bypassPermissions + unrestricted built-in tools is a divergence from this ADR and is surfaced as such (constitution rule 4). The reconciliation — restrict the SDK tool set, wrap Bash, or sandbox at the OS level — is the open mechanism question above. Whichever is chosen must uphold the AgentSurface invariant.
  • Inbound file/image delivery (hub/src/feishu/trigger.ts) lands files under ${workspaceDir}/.cph/inbox/, which is inside the boundary — consistent, no change to the delivery path.
  • The cph subprocess (ADR-0016) already runs with cwd = workspaceDir (hub/src/agent/cph.ts), so it is inside the boundary by construction.

Open Questions / Deferred

  • Network egress. Decided: network is open in the initial production deployment. The sandbox confines filesystem writes to the workspace and denies reads of sensitive host paths, but does not restrict outbound network. Rationale: future Gitea/Lark CLI integration needs unconstrained network egress; cph build may fetch typst packages. The exfiltration risk (agent curls workspace content out) is accepted as a tradeoff for CLI integration simplicity. This is revisitable — a network allowlist (sandbox.network.allowedDomains) can be added without a spec change if the threat model tightens.
  • Sensitive read path coverage. SENSITIVE_READ_PATHS in runner.ts covers /etc, /root, /var/log, /proc, /sys, and common credential dirs (~/.ssh, ~/.aws, ~/.config/gcloud, ~/.gnupg). Deployments with additional secret locations extend via CPH_SANDBOX_EXTRA_DENY_READ (colon-separated). Whether this set should be spec-pinned rather than implementation-chosen is OPEN — it's plumbing, not a semantic divergence, so currently left out of spec/.
  • Shell command vocabulary. Whether the agent's Bash is restricted to a whitelist (e.g. cph, ls, grep) or allowed arbitrary commands whose effects are then confined, is a policy choice left to implementation. The invariant governs effects either way; current choice is arbitrary commands (sandbox confines effects).
  • Per-user rate limiting and token budgets. Orthogonal to the surface boundary (this ADR is about what the agent may touch, not how often it may run). Deferred to a separate concern.
  • Inbound file size / MIME limits. The inbox lives inside the boundary, but unbounded file size is a DoS vector distinct from the boundary invariant. Deferred.