Files
mjalmoudy94 d41e00ad10 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
2026-10-14 00:18:13 +03:30

74 lines
2.2 KiB
Python

"""Projects domain: projects, their Git repositories and per-project credentials."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from app.domain.base import new_id, utcnow
@dataclass(slots=True, kw_only=True)
class Project:
organization_id: str
name: str
slug: str
description: str = ""
created_by_user_id: str | None = None
default_branch: str = "main"
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class ProjectGitRepo:
"""The Git repository attached to a project.
Named ``ProjectGitRepo`` rather than ``ProjectRepository`` on purpose: the
latter collided with the *infrastructure* repository that manages ``Project``
rows, and the shadowing silently broke every Git endpoint at runtime.
"""
project_id: str
provider: str = "gitea"
owner: str = ""
repo_name: str = ""
clone_url: str = ""
web_url: str = ""
default_branch: str = "main"
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@property
def full_name(self) -> str:
return f"{self.owner}/{self.repo_name}"
@dataclass(slots=True, kw_only=True)
class ProjectGitCredential:
"""Per-project Git identity. The password is encrypted at rest and never
returned to the frontend."""
project_id: str
username: str
encrypted_password: str
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class ProjectExternalGit:
"""Credentials for a *user-supplied* git remote (v2 ``git_source=external``).
Separate from :class:`ProjectGitCredential`, which is the platform's own
Gitea identity. Keeping them apart means switching a project to an external
remote can never accidentally reuse — or expose — the platform token.
"""
project_id: str
auth_type: str = "none" # none | token | ssh_key
username: str | None = None
secret_encrypted: str | None = None
known_hosts: str | None = None
updated_at: datetime = field(default_factory=utcnow)