Skip to content
Open
7 changes: 5 additions & 2 deletions docs/reference/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ What each target runs is in [Testing](../development/testing.md).
| `SHELL` | Its basename picks which rc candidates are tried first. |
| `PATH` | Only to warn when `~/.local/bin` is not on it. |

The launcher reads `SWARMFORGE_USER_ASSETS_DIR` from its own environment too, as the fallback root for `approvals.json` and `secret-providers.yaml` when a call passes neither `--approvals` nor `--providers`.
The launcher reads `SWARMFORGE_USER_ASSETS_DIR` from its own environment too, as the fallback root for `approvals.json` and `secret-providers.yaml` when a call passes neither `--approvals` nor `--providers`. It also reads `TMUX` and `TMUX_PANE`, which only a [`tmux-socket`](../tongs/definitions.md#mounts) tong uses; `make` passes both through unchanged.

[`swarmforge clone` and `swarmforge init`](../cli.md) drop `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`, `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, and `GIT_NAMESPACE` from the environment they hand git, since `git -C` does not override them.

Expand All @@ -168,7 +168,10 @@ The launcher reads `SWARMFORGE_USER_ASSETS_DIR` from its own environment too, as
| `SWARMFORGE_CONFIG_DEST` `SWARMFORGE_CONFIG_RESET` | the make variables, passed through | `swarmforge.harness.init`. |
| `SWARMFORGE_TONG_MCP_FILE` | `/tmp/swarmforge-tong-mcp.json` | `swarmforge.harness.init`, as the generated MCP fragment to merge. Set only for a session with MCP tongs and a harness whose spec takes the path by environment variable; one that takes a flag gets the same mount on its command line. |
| `SWARMFORGE_TONG_<NAME>_HOST` `_PORT` `_PATH` | the tong's alias and port, or its mountpoint | The agent. One set per `port` or `volume` [tong](../tongs/definitions.md#interface-kinds). |
| `SWARMFORGE_WORKSPACE_HOST_PATH` | the workspace's path on the host | A socket-holding `session` [broker](../tongs/broker.md) tong. Never a `shared` tong, and never over a value the tong sets itself. |
| `SWARMFORGE_WORKSPACE_HOST_PATH` | the workspace's path on the host | A `session` tong that mounts `docker-socket` (a [broker](../tongs/broker.md)) or [`tmux-socket`](../tongs/definitions.md#mounts). Never a `shared` tong, and never over a value the tong sets itself. |
| `TMUX` | the launcher's `$TMUX`, with the socket re-pointed under `/run/swarmforge-tmux` | A `session` tong that mounts [`tmux-socket`](../tongs/definitions.md#mounts), so a stock `tmux` client reaches the host server. Replaces a plain value the definition sets; a secret reference there is refused at validation. |
| `TMUX_PANE` | the launcher's `$TMUX_PANE` | The same tong, as tmux's default target — a default, not a restriction, since the tong can still name any pane. Set only when the launcher has one; a plain value the definition sets is dropped either way, and a secret reference is refused. |
| `SWARMFORGE_SESSION_HANDLE` | the anvil's container name (`--name`) | The same tong, as the session it serves. Replaces a plain value the definition sets; a secret reference there is refused at validation. |
| `CLAUDE_CONFIG_DIR` | [the merged config dir](../harnesses/claude-code.md#the-config-directory) | `claude`, set by its `pre_exec` hook. |
| `CLAUDE_SECURESTORAGE_CONFIG_DIR` | `/home/anvil/.claude` | `claude`, set by the same hook. |
| `PYTHONPATH` | `/usr/local/lib/swarmforge` | Both lifecycle drivers, so the copied package imports. Dropped before the harness is exec'd. |
Expand Down
2 changes: 1 addition & 1 deletion docs/tongs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The workspace layer is gated by [first-run approval](approval.md).
## Lifecycle

- **`session`** — started with the anvil, torn down when it exits. Per-session isolation; the default for credential tongs.
- **`shared`** — long-lived, survives across anvil sessions. Started on first use, connected to each session's network via a network alias, and left running on teardown (no refcounting). A running `shared` container whose config-hash docker label still matches the current definition is reused untouched; a missing, stopped, or stale one is recreated automatically. A rotated secret behind an unchanged reference does **not** churn it — force a restart with `docker rm -f <container>`. Recreating the container loses what the tong wrote inside it; state worth keeping, such as a model store, belongs in a [`volume:` mount](definitions.md#mounts). A `shared` tong may not mount the `workspace` (it would leak one session's workspace into the next); use a `session` tong for per-workspace mounts.
- **`shared`** — long-lived, survives across anvil sessions. Started on first use, connected to each session's network via a network alias, and left running on teardown (no refcounting). A running `shared` container whose config-hash docker label still matches the current definition is reused untouched; a missing, stopped, or stale one is recreated automatically. A rotated secret behind an unchanged reference does **not** churn it — force a restart with `docker rm -f <container>`. Recreating the container loses what the tong wrote inside it; state worth keeping, such as a model store, belongs in a [`volume:` mount](definitions.md#mounts). A `shared` tong may not mount the `workspace` (it would leak one session's workspace into the next) or the `tmux-socket` (it would carry one session's tmux server into the next); use a `session` tong for either.

## Reference

Expand Down
4 changes: 2 additions & 2 deletions docs/tongs/approval.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# First-run approval

The user, org, and repo layers are installed deliberately and are trusted; they skip the gate.
A **workspace**-sourced tong (from a repo you cloned) could otherwise request your secrets, host mounts, the docker socket, or the host's GPUs simply by being present, so the launcher gates it:
A **workspace**-sourced tong (from a repo you cloned) could otherwise request your secrets, host mounts, the docker socket, your tmux server, or the host's GPUs simply by being present, so the launcher gates it:

- Before starting, it prints exactly what the tong requests — image, secret references, mounts, networks, docker-socket access, and GPU access — and asks you to approve. Control and formatting characters in those values are shown as escapes such as `\x1b` or `\n`, so a value cannot move the cursor, restyle, or hide any part of the summary. Every `tongs:` warning escapes them the same way. Each volume is listed with its target and docker volume name, with a note that its data persists and is shared by concurrent sessions.
- Before starting, it prints exactly what the tong requests — image, secret references, mounts, networks, docker-socket access, tmux-socket access, and GPU access — and asks you to approve. Both sockets get a line of their own, since each lets the tong run anything on your host. Control and formatting characters in those values are shown as escapes such as `\x1b` or `\n`, so a value cannot move the cursor, restyle, or hide any part of the summary. Every `tongs:` warning escapes them the same way. Each volume is listed with its target and docker volume name, with a note that its data persists and is shared by concurrent sessions.
- Approval is keyed by workspace path (symlinks resolved, as for its [volumes](definitions.md#volume-naming-and-scope)) + tong name + a hash of the merged definition, stored in `~/.swarmforge/approvals.json`. Any change to the definition re-prompts.
- The gate defaults to **No**, and a non-interactive stdin reads as No. A scripted `--no-prompt` run **fails closed** rather than auto-approving.
- Approving `image: foo:latest` approves a moving target; **pinned digests are the recommended convention** for workspace tongs.
5 changes: 3 additions & 2 deletions docs/tongs/definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,12 @@ readiness:

## Mounts

Mounts are opt-in **magic words**, never raw host paths. Only three are recognized, each spelled as shown below with the optional access mode (`ro`/`rw`) last:
Mounts are opt-in **magic words**, never raw host paths. Only four are recognized, each spelled as shown below with the optional 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"). 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.
- `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), the docker socket (for a tong that mounts it), or `/run/swarmforge-tmux` (for a tong that mounts the tmux socket). 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.
- `volume:<name>:/target[:mode]` — mounts a docker **named volume** at `target`, for state the tong keeps between containers (a model store, a cache). The name comes first and is at most 64 letters, digits, `.` and `-`, starting with a letter or digit; the target is required and held to the same rules as a `workspace` target, may not overlap the git dir that a `workspace` mount brings along, and a tong mounts each volume only once. Docker creates the volume on first use and seeds a new, empty one with whatever the image has at `target`. Only that first seeding happens: a later image's files at `target` never replace what the volume holds, so point `target` at a data directory, not at files the image ships. The launcher never removes a volume; remove one by hand with `docker volume rm <volume>` (`docker volume ls --filter name=swarmforge-volume-` lists them). A `volume:` mount is private to its tong; it is unrelated to `interface.kind: volume`, which would share a volume with the anvil and is not wired up yet.
- `tmux-socket[:ro]` — binds the directory holding the launcher's tmux socket at `/run/swarmforge-tmux`, so the tong reaches every tmux server you run in that directory — the one the launcher was started from and any other you started with `-L` — and through any of them can run any command on the host as you. That is as strong a grant as the docker socket, and the approval prompt calls it out the same way. It is always read-only (`tmux-socket:rw` is refused), but read-only only stops the tong deleting or replacing the sockets in that directory: a tmux client needs nothing more than to connect, so it limits nothing the tong can do through a server. It takes no target, and because the directory rather than the socket is bound, a socket tmux recreates on a server restart is still found. It is `session`-only (see [Lifecycle](README.md#lifecycle)). The launcher must itself run in a tmux pane, and the socket must sit directly in tmux's default per-user directory (`tmux-<uid>` under `$TMUX_TMPDIR` or `/tmp`); a socket at a custom `-S` path is refused, because binding its directory could expose an arbitrary host directory. The launcher also checks that directory on disk: no symlink on the way to it (docker would bind whatever one points at), owned by you, no group or other permission bits (tmux creates it `0700`), and holding a socket at the path `$TMUX` names. Every directory above it, up to `/`, must be owned by root or you and not writable by group or others unless it has the sticky bit (as `/tmp` does), so no other user can swap the directory between the check and docker's bind. The check assumes the docker daemon resolves the bind on the launcher's own filesystem, as the workspace and docker-socket binds already do; a remote `DOCKER_HOST` is not supported for this mount. Any of these failures stops the launch before anything starts. The tong gets `TMUX`, `TMUX_PANE`, `SWARMFORGE_SESSION_HANDLE`, and `SWARMFORGE_WORKSPACE_HOST_PATH` ([environment reference](../reference/environment.md#inside-the-container)). The socket directory's `0700` mode means the tong's process must run as your host uid or as root to connect.

### Volume naming and scope

Expand Down
19 changes: 11 additions & 8 deletions swarmforge/anvil/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@
handed to the tong as environment, so the launcher starts `shared` and `session`
tongs reached over the network (`mcp`/`port`) or with no anvil-facing surface
(`none`), with or without secrets. A `volume` interface, or a `shared` tong that
mounts the workspace, is refused with a clear message rather than started
half-wired.
mounts the workspace or the tmux socket, is refused with a clear message rather
than started half-wired.

MCP config
----------
Expand Down Expand Up @@ -94,12 +94,13 @@
------------------
The user, org, and Swarmforge-repo layers are installed deliberately and are
trusted. The workspace is any repo you happened to clone, so a workspace-sourced
tong -- which may request secrets, host mounts, or the docker socket -- is gated:
before the anvil starts, the launcher prints the privilege summary and asks the
user to approve it. Approval is keyed by workspace path + tong name + a hash of
the merged definition (so any change re-prompts) and persists in the user-layer
store passed as `--approvals`. The scripted `--no-prompt` mode fails closed
(refusing the run) rather than auto-approving an unapproved tong.
tong -- which may request secrets, host mounts, the docker socket, the
launcher's tmux server, or host GPUs -- is gated: before the anvil starts, the
launcher prints the privilege summary and asks the user to approve it. Approval
is keyed by workspace path + tong name + a hash of the merged definition (so any
change re-prompts) and persists in the user-layer store passed as
`--approvals`. The scripted `--no-prompt` mode fails closed (refusing the run)
rather than auto-approving an unapproved tong.

Passthrough invariant
---------------------
Expand Down Expand Up @@ -135,6 +136,7 @@
from .orchestrate import (
MCP_CONFIG_CONTAINER_PATH,
MCP_FILE_ENV,
check_tmux_socket_dir,
ensure_mcp_harness_supported,
exec_anvil,
run_with_tongs,
Expand Down Expand Up @@ -166,6 +168,7 @@
"TerminationSignal",
"MCP_CONFIG_CONTAINER_PATH",
"MCP_FILE_ENV",
"check_tmux_socket_dir",
"ensure_mcp_harness_supported",
"exec_anvil",
"run_with_tongs",
Expand Down
24 changes: 10 additions & 14 deletions swarmforge/anvil/approval.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
"""The first-run approval gate for workspace-sourced tongs.

The user, org, and Swarmforge-repo layers are installed deliberately and are
trusted. The workspace is any repo you happened to clone, so a workspace-sourced
tong -- which may request secrets, host mounts, the docker socket, or host
GPUs -- is gated: its privilege summary is rendered and the user is asked to
approve it before the anvil starts. Approval is keyed by workspace path, tong
name, and a hash of the merged definition, so any change re-prompts, and
persists in the user layer.

The gate itself is described in `swarmforge.anvil` ("First-run approval").
Deciding what a definition asks for and remembering the answer are the pure core's
job; rendering the question and reading the reply are this module's.
"""
Expand All @@ -25,12 +18,11 @@ def render_privilege_summary(name, summary):
"""Human-readable block describing what a workspace tong requests.

`summary` is the structured output of `tongs.privilege_summary`. Only the
privileges actually requested are shown, and docker-socket access -- the
broadest grant, since it is full control of the host's docker -- is always
called out explicitly so it cannot be approved unseen, as is a GPU request,
which grants the host's GPU devices. Every line passes through
`tongs.printable`, so a definition value cannot move the cursor, erase, or
forge a line of this block.
privileges actually requested are shown, and the two host-command grants --
the docker socket and the tmux socket -- each get a line of their own so
neither can be approved unseen, as does a GPU request. Every line passes
through `tongs.printable`, so a definition value cannot move the cursor,
erase, or forge a line of this block.
"""
lines = ["Workspace tong %r requests approval:" % name]
lines.append(" image: %s" % (summary.get("image") or "(none declared)"))
Expand All @@ -56,6 +48,10 @@ def render_privilege_summary(name, summary):
lines.append(" networks: %s" % ", ".join(str(n) for n in networks))
if summary.get("socket"):
lines.append(" docker socket: full host docker control")
if summary.get("tmux"):
lines.append(
" tmux socket: every tmux server in your socket directory -- any "
"command on the host as you (read-only does not limit this)")
if summary.get("gpus") is not None:
lines.append(" gpus: %s (host GPU access)" % summary["gpus"])
return "\n".join(tongs.printable(line) for line in lines)
Expand Down
Loading
Loading