PRD 0008: Git gate #11
@@ -0,0 +1,37 @@
|
|||||||
|
# Per-agent git-gate sidecar image (PRD 0008).
|
||||||
|
#
|
||||||
|
# Runs `git daemon --enable=receive-pack` so the agent in the bottle
|
||||||
|
# can push to it over git://. A shared pre-receive hook runs gitleaks
|
||||||
|
# against each incoming ref; on clean, it forwards the ref to the real
|
||||||
|
# upstream using a credential the gate holds. The agent never sees the
|
||||||
|
# upstream credential.
|
||||||
|
#
|
||||||
|
# The agent-facing leg sits on a Docker --internal network with no
|
||||||
|
# default route, so the image is fully self-contained: no apk pulls at
|
||||||
|
# boot, no remote registry lookups during the entrypoint.
|
||||||
|
|
||||||
|
# Base on the upstream gitleaks image (alpine + gitleaks v8.x);
|
||||||
|
# alpine doesn't package gitleaks so this avoids a separate
|
||||||
|
# install path. Pinned by digest for reproducibility.
|
||||||
|
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
|
||||||
|
|
||||||
|
# openssh-client supplies the upstream SSH transport the pre-receive
|
||||||
|
# hook uses to forward accepted refs. git-daemon is the listener the
|
||||||
|
# agent pushes to (alpine ships `git-daemon` as a sub-package, not
|
||||||
|
# part of `git`). The `git` core binary is already in the base image.
|
||||||
|
RUN apk add --no-cache openssh-client git-daemon
|
||||||
|
|
||||||
|
# Layout the gate uses at runtime:
|
||||||
|
# /git-gate-entrypoint.sh — docker-cp'd at start time
|
||||||
|
# /etc/git-gate/pre-receive — shared hook, docker-cp'd at start
|
||||||
|
# /git-gate/creds/<name>-key — per-upstream identity, docker-cp'd
|
||||||
|
# /git-gate/creds/<name>-known_hosts — per-upstream known_hosts, docker-cp'd
|
||||||
|
# /git/<name>.git — bare repos, created by the entrypoint
|
||||||
|
#
|
||||||
|
# The intermediate directories must exist before `docker cp` runs (cp
|
||||||
|
# does not create them); the bare-repo parent (/git) is also pre-created
|
||||||
|
# defensively.
|
||||||
|
RUN mkdir -p /etc/git-gate /git-gate/creds /git
|
||||||
|
|
||||||
|
# Base image's ENTRYPOINT is the gitleaks binary; override explicitly.
|
||||||
|
ENTRYPOINT ["/bin/sh", "/git-gate-entrypoint.sh"]
|
||||||
@@ -56,12 +56,14 @@ pieces of v1.
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
A bottle is three containers on a per-agent Docker `--internal`
|
A bottle is the agent container plus up to three per-protocol egress
|
||||||
network. The agent has no default route off-box; its only way out is
|
sidecars on a per-agent Docker `--internal` network. The agent has no
|
||||||
through the pipelock sidecar (for HTTP/HTTPS) or the ssh-gate sidecar
|
default route off-box; its only way out is through the pipelock
|
||||||
(for SSH). Both sidecars also sit on an egress network that does have
|
sidecar (for HTTP/HTTPS), the ssh-gate sidecar (for SSH), or the
|
||||||
internet access, so the agent's traffic always passes through a
|
git-gate sidecar (for git operations against declared upstreams).
|
||||||
container that enforces the manifest before it leaves the host.
|
Each sidecar also sits on an egress network that does have internet
|
||||||
|
access, so the agent's traffic always passes through a container
|
||||||
|
that enforces the manifest before it leaves the host.
|
||||||
|
|
||||||
```
|
```
|
||||||
host ( ./cli.py )
|
host ( ./cli.py )
|
||||||
@@ -76,11 +78,17 @@ container that enforces the manifest before it leaves the host.
|
|||||||
│ │ built locally) │ │ (TLS bump, DLP,│ │ hosts
|
│ │ built locally) │ │ (TLS bump, DLP,│ │ hosts
|
||||||
│ │ │ │ allowlist) │ │
|
│ │ │ │ allowlist) │ │
|
||||||
│ │ skills, env, │ └────────────────┘ │
|
│ │ skills, env, │ └────────────────┘ │
|
||||||
│ │ ~/.ssh/config │ │
|
│ │ ~/.ssh/config, │ │
|
||||||
│ │ │ ssh ┌────────────────┐ │ TCP to
|
│ │ ~/.gitconfig │ ssh ┌────────────────┐ │ TCP to
|
||||||
│ │ │ ───────────────► │ socat/ssh image│──┼──► bottle.ssh
|
│ │ │ ───────────────► │ socat/ssh image│──┼──► bottle.ssh
|
||||||
│ │ │ │ (alpine/socat, │ │ upstreams
|
│ │ │ │ (alpine/socat, │ │ upstreams
|
||||||
│ │ │ │ L4 forwarder) │ │
|
│ │ │ │ L4 forwarder) │ │
|
||||||
|
│ │ │ └────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ git ops ┌────────────────┐ │ SSH (push/
|
||||||
|
│ │ │ ───────────────► │ git-gate image │──┼──► fetch) to
|
||||||
|
│ │ │ │ (gitleaks + │ │ bottle.git
|
||||||
|
│ │ │ │ git daemon) │ │ upstreams
|
||||||
│ └──────────────────┘ └────────────────┘ │
|
│ └──────────────────┘ └────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ agent on internal network (no default route); │
|
│ agent on internal network (no default route); │
|
||||||
@@ -90,7 +98,8 @@ container that enforces the manifest before it leaves the host.
|
|||||||
|
|
||||||
- **agent image** — built from the repo `Dockerfile` (`node:22-slim`
|
- **agent image** — built from the repo `Dockerfile` (`node:22-slim`
|
||||||
base) on first run; runs `claude` with the manifest-granted skills,
|
base) on first run; runs `claude` with the manifest-granted skills,
|
||||||
env vars, and `~/.ssh/config`.
|
env vars, `~/.ssh/config`, and `~/.gitconfig` (the latter for the
|
||||||
|
git-gate's `pushInsteadOf` rules when `bottle.git` is set).
|
||||||
- **pipelock image** — per-agent sidecar. Terminates the agent's
|
- **pipelock image** — per-agent sidecar. Terminates the agent's
|
||||||
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
|
outbound HTTP/HTTPS, enforces the resolved allowlist, runs DLP
|
||||||
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
|
scanning. Design in `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
|
||||||
@@ -99,9 +108,22 @@ container that enforces the manifest before it leaves the host.
|
|||||||
One container, one socat listener per `bottle.ssh` entry, each
|
One container, one socat listener per `bottle.ssh` entry, each
|
||||||
forwarding TCP to the upstream `Hostname:Port`. SSH does *not* go
|
forwarding TCP to the upstream `Hostname:Port`. SSH does *not* go
|
||||||
through pipelock. Design in `docs/prds/0007-ssh-egress-gate.md`.
|
through pipelock. Design in `docs/prds/0007-ssh-egress-gate.md`.
|
||||||
|
- **git-gate image** — per-agent sidecar built on `zricethezav/gitleaks`
|
||||||
|
(alpine + gitleaks + git-daemon + openssh-client). Runs
|
||||||
|
`git daemon` over `git://` as a bidirectional mirror of each
|
||||||
|
declared upstream. A pre-receive hook gitleaks-scans incoming
|
||||||
|
refs and forwards clean refs to the real upstream over SSH; an
|
||||||
|
access-hook runs `git fetch origin --prune` against the upstream
|
||||||
|
before every upload-pack so an agent fetch returns whatever the
|
||||||
|
upstream has *now* (fail-closed if unreachable). The agent's
|
||||||
|
`~/.gitconfig` rewrites the real URL to the gate via `insteadOf`,
|
||||||
|
so push, fetch, clone, and pull all route through. The agent
|
||||||
|
never sees the upstream credential. Brought up only when
|
||||||
|
`bottle.git` has entries. Design in `docs/prds/0008-git-gate.md`.
|
||||||
|
|
||||||
When the agent exits, `cli.py` tears down both sidecars and the two
|
When the agent exits, `cli.py` tears down every sidecar that was
|
||||||
networks; nothing about a bottle persists between runs.
|
brought up and the two networks; nothing about a bottle persists
|
||||||
|
between runs.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Generic, Sequence, TypeVar
|
from typing import Any, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
from ..log import die
|
from ..log import die
|
||||||
from ..manifest import Manifest, SshEntry
|
from ..manifest import GitEntry, Manifest, SshEntry
|
||||||
from ..util import expand_tilde
|
from ..util import expand_tilde
|
||||||
from .util import host_skill_dir
|
from .util import host_skill_dir
|
||||||
|
|
||||||
@@ -171,6 +171,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
self._validate_skills(agent.skills)
|
self._validate_skills(agent.skills)
|
||||||
self._validate_ssh_entries(bottle.ssh)
|
self._validate_ssh_entries(bottle.ssh)
|
||||||
|
self._validate_git_entries(bottle.git)
|
||||||
|
|
||||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||||
"""Each named skill must be a directory under the host's
|
"""Each named skill must be a directory under the host's
|
||||||
@@ -193,6 +194,16 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
if not os.path.isfile(key):
|
if not os.path.isfile(key):
|
||||||
die(f"ssh key file not found for host '{entry.Host}': {key}")
|
die(f"ssh key file not found for host '{entry.Host}': {key}")
|
||||||
|
|
||||||
|
def _validate_git_entries(self, entries: Sequence[GitEntry]) -> None:
|
||||||
|
"""Each entry's IdentityFile must exist on the host (after
|
||||||
|
expanding leading ~) — the git-gate copies it in at start time
|
||||||
|
to authenticate the upstream push (PRD 0008). Shape is already
|
||||||
|
enforced by Manifest validation; this only checks presence."""
|
||||||
|
for entry in entries:
|
||||||
|
key = expand_tilde(entry.IdentityFile)
|
||||||
|
if not os.path.isfile(key):
|
||||||
|
die(f"git upstream key file not found for '{entry.Name}': {key}")
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
||||||
"""Backend-specific plan resolution: image/container names,
|
"""Backend-specific plan resolution: image/container names,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from . import prepare as _prepare
|
|||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
from .provision import ca as _ca
|
from .provision import ca as _ca
|
||||||
from .provision import git as _git
|
from .provision import git as _git
|
||||||
@@ -41,16 +42,25 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._proxy = DockerPipelockProxy()
|
self._proxy = DockerPipelockProxy()
|
||||||
self._gate = DockerSSHGate()
|
self._gate = DockerSSHGate()
|
||||||
|
self._git_gate = DockerGitGate()
|
||||||
|
|
||||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||||
return _prepare.resolve_plan(
|
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
|
@contextmanager
|
||||||
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
||||||
with _launch.launch(
|
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:
|
) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import sys
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...git_gate import GitGatePlan
|
||||||
from ...log import info
|
from ...log import info
|
||||||
from ...manifest import Agent, Bottle
|
from ...manifest import Agent, Bottle
|
||||||
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
||||||
@@ -27,6 +28,7 @@ class _PlanView:
|
|||||||
bottle: Bottle
|
bottle: Bottle
|
||||||
env_names: list[str]
|
env_names: list[str]
|
||||||
ssh_hosts: list[str]
|
ssh_hosts: list[str]
|
||||||
|
git_names: list[str]
|
||||||
prompt_first_line: str
|
prompt_first_line: str
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
prompt_file: Path
|
prompt_file: Path
|
||||||
proxy_plan: PipelockProxyPlan
|
proxy_plan: PipelockProxyPlan
|
||||||
gate_plan: SSHGatePlan
|
gate_plan: SSHGatePlan
|
||||||
|
git_gate_plan: GitGatePlan
|
||||||
allowlist_summary: str
|
allowlist_summary: str
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
bottle=bottle,
|
bottle=bottle,
|
||||||
env_names=env_names,
|
env_names=env_names,
|
||||||
ssh_hosts=[e.Host for e in bottle.ssh],
|
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 "",
|
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)}")
|
info(f" ssh gate : {'; '.join(gate_lines)}")
|
||||||
else:
|
else:
|
||||||
info(" ssh hosts : (none)")
|
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(f" egress : {self.allowlist_summary}")
|
||||||
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
|
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
|
||||||
info(
|
info(
|
||||||
@@ -131,6 +145,16 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
}
|
}
|
||||||
for u in self.gate_plan.upstreams
|
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": {
|
"egress": {
|
||||||
"host_count": len(hosts),
|
"host_count": len(hosts),
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""DockerGitGate — the Docker-specific lifecycle for the per-agent
|
||||||
|
git-gate sidecar (PRD 0008). Inherits the platform-agnostic prepare
|
||||||
|
step (upstream lift + entrypoint/hook render) from `GitGate`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...git_gate import GitGate, GitGatePlan, git_gate_known_hosts_line
|
||||||
|
from ...log import die, info, warn
|
||||||
|
from ...util import expand_tilde
|
||||||
|
from . import util as docker_mod
|
||||||
|
|
||||||
|
|
||||||
|
GIT_GATE_IMAGE = os.environ.get(
|
||||||
|
"CLAUDE_BOTTLE_GIT_GATE_IMAGE",
|
||||||
|
"claude-bottle-git-gate:latest",
|
||||||
|
)
|
||||||
|
|
||||||
|
GIT_GATE_DOCKERFILE = "Dockerfile.git-gate"
|
||||||
|
|
||||||
|
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
|
||||||
|
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
||||||
|
GIT_GATE_ACCESS_HOOK_IN_CONTAINER = "/etc/git-gate/access-hook"
|
||||||
|
GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
|
||||||
|
|
||||||
|
# git daemon's default listening port. Surfaced as a constant because
|
||||||
|
# integration tests probe the gate on it.
|
||||||
|
GIT_GATE_PORT = 9418
|
||||||
|
|
||||||
|
# Repo root, for `docker build` context. Resolved from this file's
|
||||||
|
# location: claude_bottle/backend/docker/git_gate.py → repo root.
|
||||||
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def git_gate_container_name(slug: str) -> str:
|
||||||
|
return f"claude-bottle-git-gate-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def git_gate_host(slug: str) -> str:
|
||||||
|
"""The hostname the agent's git client should connect to (same as
|
||||||
|
the container name — Docker's embedded DNS resolves it on the
|
||||||
|
`--internal` network)."""
|
||||||
|
return git_gate_container_name(slug)
|
||||||
|
|
||||||
|
|
||||||
|
def build_git_gate_image() -> None:
|
||||||
|
"""Build the git-gate image from `Dockerfile.git-gate`. Called by
|
||||||
|
`DockerGitGate.start`; exposed at module level so integration
|
||||||
|
tests can build it without running the full launch pipeline."""
|
||||||
|
docker_mod.build_image(GIT_GATE_IMAGE, _REPO_DIR, dockerfile=GIT_GATE_DOCKERFILE)
|
||||||
|
|
||||||
|
|
||||||
|
class DockerGitGate(GitGate):
|
||||||
|
"""Brings the git-gate sidecar up and down via Docker."""
|
||||||
|
|
||||||
|
def start(self, plan: GitGatePlan) -> str:
|
||||||
|
"""Boot the gate sidecar:
|
||||||
|
1. Build the gate image (no-op when cache is hot).
|
||||||
|
2. `docker create` on the internal network with the canonical
|
||||||
|
name; the image's ENTRYPOINT runs the cp'd entrypoint
|
||||||
|
script at start time.
|
||||||
|
3. `docker cp` the entrypoint, the shared pre-receive hook,
|
||||||
|
and each upstream's identity + known_hosts into the
|
||||||
|
container.
|
||||||
|
4. Attach to the per-agent egress network so the gate can
|
||||||
|
reach the real upstream.
|
||||||
|
5. `docker start`.
|
||||||
|
Returns the container name (the target passed to `.stop`)."""
|
||||||
|
if not plan.upstreams:
|
||||||
|
die("DockerGitGate.start called with no upstreams; caller should skip")
|
||||||
|
if not plan.internal_network or not plan.egress_network:
|
||||||
|
die(
|
||||||
|
"DockerGitGate.start: internal_network / egress_network must be "
|
||||||
|
"populated on the plan before start"
|
||||||
|
)
|
||||||
|
if not plan.entrypoint_script.is_file():
|
||||||
|
die(
|
||||||
|
f"git-gate entrypoint missing at {plan.entrypoint_script}; "
|
||||||
|
f"GitGate.prepare must run first"
|
||||||
|
)
|
||||||
|
if not plan.hook_script.is_file():
|
||||||
|
die(
|
||||||
|
f"git-gate hook missing at {plan.hook_script}; "
|
||||||
|
f"GitGate.prepare must run first"
|
||||||
|
)
|
||||||
|
if not plan.access_hook_script.is_file():
|
||||||
|
die(
|
||||||
|
f"git-gate access-hook missing at {plan.access_hook_script}; "
|
||||||
|
f"GitGate.prepare must run first"
|
||||||
|
)
|
||||||
|
|
||||||
|
build_git_gate_image()
|
||||||
|
|
||||||
|
name = git_gate_container_name(plan.slug)
|
||||||
|
info(f"starting git-gate sidecar {name} on network {plan.internal_network}")
|
||||||
|
|
||||||
|
create_args = [
|
||||||
|
"docker", "create",
|
||||||
|
"--name", name,
|
||||||
|
"--network", plan.internal_network,
|
||||||
|
GIT_GATE_IMAGE,
|
||||||
|
]
|
||||||
|
if subprocess.run(
|
||||||
|
create_args,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
).returncode != 0:
|
||||||
|
die(f"failed to create git-gate sidecar {name}")
|
||||||
|
|
||||||
|
# Order matters: entrypoint + hook first so they're present
|
||||||
|
# when docker start fires. Per-upstream creds afterwards.
|
||||||
|
stage_dir = plan.entrypoint_script.parent
|
||||||
|
cps: list[tuple[str, str, str]] = [
|
||||||
|
(str(plan.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, "entrypoint"),
|
||||||
|
(str(plan.hook_script), GIT_GATE_HOOK_IN_CONTAINER, "pre-receive hook"),
|
||||||
|
(str(plan.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, "access-hook"),
|
||||||
|
]
|
||||||
|
for u in plan.upstreams:
|
||||||
|
keypath = expand_tilde(u.identity_file)
|
||||||
|
cps.append((
|
||||||
|
keypath,
|
||||||
|
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
||||||
|
f"upstream key for '{u.name}'",
|
||||||
|
))
|
||||||
|
if u.known_host_key:
|
||||||
|
hosts_path = stage_dir / f"git_gate_known_hosts_{u.name}"
|
||||||
|
hosts_path.write_text(
|
||||||
|
git_gate_known_hosts_line(
|
||||||
|
u.upstream_host, u.upstream_port, u.known_host_key
|
||||||
|
)
|
||||||
|
)
|
||||||
|
hosts_path.chmod(0o600)
|
||||||
|
cps.append((
|
||||||
|
str(hosts_path),
|
||||||
|
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
||||||
|
f"upstream known_hosts for '{u.name}'",
|
||||||
|
))
|
||||||
|
|
||||||
|
for src, dst, label in cps:
|
||||||
|
cp_result = subprocess.run(
|
||||||
|
["docker", "cp", src, f"{name}:{dst}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if cp_result.returncode != 0:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
die(
|
||||||
|
f"failed to copy {label} into {name}: "
|
||||||
|
f"{cp_result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if subprocess.run(
|
||||||
|
["docker", "network", "connect", plan.egress_network, name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
).returncode != 0:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
die(
|
||||||
|
f"failed to attach git-gate sidecar {name} to egress network "
|
||||||
|
f"{plan.egress_network}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if subprocess.run(
|
||||||
|
["docker", "start", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
).returncode != 0:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
die(f"failed to start git-gate sidecar {name}")
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
def stop(self, target: str) -> None:
|
||||||
|
"""Idempotent: missing container is success. `target` is the
|
||||||
|
container name returned by `.start`."""
|
||||||
|
if subprocess.run(
|
||||||
|
["docker", "inspect", target],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
).returncode == 0:
|
||||||
|
if subprocess.run(
|
||||||
|
["docker", "rm", "-f", target],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
).returncode != 0:
|
||||||
|
warn(
|
||||||
|
f"failed to remove git-gate sidecar {target}; "
|
||||||
|
f"clean up with 'docker rm -f {target}'"
|
||||||
|
)
|
||||||
@@ -22,6 +22,7 @@ from . import network as network_mod
|
|||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
||||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
from .ssh_gate import DockerSSHGate
|
from .ssh_gate import DockerSSHGate
|
||||||
@@ -37,6 +38,7 @@ def launch(
|
|||||||
*,
|
*,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
gate: DockerSSHGate,
|
gate: DockerSSHGate,
|
||||||
|
git_gate: DockerGitGate,
|
||||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||||
) -> Generator[DockerBottle, None, None]:
|
) -> Generator[DockerBottle, None, None]:
|
||||||
"""Build, launch, and provision a Docker bottle. Teardown on exit.
|
"""Build, launch, and provision a Docker bottle. Teardown on exit.
|
||||||
@@ -102,6 +104,21 @@ def launch(
|
|||||||
gate_name = gate.start(plan.gate_plan)
|
gate_name = gate.start(plan.gate_plan)
|
||||||
stack.callback(gate.stop, gate_name)
|
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://<container-name>/<name>.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)
|
container = _run_agent_container(plan, internal_network)
|
||||||
stack.callback(docker_mod.force_remove_container, container)
|
stack.callback(docker_mod.force_remove_container, container)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ...log import die
|
|||||||
from .. import BottleSpec
|
from .. import BottleSpec
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
from .ssh_gate import DockerSSHGate
|
from .ssh_gate import DockerSSHGate
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ def resolve_plan(
|
|||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
gate: DockerSSHGate,
|
gate: DockerSSHGate,
|
||||||
|
git_gate: DockerGitGate,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||||
that the agent and its skills/SSH keys are present — validation
|
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)
|
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
||||||
gate_plan = gate.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)
|
resolved = resolve_env(manifest, spec.agent_name)
|
||||||
# Everything that should reach the bottle by-name (so its value
|
# Everything that should reach the bottle by-name (so its value
|
||||||
# never lands on argv or in env_file) goes into one dict. The
|
# never lands on argv or in env_file) goes into one dict. The
|
||||||
@@ -109,6 +112,7 @@ def resolve_plan(
|
|||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
proxy_plan=proxy_plan,
|
proxy_plan=proxy_plan,
|
||||||
gate_plan=gate_plan,
|
gate_plan=gate_plan,
|
||||||
|
git_gate_plan=git_gate_plan,
|
||||||
allowlist_summary=allowlist_summary,
|
allowlist_summary=allowlist_summary,
|
||||||
use_runsc=use_runsc,
|
use_runsc=use_runsc,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,19 +1,39 @@
|
|||||||
"""Copy the host cwd's .git directory into a running Docker bottle.
|
"""Git provisioning inside a running Docker bottle.
|
||||||
|
|
||||||
Only fires when `--cwd` was passed AND the host cwd actually has a
|
Two concerns, both about git in the agent:
|
||||||
.git. The container-side path is fixed at /home/node/workspace/.git;
|
|
||||||
ownership is reset to node so the agent can run git commands."""
|
1. If --cwd was passed AND the host cwd has a .git, copy that .git
|
||||||
|
into /home/node/workspace/.git so the agent operates on the
|
||||||
|
user's repo.
|
||||||
|
2. If the bottle declares `git` entries (PRD 0008), write a
|
||||||
|
~/.gitconfig with insteadOf rules so every git operation
|
||||||
|
against a declared upstream (push, fetch, clone, pull,
|
||||||
|
ls-remote) transparently hits the per-agent git-gate. The
|
||||||
|
gate mirrors the upstream in both directions, so URL
|
||||||
|
rewriting is symmetric.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ....log import info
|
from ....log import info
|
||||||
|
from ....manifest import GitEntry
|
||||||
|
from .. import util as docker_mod
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
from ..git_gate import git_gate_host
|
||||||
|
|
||||||
|
|
||||||
def provision_git(plan: DockerBottlePlan, target: str) -> None:
|
def provision_git(plan: DockerBottlePlan, target: str) -> None:
|
||||||
|
"""Set up git inside the bottle. Runs both subcases; each no-ops
|
||||||
|
when its condition isn't met."""
|
||||||
|
_provision_cwd_git(plan, target)
|
||||||
|
_provision_git_gate_config(plan, target)
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
|
||||||
"""If --cwd was set and the host cwd has a .git directory, copy
|
"""If --cwd was set and the host cwd has a .git directory, copy
|
||||||
it into /home/node/workspace/.git and fix ownership. No-op
|
it into /home/node/workspace/.git and fix ownership. No-op
|
||||||
otherwise."""
|
otherwise."""
|
||||||
@@ -34,3 +54,49 @@ def provision_git(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
|
||||||
|
"""Render the ~/.gitconfig content for git-gate `insteadOf`
|
||||||
|
rewrites. Pure host-side, no docker; exposed for tests.
|
||||||
|
|
||||||
|
Empty `entries` returns an empty string so callers can no-op
|
||||||
|
cleanly without conditional formatting at the call site."""
|
||||||
|
if not entries:
|
||||||
|
return ""
|
||||||
|
gate = git_gate_host(slug)
|
||||||
|
out = [
|
||||||
|
"# claude-bottle git-gate (PRD 0008): every git operation against\n",
|
||||||
|
"# a declared upstream routes through the gate, which mirrors\n",
|
||||||
|
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
||||||
|
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
||||||
|
]
|
||||||
|
for entry in entries:
|
||||||
|
out.append(f'[url "git://{gate}/{entry.Name}.git"]\n')
|
||||||
|
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
|
||||||
|
"""Write ~/.gitconfig in the bottle with the git-gate
|
||||||
|
insteadOf rules. No-op when the bottle has no `git` entries."""
|
||||||
|
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
|
if not bottle.git:
|
||||||
|
return
|
||||||
|
container = target
|
||||||
|
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
|
container_gitconfig = f"{container_home}/.gitconfig"
|
||||||
|
|
||||||
|
content = render_git_gate_gitconfig(plan.slug, bottle.git)
|
||||||
|
config_file = plan.stage_dir / "agent_gitconfig"
|
||||||
|
config_file.write_text(content)
|
||||||
|
config_file.chmod(0o600)
|
||||||
|
|
||||||
|
info(f"writing {container_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig])
|
||||||
|
docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig])
|
||||||
|
|||||||
@@ -100,12 +100,20 @@ def slugify(name: str) -> str:
|
|||||||
return slug
|
return slug
|
||||||
|
|
||||||
|
|
||||||
def build_image(ref: str, context: str) -> None:
|
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||||
"""Invokes `docker build` every call. Layer cache makes no-change
|
"""Invokes `docker build` every call. Layer cache makes no-change
|
||||||
rebuilds cheap; running every time means Dockerfile edits land
|
rebuilds cheap; running every time means Dockerfile edits land
|
||||||
without manual `docker rmi`."""
|
without manual `docker rmi`.
|
||||||
|
|
||||||
|
`dockerfile` is an optional path (relative to `context`, or
|
||||||
|
absolute) for callers that need to build from a non-default
|
||||||
|
Dockerfile in the same context — e.g. `Dockerfile.git-gate`."""
|
||||||
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
||||||
subprocess.run(["docker", "build", "-t", ref, context], check=True)
|
args = ["docker", "build", "-t", ref]
|
||||||
|
if dockerfile:
|
||||||
|
args.extend(["-f", dockerfile])
|
||||||
|
args.append(context)
|
||||||
|
subprocess.run(args, check=True)
|
||||||
|
|
||||||
|
|
||||||
_TRUST_DIALOG_NODE_SCRIPT = (
|
_TRUST_DIALOG_NODE_SCRIPT = (
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
"""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 third sidecar (not folded into pipelock or ssh-gate): the
|
||||||
|
gate is the only one of the three that holds upstream push
|
||||||
|
credentials. Mixing it with pipelock 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
|
||||||
|
`claude_bottle/backend/docker/git_gate.py`)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .manifest import Bottle
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitGateUpstream:
|
||||||
|
"""One bare repo on the gate. `name` drives the bare-repo path
|
||||||
|
(`/git/<name>.git`), the agent's URL after insteadOf rewrite
|
||||||
|
(`git://<gate>/<name>.git`), and the per-upstream credential
|
||||||
|
paths inside the gate (`/git-gate/creds/<name>-key` and
|
||||||
|
`/git-gate/creds/<name>-known_hosts`).
|
||||||
|
|
||||||
|
`identity_file` is the host-side absolute path the gate's start
|
||||||
|
step will docker-cp into the container. `known_host_key` is the
|
||||||
|
KnownHostKey string from the manifest; the gate's start step
|
||||||
|
materialises it into a known_hosts file if non-empty."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
upstream_url: str
|
||||||
|
upstream_host: str
|
||||||
|
upstream_port: str
|
||||||
|
identity_file: str
|
||||||
|
known_host_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@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 = ""
|
||||||
|
|
||||||
|
|
||||||
|
def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]:
|
||||||
|
"""Lift each `bottle.git` entry into a GitGateUpstream. Cross-entry
|
||||||
|
validation (unique Names, no shadow route with bottle.ssh) already
|
||||||
|
ran in `manifest.Bottle.from_dict`."""
|
||||||
|
return tuple(
|
||||||
|
GitGateUpstream(
|
||||||
|
name=e.Name,
|
||||||
|
upstream_url=e.Upstream,
|
||||||
|
upstream_host=e.UpstreamHost,
|
||||||
|
upstream_port=e.UpstreamPort,
|
||||||
|
identity_file=e.IdentityFile,
|
||||||
|
known_host_key=e.KnownHostKey,
|
||||||
|
)
|
||||||
|
for e in bottle.git
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def git_gate_known_hosts_line(host: str, port: str, key: str) -> str:
|
||||||
|
"""Format `host[:port] key` for OpenSSH's known_hosts. Non-default
|
||||||
|
ports use the bracketed `[host]:port` form (the form OpenSSH writes
|
||||||
|
on disk for hosts reached via a non-22 port)."""
|
||||||
|
if port and port != "22":
|
||||||
|
target = f"[{host}]:{port}"
|
||||||
|
else:
|
||||||
|
target = host
|
||||||
|
return f"{target} {key}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||||
|
"""Posix-sh entrypoint (alpine ash). One `init_repo` call per
|
||||||
|
upstream, then `exec git daemon`. The function reads
|
||||||
|
`/git-gate/creds/<name>-{key,known_hosts}` (laid down by
|
||||||
|
`DockerGitGate.start` via docker cp) and wires them into each
|
||||||
|
bare repo's config; the access-hook + pre-receive hook pick those
|
||||||
|
paths up at fetch / push time."""
|
||||||
|
lines = [
|
||||||
|
"#!/bin/sh",
|
||||||
|
"set -eu",
|
||||||
|
"",
|
||||||
|
"init_repo() {",
|
||||||
|
" name=$1",
|
||||||
|
" upstream_url=$2",
|
||||||
|
" keyfile=/git-gate/creds/${name}-key",
|
||||||
|
" hostsfile=/git-gate/creds/${name}-known_hosts",
|
||||||
|
"",
|
||||||
|
" chmod 600 \"$keyfile\"",
|
||||||
|
" if [ -f \"$hostsfile\" ]; then",
|
||||||
|
" chmod 600 \"$hostsfile\"",
|
||||||
|
" fi",
|
||||||
|
"",
|
||||||
|
" repo=/git/${name}.git",
|
||||||
|
" if [ ! -d \"$repo\" ]; then",
|
||||||
|
" git init --bare \"$repo\" >/dev/null",
|
||||||
|
# --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so",
|
||||||
|
# a later `git fetch origin` mirrors the upstream's full ref",
|
||||||
|
# graph (heads, tags, notes) into the bare repo at canonical",
|
||||||
|
# paths. It does NOT set remote.origin.mirror=true, so an",
|
||||||
|
# explicit `git push origin <ref>:<ref>` still pushes one ref.",
|
||||||
|
" git -C \"$repo\" remote add --mirror=fetch origin \"$upstream_url\"",
|
||||||
|
" fi",
|
||||||
|
" git -C \"$repo\" config git-gate.identityFile \"$keyfile\"",
|
||||||
|
" git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"",
|
||||||
|
" git -C \"$repo\" config receive.denyCurrentBranch ignore",
|
||||||
|
" install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"mkdir -p /git",
|
||||||
|
]
|
||||||
|
for u in upstreams:
|
||||||
|
# Single-quote args so URL/path content (containing : and /)
|
||||||
|
# passes through ash unmangled. Names came through the manifest
|
||||||
|
# validator so they don't contain a single quote.
|
||||||
|
lines.append(f"init_repo '{u.name}' '{u.upstream_url}'")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"exec git daemon \\",
|
||||||
|
" --reuseaddr \\",
|
||||||
|
" --base-path=/git \\",
|
||||||
|
" --export-all \\",
|
||||||
|
" --enable=receive-pack \\",
|
||||||
|
" --access-hook=/etc/git-gate/access-hook \\",
|
||||||
|
" --verbose",
|
||||||
|
])
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def git_gate_render_hook() -> str:
|
||||||
|
"""The shared pre-receive hook: gitleaks-scan all incoming refs,
|
||||||
|
then forward each accepted ref to the real upstream (`origin`)
|
||||||
|
using the per-repo credential. Failure in either phase aborts
|
||||||
|
the push so the agent sees a real rejection. POSIX sh.
|
||||||
|
|
||||||
|
Two phases (scan all, then push all) keeps a hit on ref N from
|
||||||
|
half-pushing refs 1..N-1; both phases re-read stdin from a temp
|
||||||
|
file because pre-receive's stdin is a one-shot stream."""
|
||||||
|
return r"""#!/bin/sh
|
||||||
|
# git-gate pre-receive (PRD 0008). Stdin: <old> <new> <ref> per line.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
refs_file=$(mktemp)
|
||||||
|
trap 'rm -f "$refs_file"' EXIT
|
||||||
|
cat > "$refs_file"
|
||||||
|
|
||||||
|
zero=0000000000000000000000000000000000000000
|
||||||
|
|
||||||
|
# Phase 1: gitleaks scan each ref's incoming commits.
|
||||||
|
while IFS=' ' read -r old new ref; do
|
||||||
|
[ -z "$ref" ] && continue
|
||||||
|
[ "$new" = "$zero" ] && continue
|
||||||
|
if [ "$old" = "$zero" ]; then
|
||||||
|
log_opts="$new"
|
||||||
|
else
|
||||||
|
log_opts="$old..$new"
|
||||||
|
fi
|
||||||
|
echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2
|
||||||
|
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
|
||||||
|
echo "git-gate: gitleaks rejected push to $ref" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done < "$refs_file"
|
||||||
|
|
||||||
|
# Phase 2: forward each ref to the upstream (`origin`, configured
|
||||||
|
# in the entrypoint via `git remote add --mirror=fetch`).
|
||||||
|
keyfile=$(git config --get git-gate.identityFile)
|
||||||
|
hostsfile=$(git config --get git-gate.knownHosts)
|
||||||
|
if [ ! -f "$hostsfile" ]; then
|
||||||
|
echo "git-gate: no KnownHostKey configured for this upstream; refusing to push" >&2
|
||||||
|
echo "git-gate: add KnownHostKey to the bottle.git entry and restart the bottle" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes"
|
||||||
|
|
||||||
|
while IFS=' ' read -r old new ref; do
|
||||||
|
[ -z "$ref" ] && continue
|
||||||
|
if [ "$new" = "$zero" ]; then
|
||||||
|
refspec=":$ref"
|
||||||
|
else
|
||||||
|
refspec="$new:$ref"
|
||||||
|
fi
|
||||||
|
echo "git-gate: forwarding $ref to origin" >&2
|
||||||
|
if ! GIT_SSH_COMMAND="$ssh_cmd" git push origin "$refspec" 1>&2; then
|
||||||
|
echo "git-gate: upstream push failed for $ref" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done < "$refs_file"
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def git_gate_render_access_hook() -> str:
|
||||||
|
"""`git daemon --access-hook` script. Runs before each protocol
|
||||||
|
service; for `upload-pack` (fetch / clone / ls-remote / pull) it
|
||||||
|
refreshes the bare repo from upstream first, so the response
|
||||||
|
reflects upstream's current state. For other services (notably
|
||||||
|
`receive-pack`) it returns 0 immediately and lets the existing
|
||||||
|
pre-receive hook gate the operation. POSIX sh.
|
||||||
|
|
||||||
|
The hook receives:
|
||||||
|
$1 service name (`upload-pack`, `receive-pack`, ...)
|
||||||
|
$2 absolute path to the resolved repo
|
||||||
|
$3 client hostname (unused)
|
||||||
|
$4 client tcp address (unused)
|
||||||
|
|
||||||
|
Fail-closed on upstream errors: the agent's fetch fails too,
|
||||||
|
so it never silently sees stale data — matches the PRD's
|
||||||
|
'equivalent to operations against the upstream' contract."""
|
||||||
|
return r"""#!/bin/sh
|
||||||
|
# git-gate access-hook (PRD 0008). $1=service $2=repo $3=host $4=peer
|
||||||
|
set -u
|
||||||
|
service=$1
|
||||||
|
repo_dir=$2
|
||||||
|
|
||||||
|
# Push path keeps its own gating in pre-receive (gitleaks +
|
||||||
|
# forward). Only refresh-from-upstream on fetch operations.
|
||||||
|
if [ "$service" != "upload-pack" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
keyfile=$(git -C "$repo_dir" config --get git-gate.identityFile 2>/dev/null || true)
|
||||||
|
hostsfile=$(git -C "$repo_dir" config --get git-gate.knownHosts 2>/dev/null || true)
|
||||||
|
if [ -z "$keyfile" ] || [ ! -f "$hostsfile" ]; then
|
||||||
|
echo "git-gate: missing credentials for $repo_dir; refusing fetch" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes"
|
||||||
|
|
||||||
|
echo "git-gate: refreshing $repo_dir from upstream" >&2
|
||||||
|
if ! GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" fetch origin --prune >&2; then
|
||||||
|
echo "git-gate: upstream fetch failed for $repo_dir; refusing to serve stale data" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sync the bare repo's HEAD to upstream's HEAD on the first fetch
|
||||||
|
# (when it still points at the `git init --bare` default of
|
||||||
|
# refs/heads/master and upstream uses something else, the cloned
|
||||||
|
# checkout would fail with "remote HEAD refers to nonexistent ref").
|
||||||
|
# Costs one extra ls-remote on first fetch only; subsequent fetches
|
||||||
|
# skip the branch. If upstream's default branch changes after the
|
||||||
|
# gate has cached it, restart the bottle to resync.
|
||||||
|
if ! git -C "$repo_dir" rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||||
|
upstream_head=$(GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" \
|
||||||
|
ls-remote --symref origin HEAD 2>/dev/null \
|
||||||
|
| awk '/^ref:/ {print $2; exit}')
|
||||||
|
if [ -n "$upstream_head" ]; then
|
||||||
|
git -C "$repo_dir" symbolic-ref HEAD "$upstream_head" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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: Bottle, 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.
|
||||||
|
|
||||||
|
Returned plan is incomplete: the launch step must fill
|
||||||
|
`internal_network` / `egress_network` via `dataclasses.replace`
|
||||||
|
before passing the plan to `.start`."""
|
||||||
|
upstreams = git_gate_upstreams_for_bottle(bottle)
|
||||||
|
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)
|
||||||
|
return GitGatePlan(
|
||||||
|
slug=slug,
|
||||||
|
entrypoint_script=entrypoint,
|
||||||
|
hook_script=hook,
|
||||||
|
access_hook_script=access_hook,
|
||||||
|
upstreams=upstreams,
|
||||||
|
)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def start(self, plan: GitGatePlan) -> str:
|
||||||
|
"""Bring up the gate sidecar according to `plan`. Returns the
|
||||||
|
target string identifying the running instance — the same
|
||||||
|
value to pass to `.stop`. Backend-specific."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def stop(self, target: str) -> None:
|
||||||
|
"""Tear down the gate sidecar identified by `target` (the
|
||||||
|
value `.start` returned). Idempotent: a missing target is
|
||||||
|
success. Backend-specific."""
|
||||||
+141
-1
@@ -7,6 +7,7 @@ Schema (see CLAUDE.md "Intended design"):
|
|||||||
"<bottle-name>": {
|
"<bottle-name>": {
|
||||||
"env": { "<NAME>": <env-entry>, ... },
|
"env": { "<NAME>": <env-entry>, ... },
|
||||||
"ssh": [ <ssh-entry>, ... ],
|
"ssh": [ <ssh-entry>, ... ],
|
||||||
|
"git": [ <git-entry>, ... ],
|
||||||
"egress": { "allowlist": [ "<hostname>", ... ] }
|
"egress": { "allowlist": [ "<hostname>", ... ] }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -79,6 +80,65 @@ class SshEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitEntry:
|
||||||
|
"""One upstream the per-agent git-gate (PRD 0008) is allowed to
|
||||||
|
talk to. `Upstream` is the real remote URL the agent would push to
|
||||||
|
if there were no gate; the gate hosts a bare repo at /git/<Name>.git
|
||||||
|
and `IdentityFile` is the SSH key the gate uses to push that repo
|
||||||
|
upstream after gitleaks passes. The agent itself never holds the
|
||||||
|
upstream credential.
|
||||||
|
|
||||||
|
The Upstream URL is parsed once at construction and the pieces are
|
||||||
|
stashed in the `Upstream*` fields so the git-gate render step
|
||||||
|
doesn't have to re-parse."""
|
||||||
|
|
||||||
|
Name: str
|
||||||
|
Upstream: str
|
||||||
|
IdentityFile: str
|
||||||
|
KnownHostKey: str = ""
|
||||||
|
UpstreamUser: str = ""
|
||||||
|
UpstreamHost: str = ""
|
||||||
|
UpstreamPort: str = ""
|
||||||
|
UpstreamPath: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "GitEntry":
|
||||||
|
d = _as_json_object(raw, f"bottle '{bottle_name}' git[{idx}]")
|
||||||
|
name = d.get("Name")
|
||||||
|
if not isinstance(name, str) or not name:
|
||||||
|
die(f"bottle '{bottle_name}' git[{idx}] missing required string field 'Name'")
|
||||||
|
upstream = d.get("Upstream")
|
||||||
|
if not isinstance(upstream, str) or not upstream:
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' git '{name}' missing required string field "
|
||||||
|
f"'Upstream'"
|
||||||
|
)
|
||||||
|
ident = d.get("IdentityFile")
|
||||||
|
if not isinstance(ident, str) or not ident:
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' git '{name}' missing required string field "
|
||||||
|
f"'IdentityFile'"
|
||||||
|
)
|
||||||
|
khk = _opt_str(
|
||||||
|
d.get("KnownHostKey"),
|
||||||
|
f"bottle '{bottle_name}' git '{name}' KnownHostKey",
|
||||||
|
)
|
||||||
|
user, host, port, path = _parse_git_upstream(
|
||||||
|
upstream, f"bottle '{bottle_name}' git '{name}' Upstream"
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
Name=name,
|
||||||
|
Upstream=upstream,
|
||||||
|
IdentityFile=ident,
|
||||||
|
KnownHostKey=khk,
|
||||||
|
UpstreamUser=user,
|
||||||
|
UpstreamHost=host,
|
||||||
|
UpstreamPort=port,
|
||||||
|
UpstreamPath=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DLP_ACTIONS = ("block", "warn")
|
DLP_ACTIONS = ("block", "warn")
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +194,7 @@ class BottleEgress:
|
|||||||
class Bottle:
|
class Bottle:
|
||||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||||
ssh: tuple[SshEntry, ...] = ()
|
ssh: tuple[SshEntry, ...] = ()
|
||||||
|
git: tuple[GitEntry, ...] = ()
|
||||||
egress: BottleEgress = field(default_factory=BottleEgress)
|
egress: BottleEgress = field(default_factory=BottleEgress)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -171,6 +232,19 @@ class Bottle:
|
|||||||
for i, entry in enumerate(ssh_list)
|
for i, entry in enumerate(ssh_list)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
git: tuple[GitEntry, ...] = ()
|
||||||
|
git_raw = d.get("git")
|
||||||
|
if git_raw is not None:
|
||||||
|
if not isinstance(git_raw, list):
|
||||||
|
die(f"bottle '{name}' git must be an array (was {type(git_raw).__name__})")
|
||||||
|
git_list = cast(list[object], git_raw)
|
||||||
|
git = tuple(
|
||||||
|
GitEntry.from_dict(name, i, entry)
|
||||||
|
for i, entry in enumerate(git_list)
|
||||||
|
)
|
||||||
|
_validate_unique_git_names(name, git)
|
||||||
|
_validate_no_shadow_route(name, ssh, git)
|
||||||
|
|
||||||
egress_raw = d.get("egress")
|
egress_raw = d.get("egress")
|
||||||
egress = (
|
egress = (
|
||||||
BottleEgress.from_dict(name, egress_raw)
|
BottleEgress.from_dict(name, egress_raw)
|
||||||
@@ -178,7 +252,7 @@ class Bottle:
|
|||||||
else BottleEgress()
|
else BottleEgress()
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(env=env, ssh=ssh, egress=egress)
|
return cls(env=env, ssh=ssh, git=git, egress=egress)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -359,3 +433,69 @@ def _opt_port(value: object, label: str) -> str:
|
|||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
die(f"{label} must be a string or number (was {type(value).__name__})")
|
die(f"{label} must be a string or number (was {type(value).__name__})")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
||||||
|
"""Parse `ssh://user@host[:port]/path` into (user, host, port, path).
|
||||||
|
Dies if `url` doesn't match the ssh:// shape v1 supports. Default
|
||||||
|
port is 22 (matches OpenSSH)."""
|
||||||
|
if not url.startswith("ssh://"):
|
||||||
|
die(f"{label} must be an ssh:// URL (was {url!r})")
|
||||||
|
rest = url[len("ssh://"):]
|
||||||
|
if "@" not in rest:
|
||||||
|
die(f"{label} must include a user (e.g. ssh://git@host/path.git); was {url!r}")
|
||||||
|
user, _, hostpart = rest.partition("@")
|
||||||
|
if not user:
|
||||||
|
die(f"{label} user is empty in {url!r}")
|
||||||
|
if "/" not in hostpart:
|
||||||
|
die(f"{label} must include a path (e.g. ssh://git@host/path.git); was {url!r}")
|
||||||
|
hostport, _, path = hostpart.partition("/")
|
||||||
|
if not path:
|
||||||
|
die(f"{label} path is empty in {url!r}")
|
||||||
|
if ":" in hostport:
|
||||||
|
host, _, port = hostport.partition(":")
|
||||||
|
if not port.isdigit():
|
||||||
|
die(f"{label} port must be numeric in {url!r}")
|
||||||
|
else:
|
||||||
|
host = hostport
|
||||||
|
port = "22"
|
||||||
|
if not host:
|
||||||
|
die(f"{label} host is empty in {url!r}")
|
||||||
|
return (user, host, port, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
|
||||||
|
seen: dict[str, None] = {}
|
||||||
|
for g in git:
|
||||||
|
if g.Name in seen:
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' git entries have duplicate Name '{g.Name}'; "
|
||||||
|
f"each entry maps to a distinct bare repo on the gate."
|
||||||
|
)
|
||||||
|
seen[g.Name] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_no_shadow_route(
|
||||||
|
bottle_name: str,
|
||||||
|
ssh: tuple[SshEntry, ...],
|
||||||
|
git: tuple[GitEntry, ...],
|
||||||
|
) -> None:
|
||||||
|
"""Reject if any git entry's (host, port) matches an ssh entry's
|
||||||
|
(Hostname, Port). The same upstream reachable two ways — once through
|
||||||
|
the L4 ssh-gate, once through the gitleaks-bearing git-gate — defeats
|
||||||
|
the git-gate."""
|
||||||
|
ssh_targets: dict[tuple[str, str], str] = {}
|
||||||
|
for e in ssh:
|
||||||
|
if not e.Hostname:
|
||||||
|
continue
|
||||||
|
port = e.Port or "22"
|
||||||
|
ssh_targets[(e.Hostname, port)] = e.Host
|
||||||
|
for g in git:
|
||||||
|
ssh_host = ssh_targets.get((g.UpstreamHost, g.UpstreamPort))
|
||||||
|
if ssh_host is not None:
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' has ssh entry '{ssh_host}' "
|
||||||
|
f"({g.UpstreamHost}:{g.UpstreamPort}) and git entry '{g.Name}' "
|
||||||
|
f"pointing at the same upstream. The same remote reachable two "
|
||||||
|
f"ways defeats the git-gate; remove one."
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# PRD 0008: Git gate
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-05-12
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Per-bottle sidecar that fronts the agent's git remotes as a
|
||||||
|
transparent mirror. Push is gated: gitleaks scans incoming refs
|
||||||
|
via a `pre-receive` hook, and only clean refs get forwarded to
|
||||||
|
the real upstream. Fetch is mirrored: every `upload-pack` first
|
||||||
|
runs `git fetch origin --prune` against the upstream via the
|
||||||
|
daemon's `--access-hook`, so an agent fetch returns whatever the
|
||||||
|
upstream has *now* (fail-closed if the upstream is unreachable).
|
||||||
|
|
||||||
|
Upstream credentials live in the gate, not the agent — so a
|
||||||
|
misbehaving agent cannot push a secret-bearing commit past it
|
||||||
|
and cannot acquire push access by inspecting the agent's own
|
||||||
|
filesystem.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Today the agent holds its own SSH identity for each `bottle.ssh`
|
||||||
|
entry and pushes straight at gitea/github with ssh-gate doing dumb
|
||||||
|
L4 forwarding. There is no boundary between "the agent thinks this
|
||||||
|
commit is fine" and "the secret hits an external remote." If a
|
||||||
|
compromised or careless agent stages a `.env`, slips a token into
|
||||||
|
a fixture, or commits the `CLAUDE_BOTTLE_OAUTH_TOKEN` itself, `git
|
||||||
|
push` ships it.
|
||||||
|
|
||||||
|
Host-side pre-commit / pre-push hooks are the usual defense, but
|
||||||
|
they live on the agent's side of the trust boundary: an agent with
|
||||||
|
shell access can `git push --no-verify` past them, edit
|
||||||
|
`.githooks/`, or `git config core.hooksPath /dev/null`. Anything
|
||||||
|
the agent can disable is not a gate.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
Two integration tests, both with the gate as the only git path
|
||||||
|
for a declared upstream:
|
||||||
|
|
||||||
|
1. **Push:** drop a synthetic high-entropy secret into a commit,
|
||||||
|
run `git push` from inside the agent, observe a non-zero exit
|
||||||
|
and a gitleaks finding in the response. Repeat with a clean
|
||||||
|
commit and observe exit 0 + the commit landing on the real
|
||||||
|
upstream.
|
||||||
|
2. **Fetch:** clone the upstream through the gate (`git clone`
|
||||||
|
against the gate URL), observe the upstream's content. Push
|
||||||
|
a new commit to the upstream out-of-band, refetch through the
|
||||||
|
gate, observe the new commit. The gate must never serve stale
|
||||||
|
data — every fetch refreshes from upstream first.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Pre-commit scanning. The gate is a `pre-receive` checkpoint
|
||||||
|
only; it does not run on `git commit`, does not block local
|
||||||
|
commits, and does not edit the agent's working tree.
|
||||||
|
- Git-protocol awareness beyond what `pre-receive` already gives
|
||||||
|
you. No bespoke pack inspection; gitleaks runs against the
|
||||||
|
incoming ref(s) in a bare repo, full stop.
|
||||||
|
- Per-user authentication on the agent → gate hop. The hop sits
|
||||||
|
inside a single bottle on an `--internal` Docker network; only
|
||||||
|
the bottle's agent can reach the gate. No additional ACLs.
|
||||||
|
- Subsuming ssh-gate or pipelock. Non-git SSH (if any) keeps
|
||||||
|
flowing through ssh-gate; HTTPS through pipelock. The git-gate
|
||||||
|
is git-only.
|
||||||
|
- Multi-tenant gate. One gate is provisioned per bottle, not
|
||||||
|
shared across bottles (same one-sidecar-per-agent posture as
|
||||||
|
pipelock / ssh-gate).
|
||||||
|
- Smolmachines / microVM colocation policy. Whether the future
|
||||||
|
smolmachines backend packs gates into one VM or runs them as
|
||||||
|
separate VMs is a backend decision, not a manifest or design
|
||||||
|
decision in this PRD. See "Future work."
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- **Gate sidecar lifecycle.** New `GitGate` + `DockerGitGate`,
|
||||||
|
mirroring `DockerSSHGate` and `DockerPipelockProxy` in shape and
|
||||||
|
network-attachment story.
|
||||||
|
- **Manifest field.** `bottle.git` — a list of git remotes the
|
||||||
|
bottle is allowed to talk to, each with the credential the gate
|
||||||
|
uses to push upstream. The agent gets no parallel `bottle.ssh`
|
||||||
|
entry for those upstreams.
|
||||||
|
- **Agent-side URL rewrite.** Provisioner emits `~/.gitconfig`
|
||||||
|
with `[url "<gate-url>"] insteadOf = <real-url>` so every git
|
||||||
|
operation against the declared upstream (push, fetch, clone,
|
||||||
|
pull, ls-remote) transparently hits the gate.
|
||||||
|
- **Pre-receive gitleaks hook.** Baked into the gate image. On a
|
||||||
|
hit the hook exits non-zero and the push fails; on clean it
|
||||||
|
shells out `git push origin <ref>:<ref>` using the gate-resident
|
||||||
|
credential.
|
||||||
|
- **Access-hook upstream refresh.** `git daemon --access-hook` runs
|
||||||
|
`git fetch origin --prune` against the upstream before every
|
||||||
|
`upload-pack` request, so a fetch through the gate is observably
|
||||||
|
equivalent to a fetch against the real upstream. Failure to reach
|
||||||
|
the upstream is fail-closed: the access hook exits non-zero and
|
||||||
|
the agent's fetch fails.
|
||||||
|
- **Plan rendering / dry-run.** `bottle_plan.py` and the y/N
|
||||||
|
preflight surface the gate sidecar (name, listed upstreams,
|
||||||
|
which credential it holds per upstream).
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Push policy beyond gitleaks. No commit-author allowlist, no
|
||||||
|
branch-name policy, no signed-commit enforcement. gitleaks is
|
||||||
|
the single rule for v1.
|
||||||
|
- Fetch caching / stale-while-revalidate. Every `upload-pack`
|
||||||
|
refresh is a synchronous round-trip to the upstream; there is
|
||||||
|
no TTL cache, no background refresh. If the upstream is slow,
|
||||||
|
the agent's fetch is slow.
|
||||||
|
- Quarantine / replay. A rejected push is discarded; we do not
|
||||||
|
stash it for the user to inspect.
|
||||||
|
- Non-Docker backends. Implementation lands for Docker only; the
|
||||||
|
`BottleBackend` abstraction gains the hook but other backends
|
||||||
|
are deferred.
|
||||||
|
- Bypass for trusted commits. No `[skip gitleaks]` trailer, no
|
||||||
|
allowlist by commit hash. If the gate is bypassable it isn't a
|
||||||
|
gate.
|
||||||
|
|
||||||
|
## Proposed Design
|
||||||
|
|
||||||
|
### New services / components
|
||||||
|
|
||||||
|
Mirror the existing sidecar layout:
|
||||||
|
|
||||||
|
- **`claude_bottle/git_gate.py`** (new): abstract `GitGate` +
|
||||||
|
`GitGatePlan` dataclass. `prepare` is host-side / side-effect-
|
||||||
|
free on docker; renders the per-upstream config and stages the
|
||||||
|
push credentials under `stage_dir`.
|
||||||
|
- **`claude_bottle/backend/docker/git_gate.py`** (new):
|
||||||
|
`DockerGitGate` concrete subclass. `start` does `docker create`
|
||||||
|
on the internal network, copies in the bare-repo skeleton, the
|
||||||
|
hook script, and per-upstream credentials, then `docker start`.
|
||||||
|
`stop` is idempotent `docker rm -f`. Container name:
|
||||||
|
`claude-bottle-git-gate-<slug>`.
|
||||||
|
|
||||||
|
Gate image: `git-daemon` + `openssh-client` over a
|
||||||
|
`zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest.
|
||||||
|
For each declared upstream the gate hosts a bare repo at
|
||||||
|
`/git/<name>.git` with `remote.origin.url` set to the real
|
||||||
|
upstream (via `git remote add --mirror=fetch`), `hooks/pre-receive`
|
||||||
|
wired to gitleaks-then-`git push origin`, and the bare repo's
|
||||||
|
config carrying per-upstream credential paths.
|
||||||
|
|
||||||
|
Inside the bottle, the agent's `.gitconfig` rewrites the real
|
||||||
|
upstream URL to the gate's `git://` URL via `insteadOf`. Every
|
||||||
|
git operation against the declared upstream therefore hits the
|
||||||
|
gate.
|
||||||
|
|
||||||
|
For pushes, the pre-receive hook gitleaks-scans the incoming
|
||||||
|
refs and, on clean, pushes each accepted ref to the real
|
||||||
|
upstream using the credential the gate holds.
|
||||||
|
|
||||||
|
For fetches (clone, pull, fetch, ls-remote), `git daemon`'s
|
||||||
|
`--access-hook=<path>` runs `git fetch origin --prune` against
|
||||||
|
the real upstream before the upload-pack service serves the
|
||||||
|
client. The bare repo therefore reflects the upstream's current
|
||||||
|
state at the moment the agent's fetch begins; if the upstream
|
||||||
|
is unreachable, the access hook exits non-zero and the agent's
|
||||||
|
fetch fails — same observable behavior as if the agent were
|
||||||
|
talking to the upstream directly.
|
||||||
|
|
||||||
|
The agent never sees the upstream credential under either
|
||||||
|
operation.
|
||||||
|
|
||||||
|
### Existing code touched
|
||||||
|
|
||||||
|
- **`claude_bottle/manifest.py`**: parse and validate the new
|
||||||
|
`bottle.git` block; reject `bottle.ssh` entries whose upstream
|
||||||
|
is also claimed by a `bottle.git` upstream (one path per
|
||||||
|
remote, no shadow route).
|
||||||
|
- **`claude_bottle/backend/docker/provision/git.py`** (new) or an
|
||||||
|
extension of the ssh provisioner: render the `insteadOf` config
|
||||||
|
and any extra `~/.gitconfig` plumbing.
|
||||||
|
- **`claude_bottle/backend/docker/backend.py`**: instantiate
|
||||||
|
`DockerGitGate` alongside `DockerPipelockProxy` and
|
||||||
|
`DockerSSHGate`; thread its `prepare` / `start` / `stop`
|
||||||
|
through `resolve_plan` / `launch`.
|
||||||
|
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
|
||||||
|
stop to the `ExitStack` so the gate is up before any
|
||||||
|
provisioner that writes the agent's `~/.gitconfig`.
|
||||||
|
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
|
||||||
|
`GitGatePlan` field on `DockerBottlePlan`; preflight rendering
|
||||||
|
surfaces the gate sidecar (name, per-upstream local paths,
|
||||||
|
upstream real URLs, which credential is in use).
|
||||||
|
- **Tests**: unit tests for `GitGate.prepare` and render shape;
|
||||||
|
manifest validator tests for the new field and the
|
||||||
|
no-shadow-route rule; an integration test in
|
||||||
|
`tests/integration/` for the push-with-secret (rejected) and
|
||||||
|
push-without-secret (forwarded) cases.
|
||||||
|
|
||||||
|
### Data model changes
|
||||||
|
|
||||||
|
`Bottle` grows an optional `git: list[GitEntry]` field. A
|
||||||
|
`GitEntry` carries the upstream URL, the local name the gate
|
||||||
|
exposes it as, and the credential the gate uses to push upstream
|
||||||
|
(initial shape: `identity_file` + `known_host_key`, matching
|
||||||
|
`bottle.ssh`).
|
||||||
|
|
||||||
|
### External dependencies
|
||||||
|
|
||||||
|
- `zricethezav/gitleaks` base image, pinned by digest. The base
|
||||||
|
ships gitleaks + git; the gate Dockerfile adds `git-daemon` and
|
||||||
|
`openssh-client` on top.
|
||||||
|
- No new Python packages.
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
- **Smolmachines colocation.** The eventual smolmachines backend
|
||||||
|
may pack pipelock + ssh-gate + git-gate into a single microVM,
|
||||||
|
or split git-gate off because it holds push creds and the
|
||||||
|
others don't. That decision belongs to the backend; the shared
|
||||||
|
`BottleBackend` interface keeps sidecars independent so either
|
||||||
|
packing is possible without touching this PRD's design.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Protocol on the agent → gate hop: SSH (`sshd` + `git-shell`
|
||||||
|
inside the gate) or HTTP smart protocol (`git-http-backend`
|
||||||
|
behind a tiny webserver)? SSH matches the existing ssh-gate
|
||||||
|
patterns and the user's existing `~/.ssh` muscle memory; HTTP
|
||||||
|
is lighter on image size and avoids an `authorized_keys`
|
||||||
|
story. Default: SSH unless image size becomes a problem.
|
||||||
|
- Where gitleaks runs: pre-receive hook against a checkout of the
|
||||||
|
incoming ref vs. a wrapper around `git-receive-pack` that
|
||||||
|
inspects the pack file directly. Hook is canonical; defer the
|
||||||
|
wrapper variant.
|
||||||
|
- Rejection signalling: gitleaks failures surface as a normal
|
||||||
|
pre-receive reject (the user sees gitleaks's report on
|
||||||
|
stderr). Worth a "redacted" mode that hides the matched bytes
|
||||||
|
from the rejection message? Default: show file + line, hide
|
||||||
|
the matched bytes.
|
||||||
|
- Credential reuse vs. duplication from `bottle.ssh`. If a user
|
||||||
|
lists the same identity for ssh-gate (read) and git-gate
|
||||||
|
(write), we can either reference by name or require two
|
||||||
|
copies. Default: inline copies; revisit when it gets annoying.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- PRD 0001: per-agent egress proxy via pipelock — sidecar
|
||||||
|
pattern this PRD reuses.
|
||||||
|
- PRD 0007: SSH egress gate — the L4 SSH forwarder this PRD
|
||||||
|
sits alongside; explicitly *not* the place to add
|
||||||
|
git-protocol awareness.
|
||||||
|
- `claude_bottle/ssh_gate.py` / `claude_bottle/pipelock.py` —
|
||||||
|
existing sidecar abstractions to mirror.
|
||||||
|
- gitleaks: <https://github.com/gitleaks/gitleaks>
|
||||||
@@ -65,6 +65,31 @@ def fixture_with_ssh_dict() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_with_git_dict() -> dict[str, Any]:
|
||||||
|
"""Bottle declares a git-gate upstream. JSON shape."""
|
||||||
|
return {
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"git": [
|
||||||
|
{
|
||||||
|
"Name": "claude-bottle",
|
||||||
|
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
"KnownHostKey": "ssh-ed25519 AAAA...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com/didericis/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
"KnownHostKey": "ssh-ed25519 BBBB...",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def fixture_minimal() -> Manifest:
|
def fixture_minimal() -> Manifest:
|
||||||
return Manifest.from_json_obj(fixture_minimal_dict())
|
return Manifest.from_json_obj(fixture_minimal_dict())
|
||||||
|
|
||||||
@@ -77,6 +102,10 @@ def fixture_with_ssh() -> Manifest:
|
|||||||
return Manifest.from_json_obj(fixture_with_ssh_dict())
|
return Manifest.from_json_obj(fixture_with_ssh_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_with_git() -> Manifest:
|
||||||
|
return Manifest.from_json_obj(fixture_with_git_dict())
|
||||||
|
|
||||||
|
|
||||||
def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path:
|
def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path:
|
||||||
"""Write fixture JSON to a temp file; return the path. Caller must rm.
|
"""Write fixture JSON to a temp file; return the path. Caller must rm.
|
||||||
Accepts a function returning either a dict (JSON shape) or a Manifest;
|
Accepts a function returning either a dict (JSON shape) or a Manifest;
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ class TestDryRunPlan(unittest.TestCase):
|
|||||||
self.assertEqual([], plan["skills"])
|
self.assertEqual([], plan["skills"])
|
||||||
self.assertEqual([], plan["ssh_hosts"])
|
self.assertEqual([], plan["ssh_hosts"])
|
||||||
self.assertEqual([], plan["ssh_gate"])
|
self.assertEqual([], plan["ssh_gate"])
|
||||||
|
self.assertEqual([], plan["git_remotes"])
|
||||||
|
self.assertEqual([], plan["git_gate"])
|
||||||
self.assertEqual(False, plan["remote_control"])
|
self.assertEqual(False, plan["remote_control"])
|
||||||
self.assertEqual(0, plan["prompt"]["length"])
|
self.assertEqual(0, plan["prompt"]["length"])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
"""Integration: the git-gate is a bidirectional mirror of its
|
||||||
|
upstream (PRD 0008 v1.1).
|
||||||
|
|
||||||
|
Three round-trip assertions against a real Docker daemon plus a
|
||||||
|
sibling sshd container playing the role of "real upstream":
|
||||||
|
|
||||||
|
1. clone-through-gate returns whatever the upstream has at the
|
||||||
|
moment of clone (refs + content).
|
||||||
|
2. After a second commit lands on the upstream out-of-band, a
|
||||||
|
fetch through the gate picks it up — the access-hook is
|
||||||
|
refreshing before each upload-pack.
|
||||||
|
3. A push through the gate (clean commit) lands on the upstream's
|
||||||
|
bare repo — the pre-receive hook's forward phase works.
|
||||||
|
|
||||||
|
These are the user-facing semantics: every operation against the
|
||||||
|
gate is observably equivalent to the same operation against the
|
||||||
|
real upstream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_bottle.backend.docker.git_gate import (
|
||||||
|
DockerGitGate,
|
||||||
|
build_git_gate_image,
|
||||||
|
)
|
||||||
|
from claude_bottle.backend.docker.network import (
|
||||||
|
network_create_egress,
|
||||||
|
network_create_internal,
|
||||||
|
network_remove,
|
||||||
|
)
|
||||||
|
from claude_bottle.manifest import Manifest
|
||||||
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
|
# Same image used by test_git_gate_sidecar — alpine + git + gitleaks.
|
||||||
|
CLIENT_IMAGE = "zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f"
|
||||||
|
|
||||||
|
# Built once in setUpClass via `docker build -` from the inline
|
||||||
|
# Dockerfile below. Carries openssh-server, a `git` user, baked-in
|
||||||
|
# host keys, and a bare repo at /git/foo.git seeded with one commit.
|
||||||
|
UPSTREAM_IMAGE = "claude-bottle-test-upstream:latest"
|
||||||
|
|
||||||
|
UPSTREAM_DOCKERFILE = textwrap.dedent("""
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache openssh-server git
|
||||||
|
RUN adduser -D -s /usr/bin/git-shell git && \\
|
||||||
|
passwd -u git && \\
|
||||||
|
mkdir -p /home/git/.ssh && \\
|
||||||
|
chown git:git /home/git/.ssh && \\
|
||||||
|
chmod 700 /home/git/.ssh && \\
|
||||||
|
mkdir -p /git && \\
|
||||||
|
chown git:git /git
|
||||||
|
# Bake host keys into the image so the test can pin the
|
||||||
|
# KnownHostKey value before the container starts. Re-running
|
||||||
|
# ssh-keygen -A at boot would invalidate that pinning.
|
||||||
|
RUN ssh-keygen -A
|
||||||
|
USER git
|
||||||
|
RUN git config --global init.defaultBranch main && \\
|
||||||
|
git config --global user.email upstream@example && \\
|
||||||
|
git config --global user.name upstream && \\
|
||||||
|
git init --bare /git/foo.git && \\
|
||||||
|
git clone /git/foo.git /tmp/w && \\
|
||||||
|
cd /tmp/w && \\
|
||||||
|
echo "initial upstream content" > README.md && \\
|
||||||
|
git add README.md && \\
|
||||||
|
git commit -q -m "initial commit" && \\
|
||||||
|
git push -q origin main && \\
|
||||||
|
rm -rf /tmp/w
|
||||||
|
USER root
|
||||||
|
RUN echo "PermitRootLogin no" >> /etc/ssh/sshd_config && \\
|
||||||
|
echo "PasswordAuthentication no" >> /etc/ssh/sshd_config && \\
|
||||||
|
echo "AuthorizedKeysFile /home/git/.ssh/authorized_keys" >> /etc/ssh/sshd_config
|
||||||
|
CMD ["/usr/sbin/sshd", "-D", "-e"]
|
||||||
|
""").strip()
|
||||||
|
|
||||||
|
|
||||||
|
@skip_unless_docker()
|
||||||
|
class TestGitGateBidirectionalMirror(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
# Pull the client image first (other suites do the same — keeps
|
||||||
|
# registry races contained to setUpClass).
|
||||||
|
if subprocess.run(
|
||||||
|
["docker", "pull", CLIENT_IMAGE],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
).returncode != 0:
|
||||||
|
raise unittest.SkipTest(f"could not pull {CLIENT_IMAGE}")
|
||||||
|
|
||||||
|
# Build the upstream sshd image from stdin (no build context
|
||||||
|
# needed — Dockerfile has no COPY/ADD).
|
||||||
|
build_result = subprocess.run(
|
||||||
|
["docker", "build", "-t", UPSTREAM_IMAGE, "-"],
|
||||||
|
input=UPSTREAM_DOCKERFILE,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if build_result.returncode != 0:
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
f"could not build upstream image: {build_result.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pull the upstream's baked-in ed25519 host pubkey out of the
|
||||||
|
# image so we can pin it as KnownHostKey on the gate's manifest
|
||||||
|
# entry. Reading from a transient container ensures we get the
|
||||||
|
# same key the running sshd will present.
|
||||||
|
pub_result = subprocess.run(
|
||||||
|
["docker", "run", "--rm", "--entrypoint", "cat",
|
||||||
|
UPSTREAM_IMAGE, "/etc/ssh/ssh_host_ed25519_key.pub"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
parts = pub_result.stdout.strip().split()
|
||||||
|
# Format: "ssh-ed25519 <base64-pubkey> <comment>" — drop comment.
|
||||||
|
cls.upstream_host_key = f"{parts[0]} {parts[1]}"
|
||||||
|
|
||||||
|
# Build the gate image (uses build cache after the first run).
|
||||||
|
build_git_gate_image()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
suffix = self.id().rsplit('.', 1)[-1].replace('_', '-')[-12:]
|
||||||
|
self.slug = f"t{os.getpid()}-{suffix}"
|
||||||
|
self.gate_name = ""
|
||||||
|
self.upstream_name = f"claude-bottle-test-upstream-{self.slug}"
|
||||||
|
self.internal_net = ""
|
||||||
|
self.egress_net = ""
|
||||||
|
self.work_dir = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
# Per-test SSH auth keypair. The host gets the private key
|
||||||
|
# path on disk (manifest IdentityFile); the upstream's
|
||||||
|
# authorized_keys gets the public key, docker-cp'd in just
|
||||||
|
# before sshd starts.
|
||||||
|
self.auth_key = self.work_dir / "auth_key"
|
||||||
|
subprocess.run(
|
||||||
|
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(self.auth_key),
|
||||||
|
"-C", "git-gate-test"],
|
||||||
|
check=True, stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
self.auth_pub = self.work_dir / "auth_key.pub"
|
||||||
|
|
||||||
|
# Networks first so the upstream can attach to the egress
|
||||||
|
# network at create time.
|
||||||
|
self.internal_net = network_create_internal(self.slug)
|
||||||
|
self.egress_net = network_create_egress(self.slug)
|
||||||
|
|
||||||
|
# Start the upstream sshd container, attached to the egress
|
||||||
|
# network (which the gate also lives on). Container name doubles
|
||||||
|
# as its DNS-resolvable hostname.
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "create",
|
||||||
|
"--name", self.upstream_name,
|
||||||
|
"--network", self.egress_net,
|
||||||
|
UPSTREAM_IMAGE],
|
||||||
|
check=True, stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
# docker cp the per-test pubkey into the upstream as
|
||||||
|
# /home/git/.ssh/authorized_keys (right user, right path).
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", str(self.auth_pub),
|
||||||
|
f"{self.upstream_name}:/home/git/.ssh/authorized_keys"],
|
||||||
|
check=True, stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
# chown / chmod the authorized_keys before sshd refuses to
|
||||||
|
# use it.
|
||||||
|
for argv in (
|
||||||
|
["chown", "git:git", "/home/git/.ssh/authorized_keys"],
|
||||||
|
["chmod", "600", "/home/git/.ssh/authorized_keys"],
|
||||||
|
):
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", self.upstream_name, *argv],
|
||||||
|
check=False, stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
# The exec-then-start ordering is unusual — exec on a stopped
|
||||||
|
# container is OK on modern docker but if it errors we just
|
||||||
|
# do the chown after start instead. Retry post-start to be
|
||||||
|
# safe.
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "start", self.upstream_name],
|
||||||
|
check=True, stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
for argv in (
|
||||||
|
["chown", "git:git", "/home/git/.ssh/authorized_keys"],
|
||||||
|
["chmod", "600", "/home/git/.ssh/authorized_keys"],
|
||||||
|
):
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", self.upstream_name, *argv],
|
||||||
|
check=False, stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
# Wait for sshd to bind; a short retry against TCP 22 is enough.
|
||||||
|
ready = False
|
||||||
|
for _ in range(30):
|
||||||
|
probe = subprocess.run(
|
||||||
|
["docker", "exec", self.upstream_name,
|
||||||
|
"sh", "-c", "nc -z 127.0.0.1 22"],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
)
|
||||||
|
if probe.returncode == 0:
|
||||||
|
ready = True
|
||||||
|
break
|
||||||
|
subprocess.run(["sleep", "0.2"], check=False)
|
||||||
|
if not ready:
|
||||||
|
self.fail("upstream sshd never bound port 22")
|
||||||
|
|
||||||
|
# Build the gate plan + start it. Upstream URL points at the
|
||||||
|
# upstream container's hostname (Docker DNS resolves it on the
|
||||||
|
# egress network) on port 22, user `git`.
|
||||||
|
manifest = Manifest.from_json_obj({
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"git": [{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": f"ssh://git@{self.upstream_name}/git/foo.git",
|
||||||
|
"IdentityFile": str(self.auth_key),
|
||||||
|
"KnownHostKey": self.upstream_host_key,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
bottle = manifest.bottles["dev"]
|
||||||
|
gate = DockerGitGate()
|
||||||
|
prep = gate.prepare(bottle, self.slug, self.work_dir)
|
||||||
|
plan = dataclasses.replace(
|
||||||
|
prep,
|
||||||
|
internal_network=self.internal_net,
|
||||||
|
egress_network=self.egress_net,
|
||||||
|
)
|
||||||
|
self.gate_name = gate.start(plan)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if self.gate_name:
|
||||||
|
DockerGitGate().stop(self.gate_name)
|
||||||
|
if self.upstream_name:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", self.upstream_name],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
)
|
||||||
|
for n in (self.internal_net, self.egress_net):
|
||||||
|
if n:
|
||||||
|
network_remove(n)
|
||||||
|
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _upstream_main_sha(self) -> str:
|
||||||
|
"""Read upstream's current refs/heads/main sha by exec'ing
|
||||||
|
directly into the upstream container's bare repo."""
|
||||||
|
out = subprocess.run(
|
||||||
|
["docker", "exec", "-u", "git", self.upstream_name,
|
||||||
|
"git", "-C", "/git/foo.git", "rev-parse", "refs/heads/main"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
return out.stdout.strip()
|
||||||
|
|
||||||
|
def _push_to_upstream_oob(self, message: str) -> str:
|
||||||
|
"""Make a new commit directly on the upstream's bare repo
|
||||||
|
(out-of-band, not through the gate). Returns the new sha."""
|
||||||
|
script = textwrap.dedent(f"""
|
||||||
|
set -e
|
||||||
|
cd /tmp
|
||||||
|
rm -rf w
|
||||||
|
git clone /git/foo.git w
|
||||||
|
cd w
|
||||||
|
git config user.email upstream@example
|
||||||
|
git config user.name upstream
|
||||||
|
echo "$RANDOM-$$" >> README.md
|
||||||
|
git add README.md
|
||||||
|
git commit -q -m "{message}"
|
||||||
|
git push -q origin main
|
||||||
|
git rev-parse HEAD
|
||||||
|
""").strip()
|
||||||
|
out = subprocess.run(
|
||||||
|
["docker", "exec", "-u", "git", self.upstream_name,
|
||||||
|
"sh", "-c", script],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
return out.stdout.strip().splitlines()[-1]
|
||||||
|
|
||||||
|
@unittest.skipIf(
|
||||||
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
|
"skipped under act_runner: docker socket mount topology breaks "
|
||||||
|
"in-process visibility of networks created on the host daemon",
|
||||||
|
)
|
||||||
|
def test_clone_and_refetch_reflect_upstream(self):
|
||||||
|
"""Clone via gate returns upstream's commit. After a second
|
||||||
|
commit lands on the upstream out-of-band, a re-fetch through
|
||||||
|
the gate picks it up — the access-hook is refreshing before
|
||||||
|
each upload-pack."""
|
||||||
|
initial_sha = self._upstream_main_sha()
|
||||||
|
|
||||||
|
# Clone via gate.
|
||||||
|
clone_script = (
|
||||||
|
f"set -e\n"
|
||||||
|
f"cd /tmp && git clone -q git://{self.gate_name}/foo.git r\n"
|
||||||
|
f"git -C r rev-parse refs/remotes/origin/main\n"
|
||||||
|
f"cat r/README.md\n"
|
||||||
|
)
|
||||||
|
clone = subprocess.run(
|
||||||
|
["docker", "run", "--rm",
|
||||||
|
"--network", self.internal_net,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
CLIENT_IMAGE,
|
||||||
|
"-c", clone_script],
|
||||||
|
capture_output=True, text=True, timeout=60, check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0, clone.returncode,
|
||||||
|
f"clone via gate failed: stdout={clone.stdout!r} "
|
||||||
|
f"stderr={clone.stderr!r}",
|
||||||
|
)
|
||||||
|
cloned_sha = clone.stdout.strip().splitlines()[0]
|
||||||
|
self.assertEqual(
|
||||||
|
initial_sha, cloned_sha,
|
||||||
|
"clone via gate must return the upstream's current sha",
|
||||||
|
)
|
||||||
|
self.assertIn("initial upstream content", clone.stdout)
|
||||||
|
|
||||||
|
# Out-of-band commit on the upstream.
|
||||||
|
new_sha = self._push_to_upstream_oob("second commit")
|
||||||
|
self.assertNotEqual(initial_sha, new_sha)
|
||||||
|
|
||||||
|
# ls-remote via gate (re-fetch should pick up the new sha).
|
||||||
|
ls = subprocess.run(
|
||||||
|
["docker", "run", "--rm",
|
||||||
|
"--network", self.internal_net,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
CLIENT_IMAGE,
|
||||||
|
"-c", f"git ls-remote git://{self.gate_name}/foo.git refs/heads/main"],
|
||||||
|
capture_output=True, text=True, timeout=60, check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, ls.returncode, f"ls-remote failed: {ls.stderr!r}")
|
||||||
|
gate_sha = ls.stdout.split()[0]
|
||||||
|
self.assertEqual(
|
||||||
|
new_sha, gate_sha,
|
||||||
|
"ls-remote via gate must reflect the upstream's out-of-band update; "
|
||||||
|
"if this assertion fails, the access-hook is not refreshing on every "
|
||||||
|
"upload-pack and the gate is serving stale data",
|
||||||
|
)
|
||||||
|
|
||||||
|
@unittest.skipIf(
|
||||||
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
|
"skipped under act_runner: docker socket mount topology breaks "
|
||||||
|
"in-process visibility of networks created on the host daemon",
|
||||||
|
)
|
||||||
|
def test_push_through_gate_lands_on_upstream(self):
|
||||||
|
"""A clean (no-gitleaks-hit) push through the gate lands on
|
||||||
|
the upstream's bare repo — pre-receive phase 2 forwards
|
||||||
|
the accepted refs."""
|
||||||
|
# Make a commit through the gate. The script clones via gate
|
||||||
|
# (so the commit will be a child of upstream's current main).
|
||||||
|
push_script = textwrap.dedent(f"""
|
||||||
|
set -e
|
||||||
|
cd /tmp
|
||||||
|
git clone -q git://{self.gate_name}/foo.git r
|
||||||
|
cd r
|
||||||
|
git config user.email client@example
|
||||||
|
git config user.name client
|
||||||
|
echo "client-side commit" > NEW.md
|
||||||
|
git add NEW.md
|
||||||
|
git commit -q -m "client commit"
|
||||||
|
git rev-parse HEAD
|
||||||
|
git push origin main 2>&1
|
||||||
|
""").strip()
|
||||||
|
push = subprocess.run(
|
||||||
|
["docker", "run", "--rm",
|
||||||
|
"--network", self.internal_net,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
CLIENT_IMAGE,
|
||||||
|
"-c", push_script],
|
||||||
|
capture_output=True, text=True, timeout=120, check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0, push.returncode,
|
||||||
|
f"push via gate failed: stdout={push.stdout!r} "
|
||||||
|
f"stderr={push.stderr!r}",
|
||||||
|
)
|
||||||
|
client_sha = push.stdout.splitlines()[0].strip()
|
||||||
|
self.assertEqual(
|
||||||
|
client_sha, self._upstream_main_sha(),
|
||||||
|
"push via gate must land on upstream's bare repo; "
|
||||||
|
"if this fails the pre-receive forward phase is broken or the "
|
||||||
|
"upstream credential is misconfigured",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Integration: per-agent git-gate sidecar (PRD 0008).
|
||||||
|
|
||||||
|
Two tests against a real Docker daemon:
|
||||||
|
|
||||||
|
1. ls-remote against a gate whose upstream is unreachable fails
|
||||||
|
with the access-hook's fail-closed rejection. Proves the
|
||||||
|
daemon is bound to its port AND the access-hook is wired:
|
||||||
|
a working ls-remote against the gate is necessarily a working
|
||||||
|
ls-remote against the upstream (PRD 0008's transparent-mirror
|
||||||
|
contract).
|
||||||
|
2. A push containing a gitleaks-detectable secret is rejected
|
||||||
|
by the pre-receive hook with a non-zero exit on the agent
|
||||||
|
side and a gitleaks-rejection line in the response. The PRD's
|
||||||
|
primary success criterion.
|
||||||
|
|
||||||
|
A successful round-trip (clone through gate reflects upstream)
|
||||||
|
needs a reachable upstream SSH host; deferred to a follow-up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_bottle.backend.docker.git_gate import (
|
||||||
|
DockerGitGate,
|
||||||
|
build_git_gate_image,
|
||||||
|
)
|
||||||
|
from claude_bottle.backend.docker.network import (
|
||||||
|
network_create_egress,
|
||||||
|
network_create_internal,
|
||||||
|
network_remove,
|
||||||
|
)
|
||||||
|
from claude_bottle.manifest import Manifest
|
||||||
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
# The official gitleaks image already has git + alpine; reusing it
|
||||||
|
# for the client side too saves a separate image pull.
|
||||||
|
CLIENT_IMAGE = "zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f"
|
||||||
|
|
||||||
|
# Synthetic high-entropy AKIA-shaped string; gitleaks's aws-access-token
|
||||||
|
# rule fires on this with the default config. AWS's own example
|
||||||
|
# ("AKIAIOSFODNN7EXAMPLE") is NOT flagged by gitleaks v8.x — entropy
|
||||||
|
# filter rejects it — so we use a distinct random-looking value.
|
||||||
|
FAKE_AWS_KEY = "AKIAQRJHK7N5ZPM2VXTL"
|
||||||
|
|
||||||
|
|
||||||
|
@skip_unless_docker()
|
||||||
|
class TestGitGateSidecar(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
# Pre-pull the client/gitleaks base so per-test runs aren't
|
||||||
|
# racing the registry. Skip cleanly on pull failure (a real
|
||||||
|
# outage is out of scope here).
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "pull", CLIENT_IMAGE],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise unittest.SkipTest(f"could not pull {CLIENT_IMAGE}")
|
||||||
|
# Build the gate image once for the class. Layer cache makes
|
||||||
|
# repeated runs cheap.
|
||||||
|
build_git_gate_image()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# DNS hostnames on user-defined Docker networks max out at 63
|
||||||
|
# chars per label (RFC 1035). The full container name is
|
||||||
|
# `claude-bottle-git-gate-<slug>` = 23 + len(slug), so the slug
|
||||||
|
# has to stay under ~40 to be resolvable. Keep it short.
|
||||||
|
suffix = self.id().rsplit('.', 1)[-1].replace('_', '-')[-12:]
|
||||||
|
self.slug = f"t{os.getpid()}-{suffix}"
|
||||||
|
self.gate_name = ""
|
||||||
|
self.internal_net = ""
|
||||||
|
self.egress_net = ""
|
||||||
|
self.work_dir = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if self.gate_name:
|
||||||
|
DockerGitGate().stop(self.gate_name)
|
||||||
|
for n in (self.internal_net, self.egress_net):
|
||||||
|
if n:
|
||||||
|
network_remove(n)
|
||||||
|
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _start_gate(self, name: str = "foo") -> str:
|
||||||
|
"""Build a one-upstream gate and bring it up. Returns the
|
||||||
|
container name (== git-gate hostname on the internal net)."""
|
||||||
|
# Contents of the fake key don't matter for these tests — the
|
||||||
|
# rejection-path hook never reaches phase 2 where it would be
|
||||||
|
# used, and ls-remote doesn't push.
|
||||||
|
fake_key = self.work_dir / "fake-key"
|
||||||
|
fake_key.write_text("not-a-real-key\n")
|
||||||
|
|
||||||
|
manifest = Manifest.from_json_obj({
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"git": [{
|
||||||
|
"Name": name,
|
||||||
|
"Upstream": "ssh://git@upstream.invalid/path.git",
|
||||||
|
"IdentityFile": str(fake_key),
|
||||||
|
"KnownHostKey": "ssh-ed25519 AAAAEXAMPLE",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
bottle = manifest.bottles["dev"]
|
||||||
|
|
||||||
|
gate = DockerGitGate()
|
||||||
|
prep = gate.prepare(bottle, self.slug, self.work_dir)
|
||||||
|
|
||||||
|
self.internal_net = network_create_internal(self.slug)
|
||||||
|
self.egress_net = network_create_egress(self.slug)
|
||||||
|
plan = dataclasses.replace(
|
||||||
|
prep,
|
||||||
|
internal_network=self.internal_net,
|
||||||
|
egress_network=self.egress_net,
|
||||||
|
)
|
||||||
|
self.gate_name = gate.start(plan)
|
||||||
|
return self.gate_name
|
||||||
|
|
||||||
|
@unittest.skipIf(
|
||||||
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
|
"skipped under act_runner: docker socket mount topology breaks "
|
||||||
|
"in-process visibility of networks created on the host daemon",
|
||||||
|
)
|
||||||
|
def test_ls_remote_fails_closed_when_upstream_unreachable(self):
|
||||||
|
"""The gate's access-hook runs `git fetch origin --prune` before
|
||||||
|
every upload-pack. With the fixture's deliberately unreachable
|
||||||
|
`ssh://git@upstream.invalid/...`, that fetch fails and the
|
||||||
|
hook exits 1; the daemon reports access-denied. Asserting
|
||||||
|
non-zero here is what proves the access-hook is wired: under
|
||||||
|
the v1 (push-only) design ls-remote against a fresh gate
|
||||||
|
returned exit 0 with no refs."""
|
||||||
|
gate = self._start_gate("foo")
|
||||||
|
# Daemon still has to bind first; retry the TCP connect a few
|
||||||
|
# times. The expected end state is a non-zero exit from the
|
||||||
|
# daemon's access-denied response — not a connection refused.
|
||||||
|
probe = subprocess.run(
|
||||||
|
["docker", "run", "--rm",
|
||||||
|
"--network", self.internal_net,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
CLIENT_IMAGE,
|
||||||
|
"-c",
|
||||||
|
f"for i in $(seq 1 15); do "
|
||||||
|
f" out=$(git ls-remote git://{gate}/foo.git 2>&1) && exit 99;"
|
||||||
|
f" case \"$out\" in *'access denied'*|*'not exported'*) "
|
||||||
|
f" echo \"$out\"; exit 1;; esac;"
|
||||||
|
f" sleep 1;"
|
||||||
|
f"done;"
|
||||||
|
f"echo TIMEOUT; exit 2"],
|
||||||
|
capture_output=True, text=True, timeout=60, check=False,
|
||||||
|
)
|
||||||
|
# exit 1: daemon access-denied as expected. exit 99 would mean
|
||||||
|
# ls-remote actually succeeded against the unreachable upstream
|
||||||
|
# (impossible — would indicate stale-data serving, the very
|
||||||
|
# thing the access-hook is meant to prevent).
|
||||||
|
self.assertEqual(
|
||||||
|
1, probe.returncode,
|
||||||
|
f"expected fail-closed access-denied; got "
|
||||||
|
f"exit={probe.returncode} stdout={probe.stdout!r} "
|
||||||
|
f"stderr={probe.stderr!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@unittest.skipIf(
|
||||||
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
|
"skipped under act_runner: docker socket mount topology breaks "
|
||||||
|
"in-process visibility of networks created on the host daemon",
|
||||||
|
)
|
||||||
|
def test_push_with_secret_is_rejected(self):
|
||||||
|
"""The PRD 0008 success criterion: a push containing a
|
||||||
|
gitleaks-detectable secret is rejected; the hook's "gitleaks
|
||||||
|
rejected" line appears in the response, and git push exits
|
||||||
|
non-zero on the client side."""
|
||||||
|
gate = self._start_gate("foo")
|
||||||
|
push_script = (
|
||||||
|
"set -e\n"
|
||||||
|
"cd /tmp\n"
|
||||||
|
# Wait for git daemon to bind. Under the v1.1 design,
|
||||||
|
# ls-remote never returns 0 against an unreachable
|
||||||
|
# upstream (access-hook fail-closed), so we wait for *any*
|
||||||
|
# response (the daemon's access-denied line) as the
|
||||||
|
# readiness signal.
|
||||||
|
f"for i in $(seq 1 15); do "
|
||||||
|
f" out=$(git ls-remote git://{gate}/foo.git 2>&1) || true;"
|
||||||
|
f" case \"$out\" in *'remote error'*|*'access denied'*) break;; esac;"
|
||||||
|
f" sleep 1;"
|
||||||
|
f"done\n"
|
||||||
|
"git init -q -b main repo\n"
|
||||||
|
"cd repo\n"
|
||||||
|
"git config user.email test@example.com\n"
|
||||||
|
"git config user.name test\n"
|
||||||
|
f"echo '{FAKE_AWS_KEY}' > leak.txt\n"
|
||||||
|
"git add leak.txt\n"
|
||||||
|
"git commit -q -m leak\n"
|
||||||
|
f"git push git://{gate}/foo.git main 2>&1\n"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "run", "--rm",
|
||||||
|
"--network", self.internal_net,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
CLIENT_IMAGE,
|
||||||
|
"-c", push_script],
|
||||||
|
capture_output=True, text=True, timeout=120, check=False,
|
||||||
|
)
|
||||||
|
combined = result.stdout + result.stderr
|
||||||
|
self.assertNotEqual(
|
||||||
|
0, result.returncode,
|
||||||
|
f"expected push to fail; output={combined!r}",
|
||||||
|
)
|
||||||
|
# Hook's stderr is delivered to the client via the `remote:`
|
||||||
|
# prefix during a git push. Either token is enough to prove
|
||||||
|
# the pre-receive hook ran and rejected the push.
|
||||||
|
self.assertTrue(
|
||||||
|
"gitleaks rejected" in combined or "leaks found" in combined,
|
||||||
|
f"expected a gitleaks rejection in the response; got: {combined!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Unit: GitGate prepare shape + entrypoint/hook render (PRD 0008)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_bottle.git_gate import (
|
||||||
|
GitGate,
|
||||||
|
GitGatePlan,
|
||||||
|
GitGateUpstream,
|
||||||
|
git_gate_known_hosts_line,
|
||||||
|
git_gate_render_access_hook,
|
||||||
|
git_gate_render_entrypoint,
|
||||||
|
git_gate_render_hook,
|
||||||
|
git_gate_upstreams_for_bottle,
|
||||||
|
)
|
||||||
|
from tests.fixtures import fixture_minimal, fixture_with_git
|
||||||
|
|
||||||
|
|
||||||
|
class _StubGate(GitGate):
|
||||||
|
def start(self, plan: GitGatePlan) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def stop(self, target: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpstreamsForBottle(unittest.TestCase):
|
||||||
|
def test_one_upstream_per_git_entry(self):
|
||||||
|
bottle = fixture_with_git().bottles["dev"]
|
||||||
|
ups = git_gate_upstreams_for_bottle(bottle)
|
||||||
|
self.assertEqual(2, len(ups))
|
||||||
|
self.assertEqual("claude-bottle", ups[0].name)
|
||||||
|
self.assertEqual("gitea.dideric.is", ups[0].upstream_host)
|
||||||
|
self.assertEqual("30009", ups[0].upstream_port)
|
||||||
|
self.assertEqual("foo", ups[1].name)
|
||||||
|
self.assertEqual("github.com", ups[1].upstream_host)
|
||||||
|
self.assertEqual("22", ups[1].upstream_port)
|
||||||
|
|
||||||
|
def test_empty_bottle_yields_empty_upstreams(self):
|
||||||
|
bottle = fixture_minimal().bottles["dev"]
|
||||||
|
self.assertEqual((), git_gate_upstreams_for_bottle(bottle))
|
||||||
|
|
||||||
|
|
||||||
|
class TestKnownHostsLine(unittest.TestCase):
|
||||||
|
def test_default_port_unbracketed(self):
|
||||||
|
line = git_gate_known_hosts_line("github.com", "22", "ssh-ed25519 AAAA")
|
||||||
|
self.assertEqual("github.com ssh-ed25519 AAAA\n", line)
|
||||||
|
|
||||||
|
def test_non_default_port_bracketed(self):
|
||||||
|
line = git_gate_known_hosts_line("gitea.dideric.is", "30009", "ssh-ed25519 AAAA")
|
||||||
|
self.assertEqual("[gitea.dideric.is]:30009 ssh-ed25519 AAAA\n", line)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntrypointRender(unittest.TestCase):
|
||||||
|
def test_one_init_repo_call_per_upstream(self):
|
||||||
|
ups = (
|
||||||
|
GitGateUpstream(
|
||||||
|
name="claude-bottle",
|
||||||
|
upstream_url="ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
|
upstream_host="gitea.dideric.is",
|
||||||
|
upstream_port="30009",
|
||||||
|
identity_file="/host/path/key",
|
||||||
|
known_host_key="ssh-ed25519 AAAA",
|
||||||
|
),
|
||||||
|
GitGateUpstream(
|
||||||
|
name="foo",
|
||||||
|
upstream_url="ssh://git@github.com/didericis/foo.git",
|
||||||
|
upstream_host="github.com",
|
||||||
|
upstream_port="22",
|
||||||
|
identity_file="/host/path/key2",
|
||||||
|
known_host_key="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
script = git_gate_render_entrypoint(ups)
|
||||||
|
self.assertIn("#!/bin/sh", script)
|
||||||
|
self.assertIn(
|
||||||
|
"init_repo 'claude-bottle' "
|
||||||
|
"'ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git'",
|
||||||
|
script,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"init_repo 'foo' 'ssh://git@github.com/didericis/foo.git'",
|
||||||
|
script,
|
||||||
|
)
|
||||||
|
# Daemon line is what keeps PID 1 alive.
|
||||||
|
self.assertIn("exec git daemon", script)
|
||||||
|
self.assertIn("--enable=receive-pack", script)
|
||||||
|
self.assertIn("--base-path=/git", script)
|
||||||
|
# The access-hook is what makes fetch a mirror operation
|
||||||
|
# against the upstream (PRD 0008 v1.1).
|
||||||
|
self.assertIn("--access-hook=/etc/git-gate/access-hook", script)
|
||||||
|
# Each repo's `origin` remote is wired to the upstream via
|
||||||
|
# --mirror=fetch so `git fetch origin` mirrors all refs.
|
||||||
|
self.assertIn("remote add --mirror=fetch origin", script)
|
||||||
|
|
||||||
|
def test_empty_upstreams_still_execs_daemon(self):
|
||||||
|
# A no-upstream gate is a no-op for repos but the daemon still
|
||||||
|
# has to start so the entrypoint doesn't exit.
|
||||||
|
script = git_gate_render_entrypoint(())
|
||||||
|
self.assertNotIn("init_repo '", script)
|
||||||
|
self.assertIn("exec git daemon", script)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHookRender(unittest.TestCase):
|
||||||
|
def test_pre_receive_hook_has_two_phases(self):
|
||||||
|
hook = git_gate_render_hook()
|
||||||
|
# Phase 1: gitleaks. Phase 2: forward to origin.
|
||||||
|
self.assertIn("gitleaks git", hook)
|
||||||
|
self.assertIn("git push origin", hook)
|
||||||
|
# KnownHostKey absence is fail-closed.
|
||||||
|
self.assertIn("refusing to push", hook)
|
||||||
|
# Stdin is buffered to a tempfile so both phases can re-read.
|
||||||
|
self.assertIn("refs_file=$(mktemp)", hook)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccessHookRender(unittest.TestCase):
|
||||||
|
def test_access_hook_refreshes_origin_on_upload_pack(self):
|
||||||
|
hook = git_gate_render_access_hook()
|
||||||
|
# Service-name guard: only upload-pack (fetch / clone / pull /
|
||||||
|
# ls-remote) triggers the upstream refresh; receive-pack
|
||||||
|
# bypasses this and the pre-receive hook gates it instead.
|
||||||
|
self.assertIn('service=$1', hook)
|
||||||
|
self.assertIn('"$service" != "upload-pack"', hook)
|
||||||
|
# The fetch is what makes the gate a transparent mirror.
|
||||||
|
self.assertIn("git -C \"$repo_dir\" fetch origin --prune", hook)
|
||||||
|
|
||||||
|
def test_access_hook_fail_closed_on_upstream_error(self):
|
||||||
|
hook = git_gate_render_access_hook()
|
||||||
|
# Upstream-fetch failure exits non-zero, which propagates to
|
||||||
|
# the agent's fetch as a real error rather than stale data.
|
||||||
|
self.assertIn("refusing to serve stale data", hook)
|
||||||
|
self.assertIn("exit 1", hook)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrepare(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.stage = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(self.stage, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_prepare_writes_all_three_scripts(self):
|
||||||
|
plan = _StubGate().prepare(
|
||||||
|
fixture_with_git().bottles["dev"], "demo", self.stage
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.stage / "git_gate_entrypoint.sh", plan.entrypoint_script
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.stage / "git_gate_pre_receive.sh", plan.hook_script
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.stage / "git_gate_access_hook.sh", plan.access_hook_script
|
||||||
|
)
|
||||||
|
# Entrypoint + pre-receive are mode 600 (loaded into the
|
||||||
|
# gate by docker cp and then `install -m 755`'d into each
|
||||||
|
# bare repo's hooks/ — source bit doesn't matter). The
|
||||||
|
# access-hook is execed directly by git daemon, so it has to
|
||||||
|
# carry the x bit through docker cp.
|
||||||
|
self.assertEqual(0o600, os.stat(plan.entrypoint_script).st_mode & 0o777)
|
||||||
|
self.assertEqual(0o600, os.stat(plan.hook_script).st_mode & 0o777)
|
||||||
|
self.assertEqual(0o700, os.stat(plan.access_hook_script).st_mode & 0o777)
|
||||||
|
|
||||||
|
def test_prepare_plan_carries_upstreams_and_slug(self):
|
||||||
|
plan = _StubGate().prepare(
|
||||||
|
fixture_with_git().bottles["dev"], "demo", self.stage
|
||||||
|
)
|
||||||
|
self.assertEqual("demo", plan.slug)
|
||||||
|
self.assertEqual(2, len(plan.upstreams))
|
||||||
|
self.assertEqual("", plan.internal_network)
|
||||||
|
self.assertEqual("", plan.egress_network)
|
||||||
|
|
||||||
|
def test_prepare_with_no_git_writes_minimal_script(self):
|
||||||
|
plan = _StubGate().prepare(
|
||||||
|
fixture_minimal().bottles["dev"], "demo", self.stage
|
||||||
|
)
|
||||||
|
self.assertEqual((), plan.upstreams)
|
||||||
|
content = plan.entrypoint_script.read_text()
|
||||||
|
self.assertNotIn("init_repo '", content)
|
||||||
|
self.assertIn("exec git daemon", content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Unit: Bottle.git manifest parsing + validation (PRD 0008)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from claude_bottle.log import Die
|
||||||
|
from claude_bottle.manifest import Manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(git_entries):
|
||||||
|
return {
|
||||||
|
"bottles": {"dev": {"git": git_entries}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitEntryParsing(unittest.TestCase):
|
||||||
|
def test_parses_minimal_entry(self):
|
||||||
|
m = Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "claude-bottle",
|
||||||
|
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
entries = m.bottles["dev"].git
|
||||||
|
self.assertEqual(1, len(entries))
|
||||||
|
e = entries[0]
|
||||||
|
self.assertEqual("claude-bottle", e.Name)
|
||||||
|
self.assertEqual("git", e.UpstreamUser)
|
||||||
|
self.assertEqual("gitea.dideric.is", e.UpstreamHost)
|
||||||
|
self.assertEqual("30009", e.UpstreamPort)
|
||||||
|
self.assertEqual("didericis/claude-bottle.git", e.UpstreamPath)
|
||||||
|
|
||||||
|
def test_default_port_is_22(self):
|
||||||
|
m = Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com/didericis/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
e = m.bottles["dev"].git[0]
|
||||||
|
self.assertEqual("22", e.UpstreamPort)
|
||||||
|
self.assertEqual("github.com", e.UpstreamHost)
|
||||||
|
|
||||||
|
def test_known_host_key_optional(self):
|
||||||
|
m = Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
|
||||||
|
|
||||||
|
def test_missing_name_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Upstream": "ssh://git@github.com/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
|
||||||
|
def test_missing_upstream_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
|
||||||
|
def test_missing_identity_file_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com/foo.git",
|
||||||
|
}]))
|
||||||
|
|
||||||
|
def test_non_ssh_upstream_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "https://github.com/didericis/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
|
||||||
|
def test_scp_style_upstream_dies(self):
|
||||||
|
# SCP-style "git@host:path" is intentionally not supported in
|
||||||
|
# v1 — ssh:// only.
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "git@github.com:didericis/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
|
||||||
|
def test_upstream_without_user_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://github.com/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
|
||||||
|
def test_upstream_without_path_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
|
||||||
|
def test_non_numeric_port_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([{
|
||||||
|
"Name": "foo",
|
||||||
|
"Upstream": "ssh://git@github.com:notaport/foo.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitEntryCrossValidation(unittest.TestCase):
|
||||||
|
def test_duplicate_name_dies(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj(_manifest([
|
||||||
|
{"Name": "foo", "Upstream": "ssh://git@a.example/x.git",
|
||||||
|
"IdentityFile": "/dev/null"},
|
||||||
|
{"Name": "foo", "Upstream": "ssh://git@b.example/y.git",
|
||||||
|
"IdentityFile": "/dev/null"},
|
||||||
|
]))
|
||||||
|
|
||||||
|
def test_shadow_route_with_ssh_entry_dies(self):
|
||||||
|
# An ssh entry pointing at gitea.dideric.is:30009 AND a git
|
||||||
|
# entry pointing at ssh://git@gitea.dideric.is:30009/... is a
|
||||||
|
# bypass: agents could route around the gate by using the
|
||||||
|
# ssh-gate. Manifest construction must reject.
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj({
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"ssh": [{
|
||||||
|
"Host": "gitea",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
"Hostname": "gitea.dideric.is",
|
||||||
|
"User": "git",
|
||||||
|
"Port": 30009,
|
||||||
|
}],
|
||||||
|
"git": [{
|
||||||
|
"Name": "claude-bottle",
|
||||||
|
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_independent_ssh_and_git_targets_allowed(self):
|
||||||
|
# Same hostname but different ports are independent targets.
|
||||||
|
m = Manifest.from_json_obj({
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"ssh": [{
|
||||||
|
"Host": "gitea-ssh",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
"Hostname": "gitea.dideric.is",
|
||||||
|
"User": "git",
|
||||||
|
"Port": 22,
|
||||||
|
}],
|
||||||
|
"git": [{
|
||||||
|
"Name": "claude-bottle",
|
||||||
|
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
self.assertEqual(1, len(m.bottles["dev"].ssh))
|
||||||
|
self.assertEqual(1, len(m.bottles["dev"].git))
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyGitField(unittest.TestCase):
|
||||||
|
def test_no_git_field_yields_empty_tuple(self):
|
||||||
|
m = Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": {}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
self.assertEqual((), m.bottles["dev"].git)
|
||||||
|
|
||||||
|
def test_git_array_type_required(self):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": {"git": "not-a-list"}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Unit: render of ~/.gitconfig pushInsteadOf rules (PRD 0008)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from claude_bottle.backend.docker.provision.git import render_git_gate_gitconfig
|
||||||
|
from tests.fixtures import fixture_minimal, fixture_with_git
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitGateGitconfigRender(unittest.TestCase):
|
||||||
|
def test_empty_entries_renders_nothing(self):
|
||||||
|
bottle = fixture_minimal().bottles["dev"]
|
||||||
|
self.assertEqual("", render_git_gate_gitconfig("demo", bottle.git))
|
||||||
|
|
||||||
|
def test_one_block_per_entry(self):
|
||||||
|
bottle = fixture_with_git().bottles["dev"]
|
||||||
|
out = render_git_gate_gitconfig("demo", bottle.git)
|
||||||
|
# Both entries map to a [url ...] block keyed on the gate's
|
||||||
|
# container hostname (claude-bottle-git-gate-<slug>).
|
||||||
|
self.assertIn(
|
||||||
|
'[url "git://claude-bottle-git-gate-demo/claude-bottle.git"]',
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"\tinsteadOf = "
|
||||||
|
"ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
self.assertIn('[url "git://claude-bottle-git-gate-demo/foo.git"]', out)
|
||||||
|
self.assertIn(
|
||||||
|
"\tinsteadOf = ssh://git@github.com/didericis/foo.git",
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_insteadOf_not_pushInsteadOf(self):
|
||||||
|
# The gate mirrors fetch and push, so insteadOf (which rewrites
|
||||||
|
# both directions) is the right knob. pushInsteadOf would only
|
||||||
|
# gate push and leave fetch on the original URL — exactly the
|
||||||
|
# v1 design we've moved past.
|
||||||
|
bottle = fixture_with_git().bottles["dev"]
|
||||||
|
out = render_git_gate_gitconfig("demo", bottle.git)
|
||||||
|
self.assertIn("\tinsteadOf", out)
|
||||||
|
self.assertNotIn("pushInsteadOf", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user