diff --git a/docs/src/security/egress.md b/docs/src/security/egress.md index 8de37a8..d42b516 100644 --- a/docs/src/security/egress.md +++ b/docs/src/security/egress.md @@ -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 diff --git a/lib/ccvm-context.md b/lib/ccvm-context.md index df14c3a..c49c8fd 100644 --- a/lib/ccvm-context.md +++ b/lib/ccvm-context.md @@ -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. diff --git a/tests/egress.sh b/tests/egress.sh index 44d6a19..419fcc0 100644 --- a/tests/egress.sh +++ b/tests/egress.sh @@ -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 ]] diff --git a/tests/host.sh b/tests/host.sh index b277e55..020224e 100755 --- a/tests/host.sh +++ b/tests/host.sh @@ -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" diff --git a/wrapper/ccvm.sh b/wrapper/ccvm.sh index 7eb3f80..968094b 100644 --- a/wrapper/ccvm.sh +++ b/wrapper/ccvm.sh @@ -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