Gunnar

nordisk/heimdall

docs/SPEC.md

Heimdall: specification (v0.1, 2026-10-30)

Heimdall is a Rust sandbox for running untrusted work: parsing, verifying or scanning data that came from somewhere you do not trust. It is generic. Holger is the first user, then Gunnar, and tunnr/rusternetes can use it as well. No Go and no gVisor.

The core idea: the software is probed while it runs, and the kernel-call allowlist is generated from what it actually did. That profile is checked in, enforced at runtime, and re-checked in CI.

1. Goals and non-goals

Goals

Non-goals

2. Three layers, stacked

┌─ Layer 1: youki container ───────────────────────────────────┐
│  generated seccomp allowlist (default action KILL_PROCESS)   │
│  Landlock: only the job's in/ (read) and out/ (write)        │
│  no-new-privs, all capabilities dropped, user namespace,     │
│  read-only root, tmpfs /tmp, no network (except fetch jobs), │
│  cgroup v2 limits (cpu, memory, pids, io), rlimits           │
│                                                              │
│  Layer 2: the syscall broker (seccomp USER_NOTIF)            │
│   the few risky calls left (openat, socket, connect, …)      │
│   pause the worker; a Rust supervisor checks the call        │
│   against the job policy and answers or opens on its behalf  │
│                                                              │
│  ┌─ Layer 3: wasmtime (optional per job) ──────────────────┐ │
│  │  the parser/verifier compiled to wasm32: NO WASI fs/net │ │
│  │  bytes in through memory, verdict + files out           │ │
│  │  fuel + epoch interruption + memory limit + timeout     │ │
│  └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

3. The profile generator ("probe, then generate")

This is the part tunnr/rusternetes asked about. It is generic: profiles belong to each workload, the generator to Heimdall.

3.1 Workflow (a Build Thing / Runa pipeline step)

  1. Build the worker, release profile, for the target (x86_64 and aarch64; musl and glibc are separate profiles, because the libc changes the calls).
  2. Probe: run the worker's probe corpus (its integration tests plus a fuzz corpus that drives error paths, large inputs, timeouts and signal handling) under Heimdall's tracer in learning mode.
    • The tracer uses seccomp USER_NOTIF with a "log and allow" policy for every syscall number, in Rust. There is no strace dependency and no ptrace by default; ptrace is only a fallback for kernels before 5.0.
    • For each call it records: the syscall number, the arch, and the argument facts that matter. These are the socket domain/type/protocol, openat flags (O_RDONLY vs write/create), ioctl request numbers, mmap/mprotect protection bits (refuse PROT_EXEC|PROT_WRITE together), clone flags, prctl options and fcntl commands.
    • vDSO calls (clock_gettime and friends) never reach seccomp. That is fine, but they are noted.
  3. Merge and minimise: take the union over all probe runs, then reduce to the smallest rule set. That means exact argument matches where the observed values form a small set, masks for flag fields, and a bare allow only where arguments vary freely (read/write on already-open fds).
  4. Generate code:
    • profiles/<workload>/<arch>-<libc>.rs: a Rust const table the runtime compiles into a BPF filter (crate seccompiler, or Heimdall's own BPF builder);
    • the same profile as reviewable JSON (.json), for humans and diffs.
    • Default action: SECCOMP_RET_KILL_PROCESS, plus an audit log entry. The few calls the broker should see become SECCOMP_RET_USER_NOTIF instead of a plain allow (see §4).
  5. Review: the generated diff is committed by a person, never auto-merged into a release branch.
  6. Drift check in CI: every CI run re-probes in learning mode and fails if the observed set is not a subset of the checked-in profile. The failure names the new calls and the test that triggered them. The fix is regenerating and reviewing; the profile is never widened by hand without a probe.

3.2 What "hard-coded for our purpose" means

The profile is fixed per workload version. It changes only when the workload's code or toolchain changes, and CI forces that. Nothing is learned at runtime in production: production always runs in enforcing mode.

3.3 Coverage risk and how it is handled

A path the probe corpus never ran is missing from the profile, so production kills the worker on it. That fails closed, which is the safe direction. Mitigations:

4. The syscall broker (layer 2)

5. The container (layer 1)

6. The wasm host (layer 3)

7. Job API and the signed result

struct Job { kind: JobKind, input_dir: PathBuf, params: Params, policy: Policy }
enum JobKind { Wasm { module: GuestRef }, Native { workload: WorkloadRef } }
struct Policy { read: Vec<PathBuf>, write: Vec<PathBuf>, net: Option<NetAllow>, limits: Limits }
struct JobResult { job_id, verdict: Accepted | Refused { reason }, outputs: Vec<(name, sha256)>,
                   events: Vec<BrokerEvent>, started, finished }
struct SignedResult { result: JobResult, sig: Ed25519Signature, key_id }

8. Repository and crates

git.gunnar.rs/nordisk/heimdall, public, AGPL-3.0-only plus commercial (Ignalina ApS and Vetra Automation AB 50/50, like holger), CLA like holger's.

CrateWhat it holds
heimdall-coreJob, Policy, Result, SignedResult, key handling
heimdall-seccompprofile types, the BPF builder, enforcing/audit modes
heimdall-probethe learning-mode tracer (USER_NOTIF log-and-allow), the merge and minimise steps, the code generator
heimdall-brokerthe USER_NOTIF supervisor, ADDFD, the policy
heimdall-containeryouki libcontainer setup, Landlock, cgroups, rlimits, image verify
heimdall-wasmwasmtime host, WIT, limits, guest signature check
heimdall-runties one job together
heimdall-clithe command line
profiles/generated, checked-in profiles per workload/arch/libc (consumers may also keep theirs in their own repo)

9. Consumers

10. Kernel requirements and fallbacks

FeatureKernelIf missing
seccomp-bpf3.5refuse to start
USER_NOTIF5.0no broker; brokered calls become plain deny (fail closed)
NOTIF_ADDFD5.9broker answers allow/deny only, no fd injection; warn
Landlock ABI 1 / 4 (net)5.13 / 6.7log and continue without it (or refuse in strict mode)
cgroup v24.15+refuse in strict mode

Heimdall reports the features it actually got on every start, and each signed result names them.

11. Tests (test-first, every one RED before GREEN)

12. Milestones

  1. The repository, heimdall-core, heimdall-wasm and the verify-bundle guest. Pure Rust, no syscall work: the most value, the least risk.
  2. heimdall-container: youki, Landlock, cgroups, image verify, with a fixed hand-reviewed profile.
  3. heimdall-probe: learning mode, the generator, the CI drift check, as a Runa pipeline step.
  4. heimdall-broker: USER_NOTIF + ADDFD.
  5. Consumers: Holger verify and scan, then the fetch job; the tunnr/rusternetes RuntimeClass; Gunnar later.

13. Rules for whoever builds it