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
+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.