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 reflogis 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:
--userns=keep-idon 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.- 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/passwdentry.
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/passwdon 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.