33fe8d2c7a
git_gate.py (699 LOC) mixed three responsibilities. Split into: - git_gate_render.py — pure host-side rendering: the gate constants, GitGateUpstream, gitconfig/known-hosts rendering, and the entrypoint / pre-receive / access-hook script builders. - git_gate_provision.py — the gitea deploy-key lifecycle (_provision_dynamic_key / revoke / _resolve_identity_file). - git_gate.py — the GitGate ABC + GitGatePlan, now 169 LOC, re-exporting all moved names (see __all__) so the 19 importers are unchanged. Host-side only (not flat-bundled), so no sidecar import shim. The one test that patched the internal `_provision_dynamic_key` lookup is repointed to its new module (public API unchanged). The two new modules are added to scripts/critical-modules.txt so the decompose doesn't move security code out of the measured core — critical aggregate stays 95% (git_gate 100%, render 100%, provision 97%). Closes #303 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
170 lines
6.3 KiB
Python
170 lines
6.3 KiB
Python
"""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://<gate>/<name>.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",
|
|
]
|