forked from EduCraft/curriculum-project-hub
ecbc2c8666
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 更新。
167 lines
9.2 KiB
Markdown
167 lines
9.2 KiB
Markdown
# 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 `@Claude`s: 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 `curl`s 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.
|