Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/src/security/egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ The firewall is installed inside the guest by a **root systemd unit** (not the a
(`egress-hosts`), written into the guest's `/etc/hosts`, so the agent resolves each FQDN to exactly
the IP the firewall allows. DNS is pinned to the slirp stub resolver.

## The agent is told its reachable hosts

A default-deny firewall fails *silently*: a request to a blocked host hangs until it times out,
which reads to an agent like a flaky network, so it retries and burns turns. To avoid that, the
per-run `# ccvm session` header ccvm prepends to the guest `~/.claude/CLAUDE.md` names the **exact**
reachable hosts this run — the allowlist plus the always-added `api.anthropic.com` — and tells the
in-VM agent that anything else will hang, not to attempt it, and to hand unreachable fetches (and
host-credential work like `git fetch`/`push`) back to the user in a copy-pasteable block. Built in
`wrapper/ccvm.sh` from the resolved `EGRESSALLOW`; the generic handoff rules live in
`lib/ccvm-context.md`. So a locked allowlist changes agent *behaviour*, not just packet fate.

## The load-bearing caveat: enforcement lives in the guest

Because enforcement lives in the guest, it only binds a **non-root** agent — a root agent could
Expand Down
53 changes: 49 additions & 4 deletions lib/ccvm-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,58 @@ user's host machine directly. A few things follow from that:
exit. Prefer getting routine, reversible work done over pausing to ask permission for it.
- **Git: commits work, pushing usually does not.** Your host git identity and aliases are
available so `git commit` records authorship as you, but the host's SSH keys are not shared,
so `git push` to an SSH remote cannot authenticate. Commit freely; leave pushing to the
user, or use an HTTPS remote with a token they provide.
- **Network access may be restricted.** Egress can be limited to an allowlist; if a network
request fails unexpectedly, the destination may simply not be permitted from inside the VM.
so `git push` to an SSH remote cannot authenticate. Commit freely; hand the push (or a
`git fetch`) to the user — see **Handing work to the host** below — or use an HTTPS remote
with a token they provide.
- **Network egress may be locked to an allowlist.** When it is, the `# ccvm session` header at
the top of this file names the **exact** hosts you can reach this run (plus `api.anthropic.com`
for the Claude API). A request to any host NOT on that list does not fail fast — it HANGS and
then times out. That is the firewall doing its job, not a transient error, so retrying will not
help. Do not attempt to fetch, clone, `curl`, install packages from, or `git fetch`/`pull` from
a host that is not reachable — hand that work to the user instead (below). If the header says
egress is OPEN this run, ignore this and fetch normally.
- **Prefer the codebase over agent memory for anything durable.** Write lasting knowledge into
the project's own files — `CLAUDE.md`, `README.md`, `docs/`, code comments — and commit it,
rather than relying on saved memory. Memory is brittle for developer workflows, and in ccvm it
is **ephemeral by default**: it lives in this throwaway VM and is discarded on exit. Only when
`CCVM_PERSIST_PROJECTS=1` does it survive across runs — see the session note above for whether
it persists right now.

## Handing work to the host

Some things simply cannot run inside this VM: fetching from a host the egress allowlist blocks,
`git fetch`/`git push` that needs the host's credentials, running the project's host-side CI, or
anything else that needs host network or host secrets. Do NOT keep retrying these — they will
hang or fail every time. Hand them to the user to run in a **host terminal** instead, and make
the commands trivially copy-pasteable:

- Put the exact commands in a fenced code block, **one command per line**.
- **No inline or trailing comments inside the block** — a `#` comment pasted into the user's
shell (zsh) can break the command. Describe what each command does in prose *outside* the
block, then give the clean block.
- Keep it to what the user must actually run; nothing else.

For example, to get the local CI run after you have committed:

> Committed as `abc1234`. My egress is limited, so I can't run the CI myself. Please run this in
> a host terminal:
>
> ```sh
> ./ci.sh
> ```

Or when you need content from a host you can't reach — here `git fetch` needs host credentials,
and the second command saves a doc page you'll read next into an ignored file:

> I can't reach those hosts from inside the VM. Please run these in a host terminal, then tell me
> when they're done:
>
> ```sh
> git fetch origin
> curl -fsSL https://docs.example.com/page > .ccvm-scratch/page.html
> ```
>
> Then I'll pick up from `.ccvm-scratch/page.html`.

When file edits reach the host live (writableCwd=true, see the session header), a fetch the user
runs into a file in the project tree becomes visible to you immediately.
15 changes: 15 additions & 0 deletions tests/egress.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,20 @@ if [[ -n ${CCVM_FQDNONLY:-} ]]; then
fi
fi

# The agent-facing context (seed/claude-md) must tell the in-VM Claude that egress is LOCKED, name
# the exact reachable hosts (the baked allowlist + the always-added api.anthropic.com), so it does
# not waste turns on firewalled fetches. The static body is a stand-in fixture here — this asserts
# only the dynamic per-run header, which is exactly the config-driven behaviour under test.
CM="$SEED/claude-md"
grep -q 'LOCKED DOWN' "$CM" 2>/dev/null &&
ok "claude-md: locked-egress run tells the agent egress is locked down" ||
no "claude-md: locked-egress note missing (agent not warned egress is closed)"
grep -q '10.0.0.0/8' "$CM" 2>/dev/null &&
ok "claude-md: locked-egress header names the allowlisted host" ||
no "claude-md: allowlisted host (10.0.0.0/8) missing from the header"
grep -q 'api.anthropic.com' "$CM" 2>/dev/null &&
ok "claude-md: locked-egress header names the always-reachable api.anthropic.com" ||
no "claude-md: api.anthropic.com not named in the locked-egress header"

printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
4 changes: 4 additions & 0 deletions tests/host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,10 @@ grep -q 'do NOT persist across runs' "$CM" 2>/dev/null &&
grep -q 'PREFER writing durable information into the codebase' "$CM" 2>/dev/null &&
ok "claude-md: persist-off run warns memory is ephemeral, prefer the codebase" ||
no "claude-md: missing the ephemeral-memory / prefer-codebase guidance"
# Open egress (baked default): the agent is told egress is OPEN and it can reach any host.
grep -q 'egress is OPEN' "$CM" 2>/dev/null &&
ok "claude-md: open-egress run tells the agent it can reach any host" ||
no "claude-md: open-egress note missing (agent not told egress is open)"

# Overlay run: the mode line must flip to the DISCARDED warning (and not claim LIVE).
SEED="$(HOME="$FAKE_HOME" CCVM_SHARE_CLAUDE_CONFIG=0 run --read-only-cwd)/seed"
Expand Down
9 changes: 9 additions & 0 deletions wrapper/ccvm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,15 @@ if [[ -n $CLAUDEMD && -r $CLAUDEMD ]]; then
if ((VMDISKSIZE > 0)); then
printf 'A disk-backed, encrypted scratch area is mounted at /scratch — use it for LARGE ephemeral writes (build outputs, node_modules, target/, caches) that would otherwise exhaust the RAM-backed filesystem. It is wiped on exit like everything else, so nothing there is durable.\n\n'
fi
# Egress posture, resolved per run. Locked => name the EXACT reachable hosts (plus the always-
# auto-added api.anthropic.com) so the agent does not waste turns on firewalled requests that
# silently hang. Empty EGRESSALLOW => open egress (native default). $EGRESSALLOW is passed as a
# %s ARGUMENT, never spliced into the format string.
if [[ -n ${EGRESSALLOW// /} ]]; then
printf 'Network egress is LOCKED DOWN this run. The ONLY hosts reachable from inside this VM are: %s — plus api.anthropic.com (the Claude API). Every other host is firewalled: a request to it HANGS and then times out. That is not a transient error and retrying will NOT help, so do NOT try to fetch, clone, curl, pip/npm/cargo install, or git fetch/pull from any host that is not in that list. When you need something from an unlisted host — or any command that needs host network or host credentials — hand it to the user to run on the host (see "Handing work to the host" below) instead of attempting it yourself.\n\n' "$EGRESSALLOW"
else
printf 'Network egress is OPEN this run (no allowlist) — you can reach any host, the same as running claude natively.\n\n'
fi
cat "$CLAUDEMD"
} >"$SEED/claude-md"
fi
Expand Down