Files
bot-bottle/claude_bottle/backend/smolmachines/gvproxy_config.py
T
didericis 20f411b22e
test / unit (pull_request) Successful in 22s
test / integration (pull_request) Successful in 43s
feat(smolmachines): backend skeleton + Smolfile/gvproxy renderers (PRD 0023 chunk 1)
Ships the smolmachines backend's prepare side: subpackage layout,
`_BACKENDS` registration under "smolmachines", preflight check
for `smolvm` + `gvproxy` on PATH, and the two config-file
renderers (Smolfile TOML + gvproxy YAML). Launch raises
NotImplementedError until chunk 2.

New module layout (mirrors backend/docker/):
  claude_bottle/backend/smolmachines/
    __init__.py            re-exports SmolmachinesBottleBackend
    backend.py             SmolmachinesBottleBackend façade
    bottle.py              SmolmachinesBottle stub (NotImpl until ch2)
    bottle_plan.py         SmolmachinesBottlePlan + .print()
    bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan stub
    prepare.py             resolve_plan: writes both config files
    smolfile.py            TOML renderer (stdlib, no tomli_w dep)
    gvproxy_config.py      YAML renderer (same shape as pipelock_yaml)
    util.py                preflight + per-slug subnet + loopback port

The renderers are pure functions. `resolve_plan` runs the
preflight, allocates one host-side loopback port per active
sidecar (pipelock always; git-gate / supervise conditional),
derives a per-slug gvproxy subnet (hash-mod-254, skipping the
docker-default 17), and writes:

  - <stage>/gvproxy.yaml: subnet + DNS rule resolving only
    `proxy.internal` + port_forwards (one per active sidecar).
  - <stage>/smolfile.toml: guest command/env + virtio-net device
    backed by gvproxy's unixgram socket. No TSI flags — see
    PRD 0023 "Why gvproxy, not TSI".

The agent's HTTPS_PROXY etc. point at `proxy.internal:<gateway-
port>` so the guest dials through gvproxy. gvproxy resolves only
`proxy.internal` → the gateway IP, and forwards exactly the
listed ports to the host-side sidecar bundle (PRD 0024); every
other destination — host LAN, host loopback, public internet
directly — is unreachable by construction.

29 new unit tests covering renderer correctness, subnet
derivation stability + collision-avoidance, loopback port
allocation, and preflight error paths. Full unit suite: 532
passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:22:08 -04:00

102 lines
3.4 KiB
Python

"""gvproxy YAML config renderer (PRD 0023).
The gvproxy config defines:
- The per-bottle subnet + gateway IP — derived from the slug so
parallel bottles don't collide on 192.168.X.0/24.
- A DNS rule that resolves only `proxy.internal` to the gateway.
Every other hostname returns NXDOMAIN. This is the load-bearing
rule for PRD 0022's DNS-exfil attack: the guest can't `dig`
arbitrary names.
- `port_forwards` — one entry per sidecar daemon the bottle uses.
Only what's listed here is reachable from the guest. The
host-side sidecar bundle listens on the resolved `host_port`s;
gvproxy port-forwards `gateway_port` (what the guest dials) →
host `host_port`.
This is the file the PRD 0023 design names as the network
primitive. TSI is explicitly NOT used — see PRD 0023 "Why gvproxy,
not TSI".
The renderer is pure; disk writes happen in prepare.py."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class PortForward:
"""One gvproxy port forward. The guest dials `gateway_port`; the
host receives the connection at `127.0.0.1:host_port`."""
gateway_port: int
host_port: int
def gvproxy_config_build(
*,
subnet: str,
gateway: str,
port_forwards: tuple[PortForward, ...],
) -> dict[str, Any]:
"""Build the gvproxy YAML config dict. The shape matches the
recipe in `agent-vm-isolation.md` § "Full Setup": top-level
`subnet`, `gateway`, `dns:` with the `proxy.internal` carve-out,
and `port_forwards`."""
return {
"subnet": subnet,
"gateway": gateway,
"dns": [
{
"zone": ".",
"records": [
{"name": "proxy.internal", "ip": gateway},
],
},
],
"port_forwards": [
{
"gateway_port": p.gateway_port,
"host": "127.0.0.1",
"host_port": p.host_port,
}
for p in port_forwards
],
}
def gvproxy_config_render(cfg: dict[str, Any]) -> str:
"""Render the gvproxy config as a small YAML subset (the same
shape `pipelock_render_yaml` uses). Stdlib has no YAML writer
and the config shape is narrow — strings + ints + lists of dicts
— so rendering by hand is straightforward and keeps the project
stdlib-only."""
lines: list[str] = []
lines.append(f'subnet: "{cfg["subnet"]}"')
lines.append(f'gateway: "{cfg["gateway"]}"')
lines.append("dns:")
for zone in cfg["dns"]:
lines.append(f' - zone: "{zone["zone"]}"')
lines.append(" records:")
for record in zone["records"]:
lines.append(f' - name: "{record["name"]}"')
lines.append(f' ip: "{record["ip"]}"')
if cfg["port_forwards"]:
lines.append("port_forwards:")
for pf in cfg["port_forwards"]:
lines.append(f' - gateway_port: {pf["gateway_port"]}')
lines.append(f' host: "{pf["host"]}"')
lines.append(f' host_port: {pf["host_port"]}')
else:
lines.append("port_forwards: []")
return "\n".join(lines) + "\n"
def gvproxy_config_write(cfg: dict[str, Any], path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(gvproxy_config_render(cfg))
path.chmod(0o600)
return path