Repository navigation
bug: sandbox from a USER root image fails with an unexplained "UID or GID zero" error #4030
Description
User Story
As someone creating a sandbox from an off-the-shelf image,
I want the error to say that the image runs as root and what I can do about it,
so that I can fix it without having to read OpenShell source code.
Problem Statement
OpenShell never runs a workload as root. When the policy doesn't set process.run_as_user, the Docker driver takes the identity from the image's USER, so an image that declares USER root is rejected. That's intended and documented in docs/how-it-works/policies/default-policy.mdx. The error message could be improved:
IdentityResolutionFailed: descriptor error: workload identity must not contain UID or GID zero
It doesn't say the zero came from the image's USER, it doesn't suggest a fix, and descriptor error is an internal name. The CLI also prints it twice.
From reading the code, a non-root user listed as a member of group 0 in the image's /etc/group gets the same error. Setting run_as_group alone doesn't fix it: the user still belongs to group 0 through the image's /etc/group.
Two related things I found:
- Docker defaults to root both when the image has no
USERand when it explicitly declaresUSER root. OpenShell instead uses1000:1000for the first case and rejects the second. docs/how-it-works/sandboxes/runtimes.mdxsays images withoutUSERmust set both policy fields. The code anddefault-policy.mdxsay they fall back to 1000.
#3653 asks for regression tests on the same root rejection. This issue is about the error message.
Impact / Why This Matters
The error comes after the image pull, and it names neither the image nor a fix. Reading crates/openshell-driver-docker/src/lib.rs shows that the image's USER was the cause.
As an end user, the workaround is to build a derived image with a non-root USER, or to set run_as_user and run_as_group in a policy file. Neither is discoverable from the error.
For netshoot the policy route gets past identity resolution and then fails workspace validation: its WORKDIR is /root, which is drwxrwx--- root root in the image, so UID and GID 1000 can't enter it. That's a separate problem and not part of this issue.
Acceptance Criteria
- When the Docker or Podman driver resolves an identity that contains a 0, the error says which part is zero (user, group, or a supplementary group) and where it came from (the image's
USER, its/etc/passwdor/etc/group, or the policy). - When the zero comes from the image's
USER, the error names the image and says to use a non-root image or setprocess.run_as_userandrun_as_group. -
descriptor errordoesn't appear in the message. - The CLI prints the failure once.
-
runtimes.mdxmatchesdefault-policy.mdxand the code for images with noUSER. - Root is still rejected, and an image with no
USERand no policy identity still gets1000:1000.
Reproduction Steps
openshell sandbox create --name test-sandbox --detach --from nicolaka/netshoot:latest- It fails with "IdentityResolutionFailed".
docker image inspect nicolaka/netshoot:latest --format '{{.Config.User}}'showsroot.
Environment
- OpenShell 0.1.2 (Homebrew) on macOS 26.2 arm64, local Docker gateway. The identity code and both docs pages are the same on
mainat021400be8. - Docker Engine 29.4.0 (linux/arm64)
Logs
$ openshell sandbox create --name test-sandbox --detach --from nicolaka/netshoot
Created sandbox: test-sandbox
✗ Error: IdentityResolutionFailed: descriptor error: workload identity must not contain UID or GID zero
✓ Sandbox allocated (0s)
✓ Image pulled (0s) Error: × sandbox entered error phase while provisioning: IdentityResolutionFailed: descriptor error: workload identity must not
│ contain UID or GID zero
Activity
shrxyas-246 commentedon Oct 1, 2026
Reproduced on 0.1.2 (deb), Ubuntu 24.04 on WSL2, kernel 6.18.33.1 x86_64,
Docker 29.8.2, local Docker gateway.
On the CLI printing it twice: on a TTY the message body actually appears three
times, from three separate paths in
crates/openshell-cli/src/run.rs.run.rs:993,d.set_active_detail(&ev.message)renders the conditionmessage into the spinner detail.
run.rs:1028,d.finish_error()prints the✗ Error:line fromlast_error_reason, assembled atrun.rs:940asformat!("{}: {}", condition.reason, condition.message).run.rs:1248wraps that same string in amiette!which the outer handlerprints again.
The first two are gated on
let interactive = std::io::stdout().is_terminal()at
run.rs:725, so the count depends on whether stdout is a TTY. Piping thecommand collapses it. Worth knowing for whoever writes the test, since a piped
assertion won't catch a regression here. I captured the above under
scripttokeep a real PTY.
The
descriptor errorprefix isn't in the identity code. It comes from theDisplayimpl onBackendErroratcrates/openshell-isolation-interface/src/contract.rs:118:and the guard is at
contract.rs:219, whereuid == 0,gid == 0andsupplementary_gids.contains(&0)all produce the same string. So that bulletneeds a different error variant or a mapping at the driver, not a reworded
message.
On the docs conflict,
runtimes.mdx:298is the incorrect page. An image withno
USERand no policy identity resolves to 1000:1000 and comes up fine:The effective policy had no
processblock, so images withoutUSERplainly donot "must set both fields in policy". The code at
crates/openshell-driver-docker/src/lib.rs:655anddefault-policy.mdx:52agree on the 1000 fallback.
One scoping question.
ResolvedWorkloadIdentity::newhas 7 non-test callers:openshell-driver-docker/src/lib.rs:655and:694,openshell-driver-podman/src/isolation.rs:137and:181,openshell-driver-kubernetes/src/driver.rs:2481and:2738, andopenshell-driver-vm/src/isolation/mod.rs:87. The acceptance criteria name onlyDocker and Podman, so Kubernetes and VM would keep the current message. Is that
deliberate for now, or should the constructor return a structured reason so
every backend improves together? Happy either way, I just don't want to pick the
wrong one and redo it.
Minor: the
sourcedoc comment onResolvedWorkloadIdentitylistspolicy,template,image, but the no-USER branch atlib.rs:655passes"default".I'd like to pick this up if it gets accepted.
elezar commentedon Oct 1, 2026
Triage Assessment
Classification: validated-bug — identity diagnostics, CLI error presentation, and documentation. Rejecting a root workload is intended behavior and must remain enforced.
Summary
The report provides sufficient reproduction and environment details. The original macOS/Docker report and the subsequent Linux/WSL2 reproduction agree with the active code paths. The diagnostic omits which identity component is zero, its origin, the image name, and remediation; interactive CLI output repeats the failure; the runtime documentation incorrectly requires policy identity fields for images without
USER.Investigation
021400be8and the relevant files on currentmain,b3e92013163ade4856d96f5857f5969d9a7bb4fc. The reported paths remain present. This assessment used source inspection and the two supplied runtime reproductions; I did not run a new sandbox or PTY reproduction.ResolvedWorkloadIdentity::newcombines UID zero, primary GID zero, and supplementary GID zero into one message.BackendError::Displayaddsdescriptor error:. Both Docker and Podman expose that constructor error.sourcevalue becomespolicywhen either field is overridden, so it is insufficient to attribute every rejected component accurately. Diagnostics should distinguish the field's selector origin from the account file that supplied its numeric value.run.rschooses interactive progress from stdout's TTY status. It renders event detail at line 994, finishes with the condition error at line 1030, and returns a diagnostic containing that error at line 1248. This supports the comment's report of up to three appearances on a TTY; the first two paths are absent from plain output. The spinner detail is transient, while the progress error and returned diagnostic produce two persistent reports. Clarify whether “once” covers final visible output or every terminal write, including transient spinner frames. A piped assertion alone would not validate the interactive acceptance criterion.1000:1000when neither the image nor policy supplies an identity. Existing resolver tests cover that fallback.runtimes.mdx:298contradicts it;default-policy.mdxcorrectly describes it. An explicit rootUSERfollows account resolution and is rejected instead. These cases must stay distinct.v0.1.2, which both reporters used. No concrete released fix was identified, and the affected code remains on currentmain.Related Work
This is related to matthewgrossman's OCI image work, but the reviewed changes do not resolve this issue:
WORKDIRhandling andWorkspaceValidationFailedreporting. The/rootworking-directory failure described here belongs to that workspace concern, separate from identity resolution.WORKDIRparagraph, but leaves the incorrect no-USER identity table entry unchanged. Coordinate edits in that file if both changes proceed.Impact Signals
Human Decision Required
Decide whether to accept this diagnostics/docs work and whether it remains human-owned. Acceptance is recorded by
state:accepted, roadmap association, or both; sequencing remains a maintainer decision. The contributor's question about extending improved errors to Kubernetes/VM is a scope decision: the current acceptance criteria explicitly cover Docker/Podman, and a shared constructor change may affect other drivers. The diagnostics request is technically feasible without relaxing root rejection.If deeper investigation or planning should enter the unattended queue, a maintainer can apply
agent:plan-requested; a direct request can also authorize that phase. If declining, close as not planned with a rationale. Technical validation alone does not record acceptance or assign implementation ownership.github-actions commentedon Oct 16, 2026
This issue has had no activity for 14 days and is now marked stale. It may be closed in 14 days if there is no further activity. Comment or remove the state:stale label to keep it open.