Skip to content

Restrict actor capabilities.add and make mounts nodev - #3580

Merged
Benjamin Elder (BenTheElder) merged 3 commits into
agent-substrate:mainfrom
destijl:actor-cap-allowlist
Nov 4, 2026
Merged

Benjamin Elder (BenTheElder) merged 3 commits into
agent-substrate:mainfrom
destijl:actor-cap-allowlist

Conversation

@destijl

@destijl Greg Castle (destijl) commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3579

Summary

Restricts securityContext.capabilities.add to a hardened allowlist of 12 Linux capabilities and blocks dangerous capabilities such as MKNOD, SYS_ADMIN, and SYS_RAWIO before 1.0.

In microVM sandboxes, granting MKNOD to a container running as root allows it to create block device nodes matching the guest's virtio-pmem root disk and directly write to the guest filesystem. Other capabilities like SYS_ADMIN and SYS_RAWIO expose dangerous guest kernel attack surface.

Changes

  • Capability Allowlist: securityContext.capabilities.add now only permits 12 capabilities required for package installs, non-root user switches, and standard container workflows:
    • AUDIT_WRITE
    • CHOWN
    • DAC_OVERRIDE
    • FOWNER
    • FSETID
    • KILL
    • NET_BIND_SERVICE
    • SETFCAP
    • SETGID
    • SETPCAP
    • SETUID
    • SYS_CHROOT
  • Admission Enforcement: ateapi and atelet reject actor template creations and updates specifying capabilities outside this allowlist.
  • Drop Unrestricted: securityContext.capabilities.drop continues to accept any capability name.
  • MicroVM Hardening:
    • Volume bind mounts are now mounted with nodev, and the microVM guest mounts the virtio-fs share with nodev (covering automounted submounts and rootfs). ShapeGVisor strips nodev, keeping gVisor specs identical and preserving checkpoint/restore compatibility.
    • MicroVM guest sysctl net.ipv4.ping_group_range = "0 2147483647" is configured so standard unprivileged ping continues to work without requiring NET_RAW.
  • Documentation & Schemas: Updated api-guide.md, sandbox-classes.md, and regenerated protobuf bindings (ateapi.pb.go, atelet.pb.go).

Breaking change: gVisor MEMORY snapshots taken before this release won't restore

gVisor now gets nodev on volume and system mounts (/proc, /dev/shm, /sys, /tmp), matching microVM. runsc applies it to the host bind mount. runsc restore rejects a checkpoint whose mounts differ from the new spec ("Mounts" does not match across checkpoint restore), so gVisor MEMORY snapshots taken before the upgrade can't be restored. rc.2 already breaks these snapshots with #3533 (tmpfs /dev/shm and /tmp), so this adds no new upgrade step on top of that. MicroVM actors are unaffected.

What breaks:

  • Suspended gVisor actors whose latest snapshot is MEMORY fidelity (the default) move to ACTOR_STATE_CRASHED on ResumeActor.
  • New actors from gVisor templates created before the upgrade crash on first resume, because the template's golden snapshot is an old MEMORY checkpoint.

Upgrade steps:

  1. Before upgrading, resume any suspended gVisor actor whose in-memory state you need, and save that state to durable storage or let its work finish.
  2. Re-create gVisor templates. ActorTemplate is immutable, so create a new template (e.g. my-template-v2), or delete and re-create it, to build a new golden snapshot. Point clients at the new template.
  3. Replace crashed actors. RevertActor won't help: it reverts to the same pre-upgrade checkpoint, which crashes again. UpdateActor --template won't either, because resume restores the actor's own snapshot. Delete the actor and create it again from the new template. Its in-memory state and its snapshot's rootfs and volume contents are lost.

Testing

  • Unit Tests: Updated resource validation tests, parity tests, and microVM spec tests (go test ./... passes in container).
  • Live GKE Testing (sgke-rc1):
    • Verified template rejection when adding MKNOD or SYS_ADMIN.
    • Verified unprivileged ICMP ping works in microVM guests without NET_RAW.
    • Verified that pre-existing gVisor actor snapshots restore cleanly.
  • e2e Probe Coverage:
    • Verified inside the guest that mknod fails with EPERM.
    • Verified that unprivileged users can open SOCK_DGRAM/IPPROTO_ICMP sockets across both runtimes.

Thanks to Fabiano Fidêncio (@fidencio) for the report on MKNOD that led us to make this change.

securityContext.capabilities.add accepted any Linux capability. In a
microVM, MKNOD lets a root container create a node for the guest's
virtio-pmem root disk and write to the guest rootfs, and SYS_ADMIN,
SYS_RAWIO and similar reach the guest kernel directly.

