diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 79a4eb9..dbc0ea5 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -23,6 +23,7 @@ from . import prepare as _prepare from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan +from .git_gate import DockerGitGate from .pipelock import DockerPipelockProxy from .provision import ca as _ca from .provision import git as _git @@ -41,16 +42,25 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup def __init__(self) -> None: self._proxy = DockerPipelockProxy() self._gate = DockerSSHGate() + self._git_gate = DockerGitGate() def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: return _prepare.resolve_plan( - spec, stage_dir=stage_dir, proxy=self._proxy, gate=self._gate + spec, + stage_dir=stage_dir, + proxy=self._proxy, + gate=self._gate, + git_gate=self._git_gate, ) @contextmanager def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]: with _launch.launch( - plan, proxy=self._proxy, gate=self._gate, provision=self.provision + plan, + proxy=self._proxy, + gate=self._gate, + git_gate=self._git_gate, + provision=self.provision, ) as bottle: yield bottle diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index f61e9d2..d031b23 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -11,6 +11,7 @@ import sys from dataclasses import dataclass, field from pathlib import Path +from ...git_gate import GitGatePlan from ...log import info from ...manifest import Agent, Bottle from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist @@ -27,6 +28,7 @@ class _PlanView: bottle: Bottle env_names: list[str] ssh_hosts: list[str] + git_names: list[str] prompt_first_line: str @@ -51,6 +53,7 @@ class DockerBottlePlan(BottlePlan): prompt_file: Path proxy_plan: PipelockProxyPlan gate_plan: SSHGatePlan + git_gate_plan: GitGatePlan allowlist_summary: str use_runsc: bool @@ -67,6 +70,7 @@ class DockerBottlePlan(BottlePlan): bottle=bottle, env_names=env_names, ssh_hosts=[e.Host for e in bottle.ssh], + git_names=[e.Name for e in bottle.git], prompt_first_line=agent.prompt.splitlines()[0] if agent.prompt else "", ) @@ -100,6 +104,16 @@ class DockerBottlePlan(BottlePlan): info(f" ssh gate : {'; '.join(gate_lines)}") else: info(" ssh hosts : (none)") + if v.git_names: + info(f" git remotes : {', '.join(v.git_names)}") + git_lines = [ + f"{u.name} -> {u.upstream_host}:{u.upstream_port} " + f"(gitleaks-scanned)" + for u in self.git_gate_plan.upstreams + ] + info(f" git gate : {'; '.join(git_lines)}") + else: + info(" git remotes : (none)") info(f" egress : {self.allowlist_summary}") info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)") info( @@ -131,6 +145,16 @@ class DockerBottlePlan(BottlePlan): } for u in self.gate_plan.upstreams ], + "git_remotes": v.git_names, + "git_gate": [ + { + "name": u.name, + "upstream": f"{u.upstream_host}:{u.upstream_port}", + "upstream_url": u.upstream_url, + "known_host_key_pinned": bool(u.known_host_key), + } + for u in self.git_gate_plan.upstreams + ], "egress": { "host_count": len(hosts), "hosts": hosts, diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 7e5d10f..006a719 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -22,6 +22,7 @@ from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle from .bottle_plan import DockerBottlePlan +from .git_gate import DockerGitGate from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH from .ssh_gate import DockerSSHGate @@ -37,6 +38,7 @@ def launch( *, proxy: DockerPipelockProxy, gate: DockerSSHGate, + git_gate: DockerGitGate, provision: Callable[[DockerBottlePlan, str], str | None], ) -> Generator[DockerBottle, None, None]: """Build, launch, and provision a Docker bottle. Teardown on exit. @@ -102,6 +104,21 @@ def launch( gate_name = gate.start(plan.gate_plan) stack.callback(gate.stop, gate_name) + # Git gate (PRD 0008). One sidecar per agent, only brought up + # when the bottle has git entries. Same internal + egress + # network attachment as the other sidecars; agent dials it as + # `git:///.git` via the pushInsteadOf + # rules provision_git writes into ~/.gitconfig. + if plan.git_gate_plan.upstreams: + git_gate_plan = dataclasses.replace( + plan.git_gate_plan, + internal_network=internal_network, + egress_network=egress_network, + ) + plan = dataclasses.replace(plan, git_gate_plan=git_gate_plan) + git_gate_name = git_gate.start(plan.git_gate_plan) + stack.callback(git_gate.stop, git_gate_name) + container = _run_agent_container(plan, internal_network) stack.callback(docker_mod.force_remove_container, container) diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index f420851..ce08cba 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -19,6 +19,7 @@ from ...log import die from .. import BottleSpec from . import util as docker_mod from .bottle_plan import DockerBottlePlan +from .git_gate import DockerGitGate from .pipelock import DockerPipelockProxy from .ssh_gate import DockerSSHGate @@ -29,6 +30,7 @@ def resolve_plan( stage_dir: Path, proxy: DockerPipelockProxy, gate: DockerSSHGate, + git_gate: DockerGitGate, ) -> DockerBottlePlan: """Resolve Docker-specific names and write scratch files. Trusts that the agent and its skills/SSH keys are present — validation @@ -81,6 +83,7 @@ def resolve_plan( proxy_plan = proxy.prepare(bottle, slug, stage_dir) gate_plan = gate.prepare(bottle, slug, stage_dir) + git_gate_plan = git_gate.prepare(bottle, slug, stage_dir) resolved = resolve_env(manifest, spec.agent_name) # Everything that should reach the bottle by-name (so its value # never lands on argv or in env_file) goes into one dict. The @@ -109,6 +112,7 @@ def resolve_plan( prompt_file=prompt_file, proxy_plan=proxy_plan, gate_plan=gate_plan, + git_gate_plan=git_gate_plan, allowlist_summary=allowlist_summary, use_runsc=use_runsc, ) diff --git a/tests/integration/test_dry_run_plan.py b/tests/integration/test_dry_run_plan.py index 3f8add4..09e8a1c 100644 --- a/tests/integration/test_dry_run_plan.py +++ b/tests/integration/test_dry_run_plan.py @@ -81,6 +81,8 @@ class TestDryRunPlan(unittest.TestCase): self.assertEqual([], plan["skills"]) self.assertEqual([], plan["ssh_hosts"]) self.assertEqual([], plan["ssh_gate"]) + self.assertEqual([], plan["git_remotes"]) + self.assertEqual([], plan["git_gate"]) self.assertEqual(False, plan["remote_control"]) self.assertEqual(0, plan["prompt"]["length"])