# 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///`: `git/` (shared gitdir, never mounted), `work/` (the `dev` work tree, mounted at `/workspace`), `work/.worktrees//` (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= --work-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: `/worktrees/`, 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 (``), 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.