Skip to content

feat: migrate the run action's sandbox to runc with seccomp supp#175

Draft
dash14 wants to merge 21 commits into
mainfrom
feat/run-action-runc-migration
Draft

feat: migrate the run action's sandbox to runc with seccomp supp#175
dash14 wants to merge 21 commits into
mainfrom
feat/run-action-runc-migration

Conversation

@dash14

@dash14 dash14 commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

run's sandbox (run-isolated.sh) isolates the user's command via runc (the OCI reference runtime) instead of hand-rolled unshare/nsenter/setpriv namespace/mount/capability handling. Namespaces, capabilities, mounts, uid/gid, and a seccomp filter are all declared in an OCI config.json and enforced by runc itself, including native support for joining the proxy container's pre-existing network namespace and uid/gid dropping without a user namespace. runc is also already indirectly trusted by buildcage's own explicit/transparent build engines via moby/buildkit.

How the sandbox is built

  • isolated-exec.js builds config.json starting from runc spec's own defaults and patches in: root.path pointing at a bind-mounted copy of the host's / (read-only — every real host mount point is forced individually read-only outside workdir/home/tmp/writable, since root.readonly alone only covers the top-level rootfs mount), the network namespace entry pointed at the netns run-isolated.sh wires a veth into, fully cleared capabilities plus noNewPrivileges, the step's real environment, and a seccomp filter.
  • The seccomp filter is Docker's own default profile (moby/profiles), resolved into OCI form against an empty capability set (matching the sandbox) by a small Go tool, gen-seccomp-profile. It blocks unshare(CLONE_NEWUSER), the io_uring_* family, and the rest of Docker's default deny-list.
  • runc and gen-seccomp-profile are distributed as part of the proxy image (runc as a checksum-pinned official release binary) and extracted onto the runner host at run action startup — gen-seccomp-profile specifically must run natively on the host rather than docker exec'd, since its output depends on the real host's kernel/architecture. Both extractions, plus runc spec and the resolved seccomp profile, are cached per proxy image (digest-keyed, under a shared tmp directory) and reused across every run: step in the same job; the runc binary handed to each run is always a fresh copy into that run's own scratch directory, keeping the shared cache itself out of any single run's rootfs snapshot.
  • run-isolated.sh re-execs itself into a fresh, private mount namespace (unshare --mount --propagation private) before doing any of its own mount/netns work, so its rootfs bind-mount and netns file are invisible to, and unaffected by, every other run: step running concurrently on the same host.
  • A two-hop setpriv --pdeathsig=KILL chain (run-isolated.shrunc run → the sandboxed command) ties the sandboxed process's life to run-isolated.sh's own, since runc run's own process sits between the two — SIGKILL-ing run-isolated.sh tears down the whole tree instead of leaving the sandboxed command running as an orphan.
  • Cleanup of the rootfs bind-mount is defense-in-depth at two layers: run-isolated.sh's own trap unmounts it on exit, and isolated-exec.js's withScratchDir force-detaches (lazy unmount) anything still mounted under its scratch directory before deleting it, retrying on transient EBUSY.

Tests and tooling

  • Integration tests cover seccomp enforcement, die-with-parent, and concurrent run: steps (run/test/integration-test-*.sh, wired into test_sandbox_integration).
  • The Mac dev loop (run/dev/) builds runc/gen-seccomp-profile directly into its own image rather than extracting them via a mounted Docker socket, keeping it scoped to testing run-isolated.sh itself.
  • renovate.json tracks the pinned RUNC_VERSION in both the proxy image and the dev-loop image.
  • docs/security.md/docs/development.md/docs/reference.md describe the runc-based implementation; the previously-documented "No seccomp profile" Known Limitation is resolved.

dash14 added 8 commits July 19, 2026 09:35
Adds docker cp extraction of runc and gen-seccomp-profile onto the runner
host, runs gen-seccomp-profile natively to resolve the seccomp filter, and
builds the full OCI Runtime Spec (config.json) in JS from runc's own
`runc spec` baseline. run-isolated.sh itself isn't wired up to consume
these yet.
…priv

Replaces the hand-rolled unshare placeholder namespace, setpriv privilege
drop, and mountinfo-walking read-only remount with runc run against the
OCI bundle isolated-exec.js now builds. run-isolated.sh's own job shrinks
to what runc can't do itself: wiring the sandbox netns's veth into the
proxy's sandbox0 bridge, and bind-mounting the host's own / for runc's
rootfs. Verified end-to-end (network isolation, capability/seccomp
enforcement, uid/gid drop, read-only filesystem policy) by driving the
built run/dist/main.cjs against a real proxy container as a non-root
user.
Ties the sandboxed process's life to run-isolated.sh's via a two-hop
setpriv --pdeathsig=KILL chain (run-isolated.sh -> runc run -> sandboxed
command), since `runc run`'s own process sits between them. Closes the
same SIGKILL-orphaning gap bwrap's --die-with-parent would have, without
needing a new binary -- confirmed util-linux's setpriv already supports
--pdeathsig. Verified empirically: killing run-isolated.sh directly tears
down the whole chain.
…loop

