Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
74ed662
sandbox: command sandbox infrastructure (Python + Rust) — PR 1
ericeil Jul 14, 2026
ab32015
run-confined: probe Landlock via the crate's public API
ericeil Jul 14, 2026
d79d216
docs/command-sandbox: correct what --probe reports
ericeil Jul 14, 2026
eb76f6c
sandbox: close io_uring network bypass and other boundary gaps
ericeil Jul 20, 2026
9f769e4
sandbox: close x32-ABI seccomp bypass
ericeil Jul 20, 2026
f6af1ae
Update doc and comment
ericeil Jul 20, 2026
0860e34
ci: escape suite on the production kernel (AL2023 6.1) via QEMU
ericeil Jul 20, 2026
9d90a90
ci: snapshot repo with git archive, not tar of the workspace
ericeil Jul 20, 2026
bbfe84f
ci: bump checkout/upload-artifact to v5 (node24)
ericeil Jul 20, 2026
6f965c1
TEMP (DO NOT MERGE): intentional sandbox bug to verify CI catches reg…
ericeil Jul 20, 2026
16d8a0f
Revert "TEMP (DO NOT MERGE): intentional sandbox bug to verify CI cat…
ericeil Jul 20, 2026
638a6ff
sandbox: move run-confined launcher into an opt-in compose overlay
ericeil Jul 20, 2026
67ebc80
run-confined: parse argv with clap; adopt coreutils exec-wrapper exit…
ericeil Jul 22, 2026
dfadba8
sandbox/command: type observation payload, simplify path confinement
ericeil Jul 22, 2026
3c8cf78
sandbox/config: strongly type from_env overrides via SandboxArgs
ericeil Jul 22, 2026
a1d3bea
sandbox: discover providers via entry points instead of a runtime reg…
ericeil Jul 22, 2026
fde19ee
sandbox: decouple backend_spec from the run-confined dialect
ericeil Jul 22, 2026
f4da22f
sandbox: make provider availability probe async
ericeil Jul 22, 2026
da5d7e8
sandbox: model Availability as a tagged union, not (bool, reason)
ericeil Jul 22, 2026
6fee6b8
sandbox: make SandboxProvider.name read-only, drop @runtime_checkable
ericeil Jul 22, 2026
42c418b
sandbox/recipes: minor cleanups
ericeil Jul 22, 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
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ build/
dist/
.idea/
.vscode/

# Cargo build output — the image's sandbox-builder stage recompiles run-confined;
# copying host target/ (large) would only bloat the build context.
**/target/
82 changes: 82 additions & 0 deletions .github/scripts/sandbox_vm_provision.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# Runs INSIDE the QEMU Amazon Linux 2023 (kernel 6.1) guest — see
# .github/workflows/sandbox-escape-6.1.yml. Provisions a minimal toolchain and
# runs the sandbox escape suite against the *production* kernel version.
#
# Why the guest at all: Landlock + seccomp are kernel-mediated, and a container
# shares the host kernel — so the only faithful way to test run-confined on the
# prod 6.1 kernel is to boot that kernel (docs/command-sandbox.md §8). On 6.1 the
# suite exercises the 6.1 contract specifically: Landlock FS enforced, network is
# seccomp-only (no Landlock net rules < 6.7), and scopes are absent (< 6.12, so
# the signal / abstract-UDS asserts self-skip). The x32 deny-mirror is asserted
# regardless (§11 item 2).
#
# Kept dependency-light on purpose: the escape suite imports only stdlib +
# composer.sandbox.* (all stdlib) + pytest, so we install just pytest via uv and
# put the repo on PYTHONPATH — no project build, no numpy/psycopg/langchain. We
# pass --noconftest so tests/conftest.py (which imports those heavy deps) is not
# collected; the suite's fixtures are all in-module.
set -euo pipefail

REPO="${1:?usage: sandbox_vm_provision.sh <repo-dir>}"
JUNIT="${REPO}/sandbox-escape-junit.xml"

section() { printf '\n=== %s ===\n' "$*"; }

