GitHub Action that runs CI commands inside a sandboxed container with network restrictions and credential isolation. Designed for repositories where PRs may come from untrusted sources -- such as a coding agent like Airut.
Standard GitHub Actions runners give workflow steps full outbound network access and expose repository secrets as environment variables. This means a malicious PR that modifies test scripts or build steps can exfiltrate secrets to an external server. Sandbox Action prevents this by:
- Restricting network access to an allowlist of permitted hosts and paths
- Masking credentials with surrogate values that the network proxy swaps for real secrets only on matching outbound requests, so the code inside the container never sees real credential values
- Isolating execution in a container with
--cap-drop=ALLandno-new-privileges
Before using this action, you MUST ensure all three security requirements are met. Failure to do so undermines the sandbox and may expose secrets.
Restrict workflow file modifications. The token used to push branches must lack the
workflowscope, or a repository ruleset must prevent modifications to.github/workflows/. Otherwise, untrusted code can push a workflow that runs outside the sandbox.Protect the base branch and restrict the workflow trigger. The workflow must trigger only on PRs targeting a protected branch (e.g.,
branches: [main]). Sandbox configuration is loaded from the base branch; if the base branch is unprotected, a PR author could push malicious configuration to it.Do not add steps after this action. After sandbox execution, the workspace is tainted -- untrusted code had write access to
.git/and all files. Any subsequent step (git commands, artifact uploads, scripts) risks executing attacker-controlled code outside the sandbox.
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main] # MUST target only protected branches
jobs:
test:
runs-on: ubuntu-latest
steps:
# This must be the ONLY step -- nothing after it
- uses: airutorg/sandbox-action@v0
with:
command: 'uv sync && uv run pytest'
pr_sha: ${{ github.event.pull_request.head.sha }}- Installs
uv, Python, andairut-sandboxon the host - Checks out the base branch (trusted sandbox configuration)
- Fetches the PR commit on the host (no GitHub credentials needed in sandbox)
- Restores cached container images (or builds and caches them on first run)
- Runs your command inside
airut-sandbox: container isolation, network allowlisting, and masked credentials (surrogate tokens that the proxy replaces with real values only on matching outbound requests)
The PR code runs only inside the container. Sandbox configuration
(.airut/sandbox.yaml, .airut/container/Dockerfile,
.airut/network-allowlist.yaml) always comes from the trusted base branch.
| Input | Required | Default | Description |
|---|---|---|---|
command |
Yes | CI command to run inside the sandbox (after PR checkout) | |
pr_sha |
Yes | PR commit SHA to check out and test | |
merge |
No | true |
Merge PR into base branch before running (like GitHub's default behavior) |
airut_version |
No | from VERSION |
Airut version (0.15.0 for PyPI, main for GitHub HEAD) |
sandbox_args |
No | --verbose |
Additional arguments for airut-sandbox run |
cache |
No | true |
Enable image caching across CI runs |
cache-version |
No | "" |
Arbitrary string to force cache invalidation |
cache-max-age |
No | 168 |
Maximum image age (hours) before forced rebuild |
When merge is true (the default), the container starts on the base branch
and runs git merge --no-edit <sha> to create a temporary merge commit. This
matches GitHub Actions' default pull_request checkout behavior and tests the
code as it would exist after merging. Set to false to check out the PR commit
directly instead.
Your repository needs:
.airut/container/Dockerfile-- container image (Python, uv, tools).airut/sandbox.yaml(optional) -- env vars, masked secrets, resource limits.airut/network-allowlist.yaml(optional) -- required ifnetwork_sandbox: true
The network allowlist does not need to include your repository's GitHub URL. The action fetches the PR SHA on the host before entering the sandbox.
This file controls what the container receives. It lives on the default branch and is reviewed by humans before taking effect.
# .airut/sandbox.yaml
# Environment variables (non-sensitive only)
env:
CI: "true"
PYTHONDONTWRITEBYTECODE: "1"
# Network sandbox (enabled by default)
network_sandbox: true
# Masked secrets — container gets surrogates, proxy swaps for real values
# only on matching hosts. Prevents credential exfiltration.
masked_secrets:
GH_TOKEN:
value: !env GH_TOKEN
scopes: ["api.github.com", "*.githubusercontent.com"]
headers: ["Authorization"]
# Resource limits
resource_limits:
memory: "4g"
cpus: 2
timeout: 600Pass secrets from GitHub Actions via env: on the action step:
- uses: airutorg/sandbox-action@v0
with:
command: 'uv sync && uv run pytest'
pr_sha: ${{ github.event.pull_request.head.sha }}
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}The !env tags in sandbox.yaml resolve from the runner's environment
variables (set by the workflow env: block). If a referenced variable is
missing, airut-sandbox exits with code 125 (fail-closed).
The credential mechanism is determined by what the target service supports:
| Mechanism | When to use | How it works |
|---|---|---|
| Signing credentials | Services that use SigV4/SigV4A (AWS, R2, MinIO...) | Proxy re-signs requests; real keys stay in proxy, never forwarded |
| Masked secrets | Token-based APIs (GitHub, Anthropic, etc.) | Proxy substitutes real tokens into outbound requests to scoped hosts |
pass_env |
Non-sensitive values (CI flags, locale) | Real value visible inside container |
With both signing credentials and masked secrets, real credentials never enter the container — the container only sees surrogates (random placeholder strings that are not usable outside the sandbox). The difference is what happens at the proxy:
- Signing credentials: Real keys never leave the proxy. The proxy uses them to compute request signatures and forwards only the signature — the secret key is never sent on the wire. Even the target host never sees the raw credential.
- Masked secrets: The proxy substitutes the real token into the outbound request toward the scoped host. The target host receives the real credential, which means it could in theory reflect the value back (e.g., in error messages or response bodies).
The choice between the two is driven by what the target service supports. Use signing credentials for any service that accepts SigV4/SigV4A and masked secrets for token-based APIs.
If network_sandbox: true (the default), the container's outbound HTTP(S)
traffic is restricted to .airut/network-allowlist.yaml. The allowlist does
not need to include the repository's own GitHub URL -- the action fetches
the PR SHA on the host before entering the sandbox.
See the Airut network sandbox documentation for the allowlist format and examples.
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: airutorg/sandbox-action@v0
with:
command: |
uv sync
uv run scripts/ci.py --verbose --timeout 0
pr_sha: ${{ github.event.pull_request.head.sha || github.sha }}
sandbox_args: '--verbose'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}- Container runtime: podman (included on GitHub-hosted
ubuntu-latest) or docker - Network: host needs internet for checkout, uv install, and image builds
The base branch is trusted. Sandbox configuration (Dockerfile, network allowlist, masked secret definitions, resource limits) is loaded from the base branch checkout on the host. The PR is untrusted -- it runs inside the sandbox where network access is restricted and credentials are masked.
This requires two external controls that the action cannot enforce itself:
-
Workflow files must be immutable to the PR author. The push token must lack the
workflowscope, or a repository ruleset must block changes to.github/workflows/. Without this, the PR author can push a workflow that bypasses the sandbox entirely. -
The base branch must be protected, and the workflow must only trigger on PRs targeting protected branches. If the workflow triggers on PRs to unprotected branches, the PR author can push malicious
.airut/config to the base branch before the workflow runs.
The action is fail-secure: if any setup step fails (installation error, missing container runtime, fetch failure), the workflow exits non-zero. There is no fallback to unsandboxed execution.
For the full trust model, detailed security requirements (PAT scope configuration, branch protection setup, push rulesets), and residual risk analysis, see the Airut CI sandbox security guide.
This action must be the last step in the job. After sandbox execution, the
workspace is tainted -- untrusted PR code had write access to all files
including .git/. A malicious PR could install git hooks, modify .git/config,
or replace binaries. Any subsequent workflow step that touches the workspace or
runs git commands risks executing attacker-controlled code outside the sandbox.
If post-sandbox operations are needed (e.g., uploading test artifacts), they must run in a separate job that does not share the tainted workspace.
By default, the action caches built container images across CI runs using
actions/cache. This eliminates redundant image builds on ephemeral runners,
saving ~50 s per CI run.
Two images are cached independently:
- Repo image: Your tools and dependencies. Cache key includes the Dockerfile content hash.
- Proxy image: The network sandbox proxy. Cache key includes a hash of the proxy package files.
All cache operations run before the sandbox executes untrusted code. No steps run after the sandbox. Cache keys are content-addressed, so cached images are always consistent with the current configuration.
To disable caching (e.g., for debugging image builds):
- uses: airutorg/sandbox-action@v0
with:
command: 'uv sync && uv run pytest'
pr_sha: ${{ github.event.pull_request.head.sha }}
cache: 'false'To force cache invalidation (e.g., after urgent security patches), bump
cache-version:
- uses: airutorg/sandbox-action@v0
with:
command: 'uv sync && uv run pytest'
pr_sha: ${{ github.event.pull_request.head.sha }}
cache-version: '2'When a sandboxed CI command fails due to blocked network requests, use the
sandbox_args input to enable live network logging. This streams every DNS
query, allowed request, and blocked request to the job log in real time:
- uses: airutorg/sandbox-action@v0
with:
command: 'uv sync && uv run pytest'
pr_sha: ${{ github.event.pull_request.head.sha }}
sandbox_args: '--verbose --network-log-live'The --network-log-live flag prints each network event to stderr as it happens,
prefixed with [net]:
[net] DNS A pypi.org -> 10.199.1.100
[net] allowed GET https://pypi.org/simple/requests/ -> 200
[net] BLOCKED GET https://evil.com/exfiltrate -> 403
You can also save the full network log to a file for later inspection (e.g., as
a CI artifact) by adding --network-log:
- uses: airutorg/sandbox-action@v0
with:
command: 'uv sync && uv run pytest'
pr_sha: ${{ github.event.pull_request.head.sha }}
sandbox_args: '--verbose --network-log-live --network-log /tmp/network.log'Available network debugging flags (passed via sandbox_args):
| Flag | Effect |
|---|---|
--network-log-live |
Stream network activity to stderr during execution |
--network-log FILE |
Save network activity log to FILE |
--verbose |
Enable INFO-level sandbox logging |
--debug |
Enable DEBUG-level logging (implies --verbose) |
The default sandbox_args is --verbose. When you override it, include
--verbose explicitly if you still want sandbox-level informational logs
alongside the network log.
| Ref | Installs from | Use case |
|---|---|---|
@v0 |
PyPI (latest 0.x.y) | Stable |
@v0.15.0 |
PyPI (airut==0.15.0) |
Pinned |
@main |
GitHub (airut repo, HEAD) | Development |