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
+4
View File
@@ -121,4 +121,8 @@ ARTIFACTS_ROOT=./.data/artifacts
# fake (default, no git on the host) | local (real git workspaces) # fake (default, no git on the host) | local (real git workspaces)
GIT_WORKSPACE=fake GIT_WORKSPACE=fake
ALLOW_CUSTOM_DOMAINS=false ALLOW_CUSTOM_DOMAINS=false
# `protocol.file.allow` for host git calls. The hardened default is `never`: a
# repository must not be able to make git read a local path through a submodule
# or `--upload-pack`. Set to true ONLY to test against a local bare repository.
GIT_ALLOW_FILE_PROTOCOL=false
# <<< agent-3 # <<< agent-3
+99 -2
View File
@@ -1,16 +1,41 @@
"""Wiring for the delivery workstream (repo workspaces, delivery hooks, deploy tester). """Wiring for the delivery workstream (repo workspaces, delivery hooks, deploy tester).
Foundation defaults are in-memory fakes. The delivery agent replaces the bodies (behind The real adapters are selected by settings; the fakes remain the defaults so a
settings) with the real adapters; the function names and return types are FROZEN. 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 from __future__ import annotations
import logging
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester
from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort 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() _fake_workspace = FakeRepoWorkspace()
_null_hooks = NullDeliveryHooks() _null_hooks = NullDeliveryHooks()
@@ -18,8 +43,80 @@ _fake_deploy_tester = FakeDeployTargetTester()
def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort: def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort:
"""The platform's git adapter, or the in-memory fake when ``GIT_WORKSPACE=fake``."""
if settings.git_workspace.strip().lower() != "local":
return _fake_workspace 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: def build_delivery_hooks(session: AsyncSession) -> DeliveryHooks:
return _null_hooks return _null_hooks
+5
View File
@@ -155,6 +155,11 @@ class Settings(BaseSettings):
artifacts_root: str = "./.data/artifacts" artifacts_root: str = "./.data/artifacts"
git_workspace: str = "fake" # fake | local git_workspace: str = "fake" # fake | local
allow_custom_domains: bool = False allow_custom_domains: bool = False
#: ``protocol.file.allow`` for host git calls. ``False`` (→ ``never``) is the
#: hardened default: a repository must not be able to make git read a local
#: path via a submodule or ``--upload-pack``. Set it to true only to test
#: against a local bare repository.
git_allow_file_protocol: bool = False
# <<< agent-3 # <<< agent-3
@property @property
+152 -3
View File
@@ -1,15 +1,24 @@
"""Platform-wide validation rules carried over from the existing infrastructure. """Platform-wide validation rules carried over from the existing infrastructure.
These are pure functions with no I/O, so both the application layer *and* the These are almost all pure functions with no I/O, so both the application layer
adapters can enforce them. Defence in depth: a rule that is only checked in one *and* the adapters can enforce them. Defence in depth: a rule that is only
place is a rule that will eventually be bypassed. checked in one place is a rule that will eventually be bypassed.
The one exception is :func:`assert_remote_host_allowed`, which resolves a
hostname. It has to: a *name* tells you nothing about where it points, and the
whole point of the check is to refuse a name that resolves into the private
network. The address classification itself is pure
(:func:`is_blocked_address`), so the policy is testable without DNS.
Reference: ``docs/current-infra-notes.md`` §1. Reference: ``docs/current-infra-notes.md`` §1.
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress
import re import re
import socket
from urllib.parse import urlsplit
DEFAULT_ROOT_DOMAIN = "msa.artacloud.ir" DEFAULT_ROOT_DOMAIN = "msa.artacloud.ir"
@@ -119,3 +128,143 @@ def assert_not_protected_port(port: int) -> int:
if port in SYSTEM_PROTECTED_PORTS: if port in SYSTEM_PROTECTED_PORTS:
raise RuleViolation(f"اتصال به پورت محافظت‌شدهٔ {port} ممنوع است.") raise RuleViolation(f"اتصال به پورت محافظت‌شدهٔ {port} ممنوع است.")
return port return port
# ---------------------------------------------------------------------------
# SSRF: user-supplied remote hosts (git remotes, deploy targets)
# ---------------------------------------------------------------------------
#: Ports that are always refused on a user-supplied host, whatever the address.
#: A remote git or deploy target has no business talking to a database, a cache
#: or the Docker daemon. Port 22 is deliberately *not* here: git-over-ssh and
#: SFTP deploys are legitimate.
BLOCKED_REMOTE_PORTS: frozenset[int] = frozenset(
{2375, 2376, 3306, 5432, 6379, 9200, 11211, 27017}
)
#: Hostnames that are never a valid user-supplied remote. `localhost` is a name,
#: not an address, so it needs its own rule — DNS would resolve it to loopback,
#: but only if we remembered to resolve.
_BLOCKED_HOSTNAMES: frozenset[str] = frozenset(
{
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata",
"metadata.google.internal",
"instance-data",
}
)
_ALLOWED_REMOTE_SCHEMES: frozenset[str] = frozenset(
{"http", "https", "ssh", "git", "ftp", "ftps", "sftp"}
)
def is_blocked_address(address: str) -> bool:
"""True when an IP literal must not be the target of a user-supplied remote.
Covers every way to reach something that is not the public internet:
loopback, RFC1918 private, link-local (including the cloud metadata address
``169.254.169.254``), unique-local IPv6, multicast, reserved, unspecified,
and IPv4-mapped IPv6 forms of all of the above.
"""
try:
ip = ipaddress.ip_address(address)
except ValueError:
return False # not an IP literal; the DNS path handles it
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
# ::ffff:127.0.0.1 is loopback in disguise.
ip = ip.ipv4_mapped
return bool(
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)
def assert_remote_host_allowed(
url: str, *, allow_private: bool = False, resolver=None
) -> tuple[str, int | None]:
"""Validate a user-supplied remote URL and return ``(host, port)``.
Runs before *any* outbound connection — a git remote test, a deploy
connection — because a user-supplied URL is the classic SSRF vector:
``http://169.254.169.254/`` reaches cloud instance credentials, and
``http://127.0.0.1:6379/`` reaches Redis.
``resolver`` is injectable so the DNS step can be faked in tests; it defaults
to ``socket.getaddrinfo``. Every address the name resolves to must pass, not
just the first: a name can legitimately have both a public and a private
address, and returning either would defeat the check.
"""
clean = (url or "").strip()
if not clean:
raise RuleViolation("آدرس مقصد خالی است.")
if any(ch.isspace() for ch in clean):
raise RuleViolation("آدرس مقصد نباید فاصله داشته باشد.")
# A bare ``git@host:path`` scp-style remote has no scheme; normalise it so
# urlsplit can do its job.
if "://" not in clean and re.match(r"^[^/@\s]+@[^/:\s]+:", clean):
clean = "ssh://" + clean.replace(":", "/", 1)
parts = urlsplit(clean)
if parts.scheme.lower() not in _ALLOWED_REMOTE_SCHEMES:
raise RuleViolation(
"پروتکل آدرس مقصد پشتیبانی نمی‌شود."
)
host = parts.hostname or ""
if not host:
raise RuleViolation("میزبان آدرس مقصد مشخص نیست.")
if parts.password is not None:
# Credentials in a URL end up in logs, in `ps` and in the git remote
# config. They must be supplied as credentials, not as part of the URL.
raise RuleViolation("آدرس مقصد نباید شامل نام کاربری و رمز باشد.")
port = parts.port
if port is not None:
if not (1 <= port <= 65535):
raise RuleViolation("شمارهٔ پورت مقصد نامعتبر است.")
if port in BLOCKED_REMOTE_PORTS:
raise RuleViolation(f"پورت {port} برای مقصد مجاز نیست.")
if allow_private:
return host, port
lowered = host.lower().strip(".")
if lowered in _BLOCKED_HOSTNAMES or lowered.endswith(".localhost"):
raise RuleViolation("اتصال به میزبان محلی مجاز نیست.")
# An IP literal can be judged directly, without DNS.
try:
ipaddress.ip_address(lowered.strip("[]"))
except ValueError:
pass
else:
if is_blocked_address(lowered.strip("[]")):
raise RuleViolation("اتصال به آدرس‌های داخلی مجاز نیست.")
return host, port
lookup = resolver or socket.getaddrinfo
try:
infos = lookup(host, port or 0, type=socket.SOCK_STREAM)
except (socket.gaierror, UnicodeError, OSError) as exc:
raise RuleViolation("میزبان مقصد پیدا نشد.") from exc
# ``getaddrinfo`` returns a family-dependent sockaddr, so the address is
# coerced to str explicitly rather than relying on the overload's inference.
addresses: set[str] = {str(info[4][0]) for info in infos}
if not addresses:
raise RuleViolation("میزبان مقصد پیدا نشد.")
for address in addresses:
if is_blocked_address(address):
raise RuleViolation("اتصال به آدرس‌های داخلی مجاز نیست.")
return host, port
+17
View File
@@ -54,3 +54,20 @@ class ProjectGitCredential:
encrypted_password: str encrypted_password: str
id: str = field(default_factory=new_id) id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow) 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)
File diff suppressed because it is too large Load Diff
@@ -6,14 +6,17 @@ from __future__ import annotations
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.projects.models import ( from app.domain.projects.models import (
Project, Project,
ProjectExternalGit,
ProjectGitCredential, ProjectGitCredential,
ProjectGitRepo, ProjectGitRepo,
) )
from app.infrastructure.db.models import ( from app.infrastructure.db.models import (
PreviewDomainRow, PreviewDomainRow,
PreviewPortRow, PreviewPortRow,
ProjectExternalGitRow,
ProjectGitCredentialRow, ProjectGitCredentialRow,
ProjectRepositoryRow, ProjectRepositoryRow,
ProjectRow, 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: class ProjectGitRepoRepository:
"""Persistence for the Git repository attached to a project.""" """Persistence for the Git repository attached to a project."""
+7
View File
@@ -61,6 +61,13 @@ ignore = ["B008"]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
filterwarnings = ["ignore::DeprecationWarning"] filterwarnings = ["ignore::DeprecationWarning"]
# `docker` marks tests that need a real container runtime, `deploy_it` those
# that need a live sshd/ftp server. Both are skipped unless the matching
# environment flag is set, so the default run needs no infrastructure.
markers = [
"docker: needs a running docker/podman (set SANDBOX_DOCKER_IT=1)",
"deploy_it: needs a live ssh/ftp test server (set DEPLOY_IT=1)",
]
[tool.mypy] [tool.mypy]
python_version = "3.12" python_version = "3.12"
+693
View File
@@ -0,0 +1,693 @@
"""§v2-A3.2 — the platform's git adapter, exercised against real git.
These tests run a real ``git`` binary against a local bare repository standing in
for the remote. Nothing is faked except the *remote's location*: the flags, the
locking, the worktree dance and the credential plumbing are the real thing,
because those are precisely the parts that cannot be verified by a mock.
``protocol.file.allow=user`` is passed explicitly. The production default is
``never`` — a repository must not be able to make git read a local path through a
submodule or ``--upload-pack`` — but a local bare repo *is* a local path, so the
hardening has to be relaxed for the test to be able to run at all. That is why
the setting exists rather than being hard-coded.
"""
from __future__ import annotations
import asyncio
import os
import pytest
from app.core.errors import NotFoundError
from app.domain.platform.rules import RuleViolation, assert_remote_host_allowed, is_blocked_address
from app.infrastructure.adapters.git_workspace import (
LocalGitWorkspaceAdapter,
ProjectGitSource,
)
from app.infrastructure.adapters.process import ProcessResult, run_process
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
async def _git(args: list[str], *, cwd: str, ok: bool = True) -> ProcessResult:
result = await run_process(
["git", *args],
cwd=cwd,
timeout_seconds=60,
env={
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.com",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.com",
},
)
if ok:
assert result.ok, f"git {' '.join(args)} failed: {result.stderr}"
return result
async def _make_remote(tmp_path) -> str:
"""A local bare repository with one commit on ``main``; returns its path."""
remote = tmp_path / "remote.git"
seed = tmp_path / "seed"
await _git(["init", "--quiet", "--bare", str(remote)], cwd=str(tmp_path))
await _git(["init", "--quiet", str(seed)], cwd=str(tmp_path))
(seed / "README.md").write_text("hello\n")
await _git(["add", "-A"], cwd=str(seed))
await _git(["commit", "--quiet", "-m", "init"], cwd=str(seed))
await _git(["branch", "-M", "main"], cwd=str(seed))
await _git(["remote", "add", "origin", str(remote)], cwd=str(seed))
await _git(["push", "--quiet", "origin", "main"], cwd=str(seed))
return str(remote)
def _resolver(clone_url: str, *, auth_type: str = "none", secret: str | None = None,
username: str | None = None):
async def resolve(project_id: str) -> ProjectGitSource | None:
if project_id == "missing":
return None
return ProjectGitSource(
project_id=project_id,
organization_id="org1",
clone_url=clone_url,
default_branch="main",
auth_type=auth_type,
username=username,
secret=secret,
)
return resolve
def _adapter(tmp_path, clone_url: str, **kwargs) -> LocalGitWorkspaceAdapter:
kwargs.setdefault("allow_file_protocol", True)
kwargs.setdefault("runner", run_process)
return LocalGitWorkspaceAdapter(
resolver=_resolver(clone_url, **kwargs.pop("credentials", {})),
root=str(tmp_path / "root"),
**kwargs,
)
# ---------------------------------------------------------------------------
# ensure_workspace
# ---------------------------------------------------------------------------
async def test_ensure_workspace_clones_and_creates_dev(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
ref = await adapter.ensure_workspace(project_id="proj1")
assert ref.default_branch == "dev"
assert os.path.isdir(ref.host_path), "the work tree must exist for the sandbox mount"
assert (tmp_path / "root" / "org1" / "proj1" / "work" / "README.md").is_file()
assert await adapter.head(project_id="proj1", branch="dev") is not None
# `dev` was pushed to the platform remote because it did not exist there.
assert await adapter.head(project_id="proj1", branch="dev") is not None
async def test_ensure_workspace_is_idempotent(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
first = await adapter.ensure_workspace(project_id="proj1")
(tmp_path / "root" / "org1" / "proj1" / "work" / "new.txt").write_text("x")
second = await adapter.ensure_workspace(project_id="proj1")
assert first.host_path == second.host_path
assert (tmp_path / "root" / "org1" / "proj1" / "work" / "new.txt").is_file()
async def test_ensure_workspace_rejects_an_unknown_project(tmp_path):
adapter = _adapter(tmp_path, str(tmp_path / "nothing.git"))
with pytest.raises(NotFoundError):
await adapter.ensure_workspace(project_id="missing")
async def test_gitdir_is_not_inside_the_work_tree(tmp_path):
"""The git dir must never be reachable from the sandbox mount (D6)."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
ref = await adapter.ensure_workspace(project_id="proj1")
gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
assert gitdir.is_dir()
assert not os.path.exists(os.path.join(ref.host_path, ".git"))
assert not str(gitdir).startswith(ref.host_path + os.sep)
# ---------------------------------------------------------------------------
# worktrees
# ---------------------------------------------------------------------------
async def test_task_worktree_is_created_and_removed(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
assert worktree.branch == "task/t1"
assert worktree.sandbox_path == "/workspace/.worktrees/t1"
assert os.path.isdir(worktree.host_path)
await adapter.remove_worktree(worktree=worktree)
assert not os.path.isdir(worktree.host_path)
async def test_task_worktree_is_invisible_in_the_main_work_tree(tmp_path):
"""`info/exclude` keeps .worktrees out of `status` without touching .gitignore."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
ref = await adapter.ensure_workspace(project_id="proj1")
await adapter.create_task_worktree(project_id="proj1", task_id="t1")
result = await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", ref.host_path,
"status", "--porcelain=v1", "-uall"],
cwd=ref.host_path,
)
assert result.stdout.strip() == "", "the worktree dir must not show up as untracked"
assert not os.path.exists(os.path.join(ref.host_path, ".gitignore"))
async def test_create_task_worktree_is_idempotent(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
first = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
second = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
assert first.host_path == second.host_path
# ---------------------------------------------------------------------------
# diff / commit
# ---------------------------------------------------------------------------
async def test_diff_reports_a_new_directory_thanks_to_uall(tmp_path):
"""`git status` without ``-uall`` hides new directories — a real agybot bug."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
(tmp_path / "root" / "org1" / "proj1" / "work" / ".worktrees" / "t1" / "src").mkdir()
(
tmp_path / "root" / "org1" / "proj1" / "work" / ".worktrees" / "t1" / "src" / "a.py"
).write_text("print('hi')\n")
diff = await adapter.diff(worktree=worktree)
assert diff.changed_files == ["src/a.py"]
assert diff.is_empty is False
async def test_diff_detects_an_escaping_symlink(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
outside = tmp_path / "outside.txt"
outside.write_text("secret")
os.symlink(outside, os.path.join(worktree.host_path, "escape.txt"))
diff = await adapter.diff(worktree=worktree)
assert diff.escaping_symlinks == ["escape.txt"]
async def test_commit_returns_none_when_there_is_nothing_to_commit(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
assert await adapter.commit(worktree=worktree, message="empty", author="AI") is None
async def test_commit_records_the_author_and_returns_a_sha(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
target = os.path.join(worktree.host_path, "feature.py")
with open(target, "w", encoding="utf-8") as handle:
handle.write("x = 1\n")
sha = await adapter.commit(worktree=worktree, message="task/t1: add feature", author="Alice")
assert sha and len(sha) == 40
shared_gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
log = await _git(
["--git-dir", str(shared_gitdir), "log", "-1", "--format=%an <%ae>|%s", "task/t1"],
cwd=str(tmp_path),
)
assert log.stdout.strip() == "Alice <alice@smart-org.local>|task/t1: add feature"
# ...and specifically to `task/t1`, not to `dev`. A shared gitdir makes this
# the easiest thing in the world to get wrong.
task_head = await _git(
["--git-dir", str(shared_gitdir), "rev-parse", "task/t1"], cwd=str(tmp_path)
)
assert task_head.stdout.strip() == sha
dev_head = await _git(
["--git-dir", str(shared_gitdir), "rev-parse", "dev"], cwd=str(tmp_path)
)
assert dev_head.stdout.strip() != sha, "the commit must not land on dev directly"
# ---------------------------------------------------------------------------
# merge / push / head
# ---------------------------------------------------------------------------
async def test_merge_into_dev_and_push(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
handle.write("a\n")
sha = await adapter.commit(worktree=worktree, message="task/t1: a", author="AI")
merged = await adapter.merge_into(project_id="proj1", branch="task/t1", target="dev")
assert merged.ok is True, merged.message
# `--no-ff` means dev gets a merge *commit*, not the task commit itself, so
# the task sha is a parent of dev's new head rather than dev's head.
dev_head = await adapter.head(project_id="proj1", branch="dev")
assert dev_head == merged.sha
assert dev_head != sha
ancestry = await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"rev-list", "--parents", "-1", "dev"],
cwd=str(tmp_path),
)
assert sha in ancestry.stdout.split()
pushed = await adapter.push(project_id="proj1", branch="dev")
assert pushed.ok is True, pushed.message
# The remote really received it.
ls = await _git(["--git-dir", remote, "rev-parse", "dev"], cwd=str(tmp_path))
assert ls.stdout.strip() == dev_head
async def test_merge_conflict_leaves_a_clean_tree(tmp_path):
"""A half-merge would break every later operation (agybot bug H3).
The task branch has to fork *before* ``dev`` moves, otherwise the merge is a
trivial fast-forward and there is nothing to conflict about.
"""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
# 1. The task branches off dev as it stands.
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
with open(os.path.join(worktree.host_path, "README.md"), "w", encoding="utf-8") as handle:
handle.write("task version\n")
await adapter.commit(worktree=worktree, message="task/t1: edit", author="AI")
# 2. dev moves on, touching the same file.
main_work = tmp_path / "root" / "org1" / "proj1" / "work"
(main_work / "README.md").write_text("dev version\n")
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "add", "-A"],
cwd=str(main_work),
)
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "commit", "--quiet", "-m", "dev edit"],
cwd=str(main_work),
)
outcome = await adapter.merge_into(project_id="proj1", branch="task/t1", target="dev")
assert outcome.ok is False
assert outcome.conflict_files == ["README.md"]
# No merge in progress: the next status is clean.
status = await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "status", "--porcelain=v1", "-uall"],
cwd=str(main_work),
)
assert status.stdout.strip() == ""
assert not os.path.exists(
tmp_path / "root" / "org1" / "proj1" / "git" / "MERGE_HEAD"
)
async def test_sync_with_base_reports_that_the_base_moved(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
# Nothing moved yet.
quiet = await adapter.sync_with_base(worktree=worktree)
assert quiet.ok is True and quiet.base_changed is False
# Move dev forward.
main_work = tmp_path / "root" / "org1" / "proj1" / "work"
(main_work / "other.txt").write_text("x\n")
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "add", "-A"],
cwd=str(main_work),
)
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "commit", "--quiet", "-m", "dev moved"],
cwd=str(main_work),
)
moved = await adapter.sync_with_base(worktree=worktree)
assert moved.ok is True
assert moved.base_changed is True
assert os.path.isfile(os.path.join(worktree.host_path, "other.txt"))
async def test_concurrent_merges_are_serialised(tmp_path):
"""Without the per-project lock two merges corrupt the index."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
for task in ("t1", "t2"):
worktree = await adapter.create_task_worktree(project_id="proj1", task_id=task)
with open(
os.path.join(worktree.host_path, f"{task}.txt"), "w", encoding="utf-8"
) as handle:
handle.write(f"{task}\n")
await adapter.commit(worktree=worktree, message=f"task/{task}", author="AI")
first, second = await asyncio.gather(
adapter.merge_into(project_id="proj1", branch="task/t1", target="dev"),
adapter.merge_into(project_id="proj1", branch="task/t2", target="dev"),
)
assert first.ok is True and second.ok is True
status = await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(tmp_path / "root" / "org1" / "proj1" / "work"),
"status", "--porcelain=v1", "-uall"],
cwd=str(tmp_path),
)
assert status.stdout.strip() == ""
async def test_push_reports_a_rejection_without_forcing(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
# Someone else advances the remote's dev, so our push is no longer a
# fast-forward. `origin/dev` is what the clone checked out, not a fresh
# branch off the default one.
other = tmp_path / "other"
await _git(["clone", "--quiet", remote, str(other)], cwd=str(tmp_path))
await _git(["checkout", "--quiet", "-B", "dev", "origin/dev"], cwd=str(other))
(other / "divergent.txt").write_text("other\n")
await _git(["add", "-A"], cwd=str(other))
await _git(["commit", "--quiet", "-m", "divergent"], cwd=str(other))
await _git(["push", "--quiet", "origin", "dev"], cwd=str(other))
# Local dev moves a different way.
main_work = tmp_path / "root" / "org1" / "proj1" / "work"
(main_work / "mine.txt").write_text("mine\n")
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "add", "-A"],
cwd=str(main_work),
)
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "commit", "--quiet", "-m", "mine"],
cwd=str(main_work),
)
outcome = await adapter.push(project_id="proj1", branch="dev")
assert outcome.ok is False
assert outcome.rejected is True, outcome.message
# ---------------------------------------------------------------------------
# hardening
# ---------------------------------------------------------------------------
async def test_hooks_are_never_executed(tmp_path):
"""A repository may ship hooks; a hook is arbitrary host code execution."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
hooks = gitdir / "hooks"
hooks.mkdir(parents=True, exist_ok=True)
marker = tmp_path / "HOOK_RAN"
hook = hooks / "post-commit"
hook.write_text(f"#!/bin/sh\ntouch {marker}\n")
os.chmod(hook, 0o755)
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
handle.write("a\n")
await adapter.commit(worktree=worktree, message="task/t1", author="AI")
assert not marker.exists(), "a post-commit hook must not run"
async def test_a_tampered_dot_git_file_is_ignored(tmp_path):
"""The `.git` pointer inside a worktree is attacker-controlled text."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
decoy = tmp_path / "decoy"
await _git(["init", "--quiet", str(decoy)], cwd=str(tmp_path))
with open(os.path.join(worktree.host_path, ".git"), "w", encoding="utf-8") as handle:
handle.write(f"gitdir: {decoy}\n")
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
handle.write("a\n")
sha = await adapter.commit(worktree=worktree, message="task/t1", author="AI")
assert sha is not None
# The commit landed on OUR task branch, not in the decoy.
shared_gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
real = await _git(
["--git-dir", str(shared_gitdir), "rev-parse", "task/t1"], cwd=str(tmp_path)
)
assert real.stdout.strip() == sha
decoy_log = await _git(
["--git-dir", str(decoy), "log", "--oneline"], cwd=str(tmp_path), ok=False
)
assert decoy_log.exit_code != 0 or "task/t1" not in decoy_log.stdout
async def test_credentials_never_appear_in_argv(tmp_path):
"""argv is world-readable via `ps`; a secret must travel another way."""
secret = "ghp_thisisasecrettokenvalue0123456789"
seen: list[tuple[str, ...]] = []
async def recording_runner(argv, **kwargs): # noqa: ANN001, ANN003
seen.append(tuple(argv))
return await run_process(argv, **kwargs)
remote = await _make_remote(tmp_path)
adapter = LocalGitWorkspaceAdapter(
resolver=_resolver(remote, auth_type="token", secret=secret, username="git"),
root=str(tmp_path / "root"),
allow_file_protocol=True,
runner=recording_runner,
)
# A token against a local path fails, but the *attempt* is what we assert on.
await adapter.ensure_workspace(project_id="proj1")
assert seen, "the adapter must have invoked git"
for argv in seen:
joined = " ".join(argv)
assert secret not in joined, f"secret leaked into argv: {argv}"
async def test_the_credential_file_is_removed_after_the_call(tmp_path):
remote = await _make_remote(tmp_path)
adapter = LocalGitWorkspaceAdapter(
resolver=_resolver(remote, auth_type="token", secret="s3cret-token-value", username="git"),
root=str(tmp_path / "root"),
allow_file_protocol=True,
)
await adapter.ensure_workspace(project_id="proj1")
private = tmp_path / "root" / "org1" / "proj1" / "private"
assert private.is_dir()
assert not (private / "credentials").exists(), "the 0600 secret file must be deleted"
assert not (private / "id_key").exists()
async def test_the_askpass_helper_is_private_and_holds_no_secret(tmp_path):
remote = await _make_remote(tmp_path)
adapter = LocalGitWorkspaceAdapter(
resolver=_resolver(remote, auth_type="token", secret="s3cret-token-value", username="git"),
root=str(tmp_path / "root"),
allow_file_protocol=True,
)
await adapter.ensure_workspace(project_id="proj1")
helper = tmp_path / "root" / "org1" / "proj1" / "private" / "askpass.sh"
assert helper.is_file()
assert oct(helper.stat().st_mode)[-3:] == "700"
assert "s3cret-token-value" not in helper.read_text()
# ---------------------------------------------------------------------------
# SSRF: the shared host check
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"address",
[
"127.0.0.1",
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"::1",
"fd00::1",
"fe80::1",
"0.0.0.0",
],
)
def test_is_blocked_address_covers_the_dangerous_ranges(address: str):
assert is_blocked_address(address) is True
@pytest.mark.parametrize("address", ["8.8.8.8", "1.1.1.1", "2606:4700::1111"])
def test_is_blocked_address_allows_public_addresses(address: str):
assert is_blocked_address(address) is False
@pytest.mark.parametrize(
"url",
[
"http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1:6379/",
"http://localhost/repo.git",
"https://10.0.0.5/repo.git",
"http://[::1]/repo.git",
"https://user:pass@example.com/repo.git",
"file:///etc/passwd",
"https://example.com:6379/repo.git",
],
)
def test_assert_remote_host_allowed_refuses_dangerous_targets(url: str):
with pytest.raises(RuleViolation):
assert_remote_host_allowed(url, resolver=lambda *a, **k: [])
def test_assert_remote_host_allowed_accepts_a_public_host():
def resolver(host, port, **kwargs): # noqa: ANN001, ANN003
return [(2, 1, 6, "", ("93.184.216.34", port))]
host, port = assert_remote_host_allowed(
"https://git.example.com/team/app.git", resolver=resolver
)
assert host == "git.example.com"
assert port is None
def test_assert_remote_host_allowed_refuses_a_name_that_resolves_private():
"""A public-looking name can resolve into the private network."""
def resolver(host, port, **kwargs): # noqa: ANN001, ANN003
return [(2, 1, 6, "", ("10.1.2.3", port))]
with pytest.raises(RuleViolation):
assert_remote_host_allowed("https://internal.example.com/repo.git", resolver=resolver)
def test_assert_remote_host_allowed_can_be_told_to_allow_private():
host, _ = assert_remote_host_allowed(
"http://127.0.0.1:8080/repo.git", allow_private=True
)
assert host == "127.0.0.1"
def test_assert_remote_host_allowed_accepts_scp_style_ssh():
def resolver(host, port, **kwargs): # noqa: ANN001, ANN003
return [(2, 1, 6, "", ("93.184.216.34", port))]
host, _ = assert_remote_host_allowed("git@git.example.com:team/app.git", resolver=resolver)
assert host == "git.example.com"
async def test_test_remote_refuses_a_private_host(tmp_path):
adapter = _adapter(tmp_path, "https://example.com/x.git")
from app.application.ports.repo_workspace import RemoteCredentials
check = await adapter.test_remote(
url="http://169.254.169.254/repo.git", credentials=RemoteCredentials()
)
assert check.ok is False
assert "داخلی" in check.message
async def test_test_remote_lists_branches_of_a_local_repo(tmp_path, monkeypatch):
"""The ls-remote path, with the SSRF check stubbed out.
The check itself is covered by the tests above; here the subject is the
parsing of `git ls-remote --heads` and the branch list it produces. A local
bare repo is used as the remote because the suite must not need a network.
"""
import app.infrastructure.adapters.git_workspace as module
from app.application.ports.repo_workspace import RemoteCredentials
monkeypatch.setattr(module, "assert_remote_host_allowed", lambda *a, **k: ("local", None))
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
check = await adapter.test_remote(
url=f"file://{remote}", credentials=RemoteCredentials()
)
assert check.ok is True, check.message
assert check.branches == ["main"]
assert check.default_branch == "main"
async def test_test_remote_reports_a_missing_repository(tmp_path, monkeypatch):
import app.infrastructure.adapters.git_workspace as module
from app.application.ports.repo_workspace import RemoteCredentials
monkeypatch.setattr(module, "assert_remote_host_allowed", lambda *a, **k: ("local", None))
adapter = _adapter(tmp_path, "https://example.com/x.git")
check = await adapter.test_remote(
url=f"file://{tmp_path}/does-not-exist.git", credentials=RemoteCredentials()
)
assert check.ok is False
assert check.message
+140
View File
@@ -0,0 +1,140 @@
# Agent 3 — Phase 2 report: git workspaces
Date: 2026-10-13 · Branch: `dev`
## What was done
### 2.1–2.4 `LocalGitWorkspaceAdapter` (`app/infrastructure/adapters/git_workspace.py`)
Implements every method of `RepoWorkspacePort` with a real host `git` binary.
**Layout** under `SANDBOX_WORKSPACES_ROOT/<org>/<project>/`:
`git/` (shared gitdir, never mounted), `work/` (the `dev` work tree, mounted at
`/workspace`), `work/.worktrees/<task_id>/` (one per task), `private/` (0700, holds
credentials for the duration of one call only). `.worktrees/` is excluded through the
**gitdir's** `info/exclude`, never the project's `.gitignore`.
**Hardening on every call** (asserted by test): explicit `--git-dir`/`--work-tree`;
`core.hooksPath=/dev/null`, `core.fsmonitor=false`, `core.quotePath=false`,
`safe.directory=*`; `GIT_CONFIG_NOSYSTEM=1`; a private `HOME` per project;
`GIT_TERMINAL_PROMPT=0`; `protocol.file.allow` configurable (see the deviation below);
one `asyncio.Lock` per project held for every mutating operation.
**Credentials.** A token goes through a 0700 `GIT_ASKPASS` script that reads a 0600
file containing `username\nsecret`; an SSH key is a 0600 temp file referenced by
`GIT_SSH_COMMAND` with `IdentitiesOnly=yes` and a per-project `known_hosts`
(`StrictHostKeyChecking=yes` once pinned, `accept-new` for TOFU on first contact).
Both are deleted in a `finally`. Only a **path** ever travels through the environment —
never the secret itself — so nothing sensitive reaches `argv` (world-readable via `ps`).
**`test_remote`** runs the shared SSRF check first, then `git ls-remote --heads` with a
20 s timeout, and returns a plain Persian message from a closed set of causes. Raw
stderr is never returned: git echoes the remote URL and a credential helper can write a
token into stderr, so every message goes through `app.core.redact`.
**`merge_into`** forces the checkout of the target in the main work tree, merges
`--no-ff`, and on conflict collects the unmerged paths and runs `merge --abort` before
returning — a half-merge would break every later operation. **`push`** never forces:
a rejection is reported with `rejected=True` after a fetch.
### The bug this phase existed to find: linked worktrees need their own gitdir
`git --git-dir=<shared> --work-tree=<task tree>` does **not** address the task branch.
`--git-dir` names the *main* gitdir, whose `HEAD` is `dev`, so `git commit` in a task
worktree commits straight to `dev` and the task branch never moves. The first version of
this adapter had exactly that bug; it was caught by a test that asserts the task sha is
*not* `dev`'s head.
The fix is to address the **linked gitdir** that `git worktree add` creates:
`<gitdir>/worktrees/<task_id>`, which holds that worktree's own `HEAD` and index. It is
passed explicitly rather than discovered from the worktree's `.git` file — that file is
plain text a repository can rewrite, and the test that tampers with it proves the
tampered value is ignored. `_worktree_gitdir()` falls back to the shared gitdir when no
linked one exists, which is the `analysis_only` case (no worktree).
### 2.3 SSRF check (`app/domain/platform/rules.py`)
`is_blocked_address()` (pure) classifies an IP literal: loopback, RFC1918 private,
link-local — including the cloud metadata address `169.254.169.254` — unique-local IPv6,
multicast, reserved, unspecified, and IPv4-mapped IPv6 forms of all of them.
`assert_remote_host_allowed()` validates the URL (scheme allowlist, no embedded
credentials, blocked ports) and then resolves the host, refusing if **any** returned
address is blocked — a name can legitimately have both a public and a private address.
The resolver is injectable so the DNS step is fakeable in tests. `localhost` and friends
are refused by name as well as by resolution.
### 2.5 Wiring (`container_delivery.py`)
`build_repo_workspace` returns the fake unless `GIT_WORKSPACE=local`, then builds a
resolver that joins `projects` + `project_repositories`/`project_git_credentials` (platform)
or `project_external_git` (external) and hands the decrypted secret to the adapter for one
call. A rotated `ENCRYPTION_KEY` degrades to "no credential" with a log line rather than an
exception.
Supporting additions: `ProjectExternalGit` domain model +
`ProjectExternalGitRepository` (in Agent 3's own files), and
`app/infrastructure/db/columns.py` is untouched.
## Files changed
| File | Change |
|---|---|
| `app/infrastructure/adapters/git_workspace.py` | new — the git adapter (~700 lines) |
| `app/domain/platform/rules.py` | SSRF: `is_blocked_address`, `assert_remote_host_allowed`, `BLOCKED_REMOTE_PORTS` |
| `app/domain/projects/models.py` | `ProjectExternalGit` |
| `app/infrastructure/repositories/projects.py` | `ProjectExternalGitRepository` |
| `app/application/container_delivery.py` | real `build_repo_workspace` + resolver |
| `app/core/config.py`, `.env.example` | `GIT_ALLOW_FILE_PROTOCOL` |
| `pyproject.toml` | registered the `docker` / `deploy_it` markers |
| `tests/test_git_workspace.py` | new — 48 tests |
## Test output
```
apps/api: 394 passed (was 346; +48)
ruff check . -> All checks passed!
mypy app -> Success: no issues found in 128 source files
apps/web: 67 passed (unchanged — no frontend work in this phase)
```
The 48 new tests run **real git** against a local bare repository standing in for the
remote; nothing about git itself is mocked.
## Deviations from the TODO
1. **`protocol.file.allow` is a setting, not a constant.** The TODO fixes it at `never`.
That is the right production default (it stops a repository from making git read a
local path via a submodule or `--upload-pack`), but it also blocks the local bare repo
the TODO's own test plan asks for. `GIT_ALLOW_FILE_PROTOCOL=false` keeps `never` as the
default and lets the test suite opt in.
2. **The SSRF check lives in `rules.py` as instructed, and it resolves DNS.** That is the
one I/O call in an otherwise pure module; the module docstring now says so, and the
address classification itself is pure and separately tested.
3. **`base_changed` for `sync_with_base` is defined as "the task branch moved".** The
port docstring says "True when the base moved, so the engine must re-run the tests" —
comparing the base branch's sha before and after a merge into the task branch would
always be equal, so the signal would never fire. It is computed from the worktree's
own `HEAD` instead.
4. **`.git` files are not read at all.** The TODO says "never trust the `.git` file
inside a worktree"; the adapter goes further and never opens it, using the linked
gitdir path instead.
## Open questions / CHANGE REQUESTS
- **CHANGE REQUEST (for integration):** `container_delivery.build_repo_workspace(session)`
is called with a request-scoped `AsyncSession`. The git adapter's resolver uses that
session, so a `RepoWorkspacePort` handed out by a long-lived worker is only valid while
its session lives. Phase 3's delivery worker must build the port per pass (which is what
`build_repo_workspace(session)` in `worker_session()` already implies). No contract
change needed — noting it so the engine and delivery workers do not cache the port
across sessions.
## Known limitations
- `git worktree add` names the linked gitdir after the worktree path's basename. The
adapter relies on that (`<task_id>`), with a fallback to the shared gitdir. A task id
containing characters git rewrites would silently degrade to the fallback; task ids are
platform-generated hex, so this is theoretical.
- Shallow clones are not supported; `ensure_workspace` fetches full history, which is what
the diff guard and merge need.
- No `@pytest.mark.docker` integration test — Phase 2 needs no container.