add now accepts only AUDIT_WRITE, CHOWN, DAC_OVERRIDE, DAC_READ_SEARCH,
FOWNER, FSETID, KILL, NET_BIND_SERVICE, SETFCAP, SETGID, SETPCAP, SETUID
and SYS_CHROOT. ateapi and atelet reject anything else, and atelet
ignores it when resolving the container's capabilities. drop still
accepts any name.

MicroVM guests also mount volumes nosuid,nodev, and set
net.ipv4.ping_group_range so ping works without NET_RAW.

Signed-off-by: Greg Castle <[email protected]>
@BenTheElder

Benjamin Elder (BenTheElder) commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

=== FAIL: internal/e2e/suites/demo TestWorkerSecurityFilesystem (15.42s)
worker_security_filesystem_test.go:199: Running command: /home/runner/work/substrate/substrate/hack/run-tool.sh ko resolve -f /tmp/TestWorkerSecurityFilesystem2797485032/001/fixture-image.yaml
2026/11/03 04:41:38 Using base gcr.io/distroless/static-debian13:latest@sha256:15f8637509792e05b97d83451b0b6ccfa4abd548f997e406ac1e72c87fe1fa78 for go.ate.dev/substrate/internal/e2e/fixtures/probe
2026/11/03 04:41:38 git doesn't contain any tags. Tag info will not be available
2026/11/03 04:41:38 Building go.ate.dev/substrate/internal/e2e/fixtures/probe for linux/amd64
2026/11/03 04:41:40 Publishing localhost:5001/probe-cb12bd4607ae1fdb24a003a6a26631c3:latest
2026/11/03 04:41:40 existing manifest: sha256-e42b33b8a9d037446ef664accf07f4a7e48b419ee40931784608f5e4fecfea53.sbom@sha256:4ef30906ffaf0ee91e77efc813f2e32b055f18b8fbd33e3b0bc98161780da605
2026/11/03 04:41:40 Published SBOM localhost:5001/probe-cb12bd4607ae1fdb24a003a6a26631c3:sha256-e42b33b8a9d037446ef664accf07f4a7e48b419ee40931784608f5e4fecfea53.sbom
2026/11/03 04:41:40 existing manifest: latest@sha256:e42b33b8a9d037446ef664accf07f4a7e48b419ee40931784608f5e4fecfea53
2026/11/03 04:41:40 Published localhost:5001/probe-cb12bd4607ae1fdb24a003a6a26631c3@sha256:e42b33b8a9d037446ef664accf07f4a7e48b419ee40931784608f5e4fecfea53
worker_security_filesystem_test.go:205: Creating namespace: jyia-860
worker_security_filesystem_test.go:212: Waiting for ActorTemplate demo/worker-filesystem-jyia-860 golden snapshot...
worker_security_filesystem_test.go:271: guest filesystem operations: {"files":[{"path":"/var/worker-security/owned","uid":1000,"gid":1000,"mode":1517,"contents":"worker filesystem state"},{"path":"/home/counter/worker-security/owned","uid":1000,"gid":1000,"mode":1517,"contents":"worker filesystem state"}],"error":"guest device-node creation: operation not permitted"}

https://github.com/agent-substrate/substrate/actions/runs/41578284851/job/125726819611?pr=3580

@fidencio

Fabiano Fidêncio (fidencio) commented Nov 3, 2026 •

Copy link
Copy Markdown

https://github.com/agent-substrate/substrate/actions/runs/41578284851/job/125726819611?pr=3580

Not that familiar with the repo here, so take this with a pinch of salt, but:

if err := syscall.Mknod("/var/worker-security/null", syscall.S_IFCHR|0o600, (1<<8)|3); err != nil && !os.IsExist(err) {
return fmt.Errorf("guest device-node creation: %w", err)
}
should be adjusted to match an EPERM now that MKNOD has been dropped.

-	if err := syscall.Mknod("/var/worker-security/null", syscall.S_IFCHR|0o600, (1<<8)|3); err != nil && !os.IsExist(err) {
-		return fmt.Errorf("guest device-node creation: %w", err)
+	if err := syscall.Mknod("/var/worker-security/null", syscall.S_IFCHR|0o600, (1<<8)|3); err != syscall.EPERM {
+		return fmt.Errorf("guest device-node creation: got %v, want EPERM", err)
 	}

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.

agent-assisted. thanks for working on this at the last minute.

},
SecurityContext: &ateapi.SecurityContext{Capabilities: &ateapi.Capabilities{
Add: []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "SETUID", "SETGID", "MKNOD"},
Add: []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "SETUID", "SETGID"},

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.

blocking (this is the CI failure): internal/e2e/fixtures/probe/filesystem.go:146-148 still calls syscall.Mknod in checkNonRootFilesystem and expects it to succeed.