section "Guest kernel — this is what the sandbox is actually tested against"
uname -srm
# The behavior of run-confined is version-gated; record it so a CI artifact shows
# exactly what protected the run (not just pass/skip).
KREL="$(uname -r)"
if [ -r "/boot/config-${KREL}" ]; then
X32="$(grep -E '^CONFIG_X86_X32_ABI' "/boot/config-${KREL}" || echo 'CONFIG_X86_X32_ABI not set')"
elif [ -r /proc/config.gz ]; then
X32="$(zcat /proc/config.gz | grep -E '^CONFIG_X86_X32_ABI' || echo 'CONFIG_X86_X32_ABI not set')"
else
X32="kernel config not available in guest"
fi
echo "x32 ABI: ${X32}"
echo "(The x32 deny-mirror is asserted by the suite regardless — this only records"
echo " whether the bypass was ever live on this kernel build.)"

section "Toolchain: gcc (linker + rustc's cc), git"
sudo dnf -y -q install gcc git tar >/dev/null

section "Rust (rustup, minimal profile) — builds run-confined + compiles the malicious probe"
if ! command -v rustc >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain stable
fi
# shellcheck disable=SC1091
source "${HOME}/.cargo/env"
rustc --version

section "uv (isolated pytest env on Python 3.12 — AL2023 ships 3.11)"
if ! command -v uv >/dev/null 2>&1; then
curl -LsSf https://astral.sh/uv/install.sh | sh
fi
export PATH="${HOME}/.local/bin:${PATH}"
uv --version

section "Build run-confined (release)"
cargo build -p run-confined --release --manifest-path "${REPO}/rust/Cargo.toml"
export RUN_CONFINED_BIN="${REPO}/rust/target/release/run-confined"

section "Landlock probe on the 6.1 kernel (drives fail-closed available())"
# Non-fatal: the suite's own skip-guard depends on this, but we want the output.
"${RUN_CONFINED_BIN}" --probe || {
echo "!! run-confined --probe reported Landlock NOT enforcing on this kernel."
echo "!! On a real 6.1 kernel this should succeed (Landlock floor is 5.13)."
exit 3
}

section "Escape suite against kernel ${KREL}"
export PYTHONPATH="${REPO}"
# --no-project: don't build AutoProver; --with: ephemeral pytest env.
# --noconftest: skip tests/conftest.py's heavy imports (fixtures here are in-module).
uv run --no-project --python 3.12 \
--with 'pytest>=9.0' --with 'pytest-asyncio>=1.3' \
pytest --noconftest -v \
--junitxml="${JUNIT}" \
"${REPO}/tests/test_sandbox_escape.py"
177 changes: 177 additions & 0 deletions .github/workflows/sandbox-escape-6.1.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
name: sandbox-escape (kernel 6.1)

# Validates the RunCommand sandbox (rust/run-confined) on the PRODUCTION kernel
# version — Amazon Linux 2023, kernel 6.1 (docs/command-sandbox.md §8).
#
# Landlock + seccomp are kernel-mediated and a container shares the host kernel,
# so GitHub's ubuntu-latest runner (a newer, unpinned kernel) cannot test the 6.1
# contract. We therefore boot the real AL2023 6.1 cloud image under QEMU/KVM and
# run tests/test_sandbox_escape.py inside it. On 6.1 the suite asserts the FS,
# env, network (seccomp-only — no Landlock net < 6.7) and x32 vectors are denied,
# and self-skips the scope vectors (Landlock scopes need 6.12).

on:
pull_request:
paths:
- "rust/run-confined/**"
- "rust/Cargo.toml"
- "rust/Cargo.lock"
- "composer/sandbox/**"
- "tests/test_sandbox_escape.py"
- ".github/workflows/sandbox-escape-6.1.yml"
- ".github/scripts/sandbox_vm_provision.sh"
push:
branches: [master]
paths:
- "rust/run-confined/**"
- "composer/sandbox/**"
- "tests/test_sandbox_escape.py"
- ".github/workflows/sandbox-escape-6.1.yml"
- ".github/scripts/sandbox_vm_provision.sh"
# Weekly: catch AL2023 dropping the 6.1 image or drift in the latest 6.1 build.
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:

