Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
9 changes: 9 additions & 0 deletions docs/git-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>`, 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 <remote-branch>` 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:<branch>` and `git switch -c <name> --no-track origin/<branch>`, 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.
2 changes: 2 additions & 0 deletions docs/harnesses/grok.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]` 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/tongs/definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,5 @@ readiness:

Mounts are opt-in **magic words**, never raw host paths. Only two are recognized, spelled `<word>[:/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.
3 changes: 2 additions & 1 deletion swarmforge/anvil/orchestrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading