Files
bot-bottle/tests/unit/test_git_gate_render_provision.py
T
didericis d4b27ebf1f
lint / lint (push) Successful in 2m12s
test / unit (pull_request) Successful in 1m12s
test / integration (pull_request) Successful in 27s
test / coverage (pull_request) Successful in 1m21s
feat(gateway): mandatory identity-token attribution on every data plane (PR #354 review)
Codex review: the /31 TAP doesn't make source IP unspoofable, and the
app-layer token was returned by launch but never delivered or enforced, so
a spoofed source could select a victim bottle's policy/tokens. Make the
token mandatory and deliver it on each attributed plane (anti-spoof landed
separately as the network boundary).

Enforcement (control plane):
- `Orchestrator.resolve` now requires a matching (source_ip, identity_token)
  pair (constant-time) — no source-IP-only fallback. `/resolve` fail-closes
  (403) on a missing/empty/mismatched token.

Delivery, per plane (the token is `token_urlsafe`, safe in a URL):
- egress: proxy credentials (`HTTPS_PROXY=http://bottle:<token>@gw`). The
  addon reads `Proxy-Authorization` — from the request (HTTP) or captured at
  the CONNECT for HTTPS tunnels (keyed by client conn, cleared on disconnect)
  — validates, and strips it (+ the legacy header) before upstream.
- git-http: a URL-scoped `http.<gate>/.extraHeader: x-bot-bottle-identity`
  in the agent's git config (only over the http transport).
- supervise: `mcp add --header x-bot-bottle-identity: <token>` (claude +
  codex); the server reads the header and passes it to resolve.

Wiring: thread `ctx.identity_token` onto the firecracker + docker plans and
into the agent env/config at launch.

Verified on a KVM host: egress with the correct proxy-cred token returns
200 (HTTP and HTTPS/CONNECT), and no-token / wrong-token return 403; a real
`cli.py start --backend=firecracker` launch provisions git config + the
supervise MCP header and reaches the agent session, all under mandatory
enforcement. Fixed a `claude mcp add` arg-order bug (--header must follow
the positional name/url) found by that launch.

Transparent proxy for tools that ignore proxy env is deferred to a
follow-up (see thread); anti-spoof + host firewall remain the fail-closed
boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-16 17:02:41 -04:00

189 lines
7.5 KiB
Python

"""Unit: git_gate gitconfig rendering + deploy-key provision/revoke
(coverage ratchet, ADR 0004).
Covers the pure `git_gate_render_gitconfig` renderer and the dynamic
(gitea) deploy-key lifecycle, with the forge provisioner mocked."""
from __future__ import annotations
import tempfile
import types
import unittest
from pathlib import Path
from typing import Any, cast
from unittest.mock import patch
from bot_bottle.errors import MissingEnvVarError
from bot_bottle.git_gate import (
_gitconfig_validate_value,
_provision_dynamic_key,
git_gate_render_gitconfig,
revoke_git_gate_provisioned_keys,
)
from bot_bottle.manifest_git import ManifestGitEntry, ManifestKeyConfig
def _entry(**kw: Any) -> ManifestGitEntry:
base: dict[str, Any] = {
"Name": "repo",
"Upstream": "git@github.com:o/r.git",
"UpstreamHost": "github.com",
"UpstreamUser": "git",
"UpstreamPath": "o/r.git",
"UpstreamPort": "22",
}
base.update(kw)
return ManifestGitEntry(**base)
def _gitea_entry(**kw: Any) -> ManifestGitEntry:
return _entry(
Key=ManifestKeyConfig(provider="gitea", forge_token_env="GITEA_TOK"),
**kw,
)
class _FakeProvisioner:
def __init__(self) -> None:
self.created: list[tuple[str, str]] = []
self.deleted: list[tuple[str, str]] = []
def create(self, owner_repo: str, title: str) -> tuple[str, bytes]:
self.created.append((owner_repo, title))
return "kid123", b"PRIVATE-KEY-BYTES"
def delete(self, owner_repo: str, key_id: str) -> None:
self.deleted.append((owner_repo, key_id))
# ---------------------------------------------------------------------------
# git_gate_render_gitconfig
# ---------------------------------------------------------------------------
class TestRenderGitconfig(unittest.TestCase):
def test_empty_entries_returns_empty_string(self) -> None:
self.assertEqual("", git_gate_render_gitconfig((), "git-gate"))
def test_single_entry_renders_insteadof(self) -> None:
out = git_gate_render_gitconfig((_entry(),), "git-gate")
self.assertIn('[url "git://git-gate/repo.git"]', out)
self.assertIn("insteadOf = git@github.com:o/r.git", out)
def test_scheme_override(self) -> None:
out = git_gate_render_gitconfig((_entry(),), "1.2.3.4:9418", scheme="http")
self.assertIn('[url "http://1.2.3.4:9418/repo.git"]', out)
def test_identity_token_extraheader_over_http(self) -> None:
# Delivered as a URL-scoped http.extraHeader so git-http can enforce
# the mandatory (source_ip, token) pair; only over the http transport.
out = git_gate_render_gitconfig(
(_entry(),), "1.2.3.4:9420", scheme="http", identity_token="TOK123")
self.assertIn('[http "http://1.2.3.4:9420/"]', out)
self.assertIn("extraHeader = x-bot-bottle-identity: TOK123", out)
def test_identity_token_omitted_over_git_scheme(self) -> None:
out = git_gate_render_gitconfig(
(_entry(),), "git-gate", scheme="git", identity_token="TOK123")
self.assertNotIn("extraHeader", out)
def test_remote_key_alias_with_nondefault_port(self) -> None:
out = git_gate_render_gitconfig(
(_entry(RemoteKey="10.0.0.5", UpstreamPort="2222"),), "git-gate",
)
self.assertIn("insteadOf = ssh://git@10.0.0.5:2222/o/r.git", out)
def test_remote_key_alias_default_port_omits_port(self) -> None:
out = git_gate_render_gitconfig(
(_entry(RemoteKey="10.0.0.5", UpstreamPort="22"),), "git-gate",
)
self.assertIn("insteadOf = ssh://git@10.0.0.5/o/r.git", out)
self.assertNotIn(":22/", out)
def test_validate_rejects_newline(self) -> None:
with self.assertRaises(ValueError):
_gitconfig_validate_value("field", "line1\nline2")
def test_render_rejects_newline_in_upstream(self) -> None:
with self.assertRaises(ValueError):
git_gate_render_gitconfig((_entry(Upstream="a\nb"),), "git-gate")
# ---------------------------------------------------------------------------
# _provision_dynamic_key
# ---------------------------------------------------------------------------
class TestProvisionDynamicKey(unittest.TestCase):
def test_happy_path_writes_key_and_id(self) -> None:
fake = _FakeProvisioner()
with tempfile.TemporaryDirectory() as d, \
patch.dict("os.environ", {"GITEA_TOK": "secret-token"}), \
patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake), \
patch("sys.stderr"):
path = _provision_dynamic_key(_gitea_entry(), "myslug", Path(d))
key_file = Path(path)
self.assertEqual(b"PRIVATE-KEY-BYTES", key_file.read_bytes())
id_file = Path(d) / "repo-deploy-key-id"
self.assertEqual("kid123", id_file.read_text())
# owner_repo had .git stripped; title carries slug + name
self.assertEqual([("o/r", "bot-bottle:myslug:repo")], fake.created)
def test_missing_token_raises(self) -> None:
with tempfile.TemporaryDirectory() as d, \
patch.dict("os.environ", {}, clear=False):
import os
os.environ.pop("GITEA_TOK", None)
with self.assertRaises(MissingEnvVarError):
_provision_dynamic_key(_gitea_entry(), "s", Path(d))
# ---------------------------------------------------------------------------
# revoke_git_gate_provisioned_keys
# ---------------------------------------------------------------------------
def _bottle(*entries: ManifestGitEntry) -> Any:
return cast(Any, types.SimpleNamespace(git=entries))
class TestRevokeProvisionedKeys(unittest.TestCase):
def test_revokes_gitea_key_when_id_present(self) -> None:
fake = _FakeProvisioner()
with tempfile.TemporaryDirectory() as d, \
patch.dict("os.environ", {"GITEA_TOK": "secret-token"}), \
patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake), \
patch("sys.stderr"):
(Path(d) / "repo-deploy-key-id").write_text("kid123")
revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d))
self.assertEqual([("o/r", "kid123")], fake.deleted)
def test_skips_non_gitea_entry(self) -> None:
fake = _FakeProvisioner()
static_entry = _entry(Key=ManifestKeyConfig(provider="static", path="/k"))
with tempfile.TemporaryDirectory() as d, \
patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake):
revoke_git_gate_provisioned_keys(_bottle(static_entry), Path(d))
self.assertEqual([], fake.deleted)
def test_skips_when_id_file_missing(self) -> None:
fake = _FakeProvisioner()
with tempfile.TemporaryDirectory() as d, \
patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake):
# no id file written -> entry skipped
revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d))
self.assertEqual([], fake.deleted)
def test_missing_token_raises(self) -> None:
with tempfile.TemporaryDirectory() as d, \
patch.dict("os.environ", {}, clear=False):
import os
os.environ.pop("GITEA_TOK", None)
(Path(d) / "repo-deploy-key-id").write_text("kid123")
with self.assertRaises(MissingEnvVarError):
revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d))
if __name__ == "__main__":
unittest.main()