Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
75018c8
feat: distribute runc and a seccomp profile generator in run's proxy …
dash14 Jul 19, 2026
ea4a4d5
feat: extract runc/gen-seccomp-profile and build the OCI bundle in JS
dash14 Jul 19, 2026
0d57234
feat: drive run-isolated.sh's sandbox via runc instead of unshare/set…
dash14 Jul 19, 2026
797bd4f
feat: add a die-with-parent equivalent via setpriv --pdeathsig
dash14 Jul 19, 2026
6f88433
test: add seccomp and die-with-parent integration tests, fix the dev …
dash14 Jul 19, 2026
4f2ad1c
docs: describe the runc-based sandbox and seccomp filter
dash14 Jul 19, 2026
be15390
fix: assert exact values in the dev-loop smoke test, not just presence
dash14 Jul 19, 2026
6c20888
fix: prevent rmSync from destroying host files and fix root.readonly'…
dash14 Jul 19, 2026
eaf75a0
fix: wrap OCI bundle construction in a SandboxError
dash14 Jul 19, 2026
f3e9152
refactor: share the extra masked /proc paths between JS and the dev loop
dash14 Jul 19, 2026
c9e954a
refactor: require --gateway/--dns/--target-ip instead of hardcoded de…
dash14 Jul 19, 2026
f1cc688
chore: track run/dev/Dockerfile's RUNC_VERSION with renovate too
dash14 Jul 19, 2026
c4fdc75
fix: guard against pgrep -f matching multiple run-isolated.sh processes
dash14 Jul 19, 2026
d201c6c
perf: cache the runc bootstrap (extraction/spec/seccomp) per proxy image
dash14 Jul 19, 2026
e719b78
docs: describe the runc bootstrap cache added in the previous commit
dash14 Jul 19, 2026
d86ba87
fix: retry the scratch-dir cleanup on transient EBUSY
dash14 Jul 19, 2026
b9faab4
fix: stage the rootfs bind-mount in a private mount namespace
dash14 Jul 19, 2026
8e49d87
docs: describe the private mount namespace staging from the previous …
dash14 Jul 19, 2026
a5ca62c
refactor: trim redundant comments in the runc migration's source files
dash14 Jul 19, 2026
a0d0e0d
fix: use sudo to kill run-isolated.sh in the die-with-parent test
dash14 Jul 19, 2026
9c05494
docs: rewrite Run Action Internals as a chronological walkthrough
dash14 Jul 19, 2026
7c1f459
refactor: extract the runc bootstrap into the run's own scratch direc…
dash14 Jul 19, 2026
385151e
fix: stage the sandbox rootfs outside every writable exception and re…
dash14 Jul 19, 2026
10a947a
fix: resolve setpriv to an absolute path for the sandboxed init
dash14 Jul 19, 2026
eb07b83
docs: clarify the run action's design goal and the Docker-in-sandbox …
dash14 Jul 19, 2026
fff49b2
test: cover $RUNNER_TEMP writability and nested-mount read-only enfor…
dash14 Jul 19, 2026
a0366f1
perf: bind-mount the sandbox rootfs before setting up its network
dash14 Jul 19, 2026
672bc6f
fix: force read-only real host mounts runc doesn't itself remount fresh
dash14 Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,12 @@ run_sandbox_mode: ## Start sandbox proxy + dev runner (mac-friendly dev loop)
test_sandbox_mode: ## Run a sample isolated command in the dev loop and verify isolation
@$(MAKE) run_sandbox_mode
@PROXY_PID=$$(docker inspect --format '{{.State.Pid}}' buildcage-proxy); \
docker compose -f compose.yaml -f run/compose.sandbox-dev.yaml exec sandbox-dev-runner \
run-isolated.sh --proxy-pid $$PROXY_PID --uid 1000 --gid 1000 \
--gateway 172.20.0.1 --dns 172.20.0.1 -- /usr/local/bin/smoke-test.sh
docker compose -f compose.yaml -f run/compose.sandbox-dev.yaml exec sandbox-dev-runner sh -c " \
set -e; \
build-test-bundle.sh --netns-name buildcage-sandbox-dev --script /usr/local/bin/smoke-test.sh --bundle /var/tmp/buildcage/dev-bundle; \
run-isolated.sh --proxy-pid $$PROXY_PID --runc /usr/local/bin/runc --bundle /var/tmp/buildcage/dev-bundle \
--container-id buildcage-sandbox-dev --netns-name buildcage-sandbox-dev --rootfs-bind-dir /var/tmp/buildcage/dev-bundle/rootfs \
--gateway 172.20.0.1 --dns 172.20.0.1 --target-ip 172.20.0.101"
@$(MAKE) clean_sandbox_mode