We should flip that check to assert errors.Is(err, syscall.EPERM), which also gives us e2e coverage that mknod is denied inside the microVM guest.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Done. The probe now requires errors.Is(err, syscall.EPERM), so the microVM lane asserts that mknod is denied inside the guest. Thanks Fabiano Fidêncio (@fidencio) for the matching suggestion.

Comment thread internal/ocispec/microvm.go Outdated
Comment on lines +83 to +88
// The guest must not honor a device node or setuid bit on a volume.
for _, opt := range []string{"nosuid", "nodev"} {
if !slices.Contains(m.Options, opt) {
m.Options = append(m.Options, opt)
}
}

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.

I don't think we should add "nosuid" here (blocking for runtime parity).

Neither containerd CRI nor gVisor (internal/ocispec/ocispec.go:172) sets nosuid on volume mounts. MS_NOSUID disables both setuid/setgid bits and security.capability xattrs (SETFCAP, which we allow here), breaking parity when e.g. /home is a durableDir or tools come from an imageVolume (docs/reference/api-guide.md:379). Inside the guest, setuid and file capabilities on a volume are still bounded by the container's CapBnd just like on rootfs, and setNosuidNodev (cmd/ateworker-microvm/internal/kata/overlay_linux.go:376-382) already sets nosuid on the host mount.
https://github.com/containerd/containerd/blob/c507a0257ea6462fbd6f5ba4f5c74facb04021f4/internal/cri/opts/spec_linux_opts.go#L126
https://github.com/google/gvisor/blob/760b80785dc53311d4c87bd2de167525a99709c1/runsc/boot/vfs.go#L1183-L1184

"nodev" makes sense so the guest won't honor a pre-existing device node on an external volume. Two non-blocking notes on "nodev":

  1. ShapeGVisor already strips "nodev" (internal/ocispec/gvisor.go:78-80), so "nodev" could live on bind in internal/ocispec/ocispec.go:172 alongside systemMounts().
  2. rustjail applies "nodev" via MS_BIND | MS_REMOUNT, which in Linux (do_reconfigure_mnt) ignores MS_REC and only sets MNT_NODEV on the top mount, not --announce-submounts submounts. Passing Options: []string{"nodev"} on kataShared in cmd/ateworker-microvm/internal/kata/overlay_linux.go:433-438 would set MNT_NODEV on the virtiofs mount and its automounted submounts (finish_automount inherits mnt_flags).
    https://github.com/kata-containers/kata-containers/blob/03cf2ec06a5d302f23af811744f6bcea49988bfb/src/agent/rustjail/src/mount.rs#L866-L884
    https://github.com/kata-containers/kata-containers/blob/03cf2ec06a5d302f23af811744f6bcea49988bfb/src/agent/src/storage/mod.rs#L509-L534
    https://github.com/torvalds/linux/blob/adc218676eef25575469234709c2d87185ca223a/fs/namespace.c#L3560
    https://github.com/torvalds/linux/blob/adc218676eef25575469234709c2d87185ca223a/fs/namespace.c#L3823-L3824

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Agreed, dropped nosuid so setuid binaries and file capabilities on volumes behave the same as under containerd and gVisor. Adopted both nodev notes:

  1. nodev now lives on bind in ocispec.Build. ShapeGVisor strips it, so gVisor specs remain byte-for-byte identical and existing gVisor snapshots restore without spec drift.
  2. CreateSandboxForActor passes Options: ["nodev"] on the kataShared storage, applying MNT_NODEV to the virtio-fs mount and its automounted submounts. This also covers container rootfs trees on the share, which is safe because /dev is a separate tmpfs. Note that this applies on fresh boots; memory restores resume the existing guest mount.

Comment on lines +426 to +430
var AllowedAddCapabilities = sets.New(
"AUDIT_WRITE", "CHOWN", "DAC_OVERRIDE", "DAC_READ_SEARCH", "FOWNER",
"FSETID", "KILL", "NET_BIND_SERVICE", "SETFCAP", "SETGID", "SETPCAP",
"SETUID", "SYS_CHROOT",
)

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.

blocking: should we exclude DAC_READ_SEARCH here (and in api/ateapi/v1/ateapi.proto and docs/reference/api-guide.md)?

Kubernetes Pod Security Standards Baseline excludes it, and in Linux CAP_DAC_READ_SEARCH also gates open_by_handle_at(2), which can escape a mounted subdirectory on a shared CSI filesystem. DAC_OVERRIDE already covers file read and directory search permission checks, and starting stricter lets us relax later without a breaking change if we ever need to.
https://github.com/kubernetes/kubernetes/blob/70d3cc986aa8221cd1dfb1121852688902d3bf53/staging/src/k8s.io/pod-security-admission/policy/check_capabilities_baseline.go#L62-L76
https://github.com/torvalds/linux/blob/adc218676eef25575469234709c2d87185ca223a/fs/fhandle.c#L296-L299
https://github.com/torvalds/linux/blob/adc218676eef25575469234709c2d87185ca223a/fs/namei.c#L418-L446

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Agreed, removed DAC_READ_SEARCH from the allowlist, the proto definition, and the API guide.

