# The boundary model — reproducing Cowork's *limitations*

The point of this harness is not that a skill *can* do something — it's that a skill is **constrained the same way Cowork constrains it**. A test that's more permissive than Cowork produces **false passes**: a skill that works in the harness but breaks in production because it read a host folder that wasn't mounted, or tried to reach the network directly instead of through MCP.

This page describes the limitations the harness reproduces, how each tier enforces (or doesn't) them, and how to verify it.

> **Verify it in one command:** `cowork-harness boundary-check` proves the **L1 Docker** sandbox actually
> enforces these limits (sealed FS, default-deny egress) — no token, no model. It probes the `container`
> path; the `microvm` guest-iptables firewall is **not** exercised here. `hostloop`'s agent process is a
> native host spawn (see below) — the container sandbox this check proves out is the VM sidecar its
> `bash`/`web_fetch` route into, not a sandbox around the agent's own file tools. The rest of this page is
> the *why* and the per-tier detail.

## The three limitations that matter

| Limitation | In Cowork | Why a skill must respect it |
|---|---|---|
| **Sealed filesystem** | The agent runs in a microVM; it sees only mounted folders (`mnt/uploads`, work folders at `mnt/<folder-name>` — the collision-resolved basename, at `mnt/.projects/<name>` on Desktop older than 1.14271.0; plugins under `.local-plugins/…`, `mnt/outputs`). No `~/`, no `/Users`, no arbitrary host path. | A skill that hard-codes a host path works on your laptop and fails in Cowork. |
| **Default-deny egress** | `vm_network_mode: "gvisor"` with a compiled domain allowlist; off-list hosts are rejected. | A skill that calls an un-allowlisted API silently fails for real users. |
| **Cross-boundary only via MCP** | The VM shell is sealed; reaching host resources/services goes through MCP servers (which Cowork runs host-side). | A skill that shells out to a host tool instead of an MCP server won't have that tool in Cowork. |

## How each tier enforces the boundary

| | `protocol` (L0) | `container` (L1, default) | `microvm` (L2) |
|---|---|---|---|
| Sealed filesystem | ❌ none (runs on host) | ✅ only bind-mounts visible | ✅ only mounts visible |
| Default-deny egress | ❌ none | ✅ `internal` network + allowlist proxy | ✅ guest iptables default-deny + allowlist proxy |
| MCP-only crossing | ⚠️ not enforced | ✅ no host FS/procs; egress via proxy only | ✅ VM boundary |
| Escape resistance (untrusted code) | ❌ | ⚠️ container-grade | ✅ VM-grade |

`hostloop` and `cowork` aren't columns here: `hostloop`'s boundary model is a split (software
path-containment for its native file tools, the same container/VM egress path as above for
`bash`/`web_fetch`) that doesn't reduce to one cell per row, and `cowork` isn't a boundary model of its
own — it resolves to `hostloop` or `container` at run time. Both are covered in full below.

**`protocol` runs on the host with no sandbox.** It's for fast logic iteration only. The CLI **refuses** to "pass" any scenario that asserts boundary behavior (`egress_denied`, `egress_allowed`, `expect_denied`) at this tier — that would be a false pass. Use it for "did the skill produce the right output / answer the question correctly," not "does the skill respect the boundary."

**`container` is the default and reproduces all three constraints:**

- *Filesystem*: a container only sees its own image plus explicit bind mounts. It physically cannot read `~/` or `/Users` — the same sealed view as the VM. The harness mounts **only** what the session setup declares.
- *Egress*: each run is placed on a **per-run** Docker network marked `internal: true` (no route off-box; see the lifecycle note below) and only the egress proxy is dual-homed onto an external network. So the agent can reach **only** allowlisted hosts, and **only** through the proxy. Direct/raw egress is impossible — not merely discouraged. (`docker/compose.yml` is a standalone reference for this shape; the live harness creates the networks per-run in `src/egress/sidecar.ts` — it does not invoke compose.)
- *Privileges*: `--cap-drop ALL`, `--security-opt no-new-privileges`, read-only rootfs + tmpfs. A skill can't escalate or persist outside the mounts. (Toggle with `COWORK_LOCKDOWN=off` for debugging; leave it on for parity.)

> **Egress-network lifecycle (operational).** On `container`/`hostloop`, each run creates a **per-run** pair
> of Docker networks (`cowork-int-<id>` / `cowork-out-<id>`) plus an egress-proxy container, and **reaps all
> three on every exit** — success, exception, agent crash, or unanswered gate — and on **Ctrl-C** (a
> `SIGINT`/`SIGTERM` handler reaps in-flight runs before exiting). The only path that can orphan them is a
> hard `SIGKILL`/`kill -9`; clean those with `docker network prune` (or `docker network rm cowork-int-*
> cowork-out-*`). There is **no hard concurrency ceiling**, but each concurrent run consumes one internal +
> one external network from Docker's address pool, so very high parallelism can hit `all predefined address
> pools have been fully subnetted` (the harness re-frames that error with this guidance) — widen the daemon
> `default-address-pools` if you need more. (`microvm` uses a host-port proxy + Lima VM, not Docker
> networks; run `cowork-harness vm prune` (see `vm --help`, or the microvm tier section in [scenario.md](./scenario.md)) for guest cleanup.)

**`microvm`** adds VM-grade escape resistance for untrusted code. Its egress is the **same default-deny allowlist proxy as `container`**, enforced by a guest iptables firewall — **no gVisor netstack**. Use it when you're testing isolation of code you don't trust.

**`hostloop`** reproduces Cowork's real host-loop architecture: the agent LOOP itself is a **native process spawned directly on the host** — Read/Write/Edit/Glob/Grep run with NO container around them, matching production exactly (Desktop stages this same native macOS binary alongside the Linux/arm64 ELF the other tiers use). Only `bash`/`web_fetch` route into a Docker "VM" sidecar via MCP (`bash` via `docker exec`; **`web_fetch` is host-routed**, `curl` on the host, by design — Cowork fetches via the host API (gate `coworkWebFetchViaApi`, binary-verified), not the container egress path). So a web_fetch `egress_*` entry reflects host reachability + the web-fetch allowlist/provenance, **not** the container egress boundary; only `bash` egress exercises the sandbox proxy.

Because the native file tools run with no OS sandbox, hostloop's filesystem boundary is a **software check**, not a container/VM wall: a PreToolUse hook (a byte-faithful port of production's own containment check) denies any Read/Write/Edit/Glob/Grep/MultiEdit whose resolved path falls outside the session's mounted roots — outputs (rw, the agent cwd) · uploads (read-only: hardlink write-block) · spooled projects (read-only) · staged skills + plugin copies (read-only: write a modified copy under outputs) · connected rw folders — this is production's OWN security model for host-loop, not something weaker the harness substitutes. A run-end tripwire hard-fails if a gated tool call ever completes with no evidence the hook fired (version-skew insurance, not doubt about the currently-pinned binary). A `hostloop` scenario with a **writable** connected folder therefore gives the native process genuine host filesystem access and requires explicit consent (`allow_host_writes: true` in a `run` scenario, or `--allow-host-writes` for `chat`/`skill`/`critique`) — the same consent a real user gives by clicking "connect folder" in Desktop, made visible in committed scenario YAML. Connected folders are bind-mounted (never copied) into both the native process's view and the VM sidecar's view — one set of bytes, matching production, which is what lets a hardcoded VM-absolute path in a skill fail here exactly as it fails in real Cowork.

**`cowork`** is not a sandbox of its own: it resolves at run time to either `hostloop` or `container` — the same choice real Cowork makes, read from the synced baseline's GrowthBook host-loop gate (`1143815894`). (An org policy `requireCoworkFullVmSandbox` forces the VM loop and *overrides* the gate.) The two resolved tiers do **not** share one boundary model: `container` seals the agent's file tools inside the container wall; `hostloop` runs them natively on the host under the software path-containment gate described above. A scenario authored with `fidelity: cowork` should not assume which one it lands on for filesystem-boundary assertions — pin `hostloop`/`container` explicitly if the distinction matters to the test.

### Where a sub-agent (or the main agent) writes deliverables — tier-qualified, no single literal form

The path a dispatched sub-agent (or the main loop) should write outputs to differs by tier — a skill
that hardcodes one relative form is only portable to the tier it was authored against:

- **`hostloop`**: the agent's file-tool cwd IS the host outputs dir, so cwd-relative `artifacts/<...>`
  is the stable contract. `/sessions/...` is **denied unconditionally** by the path gate above — it
  doesn't exist on this tier's host filesystem.
- **`container` / `microvm`**: the agent cwd is `/sessions/<id>`, there is no path gate, and
  `/sessions/...` is a **valid** path there. The outputs mount is `mnt/outputs`, so the deliverable
  path is cwd-relative `mnt/outputs/artifacts/<...>` or absolute
  `/sessions/<id>/mnt/outputs/artifacts/<...>`. A bare `artifacts/<...>` on this tier resolves to
  `/sessions/<id>/artifacts` — sandbox scratch outside the outputs mount, not denied but silently
  non-deliverable.

See [subagents.md](./subagents.md) for the full capability/path matrix (uploads, plugin content,
spooled tool results, connected folders, delete semantics, `${CLAUDE_PLUGIN_ROOT}`) and the sub-agent
tool-composition rules that sit alongside this path contract.

## Verifying the boundary holds

The harness validates its **own** faithfulness — don't take our word for it:

```bash
cowork-harness boundary-check          # uses the latest platform baseline
```

To verify the sandbox still enforces boundaries **given a session's egress widening**, pass that session:

```bash
cowork-harness boundary-check --session ./sessions/with-github.yaml
```

`--session` loads the session YAML and folds its egress additions (`egress.extra_allow` / `egress.unrestricted`) into the allowlist the probes test against — so the `allowlist-permits` / `allowlist-enforced` checks reflect the same allowlist a scenario using that session would run under.

You can pin a specific baseline by passing it as a positional (`boundary-check <baseline>`; default is the latest platform baseline), and emit machine-readable results for CI with `--output-format json`.

This runs probes (independent of any agent) and asserts each constraint:

```
Boundary parity: ALL CONSTRAINTS ENFORCED
PASS  host-fs-sealed         — host paths (/Users, /host) invisible
PASS  direct-egress-denied   — no route to internet without proxy
PASS  allowlist-enforced     — off-list host refused by proxy
PASS  allowlist-permits      — allowlisted host reachable via proxy
PASS  loopback-not-proxied   — loopback bypasses the egress proxy (control: proxied without the exemption)
PASS  hostloop-bash-egress   — host-loop bash reaches the allowlist through the proxy (not a dead network)
```

**The allowlist is a public-egress filter, not a loopback firewall.** It governs what leaves the
sandbox; it does not stand between a process and its own `localhost`. The sandbox therefore sets
`NO_PROXY=localhost,127.0.0.1,::1` alongside the proxy vars, so a skill that starts a local server and
curls it talks to *its own* server rather than getting a 403 from the proxy container. The fifth probe
pins this, and carries a positive control: it re-runs the same request without the exemption and
requires the 403, so the check cannot pass merely because nothing was proxied at all.

Two consequences worth knowing. Loopback requests never reach the proxy, so they log no `deny` row — an
`egress_denied` assertion against `localhost` has no evidence to find. And `NO_PROXY` matching is by
hostname label, so a `*.localhost` subdomain is exempted too (RFC 6761 reserves those to loopback).
Neither changes reachability: the sandbox sits on an `internal` network with no route off-box.

The sixth probe covers `hostloop` specifically. There, only `bash` routes into a container, and its
egress env is built by the same function the runtime spawns with — so the probe fails if that sidecar
ever loses its proxy again. It asserts **both** halves: an allowlisted host must be reachable *and* an
off-list host refused. The allowlisted half is the load-bearing one — a sidecar with no egress at all
also refuses everything, which is indistinguishable from enforcement if you only check denials.

Run it in CI before your scenario suite so a Docker/network misconfiguration that *weakens* the sandbox fails loudly instead of silently turning your tests into false passes.

## Writing limitation tests

Two complementary styles:

1. **Implicit** — just run your real scenario at `container` fidelity. If the skill tries to step outside the boundary, it fails the same way it would in Cowork, and your behavioral assertions catch the fallout.
2. **Explicit negative tests** — assert the boundary actively blocks something:

```yaml
# the skill should reach GitHub (allowlisted) but never example.com
session: ./sessions/with-github.yaml   # egress.extra_allow: ["api.github.com"]
expect_denied: ["example.com"]
assert:
  - egress_allowed: api.github.com
  - egress_denied: example.com
```

For "must use MCP, not a host tool," give the session an MCP server for the capability and **omit** the host tool; if the skill assumed the host tool, it fails — exactly as in Cowork.

## Known fidelity gaps

For the full catalog of what the harness deliberately does NOT reproduce vs real Cowork, see [fidelity-gaps.md](./fidelity-gaps.md). The gaps most relevant to the limitations model are:

- At `container` tier, stdio **MCP servers run alongside the agent**, whereas Cowork runs them host-side (split execution). This host/VM split is **not reproduced at any tier** — `microvm` runs MCP inside the guest too — so a skill that depends on it (e.g. an MCP server reaching host-only resources) is an unreproduced gap. See [discovery.md](./discovery.md).
- The `container`, `microvm` **and `hostloop`** egress boundary is a proxy + firewall, not a kernel gVisor netstack. Allow/deny is decided per domain against the pinned allowlist, the way production decides it; raw-packet behavior is not. (Whether that list matches production's own server-delivered set is a separate question — the baseline's `network.$comment` flags four entries as unverified.) (`hostloop` is on this list for its `bash` sidecar, which shares the proxy; its native file tools have no container around them at all.) A client that ignores proxy env — a raw socket, a Go binary, `node`'s built-in `fetch` — therefore reaches **nothing** rather than escaping: containment comes from the `internal` network with no route off-box, not from the env vars. That is stricter than production, not looser.
- The sandbox is a **fidelity fixture, not a security boundary** against malicious code — see [SECURITY.md](../SECURITY.md).
- **Read-only mounts ARE enforced; outputs delete-deny is still post-hoc.**
  - `mode:r` mounts (uploads = asar `'ro'`; local/remote plugins) get a per-mount nested `:ro` bind on the Docker tiers, so a write to them fails in the guest — matching Cowork.
  - **But** the `rw`/`rwd` distinction (write-but-no-delete on `mnt/outputs/` + connected folders) is NOT yet enforced at the mount: Cowork denies deletes there (`rm` fails `Operation not permitted`; a skill may request approval via `allow_cowork_file_delete` and then delete), whereas the harness mounts those writable, so `rm` **succeeds** and the violation is caught afterward by a post-hoc assertion — `no_delete_in_mounts` for connected folders (`no_delete_in_outputs` covers `outputs/` only) (the `scanEvents` scanner covers both native `Bash` and host-loop `mcp__workspace__bash`).
  - **What production actually denies** (probed directly against a real outputs mount with raw syscalls, in a folder-connected session and a folder-less one — both agree): outputs is a **FUSE** mount (`user_id=0,group_id=0,default_permissions,allow_other`) on its own device. `unlink` and `rmdir` fail `EPERM`, and the denial nests into subdirectories. **Nothing else is denied** — `truncate(f,0)`, `open(f,"w")` (`O_TRUNC`), a statement-leading `> f`, renaming *within* outputs, and renaming *onto an existing destination* all SUCCEED, even though the last two remove a name and destroy content. Renaming *out of* outputs fails `EXDEV` at the VFS layer (separate device), and GNU `mv`'s copy-then-unlink fallback then hits the same `EPERM`, so a move out is genuinely blocked. The approval path is a live re-mount of the folder as `rwd`, persisted on the session, so it also applies to subsequent spawns.
  - The harness's scanner mirrors exactly that set: it flags operations that UNLINK a name (`rm`/`unlink`/`rmdir`, `shred -u`, `find … -delete`, the python equivalents) and does **not** flag in-place emptying (`truncate`, `> file`, bare `shred`), which production permits.
  - A skill that relies on the live `EPERM` (catches it → requests approval, or overwrites-in-place) behaves differently.
  - A faithful per-mount delete-deny (FUSE/overlay + the approval-unblock path) is the separate planned FUSE/overlay sub-project.
  - The `no_delete_in_outputs` detector parses `mv` direction (a move *into* `outputs/` is no longer mistaken for a delete) and, for the `rm` family, the actual delete TARGET per statement — this mirrors production's own enforcement, which gates on the resolved target's mount, not command-text co-occurrence.
  - A delete is suppressed only when every target is *provably* outside outputs: an absolute/relative path clear of `outputs/`, or a path under a safe prefix. `/tmp/` and the literal `$TMPDIR`/`${TMPDIR}` idiom are safe by default (including a `VAR=$(mktemp …)`-sourced `$VAR`); an operator can extend that set with `COWORK_HARNESS_SAFE_STAGING_PREFIX=/your-scratch` (comma-separated for several).
  - Unresolved/command-substituted delete targets (other than the recognized `mktemp` idiom) still always flag.
- **`hostloop`'s `uploads` mount is writable by the native file tools only via the same hardlink-write-block message production itself gives.** Production's PreToolUse hook denies a native `Write`/`Edit`/`MultiEdit` under `uploads` with a read-only message — a task-session upload is a hardlink to the user's original file on disk, so production blocks writing it in place (chat sessions get a distinct "write a modified copy into scratch" wording instead). This harness's gate reproduces that same block, verbatim-in-meaning, for the native file tools; `bash`'s Docker `:ro` bind enforces the identical restriction on the VM sidecar side. The mount is still READABLE (containment allows it), matching production.
- **`hostloop`'s path-containment gate replicates a real hole in production's own resolver, by design.** A NEW file **two** levels deep under a symlinked parent (e.g. `allowed/esc/sub/new.txt` where `sub` doesn't exist) resolves the parent's realpath failure as ENOENT and falls back to the lexical path — allowed, even though a parent-creating `Write` would actually follow the symlink outside the root. Per this project's fidelity-first principle, the port replicates production's own behavior here rather than "fixing" it into something stricter than the real product.
- **Identity + gated env vars are absent; the two host-derived platform keys are emitted.** Real Cowork sets identity + gated keys (`CLAUDE_CODE_ACCOUNT_UUID`/`_USER_EMAIL`/`_ORGANIZATION_UUID`, `CLAUDE_CODE_SUBAGENT_MODEL`, `ENABLE_TOOL_SEARCH`); the synced baseline does not carry their *values* (the gates are opaque state strings, not a key→value map), so injecting them would be fabrication, not binary-verified fidelity. But the two *host-derived* keys **are** emitted: `CLAUDE_CODE_HOST_PLATFORM` (every assembling tier) and `CLAUDE_CODE_WORKSPACE_HOST_PATHS` (`hostloop`, when connected folders are present) — see [`fidelity-gaps.md`](./fidelity-gaps.md). Only matters for skills that read identity or those gates. Revisit if a future `sync` captures the gated values.
- **System prompt: base prose comes from the agent ELF's built-in default, not Cowork's host-rendered prompt.**
  - The harness layers reconstructed cowork-specific sections via `--append-system-prompt`; Cowork by default does the channel-equivalent — `systemPrompt:{type:"preset",preset:"claude_code",append:<cowork sections>}` over `initialize`, i.e. it KEEPS the `claude_code` base preset and APPENDS (full-replacement only fires on a server-pushed `spVariant.mode==="replace"` — the exception, not the default; binary-verified, see the channel-divergence finding).
  - So the *channel* matches; the residual gap is *content* (we reconstruct partial cowork sections vs Cowork's full `y8r` append, which also carries the host-rendered `<env>` block: date, timezone, selected-folder list, model, account).
  - The fact *categories* are covered by the ELF's built-in default, but exact wording/values may differ.
  - We do not reconstruct/bundle Cowork's base prose (Anthropic-owned; not cleanly extractable) and cannot *programmatically* drive real Cowork to diff it (Desktop IPC is locked).
  - **Update (2026-06-18):** *manual* behavioral capture — asking the running Cowork agent to describe its own system prompt — IS available, and was used to add a reconstructed `<identity>` section to the append (Cowork self-identifies as "Claude … the Cowork assistant, not Claude Code"; the base `claude_code` preset alone would say "Claude Code").
  - The "IPC locked" caveat applies to automated diffing, not manual capture.
