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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user