forked from EduCraft/curriculum-project-hub
79f72ecca8
Add full skill lifecycle to the org-admin web surface: create, read, edit, disable. Skills are directories (SKILL.md manifest + supporting files), content-addressed by SHA-256 in an immutable store. Backend: - skillStore: extract commitSkillContent (shared populate→inspect→ dedup→atomic rename); add importSkillFromFiles (in-memory file list ingestion) and readSkillFiles (read stored version back as UTF-8) - configuration: add installSkillFromFiles, readSkillFiles, disableSkill (soft-delete + archive bound role sessions), updateSkillDescription (label-only, no archival); refactor installSkill to share commitInstalledSkill - agentConfigRoutes: wire skillStoreRoot; add GET /agent-skills/:name/files, PUT /agent-skills/:name (create/replace), PATCH /agent-skills/:name (description/disable) - orgRoutes: pass readSkillStoreRoot() to agent config routes Frontend: - api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods - SkillEditor.svelte: file tree + text editor + version/description form - skills/+page.svelte: skill list, create form (generates SKILL.md template), per-skill editor - layout: add 技能 nav item ADR-0018: update Decision to reflect web surface joining host-console CLI in the shared content-addressed ingestion pipeline. Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web installation is one implementation of it.
212 lines
12 KiB
Markdown
212 lines
12 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 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
|
||
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
|
||
emits an error and exits rather than running unsandboxed.
|
||
- `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the
|
||
SDK's `dangerouslyDisableSandbox` input. Claude Code 2.1.202 does not enforce
|
||
that option reliably, so a host-side `PreToolUse` hook also denies every
|
||
Bash request whose input explicitly sets `dangerouslyDisableSandbox: true`
|
||
before a process can start.
|
||
- `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical
|
||
current workspace and a small named system-runtime set — normal reads stay
|
||
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]` confines persistent host
|
||
effects to the canonical ADR-0007 workspace. Config/cache/home stay beneath
|
||
`.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all
|
||
point at the workspace-local `.cph/t`. The workspace allocator uses stable
|
||
compact Organization/Project path segments, deployment requires a short
|
||
workspace root, and the canonical temp prefix fails fast above 56 bytes so
|
||
the SDK can append randomized `socat` bridge socket names without exceeding
|
||
Linux `sockaddr_un.sun_path`. Bubblewrap shadows non-allowlisted host trees
|
||
with disposable tmpfs mounts: a shell write there may succeed inside that
|
||
private namespace, but it cannot mutate the corresponding host path. The
|
||
Linux proof checks host state after the sandbox exits.
|
||
- 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.
|
||
`sandbox.credentials` denies provider token variables inside Bash and denies
|
||
configured credential files. `CPH_SANDBOX_EXTRA_DENY_READ` names additional
|
||
deployment-specific secret paths on the Hub side only.
|
||
- `settingSources: []` and strict MCP configuration prevent an untrusted
|
||
workspace or service-user config from widening tools, hooks, MCP servers, or
|
||
sandbox paths.
|
||
- Agent skills are Organization-scoped runtime configuration, not Hub release
|
||
assets. Skill content is imported into a content-addressed persistent store
|
||
through a shared ingestion pipeline (`importSkillDirectory` for host-console
|
||
CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
|
||
safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
|
||
symlink rejection, SHA-256 content addressing. The web surface
|
||
(`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
|
||
store as the host-console CLI (`installSkill`); both flow through
|
||
`commitSkillContent` for deduplication and atomic rename into
|
||
`versions/<digest>/`. Org-admin authentication gates the web surface; the
|
||
content-addressed store remains platform-controlled. A role selects enabled
|
||
Organization skills alongside its model, system prompt and tool allowlist.
|
||
Each run copies only those selected immutable versions into a run-scoped
|
||
plugin outside the project workspace; the sandbox exposes that snapshot
|
||
read-only and deletes it after the run. SDK-bundled skills and filesystem
|
||
setting sources remain disabled, so project `.claude` content cannot register
|
||
skills or widen tools. Requested skill versions are recorded on
|
||
`run.created`; SDK initialization remains authoritative loading evidence.
|
||
- 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.
|
||
- **Named system-runtime read set.** The implementation allows only the
|
||
platform runtime directories/files needed for shell, dynamic libraries,
|
||
fonts, TLS/DNS and `cph` execution in addition to the project workspace.
|
||
Expanding that set requires an explicit reviewed code change. Deployments
|
||
name credential files with `CPH_SANDBOX_EXTRA_DENY_READ`; these paths are
|
||
protected through the SDK credential layer and are not passed to the Agent
|
||
environment. The exact runtime set remains plumbing and is not spec-pinned.
|
||
- **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).
|
||
|
||
## Related Decisions
|
||
|
||
- **Rate limiting and token usage.** ADR-0022 requires multi-dimensional hard
|
||
request limits while keeping token thresholds as attributed soft alerts in
|
||
the initial release.
|
||
- **Inbound file limits.** ADR-0022 requires hard file, attachment, archive,
|
||
Project-storage, and Organization-storage limits with explicit failure and
|
||
partial-file cleanup.
|