Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Workspace

The workspace is what the agent’s tools get to read and write. By default, your repository is mounted live into the container at /workspace. You can also mount extra resource directories, read-only by default, when the agent needs supporting material outside the repo. Read this page before you run outrig on anything you can’t easily roll back.

Direct bind-mount, no staging

outrig mounts the primary host workspace directly:

podman run -v <repo>:/workspace:rw --userns=keep-id ...

That means changes made by tools inside the container appear on your host filesystem immediately for any read-write bind mount. There is no staging directory, no overlay, no per-session shadow copy. If the agent runs rm -rf /workspace/*, your repo is gone – recoverable only via git or whatever backup you have. Extra mounts default to read-only, but an extra access = "read-write" mount has the same immediate-write behavior.

This is intentional. The alternative – staging changes in a sandbox and asking you to “apply” them after the session – has a few real costs:

  • Upfront copy time, every session, on potentially large repos.
  • A second source of truth between session-end and apply-time that can drift.
  • Friction every time you want to actually use the agent’s output.
  • An apply step that may merge surprisingly with files you’ve edited concurrently.

For an autonomous-agent workflow, the better safety net is git, not a staging step:

  • Run outrig on a feature branch.
  • Commit (or stash) your work-in-progress before starting.
  • After the session, use git diff, git status, git checkout -- <path>, git reset --hard, etc. to review and roll back as you would any change.
  • If the agent really destroyed something, that’s what git reflog is for.

If you find yourself wanting a “review before apply” step, that’s a signal you don’t yet trust the agent’s environment – usually the right fix is to tighten the Dockerfile or the MCP server choices, not to layer a staging mechanism over the top.

UID/GID: runtime user mapping

By default, rootless podman maps the container’s UID 0 (root) to your host UID. Anything the container does as root therefore appears on the host as files you own – but anything done as a non-root in-container UID maps to some scrambled subuid number that’s awkward to clean up.

outrig sidesteps this in two parts:

  1. --userns=keep-id on the run command. With this, your host UID/GID map straight onto the same UID/GID inside the container’s user namespace – so an in-container process running as UID 1000 produces files owned by host UID 1000 on the bind-mount.
  2. A startup bootstrap that creates a matching user inside the container at run time, so tools that call getpwuid() (some shells, npm postinstall scripts, etc.) don’t fail on a missing /etc/passwd entry.

After podman run -d brings the container up, but before any MCP server starts, outrig writes those entries itself – from the host, without running anything inside the container. A forked child joins the container’s user namespace (which makes it the container’s root), then its mount namespace, and hands the two open files back; outrig reads them and appends what’s missing:

# Group: reuse if a group with this GID already exists, else append one.
# Name collisions get a `_` suffix, retried up to ten times.
tgockel:x:1000:

# User: same dance. The home directory is named here but created separately,
# exactly as `useradd` without `-m` behaves.
tgockel:x:1000:1000::/home/tgockel:/bin/sh

# Some tools assume $HOME exists, and it must belong to you, not to root.
mkdir -p /home/tgockel && chown 1000:1000 /home/tgockel

Appending, rather than rewriting the files, is deliberate: it keeps their owner and mode, and it is safe against a concurrent writer. The one exception is a reused user entry’s home, covered below. /etc/shadow gets no entry – nothing in outrig authenticates as this user, and the entry useradd would write is a locked password.

After bootstrap, every podman exec outrig issues – to start MCP servers, to run anything else – uses --user=$(id -u):$(id -g) and HOME=/home/<user>. Files written under /workspace therefore appear with your UID/GID on the host. The image itself needs no user setup and no user tooling: any base image works, including one with no useradd, groupadd, or getent at all.

The collision dance handles the case where the image already has a group or user at your UID/GID (common for 1000:1000 – the typical first non-root user in many distros). When that happens, outrig reuses the existing entry rather than creating a duplicate. --userns=keep-id usually gets there first: podman appends an entry for you before the bootstrap looks, and that is the one reused.

A reused user entry keeps its name, but its home is changed to /home/<name> if it names another, so getpwuid(), ~<name>, and su - agree with $HOME. podman’s own entry names the container’s working directory, which for the primary is /workspace – your checkout, where a tool that finds its home that way would leave its caches. Only that field changes. The file is truncated where the entry starts and the entry and the lines after it appended back, so it keeps its owner and mode, and nothing before the entry is rewritten. That rewrite is not atomic, so it is done only in a container outrig has just started and nothing else is using yet. A container borrowed with outrig mcp --attach keeps its entry as it is. The name has to work as a directory under /home: an entry named ., .., or anything with a / in it fails the bootstrap with an error naming the entry, and nothing is written or created.

A /home/<user> the image already has is reused too, and chowned to you, as long as it is a directory. Unlike mkdir -p, anything else there – a file, a FIFO, or a symlink, even one to a directory – fails the bootstrap with an error naming the path. Taking it anyway would hand every tool a $HOME it can’t write under, and a symlink would redirect the chown to whatever it points at. /home itself may be a symlink to a directory; only the last component is held to this.

What’s mounted, what isn’t

The [workspace] block controls what host directories the container sees:

[workspace]
host-path      = "."          # relative to the repo root containing .agents/outrig/
container-path = "/workspace"

[[workspace.mounts]]
host-path      = "../shared-docs"   # relative to the file that declared it
container-path = "/resources/shared-docs"

[[workspace.mounts]]
host-path      = "/var/tmp/outrig-cache"
container-path = "/resources/cache"
access         = "read-write"

host-path = "." (the default) mounts your whole repo. You can narrow this – e.g. host-path = "src" mounts only the source dir. The primary workspace is always read-write and becomes the container workdir. Its container-path must be absolute and cannot be /.

outrig never picks your home directory on its own. With host-path undeclared, the workspace is the repo root – the directory holding .agents/outrig/, or the current directory when no repo config is found – and a session refuses to start when that is your home directory or a directory above it, such as / or /home. Running from ~, an MCP client that starts outrig in /, or a stray ~/.agents/outrig/config.toml would otherwise put ~/.ssh in the container. To mount your home directory on purpose, declare it in the global config, for example host-path = "~".

Nor does it pick a directory that contains your global config. The workspace is read-write, so an agent working in one could rewrite that file – a provider’s api-key, the [network] policy, session-root – for the next session. A session therefore also refuses to start when an undeclared workspace contains the global config file, or a directory its path is looked up through (a symlink or a missing component there could be swapped to redirect it). Running from ~/.outrig with no repo config above it does this, as does running from a project that holds the file --global-config names. Run from a project directory that does not hold it. To give the agent the global config on purpose, pass --volume for it, or declare host-path in the global config, which sets the workspace of every session whose repo config declares none.

Extra workspace.mounts entries are for supporting directories: sibling repos, generated docs, SDK checkouts, model artifacts, or caches. A relative host path resolves against the repo root when the repo config declares it, and beside the file that declares it otherwise – the global config, or a --config file kept outside .agents/outrig/; a leading ~ is your home directory. A --volume on the command line is declared in no file, so its relative host path resolves against the directory you ran outrig from, as your shell would read it. See path resolution. Their container paths must be absolute, cannot be /, and must not duplicate the primary workspace or another extra mount. Exact duplicates fail during config validation instead of relying on podman mount ordering.

No path in a bind mount may contain :, host or container, here or on a sidecar: podman reads a mount as <host>:<container>[:<options>], so a : in a path makes a fourth field it refuses at launch – long after the config was read, naming neither the key nor the file. Config validation refuses what a config spelled, and a session that would mount a path resolving through such a directory – a repo root with a : in its name, say – is refused as it starts. There is no escaping form: a host directory whose name contains : cannot be mounted.

Extra mounts default to access = "read-only". Use access = "read-write" only when the agent really should mutate that host directory, such as a scratch cache under /var/tmp.

Files outside the declared bind-mounts are not reachable from the container under any circumstances:

  • ~/.ssh – not mounted, agent can’t read your keys.
  • ~/.config – not mounted.
  • /etc/passwd on the host – not mounted; the container has its own.

The container has its own /etc, /home, /tmp, etc. coming from the image. The only windows onto the host filesystem are the primary workspace and the extra mounts you declare.

Sidecar containers see nothing by default

MCP sidecar containers (see Containers) get their own, stricter defaults: workspace = "none" – a sidecar sees no host directory at all unless its block asks. workspace = "ro" mounts the session workspace read-only at the primary’s container path (and makes it the working directory for the sidecar’s servers); workspace = "rw" mounts it read-write. Extra mounts entries on a sidecar use the same shape and validation as [[workspace.mounts]], including path resolution: a relative host-path resolves against the directory of the file that declared the sidecar block.

Sidecars run with --userns=keep-id like the primary. The in-container user bootstrap runs only where identity matters: the sidecar hosts at least one exec-stdio server, sees the workspace, or declares mounts. A mount-less sidecar keeps the image’s own USER untouched – as does an entrypoint host, mounts or not, since the bootstrap needs a running container and that container’s first process is already the server.

Network is not part of the workspace

By default, outrig grants the container full outbound network access. This means an agent with shell access can run curl, git push, npm publish, etc. – anything that talks to the outside world.

Network audit and filter modes are opt-in. Set one in config, or override one fresh session with --network audit or --network filter, to put a host-side interceptor in the path of outbound container traffic:

[network]
mode = "audit"

Audit mode writes one Zeek conn.log-style JSON object per TCP connection, and one per name lookup, to <session_dir>/logs/network.jsonl. Audit mode is allow-and-log only: every connection and every lookup is still allowed, but records include the best known host, destination IP and port, transport, service, byte counts, and duration. HTTPS remains opaque except for TLS SNI. URL, method, status, and body inspection are deferred. The interceptor carries TCP and DNS over UDP/53; any other datagram leaves by podman’s default route and is not recorded.

Filter mode uses the same interceptor and audit log, then applies global host/port policy before opening upstream TCP connections:

[network]
mode    = "filter"
default = "deny"
allow   = ["github.com:443", "*.npmjs.org"]
deny    = ["*:22"]

Policy is global-only because it belongs to the machine running the agent. Repo config may choose network.mode, but cannot set default, allow, or deny. Where both match, a deny entry wins over an allow entry, and a connection neither matches uses default. A denied connection is closed immediately and still writes a network.jsonl record with outrig.action = "deny" and zero byte counts, so the audit log is the place to diagnose network policy failures.

Entries match TCP connections. Anything else the container sends – UDP to any port but 53, which is how QUIC and so HTTP/3 travel, ICMP, any other transport – matches no entry and takes default directly, so a deny default stops it. Such a datagram never reaches the interceptor and writes no network.jsonl record; see Reference -> Config for what is carried and what the sending tool sees.

Name lookups, which are UDP/53, are decided by the name asked for, since a name travels to whoever serves its zone. Under a deny default the container resolves only the names an allow hostname entry matches, and an address or CIDR entry opens none; a refused lookup is answered REFUSED without a resolver being asked. Every lookup is recorded either way. See Reference -> Config.

A hostname entry in allow grants only against a destination outrig resolved to that name itself, through the container’s own lookup at the interceptor’s DNS listener. A name the container writes into a Host: header or a TLS ClientHello is a claim rather than evidence: it can match a deny entry, but it cannot authorize an address outrig never resolved to that name. A glob with no letter in it (*, 10.0.*) is matched against the destination address instead, and never against a name. An allow entry with no such binding does not match, and evaluation falls through to the remaining entries and then default. This narrowed in 0.2.0; see Reference -> Config for what an existing allowlist now permits.

Two limits follow. A hostname deny entry cannot stop a container that reaches an address without a lookup here and announces no name, and a hostname allow entry grants the address its name resolved to, so any other site served from it. See Reference -> Config.

When network mode is default, outrig leaves Podman’s configured default networking in place, does not install nftables rules, does not rewrite container DNS, and does not create network.jsonl. Systems without nftables or namespace support therefore keep running normal sessions. If audit or filter mode is explicitly enabled and the host cannot install the interceptor, startup fails before MCP servers launch.

See also

  • Containers – Dockerfile conventions, including the UID/GID setup.
  • MCP Servers – the layer that bounds what the agent can actually do with the workspace.
  • Reference -> Config – full schema for the [workspace] block.