fix(git-gate): review — sandbox naming, ResolverLike Protocol, token-required note
Addresses PR #365 review: - Type `resolve_sandbox_root`'s resolver param as a `ResolverLike` Protocol (structural, `resolve_bottle_id` only) — fixes the pyright errors from passing a duck-typed fake resolver in tests; mirrors egress_addon_core.PolicyResolverLike. - Rename `_project_root`/`project_root`/`resolve_repo_root`/ `DEFAULT_PROJECT_ROOT` -> `_sandbox_root`/`sandbox_root`/ `resolve_sandbox_root`/`DEFAULT_REPO_ROOT`: "sandbox" names the per-tenant scope; "project" was too amorphous. `GIT_PROJECT_ROOT` keeps git's own env-var name. - Note the single-tenant path is transitional (stripped once every backend runs the consolidated gateway). - policy_resolver: document that the optional identity token is transitional — the consolidated end state requires it (flips at the /resolve boundary once token *delivery* lands, a PRD 0070 open question). - Split the long line in main() (was 105 cols). pyright 0 errors; pylint 9.95/10; unit suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -13,7 +13,8 @@ calling bottle's repo namespace (`<root>/<bottle_id>`), attributed from
|
|||||||
the unspoofable source IP via the orchestrator. Per-repo credentials +
|
the unspoofable source IP via the orchestrator. Per-repo credentials +
|
||||||
hooks scope by repo directory, so isolating the *root* per bottle isolates
|
hooks scope by repo directory, so isolating the *root* per bottle isolates
|
||||||
its creds too. Unattributed clients fail closed (404). Unset → the legacy
|
its creds too. Unattributed clients fail closed (404). Unset → the legacy
|
||||||
per-bottle single-tenant flat root, unchanged.
|
per-bottle single-tenant flat root, unchanged — a transitional path that
|
||||||
|
gets stripped out once every backend runs the consolidated gateway.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,6 +22,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import typing
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
@@ -54,24 +56,38 @@ ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
|||||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
|
|
||||||
# Default flat repo root (single-tenant, and the base under which
|
# Default flat repo root (single-tenant, and the base under which
|
||||||
# consolidated mode nests each bottle's namespace).
|
# consolidated mode nests each sandbox's namespace).
|
||||||
DEFAULT_PROJECT_ROOT = "/git"
|
DEFAULT_REPO_ROOT = "/git"
|
||||||
|
|
||||||
|
|
||||||
def resolve_repo_root(
|
class ResolverLike(typing.Protocol):
|
||||||
resolver: "PolicyResolver | None",
|
"""Structural type for the resolver `resolve_sandbox_root` needs — just
|
||||||
|
`resolve_bottle_id`. A Protocol so tests can pass a fake without
|
||||||
|
importing PolicyResolver (mirrors egress_addon_core.PolicyResolverLike)."""
|
||||||
|
|
||||||
|
def resolve_bottle_id(
|
||||||
|
self, source_ip: str, identity_token: str = ...,
|
||||||
|
) -> "str | None": ...
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_sandbox_root(
|
||||||
|
resolver: "ResolverLike | None",
|
||||||
base_root: Path,
|
base_root: Path,
|
||||||
source_ip: str,
|
source_ip: str,
|
||||||
identity_token: str = "",
|
identity_token: str = "",
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
"""The repo root to serve this request from, or None to deny (404).
|
"""The per-sandbox repo root to serve this request from, or None to
|
||||||
|
deny (404).
|
||||||
|
|
||||||
Single-tenant (`resolver is None`): the flat `base_root`, unchanged.
|
Single-tenant (`resolver is None`): the flat `base_root`, unchanged.
|
||||||
|
NOTE: this legacy per-bottle single-tenant path is transitional — it
|
||||||
|
will be stripped out once every backend runs the consolidated gateway
|
||||||
|
(PRD 0070), leaving only the source-IP-attributed path below.
|
||||||
|
|
||||||
Consolidated: `base_root/<bottle_id>`, where the bottle is attributed
|
Consolidated: `base_root/<bottle_id>`, where the sandbox is attributed
|
||||||
from the source IP via the orchestrator. Fail-closed — an unattributed
|
from the source IP via the orchestrator. Fail-closed — an unattributed
|
||||||
client, a resolver error, or a namespace that would escape `base_root`
|
client, a resolver error, or a namespace that would escape `base_root`
|
||||||
all deny, so one bottle can never reach another's repos."""
|
all deny, so one sandbox can never reach another's repos."""
|
||||||
if resolver is None:
|
if resolver is None:
|
||||||
return base_root
|
return base_root
|
||||||
try:
|
try:
|
||||||
@@ -106,24 +122,25 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
self._run_backend()
|
self._run_backend()
|
||||||
|
|
||||||
def _project_root(self) -> Path | None:
|
def _sandbox_root(self) -> Path | None:
|
||||||
"""This request's repo root, or None to deny. Single-tenant unless
|
"""This request's per-sandbox repo root, or None to deny. Single-tenant
|
||||||
the server was started with a resolver (consolidated mode), in which
|
unless the server was started with a resolver (consolidated mode), in
|
||||||
case the root is the calling bottle's source-IP-selected namespace."""
|
which case the root is the calling sandbox's source-IP-selected
|
||||||
base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_PROJECT_ROOT))
|
namespace. `GIT_PROJECT_ROOT` keeps git's own env-var name."""
|
||||||
|
base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_REPO_ROOT))
|
||||||
resolver = getattr(self.server, "policy_resolver", None)
|
resolver = getattr(self.server, "policy_resolver", None)
|
||||||
token = self.headers.get(IDENTITY_HEADER, "")
|
token = self.headers.get(IDENTITY_HEADER, "")
|
||||||
return resolve_repo_root(resolver, base, self.client_address[0], token)
|
return resolve_sandbox_root(resolver, base, self.client_address[0], token)
|
||||||
|
|
||||||
def _run_backend(self) -> None:
|
def _run_backend(self) -> None:
|
||||||
project_root = self._project_root()
|
sandbox_root = self._sandbox_root()
|
||||||
if project_root is None:
|
if sandbox_root is None:
|
||||||
# Unattributed / resolver error: deny before touching any repo.
|
# Unattributed / resolver error: deny before touching any repo.
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
return
|
return
|
||||||
parsed = urlsplit(self.path)
|
parsed = urlsplit(self.path)
|
||||||
if self._is_upload_pack(parsed.path, parsed.query):
|
if self._is_upload_pack(parsed.path, parsed.query):
|
||||||
repo_dir = self._repo_dir(project_root, parsed.path)
|
repo_dir = self._repo_dir(sandbox_root, parsed.path)
|
||||||
if repo_dir is None:
|
if repo_dir is None:
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
return
|
return
|
||||||
@@ -157,7 +174,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.update({
|
env.update({
|
||||||
"GIT_PROJECT_ROOT": str(project_root),
|
"GIT_PROJECT_ROOT": str(sandbox_root),
|
||||||
"GIT_HTTP_EXPORT_ALL": "1",
|
"GIT_HTTP_EXPORT_ALL": "1",
|
||||||
"REQUEST_METHOD": self.command,
|
"REQUEST_METHOD": self.command,
|
||||||
"PATH_INFO": parsed.path,
|
"PATH_INFO": parsed.path,
|
||||||
@@ -203,8 +220,8 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
self._write_cgi_response(proc.stdout)
|
self._write_cgi_response(proc.stdout)
|
||||||
|
|
||||||
def _repo_dir(self, project_root: Path, path: str) -> Path | None:
|
def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None:
|
||||||
root = project_root.resolve()
|
root = sandbox_root.resolve()
|
||||||
relative = path.lstrip("/").split(".git", 1)[0] + ".git"
|
relative = path.lstrip("/").split(".git", 1)[0] + ".git"
|
||||||
candidate = (root / relative).resolve()
|
candidate = (root / relative).resolve()
|
||||||
if root not in (candidate, *candidate.parents):
|
if root not in (candidate, *candidate.parents):
|
||||||
@@ -262,9 +279,10 @@ def main() -> int:
|
|||||||
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
|
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
|
||||||
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
|
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
|
||||||
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
|
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
|
||||||
# Consolidated mode: resolve each request's bottle namespace by source IP.
|
# Consolidated mode: resolve each request's sandbox namespace by source
|
||||||
# Absent → single-tenant (flat repo root); behaviour unchanged.
|
# IP. Absent → single-tenant (flat repo root); behaviour unchanged.
|
||||||
server.policy_resolver = PolicyResolver(orch_url) if orch_url else None # type: ignore[attr-defined]
|
resolver = PolicyResolver(orch_url) if orch_url else None
|
||||||
|
server.policy_resolver = resolver # type: ignore[attr-defined]
|
||||||
mode = "multi-tenant" if orch_url else "single-tenant"
|
mode = "multi-tenant" if orch_url else "single-tenant"
|
||||||
sys.stdout.write(f"git-http listening on 0.0.0.0:{port} ({mode})\n")
|
sys.stdout.write(f"git-http listening on 0.0.0.0:{port} ({mode})\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|||||||
@@ -73,8 +73,15 @@ class PolicyResolver:
|
|||||||
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
|
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
|
||||||
"""The calling bottle's policy blob, or None if unattributed. Always
|
"""The calling bottle's policy blob, or None if unattributed. Always
|
||||||
fetches from the orchestrator so revocations / changes / teardowns
|
fetches from the orchestrator so revocations / changes / teardowns
|
||||||
are honored immediately. `identity_token` is optional — omit it to
|
are honored immediately.
|
||||||
resolve by source IP alone (the network-layer attribution).
|
|
||||||
|
`identity_token` is optional *transitionally* — omitting it resolves
|
||||||
|
by source IP alone (the network-layer attribution, sound by
|
||||||
|
construction on Firecracker's `/31`+nft, weaker elsewhere). The
|
||||||
|
consolidated end state makes the token mandatory so the app-layer
|
||||||
|
defense is always enforced, not silently degraded; that flips at the
|
||||||
|
`/resolve` boundary once identity-token *delivery* lands (a PRD 0070
|
||||||
|
open question — we can't require what the agent can't yet present).
|
||||||
Raises `PolicyResolveError` if the orchestrator can't be reached."""
|
Raises `PolicyResolveError` if the orchestrator can't be reached."""
|
||||||
payload = self._post_resolve(source_ip, identity_token)
|
payload = self._post_resolve(source_ip, identity_token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit: resolve_repo_root — source-IP-keyed git-gate repo namespace (PRD 0070).
|
"""Unit: resolve_sandbox_root — source-IP-keyed git-gate repo namespace (PRD 0070).
|
||||||
|
|
||||||
The consolidated git-http backend serves every bottle from one process and
|
The consolidated git-http backend serves every bottle from one process and
|
||||||
selects each request's repo root by the calling bottle's source IP. This is
|
selects each request's repo root by the calling bottle's source IP. This is
|
||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot_bottle.git_http_backend import resolve_repo_root
|
from bot_bottle.git_http_backend import resolve_sandbox_root
|
||||||
from bot_bottle.policy_resolver import PolicyResolveError
|
from bot_bottle.policy_resolver import PolicyResolveError
|
||||||
|
|
||||||
_BASE = Path("/git")
|
_BASE = Path("/git")
|
||||||
@@ -32,32 +32,32 @@ class _FakeResolver:
|
|||||||
class TestResolveRepoRoot(unittest.TestCase):
|
class TestResolveRepoRoot(unittest.TestCase):
|
||||||
def test_single_tenant_passthrough(self) -> None:
|
def test_single_tenant_passthrough(self) -> None:
|
||||||
# No resolver → the flat base root, unchanged (legacy per-bottle mode).
|
# No resolver → the flat base root, unchanged (legacy per-bottle mode).
|
||||||
self.assertEqual(_BASE, resolve_repo_root(None, _BASE, "10.243.0.1"))
|
self.assertEqual(_BASE, resolve_sandbox_root(None, _BASE, "10.243.0.1"))
|
||||||
|
|
||||||
def test_attributed_bottle_gets_namespaced_root(self) -> None:
|
def test_attributed_bottle_gets_namespaced_root(self) -> None:
|
||||||
root = resolve_repo_root(_FakeResolver(bottle_id="ab12cd34"), _BASE, "10.243.0.1")
|
root = resolve_sandbox_root(_FakeResolver(bottle_id="ab12cd34"), _BASE, "10.243.0.1")
|
||||||
self.assertEqual(Path("/git/ab12cd34"), root)
|
self.assertEqual(Path("/git/ab12cd34"), root)
|
||||||
|
|
||||||
def test_unattributed_denies(self) -> None:
|
def test_unattributed_denies(self) -> None:
|
||||||
self.assertIsNone(resolve_repo_root(_FakeResolver(bottle_id=None), _BASE, "10.243.0.9"))
|
self.assertIsNone(resolve_sandbox_root(_FakeResolver(bottle_id=None), _BASE, "10.243.0.9"))
|
||||||
|
|
||||||
def test_empty_bottle_id_denies(self) -> None:
|
def test_empty_bottle_id_denies(self) -> None:
|
||||||
self.assertIsNone(resolve_repo_root(_FakeResolver(bottle_id=""), _BASE, "10.243.0.1"))
|
self.assertIsNone(resolve_sandbox_root(_FakeResolver(bottle_id=""), _BASE, "10.243.0.1"))
|
||||||
|
|
||||||
def test_resolver_error_denies(self) -> None:
|
def test_resolver_error_denies(self) -> None:
|
||||||
# Orchestrator unreachable/errored must never serve repos.
|
# Orchestrator unreachable/errored must never serve repos.
|
||||||
self.assertIsNone(resolve_repo_root(_FakeResolver(raises=True), _BASE, "10.243.0.1"))
|
self.assertIsNone(resolve_sandbox_root(_FakeResolver(raises=True), _BASE, "10.243.0.1"))
|
||||||
|
|
||||||
def test_namespace_escape_denies(self) -> None:
|
def test_namespace_escape_denies(self) -> None:
|
||||||
# A bottle_id that would climb out of the root is rejected (defense in
|
# A bottle_id that would climb out of the root is rejected (defense in
|
||||||
# depth — registry ids are token_hex, but never trust the namespace).
|
# depth — registry ids are token_hex, but never trust the namespace).
|
||||||
self.assertIsNone(
|
self.assertIsNone(
|
||||||
resolve_repo_root(_FakeResolver(bottle_id="../etc"), _BASE, "10.243.0.1")
|
resolve_sandbox_root(_FakeResolver(bottle_id="../etc"), _BASE, "10.243.0.1")
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_forwards_source_ip_and_token(self) -> None:
|
def test_forwards_source_ip_and_token(self) -> None:
|
||||||
r = _FakeResolver(bottle_id="b1")
|
r = _FakeResolver(bottle_id="b1")
|
||||||
resolve_repo_root(r, _BASE, "10.243.0.1", "tok")
|
resolve_sandbox_root(r, _BASE, "10.243.0.1", "tok")
|
||||||
self.assertEqual(("10.243.0.1", "tok"), r.calls[0])
|
self.assertEqual(("10.243.0.1", "tok"), r.calls[0])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user