.PHONY: clean_sandbox_mode
Expand Down Expand Up @@ -173,6 +176,12 @@ test_sandbox_integration: ## Run the run action's integration tests (needs BUILD
@./run/test/integration-test-writable-dir.sh
@./run/test/integration-test-writable-disabled.sh
@./run/test/integration-test-defaults.sh
@./run/test/integration-test-seccomp.sh
@./run/test/integration-test-die-with-parent.sh
@./run/test/integration-test-fs-escape.sh
@./run/test/integration-test-runner-temp.sh
@./run/test/integration-test-nested-mount-readonly.sh
@./run/test/integration-test-non-runc-default-pseudofs-readonly.sh
@./run/test/integration-test-concurrent.sh

.PHONY: test_unit
Expand Down
24 changes: 24 additions & 0 deletions THIRD_PARTY_LICENSES
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ QuickJS

------------------------------------------------------------------------------

runc (used only by the `run` action's proxy image, docker/Dockerfile)
Website : https://github.com/opencontainers/runc
License : Apache License 2.0 (Apache-2.0)
Source : https://github.com/opencontainers/runc
Note : Official pre-built release binary, checksum-pinned. Extracted
onto the runner host at `run` action startup and used by
run-isolated.sh to execute the isolated command.

------------------------------------------------------------------------------

run/scripts/seccomp.json (used only by the `run` action)

This file is not part of the Docker image; it ships as part of the
action's own source and is loaded directly by run/src/lib/isolated-exec.js.
It is generated offline (not at image build time) from moby/profiles'
default seccomp profile, resolved against an empty capability set with
the exported `LoadProfile` function.

moby/profiles
License : Apache License 2.0 (Apache-2.0)
Source : https://github.com/moby/profiles

------------------------------------------------------------------------------

Note on GPL components (HAProxy, dnsmasq):

These components are installed from the Alpine Linux official package
Expand Down
168 changes: 82 additions & 86 deletions docs/development.md

Large diffs are not rendered by default.

15 changes: 8 additions & 7 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ during the audited run — mirroring the same example the `report` action genera
| `allowed_http_rules` | No | empty | HTTP allow rules (wildcard or regex, port required) |
| `allowed_ip_rules` | No | empty | IP address allow rules (wildcard or regex, port required) |
| `fail_on_blocked` | No | `true` | Fail the step if blocked connections are detected (restrict mode only; ignored in audit mode) |
| `writable` | No | empty | Additional writable directories (newline-separated), on top of `$GITHUB_WORKSPACE`, `$HOME`, and `/tmp` — see [Filesystem Access](#filesystem-access) below |
| `writable` | No | empty | Additional writable directories (newline-separated), on top of `$GITHUB_WORKSPACE`, `$HOME`, `/tmp`, and `$RUNNER_TEMP` — see [Filesystem Access](#filesystem-access) below |
| `label` | No | empty | Label appended to this step's Job Summary heading, e.g. `npm install` — useful to tell steps apart when `run` is used more than once in the same job |

Rule syntax is identical to `setup`'s — see [Rule Syntax](#rule-syntax) above.
Expand Down Expand Up @@ -288,10 +288,10 @@ itself instead of a BuildKit `RUN` step:

### Filesystem Access

Only `$GITHUB_WORKSPACE`, `$HOME`, and `/tmp` are writable by default — every other path is
remounted read-only for the duration of the `run` command. This closes off using the filesystem to
plant a payload for a later, non-sandboxed step in the same job (e.g. rewriting a binary earlier on
`$PATH`); it doesn't restrict what the command can *read* (see
Only `$GITHUB_WORKSPACE`, `$HOME`, `/tmp`, and `$RUNNER_TEMP` are writable by default — every other
path is remounted read-only for the duration of the `run` command. This closes off using the
filesystem to plant a payload for a later, non-sandboxed step in the same job (e.g. rewriting a
binary earlier on `$PATH`); it doesn't restrict what the command can *read* (see
[Known Limitations](./security.md#run-action) in Security Details).

If `run` needs to write somewhere else — a tool-specific cache directory, for example — list it
Expand All @@ -314,5 +314,6 @@ To disable the read-only restriction entirely, set `writable` to `/`:
> [!NOTE]
> `run` runs `run-isolated.sh` directly on the runner host (via `sudo -n`), so it requires a
> Linux runner with passwordless `sudo` — this is the default on GitHub-hosted `ubuntu-*` runners.
> It does not currently apply a seccomp profile, AppArmor/SELinux profile, or Landlock rules; see
> [Security Details](./security.md#run-action) for the full threat model and known limitations.
> It applies a seccomp filter derived from Docker's own default profile; it does not apply an
> AppArmor/SELinux profile or Landlock rules. See [Security Details](./security.md#run-action) for
> the full threat model and known limitations.
99 changes: 73 additions & 26 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,23 +207,50 @@ the Actions runner itself (rather than inside a BuildKit-managed OCI container),
unable to reach outside its sandbox by other means (escalating privileges, reaching the Docker
socket, or reading another process's memory).

The core design goal is to let you bolt network egress control onto an existing workflow step
without changing how the rest of that workflow already works. A step that configures AWS
credentials, an npm/yarn cache directory, or any other action keeps running exactly as it did
before `run` was introduced — `run` only wraps the specific command whose network access you want
restricted, rather than requiring the surrounding job to move into a differently-configured
environment. This is why UID/GID and `$HOME` are preserved rather than switched to a dedicated
sandbox account (see UID/GID preserved below): tools and caches that assume the runner's own
identity keep working unmodified.

### Isolation Mechanisms

The isolated command runs as an [OCI](https://github.com/opencontainers/runtime-spec) container
under [runc](https://github.com/opencontainers/runc) — the same reference container runtime
`explicit`/`transparent` already trust indirectly via `moby/buildkit` — rather than being wrapped
directly by `unshare`/`setpriv` on the runner host. `run-isolated.sh` only sets up what runc cannot:
wiring a veth pair into the proxy container's bridge, and bind-mounting the host's own `/` for
runc's rootfs (`pivot_root` can't target `/` itself). Everything else below is declared in an OCI
`config.json` and enforced by runc natively.

- **Network namespace**: the isolated command runs in its own network namespace, connected to the
proxy container's bridge via a veth pair — the same enforcement `transparent` mode
applies to Docker `RUN` steps (iptables `REDIRECT`/`DROP`, DNS redirect, SNI/Host allowlist).
- **Capability bounding set**: fully cleared (`setpriv --bounding-set=-all`) before the command
executes. This is what actually makes privilege escalation impossible — even if the command
invokes `sudo` or a setuid binary, there is no `CAP_NET_ADMIN`/`CAP_SYS_ADMIN`/etc. left in the
bounding set for it to acquire, regardless of the resulting effective UID.
applies to Docker `RUN` steps (iptables `REDIRECT`/`DROP`, DNS redirect, SNI/Host allowlist). runc
joins this namespace itself, driven by the OCI spec's `linux.namespaces` path — no wrapper needed.
- **Seccomp filter**: derived from Docker's own default seccomp profile (allowlist-based;
`moby/profiles`), resolved against an empty capability set to match the capability drop below —
so any syscall Docker's default profile only conditionally allows for a *held* capability is
excluded outright. This directly closes the gap historical `io_uring` and unprivileged
user-namespace-creation CVEs relied on: `unshare(2)`/`clone(2)` with `CLONE_NEWUSER` and the
`io_uring_*` syscall family are not in the resulting allowlist at all. Generated at `run` action
startup (not baked into the image at build time), since a handful of the profile's rules are
gated on the actual kernel version — see `run/docker/gen-seccomp-profile/main.go`.
- **Capability bounding set**: fully cleared (all five capability sets emptied in `config.json`)
before the command executes. This is what actually makes privilege escalation impossible — even
if the command invokes `sudo` or a setuid binary, there is no `CAP_NET_ADMIN`/`CAP_SYS_ADMIN`/etc.
left for it to acquire, regardless of the resulting effective UID.
- **`no_new_privileges`**: set as defense-in-depth alongside the capability drop, so setuid/setgid
binaries and file capabilities can't grant anything even in edge cases the bounding-set drop
binaries and file capabilities can't grant anything even in edge cases the capability drop
doesn't cover on its own.
- **Supplementary groups cleared**: `docker` and any other supplementary group membership is
dropped (`setpriv --clear-groups`). Runner users are typically members of the `docker` group,
which is equivalent to root — the Docker daemon will happily mount `/` into a new privileged
container for anyone who can reach its socket, regardless of that user's own capabilities. Group
membership is what gates that reach, not capabilities, so it has to be cleared independently.
dropped (the OCI spec's `process.user` carries no `additionalGids`). Runner users are typically
members of the `docker` group, which is equivalent to root — the Docker daemon will happily mount
`/` into a new privileged container for anyone who can reach its socket, regardless of that user's
own capabilities. Group membership is what gates that reach, not capabilities, so it has to be
cleared independently.
- **PID namespace**: the isolated command runs in its own PID namespace. This isn't just about
hiding other processes from `ps` — the Linux kernel structurally forbids a process from tracing
(`ptrace`) or reading `/proc/<pid>/mem` for any process outside its own PID namespace's lineage,
Expand All @@ -234,32 +261,52 @@ socket, or reading another process's memory).
choice: `actions/setup-node`-installed toolchains, `$GITHUB_WORKSPACE` file ownership, and
`$HOME`-based caches (`~/.npm`, `~/.cache`, etc.) all assume the runner's own UID, and switching
UID would break them. Isolation here comes entirely from the capability/group/namespace
mechanisms above, not from UID separation.
mechanisms above, not from UID separation. No user namespace is created for this either — that
would let the isolated command re-acquire a (namespace-local) root identity via the very
unprivileged-`CLONE_NEWUSER` primitive the seccomp filter above is specifically closing off.
- **Sensitive `/proc` paths masked**: `/proc/kcore`, `/proc/kallsyms`, `/proc/kmsg`,
`/proc/sysrq-trigger`, `/proc/timer_list`, and `/proc/keys` are bind-mounted over with `/dev/null`
inside the isolated mount namespace, closing off kernel-memory-adjacent information disclosure
paths that aren't already covered by the capability drop.
- **Filesystem read-only outside the workspace/home/tmp**: `$GITHUB_WORKSPACE`, `$HOME`, and `/tmp`
are bind-mounted onto themselves and kept writable; every other existing mount is remounted
read-only inside the isolated mount namespace. This closes off tampering with anything outside
those three paths — e.g. rewriting a binary earlier on `$PATH` to plant a payload for a later,
non-sandboxed step in the same job. The read-only remount is enforced, not best-effort: a
pseudo-filesystem that legitimately rejects a read-only remount (`proc`, `sysfs`, `cgroup2`,
etc.) is tolerated, but if any *real* filesystem cannot be made read-only the step fails closed
rather than running with it silently left writable. The `writable` input adds further paths to
the writable set for tools that need to write elsewhere (e.g. a cache directory); setting it to
`/` disables this restriction entirely — see
(the OCI spec's `linux.maskedPaths`, extending runc's own sensible defaults), closing off
kernel-memory-adjacent information disclosure paths that aren't already covered by the capability
drop.
- **Filesystem read-only outside the workspace/home/tmp**: `$GITHUB_WORKSPACE`, `$HOME`, `/tmp`, and
`$RUNNER_TEMP` are bind-mounted as writable exceptions on top of a read-only root (`root.readonly`
in `config.json`, applied by runc itself). This closes off tampering with anything outside those
paths — e.g. rewriting a binary earlier on `$PATH` to plant a payload for a later, non-sandboxed
step in the same job. The rest of the host filesystem, including nested mounts, stays fully
*visible* (read-only) so existing tools keep working; only writes are restricted. The writable
exceptions are recursive bind-mounts (preserving any legitimately nested mounts under them); the
sandbox's own `mount --rbind /` rootfs is staged under `/var/tmp/buildcage`, which is never one of
those writable exceptions, so that recursion doesn't re-expose it as a second, writable copy of
the whole host `/`. A `writable:` input naming that directory (or an ancestor of it) is rejected
outright — see [Known Limitations](#known-limitations-1) below. The `writable` input adds further
paths to the writable set for tools that need to write elsewhere (e.g. a cache directory); setting
it to `/` disables this restriction entirely — see
[Filesystem Access](./reference.md#filesystem-access) in Reference.
- **Die-with-parent**: the isolated command's life is tied to `run-isolated.sh`'s own via a two-hop
`setpriv --pdeathsig=KILL` chain (`run-isolated.sh` → `runc run` → the isolated command — `runc
run`'s own process sits between the two, so a single-hop guard wouldn't be enough). If
`run-isolated.sh` is killed outright (e.g. an out-of-memory kill lands on it specifically), the
whole sandboxed process tree is killed with it rather than surviving as an orphan.

### Known Limitations

- **No seccomp profile**: a seccomp-bpf filter would add defense-in-depth against kernel bugs
reachable via syscalls that don't require any capability at all (e.g. historical `io_uring` or
unprivileged user-namespace-creation CVEs) — this is tracked as future work, not yet implemented.
- **No AppArmor/SELinux/Landlock policy**: these would add path-level MAC restrictions on top of
the capability-based model above. Not applied today; the isolation mechanisms above already
close off the specific escape routes considered (privilege escalation, Docker-socket access,
cross-namespace ptrace/memory access).
- **`writable:` cannot name the sandbox's own scratch directory**: a `run:` step's `writable:` input
listing `/var/tmp/buildcage` (or an ancestor of it, e.g. `/var/tmp` or `/`) is rejected outright —
that directory holds the run's own `mount --rbind /` rootfs, and the writable exceptions are
recursive bind-mounts, so allowing it would recursively re-expose the whole host `/` inside the
sandbox as a second, writable copy. This is a misconfiguration guard against an operator-supplied
`writable:` value, not a defense against the isolated command itself (see
[Filesystem Access](./reference.md#filesystem-access) in Reference).
- **Docker cannot be used inside the isolated command**: supplementary groups (including `docker`)
are cleared before the command runs, so even where the Docker socket is visible through the
read-only host filesystem, the isolated command has no permission to use it. A `run:` step that
itself needs to invoke `docker` (build an image, run a container, etc.) cannot be wrapped by
`run`.
- **Credential retrieval is intentionally not blocked**: `run` restricts *where* the isolated
command can send network traffic and, since the filesystem is read-only outside
`$GITHUB_WORKSPACE`/`$HOME`/`/tmp`, *where* it can persist a payload — but not what it reads. A
Expand Down
8 changes: 8 additions & 0 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
],
"matchStrings": ["# renovate: datasource=(?<datasource>[^\\s]+) depName=(?<depName>[^\\s]+)\\nARG CNI_VERSION=(?<currentValue>v[\\d.]+)"]
},
{
"customType": "regex",
"managerFilePatterns": [
"/^run/docker/Dockerfile$/",
"/^run/dev/Dockerfile$/"
],
"matchStrings": ["# renovate: datasource=(?<datasource>[^\\s]+) depName=(?<depName>[^\\s]+)\\nARG RUNC_VERSION=(?<currentValue>v[\\d.]+)"]
},
{
"customType": "regex",
"managerFilePatterns": ["/\\.md$/"],
Expand Down
2 changes: 1 addition & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const configs = [
plugins: mainPlugins,
inlineDynamicImports: true,
},
{ input: "run/src/post.js", file: "run/dist/post.cjs", plugins: [] },
{ input: "run/src/post.js", file: "run/dist/post.cjs", plugins: [json()] },
];

export default configs.map(
Expand Down
10 changes: 10 additions & 0 deletions run/compose.sandbox-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,19 @@ services:
security_opt:
- seccomp=unconfined
- apparmor=unconfined
# runc needs to create/manage a cgroup for the sandboxed process, which
# needs a writable /sys/fs/cgroup -- read-only by default in a
# non-privileged container, same reasoning as the "builder" service in
# ../../compose.yaml. Not needed on the real GitHub Actions runner host
# this dev loop stands in for (root already has full cgroup access
# there without this).
cgroup: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
- ./run/scripts/run-isolated.sh:/usr/local/bin/run-isolated.sh:ro
- ./run/scripts/extra-masked-proc-paths.json:/etc/buildcage/extra-masked-proc-paths.json:ro
- ./run/dev/smoke-test.sh:/usr/local/bin/smoke-test.sh:ro
- ./run/dev/build-test-bundle.sh:/usr/local/bin/build-test-bundle.sh:ro
depends_on:
proxy:
condition: service_started
Expand Down
Loading
Loading