integration-test-seccomp.sh verifies unshare -U and io_uring_setup are
actually blocked (the two CVE classes docs/security.md names) while
ordinary syscalls still work. integration-test-die-with-parent.sh SIGKILLs
run-isolated.sh mid-run and verifies the whole sandboxed process tree dies
with it rather than surviving as an orphan. Both wired into
test_sandbox_integration, so CI's existing test_sandbox job picks them up.

Also updates the Mac-friendly dev loop (run/dev/Dockerfile,
compose.sandbox-dev.yaml, Makefile's test_sandbox_mode) for
run-isolated.sh's new runc-based interface: runc and gen-seccomp-profile
are now built directly into the dev image (mirroring docker/Dockerfile),
and a new build-test-bundle.sh stands in for isolated-exec.js's
buildOciConfig so the dev loop can keep testing run-isolated.sh directly
without needing the Docker socket mounted in. The dev-runner service also
needs the same writable-/sys/fs/cgroup treatment the "builder" service
already has, for the same reason (runc's own cgroup management) -- not
needed on the real runner host this dev loop stands in for.
Rewrites docs/security.md's Isolation Mechanisms (seccomp filter,
die-with-parent, capabilities/mounts now enforced by runc via config.json
rather than setpriv/manual remounts) and removes the now-resolved 'No
seccomp profile' limitation. Rewrites docs/development.md's Run Action
Internals to match (runc/gen-seccomp-profile extraction, OCI bundle
construction in isolated-exec.js, the two-hop pdeathsig chain) and updates
the Sandbox Dev Loop section and directory tree listing. Flips
docs/reference.md's seccomp NOTE from 'not applied' to 'applied'.
grep -E 'CapEff|NoNewPrivs' /proc/self/status only checked that those
fields existed (always true for any process), not that CapEff was
actually 0000000000000000 or NoNewPrivs was 1 -- a capability-drop
regression would have passed silently. Switches to grep -q with the exact
expected value, matching integration-test-defaults.sh's style. Also adds
a basic read-only-filesystem-policy check (/tmp writable, /etc not),
which wasn't exercised by this dev-loop smoke test at all before.
@dash14
dash14 force-pushed the feat/run-action-runc-migration branch from b9540cf to 6c20888 Compare July 19, 2026 04:04
dash14 added 13 commits July 19, 2026 13:07
gen-seccomp-profile's execFileSync (and the subsequent
generateBaseOciSpec/writeRunScript/buildOciConfig calls) weren't wrapped
in a SandboxError like the adjacent docker-cp extraction, so a failure
surfaced as an unclassified exception instead of a clean ::error:: with a
diagnosable code. Verified with a proxy image whose gen-seccomp-profile
deliberately fails: the run now fails with a clean OCI_CONFIG_BUILD_FAILED
SandboxError and cleanup (docker compose down) still runs.
Extract EXTRA_MASKED_PROC_PATHS into run/scripts/extra-masked-proc-paths.json
so isolated-exec.js and run/dev/build-test-bundle.sh (its bash/jq stand-in
for the Mac dev loop) read the same list instead of hand-duplicating it,
removing a silent-drift risk between the two. isolated-exec.js picks it up
via a static JSON import (resolved relative to the source file, so it works
both unbundled in tests and inlined by rollup in run/dist/main.cjs);
build-test-bundle.sh reads it through a new read-only bind mount in
compose.sandbox-dev.yaml.

Also drops the now-redundant Set-based dedup of maskedPaths -- the doc
comment already establishes the two lists don't overlap on runc v1.5.1.
…faults