concurrency:
group: sandbox-escape-6.1-${{ github.ref }}
cancel-in-progress: true

jobs:
escape-suite-kernel-6_1:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
# Override to pin an exact build for reproducibility; default resolves the
# latest published AL2023 *6.1* KVM image (job fails loudly if 6.1 is gone).
AL2023_KVM_DIR: "https://cdn.amazonlinux.com/al2023/os-images/latest/kvm/"
SSH_PORT: "2222"
SSH: "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -i vmkey -p 2222 ec2-user@127.0.0.1"
steps:
- name: Check out repo
uses: actions/checkout@v5
# graphcore submodule not needed: the escape suite is stdlib + composer.sandbox.
with:
submodules: false

- name: Enable KVM on the runner
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
test -e /dev/kvm && echo "KVM available"

- name: Install QEMU + cloud-image tooling
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
qemu-system-x86 qemu-utils ovmf cloud-image-utils genisoimage openssh-client

- name: Download + verify the AL2023 6.1 KVM image
run: |
set -euo pipefail
base="${AL2023_KVM_DIR%/}/"
img="$(curl -fsSL "$base" \
| grep -oE 'al2023-kvm-[^"]*kernel-6\.1-x86_64\.xfs\.gpt\.qcow2' \
| head -1)"
if [ -z "$img" ]; then
echo "::error::No kernel-6.1 AL2023 KVM image found under $base"
echo "AL2023 may have dropped 6.1 as a published image. Pin AL2023_KVM_DIR"
echo "to a versioned dir that still ships kernel-6.1, or move the prod floor."
exit 1
fi
echo "Image: $img"
curl -fsSL -o al2023.qcow2 "${base}${img}"
curl -fsSL -o SHA256SUMS "${base}SHA256SUMS"
# SHA256SUMS lists "<hash> <filename>"; verify our file matches.
awk -v f="$img" '$2==f {print $1" al2023.qcow2"}' SHA256SUMS | sha256sum -c -
# Room for rustup + build artifacts (cloud-init grows the fs on boot).
qemu-img resize al2023.qcow2 +15G

- name: Build cloud-init seed (SSH key injection)
run: |
set -euo pipefail
ssh-keygen -t ed25519 -N "" -f vmkey -q
cat > user-data <<EOF
#cloud-config
ssh_authorized_keys:
- $(cat vmkey.pub)
EOF
cat > meta-data <<EOF
instance-id: sandbox-escape-6-1
local-hostname: al2023-6-1
EOF
cloud-localds seed.iso user-data meta-data

- name: Boot the VM
run: |
set -euo pipefail
# OVMF path differs across ubuntu releases; pick whichever exists.
for c in /usr/share/OVMF/OVMF_CODE_4M.fd /usr/share/OVMF/OVMF_CODE.fd; do
[ -r "$c" ] && OVMF_CODE="$c" && break
done
for v in /usr/share/OVMF/OVMF_VARS_4M.fd /usr/share/OVMF/OVMF_VARS.fd; do
[ -r "$v" ] && OVMF_VARS_SRC="$v" && break
done
cp "$OVMF_VARS_SRC" vars.fd
echo "Firmware: $OVMF_CODE"
qemu-system-x86_64 \
-enable-kvm -cpu host -smp 4 -m 8192 -machine q35 \
-drive if=pflash,format=raw,unit=0,readonly=on,file="$OVMF_CODE" \
-drive if=pflash,format=raw,unit=1,file=vars.fd \
-drive file=al2023.qcow2,if=virtio,format=qcow2 \
-drive file=seed.iso,if=virtio,format=raw \
-netdev user,id=n0,hostfwd=tcp::${SSH_PORT}-:22 \
-device virtio-net-pci,netdev=n0 \
-display none -serial file:serial.log -daemonize

- name: Wait for SSH
run: |
set -euo pipefail
for i in $(seq 1 60); do
if $SSH true 2>/dev/null; then
echo "SSH up after ~$((i*5))s"
$SSH 'sudo cloud-init status --wait || true'
exit 0
fi
sleep 5
done
echo "::error::VM did not become reachable over SSH in time"
echo "----- serial.log (tail) -----"; tail -n 100 serial.log || true
exit 1