It was originally included because the "Image files owned by other users" section from PR #2392 recommended adding it for read-only access. That documentation bullet now points to DAC_OVERRIDE instead.

Anish Ramasekar (@aramase) FYI since this updates that guidance: DAC_OVERRIDE is broader for file permission bypasses, but avoids exposing open_by_handle_at. If read-only bypass without write bypass is needed later, we can add DAC_READ_SEARCH back without a breaking change.

Comment thread cmd/atelet/oci.go
Comment on lines 74 to 78
for _, a := range caps.GetAdd() {
effective[a] = struct{}{}
if resources.AllowedAddCapabilities.Has(a) {
effective[a] = struct{}{}
}
}

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.

non-blocking: apivalidation.ValidateRunRequest and ValidateRestoreRequest already run ValidateCapabilities before prepareOCIBundles calls resolveCapabilities, so we don't need to silently filter unallowed entries here (per AGENTS.md, fail loudly instead of guarding against unreachable states or quietly dropping bad input).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Removed. resolveCapabilities is back to default - drop + add, leaving admission validation as the single enforcement gate.

Comment thread docs/reference/sandbox-classes.md Outdated
| Minimum `limits.memoryBytes` | None enforced by worker | `128 MiB` (`134217728` bytes) |
| Per-container limits (`containers[].resources.limits`) | Accepted in the API, ignored at runtime | Enforced via guest cgroups (`cpuMillicores` floored at 10) |
| `NET_RAW` capability | Stripped (`runsc` default `--net-raw=false`) | Granted when listed in `securityContext.capabilities.add` |
| Unprivileged `ping` | Not configured | `net.ipv4.ping_group_range=0 2147483647` |

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.

non-blocking: gVisor's netstack already allows unprivileged SOCK_DGRAM IPPROTO_ICMP and IPPROTO_ICMPV6 sockets for all users, so setting ping_group_range on microVM brings both classes to parity here (like ip_unprivileged_port_start=0 did for low ports).

We should probably drop this row and ## Capabilities (NET_RAW) below (and update the link in docs/reference/api-guide.md:378 to note both runtimes allow ICMP echo sockets without NET_RAW).
https://github.com/google/gvisor/blob/760b80785dc53311d4c87bd2de167525a99709c1/pkg/sentry/socket/netstack/provider.go#L56-L65

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Dropped the table row and the Capabilities (NET_RAW) section from sandbox-classes.md. The API guide note now clarifies that both runtimes permit ICMP echo sockets without NET_RAW.

if err != nil {
t.Fatal(err)
}
assertWorkerFilesystem(t, raw)

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.

sweet, #2791 working as intended

Comment thread internal/resources/validate.go Outdated

// ValidateCapabilities checks each capability name. allowAll is set for drop,
// which accepts "ALL" and any capability name. Add accepts only
// AllowedAddCapabilities.

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.

below in the switch, where we throw error name the individual capabilities the container needs, do we want to specify which ones are allowed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Unsupported capability names already return field.NotSupported with the full allowed list. The error for ALL in add now lists them as well:

'ALL' is not accepted; name the individual capabilities the container needs, from: AUDIT_WRITE, CHOWN, ...

@EItanya Eitan Yarmush (EItanya) 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.

🤖 AI-generated review. I looked through them to make sure they weren't crazy and they all seem worth looking at. (Comment left by Claude on behalf of Eitan Yarmush (@EItanya))

These are all optional, on top of what Ben already raised.

Optional follow-up, outside this diff: workerMicroVMCapabilities in cmd/atecontroller/internal/controllers/workerpool_apply.go keeps MKNOD "for guest device-node creation". With actors unable to add MKNOD and volumes mounted nodev, that worker grant may no longer be needed. FIFOs and sockets don't need CAP_MKNOD. I didn't check whether anything else in the guest creates device nodes over virtiofs, so this is a question for a separate PR.