run-isolated.sh silently defaulted these to the values main.js already
always passes explicitly, leaving two sources of truth for the same
addressing scheme with no way to tell which one is actually in effect.
Make them required arguments like --proxy-pid, and always pass them from
isolated-exec.js's runIsolated (they were already always truthy in
practice). Also fixes the Mac dev loop's Makefile invocation, which was
relying on the now-removed --target-ip default.
run/dev/Dockerfile duplicates the same pinned ARG RUNC_VERSION/RUNC_SHA256_*
block as run/docker/Dockerfile (see docs/development.md), but the
customManager only watched the latter -- renovate could bump one Dockerfile's
runc pin without the other, silently drifting the Mac dev loop's runc out of
sync with production.
pgrep -f could in principle match more than one process (e.g. a leftover
process from a previous failed run, or the pattern incidentally matching an
unrelated process's argv), in which case BASH_PID became a multi-line
string and kill -9 "$BASH_PID" failed with an invalid-argument error --
masking the real assertion behind a confusing, unrelated failure. Count the
matches explicitly and fail with a clear diagnostic if it's not exactly 1.
Each `run:` step redid docker cp of runc/gen-seccomp-profile, ran runc spec,
and re-resolved the seccomp profile from scratch, even though all three are
identical for repeated steps against the same proxy image. Cache them under
a digest-keyed directory in tmpdir(), reused across every `run:` step in the
same job (and across jobs on a self-hosted runner). Concurrent steps racing
to populate the same cache entry stage in a private directory and atomically
rename it into place; a losing racer just discards its own staging dir and
reuses the winner's.

The runc binary itself is still copied into each run's own scratch dir
before use, rather than executed directly from the shared cache: pointing
run-isolated.sh's `mount --rbind /` at a binary that lives in a long-lived,
shared location meant it stayed present (and busy) inside every
concurrently running sandbox's rootfs snapshot, not just the one using it.
A cheap local copy keeps the expensive work cached while keeping the
actually-executed file private per run, same as before this cache existed.

Also drops gen-seccomp-profile's JSON pretty-printing -- pure overhead for
output that's only ever machine-parsed.
integration-test-concurrent.sh intermittently failed (reproduced independent
of any other change on this branch, including on the pre-existing baseline)
with EBUSY on rmSync's final rmdir of the rootfs bind-mount directory, even
though /proc/self/mountinfo no longer listed it as a mountpoint at the time
of the error. Root-caused via a debug build that dumped mountinfo and
retried on failure: multiple `run:` steps running concurrently each
recursively bind-mount "/" into their own scratch dir, and since they all
live under the same shared /tmp, each step's snapshot unavoidably nests a
copy of every other concurrently-running step's own rootfs bind-mount.
Under that load, the kernel's teardown of an already lazily-detached mount
can lag behind rmSync's rmdir by a short, bounded window -- every observed
failure resolved on the very next retry after a brief wait.

Retrying rmSync a bounded number of times on EBUSY specifically (not other
errors) closes this: 6/6 concurrent runs passed in verification, versus a
50-75% failure rate before this change on the same environment.
run-isolated.sh's `mount --rbind /` staging previously ran directly in the
one mount namespace shared by every concurrently running `run:` step on the
host. Since each step's own rootfsBindDir lives under the same /tmp, each
step's rbind snapshot unavoidably captured a nested copy of every other
concurrently running step's own rootfs tree too -- whatever happened to
exist under "/" at that instant. This didn't break sandbox isolation itself
(runc still creates its own further-nested namespace for the sandboxed
process regardless), but it was the root cause of the intermittent EBUSY
race in withScratchDir's cleanup that the previous commit's retry papered
over: concurrent steps' unmount/rmdir could race against each other's
staging.

Re-exec into `unshare --mount --propagation private` before doing anything
else, the same staging pattern tools like bubblewrap use: every mount this
script creates from that point on (the rootfs bind-mount, the netns file)
is invisible to, and unaffected by, every other concurrent invocation from
the moment it's created. Verified empirically -- the cross-instance mount
nesting no longer occurs at all, and 8/8 concurrent runs passed.

No `--fork` is used, so the re-exec replaces the current process in place;
this script's PID (and /proc/self/cmdline, matched by
integration-test-die-with-parent.sh's pgrep) stays the same throughout.
The now-redundant `mount --make-rprivate "$ROOTFS_BIND_DIR"` is removed --
every mount created under an already-private parent is private by default.

The previous commit's EBUSY retry stays in place as defense-in-depth.
Removed comparisons to the previous unshare-based implementation
("matching the previous X", "mirrors the previous Y", "verified
empirically", debugging-journal-style asides) that don't help a reader of
the current code, and shortened a few overly long rationale blocks down to
the load-bearing facts. Also fixed two comments that had gone stale from
later commits in this same branch: main.js's gateway/dns default-values
comment (the defaults were removed from run-isolated.sh in an earlier
commit) and gen-seccomp-profile/main.go's "once per run step" claim (no
longer true once getOrPopulateBootstrapCache started caching it). No
behavior change -- comment-only, confirmed by an unchanged run/dist/main.cjs
after rebuilding.
run-isolated.sh runs as root (via sudo -n), but this test's own process
runs as the unprivileged CI user -- a plain kill -9 against it fails with
EPERM, since only root or a same-UID process can signal it. This has
failed in every real CI run since the test was added (caught locally only
because verification always ran as root, masking the UID mismatch).
Verified with a non-root user in a throwaway container: kill -9 now
succeeds and the assertion passes across repeated runs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant