Skip to content

Honor a container image's USER and WORKDIR - #1918

Closed
Davanum Srinivas (dims) wants to merge 6 commits into
agent-substrate:mainfrom
dims:pr/image-user
Closed

Davanum Srinivas (dims) wants to merge 6 commits into
agent-substrate:mainfrom
dims:pr/image-user

Conversation

@dims

@dims Davanum Srinivas (dims) commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

USER resolves as Docker does, against the image's own /etc/passwd and /etc/group: uid:gid as is, a bare uid with its login group (gid 0 without an entry), names looked up or Run/Restore fails, group memberships as supplementary groups. No USER means root. atelet resolves it into the worker's ContainerSpec; the pause container follows the same rule through WorkloadSpec.pause_user. The process starts in the image's WORKDIR (default /), created in the actor's upper when the image lacks it.

Behavior change: images with a USER stop running as root; images with a WORKDIR stop starting in /. On gVisor no snapshot from before this restores: runsc restore checks Process.User and Process.Cwd of every container, and the pause image sets USER 65535. Micro-VM snapshots resume as they were.

Merge after #2392 (image layer owners): without it a non-root process cannot write the files its image gives it. On the micro-VM class, images that serve on a privileged port also need #1904.

Live run on kind, both classes, main + #1906 + #2392 + this PR: a node image (USER 1000, WORKDIR /app, durable dir) and the jupyter demo run as their user, write to their workdir and durable dir, and resume on demand after a suspend. On micro-VM uid 1000 cannot bind port 80 (#1904); gVisor allows it.

The actor images this repository builds with ko (the counter, egress and fspersist demos, the e2e probe and test server, glutton) bind port 80 or write at the root of their filesystem, and the default base is nonroot since #3028. .ko.yaml keeps them on the root variant of the same distroless build: they write at the root of their filesystem and into the durable-dir volumes atelet creates as root.

  • Tests pass: go test -race on Linux for atelet, imagecache, ocispec, ateworker-gvisor and kata.
  • Appropriate changes to documentation are included in the PR

@dims

Copy link
Copy Markdown
Collaborator Author

Comment thread cmd/atelet/oci.go Resolved
Comment thread internal/ocispec/ocispec.go Resolved
Comment thread cmd/atelet/oci_test.go Resolved

@mayawang Maya Wang (mayawang) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The USER and WORKDIR resolution looks right to me, and moving to moby/sys/user covers Ben's gid point. My main ask is a live run before this merges, since it changes who real images run as. It also needs a rebase: internal/ocispec/parity_test.go conflicts with main.

Comment thread cmd/atelet/oci.go Resolved
Comment thread docs/api-guide.md Resolved
Comment thread internal/imagecache/imagefile.go Resolved
Quentin Bisson (QuentinBisson) added a commit to giantswarm/substrate that referenced this pull request Oct 1, 2026
… root dependency of kagent-upstream#125 (#94)

docs(fork): the egress default's upstream exit is agent-substrate#1558; note the rootfs root dependency of kagent-upstream#125 (agent-substrate#1918)
@dims Davanum Srinivas (dims) changed the title atelet: honor a container image's own USER atelet: honor a container image's USER and WORKDIR Oct 1, 2026
Quentin Bisson (QuentinBisson) added a commit to giantswarm/substrate that referenced this pull request Oct 1, 2026
@mayawang

Copy link
Copy Markdown
Collaborator

Two gVisor issues from Preston Holmes (@ptone) that every image this change moves to non-root will hit: #2035 (the actor rootfs / is mode 0700, so a non-root user can't resolve any path) and #2036 (files the image gives to a non-root user show up as owned by uid 0). There's a fork fix for #2036 in giantswarm#115. Neither is covered by this stack, and I think we need a plan for both before this lands, otherwise images with a USER go from running as root to not starting. Davanum Srinivas (@dims) Benjamin Elder (@BenTheElder) let me know what you think.

@mayawang

Copy link
Copy Markdown
Collaborator

One case to plan for once this lands: images whose USER names the workload's user while the entrypoint has to start as root and drop privileges itself. OpenShell's sandbox images are an example. They set a non-root USER, and its Kubernetes driver sets runAsUser: 0 on the container so the supervisor starts as root, builds the sandbox's namespaces, and runs the workload as that user.

With this PR as it stands, those images would start the supervisor as the non-root user and fail. Could Container get an override, in the spirit of Kubernetes' securityContext.runAsUser/runAsGroup? Happy to open a separate issue if that's easier to track.

(Checked on 70358cfc: today both classes still run such an image as root, so nothing is broken yet.)

@dims

Copy link
Copy Markdown
Collaborator Author

Maya Wang (@mayawang) i will take up your offer, please open another issue. Let me rebase this and get it to green again.

@dims
Davanum Srinivas (dims) force-pushed the pr/image-user branch 6 times, most recently from 7f4bf05 to fc7c1bd Compare October 24, 2026 11:38
@BenTheElder

Copy link
Copy Markdown
Collaborator

With this PR as it stands, those images would start the supervisor as the non-root user and fail. Could Container get an override, in the spirit of Kubernetes' securityContext.runAsUser/runAsGroup? Happy to open a separate issue if that's easier to track.

This makes sense.

For the moment I think we're blocked on the large PR to make layers match their image owners. They're security fixes IMO so still viable during code freeze, but we have a lot to work on.

@dims

Copy link
Copy Markdown
Collaborator Author

Benjamin Elder (@BenTheElder) #2392 ?

@BenTheElder

Copy link
Copy Markdown
Collaborator

Yeah. Commented more there

rootfs and upper were 0700. On gVisor the container's / takes that mode,
so a non-root process could not search its own root. 0755 is what
containerd and every image root use; the 0700 actor directory above
keeps other actors out. The micro-VM guest already sees 0755.

Signed-off-by: Davanum Srinivas <davanum@gmail.com>
atelet writes the OCI spec before the worker mounts the rootfs, so it
cannot read the image's /etc/passwd from a mount. Image.ReadFile reads a
file from the cached layers top-down, honoring recorded whiteouts and
opaque dirs. Capped at 1 MiB.

Signed-off-by: Davanum Srinivas <davanum@gmail.com>
Resolve USER as Docker does, against the image's own /etc/passwd and
/etc/group: uid:gid as is, a bare uid with its login group (gid 0 without
an entry), names looked up or Run/Restore fails, group memberships as
supplementary groups. No USER means root. atelet resolves it into the
worker's ContainerSpec.

runsc restore checks Process.User, so a gVisor snapshot taken before this
of an image that sets USER needs re-taking.

Signed-off-by: Davanum Srinivas <davanum@gmail.com>
Start the process in the image's WORKDIR instead of "/", and create the
directory in the actor's upper when the image lacks it. atelet resolves
it into the worker's ContainerSpec. runsc restore checks Process.Cwd as
well as Process.User, so gVisor snapshots from before this need
re-taking.

Signed-off-by: Davanum Srinivas <davanum@gmail.com>
Substrate makes no compatibility promise before v1.0.0, so a note about
one release's snapshots is noise in the upgrade procedure. The API guide
describes how the image's USER and WORKDIR apply.

Signed-off-by: Davanum Srinivas <davanum@gmail.com>
The actor images built from this repository write at the root of their
filesystem and into the durable-dir volumes atelet creates as root. With
the default base now nonroot and a container running as its image USER,
they would start as uid 65532 and lose those writes, so the e2e demos
break. These images stay on the root variant of the same distroless build.

Signed-off-by: Davanum Srinivas <davanum@gmail.com>
@dims

Copy link
Copy Markdown
Collaborator Author

this will be part of #2392 - thanks for picking up my commit from here Anish Ramasekar (@aramase)

Greg Castle (destijl) pushed a commit to destijl/substrate that referenced this pull request Oct 29, 2026
…s kept (agent-substrate#2392)

Containers ignored the image's `USER` and `WORKDIR` and ran as root in
`/`, and atelet's unpack dropped the file owners image layers record. So
an image that hands a directory to its user, such as jupyter's
`/home/jovyan`, didn't work as built.

- atelet applies each tar entry's uid/gid and keeps setuid, setgid and
sticky bits. It adds `CHOWN`, `FOWNER` and `FSETID` to its
`DAC_OVERRIDE`. The image cache layout moves to version 3.
- The process runs as the image's `USER`, resolved as Docker does
against the image's own `/etc/passwd` and `/etc/group`, and starts in
its `WORKDIR` (default `/`).
- The jupyter demo runs as jovyan and drops `DAC_OVERRIDE` and
`--allow-root`.

The `USER` and `WORKDIR` commits are @dims's from agent-substrate#1918, kept as
written. This PR supersedes agent-substrate#1918.

Fixes agent-substrate#2036

#### Breaking change

Please add the `breaking-change` label.

- After an upgrade or rollback, atelet refuses the existing image cache.
Delete `/var/lib/ate/image-cache` on each node once no actor runs on it,
as atelet's error says. After an upgrade, a node reboot also works.
- A custom atelet securityContext needs `CHOWN`, `DAC_OVERRIDE`,
`FOWNER` and `FSETID`.
- Images with a `USER` stop running as root, and images with a `WORKDIR`
stop starting in `/`. A non-root `USER` is expected to have no effective
capabilities, so `securityContext.capabilities.add` no longer helps it.
This is checked under runc and in the gVisor source, not yet inside a
gVisor or microVM sandbox.
- gVisor snapshots of such images don't restore, because `runsc restore`
checks each container's user and working directory. Recreate those
templates. MicroVM snapshots resume as they were.
- Files an earlier run wrote as root into a durable dir stay root-owned.

Tested: `go test -race` on atelet, imagecache, ocispec, tarutil, kata
and the probe fixture, on macOS and on Linux as non-root, and imagecache
with `-count=3 -shuffle=on`. The root-only imagecache and tarutil tests
pass as root on a privileged Linux container. `hack/verify-all.sh`
passes. The jupyter image serves `/api` under Docker as 1000:100 with
every capability dropped. @dims ran the jupyter demo and a non-root node
image as their users on kind, on both runtimes, before agent-substrate#1904 merged; on
microVM uid 1000 couldn't bind :80 then. A new imagefs e2e starts an
actor from a probe image with `USER nonroot:nonroot` and `WORKDIR
/home/nonroot`. It checks uid and gid 65532, no supplementary group
besides 65532, no effective capabilities, the working directory, and
that the user can create a file there. CI runs it on gVisor and on
microVM. At this head nothing ran in a gVisor or microVM sandbox yet:
not the jupyter demo and not the imagefs e2e.

- [ ] Tests pass
- [x] Docs updated

---------

Signed-off-by: Anish Ramasekar <anish.ramasekar@gmail.com>
Signed-off-by: Davanum Srinivas <davanum@gmail.com>
Co-authored-by: Davanum Srinivas <davanum@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/node area/security Security related issue/pr kind/feature An enhancement / feature request or implementation milestone-ack

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants