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:
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,17 @@ from __future__ import annotations
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.projects.models import (
|
||||
Project,
|
||||
ProjectExternalGit,
|
||||
ProjectGitCredential,
|
||||
ProjectGitRepo,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
PreviewDomainRow,
|
||||
PreviewPortRow,
|
||||
ProjectExternalGitRow,
|
||||
ProjectGitCredentialRow,
|
||||
ProjectRepositoryRow,
|
||||
ProjectRow,
|
||||
@@ -36,6 +39,53 @@ def _repo_to_domain(row: ProjectRepositoryRow) -> ProjectGitRepo:
|
||||
)
|
||||
|
||||
|
||||
class ProjectExternalGitRepository:
|
||||
"""Persistence for a user-supplied remote's credentials.
|
||||
|
||||
The secret is stored encrypted and is *never* returned by this repository:
|
||||
callers that need it (the git workspace adapter) ask for it explicitly
|
||||
through :meth:`get_secret`, which keeps the plaintext out of every code path
|
||||
that only needs to render a status.
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_project(self, project_id: str) -> ProjectExternalGit | None:
|
||||
row = await self.session.get(ProjectExternalGitRow, project_id)
|
||||
return _external_git_to_domain(row) if row else None
|
||||
|
||||
async def upsert(self, record: ProjectExternalGit) -> ProjectExternalGit:
|
||||
row = await self.session.get(ProjectExternalGitRow, record.project_id)
|
||||
if row is None:
|
||||
row = ProjectExternalGitRow(project_id=record.project_id)
|
||||
self.session.add(row)
|
||||
row.auth_type = record.auth_type
|
||||
row.username = record.username
|
||||
row.secret_encrypted = record.secret_encrypted
|
||||
row.known_hosts = record.known_hosts
|
||||
row.updated_at = utcnow()
|
||||
await self.session.flush()
|
||||
return _external_git_to_domain(row)
|
||||
|
||||
async def delete_for_project(self, project_id: str) -> None:
|
||||
row = await self.session.get(ProjectExternalGitRow, project_id)
|
||||
if row is not None:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
def _external_git_to_domain(row: ProjectExternalGitRow) -> ProjectExternalGit:
|
||||
return ProjectExternalGit(
|
||||
project_id=row.project_id,
|
||||
auth_type=row.auth_type,
|
||||
username=row.username,
|
||||
secret_encrypted=row.secret_encrypted,
|
||||
known_hosts=row.known_hosts,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class ProjectGitRepoRepository:
|
||||
"""Persistence for the Git repository attached to a project."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user