- name: Copy repo into the VM
run: |
set -euo pipefail
# git archive => a clean snapshot of tracked source only. Do NOT tar the
# workspace: by this step it holds the *running* VM's al2023.qcow2 (QEMU is
# actively writing it) plus seed.iso / serial.log / repo.tgz, which trips
# "file changed as we read it". Tracked files are all the suite needs
# (composer/, tests/, rust/, pyproject.toml, uv.lock, .github/scripts).
git archive --format=tar.gz -o repo.tgz HEAD
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-i vmkey -P "$SSH_PORT" repo.tgz ec2-user@127.0.0.1:/home/ec2-user/
$SSH 'mkdir -p /home/ec2-user/autoprover && tar xzf repo.tgz -C /home/ec2-user/autoprover'

- name: Run the escape suite on kernel 6.1
run: |
set -euo pipefail
$SSH 'bash /home/ec2-user/autoprover/.github/scripts/sandbox_vm_provision.sh /home/ec2-user/autoprover'

- name: Collect results
if: always()
run: |
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-i vmkey -P "$SSH_PORT" \
ec2-user@127.0.0.1:/home/ec2-user/autoprover/sandbox-escape-junit.xml . 2>/dev/null || true

- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v5
with:
name: sandbox-escape-6.1
path: |
sandbox-escape-junit.xml
serial.log
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ local/
test_scenarios/**/certora/
test_scenarios/**/out/
test_scenarios/**/lib/forge-std

# Rust build output (the run-confined command-sandbox workspace). Matches the
# **/target/ pattern the .dockerignore uses.
**/target/
61 changes: 61 additions & 0 deletions composer/sandbox/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Run local commands, optionally confined to an unprivileged in-kernel sandbox.

A backend-agnostic home for the ``RunCommand`` execution primitive
(:func:`run_local_command`) and the swappable sandbox seam
(``docs/command-sandbox.md``). It lives outside ``rustapp`` so **any** backend —
Rust-IoC *or* Python — can run untrusted native code (a compiler, a fuzzer)
confined: no network, no inherited secrets, only its own inputs on disk.

- :mod:`composer.sandbox.policy` — the tool-agnostic seam: :class:`SandboxPolicy`
(confinement intent), :class:`SandboxProvider` (maps policy+command → a
:class:`LaunchSpec`), the ``none`` passthrough, and the fail-closed helpers.
Providers are declared as ``composer.sandbox_providers`` entry points and resolved
by :meth:`composer.sandbox.config.SandboxConfig.resolve_provider`.
- :mod:`composer.sandbox.launcher` — the ``run-confined`` launcher provider
(Landlock + seccomp), wired in as the ``launcher`` ``composer.sandbox_providers``
entry point and loaded lazily; the *seam* deliberately never imports a mechanism.
- :mod:`composer.sandbox.command` — :func:`run_local_command`, the single choke
point that materializes files into a workdir and runs a command there.
"""

from composer.sandbox.command import (
DEFAULT_TIMEOUT_S,
NOT_FOUND_EXIT,
CommandResult,
UnsafePath,
run_local_command,
)
from composer.sandbox.config import SandboxConfig
from composer.sandbox.policy import (
Availability,
LaunchSpec,
NoneProvider,
Reason,
SandboxPolicy,
SandboxProvider,
SandboxUnavailable,
ensure_available,
)
from composer.sandbox.recipes import DEFAULT_ENV_PASSTHROUGH, rust_build_policy

__all__ = [
# command runner
"run_local_command",
"CommandResult",
"UnsafePath",
"DEFAULT_TIMEOUT_S",
"NOT_FOUND_EXIT",
# sandbox seam
"SandboxPolicy",
"SandboxProvider",
"LaunchSpec",
"Availability",
"Reason",
"NoneProvider",
"SandboxUnavailable",
"ensure_available",
# config + recipes
"SandboxConfig",
"rust_build_policy",
"DEFAULT_ENV_PASSTHROUGH",
]
Loading
Loading