Comment thread docs/reference/api-guide.md Outdated
- **`NET_RAW`.** gVisor removes it even when `add` lists it, and microVM grants it. See [Capabilities (`NET_RAW`)](sandbox-classes.md#capabilities-net_raw).
- **`ping` without `NET_RAW`.** `NET_RAW` can't be added. The microVM guest sets `net.ipv4.ping_group_range=0 2147483647`, so any user can open ICMP echo sockets. See [Capabilities (`NET_RAW`)](sandbox-classes.md#capabilities-net_raw).
- **Setuid binaries work.** Neither runtime sets `no_new_privileges`, as for a Kubernetes container with `allowPrivilegeEscalation: true`; gVisor runs with `--allow-suid`.
- **Image files owned by other users.** Image files keep the owner their image's layers record, as under containerd. A container running as its image's `USER` can use what the image gave that user, for example after `RUN chown -R app:app /app` and `USER app`. A container running as root, because its image sets no `USER` or `USER root`, has no `DAC_OVERRIDE` or `DAC_READ_SEARCH` by default. On a file or directory another user owns, it gets only what the permission bits grant to others. Add `DAC_OVERRIDE` if it writes to such files, or `DAC_READ_SEARCH` if it only reads them.

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.

Optional: if DAC_READ_SEARCH comes out of the allowlist per Ben's comment, this bullet still tells users to add it for read-only access. It should point to DAC_OVERRIDE instead.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed. The bullet now directs users to add DAC_OVERRIDE when reading or writing files owned by other image users.

// Containers cannot add NET_RAW, so let every group open ICMP echo
// sockets for ping.
if _, ok := spec.Linux.Sysctl["net.ipv4.ping_group_range"]; !ok {
spec.Linux.Sysctl["net.ipv4.ping_group_range"] = "0 2147483647"

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.

Optional: nothing in e2e checks that ping still works without NET_RAW. A small probe check that opens a SOCK_DGRAM/IPPROTO_ICMP socket as a non-root user would cover both runtimes, and would catch a later change to this sysctl.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Added. On both runtimes, /filesystem?icmp=1 re-execs the probe as UID 1000 and verifies opening a SOCK_DGRAM/IPPROTO_ICMP socket. On microVM this guards the ping_group_range sysctl, and on gVisor it covers netstack's default behavior.

Comment thread internal/resources/validate.go Outdated
// ValidateCapabilities checks each capability name. allowAll is set for drop,
// which accepts "ALL" and any capability name. Add accepts only
// AllowedAddCapabilities.
func ValidateCapabilities(fldPath *field.Path, caps []string, allowAll bool) field.ErrorList {

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.

Optional: allowAll now also picks between the add allowlist and the full set of known names, so it really means "this is drop". All four callers pass a literal. Splitting this into separate add and drop functions, or renaming the flag, would make the rules clearer at the call sites.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Split into ValidateAddCapabilities and ValidateDropCapabilities with a shared helper for syntactic validation (length and CAP_ prefix checks).

@destijl

Copy link
Copy Markdown
Collaborator Author

🤖 AI-generated review. I looked through them to make sure they weren't crazy and they all seem worth looking at. (Comment left by Claude on behalf of Eitan Yarmush (Eitan Yarmush (@EItanya)))

These are all optional, on top of what Ben already raised.

Optional follow-up, outside this diff: workerMicroVMCapabilities in cmd/atecontroller/internal/controllers/workerpool_apply.go keeps MKNOD "for guest device-node creation". With actors unable to add MKNOD and volumes mounted nodev, that worker grant may no longer be needed. FIFOs and sockets don't need CAP_MKNOD. I didn't check whether anything else in the guest creates device nodes over virtiofs, so this is a question for a separate PR.

🤖 Agreed that dropping MKNOD from the microVM worker looks viable now that containers cannot add it and the share is mounted nodev.

Because modifying workerMicroVMCapabilities alters the worker's bounding set (which virtiofsd's serving child inherits), I've tracked this in a dedicated issue: #3590 .

// when allowAll is set, which is the case for drop but not add.
func ValidateCapabilities(fldPath *field.Path, caps []string, allowAll bool) field.ErrorList {
// AllowedAddCapabilities are the capabilities a container may add.
var AllowedAddCapabilities = sets.New(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks, updated PR description

Comment thread internal/ocispec/ocispec.go Outdated
// that nest under one another. A volume at a system mount path, such as
// /tmp, replaces that mount in place.
// nodev: the runtime must not honor a device node on a volume. ShapeGVisor
// strips it, since runsc ignores it.

@fvoznika Fabricio Voznika (fvoznika) Nov 3, 2026 •

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.

runsc does not ignore it. nodev (and other options like nosuid) are applied to the host bind mount and should be used for both gVisor and microVM.

Some options are not yet supported inside gVisor's kernel, so they are not exposed to the sandboxed application, but the host bind mount does get the options.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ack, I'll update this comment to drop that sentence. LMK if there's anything else you see in review.

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.

Ah, please also adjust the code that is removing nodev in ShapeGVisor. It should not do that:

spec.Mounts[i].Options = slices.DeleteFunc(slices.Clone(spec.Mounts[i].Options), func(opt string) bool {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah the problem is that changing the gvisor mount options breaks restore for every existing gVisor snapshot. We could decide to do that as a pre-GA break, but it's pretty bad impact.

ShapeGVisor runs on restore as well, and runsc rejects a restore when the spec's mounts differ from the checkpoint's ("Mounts" does not match across checkpoint restore). The system mounts (/proc, /dev/shm, /sys, /tmp) already list nodev and lose it at that line, so dropping the strip changes the mounts of every gVisor actor, not just ones with volumes. I hit this on a test cluster when I first added nosuid,nodev on gVisor: actors suspended before the upgrade, and new actors from existing templates, all went to ACTOR_STATE_CRASHED.

Hence my question about how valuable it is to change in gVisor. It would have to be worth it to inflict that pain. To do this in a less breaking way we can support resuming with the previous mount options so the change isn't breaking. But that won't happen before GA.

Here's the AI description of how you'd need to recover if we made the breaking change:

Recovery / Migration Steps for Customers

  1. Pre-Upgrade Drain (Recommended to avoid state loss):
    • Resume all suspended gVisor actors before upgrading.
    • Allow active workloads to complete or flush state to durable external storage.
  2. Template Recreation:
    • Because ActorTemplate is immutable and cannot regenerate its golden snapshot in place, customers must recreate all gVisor templates.
    • Either deploy a new template version (e.g., my-template-v2), or delete and recreate the template to trigger a fresh golden snapshot run under the new mount configuration.
    • Update client workloads to reference the new template UID.
  3. Recovering Crashed / Suspended Actors:
    • RevertActor does not recover crashed actors: it reverts to the latest external snapshot, which still contains the old checkpoint mounts and will crash again on the next resume attempt.
    • Repointing an existing actor via UpdateActor --template also does not resolve it, because resume restores the actor's own checkpoint rather than the template's golden snapshot.
    • Customers must delete the affected actors (DeleteActor) and recreate them against the new template. Any in-memory state or local rootfs delta is lost.
    • The only way to recover in-memory state from an un-drained, pre-upgrade snapshot is to temporarily roll back the cluster to the previous Substrate release, resume the actors, and drain them cleanly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

OK since:

a) we're going to break snapshots whenever we make this change; and
b) we've already broken restores with another change

We should do this now to get more security and breaking changes out of the way before 1.0 final release. Updated code to pass through nodev.

DAC_READ_SEARCH also gates open_by_handle_at(2), which can escape a
mounted subdirectory on a shared filesystem, and DAC_OVERRIDE already
covers reads. Remove it from the add allowlist.

MicroVM volumes no longer get nosuid, matching containerd and gVisor,
so setuid binaries and file capabilities on volumes keep working. nodev
moves to the shared bind options (ShapeGVisor strips it, so gVisor specs
are unchanged), and the guest mounts the virtio-fs share nodev so its
submounts are covered too.

Split ValidateCapabilities into ValidateAddCapabilities and
ValidateDropCapabilities, list the allowed names when add has ALL, and
stop atelet from silently filtering adds that validation already
rejects.

The e2e probe now expects mknod to fail with EPERM in the guest and
checks on both runtimes that a non-root user can open an ICMP echo
socket.

Signed-off-by: Greg Castle <[email protected]>
ShapeGVisor stripped nodev from every mount on the assumption that runsc
ignores it. runsc applies nodev to the host bind mount, so keep it, and
gVisor and microVM now get the same nodev volumes and system mounts.

This changes the mounts in every gVisor spec, and runsc rejects a MEMORY
restore whose mounts differ from the checkpoint's.

Signed-off-by: Greg Castle <[email protected]>
@destijl Greg Castle (destijl) changed the title Restrict actor capabilities.add to an allowlist Restrict actor capabilities.add and make volume mounts nodev Nov 3, 2026
@destijl Greg Castle (destijl) changed the title Restrict actor capabilities.add and make volume mounts nodev Restrict actor capabilities.add and make mounts nodev Nov 3, 2026

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.

I have two tiny nits that can wait:

Comment on lines 78 to 82
for i := range spec.Mounts {
spec.Mounts[i].Options = slices.DeleteFunc(slices.Clone(spec.Mounts[i].Options), func(opt string) bool {
return opt == "nodev" || opt == "strictatime"
return opt == "strictatime"
})
}

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.

nit: now that nodev stays, this only strips strictatime, which only /dev sets (internal/ocispec/ocispec.go:114) and /dev is dropped just above, so this loop can go?

// ValidateCapabilities checks each capability name. "ALL" is accepted only
// when allowAll is set, which is the case for drop but not add.
func ValidateCapabilities(fldPath *field.Path, caps []string, allowAll bool) field.ErrorList {
// AllowedAddCapabilities are the capabilities a container may add.

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.

nit: can we note this is the Pod Security Standards Baseline set minus MKNOD? It tells the next person where the list came from before they add something back.

@BenTheElder
Benjamin Elder (BenTheElder) added this pull request to the merge queue Nov 4, 2026
Merged via the queue into agent-substrate:main with commit 17fa858 Nov 4, 2026
7 checks passed
Dmitry Berkovich (dberkov) pushed a commit to dberkov/substrate that referenced this pull request Nov 4, 2026
…e#3580)

Fixes [agent-substrate#3579](agent-substrate#3579)

### Summary

Restricts `securityContext.capabilities.add` to a hardened allowlist of
12 Linux capabilities and blocks dangerous capabilities such as `MKNOD`,
`SYS_ADMIN`, and `SYS_RAWIO` before 1.0.

In microVM sandboxes, granting `MKNOD` to a container running as root
allows it to create block device nodes matching the guest's
`virtio-pmem` root disk and directly write to the guest filesystem.
Other capabilities like `SYS_ADMIN` and `SYS_RAWIO` expose dangerous
guest kernel attack surface.

### Changes

* **Capability Allowlist:** `securityContext.capabilities.add` now only
permits 12 capabilities required for package installs, non-root user
switches, and standard container workflows:
  * `AUDIT_WRITE`
  * `CHOWN`
  * `DAC_OVERRIDE`
  * `FOWNER`
  * `FSETID`
  * `KILL`
  * `NET_BIND_SERVICE`
  * `SETFCAP`
  * `SETGID`
  * `SETPCAP`
  * `SETUID`
  * `SYS_CHROOT`
* **Admission Enforcement:** `ateapi` and `atelet` reject actor template
creations and updates specifying capabilities outside this allowlist.
* **Drop Unrestricted:** `securityContext.capabilities.drop` continues
to accept any capability name.
* **MicroVM Hardening:**
* Volume bind mounts are now mounted with `nodev`, and the microVM guest
mounts the virtio-fs share with `nodev` (covering automounted submounts
and rootfs). `ShapeGVisor` strips `nodev`, keeping gVisor specs
identical and preserving checkpoint/restore compatibility.
* MicroVM guest sysctl `net.ipv4.ping_group_range = "0 2147483647"` is
configured so standard unprivileged `ping` continues to work without
requiring `NET_RAW`.
* **Documentation & Schemas:** Updated `api-guide.md`,
`sandbox-classes.md`, and regenerated protobuf bindings (`ateapi.pb.go`,
`atelet.pb.go`).

### Breaking change: gVisor `MEMORY` snapshots taken before this release
won't restore

gVisor now gets `nodev` on volume and system mounts (`/proc`,
`/dev/shm`, `/sys`, `/tmp`), matching microVM. runsc applies it to the
host bind mount. `runsc restore` rejects a checkpoint whose mounts
differ from the new spec (`"Mounts" does not match across checkpoint
restore`), so gVisor `MEMORY` snapshots taken before the upgrade can't
be restored. rc.2 already breaks these snapshots with
[agent-substrate#3533](agent-substrate#3533) (tmpfs
`/dev/shm` and `/tmp`), so this adds no new upgrade step on top of that.
MicroVM actors are unaffected.

**What breaks:**

- Suspended gVisor actors whose latest snapshot is `MEMORY` fidelity
(the default) move to `ACTOR_STATE_CRASHED` on `ResumeActor`.
- New actors from gVisor templates created before the upgrade crash on
first resume, because the template's golden snapshot is an old `MEMORY`
checkpoint.

**Upgrade steps:**

1. **Before upgrading,** resume any suspended gVisor actor whose
in-memory state you need, and save that state to durable storage or let
its work finish.
2. **Re-create gVisor templates.** `ActorTemplate` is immutable, so
create a new template (e.g. `my-template-v2`), or delete and re-create
it, to build a new golden snapshot. Point clients at the new template.
3. **Replace crashed actors.** `RevertActor` won't help: it reverts to
the same pre-upgrade checkpoint, which crashes again. `UpdateActor
--template` won't either, because resume restores the actor's own
snapshot. Delete the actor and create it again from the new template.
Its in-memory state and its snapshot's rootfs and volume contents are
lost.

### Testing

* **Unit Tests:** Updated resource validation tests, parity tests, and
microVM spec tests (`go test ./...` passes in container).
* **Live GKE Testing (`sgke-rc1`):**
  * Verified template rejection when adding `MKNOD` or `SYS_ADMIN`.
* Verified unprivileged ICMP `ping` works in microVM guests without
`NET_RAW`.
  * Verified that pre-existing gVisor actor snapshots restore cleanly.
* **e2e Probe Coverage:**
  * Verified inside the guest that `mknod` fails with `EPERM`.
* Verified that unprivileged users can open `SOCK_DGRAM`/`IPPROTO_ICMP`
sockets across both runtimes.

Thanks to [Fabiano Fidêncio (@fidencio)](https://github.com/fidencio)
for the report on MKNOD that led us to make this change.

---------

Signed-off-by: Greg Castle <[email protected]>
(cherry picked from commit 17fa858)
Signed-off-by: dberkov <[email protected]>
Benjamin Elder (BenTheElder) pushed a commit that referenced this pull request Nov 4, 2026
Fixes [#3579](#3579)

### Summary

Restricts `securityContext.capabilities.add` to a hardened allowlist of
12 Linux capabilities and blocks dangerous capabilities such as `MKNOD`,
`SYS_ADMIN`, and `SYS_RAWIO` before 1.0.

In microVM sandboxes, granting `MKNOD` to a container running as root
allows it to create block device nodes matching the guest's
`virtio-pmem` root disk and directly write to the guest filesystem.
Other capabilities like `SYS_ADMIN` and `SYS_RAWIO` expose dangerous
guest kernel attack surface.

### Changes

* **Capability Allowlist:** `securityContext.capabilities.add` now only
permits 12 capabilities required for package installs, non-root user
switches, and standard container workflows:
  * `AUDIT_WRITE`
  * `CHOWN`
  * `DAC_OVERRIDE`
  * `FOWNER`
  * `FSETID`
  * `KILL`
  * `NET_BIND_SERVICE`
  * `SETFCAP`
  * `SETGID`
  * `SETPCAP`
  * `SETUID`
  * `SYS_CHROOT`
* **Admission Enforcement:** `ateapi` and `atelet` reject actor template
creations and updates specifying capabilities outside this allowlist.
* **Drop Unrestricted:** `securityContext.capabilities.drop` continues
to accept any capability name.
* **MicroVM Hardening:**
* Volume bind mounts are now mounted with `nodev`, and the microVM guest
mounts the virtio-fs share with `nodev` (covering automounted submounts
and rootfs). `ShapeGVisor` strips `nodev`, keeping gVisor specs
identical and preserving checkpoint/restore compatibility.
* MicroVM guest sysctl `net.ipv4.ping_group_range = "0 2147483647"` is
configured so standard unprivileged `ping` continues to work without
requiring `NET_RAW`.
* **Documentation & Schemas:** Updated `api-guide.md`,
`sandbox-classes.md`, and regenerated protobuf bindings (`ateapi.pb.go`,
`atelet.pb.go`).

### Breaking change: gVisor `MEMORY` snapshots taken before this release
won't restore

gVisor now gets `nodev` on volume and system mounts (`/proc`,
`/dev/shm`, `/sys`, `/tmp`), matching microVM. runsc applies it to the
host bind mount. `runsc restore` rejects a checkpoint whose mounts
differ from the new spec (`"Mounts" does not match across checkpoint
restore`), so gVisor `MEMORY` snapshots taken before the upgrade can't
be restored. rc.2 already breaks these snapshots with
[#3533](#3533) (tmpfs
`/dev/shm` and `/tmp`), so this adds no new upgrade step on top of that.
MicroVM actors are unaffected.

**What breaks:**

- Suspended gVisor actors whose latest snapshot is `MEMORY` fidelity
(the default) move to `ACTOR_STATE_CRASHED` on `ResumeActor`.
- New actors from gVisor templates created before the upgrade crash on
first resume, because the template's golden snapshot is an old `MEMORY`
checkpoint.

**Upgrade steps:**

1. **Before upgrading,** resume any suspended gVisor actor whose
in-memory state you need, and save that state to durable storage or let
its work finish.
2. **Re-create gVisor templates.** `ActorTemplate` is immutable, so
create a new template (e.g. `my-template-v2`), or delete and re-create
it, to build a new golden snapshot. Point clients at the new template.
3. **Replace crashed actors.** `RevertActor` won't help: it reverts to
the same pre-upgrade checkpoint, which crashes again. `UpdateActor
--template` won't either, because resume restores the actor's own
snapshot. Delete the actor and create it again from the new template.
Its in-memory state and its snapshot's rootfs and volume contents are
lost.

### Testing

* **Unit Tests:** Updated resource validation tests, parity tests, and
microVM spec tests (`go test ./...` passes in container).
* **Live GKE Testing (`sgke-rc1`):**
  * Verified template rejection when adding `MKNOD` or `SYS_ADMIN`.
* Verified unprivileged ICMP `ping` works in microVM guests without
`NET_RAW`.
  * Verified that pre-existing gVisor actor snapshots restore cleanly.
* **e2e Probe Coverage:**
  * Verified inside the guest that `mknod` fails with `EPERM`.
* Verified that unprivileged users can open `SOCK_DGRAM`/`IPPROTO_ICMP`
sockets across both runtimes.

Thanks to [Fabiano Fidêncio (@fidencio)](https://github.com/fidencio)
for the report on MKNOD that led us to make this change.

---------

Signed-off-by: Greg Castle <[email protected]>
(cherry picked from commit 17fa858)
Signed-off-by: dberkov <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allowlist capabilities before 1.0

7 participants