Every cell brig runs gets a read-only metadata file mounted at
/run/brig/cell.json. The cell uses it to learn its own identity
(name, workspace mount point, policy ACL) so it can hand
(cell, relpath) to a host-side worker for any agent-delegation
flow.
The pattern mirrors Kubernetes' downward API and cloud instance metadata: brig writes a small JSON file on the host, podman bind-mounts it read-only into the cell. The cell can read but cannot modify it.
{
"version": 3,
"name": "my-cell",
"started_at": "2026-05-18T17:30:00Z",
"workspace": {
"mount_point": "/work"
},
"host_sockets": [],
"ingress": [
{"name": "api", "port": 8000, "path_prefix": "/api", "auth": "token"}
],
"image_digest": "sha256:abc...",
"policy": {
"host_services": ["model"]
}
}| Field | Type | Notes |
|---|---|---|
version |
int | Schema version. Currently 3. Bumps on shape changes. |
name |
string | Cell name, matches --name / yaml name:. |
started_at |
string | RFC 3339 UTC timestamp of cell creation. |
workspace.mount_point |
string | Path inside the cell, default /work, overridable via workspace_mount in the cell spec. |
policy.host_services |
string[] | Per-cell host-service ACL — the names of host services this cell may reach. Ports live in the per-cell policy file on disk; metadata exposes names only. |
host_sockets[] |
[{name, mount_point}] |
Unix sockets bind-mounted into the cell from the host (host_path is intentionally omitted). |
ingress[] |
[{name, port, path_prefix, auth}] |
Ingress endpoints the cell publishes through warden's :8443 reverse proxy. auth is token (default; the bearer token is never stored here — it lives in ~/.brig/secrets/<cell>-ingress-token) or none (transparent pass-through, no token). brig cell start uses this list to replay route registration with a freshly-inspected cell IP after a brig system down / up cycle. |
image_digest |
string? | Optional. Set when the cell was created with a pinned digest. brig cell start re-verifies the container's current image digest against this value before letting the cell start. |
v2 → v3 added two optional fields:
ingress— letsbrig cell startreplay route registration without the original yaml after abrig system down/upcycle. No secrets land here; the bearer token still lives in the secrets directory.image_digest— letsbrig cell startre-verify the digest pin before letting the container start, closing thepodman commit + restartoperator-side bypass.
Both fields are additive; a v2 reader sees them as unknown keys and
ignores them. Bumping to v3 reflects that the writer's output shape
changed, so a future reader checking version >= 3 can rely on
image_digest / ingress semantics.
v1 also published workspace.host_path — the absolute path on the
host where the cell's workspace lives. We removed it.
The reason is a confused-deputy attack: a cell can drop a symlink
inside its workspace pointing at a host secret, then ask a host-side
worker to read by path. The host kernel follows the symlink and
returns the secret — the cell exfiltrated a file it could never
reach itself (gVisor blocked direct reads). There is no kernel-mount
fix available on macOS, so the only defense is at the application
that opens the file. Removing host_path makes that defense
structural: consumers don't have a path string to misuse.
Any language, no library required:
# Inside the cell
cat /run/brig/cell.json | jq -r .name
# → my-cell# Inside the cell
import json
with open("/run/brig/cell.json") as f:
meta = json.load(f)
print(meta["name"], meta["workspace"]["mount_point"])The file is bind-mounted read-only; writes fail with EACCES.
Host-side consumers of a cell's workspace files (e.g. an
agent-delegation worker running on behalf of the cell) MUST use one
of the safe primitives below. Plain open() on a host path
derived from the cell name is unsafe and exists exactly because a
cell can plant symlinks the host kernel will follow.
Streams the file to stdout. Each path component is walked with
O_NOFOLLOW — a cell-planted symlink anywhere along the path
errors out instead of being followed.
brig cell read my-cell input.json > /tmp/local-copy
brig cell read my-cell data/items.txt | wc -lUse this from shell, Node, Go, Rust, anywhere that can shell out.
One subprocess per file — fine for the agent-delegation pattern
(small files, modest read counts). For large bulk transfers, use
brig cell cp my-cell:/work/dir /local/dir (same safety property,
batched).
If your consumer is Python and shelling out per file is overhead you'd rather avoid:
from brig.workspace.validation import safe_open, WorkspaceEscape
def read_cell_file(cell: str, relpath: str) -> bytes:
try:
with safe_open(cell, relpath, "rb") as f:
return f.read()
except WorkspaceEscape:
raise # cell-controlled path tried to escape — refuse the readsafe_open opens the workspace root with O_NOFOLLOW | O_DIRECTORY,
walks each intermediate component with openat(parent, name, O_NOFOLLOW), and opens the final component with O_NOFOLLOW. The
caller gets an open file descriptor; there's no path string the
cell can poison after the fd binds.
For the "run a command on the workspace" case, run the command
inside the cell itself via brig cell exec. The command runs under
gVisor in the cell's namespace, so symlink resolution happens inside
the cell — a symlink to /etc/passwd resolves to the cell's
/etc/passwd (sandboxed), not the host's:
brig cell exec my-cell -- grep "TODO" /work/notes.mdThere's no host_path to copy from cell.json, so the most obvious
mistake (using it as a path argument) isn't even possible. But if
you derive the path yourself from the cell name:
# UNSAFE — do not do this.
path = f"~/.brig/state/{cell}/workspace/{relpath}"
with open(path) as f: # ← follows any cell-planted symlink
data = f.read()You're back where v1 was. Use one of the primitives above.
A cell running untrusted code can drop a symlink inside its workspace pointing outside it:
ln -s /Users/d0c/.ssh/id_rsa /work/innocuous.txtThe cell itself can't read the SSH key — gVisor blocks the syscall.
But when a host-side consumer reads /work/innocuous.txt via a
naively-resolved host path, the kernel follows the symlink and
returns the key. The cell got the host to read on its behalf —
classic confused deputy.
Live exploit shape (still works against any consumer that derives the host path itself and uses plain
open()):# 1. Cell drops a symlink inside its workspace pointing at a host file. brig cell exec my-cell -- ln -sf /etc/passwd /work/foo.txt # 2. Cell asks a host-side worker to read that filename. # If the worker uses safe_open / brig cell read: refused. # If it derives the path and uses plain open(): leaks /etc/passwd.
The schema v2 break closes the easy mistake (publishing the path). It does NOT close:
| Issue | Why brig can't fix it from here |
|---|---|
Consumers that derive the host path themselves and open() it |
Once the path is derivable from the cell name + brig install convention, anyone who tries hard enough can construct it. The safe primitives are the recommended path; using them is the consumer's responsibility. |
| In-workspace file reads inside an agent's own tools (e.g. an LLM-driven Read/Edit tool) | The tool's own file-open code runs after brig's hand-off. The fix lives in the tool. macOS doesn't have MS_NOSYMFOLLOW-equivalent for a directory tree, so there's no mount-level brig can apply. |
The schema break is a defense in depth that eliminates the easy mistake. The full chain still requires each consumer in the read path to use safe-open semantics.
brig.cell.metadata— source for the writerbrig.workspace.validation— source forsafe_open/safe_dirfdbrig cell read --help,brig cell cp --help,brig cell exec --help— the safe consumer primitives