From 2a7a2dcb822f443f776db3e0acd941911010efa9 Mon Sep 17 00:00:00 2001 From: Jaxxen Date: Sat, 18 Jul 2026 20:10:01 +0000 Subject: [PATCH] guest context: name reachable hosts + a clean host-handoff protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest ~/.claude/CLAUDE.md gave the in-VM agent too little to act on two recurring failure modes: 1. With an egressAllowlist set, egress is default-deny and a request to any unlisted host silently HANGS then times out. The old context only said "network access may be restricted", so the agent kept retrying doomed fetches. The wrapper now emits a per-run line in the `# ccvm session` header naming the EXACT reachable hosts (the resolved EGRESSALLOW plus the always-added api.anthropic.com), and says anything else will hang — don't retry. Open egress emits a short "you can reach any host" line instead. Built from the live EGRESSALLOW var, so no new @TOKEN@. 2. Host-only work (unreachable fetches, git fetch/push needing host creds, host-side CI) was handed back ad hoc. ccvm-context.md now documents a "Handing work to the host" protocol: exact commands in a fenced block, one per line, with NO inline `#` comments (they break when pasted into zsh), each command described in prose outside the block. Tests: host.sh asserts the open-egress line; egress.sh (baked 10.0.0.0/8) asserts the locked line names the host + api.anthropic.com. Both green under nix flake check. egress.md documents the agent-awareness behaviour. Co-authored-by: Claude --- docs/src/security/egress.md | 11 ++++++++ lib/ccvm-context.md | 53 ++++++++++++++++++++++++++++++++++--- tests/egress.sh | 15 +++++++++++ tests/host.sh | 4 +++ wrapper/ccvm.sh | 9 +++++++ 5 files changed, 88 insertions(+), 4 deletions(-) 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