Write x86 / x86-64 NASM assembly in the browser, hit Run, and get back real
compiled-and-executed output: stdout/stderr, exit code, execution time,
peak memory, a full syscall trace, and (optionally) a register dump
captured at the program's entry point -- all from code assembled with
nasm, linked with ld, and executed under qemu-user, inside a sandbox
hardened with gVisor, seccomp, and cgroups.
This is a from-scratch build of a teaching/portfolio tool for low-level systems programming: a real compile-and-run loop for assembly, with the kind of introspection (syscall trace, register dump) you'd normally only get from running a debugger locally. The interesting engineering is less "yet another code runner" and more:
- Getting QEMU's user-mode gdbstub and GDB to cooperate reliably,
which involved finding and fixing two genuinely non-obvious bugs (a
throwaway TCP probe killing QEMU's single-shot gdbstub session; GDB
hanging forever with no controlling terminal at all) -- see
backend/app/run.py's docstrings for both. - Building a seccomp profile from evidence, not guesswork -- every
syscall in
sandbox/docker/seccomp-profile.jsoncame from realstrace -f -coutput against the actual toolchain, with an automated test (backend/tests/test_seccomp_drift.py) that re-traces the pipeline and fails CI if the profile and reality diverge. It already caught one real gap once (see "What's been caught by testing" below). - A Redis-backed job queue decoupling HTTP request concurrency from sandboxed-execution concurrency, with a bounded worker pool so a burst of submissions can't spawn unbounded simultaneous sandboxes.
- Layered, justified hardening (gVisor + seccomp + cgroups + network
isolation + read-only rootfs), documented with what each layer actually
stops -- see
sandbox/docker/ARCHITECTURE.md.
backend/
app/
run.py the core pipeline: assemble -> link -> run+trace -> register dump -> JSON
executor.py swappable execution backend (local subprocess for dev / hardened container for prod)
queue.py Redis-backed job queue + bounded worker pool (optional, see below)
schemas.py Pydantic request/response models
main.py FastAPI routes: /api/health, /api/run, /api/jobs, /api/jobs/{id}
tests/ 40 tests against the REAL toolchain and REAL Redis -- nothing mocked
Dockerfile backend service image
requirements*.txt
frontend/
src/
App.jsx main state + layout
api.js backend client (sync + queue-polling paths)
monaco-nasm.js NASM syntax highlighting + dark theme for Monaco
components/ Header, Editor, OutputPanel + its four tabs (Output/Syscalls/Registers/Build Log)
src/**/__tests__/ 77 component + API client tests (vitest + React Testing Library)
sandbox/docker/
Dockerfile the execution sandbox image: nasm, ld, qemu-user, gdb, python3 -- nothing else
seccomp-profile.json syscall allowlist, built from strace evidence (see SECCOMP_NOTES.md)
run_sandboxed.sh the actual hardened `docker run` invocation: gVisor + seccomp + cgroups + network isolation
docker-compose.yml backend + sandbox wired together for production
ARCHITECTURE.md layer-by-layer: what each hardening layer actually stops
SECCOMP_NOTES.md per-syscall-group rationale and provenance
.github/workflows/ci.yml backend + frontend test suites, against real toolchain/Redis, on every PR
This runs the pipeline as a plain subprocess -- no container, no gVisor. Fine for development; do not expose this configuration to the public internet (see "Security posture" below).
Prerequisites: Python 3.12+, Node 22+, and nasm, binutils (ld),
qemu-user, gdb installed and on PATH.
Every setting has a working default baked into the code, so none of this
is required to get started -- but .env.example (in both backend/ and
frontend/) documents every environment variable either service reads,
with its real default value, in case you want to override something:
cp backend/.env.example backend/.env # optional, defaults work as-is
cp frontend/.env.example frontend/.env # optional, defaults work as-is# Backend
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
# Frontend (separate terminal)
cd frontend
npm install
npm run devOpen the Vite dev server URL, write some NASM, hit Run.
By default /api/run calls the executor directly and /api/jobs returns
503. To run with the Redis-backed queue instead:
redis-server --daemonize yes --port 6379
NASM_IDE_ENABLE_QUEUE=true \
NASM_IDE_REDIS_URL=redis://localhost:6379/0 \
uvicorn app.main:app --reload --port 8000The frontend detects this automatically via /api/health's
queue_enabled field and switches to the queue-polling path with no
other configuration needed.
# Backend -- needs nasm/ld/qemu-user/gdb/strace on PATH; Redis-dependent
# tests skip gracefully if Redis isn't reachable
cd backend
pip install -r requirements-dev.txt
pytest tests/ -v
# Frontend
cd frontend
npm testBoth suites are exercised against real systems, not mocks: the backend
tests run the actual toolchain and (when available) actual Redis; the
frontend tests render real components with real props and assert on real
DOM output. .github/workflows/ci.yml runs both on every push/PR with a
Redis service container provided automatically.
The dev setup above (LocalSubprocessExecutor) executes submitted code as
a plain host process. For anything internet-facing, switch to
ContainerExecutor, which runs each submission inside an ephemeral,
locked-down container:
docker build -f sandbox/docker/Dockerfile -t nasm-ide-sandbox:latest backend/
docker compose -f sandbox/docker/docker-compose.yml up --buildThis requires gVisor installed and registered as the runsc Docker
runtime on the host -- see sandbox/docker/run_sandboxed.sh's header
comment for the one-time setup steps, and sandbox/docker/ARCHITECTURE.md
for what each hardening layer (gVisor, seccomp, cgroups, network
isolation, read-only rootfs) is actually responsible for stopping.
Honest caveat: the sandbox image has been built and run for real
against a live Docker daemon -- not just reviewed -- and the full pipeline
(assemble, link, run, syscall trace, register dump) was verified working
correctly inside the actual container. What remains specifically
unverified is the gVisor/runsc runtime itself: that requires a real
Linux host with appropriate kernel support that wasn't available while
building this, so --runtime=runsc has never actually been exercised,
only reviewed. Verify that specific piece on your target host before
relying on it for untrusted traffic. See sandbox/docker/ARCHITECTURE.md's
"What's genuinely still unverified" section for the complete, current
list -- including two real, previously-undetected Dockerfile bugs that a
real docker build caught and a real Docker test corrected, one of which
would have completely broken the register-dump feature in production.
LocalSubprocessExecutor(dev default): no isolation beyond normal OS process boundaries. Fine for trusted local use; not for the public internet.ContainerExecutor(production): gVisor + seccomp + cgroups + no network + read-only rootfs + non-root user. Seesandbox/docker/ARCHITECTURE.mdfor the full breakdown.- The backend's own rate limiter is in-memory and per-process -- fine for
a single instance, not sufficient across multiple replicas (see the
comment in
backend/app/main.pyfor what to swap in). - The job queue's worker pool size (
NASM_IDE_WORKER_POOL_SIZE, default 4) is the real cap on concurrent sandboxed executions in production -- size it to what your host can actually run simultaneously under gVisor.
A few examples worth keeping visible, since they're the reason this project leans so heavily on real execution over assumed-correct code:
- The seccomp profile was missing
mkdir,epoll_create1,close_range, andgetsockname-- all fromrun.py's own Python process, not the external tools, and missed by the original manual strace pass because it only traced nasm/ld/qemu/gdb, never the orchestrating script itself. Found bytests/test_seccomp_drift.py. requirements.txtwas missingredis, despitequeue.pyimporting it -- would have broken on a firstpip installanywhere except a machine that happened to already have it. Found by actually running a fresh virtualenv install.- QEMU's user-mode gdbstub treats its listening socket as single-shot: a routine "probe the port, then connect for real" pattern silently kills the debug session before GDB gets to connect. Found by adding logging and noticing qemu had already exited.
- GDB in batch mode hangs indefinitely with no controlling terminal at
all (not just no TTY on stdin specifically) -- fixed by allocating a
real pseudo-terminal via Python's
ptymodule. sandbox/docker/Dockerfile'sCOPY --from=builder /usr/lib/python3* /usr/lib/line built successfully and looked correct, but silently brokegdbcompletely: copying two glob-matched directories (python3andpython3.12) into a shared destination flattens their contents directly into that destination rather than preserving them as named subdirectories, so/usr/lib/python3.12/ended up missing entirely. GDB's embedded Python interpreter failed at startup withModuleNotFoundError: No module named 'encodings'--gdb --versionexited 1 with zero output, which would have completely broken the register-dump feature, the entire reason GDB is in this image. Caught only once a real Docker daemon became available partway through this project and the image was actually built and its tools actually run, not merely reviewed. (An earlier pass of this README claimed a different line in the sameCOPYblock -- theld*symlink glob -- was broken, based on simulating the copy withcp -Poutside Docker. That simulation used the wrong tool for the question and the conclusion was wrong: a realdocker buildproved Docker'sCOPYcorrectly dereferences that symlink chain on its own. Corrected here rather than quietly fixed, since a wrong conclusion confidently documented is still wrong, and the corrected understanding is the more useful lesson.)
None of these would have been caught by code review alone.
MIT.
