Container Isolation
Crewship uses multiple layers of isolation to prevent agents from accessing unauthorized resources, exfiltrating data, or interfering with other agents.Isolation Layers
UID Security Boundary
Two fixed UIDs enforce a privilege boundary inside each container:What Agents Cannot Do
Because agents run as UID 1001 and the sidecar runs as UID 1002:- Agents cannot read the sidecar’s in-memory credential store
- Agents cannot access the IPC token (used for crewshipd authentication)
- Agents cannot modify network policy configuration
- Agents cannot tamper with outbound request headers (the sidecar handles this)
- Agents cannot set their own container ID in Keeper execute requests
The server’s own Docker access
Everything on this page fences the agent out of the host. The Crewship process is on the other side of that fence by necessity: it creates the containers, so it holds Docker API access, which is root-equivalent. You can narrow that to the endpoints Crewship actually calls with a socket proxy — Docker Socket Proxy publishes the exact list and ships it wired indocker/docker-compose.prod.yml. Read its ceiling
section before relying on it: the proxy filters URL paths, never request
bodies, so it cannot stop Privileged: true. The workspace flag below is what
does.
Privileged crews and credentials (fail-closed)
A privileged crew (Docker-in-Docker) runs without the UID 1001/1002 boundary above — under--privileged the split that keeps the sidecar’s
CredStore unreadable is gone, so any process in the container can reach it.
Because of that, Crewship fails closed (#1032): credentials are not
loaded into a privileged crew’s sidecar unless the workspace explicitly opts
in via allow_privileged_credentials (default off).
The toggle is workspace-scoped and admin-gated (OWNER/ADMIN):
- UI: Settings → General → Privileged credentials → “Load credentials into privileged crews.” (It is workspace-wide, not per-crew — a crew’s own Settings tab configures that crew, this switch governs the whole workspace.)
- API:
PATCH /api/v1/workspaces/{id}with{"allow_privileged_credentials": true}(read it back onGET /api/v1/workspaces/{id}). - CLI:
crewship workspace update --allow-privileged-credentials.
Container Runtime Security
Crewship supports multiple container runtimes with different security profiles:
Configure via:
Container Creation Security
Containers are created with these security measures (internal/provider/docker/docker_container.go):
- Cap-drop ALL: All Linux capabilities are dropped (
CapDrop: ["ALL"]) - No default cap-add: By default no capabilities are re-added.
NET_RAWwas removed from the default set (it enablesAF_PACKET/DNS-tunneling exfiltration). Devcontainer features may opt into an explicit allowlist (e.g.NET_BIND_SERVICE) viateam.CapAdd. - SecurityOpt no-new-privileges: Blocks execution of setuid binaries inside the container
- SUID stripped from the base image: The
crewship-sandboximage strips the SUID bit from every setuid-root binary (su,mount,umount,passwd,chsh,newgrp,ssh-keysign, …) at build time. Onlysudois kept — it is the elevation path for the NOPASSWD firewall-init rule andsudo apt-getpackage-install flows. This is defense-in-depth behindno-new-privileges(which already neuters setuid): it shrinks the surface that a single regression droppingNoNewPrivsor granting a capability could re-arm. A build-time assertion fails the image build if any SUID binary other than/usr/bin/sudoreappears after a base-image bump. - Non-root execution: Agent processes run as UID 1001; exec’d commands run as the container’s resolved configured user and are refused outright if that user would be root or cannot be determined
- Read-only filesystem: Root filesystem is read-only (
ReadonlyRootfs: true), relaxed only for privileged crews (DinD) - tmpfs /tmp (500M): Writable temp directory (
rw,noexec,nosuid,size=500m), not persistent across restarts.rwstill permits staging a file here, butnoexecblocks executing it directly andnosuidstrips any setuid/setgid bit; scripts still run via an explicit interpreter (sh,bash, …), which reads the file rather than exec’ing it. - noexec on agent-writable persistent volumes:
/workspace,/outputand/creware agent-writable and host-persistent —/crewin particular is a host directory that survives container removal and is shared across every agent in the crew. They are mountednoexec,nosuid, so a compromised agent can still write data there but cannotexecve()a payload staged on them. This closes the “durable, cross-agent code-execution foothold” vector (a payload dropped once under/crew/sharedoutliving container rebuilds). Docker’s bind-mount API has nonoexecflag, so these ride a bind-backed local volume (type=none,device=<host path>,o=bind,noexec,nosuid) — the mount still binds the exact host directory (data persists, still crew-shared), just with execution denied. The exceptions are deliberate:/opt/crew-toolsstays executable because agent tool binaries run from there, and the sidecar/entrypoint binaries are mounted read-only. Scripts under these volumes still run via an explicit interpreter (sh <path>), which reads the file rather than exec’ing it, so the runtime is unaffected. Existing containers created before this policy are recreated on next provision sonoexectakes effect./home/agentis a deliberate exemption — see The/home/agentexemption below. - PID limit: 200: Prevents fork bomb attacks (
PidsLimit: 200) - Memory limits: Configurable per-crew (
default_memory_mb, default 8192 MB / 8 GiB) - CPU limits: Configurable per-crew (
default_cpus, default 2.0) - Shared network, restricted egress: every crew’s container joins one
Docker bridge network (
crewship-agentsby default) — crews are not network-isolated from each other on that bridge. Outbound access is controlled per crew by egress policy enforced by the sidecar proxy (127.0.0.1:9119, viaHTTP_PROXY/HTTPS_PROXY, plusNODE_USE_ENV_PROXY=1so Node’s built-infetchhonours them on Node 22.21+ and 24+) — it filters only traffic actually routed through it. A raw socket, or a tool pointed past the proxy (curl --noproxy '*'), reaches the bridge directly: the shared bridge itself carries no network-layer isolation to fall back on. A crew listed incontainer.crew_network_crewsgets its own bridge instead (IPv4 only, a/27fromcontainer.crew_network_pool): containers of other crews, on their own networks or on the shared one, cannot reach it, and itsservices:aliases (postgres,redis) resolve to its own containers. Crews talk to each other through the Crewship API, not over the network. A crew network is internal whenever the instance network is. Port-expose only publishes containers of the requesting crew. Crew networks need crewshipd running on the Docker host itself, with a local daemon: a containerised crewshipd (the production compose file) is not attached to crew networks, so a listed crew there fails to start withcrew networks unsupported hereinstead of running unable to reach Crewship. If the instance network does not exist, a crew network is refused rather than created with weaker isolation. - ExtraHosts:
host.docker.internal:host-gatewayenables container-to-host communication - Named volumes:
crewship-home-{slug}-{crewID}andcrewship-tools-{slug}-{crewID}for persistent home and tools directories (namespaced by crew ID to prevent cross-tenant collisions)
The /home/agent exemption
/home/agent is agent-writable and stays executable. This is a decision, not
a gap — the persistence boundary Crewship defends is /crew, not $HOME.
The three paths that carry noexec are data paths. /home/agent is the
agent’s HOME, and self-installed tooling legitimately runs from it: a
pip install --user, a language-server binary, anything on ~/.local/bin.
Mounting it noexec would break ordinary agent workflows that /workspace,
/output and /crew never supported in the first place.
What made /crew worth fencing was a combination /home/agent does not have:
A payload staged under
/crew/shared was durable and reachable by other
agents. One staged in $HOME is neither: it is confined to the crew that wrote
it and goes away with crewship admin prune-crew-runtimes or any volume purge.
The remaining layers still apply to $HOME: the root filesystem is read-only,
all capabilities are dropped, no-new-privileges is set, SUID is stripped from
the base image, and egress still passes the sidecar allowlist. Execution from
$HOME buys an attacker code execution as UID 1001 — which an agent already
has by definition, since running code is what an agent does.
Rely on
/crew and /workspace for the durability guarantee, and on the UID
boundary plus egress policy for containment. Do not treat $HOME as a
no-execution zone: it is not, by design. Tracked and closed in
#1462.Runtime Escape Hatches
A crew’s container can opt out of individual isolation layers through itsdevcontainer_config. These are the settings with the largest blast radius,
so the crew settings UI (Container image & features → Security tab) exposes
them as labeled controls with inline warnings instead of a raw JSON blob. Each
control serializes to the exact top-level devcontainer_config key the runtime
reads, and a raw-JSON “advanced” editor remains available for anything the
structured UI does not model (preserved verbatim on save).
The UI controls are affordances, not the security boundary — every one is
re-validated server-side on save (create and update, crews_create.go /
crews_update.go via devcontainer.Config.ValidateSecurity), and the runtime
applies the validated values. A caller that bypasses the UI (raw API, a stale
client, a hand-edited config) hits the same gate.
How enforcement is wired
Theprivileged / capAdd / mounts / init keys are top-level
devcontainer.json fields — distinct from the capabilities a devcontainer
feature declares (those come from arbitrary OCI registries and are
force-stripped, internal/devcontainer/features.go). Because they are a
first-party operator declaration, the runtime honors them, but only after
the save gate has validated them:
- Save (
ValidateSecurity) —privilegedrequiresallow_privileged_credentials(403);capAddis bounded toNET_BIND_SERVICE(400); everymounts[].sourcemust passIsAllowedMountSource(400). The keys are persisted in the canonical config (they used to be silently dropped on the auto-inject re-marshal). - Runtime read (
ParseConfigSecurity) — at container start the resolver folds the validated top-level keys into the effective requirements the provider maps onto the DockerHostConfig(internal/chatbridge/resolver.go→internal/provider/docker/docker_container.go), re-filtering caps/mounts as defense in depth. The#1032credential-fail-closed gate reads the same source, so a UI-privileged crew is correctly treated as privileged when deciding whether to load vault credentials.
HostConfig.Privileged, not just the stored JSON.
Because the save path is the authority, the capability picker only offers what
the save path will accept: NET_BIND_SERVICE is selectable, everything broader
renders as privileged only and is inert. A capability stored on a crew from
before the gate landed stays interactive so it can be removed, flagged as no
longer saveable.
Seeing the posture
A privileged crew is not something you should have to go looking for. The effective posture is surfaced in three places:- Crew canvas header — a red Privileged · isolation reduced badge next to the crew name, on the first screen anyone opens.
- Security tab — an Isolation reduced badge plus a dot on the tab itself.
- CLI —
crewship crew config <crew> --showprints the storeddevcontainer_config,privilegedincluded.
Setting them from the CLI
Every control above has a flag oncrewship crew config —
--privileged, --cap-add, --init, --init-hook — so a privileged change is
scriptable and reviewable rather than a hand-edited JSON blob. See
crewship crew config. The flags merge
onto the stored config and hit the identical server-side gate.
CLI parity
The same controls are drivable from the CLI, and go through the identical server-side validation:crewship crew config --init is a deprecated no-op — the PID 1 reaper is
always on. The flag still parses, warns, and changes nothing.devcontainer_config (image and
features are preserved) and PATCH it back. Uploading a full config file with
--devcontainer ./devcontainer.json is validated the same way.
Network Policy
Each crew can have a network access policy:- Free Mode (Default)
- Restricted Mode
All outbound connections are allowed. The sidecar still intercepts and injects credentials for known LLM providers, but does not block unknown domains.
Private / LAN endpoints
The SSRF fence blocks RFC1918, loopback and link-local targets in both network modes, so an on-prem Ollama or a self-hosted model endpoint is unreachable by default even for afree crew. Opening it takes two independent opt-ins:
- Per crew —
allow_private_endpoints. Set it from the crew’s Network policy panel (admin-only toggle) or withcrewship crew update <crew> --allow-private-endpoints. - Per instance — the operator ceiling
CREWSHIP_ALLOW_PRIVATE_ENDPOINTS.
169.254.169.254) stay hard-blocked
regardless of either setting.
Unknown network modes default to restricted (fail closed). This prevents a typo in the configuration from accidentally allowing unrestricted access.New crews are created
restricted by default, and existing crews still on the legacy free default are backfilled to restricted on upgrade (migration v148). A crew that genuinely needs open egress can be re-opened to free, or granted the specific domains it needs.One allowlist, every egress path
A crew’snetwork_mode + allowed_domains is a single security dial enforced everywhere the platform makes an outbound request on that crew’s behalf — not only through the agent container’s sidecar proxy. All of these resolve the crew boundary from one shared source (internal/egresspolicy), so a restricted crew cannot exfiltrate through whichever path an author reaches for:
For notify and hooks the boundary is keyed on the crew that authored the routine (or owns the hook); the block is applied before any bytes leave — a webhook to a non-allowlisted host is never sent, and an MCP server whose endpoint host is outside the allowlist is never connected. A
free crew (the default) is unaffected: every path allows all hosts, exactly as before.
Network-layer egress fence (pilot)
The fence closes that gap for crews that opt in. It enforces the outbound path through the proxy; it is not full network isolation, because connections into the container from other crews on the shared network are still possible (see the limits below). It is an nftables table installed in the crew container’s network namespace that lets out only packets owned by the sidecar’s UID (1002). The agent (1001) and root inside the container can still reach the sidecar on127.0.0.1:9119, and nothing else: no direct TCP or UDP to the internet, no other container on the crew network, no host port, and no queries to Docker’s embedded resolver, which would otherwise forward any name upstream and serve as a DNS tunnel. The sidecar’s allowlist then applies to every byte that leaves, because the proxy is the only route out.
Turn it on per crew with container.egress_fence_crews (or CREWSHIP_EGRESS_FENCE_CREWS, a comma-separated list of crew slugs or ids). It is off for every crew by default.
How it holds:
- The container never holds
NET_ADMIN. A one-shot helper container joins the crew’s network namespace with onlyNET_ADMIN, runs the bind-mountedcrewship-sidecar --fence-apply, and exits. Nothing inside the crew container can remove the rules. - Every start re-installs it. A container restart recreates the network namespace and drops the fence, so the runtime installs it after each start it performs and checks the container’s start time before reusing a running container.
- No command runs in an unfenced start. The container’s entrypoint runs nothing of the agent’s; agent code arrives only through an exec. Every exec into a fenced container, including the web terminal, the crew’s start-up hooks, backup flush hooks, restore scripts and memory imports, first confirms the fence for the container’s current start. (Image builds exec only into temporary build containers, never into a crew’s.) After a restart outside Crewship (a daemon restart, an operator, a crashed process) the fence is installed again before the exec runs; if the server has not fenced the crew before (for example right after a server restart) the exec is refused until the next crew start. A container the server has fenced stays fenced by its id even if its crew labels are missing or stale. The start is checked again just before the exec runs and once it runs; a restart in between refuses the exec, or stops the crew if it already started — also when the caller gave up in between.
- Connections do not outlive it. Established traffic is accepted only in the reply direction, for connections opened to the container. A connection the agent opened itself, including one opened before the fence went in, stops delivering data once it is installed.
- Failure is closed. A listed crew whose fence cannot be installed is stopped, not run unfenced.
- The host is untouched. The rules live only in the crew’s namespace; the host firewall and Docker’s own rules do not change.
- It applies to
restrictedcrews on theruncruntime. A listed crew that is privileged,free, or on another runtime is refused: privileged mode removes the UID boundary the fence rests on, and gVisor and Kata carry traffic outside the namespace’s netfilter hooks. - Tools that resolve hostnames themselves before using the proxy fail, because the agent cannot query DNS. Tools that honour
HTTP_PROXY/HTTPS_PROXY(curl, git, npm, pip, the agent CLIs) let the sidecar resolve and keep working. - Turning the pilot off for a crew (removing it from the list) takes effect when its container next starts; until then the rules stay in its namespace. Stop and start the crew (
crewship crew stop <crew>, thencrewship crew start <crew>) to apply the change at once. - The fence controls traffic leaving the container, and replies to connections opened into it. Inbound reachability from other crews on the shared network is tracked separately in #2240.
Supported and refused combinations
There is no silent fallback. A listed crew either runs fenced or does not run.
Reaching the crew’s own services under the fence
A fenced crew on its own network reaches its declaredservices: and nothing else directly:
- Fixed addresses. On a crew’s own
/27each service has a fixed address (.4onward, by the sorted service names); every other container on that network gets an address from the upper half. A service address is never handed to another container, and a recreated service comes back at the same one. - Exact endpoints. The fence accepts packets to each service’s address on its declared ports and the ports its image exposes, by protocol. Not the subnet, not other ports, not the host.
- Names without DNS. The agent cannot query DNS under the fence. Service names are written into the runtime container’s
/etc/hostswhen it is created, sopostgres:5432orredis:6379resolve. Adding or renaming a service therefore takes effect when the crew’s container is next recreated (after a stop; a running container is not recreated under the agent). - Kept current. After the crew’s services are ensured, the fence is re-checked and re-installed in one atomic swap if their endpoints changed (for example a newly exposed port). This also happens when ensuring the services fails part-way, so the fence always matches the declared services and never keeps the address a moved service left.
- Address changes. Because addresses follow the sorted names, declaring a service can shift the address of an existing one. The shifted service is detached and attached again at its new address; the old address closes in the fence. Until the crew’s container is recreated, its
/etc/hostsstill names the old address, where the port is closed: the service is unreachable by name, never reachable through another service. - Failures close the path. A service that is down, cannot start, or whose move between networks fails is unreachable; the crew stays fenced and gains no other destination.
What the fence does not protect: credentials inside the container
The fence decides where traffic may go. It does not keep secrets out of the agent’s reach. These still reach the agent as readable values, and an agent can use them toward any host on the crew’s allowlist:
Each run logs these exposures, so the gap is visible rather than assumed. API keys for providers with a proxy route (the proxy-injected adapter) are not in the environment. Closing the rest needs credential injection at a gateway outside the container, which is separate work.
Operating the pilot
- Turn on: add the crew’s slug or id to
container.egress_fence_crews(orCREWSHIP_EGRESS_FENCE_CREWS) and restart the server. The fence goes in the next time the crew runs: on a container start, or on the first exec into an already running container. - Changing the crew: a re-install after an outside restart uses the crew settings of the last install. Making a fenced crew privileged, free or giving it services takes effect when its container is next recreated.
- Check: the server log records
egress fence installedwith the crew id and the install time for every start. Blocked direct connections fail at once (No route to host,Operation not permitted, or a name-resolution failure); traffic through the proxy is unchanged and appears in the crew’s journal asnetwork.egress. - When a crew does not start: the log line
crew requires the egress fence and it is not in placenames the cause (an unsupported combination from the table above, or the install failing). An exec refused withegress fence not in place for this container startmeans the container was restarted outside Crewship and has not been fenced again yet; the next exec into a crew the server has fenced before re-installs it, and after a server restart the next crew start does. - Turn off: remove the crew from the list, restart the server, then stop and start the crew. The rules live in the container’s network namespace and leave with it; removing the crew from the list alone does not remove them from a running container.
orchestrator.SidecarProxyEnv), and a routine cannot override them: any *_proxy variable declared in script.env is dropped and logged. The match is on the shape rather than an exact list, because CPython lowercases every environment name before looking for a _proxy suffix — so HtTp_PrOxY would otherwise reach a Python script’s proxy configuration, and .py is a first-class script interpreter.
This table was not always complete. Routine
script steps built their exec
environment from the step’s own inputs alone and carried no proxy, so a
restricted crew reached arbitrary hosts from a script step with a plain
curl — found by the in-container red-team suite
(scripts/test-harness/test-redteam-insider.sh) and fixed in
#1473. The lesson is
the one this page keeps making: any new code path that execs into a crew
container inherits nothing automatically.egresspolicy.Client — the pre-flight host check above is a cheap fast-fail, and the client then re-runs the SSRF guard and the crew allowlist on every redirect hop. This is what closes the redirect-exfil bypass (an allowlisted host that 302s to a non-allowlisted one is refused, not followed) and means there is genuinely no per-path divergence: routine http steps, notify/webhook, hooks, and the MCP gateway share the same gated client rather than each hand-rolling a CheckRedirect.
Domain Allowlist Implementation
TheDomainAllowlist (internal/egressallow/allowlist.go, a dependency-free leaf so both internal/sidecar and internal/egresspolicy can share it without an import cycle) is a thread-safe set of allowed domain names:
- Domains are stored lowercase for case-insensitive matching
- Port numbers are stripped before comparison
- IPv6 bracket notation is handled correctly
*.example.comentries are stored as a subdomain-suffix matcher (matches any subdomain, not the apex); everything else is an exact host match- Domains can be added at runtime via
allowlist.Add(domain)(exact or wildcard)
Proxy Security
The sidecar proxy (internal/sidecar/proxy.go) enforces several security measures:
Request Body Limits
Hop-by-Hop Header Stripping
Per RFC 2616 Section 13.5.1, the proxy strips these headers:Proxy-Authorization is especially dangerous — an agent could use it to exfiltrate credentials via a controlled proxy.
HTTPS CONNECT Tunnels
For HTTPS requests, the sidecar:- Checks the domain allowlist (in restricted mode)
- Establishes a TCP tunnel
- Does not inject credentials (the tunnel is opaque)
- Plain HTTP proxy requests (agent sets
HTTP_PROXY) - Reverse proxy mode — Claude Code via
ANTHROPIC_BASE_URL=http://127.0.0.1:9119(→api.anthropic.com) and, since #1030, Codex viaOPENAI_BASE_URL=http://127.0.0.1:9119/openai/v1(→api.openai.com) and Gemini viaGOOGLE_GEMINI_BASE_URL=http://127.0.0.1:9119/gemini(→generativelanguage.googleapis.com); the/openai//geminirouting prefixes are stripped before forwarding. For all three, the real key stays in the sidecar CredStore and only a dummy placeholder reaches the agent env.
Provider Detection
The sidecar automatically detects which LLM provider a request is targeting:Credential Injection by Provider
Keeper Execute Security
The/keeper/execute flow has the most stringent security because it runs shell commands with credentials:
-
Sidecar validation:
- Intent and command length limits (4096 chars)
- Null byte rejection
- Shell metacharacter blocking outside single quotes (
;,|,>,&,<,`,$(, newlines) - Content inside single quotes is exempt
- Container ID and requesting agent always from IPC config and the agent’s bearer token (agent cannot override)
-
crewshipd validation:
- A stricter second denylist (defense in depth): the operators above plus
${,<(,<<, ANSI-C$'…', every control character including U+2028/U+2029, interpreter re-invocation (sh -c,python -c,node -e, …) andawk/sed, with real quote-state tracking — see Shell injection protection - The credential must be
ACTIVE, granted to the requesting agent and inside its lease, or the request is404before the judge runs - The Keeper judge evaluates the command; the L1 fast path never applies to execute
- A stricter second denylist (defense in depth): the operators above plus
-
Execution:
- Credential injected as environment variable for the command only, run as the container’s resolved non-root user (refused if that user is empty, undeterminable or root)
- Output scrubbed of the credential value and a fixed set of its encodings (base64 variants, base32, URL-escaped, hex, reversed) before returning to agent — a denylist of transforms, not a boundary
Output Scrubbing
Thescrubber package (internal/scrubber/) removes credential values from agent output. This prevents:
- Credentials appearing in chat messages
- Credentials being logged to the progress stream
- Credentials in Keeper execute output being returned to the agent
Container Auto-Detection
The Docker provider (internal/provider/docker/docker.go) auto-detects the container runtime by probing socket paths:
Socket paths probed for runtime auto-detection
Socket paths probed for runtime auto-detection