"""Per-agent git-gate (PRD 0008). A third per-agent sidecar that fronts the bottle's declared git upstreams as a transparent mirror. Each `bottle.git` entry maps to a bare repo on the gate; `git daemon` serves the bare repos over `git:///.git`. Two hooks make the mirror bidirectional: - **`pre-receive`** (push path) — gitleaks-scans incoming refs and, on clean, forwards them to the real upstream with the gate-resident credential. - **`--access-hook`** (fetch path) — runs `git fetch origin --prune` against the real upstream before every `upload-pack`, so an agent fetch returns whatever the upstream has *now*. Fail-closed if the upstream is unreachable. The agent never sees the upstream credential under either path. Why a separate sidecar (not folded into egress or ssh-gate): the gate is the only one of the three that holds upstream push credentials. Mixing it with egress would put push creds in the same blast radius as internet-facing TLS interception; mixing it with ssh-gate would force ssh-gate above L4 and into git-protocol land. See `docs/prds/0008-git-gate.md`. This module defines the abstract gate (`GitGate`) and its plan dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is backend-specific and lives on concrete subclasses (see `bot_bottle/backend/docker/git_gate.py`).""" from __future__ import annotations import dataclasses from abc import ABC from dataclasses import dataclass from pathlib import Path from .manifest import ManifestBottle # Rendering and the deploy-key lifecycle live in sibling modules; the # names are re-exported here (see __all__) so existing # `from bot_bottle.git_gate import …` callers are unchanged. from .git_gate_render import ( GIT_GATE_HOSTNAME, GIT_GATE_TIMEOUT_SECS, GitGateUpstream, git_gate_known_hosts_line, git_gate_render_access_hook, git_gate_render_entrypoint, git_gate_render_gitconfig, git_gate_render_hook, git_gate_upstreams_for_bottle, _gitconfig_validate_value, ) from .git_gate_provision import ( revoke_git_gate_provisioned_keys, _provision_dynamic_key, _resolve_identity_file, ) @dataclass(frozen=True) class GitGatePlan: """Output of GitGate.prepare; consumed by .start. The script + slug + upstream fields are filled at prepare time (host-side, side-effect-free on docker). The network fields are populated by the backend's launch step via `dataclasses.replace` once those networks exist. Empty defaults are sentinels meaning "not yet set"; `.start` validates that they are populated. `hook_script` is the shared `pre-receive` for push-time gating; `access_hook_script` is `git daemon`'s `--access-hook` for the fetch-time upstream refresh.""" slug: str entrypoint_script: Path hook_script: Path access_hook_script: Path upstreams: tuple[GitGateUpstream, ...] internal_network: str = "" egress_network: str = "" class GitGate(ABC): """The per-agent git-gate. Encapsulates the host-side prepare (upstream lift + entrypoint/hook render); the sidecar's start/stop lifecycle is backend-specific and lives on concrete subclasses.""" def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan: """Compute the upstream table from `bottle.git` and write the entrypoint, pre-receive hook, and access-hook scripts (mode 600) under `stage_dir`. Pure host-side, no docker subprocess. For `gitea` key entries, also generates and registers a fresh deploy key via the forge API and writes the private key + key ID to `stage_dir`. Returned plan is incomplete: the launch step must fill `internal_network` / `egress_network` via `dataclasses.replace` before passing the plan to `.start`.""" upstreams_list = list(git_gate_upstreams_for_bottle(bottle)) for i, entry in enumerate(bottle.git): upstreams_list[i] = dataclasses.replace( upstreams_list[i], identity_file=_resolve_identity_file(entry, slug, stage_dir), ) upstreams = tuple(upstreams_list) entrypoint = stage_dir / "git_gate_entrypoint.sh" entrypoint.write_text(git_gate_render_entrypoint(upstreams)) entrypoint.chmod(0o600) hook = stage_dir / "git_gate_pre_receive.sh" hook.write_text(git_gate_render_hook()) hook.chmod(0o600) access_hook = stage_dir / "git_gate_access_hook.sh" access_hook.write_text(git_gate_render_access_hook()) # 0o700 (not 0o600): git daemon execs --access-hook directly, # not via `sh`, so the script needs the x bit. docker cp # preserves source mode into the container. access_hook.chmod(0o700) upstreams_with_files: list[GitGateUpstream] = [] for u in upstreams: known_hosts_file = Path() if u.known_host_key: known_hosts_file = stage_dir / f"{u.name}-known_hosts" known_hosts_file.write_text( git_gate_known_hosts_line( u.upstream_host, u.upstream_port, u.known_host_key, ) ) known_hosts_file.chmod(0o600) upstreams_with_files.append( GitGateUpstream( name=u.name, upstream_url=u.upstream_url, upstream_host=u.upstream_host, upstream_port=u.upstream_port, identity_file=u.identity_file, known_host_key=u.known_host_key, known_hosts_file=known_hosts_file, ) ) return GitGatePlan( slug=slug, entrypoint_script=entrypoint, hook_script=hook, access_hook_script=access_hook, upstreams=tuple(upstreams_with_files), ) __all__ = [ "GIT_GATE_HOSTNAME", "GIT_GATE_TIMEOUT_SECS", "GitGateUpstream", "GitGatePlan", "GitGate", "git_gate_upstreams_for_bottle", "git_gate_render_gitconfig", "git_gate_known_hosts_line", "git_gate_render_entrypoint", "git_gate_render_hook", "git_gate_render_access_hook", "revoke_git_gate_provisioned_keys", "_gitconfig_validate_value", "_provision_dynamic_key", "_resolve_identity_file", ]