feat(delivery): git workspaces + SSRF host check (v2 phase A3-2)

The platform's side of D6: git runs on the host, in a gitdir that is never
mounted into a container, so repository credentials never reach the model.

- adapters/git_workspace.py: LocalGitWorkspaceAdapter implementing
  RepoWorkspacePort. Bare shared gitdir + a dev work tree mounted at
  /workspace + one linked worktree per task under work/.worktrees/<id>
  (excluded via the gitdir's info/exclude, not the project's .gitignore).
  Explicit --git-dir/--work-tree, core.hooksPath=/dev/null, fsmonitor off,
  quotePath off, GIT_CONFIG_NOSYSTEM, a private HOME per project, prompts off,
  one asyncio.Lock per project. Token via GIT_ASKPASS reading a 0600 file; SSH
  key as a 0600 temp file with a pinned known_hosts. Everything deleted in a
  finally; only a *path* ever travels through the environment.
- The bug this phase existed to find: --git-dir=<shared> does not address a
  linked worktree, so `git commit` in a task worktree committed straight to
  `dev`. Operations now address <gitdir>/worktrees/<task_id> explicitly — and
  never read the worktree's .git file, which a repository can rewrite.
- domain/platform/rules.py: is_blocked_address (pure) + assert_remote_host_allowed
  (resolves, refuses if any address is loopback/private/link-local/metadata).
  Shared by the git remote test and, later, every deploy target.
- ProjectExternalGit domain model + repository; container_delivery wires the
  real adapter behind GIT_WORKSPACE=local with a resolver that joins the
  project, its repo and its (decrypted) credentials.

Tests: 394 API (+48) against real git and a local bare repo; ruff and mypy clean.

Report: docs/plan/reports/agent-3-phase-2.md
This commit is contained in:
mjalmoudy94
2026-10-14 00:18:13 +03:30
parent a0c8473eb8
commit d41e00ad10
10 changed files with 2242 additions and 6 deletions
+100 -3
View File
@@ -1,16 +1,41 @@
"""Wiring for the delivery workstream (repo workspaces, delivery hooks, deploy tester).
Foundation defaults are in-memory fakes. The delivery agent replaces the bodies (behind
settings) with the real adapters; the function names and return types are FROZEN.
The real adapters are selected by settings; the fakes remain the defaults so a
test that does not opt in never touches a git binary, a container or a network.
``build_repo_workspace`` is the interesting one: the git adapter needs to know
*where* a project's repository lives and *how* to authenticate to it, and that
knowledge lives in three places — ``project_repositories`` +
``project_git_credentials`` for the platform forge, ``project_external_git`` for a
user-supplied remote, and ``projects`` for which of the two applies. The resolver
below is the only code that joins them, and it hands the plaintext secret
straight to the adapter for the duration of a single call.
"""
from __future__ import annotations
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester
from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort
from app.core.config import settings
from app.core.crypto import decrypt
from app.infrastructure.adapters.git_workspace import (
LocalGitWorkspaceAdapter,
ProjectGitSource,
)
from app.infrastructure.db.models import ProjectRow
from app.infrastructure.repositories.projects import (
ProjectExternalGitRepository,
ProjectGitCredentialRepository,
ProjectGitRepoRepository,
ProjectRepository,
)
logger = logging.getLogger("agy.container.delivery")
_fake_workspace = FakeRepoWorkspace()
_null_hooks = NullDeliveryHooks()
@@ -18,7 +43,79 @@ _fake_deploy_tester = FakeDeployTargetTester()
def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort:
return _fake_workspace
"""The platform's git adapter, or the in-memory fake when ``GIT_WORKSPACE=fake``."""
if settings.git_workspace.strip().lower() != "local":
return _fake_workspace
projects = ProjectRepository(session)
repos = ProjectGitRepoRepository(session)
credentials = ProjectGitCredentialRepository(session)
external = ProjectExternalGitRepository(session)
async def resolve(project_id: str) -> ProjectGitSource | None:
project = await projects.get(project_id)
if project is None:
return None
row = await session.get(ProjectRow, project_id)
if row is None:
return None
git_source = (row.git_source or "platform").lower()
if git_source == "external":
url = (row.external_repo_url or "").strip()
if not url:
return None
record = await external.get_for_project(project_id)
return ProjectGitSource(
project_id=project_id,
organization_id=project.organization_id,
clone_url=url,
default_branch=project.default_branch,
auth_type=(record.auth_type if record else "none") or "none",
username=record.username if record else None,
secret=_decrypt_or_none(
record.secret_encrypted if record else None, project_id
),
known_hosts=record.known_hosts if record else None,
is_platform=False,
)
repo = await repos.get_for_project(project_id)
if repo is None or not repo.clone_url:
return None
credential = await credentials.get_for_project(project_id)
secret = _decrypt_or_none(
credential.encrypted_password if credential else None, project_id
)
return ProjectGitSource(
project_id=project_id,
organization_id=project.organization_id,
clone_url=repo.clone_url,
default_branch=repo.default_branch or project.default_branch,
auth_type="token" if secret else "none",
username=credential.username if credential else None,
secret=secret,
is_platform=True,
)
return LocalGitWorkspaceAdapter(resolver=resolve)
def _decrypt_or_none(ciphertext: str | None, project_id: str) -> str | None:
"""Decrypt a stored secret, tolerating a rotated ``ENCRYPTION_KEY``.
A rotated key must surface as "no credential" (git then fails with an auth
error the user can act on) rather than as an exception that breaks an
unrelated request. The failure is logged, never raised with the ciphertext.
"""
if not ciphertext:
return None
try:
return decrypt(ciphertext)
except Exception: # noqa: BLE001 - see docstring
logger.warning("git credential for project %s could not be decrypted", project_id)
return None
def build_delivery_hooks(session: AsyncSession) -> DeliveryHooks: