Skip to main content

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:
The UID assignments (1001 for agent, 1002 for sidecar) are a security boundary in the standard runtime image. The exec paths no longer hardcode them, though: when crewshipd runs a command in a container (Keeper /execute, provider Exec), it resolves the container’s actual configured run-as user via ContainerInspect and fails closed — refusing to exec — if that user is empty, undeterminable, or privileged (root uid, root gid, or a root alias). A custom base image with a different non-root uid (e.g. 2000:2000) works automatically; a root-configured container does not get exec’d into.

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 in docker/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 on GET /api/v1/workspaces/{id}).
  • CLI: crewship workspace update --allow-privileged-credentials.
Turning this on means you accept that a privileged crew’s agents can read every credential injected into that crew. Leave it off unless you run a DinD workload that genuinely needs vaulted credentials and you trust the code executing inside it.

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_RAW was removed from the default set (it enables AF_PACKET/DNS-tunneling exfiltration). Devcontainer features may opt into an explicit allowlist (e.g. NET_BIND_SERVICE) via team.CapAdd.
  • SecurityOpt no-new-privileges: Blocks execution of setuid binaries inside the container
  • SUID stripped from the base image: The crewship-sandbox image strips the SUID bit from every setuid-root binary (su, mount, umount, passwd, chsh, newgrp, ssh-keysign, …) at build time. Only sudo is kept — it is the elevation path for the NOPASSWD firewall-init rule and sudo apt-get package-install flows. This is defense-in-depth behind no-new-privileges (which already neuters setuid): it shrinks the surface that a single regression dropping NoNewPrivs or granting a capability could re-arm. A build-time assertion fails the image build if any SUID binary other than /usr/bin/sudo reappears 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. rw still permits staging a file here, but noexec blocks executing it directly and nosuid strips 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, /output and /crew are agent-writable and host-persistent — /crew in particular is a host directory that survives container removal and is shared across every agent in the crew. They are mounted noexec,nosuid, so a compromised agent can still write data there but cannot execve() a payload staged on them. This closes the “durable, cross-agent code-execution foothold” vector (a payload dropped once under /crew/shared outliving container rebuilds). Docker’s bind-mount API has no noexec flag, 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-tools stays 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 so noexec takes effect. /home/agent is a deliberate exemption — see The /home/agent exemption 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-agents by 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, via HTTP_PROXY/HTTPS_PROXY, plus NODE_USE_ENV_PROXY=1 so Node’s built-in fetch honours 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 in container.crew_network_crews gets its own bridge instead (IPv4 only, a /27 from container.crew_network_pool): containers of other crews, on their own networks or on the shared one, cannot reach it, and its services: 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 with crew networks unsupported here instead 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-gateway enables container-to-host communication
  • Named volumes: crewship-home-{slug}-{crewID} and crewship-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 its devcontainer_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

The privileged / 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:
  1. Save (ValidateSecurity) — privileged requires allow_privileged_credentials (403); capAdd is bounded to NET_BIND_SERVICE (400); every mounts[].source must pass IsAllowedMountSource (400). The keys are persisted in the canonical config (they used to be silently dropped on the auto-inject re-marshal).
  2. Runtime read (ParseConfigSecurity) — at container start the resolver folds the validated top-level keys into the effective requirements the provider maps onto the Docker HostConfig (internal/chatbridge/resolver.go → internal/provider/docker/docker_container.go), re-filtering caps/mounts as defense in depth. The #1032 credential-fail-closed gate reads the same source, so a UI-privileged crew is correctly treated as privileged when deciding whether to load vault credentials.
This means toggling Privileged in the UI actually makes (or, without the workspace flag, refuses to make) the container privileged — the change reaches 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> --show prints the stored devcontainer_config, privileged included.

Setting them from the CLI

Every control above has a flag on crewship 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.
A crew running privileged has essentially no container isolation left. The Security tab surfaces an “Isolation reduced” badge on any such crew so the posture is visible wherever the crew is configured. Reserve privileged mode for crews you fully trust (e.g. Docker-in-Docker) and grant a single capability where that is enough.

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.
These flags merge onto the crew’s stored 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:
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 a free crew. Opening it takes two independent opt-ins:
  1. Per crew — allow_private_endpoints. Set it from the crew’s Network policy panel (admin-only toggle) or with crewship crew update <crew> --allow-private-endpoints.
  2. Per instance — the operator ceiling CREWSHIP_ALLOW_PRIVATE_ENDPOINTS.
They are ANDed (#974 S5), so a workspace admin cannot self-grant private egress on a shared or cloud host by flipping the crew flag alone — which is exactly the “I set the flag and it’s still blocked” case the UI toggle calls out inline. Link-local and cloud-metadata addresses (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’s network_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.
The two in-container rows are guardrails, not containment. Nothing on the server intercepts them — the boundary is the sidecar proxy the process is pointed at, which means the proxy environment is itself the security control. Code that deliberately ignores it (curl --noproxy '*', a raw socket, a non-cooperating binary) still reaches the network, because there is no network-layer fence for the crew unless it is on the egress fence pilot (#1368). Treat these rows as “a well-behaved tool obeys the allowlist”, and the four server-side rows as the ones that hold against hostile code.

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 on 127.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 only NET_ADMIN, runs the bind-mounted crewship-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.
Limits of the pilot:
  • It applies to restricted crews on the runc runtime. 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>, then crewship 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 declared services: and nothing else directly:
  • Fixed addresses. On a crew’s own /27 each service has a fixed address (.4 onward, 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/hosts when it is created, so postgres:5432 or redis:6379 resolve. 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/hosts still 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 (or CREWSHIP_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 installed with 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 as network.egress.
  • When a crew does not start: the log line crew requires the egress fence and it is not in place names the cause (an unsupported combination from the table above, or the install failing). An exec refused with egress fence not in place for this container start means 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.
Both in-container paths take those variables from one exported source (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.
Every non-container path builds its outbound client from the one shared factory, 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

The DomainAllowlist (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.com entries 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

All proxied requests have their body limited to 10 MB to prevent OOM attacks. LLM API requests are typically under 1 MB.

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:
  1. Checks the domain allowlist (in restricted mode)
  2. Establishes a TCP tunnel
  3. Does not inject credentials (the tunnel is opaque)
Credential injection only works for:
  • 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 via OPENAI_BASE_URL=http://127.0.0.1:9119/openai/v1 (→ api.openai.com) and Gemini via GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:9119/gemini (→ generativelanguage.googleapis.com); the /openai / /gemini routing 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:
  1. 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)
  2. 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, …) and awk/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 is 404 before the judge runs
    • The Keeper judge evaluates the command; the L1 fast path never applies to execute
  3. 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

The scrubber 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:
Each socket gets a 1.5-second ping timeout to avoid blocking on unresponsive daemons.
containerd and nerdctl are not supported. /run/containerd/containerd.sock used to appear in this list. It was removed because containerd serves its own gRPC API over HTTP/2, not the Docker REST API — the client Crewship uses can never talk to it, on any version. If a containerd socket is present when detection fails, the error now names it and says so. Rancher Desktop counts here too: it works in dockerd (moby) mode and not in containerd mode, where it leaves a ~/.rd/docker.sock behind that accepts a connection and then closes it.