Development Guide
August 16, 2026 · View on GitHub
This document covers local development, testing, and the project structure of isolated-run.
Local Usage
You can run isolated-run's proxy locally without GitHub Actions using Docker Compose and Make.
GitHub Actions inputs use lowercase names (e.g.,
proxy_mode), while environment variables for local usage use uppercase (e.g.,PROXY_MODE).
Sandbox Dev Loop (mac-friendly)
The action's own isolation mechanism (run-isolated.sh) uses Linux-only primitives (ip netns,
nsenter, runc) that can't run natively on macOS. make setup_sandbox_dev /
make test_sandbox_dev instead drive it from inside a container with pid: host (see
dev/Dockerfile and docker/compose.sandbox-dev.yaml), which can see the proxy container's
PID/netns via /proc — close enough to the real "runner host + separate proxy container"
arrangement for day-to-day iteration, though it can't validate the container-boundary parts of
production (see Action Internals below). runc and gen-seccomp-profile are
built directly into the dev-loop image (mirroring docker/Dockerfile) rather than
docker cp-extracted from the proxy image at runtime, so the dev loop doesn't need the Docker
socket mounted in just to reach a sibling container; dev/build-test-bundle.sh stands in for
lib/sandbox/oci-config.ts's buildOciConfig to build a minimal OCI bundle for the smoke test.
CI's test_sandbox_* e2e jobs run run-isolated.sh directly on the runner host instead, matching
production exactly — treat those as the final word on whether a change actually works, not this
dev loop.
make setup_sandbox_dev # start the proxy + dev-loop runner container
make test_sandbox_dev # run a sample isolated command and verify allow/block + capability drop
Testing
make test_unit_core # core library unit tests (src/core)
make test_unit_sandbox # action's own unit tests (src/lib, src/main.ts)
make test_unit_qjs # dual-runs the acl module's tests under real QuickJS in a throwaway image
make test_unit # all of the above
make test_sandbox_dev is the dev-loop end-to-end check described above; make test_integration_sandbox_linux drives dist/main.cjs directly for checks that don't depend on
the real action wrapper (see test/integration-test-*.sh) and is what CI's test_sandbox job in
test-integration.yml runs. The CI-only test_sandbox_* end-to-end jobs (real runner host, no
nested container) are described in Action Internals below.
Formatting & Linting
Formatting, linting, and type-aware linting are handled by vp (Vite+),
installed globally on your machine like pnpm/corepack rather than through pnpm exec:
curl -fsSL https://vite.plus | bash # macOS/Linux
# Windows: irm https://viteplus.dev/install.ps1 | iex
The project pins its own toolchain version via the vite-plus devDependency in package.json
(the same way packageManager pins pnpm) — the globally installed vp binary detects and
delegates to that pinned version automatically, so plain vp ... commands are reproducible
without going through pnpm exec.
vp check # format + lint + type-aware lint (read-only; what CI runs)
vp check --fix # same, but auto-fixes format/lint issues in place
vp lint --fix
vp fmt --write
vp run typecheck (tsc) remains the authoritative full type check; vp check's type-aware
linting (via oxlint-tsgolint) catches a subset of type-driven issues fast but doesn't replace it.
Running vp install (in place of pnpm install) automatically sets up a pre-commit hook — via
the prepare script — that formats and lints your staged files (vite.config.ts's staged
config) before each commit, auto-fixing and re-staging what it can.
Action Internals
This section walks through how the action isolates one run: command, in the order it actually
happens. For the user-facing behavior and threat model, see Security Details and
the README.
- Verify the proxy image's provenance and resolve a digest-pinned image ref (
src/main.ts). - Start a dedicated, throwaway proxy container for this one step (
src/main.ts).- The container provides network-layer isolation only (iptables
REDIRECT/DROPrules, dnsmasq, HAProxy) — no build daemon. - Every
docker composeinvocation passes an explicit-p <containerName>, so concurrentrun:steps in the same job (GitHub Actions'background/wait/parallelkeywords) never share an implicit, directory-derived Compose project — otherwise one step'sup/downcould recreate or tear down another step's still-running container.
- The container provides network-layer isolation only (iptables
- Extract
runcand a seccomp-profile generator onto the runner host (src/lib/sandbox/runc-bootstrap.ts).- Both ship inside the proxy image and are pulled onto the host via
docker cp, then run natively there — notdocker exec'd — since the seccomp profile's content depends on the real host's kernel and architecture. - Extracted fresh into this step's own scratch directory on every invocation (no shared,
cross-step/cross-job cache), so each
run:step is fully independent and everything extracted is torn down with the scratch directory afterward.
- Both ship inside the proxy image and are pulled onto the host via
- Build an OCI runtime bundle (
config.json) describing the sandbox (src/lib/sandbox/oci-config.ts).- Starts from
runc's own default spec, then patches in: a root filesystem pointing at a not-yet-created bind-mount directory, made read-only (every real host mount point is forced individually read-only outside workdir/home/tmp/RUNNER_TEMP/writable, since the top-level read-only flag alone doesn't cover separate mount points); a network namespace reference to the netns created in the next step; all Linux capabilities cleared plus no-new-privileges; the step's real environment; and a seccomp filter resolved from Docker's own default profile, applied against an empty capability set to match the sandbox. - The writable exceptions are recursive bind-mounts (so legitimately nested mounts under them
stay visible). The
mount --rbind /rootfs is therefore staged under/var/tmp/buildcage— never one of the writable exceptions — so those recursive rbinds don't re-expose it as a second, writable copy of the whole host/inside the sandbox. Awritable:input naming that directory (or an ancestor of it) is rejected outright rather than silently accepted. The sandbox's real host view (its own/and every nested mount) is untouched and stays read-only outside the writable set.
- Starts from
- Stage the sandbox's network and filesystem as root, via
sudo -n(run-isolated.sh).- Re-execs itself into a fresh, private mount namespace before touching anything else, so the
mount work below is invisible to every other
run:step running concurrently on the same host. - Bind-mounts the host's own root filesystem onto a fresh directory to serve as the sandbox's
rootfs (a plain
pivot_rootcan't target the real root directly). Done before the network setup below, since it has no dependency on it and doing it first minimizes the gap between the mount-table snapshot the read-only patching above was computed from and this actually capturing the host's mount table. - Creates a network namespace and a veth pair, with one end moved into it (as
eth0) and the other moved into the proxy container's own netns, renamed tosandbox0, and given the proxy's fixed gateway address directly — no bridge, since this is always a 1:1 connection (one sandbox, one proxy) and a plain named interface is enough forinit-iptables's-i sandbox0rule (added at container startup) to match once this device appears later.
- Re-execs itself into a fresh, private mount namespace before touching anything else, so the
mount work below is invisible to every other
- Run the sandboxed command via
runc.- runc creates its own further-nested namespaces per
config.jsonand enforces every isolation guarantee declared there — capability drop, seccomp filter, read-only filesystem, network namespace. - A two-hop process-supervision chain ties the sandboxed process's life to the staging step
above: the process that starts
runcand, separately, the sandboxed command itself both die if their immediate parent does, so killing the staging step tears down the whole chain instead of leaving the sandboxed command running as an orphan.
- runc creates its own further-nested namespaces per
- Clean up once the command exits (
run-isolated.sh).- An exit trap tears the container down, unmounts the rootfs bind-mount, removes the veth, and deletes the network namespace.
- As a second layer of defense, anything still mounted under the run's own scratch directory is force-detached before that directory is deleted, in case the trap above didn't run to completion.
- Append this step's report to the Job Summary and stop the proxy container (
src/main.ts).- The report is built in-process on the runner:
src/lib/report.tsreads the container's own communication log viadocker exec ... cat, then the container is stopped. - If the whole process is killed before reaching this point, a fallback step reads the
container's identity back from job state and stops it anyway, and reclaims the step's scratch
directory — whose path it reconstructs deterministically from that same identity, then
force-detaches any surviving mount before deleting (
src/post.ts).
- The report is built in-process on the runner:
Local Development
Local testing of the action
Sigstore verification requires a real, published GHCR image, so the action normally can't run
against an unpublished branch or local changes. This repo's own CI (the test_sandbox_* jobs in
.github/workflows/test-e2e.yml) tests the real action end-to-end against a locally built image
instead, via a build-time-gated mechanism: BUILDCAGE_BUILD_TEST_HOOKS=1 vp run build compiles
dist/main.cjs where the BUILDCAGE_LOCAL_IMAGE_REF override is reachable. The override logic
lives in its own module (src/core/lib/provenance/local-image-override.ts), loaded only via a
dynamic import() gated by that build-time flag. Without the flag (i.e. every normal/committed
build), rolldown's own module-graph tree-shaking excludes that entire file from the bundle — it's
physically absent, not just unreachable. A CI check (unit_test job) additionally confirms a
normal build never contains a live runtime read of BUILDCAGE_BUILD_TEST_HOOKS in dist/,
guarding against a future refactor silently breaking that guarantee.
To exercise it locally:
- Build the image:
docker compose build proxy. BUILDCAGE_BUILD_TEST_HOOKS=1 vp run build- Run it with
BUILDCAGE_LOCAL_IMAGE_REF=<image ref from step 1>set (e.g. viaact, or by invokingnode dist/main.cjsdirectly with the relevantINPUT_*env vars — note the action's own isolation step still needs a real Linux host, so this only gets you past image verification, not a full local run on macOS). Never commit adist/main.cjsbuilt this way — runvp run buildagain (without the flag) before committing.
See security.md for more details.
Viewing Logs
# Communication logs from the locally-built proxy
docker compose logs proxy
# Real-time log monitoring
docker compose logs -f proxy
Log format:
[28/Feb/2026:10:15:30 +0000] buildcage [ALLOWED] "github.com:443" -
[28/Feb/2026:10:15:31 +0000] buildcage [BLOCKED] "malicious.com:443" not-allowed
[28/Feb/2026:10:15:32 +0000] buildcage [AUDIT] "npmjs.org:80" -
Fields: [timestamp] buildcage [status] "domain:port" reason
Makefile Commands
| Command | Description |
|---|---|
make help | Show available commands |
make setup_sandbox_dev | Start the action's proxy + mac-friendly dev-loop runner |
make test_sandbox_dev | Run a sample isolated command in the dev loop and verify isolation |
make test_unit | Run unit tests (core + sandbox + qjs) |
make test_unit_core | Run core library unit tests |
make test_unit_sandbox | Run the action's own unit tests |
make test_unit_qjs | Dual-run the acl module's tests under real QuickJS in a throwaway image |
make test_integration_sandbox_linux | Run the action's integration tests (needs BUILDCAGE_LOCAL_IMAGE_REF and a test-hook build of dist/main.cjs) |
Directory Structure
.
├── action.yml # Action entry (node24 → dist/main.cjs, dist/post.cjs)
├── src/ # Source (ESM)
│ ├── main.ts / post.ts # Start proxy, run isolated command, report, stop
│ ├── lib/ # Action-specific implementation: container, report, sudo-preflight,
│ │ # sandbox/ (OCI config, runc bootstrap, netns/mountinfo helpers)
│ └── core/ # Code shared with the isolated-run proxy image's QuickJS scripts
│ ├── lib/ # acl/ (rule parsing, dual-consumed by Node and QuickJS),
│ │ # actions/, docker/, provenance/ (Sigstore, OCI registry lookups,
│ │ # image ref resolution, local-image test-hook override), report/,
│ │ # log/, test/test-shim.ts (portable node:test-alike shim used by
│ │ # *.test.ts across Node and QuickJS alike)
│ └── scripts/ # QuickJS entry point (convert-rule.qjs.ts), run inside the built
│ # image (rolldown-bundled into /opt/buildcage/scripts/ at image
│ # build time — see rolldown.scripts.config.js); test/ is a qjs test
│ # runner, types/ is the qjs:std/qjs:os ambient type declaration
├── dist/ # Bundled output (rolldown → CommonJS); dist/qjs, dist/qjs-test are
│ # gitignored build-time scratch output, not committed
├── docker/ # Proxy image build context
│ ├── Dockerfile # alpine + haproxy/dnsmasq/iptables/s6-overlay + pinned runc +
│ │ # gen-seccomp-profile (Go)
│ ├── files/ # dnsmasq/haproxy config, s6 service definitions
│ ├── gen-seccomp-profile/ # Go module: derives a seccomp filter from Docker's default profile
│ ├── compose.action.yaml # Runtime compose file the action itself uses (verified,
│ │ # digest-pinned image ref) — distinct from the top-level
│ │ # compose.yaml below
│ └── compose.sandbox-dev.yaml # Mac dev-loop overlay (see dev/)
├── scripts/run-isolated.sh # netns/veth/rootfs-bind setup around `runc run`, invoked via
│ # `sudo -n` (see Action Internals)
├── test/ # assert-sandbox*.sh + integration-test-*.sh (capability/filesystem/
│ # seccomp/die-with-parent checks driving dist/main.cjs directly)
├── dev/ # Mac dev-loop-only Dockerfile + smoke-test.sh + build-test-bundle.sh
│ # (see docker/compose.sandbox-dev.yaml) — not used in production or CI
├── docs/ # development.md, security.md, self-hosting.md
├── compose.yaml # Docker Compose config for local dev (builds docker/Dockerfile;
│ # also what CI's test_sandbox/test_sandbox_* jobs build from)
└── Makefile # Operational commands
Troubleshooting
If you encounter issues, try reproducing the problem locally to get detailed logs:
-
Check logs:
docker compose logs proxy -
Run in audit mode to understand your command's network behavior:
make setup_sandbox_dev # or drive the action directly — see README.md -
Open an issue at github.com/buildcage/isolated-run/issues with:
- The Job Summary report (audit or restrict mode)
- The relevant
docker compose logs proxyoutput - Your workflow YAML (with secrets redacted)