diff --git a/Makefile b/Makefile index 8954a0e..bbae279 100644 --- a/Makefile +++ b/Makefile @@ -154,6 +154,7 @@ define run_agent_container fi; \ git_guard_flags=(--workspace "$$workspace_dir" --target "$(WORKSPACE_MOUNT)"); \ if [ -n "$${repo_mount_path:-}" ]; then git_guard_flags+=(--target "$$repo_mount_path"); fi; \ + git_guard_flags+=(--worktree-at "$${repo_mount_path:-$(WORKSPACE_MOUNT)}"); \ git_dir_mounts=(); \ git_guard_specs="$$($(PYTHON) "$(SWARMFORGE_DIR)/bin/git-guard" "$${git_guard_flags[@]}")"; \ if [ -n "$$git_guard_specs" ]; then \ diff --git a/docs/git-guard.md b/docs/git-guard.md index cf32757..4dfbf8c 100644 --- a/docs/git-guard.md +++ b/docs/git-guard.md @@ -13,7 +13,16 @@ The guard warns and prints the fix, which moves `core.bare` into `config.worktre Only git dirs that exist when the session starts are covered — a repo the agent clones or `git init`s inside the workspace, or an unrelated checkout vendored there, is not. A `.git` written into an existing subdirectory shadows the guarded repo for anything run from inside that directory: `git status` at the root neither reports it nor executes it, and a git-aware shell prompt or editor entering the directory is enough to run what its config says. `safe.directory`, git's gate for this, keys on ownership, and the container runs as your own uid. +When the workspace is a linked worktree, one mount goes the other way. +The worktree's git dir records the checkout it belongs to in `gitdir`, by the path that checkout has on the host — and the container gets the git dirs at their host paths but never a checkout there. +Git still finds the worktree root by walking up from the working directory, so `git rev-parse --show-toplevel` is unaffected; what goes wrong is everything that reads the record. +`git worktree list` names that worktree by its host path and calls it `prunable`, and a harness that resolves its project root from the record — folder trust, in particular, is usually keyed on it — answers with a directory the container does not have. +A read-only mount over the record spells the checkout the way the session reaches it, `/workspace` or `/repos/`, so those answers match the directory the agent starts in. +The host's record is neither written nor moved: `git worktree list` and `git worktree prune` read it on the host, where a container path reads as a worktree that has gone away. +Only the workspace's own record is restated; the repository's other linked worktrees still read `prunable` inside the container, and a `git worktree prune` there — including one an automatic `gc` reaches — unlinks their registration files on the host until the read-only mounts stop it partway. + The rest of the git dir stays writable, so committing, branching, fetching, and `git worktree add` work as usual. Commands that write config do not, by design: `git config --local`, `git remote add`, `git submodule update --init`, and `git sparse-checkout` fail with `could not write config file ...: Device or resource busy`, and hook installers like `pre-commit install` or husky fail on the read-only `.git/hooks`. +`git worktree repair` and `git worktree move` fail the same way on the read-only registration above, which `status`, `commit` and `worktree list` never write. Branch tracking is the sharp edge: `git push -u` and `git switch ` exit 0 and still report "set up to track", but the tracking config is silently not recorded — git treats that write failing as non-fatal. Use `git push origin HEAD:` and `git switch -c --no-track origin/`, and set a repo up on the host when it needs to stick. This narrows the git-specific surface; it does not make the workspace a trust boundary. Hooks that config already points *outside* the git dir (`core.hooksPath = .githooks`, husky) and attribute-driven filter commands live in the workspace, as do `package.json` scripts and `Makefile`s — anything you run on the host from a directory an agent could write is still yours to trust. diff --git a/docs/harnesses/grok.md b/docs/harnesses/grok.md index 6f30cfe..177f70e 100644 --- a/docs/harnesses/grok.md +++ b/docs/harnesses/grok.md @@ -5,6 +5,8 @@ The image installs the official xAI CLI via `curl -fsSL https://x.ai/cli/install Grok state persists by mounting `$(GROK_HOME_DIR)` to `/home/anvil`, keeping `~/.grok/` (account and session files such as `config.toml` and the credentialed user-settings JSON). Grok reads the repo-root `AGENTS.md` family natively from the git root down, so it picks up this repo's instructions with no extra config. +Folder trust keys on that same root, and Grok records it — and with it project instructions, skills, MCP and hooks — only when the root matches the directory it was started in. +In a linked worktree the root Grok resolves comes from the worktree's registration, which the [git guard](../git-guard.md) restates for the container. Shared skills arrive through the [asset pipeline](../configuration.md#asset-layers). Subagent definitions are not translated for Grok; the unified-agent pipeline covers OpenCode, Claude, and Codex. MCP tongs reach Grok as `[mcp_servers.]` entries in a managed block of the merged `~/.grok/config.toml` — user-level config, so no folder-trust prompt. That file is in the persistent home, so the block is rewritten every run and stripped when a session has no MCP tongs; a server the user already defines under the same name wins over the generated entry. diff --git a/docs/tongs/definitions.md b/docs/tongs/definitions.md index f8b4f20..bb1cafb 100644 --- a/docs/tongs/definitions.md +++ b/docs/tongs/definitions.md @@ -66,5 +66,5 @@ readiness: Mounts are opt-in **magic words**, never raw host paths. Only two are recognized, spelled `[:/target][:mode]` with the access mode (`ro`/`rw`) last: -- `workspace[:/target][:mode]` — bind-mounts the session workspace, at `/workspace` unless an absolute `target` says otherwise (e.g. `workspace:ro`, `workspace:/code`, `workspace:/code:ro`). A custom target lets an image that expects its sources elsewhere be used unmodified (it does not set the working directory — the process still starts in the image's own `WORKDIR`). A target is refused unless it is an absolute path free of whitespace, and refused if it resolves to `/`, overlaps another of the tong's mounts, or overlaps a path the tong's own wiring occupies — the secret-delivery tmpfs at `/run/swarmforge` and the `/bin/sh` its wrapper execs (for a tong with secret references), or the docker socket (for a tong that mounts it). The workspace bind is paired with the same git-dir mounts the anvil gets from `swarmforge/gitguard.py`: read-only guards over the config and hooks the host's git obeys, and — when the workspace is a linked worktree or another checkout whose git dir lives outside it — that git dir at its own absolute path, which is where the checkout's `.git` pointer file says to look (without it, git inside the tong fails with "not a git repository"). When every `workspace` mount is `ro`, the ride-along git-dir mounts are forced read-only too. +- `workspace[:/target][:mode]` — bind-mounts the session workspace, at `/workspace` unless an absolute `target` says otherwise (e.g. `workspace:ro`, `workspace:/code`, `workspace:/code:ro`). A custom target lets an image that expects its sources elsewhere be used unmodified (it does not set the working directory — the process still starts in the image's own `WORKDIR`). A target is refused unless it is an absolute path free of whitespace, and refused if it resolves to `/`, overlaps another of the tong's mounts, or overlaps a path the tong's own wiring occupies — the secret-delivery tmpfs at `/run/swarmforge` and the `/bin/sh` its wrapper execs (for a tong with secret references), or the docker socket (for a tong that mounts it). The workspace bind is paired with the same git-dir mounts the anvil gets from `swarmforge/gitguard.py`: read-only guards over the config and hooks the host's git obeys, and — when the workspace is a linked worktree or another checkout whose git dir lives outside it — that git dir at its own absolute path, which is where the checkout's `.git` pointer file says to look (without it, git inside the tong fails with "not a git repository"). The one mount the anvil gets and a tong does not is the restated worktree registration — a tong keeps the host's copy, so `git worktree list` inside one names the checkout by its host path (the [git guard](../git-guard.md) has the rest). When every `workspace` mount is `ro`, the ride-along git-dir mounts are forced read-only too. - `docker-socket[:mode]` — bind-mounts the host docker socket onto the same path inside the container (so it takes no target). This is full host docker control and is always called out explicitly in the workspace approval prompt; it is the grant a broker tong needs. diff --git a/swarmforge/anvil/orchestrate.py b/swarmforge/anvil/orchestrate.py index 77ded13..91a486a 100644 --- a/swarmforge/anvil/orchestrate.py +++ b/swarmforge/anvil/orchestrate.py @@ -54,7 +54,8 @@ def _workspace_git_dir_specs(defn, workspace, warn=None): look. A tong that mounts the workspace needs the same set, or git inside it cannot resolve a worktree checkout at all ("fatal: not a git repository"). The guard maps workspace-internal paths below every destination the - definition mounts the workspace at. + definition mounts the workspace at. A tong keeps the host's worktree + registration, since no harness resolves a project root inside one. When every workspace mount is read-only, the extra mounts are forced read-only too: build_mounts emits the git-dir binds writable (the anvil's diff --git a/swarmforge/gitguard.py b/swarmforge/gitguard.py index 761141a..478a46f 100644 --- a/swarmforge/gitguard.py +++ b/swarmforge/gitguard.py @@ -2,6 +2,7 @@ """Docker mounts that keep the anvil out of a repo's git configuration. Usage: git-guard --workspace DIR [--target CONTAINER_PATH]... + [--worktree-at CONTAINER_PATH] Prints one docker `-v` value per line (the caller supplies the `-v` itself), for the workspace mounted at each `--target`. Nothing is printed when the workspace @@ -60,6 +61,17 @@ directory above it. The guard warns and names the fix -- `core.bare` moved into `config.worktree`, which git reads after that decision. +A linked worktree's `gitdir` record names its checkout by host path, which the +container does not have, so `git worktree list` there calls it `prunable` and a +harness that resolves its project root from the record finds none. +`--worktree-at` names the container path the session starts in, and the guard +mounts a replacement record holding `/.git` read-only over the +workspace's own; the host's copy stays as the host's git needs it. The +replacement lives under the system temp dir, in a directory only this user can +write, at a name derived from the paths it stands for: nothing removes it, since +the guard exits before the container starts. It is refused inside anything the +container mounts, where the source of a read-only mount is still writable. + Symlinks are refused rather than followed. A guarded path that is a symlink is skipped, and the walk never descends through one: otherwise a container that writes `.git/modules/x/HEAD` and points `.git/modules/x/config` at a host file @@ -90,6 +102,10 @@ * A path carrying a colon or a newline cannot be spelled as a docker `-v` value, so it is reported and left unguarded rather than turned into some other mount. Nothing is created on the host for such a path either. + * Only the workspace's own worktree registration is restated. Sibling + worktrees still read `prunable` in the container, and a `git worktree + prune` there -- an automatic `gc` runs one -- unlinks part of their + registration on the host before the read-only mounts stop it. * Hooks and commands that config already points *outside* the git dir (`core.hooksPath = .githooks`, husky's `.husky/`, an `include.path` into the worktree) live in the workspace, as do attribute-driven filter and diff @@ -103,9 +119,12 @@ no reason to distrust. """ +import hashlib import os +import stat import subprocess import sys +import tempfile # Made read-only in every git dir that holds a repository's config and hooks. # The value is written when the file is absent: an empty `config` is inert, and @@ -129,9 +148,10 @@ class UsageError(Exception): def parse_args(argv): - """Return (workspace, targets) from `--workspace DIR --target PATH...`.""" + """Return (workspace, targets, worktree_at) from the command line.""" workspace = None targets = [] + worktree_at = None index = 0 while index < len(argv): token = argv[index] @@ -147,12 +167,18 @@ def parse_args(argv): targets.append(argv[index + 1].rstrip("/") or "/") index += 2 continue + if token == "--worktree-at": + if index + 1 >= len(argv): + raise UsageError("--worktree-at requires a path argument") + worktree_at = argv[index + 1].rstrip("/") or "/" + index += 2 + continue raise UsageError("unrecognized argument: %s" % token) if not workspace: raise UsageError("--workspace is required") if not targets: raise UsageError("at least one --target is required") - return workspace, targets + return workspace, targets, worktree_at # --- Reading the repo --------------------------------------------------------- @@ -346,6 +372,27 @@ def submodule_checkout(git_dir, config): return os.path.realpath(os.path.join(git_dir, worktree)) +def linked_worktree_git_dir(workspace, common, warn): + """`workspace`'s own git dir when it is a linked worktree's, else None. + + Checked against the unresolved `/worktrees`, so a symlinked + `worktrees`, which the walk leaves unguarded, is refused, not mounted into. + """ + found = git_output( + workspace, "rev-parse", "--path-format=absolute", "--git-dir") + if not found: + return None + git_dir = os.path.realpath(found) + if git_dir == common: + return None + if os.path.dirname(git_dir) != os.path.join(common, "worktrees"): + warn(registration_refused( + "%s is a linked worktree whose git dir is not under %s/worktrees" + % (workspace, common))) + return None + return git_dir + + def worktree_checkout_pointer(worktree_git_dir): """The `.git` pointer file of a linked worktree, from its `gitdir` file.""" record = os.path.join(worktree_git_dir, "gitdir") @@ -417,22 +464,79 @@ def guarded_paths(git_dir, files, dirs, worktree_config, warn): return paths -def mountable(path, warn): +def spellable(path): """True if `path` survives the trip to docker as part of a `-v` value. A `-v` value is colon-separated and the caller reads one mount per line, so - a path carrying either would be split into a different mount -- and - directories inside a git dir are the container's to name. Callers check + a path carrying either would be split into a different mount. + """ + return ":" not in path and "\n" not in path + + +def mountable(path, warn): + """True if `path` can be guarded, reporting it when it cannot. + + Directories inside a git dir are the container's to name, so an unspellable + one is left alone rather than turned into some other mount. Callers check this before creating anything, so a path that cannot be guarded is also not modified. """ - if ":" in path or "\n" in path: + if not spellable(path): warn("%s cannot be expressed as a docker mount (it contains a colon or " "newline); leaving it writable in the container" % path) return False return True +def registration_refused(reason): + """A refusal to restate the workspace's worktree registration.""" + return ("%s; the container reads the registration the host wrote, which " + "names a checkout it does not have" % reason) + + +def gitdir_record_source(workspace, container_checkout): + """The host file holding the replacement `gitdir` for one pair of paths. + + Named by the pair, so a rerun overwrites its own file rather than adding one. + """ + key = "%s\0%s" % (workspace, container_checkout) + return os.path.join( + tempfile.gettempdir(), + "swarmforge-gitdir-%d" % os.getuid(), + hashlib.sha256(key.encode("utf-8", "surrogateescape")).hexdigest()) + + +def write_gitdir_record(path, container_checkout, warn): + """Write the `.git` of `container_checkout` to `path`. True if it landed. + + The system temp dir is shared, so the directory must be this user's alone. + """ + directory = os.path.dirname(path) + try: + os.makedirs(directory, mode=0o700, exist_ok=True) + info = os.lstat(directory) + except OSError as exc: + warn(registration_refused("could not prepare %s (%s)" + % (directory, exc.strerror or exc))) + return False + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() \ + or info.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + warn(registration_refused( + "%s is not a plain directory this user alone can write" % directory)) + return False + try: + # The name is predictable, so a symlink planted there must fail the write. + descriptor = os.open( + path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w") as handle: + handle.write("%s/.git\n" % container_checkout) + except OSError as exc: + warn(registration_refused("could not write %s (%s)" + % (path, exc.strerror or exc))) + return False + return True + + def ancestors_between(path, root): """The directories from `root` down to `path`, both ends excluded.""" if not is_inside(path, root): @@ -442,7 +546,61 @@ def ancestors_between(path, root): for depth in range(1, len(parts))] -def build_mounts(workspace, targets, warn=None): +def gitdir_records(workspace, common, targets, worktree_at, container_paths, + warn): + """(container path, replacement file) for the workspace's own `gitdir`. + + Empty unless the workspace is a linked worktree and `worktree_at` is given. + Every refusal precedes the write, so a refused one leaves nothing on the host. + """ + if not worktree_at: + return [] + git_dir = linked_worktree_git_dir(workspace, common, warn) + if git_dir is None: + return [] + if worktree_at not in targets: + warn(registration_refused( + "%s is not one of the paths the workspace is mounted at (%s)" + % (worktree_at, ", ".join(targets)))) + return [] + if not spellable(worktree_at): + warn(registration_refused( + "%s cannot be expressed as a docker mount (it contains a colon or " + "newline)" % worktree_at)) + return [] + record = os.path.join(git_dir, "gitdir") + if os.path.islink(record): + warn(registration_refused("%s is a symlink" % record)) + return [] + if not os.path.isfile(record): + warn(registration_refused("%s has no gitdir record" % git_dir)) + return [] + if not mountable(record, warn): + return [] + destinations = [container for container in container_paths(record) + if mountable(container, warn)] + if not destinations: + return [] + source = gitdir_record_source(workspace, worktree_at) + if not spellable(source): + warn(registration_refused( + "%s cannot be expressed as a docker mount (it contains a colon or " + "newline)" % source)) + return [] + # TMPDIR may sit in the workspace, where the container could rewrite the + # source and a host `git clean -fdx` could delete it mid-session. + reachable = os.path.realpath(os.path.dirname(source)) + for root in (workspace, common): + if reachable == root or is_inside(reachable, root): + warn(registration_refused( + "%s is inside %s, which the container mounts" % (source, root))) + return [] + if not write_gitdir_record(source, worktree_at, warn): + return [] + return [(container, source) for container in destinations] + + +def build_mounts(workspace, targets, warn=None, worktree_at=None): """Every mount that guards `workspace`'s git configuration. Each host path is placed at every container path it is reachable from: @@ -450,6 +608,9 @@ def build_mounts(workspace, targets, warn=None): path when it does not -- which is where the pointer file naming it says to look. Two paths landing on the same container path keep the first, since docker refuses a repeated mount destination. + + `worktree_at`, one of `targets`, is where the session starts; a linked + worktree then also gets its registration restated for that path. """ report = warn or (lambda message: None) reported = set() @@ -548,12 +709,15 @@ def guard(host, read_only): spec = "%s:%s:ro" % (host, container) if read_only \ else "%s:%s" % (host, container) specs.setdefault(container, spec) + for container, source in gitdir_records( + workspace, common, targets, worktree_at, container_paths, warn): + specs.setdefault(container, "%s:%s:ro" % (source, container)) return list(specs.values()) def main(argv, out=sys.stdout, err=sys.stderr): try: - workspace, targets = parse_args(argv) + workspace, targets, worktree_at = parse_args(argv) except UsageError as exc: err.write("git-guard: %s\n" % exc) return 2 @@ -561,7 +725,8 @@ def main(argv, out=sys.stdout, err=sys.stderr): def warn(message): err.write("%s\n" % message) - for spec in build_mounts(workspace, targets, warn=warn): + for spec in build_mounts(workspace, targets, warn=warn, + worktree_at=worktree_at): out.write("%s\n" % spec) return 0 diff --git a/tests/test_git_guard.py b/tests/test_git_guard.py index 03be850..7847bd8 100644 --- a/tests/test_git_guard.py +++ b/tests/test_git_guard.py @@ -8,6 +8,7 @@ import sys import tempfile import unittest +from unittest import mock HERE = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.dirname(HERE) @@ -41,6 +42,16 @@ def setUp(self): # realpath: git reports resolved paths, and the guard compares them. self.tmp = os.path.realpath(tempfile.mkdtemp(prefix="git-guard-")) self.addCleanup(shutil.rmtree, self.tmp, True) + # The guard leaves worktree registrations in the temp dir. + temp = mock.patch.object(tempfile, "tempdir", self.tmp) + temp.start() + self.addCleanup(temp.stop) + + def records_written(self): + """Replacement `gitdir` files the guard has left on the host.""" + directory = os.path.dirname( + gitguard.gitdir_record_source("workspace", "/target")) + return sorted(os.listdir(directory)) if os.path.isdir(directory) else [] def repo(self, name="repo", bare=False): path = os.path.join(self.tmp, name) @@ -400,6 +411,218 @@ def test_per_worktree_commondir_is_never_rewritten(self): self.assertEqual(handle.read(), before) +class WorktreeRegistration(GuardCase): + """`--worktree-at` restates a worktree's `gitdir` in the container's terms.""" + + def setUp(self): + super().setUp() + self.repo_path = self.repo() + self.common = os.path.join(self.repo_path, ".git") + self.worktree = os.path.join(self.tmp, "wt") + git(self.repo_path, "worktree", "add", "-q", "-b", "topic", + self.worktree) + self.record = os.path.join(self.common, "worktrees", "wt", "gitdir") + + def guarded(self, workspace=None, targets=("/workspace",), + worktree_at="/workspace", warn=None): + return gitguard.build_mounts( + workspace or self.worktree, list(targets), + warn=warn, worktree_at=worktree_at) + + def replacements(self, mounts): + """The specs mounting something over a `gitdir` record.""" + return [spec for spec in mounts + if spec.split(":")[1].endswith("/gitdir")] + + def guard_mounts(self, target="/workspace"): + """What the workspace gets with no registration mount at all.""" + worktree_dir = os.path.join(self.common, "worktrees", "wt") + return [ + "%s:%s" % (self.common, self.common), + "%s/config:%s/config:ro" % (self.common, self.common), + "%s/commondir:%s/commondir:ro" % (self.common, self.common), + "%s/hooks:%s/hooks:ro" % (self.common, self.common), + "%s/remotes:%s/remotes:ro" % (self.common, self.common), + "%s/branches:%s/branches:ro" % (self.common, self.common), + "%s/worktrees:%s/worktrees" % (self.common, self.common), + "%s:%s" % (worktree_dir, worktree_dir), + "%s/commondir:%s/commondir:ro" % (worktree_dir, worktree_dir), + "%s/.git:%s/.git:ro" % (self.worktree, target), + ] + + def test_the_option_adds_one_mount_and_changes_nothing_else(self): + source = gitguard.gitdir_record_source(self.worktree, "/workspace") + self.assertEqual( + self.guarded(), + self.guard_mounts() + ["%s:%s:ro" % (source, self.record)]) + + def test_without_the_option_the_registration_is_left_alone(self): + self.assertEqual(self.guarded(worktree_at=None), self.guard_mounts()) + self.assertEqual(self.records_written(), []) + + def test_the_replacement_names_the_checkout_the_container_has(self): + mounts = self.guarded(targets=("/repos/me/proj",), + worktree_at="/repos/me/proj") + specs = self.replacements(mounts) + self.assertEqual(len(specs), 1, mounts) + source, destination, mode = specs[0].split(":") + self.assertEqual(destination, self.record) + self.assertEqual(mode, "ro") + with open(source) as handle: + self.assertEqual(handle.read(), "/repos/me/proj/.git\n") + + def test_one_record_mount_however_many_targets(self): + # The record lives in the common git dir, mounted only at its host path. + mounts = self.guarded(targets=("/workspace", "/repos/me/proj")) + specs = self.replacements(mounts) + self.assertEqual(len(specs), 1, mounts) + self.assertEqual(specs[0].split(":")[1], self.record) + + def test_the_replacement_is_somewhere_the_container_cannot_reach(self): + source = self.replacements(self.guarded())[0].split(":")[0] + for reachable in (self.worktree, self.common): + self.assertFalse(gitguard.is_inside(source, reachable), source) + + def test_a_rerun_writes_the_same_file_rather_than_another_one(self): + # Nothing ever removes these files. + first = self.guarded() + written = self.records_written() + self.assertEqual(len(written), 1, written) + self.assertEqual(self.guarded(), first) + self.assertEqual(self.records_written(), written) + + def test_the_hosts_own_record_is_left_as_it_was(self): + # The host's `git worktree prune` would read a container path as gone. + with open(self.record) as handle: + before = handle.read() + self.guarded() + with open(self.record) as handle: + self.assertEqual(handle.read(), before) + + def test_a_plain_checkout_has_no_registration_to_restate(self): + # And nothing to say about the option either, however it is spelled. + plain = self.repo("plain") + warnings = [] + self.assertEqual( + gitguard.build_mounts(plain, ["/workspace"], warn=warnings.append, + worktree_at="/repos/a:b"), + gitguard.build_mounts(plain, ["/workspace"])) + self.assertEqual(warnings, []) + self.assertEqual(self.records_written(), []) + + def test_the_repo_the_worktree_belongs_to_is_left_alone(self): + self.assertEqual( + self.replacements(self.guarded(workspace=self.repo_path)), []) + self.assertEqual(self.records_written(), []) + + def assert_refused(self, warning, mounts, warnings): + self.assertEqual(self.replacements(mounts), []) + self.assertEqual(self.records_written(), []) + self.assertIn(gitguard.registration_refused(warning), warnings) + + def test_a_symlinked_record_is_refused(self): + elsewhere = os.path.join(self.tmp, "elsewhere") + with open(elsewhere, "w") as handle: + handle.write("/somewhere/.git\n") + os.remove(self.record) + os.symlink(elsewhere, self.record) + warnings = [] + mounts = self.guarded(warn=warnings.append) + self.assert_refused("%s is a symlink" % self.record, mounts, warnings) + with open(elsewhere) as handle: + self.assertEqual(handle.read(), "/somewhere/.git\n") + + def test_a_missing_record_is_reported(self): + os.remove(self.record) + warnings = [] + mounts = self.guarded(warn=warnings.append) + self.assert_refused( + "%s has no gitdir record" % os.path.dirname(self.record), + mounts, warnings) + + def test_a_symlinked_worktrees_directory_is_reported(self): + # Kept inside the common dir so the relative `commondir` still resolves. + moved = os.path.join(self.common, "worktree-store") + shutil.move(os.path.join(self.common, "worktrees"), moved) + os.symlink(moved, os.path.join(self.common, "worktrees")) + warnings = [] + mounts = self.guarded(warn=warnings.append) + self.assert_refused( + "%s is a linked worktree whose git dir is not under %s/worktrees" + % (self.worktree, self.common), mounts, warnings) + + def test_a_container_path_the_workspace_is_not_mounted_at_is_refused(self): + warnings = [] + mounts = self.guarded(worktree_at="/repos/x", warn=warnings.append) + self.assert_refused( + "/repos/x is not one of the paths the workspace is mounted at " + "(/workspace)", mounts, warnings) + + def test_a_container_path_docker_cannot_express_creates_nothing(self): + warnings = [] + mounts = self.guarded(targets=("/repos/a:b",), + worktree_at="/repos/a:b", warn=warnings.append) + self.assert_refused( + "/repos/a:b cannot be expressed as a docker mount (it contains a " + "colon or newline)", mounts, warnings) + + def test_a_record_path_docker_cannot_express_creates_nothing(self): + # A colon anywhere above the repository reaches the record's host path. + odd = os.path.join(self.tmp, "od:d") + repo = os.path.join(odd, "repo") + os.makedirs(repo) + git(repo, "init", "-q") + git(repo, "commit", "-q", "--allow-empty", "-m", "root") + worktree = os.path.join(odd, "wt") + git(repo, "worktree", "add", "-q", "-b", "other", worktree) + warnings = [] + mounts = gitguard.build_mounts(worktree, ["/workspace"], + warn=warnings.append, + worktree_at="/workspace") + self.assertEqual(self.replacements(mounts), []) + self.assertEqual(self.records_written(), []) + self.assertTrue([w for w in warnings if "colon or newline" in w], + warnings) + + def test_a_temp_dir_inside_the_workspace_is_refused(self): + inside = os.path.join(self.worktree, ".tmp") + os.makedirs(inside) + warnings = [] + with mock.patch.object(tempfile, "tempdir", inside): + mounts = self.guarded(warn=warnings.append) + self.assertEqual(self.records_written(), []) + self.assertEqual(self.replacements(mounts), []) + self.assertTrue( + [w for w in warnings if "which the container mounts" in w], + warnings) + + def test_a_shared_temp_dir_is_refused(self): + # A predictable name in a shared directory can be pre-filled. + directory = os.path.dirname( + gitguard.gitdir_record_source(self.worktree, "/workspace")) + os.makedirs(directory, mode=0o700) + os.chmod(directory, 0o777) + warnings = [] + mounts = self.guarded(warn=warnings.append) + self.assertEqual(self.replacements(mounts), []) + self.assertEqual(os.listdir(directory), []) + self.assertTrue( + [w for w in warnings if "this user alone can write" in w], warnings) + + def test_a_symlinked_temp_dir_is_refused(self): + directory = os.path.dirname( + gitguard.gitdir_record_source(self.worktree, "/workspace")) + target = os.path.join(self.tmp, "planted") + os.makedirs(target, mode=0o700) + os.symlink(target, directory) + warnings = [] + mounts = self.guarded(warn=warnings.append) + self.assertEqual(self.replacements(mounts), []) + self.assertEqual(os.listdir(target), []) + self.assertTrue( + [w for w in warnings if "this user alone can write" in w], warnings) + + class Submodules(GuardCase): def setUp(self): super().setUp() @@ -616,6 +839,17 @@ def test_warns_when_core_bare_is_in_the_shared_config(self): "git -C %s config --worktree core.bare true" % self.common, bare[0]) + def test_the_worktrees_registration_is_restated_for_the_container(self): + # The layout `swarmforge init` builds. + self.repair() + mounts = gitguard.build_mounts( + self.worktree, ["/repos/me/repo"], worktree_at="/repos/me/repo") + record = os.path.join(self.common, "worktrees", "wt1", "gitdir") + spec, = [m for m in mounts if m.split(":")[1] == record] + self.assertTrue(spec.endswith(":ro"), spec) + with open(spec.split(":")[0]) as handle: + self.assertEqual(handle.read(), "/repos/me/repo/.git\n") + def test_repaired_repo_stays_bare_and_its_worktrees_stay_checkouts(self): self.repair() for workspace in self.workspaces: @@ -656,9 +890,24 @@ def test_trailing_slash_on_a_target_does_not_double_up(self): self.assertIn("%s/.git/config:/workspace/.git/config:ro" % repo, out.splitlines()) + def test_worktree_at_reaches_the_guard(self): + repo = self.repo() + worktree = os.path.join(self.tmp, "wt") + git(repo, "worktree", "add", "-q", "-b", "topic", worktree) + record = os.path.join(repo, ".git", "worktrees", "wt", "gitdir") + code, out, _ = self._main( + ["--workspace", worktree, "--target", "/workspace", + "--worktree-at", "/workspace"]) + self.assertEqual(code, 0) + spec, = [line for line in out.splitlines() + if line.split(":")[1] == record] + with open(spec.split(":")[0]) as handle: + self.assertEqual(handle.read(), "/workspace/.git\n") + def test_missing_arguments_are_a_usage_error(self): for argv in ([], ["--workspace", "/x"], ["--target", "/workspace"], - ["--workspace"], ["--bogus"]): + ["--workspace"], ["--bogus"], + ["--workspace", "/x", "--target", "/w", "--worktree-at"]): code, out, err = self._main(argv) self.assertEqual(code, 2, argv) self.assertEqual(out, "") diff --git a/tests/test_run_agent_container.py b/tests/test_run_agent_container.py index 1346e49..90812e9 100644 --- a/tests/test_run_agent_container.py +++ b/tests/test_run_agent_container.py @@ -95,6 +95,8 @@ def launcher_argv(self, target, project_dir): env = { "PATH": self.bin + os.pathsep + os.environ.get("PATH", ""), "HOME": self.home, + # The guard leaves a worktree registration in the temp dir. + "TMPDIR": self.tmp, "CAPTURE_FILE": self.capture_path, "PYTHON_REAL": sys.executable, "GIT_CONFIG_GLOBAL": "/dev/null", @@ -224,6 +226,19 @@ def test_gitdir_pointer_file_is_readonly(self): self.assertIn("%s/.git:/workspace/.git:ro" % self.worktree, mounts) self.assertNotIn("%s/.git:/workspace/.git" % self.worktree, mounts) + def test_the_registration_names_the_path_the_session_starts_in(self): + record = os.path.join(self.common, "worktrees", "wt", "gitdir") + # The image's WORKDIR, then the `-w` of repo-slug mode. + for target, workdir in (("run_opencode", "/workspace"), + ("run_claude", "/repos/wt")): + mounts = self.mounts(self.docker_argv(target, self.worktree)) + spec, = [m for m in mounts if m.split(":")[1] == record] + self.assertTrue(spec.endswith(":ro"), spec) + with open(spec.split(":")[0]) as handle: + self.assertEqual(handle.read(), workdir + "/.git\n") + with open(record) as handle: + self.assertEqual(handle.read(), "%s/.git\n" % self.worktree) + class ClaudeSharedHomeMounts(MakeRecipeCase): """Nothing under the shared .claude is mounted except plugins/, read-only: