Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

docs/API.md

Heimdall job API (v0.2.0)

Heimdall is a defensive sandbox. It runs untrusted parsing, verification and scanning jobs so that a compromised job can reach only its own input and output. Its tests only exercise its own containment.

This is the stable job API every runner implements (milestone 0, Holger's demand). It follows semver: within a release line (0.2.x) it changes by additions only; a new 0.x line may break, and lists every break below and in CHANGELOG.md.

# The crates.io packages are named nordisk-heimdall-* (heimdall-core is an
# unrelated crate there); the library names stay heimdall_core, heimdall_plain,
# so `use heimdall_core::…` is unchanged.
heimdall-core      = { package = "nordisk-heimdall-core", version = "0.2.0" }
heimdall-plain     = { package = "nordisk-heimdall-plain", version = "0.2.0" }
heimdall-container = { package = "nordisk-heimdall-container", version = "0.2.0" }
# also 0.2.0: nordisk-heimdall-wasm, -run, -cli, -seccomp, -probe

Upgrading from 0.1

Change every version = "0.1" / "0.1.0" of a nordisk-heimdall-* crate to "0.2.0"; all crates move together (heimdall-run 0.2 needs heimdall-core 0.2). What breaks:

Where 0.1 0.2 What to write
JobKind::Wasm Wasm { module } Wasm { module, decoder: Option<GuestRef> } match Wasm { module, .. }; build JobKind::wasm(module) (or decoder: None); a split guest: JobKind::wasm_split(verifier, decoder)
Limits 13 fields + decoders: u32 build with ..Limits::default() (as before)
JobResult ends with events + kernel: Option<KernelFeatures> a runner fills kernel: None unless it probed the kernel; readers may show it
signed wire version 1, DOMAIN_V1 version 2, DOMAIN_V2 nothing for callers of verify() / Verifier; a 0.1 result (v1) is refused as UnsupportedVersion(1), so producer and verifier must both be 0.2 (results are per boot, there is nothing old to keep)
heimdall-run RunLimits 12 fields, LIMIT_KEYS: [_; 12] + decoders, [_; 13] build with ..RunLimits::default()
heimdall-run WasmLoadError 3 variants + Decoder(LoadRefusal) add an arm, or _

JSON stays compatible both ways for jobs: a 0.1 job's JSON reads with decoder: None and decoders defaulted, and a 0.2 job without a decoder writes no decoder key. A 0.1 JobResult JSON reads with kernel: None.

Holger 3.0 pins 0.1; 0.1.x stays supported with security fixes only (SECURITY.md).

The flow

use heimdall_core::*;
use heimdall_plain::{PlainConfig, PlainYoukiRunner};

let runner: Box<dyn JobRunner> =
    Box::new(PlainYoukiRunner::new(PlainConfig::new("/var/lib/holger/oci", "/var/lib/holger/heimdall")));
let pinned = runner.verifying_key();            // per boot; pin it
let job = Job::new(
    JobKind::Native {
        image: ImageRef { name: "docker.io/library/busybox".into(), digest: "sha256:…".into() },
        argv: vec!["/bin/cp".into(), "/in/a".into(), "/out/a".into()],
    },
    input_dir, output_dir,                       // output_dir: existing, empty
    serde_json::Value::Null,
    Policy::default(),
);
let signed = runner.run(job)?;                   // blocking; put it on a job queue
let result = verify(&signed, &pinned)?;          // signature, key id, well-formed
check_outputs(result, &output_dir)?;             // every listed file, hash checked, no extras
match &result.verdict {
    Verdict::Accepted => { /* use the outputs */ }
    Verdict::Refused { code, reason } => { /* code is stable, reason is for people */ }
}

Later, the protected Heimdall runner replaces PlainYoukiRunner::new(…); nothing else moves.

heimdall-core: the names

Item Shape
Job id: JobId, nonce: Nonce, kind: JobKind, input_dir: PathBuf, output_dir: PathBuf, params: serde_json::Value, policy: Policy
JobKind Native { image: ImageRef, argv: Vec<String> } | Wasm { module: GuestRef, decoder: Option<GuestRef> } (decoder: the decoder module of a split guest, module is then its verifier; serde default None, not serialized when None; JobKind::wasm(m) / JobKind::wasm_split(m, d) build them)
ImageRef name: String, digest: String (sha256: + 64 lower-case hex); ImageRef::sha256() parses it
GuestRef path: PathBuf, blake3: String
Policy read: Vec<PathBuf>, write: Vec<PathBuf>, net: Option<NetAllow>, limits: Limits
NetAllow host: String, port: u16 (exactly one peer)
Limits wall: Duration, memory_bytes: u64, cpus: f32, pids: u32 + wasm/output limits fuel, table_elements, instances, tables, memories, stack_bytes, max_output_files, max_output_file_bytes, max_output_total_bytes, decoders (decoders: decoder instances a split wasm guest runs at once, default default_decoders() = min(cores, 8); see docs/GATLING-WASM.md); build with ..Limits::default()
Verdict Accepted | Refused { code: String, reason: String }
Output name: String, sha256: Sha256Digest, bytes: u64
ExitInfo code: Option<i32>, signal: Option<i32>, killed: Option<KillReason>
KillReason Wall | Memory | Pids | Fuel
JobResult job_id, nonce, verdict, outputs: Vec<Output>, exit: ExitInfo, started_unix: i64, finished_unix: i64, runner: Features, events: Vec<BrokerEvent>, kernel: Option<KernelFeatures> (kernel: what the runner got from the kernel, spec §10; None for the wasm and plain runners; serde default None)
KernelFeatures release: String, seccomp_filter, user_notif, notif_addfd: bool, landlock_abi: Option<u32>, cgroup_v2: bool, cgroup_controllers: Vec<String>, user_namespaces: bool
SignedResult version: u16, key_id: KeyId, boot_id: BootId, result: JobResult, sig: Ed25519Signature
Features runner: String, container, seccomp: Protection, landlock, broker, wasm, image_verified_each_start (bools)
Protection None | Default | Generated { profile_digest: String }
trait JobRunner: Send + Sync run(&self, Job) -> Result<SignedResult, RunError>, verifying_key(&self) -> VerifyingKey, features(&self) -> Features
RunError Unsupported { what }, InvalidJob { reason }, Image { reason }, Setup { reason }, Io { reason }, Sign(ResultError) — nothing was signed
verify fn verify(&SignedResult, &VerifyingKey) -> Result<&JobResult, VerifyError>
check_outputs fn check_outputs(&JobResult, &Path) -> Result<(), VerifyError> (VerifyError::Output(OutputError::…) names the failure)
VerifyingKey re-export of ed25519_dalek::VerifyingKey (also heimdall_core::ed25519_dalek)

Everything public derives serde. heimdall-core is #![forbid(unsafe_code)] and depends on neither youki nor wasmtime.

Additions to the demand's shapes: Job.nonce and JobResult.nonce (bind a result to one request; Job::new fills it), JobResult.events (broker decisions, empty without a broker), SignedResult.version and boot_id, the KillReason and codes items, and Verifier/ReplayGuard/PinnedKey for callers that pin a key record and want replay detection. Output.sha256 is a typed Sha256Digest (its serde form is the 64-hex string), key_id a typed KeyId and sig an Ed25519Signature (64 bytes) rather than raw strings and arrays.

Refusal codes (heimdall_core::codes)

Code Meaning
heimdall-out-1 output_dir holds an extra: a directory, symlink, fifo, device, bad name, or more files than max_output_files
heimdall-out-2 an output over max_output_file_bytes, or all over max_output_total_bytes
heimdall-out-3 / -4 (wasm) an emitted file was refused / the output audit failed
heimdall-limit-wall killed at the wall-clock limit
heimdall-limit-memory killed at the memory limit
heimdall-limit-pids the job hit the process limit (a fork was refused), whatever its exit
heimdall-limit-workspace killed: its host-folder workspace went over its cap (exit.killed = Workspace)
heimdall-exit-1 the job exited non-zero (its outputs are still listed)
heimdall-exit-2 the job died from a signal
heimdall-seccomp-1 the job (or heimdall run's wasm worker) was killed by its seccomp profile (SIGSYS)
heimdall-wasm-<n> a wasm guest trapped or hit a wasm limit (heimdall-wasm)
guest-refused a wasm guest itself refused the input; the reason is the guest's

The signed bytes: canonical binary wire v2 (not JSON)

The demand asked for Ed25519 over canonical JSON. Heimdall signs its canonical binary encoding v2 instead (0.1 signed v1; v2 = v1 + the trailing kernel, and a v1 result is refused as an unsupported version): it is deterministic by construction (one value has exactly one encoding, the decoder accepts nothing else), domain-separated, length-prefixed, has no floats, and its decoder is fuzzed. Callers never touch the bytes: verify() re-encodes the result and checks the signature. JSON stays available for transport (SignedResult is serde) and still verifies after a JSON round trip.

wire      = "HMDL-SR\0" | version u16 = 2 | key_id[16] | boot_id[16]
            | result_len u32 | JobResult v2 | signature[64]
signature = Ed25519 over "heimdall.signed-result.v2\0" || (wire bytes between magic and signature)
key_id    = first 16 bytes of sha256("heimdall.key-id.v1\0" || public key)

JobResult v2 =
  job_id[16] nonce[32]
  verdict: u8 (0 Accepted | 1 Refused, then code: str, reason: str)
  outputs: u32 n × (name: str, sha256[32], bytes: u64)
  exit:    code: opt i32, signal: opt i32, killed: u8 (0 none, 1 Wall, 2 Memory, 3 Pids, 4 Fuel)
  started_unix: i64, finished_unix: i64
  runner:  runner: str, container: bool, seccomp: u8 (0 None, 1 Default, 2 Generated + profile_digest: str),
           landlock: bool, broker: bool, wasm: bool, image_verified_each_start: bool
  events:  u32 n × (at_ms: u64, call: str, args: u32 m × str, decision: u8)
  kernel:  opt (release: str, seccomp_filter: bool, user_notif: bool, notif_addfd: bool,
           landlock_abi: opt u32, cgroup_v2: bool, cgroup_controllers: u32 n × str, user_namespaces: bool)
str = u32 length + UTF-8; opt T = u8 0 | 1 + T; bool = u8 0 | 1; integers big-endian

Limits (and so cpus, the only float) is never part of a result.

heimdall-plain: PlainYoukiRunner (interim, NOT Heimdall)

An ordinary youki container per job (libcontainer pinned at =0.7.0), with no Heimdall protections. Its features(), and every result's runner:

runner "youki-plain", container true, seccomp Default, landlock false,
broker false, wasm false, image_verified_each_start true

Building: the shim needs the cargo feature youki (libseccomp-dev): cargo build -p nordisk-heimdall-plain --features youki --bin heimdall-plain-shim. The runner finds it via HEIMDALL_PLAIN_SHIM, next to the current executable, or PlainConfig::shim. A host binary can also serve as the shim: call heimdall_plain::shim::run_if_shim() first in main and use ShimCommand::current_exe().

Host requirements: user namespaces, cgroup v2 with the memory, pids and cpu controllers delegated to the runner's cgroup, and a cgroup dir the runner can create heimdall/ in (PlainConfig::cgroup_root, cgroup_parent). scripts/plain-tests.sh shows a working setup in a privileged rootless podman container.

heimdall-container: HeimdallRunner (the protected native runner)

Milestone 2 (spec §5, §10). The same trait, so it replaces PlainYoukiRunner::new(…) and nothing else moves:

use heimdall_container::{HeimdallConfig, HeimdallRunner};
let runner = HeimdallRunner::new(HeimdallConfig::new("/var/lib/holger/oci", "/var/lib/holger/heimdall"))?;
// Err(RunError::Unsupported { what }) when the kernel lacks a feature (strict mode).
eprintln!("{:?}", runner.kernel());   // the kernel features it got (spec §10)

Its features(), and every result's runner (where landlock is what the executor reported from inside that job, right before execve):

runner "heimdall", container true, seccomp Default, landlock true,
broker true, wasm false, image_verified_each_start true

Building: cargo build -p nordisk-heimdall-container --features youki --bin heimdall-container-shim (libseccomp-dev); found via HEIMDALL_CONTAINER_SHIM or next to the executable. scripts/container-tests.sh runs the kernel tests (the broker's own, then every heimdall-plain container test again plus Heimdall's own) in privileged rootless podman.

The syscall broker

Every job runs under the broker unless HeimdallConfig::broker is BrokerSetting::Off. The job's executor installs heimdall-broker's seccomp filter inside the container after Landlock, right before execve (and before a generated profile), and sends its USER_NOTIF listener to the shim, which serves it for the whole job. The job's openat, openat2, open, creat, socket, connect, bind, listen, accept, io_uring_setup, MSG_FASTOPEN sends, execve and execveat pause; the broker copies the arguments once, checks NOTIF_ID_VALID, decides by the job's policy and then opens the file itself (openat2 beneath the prefix it holds open, no magic links, no mount crossing) and injects the fd with SECCOMP_IOCTL_NOTIF_ADDFD (FLAG_SEND). The job's own call never runs on a path or sockaddr it could still change. A refused call fails with EACCES.

The policy (heimdall_container::brokered::policy) is never wider than the job's Landlock plan, because the broker's own opens are not under Landlock:

Where (as the job sees it) Broker access Resolution
/ (the image; never / itself) read in-root, no mount crossing (so not into /in, /out, /tmp, /proc, /dev or any other mount)
/in, policy.read read beneath the prefix (a symlink may not leave it)
/out write only: O_WRONLY, no read-back, no listing, no O_PATH no symlinks at all
/tmp, /work (the job workspace), policy.write read and write beneath (exec of /work stays the mount's and Landlock's decision)
/proc read the caller's own self, magic links (/proc/self/fd/N, /proc/self/root) refused
/dev/null read and write exact path, character device only
/dev/{zero,full,random,urandom} read exact path, character device only
sockets refused, except a fetch job's one TCP peer (below)
bind, listen, accept, MSG_FASTOPEN, io_uring_setup always refused
exec allowed and logged; Landlock decides what executes (only the image)

.. anywhere in a path is refused. A relative path is resolved against the caller's cwd or dirfd (/proc/<tid>/cwd, /proc/<tid>/fd/<n>) and checked as the absolute path. openat2 RESOLVE_* flags from the job only add strictness (RESOLVE_BENEATH / RESOLVE_IN_ROOT are refused). O_PATH is opened for reading (the kernel's ADDFD cannot inject an O_PATH file). When the job is out of fds, the call fails with EMFILE.

Events. JobResult.events starts with broker ["mode=full"] (check-only and deny-only add a warning: … argument), then one BrokerEvent per decision (openat with path=, flags= and fd= or refused: <why>, execve with path=, socket with its arguments, …), at most 4096, then a broker ["dropped=N"] event. They are inside the signature.

Kernel fallbacks (spec §10), from the runner's kernel report:

Kernel Mode Features.broker Effect
USER_NOTIF + ADDFD (5.9; 5.14 for the atomic answer) full true as above
USER_NOTIF, no ADDFD check-only false the broker checks, then lets the job's own call proceed (not TOCTOU-safe); a warning in the mode event
no USER_NOTIF deny-only false every brokered call fails with EACCES (fail closed); exec still runs

A job whose executor did not report the promised broker filter, or whose broker never served or ran in a weaker mode than promised, is a RunError::Setup; nothing is signed.

Fetch jobs (policy.net)

Policy.net = Some(NetAllow { peers }) makes a native job a fetch job with a small DECLARED set of TCP peers, each NetPeer { host, addr, port }:

Scan-worker job contract (HeimdallRunner)

This is what a scan worker (Holger's scan step, or any other native job) gets from heimdall-container 0.2.0, and what Holger's caller code must do with the answer. The protections themselves are listed in heimdall-container: HeimdallRunner and are not repeated here. Everything below is read from crates/heimdall-container/src/{lib,spec,shim,confine,kernel}.rs, the heimdall-plain parts they reuse (image.rs, spec.rs, outputs.rs) and their tests; it describes what is there, not plans.

The job input

What How it is implemented
Where The whole Job.input_dir is bind-mounted at /in (rbind, ro, nosuid, nodev). There is no single input file: the caller puts the artifact (or several files) into input_dir before run, and the worker reads /in/<name>.
Read-only Yes, three times: the mount is read-only, Landlock gives /in only read file + read dir (no write, no execute), and the broker opens /in for reading only. A write into /in fails and the host files are unchanged (test writing_into_the_input_fails_and_the_input_is_unchanged); Landlock alone refuses it even through a writable /in mount (landlock_alone_refuses_writing_into_in_even_through_a_writable_in_mount).
Checks input_dir must exist and be a directory (it is canonicalized first), else RunError::InvalidJob. Its contents are not inspected or hashed.
Extra inputs policy.read paths are bound read-only at the same path inside; policy.write paths read-write (no execute). Each must be absolute, without .., exist on the host, and not be / or under /in, /out, /proc, /dev or /sys.
Job.params Not implemented: HeimdallRunner ignores params; the worker never sees it. Pass parameters in argv or as a file in input_dir.
stdin /dev/null.

The answer: /out and the output list

What How it is implemented
Where Job.output_dir is bind-mounted read-write at /out. It must exist, be a directory and be empty when run starts (else InvalidJob).
Write-only Landlock gives /out write file, truncate, make regular file / dir / symlink / fifo, remove and refer, but no read and no execute, and the broker opens /out files O_WRONLY only: the worker can create and write files (>, >> work) but cannot read them back or list /out, also not through /proc/self/fd/N or a symlink in /in. Landlock alone refuses read-back and listing although the mount allows both (landlock_alone_refuses_reading_back_and_listing_out_which_the_mount_allows). Write each answer in one pass.
Format Heimdall imposes none. An output is any regular file directly in /out; its name is its output name. The worker and Holger agree on names and contents (for example one JSON report file).
Names One plain file name: non-empty, at most 255 bytes, not . or .., no / or \, no NUL or control characters, valid UTF-8 (heimdall_core::validate_output_name).
stdout / stderr /dev/null (the shim's stderr only with HEIMDALL_CONTAINER_DEBUG set). Nothing the worker prints reaches the caller; the answer must be a file.
Collection After the job ends, the runner lists output_dir itself (heimdall_plain::outputs::scan): sorted by name, each file opened with O_NOFOLLOW, SHA-256 and byte length. The list becomes JobResult.outputs (Output { name, sha256, bytes }).
Caps limits.max_output_files (default 64), limits.max_output_file_bytes (default 64 MiB), limits.max_output_total_bytes (default 256 MiB). max_output_file_bytes is also RLIMIT_FSIZE, so it caps every file the worker writes, in /tmp too.
Refused outputs A directory, symlink, fifo, socket or device in /out, a bad or non-UTF-8 name, more than max_output_files files, or an unreadable entry: heimdall-out-1. A file over max_output_file_bytes, or the sum over max_output_total_bytes: heimdall-out-2. On either refusal outputs is empty and HeimdallRunner empties output_dir (never following a symlink), so the signed empty list and the directory agree and check_outputs passes. If emptying fails the run is RunError::Io, nothing signed.
Non-accepted runs On heimdall-exit-1, heimdall-exit-2, heimdall-limit-wall and heimdall-limit-memory the outputs are still scanned and listed (unless the scan itself refuses).

The image

What How it is implemented
Layout HeimdallConfig::image_layout is a local OCI image layout directory. Only blobs/sha256/<hex> is read; oci-layout and index.json are not read.
Reference JobKind::Native { image: ImageRef { name, digest }, argv }. Only digest is used: it must be sha256: + 64 lower-case hex (else InvalidJob), and the manifest is the blob with that digest. ImageRef.name is ignored.
Pinning At every start the runner reads the manifest blob by the pinned digest and checks its SHA-256, then the config blob and every layer blob against the digests and sizes the manifest lists, while unpacking them into a fresh rootfs/ for this job only. Nothing is cached between jobs. A changed byte is refused at the next start (test a_changed_image_byte_is_refused_at_the_next_start).
Accepted An OCI image manifest or Docker v2 manifest; an OCI image index or Docker manifest list whose entry for linux/<host arch> is then used (one level only). Layers: OCI tar, tar+gzip, Docker rootfs.diff.tar and rootfs.diff.tar.gzip.
Refused (RunError::Image) a missing blob, a digest or size mismatch, a manifest or config blob over 4 MiB, a nested index, an index without linux/<arch>, another manifest media type, a manifest without config, another layer media type (for example zstd), and a layer entry that would leave the root (absolute path, .., a symlinked parent, a hard link outside). Device nodes and fifos in layers are skipped, ownership and xattrs are not applied, and OCI whiteouts are applied.
Used from the config Only config.Env. Entrypoint, Cmd, WorkingDir, User, ExposedPorts and the rest are ignored.

argv, env, user and working directory

Limits

All per-job limits come from Job.policy.limits (heimdall_core::Limits); the defaults are Limits::default() in crates/heimdall-core/src/types.rs.

Limit Default Enforced by When hit
wall 10 s the shim: SIGKILL to the whole container at wall, counted from start heimdall-limit-wall, exit.killed = Wall
memory_bytes 256 MiB (must be ≥ 4 MiB) cgroup v2 memory.max, memory.swap.max = 0 (written by the shim and read back) any oom_kill in the job's memory.events: heimdall-limit-memory, exit.killed = Memory, even if the job then exited 0
pids 64 (must be > 0) cgroup v2 pids.max (and RLIMIT_NPROC = pids + 1 as a backstop) fork fails inside; any max in the job's pids.events: heimdall-limit-pids, even if the job then exited 0 (exit.killed stays None: the runner did not kill it)
cpus 1.0 (must be in (0, 1024]) cgroup v2 cpu.max = cpus × 100 ms per 100 ms (at least 1 ms) throttled, never refused
max_output_file_bytes 64 MiB RLIMIT_FSIZE and the output scan SIGXFSZ: heimdall-exit-2 with "(a file over the size limit …)"; or heimdall-out-2
max_output_files, max_output_total_bytes 64, 256 MiB the output scan heimdall-out-1, heimdall-out-2
open files 1024 RLIMIT_NOFILE (spec::NOFILE, fixed) EMFILE inside
core dumps 0 RLIMIT_CORE (fixed) none written
io weight io.weight 50 (1..=10000) HeimdallConfig::io_weight (DEFAULT_IO_WEIGHT), only when the io controller is delegated; the shim writes io.weight itself (libcgroups 0.7.0 overflows u16 converting an OCI blockIO.weight) and reads it back never refused
io rates none HeimdallConfig::io_max: IoMax { device, rates }, device = IoDevice::Number(DevNum) or IoDevice::Holding(path) (the disk of that path's file system), resolved to the WHOLE disk at start (sysfs at HeimdallConfig::sysfs_root); rates = rbps, wbps, riops, wiops (None = max, 0 refused) → OCI blockIO.throttle*Device → cgroup v2 io.max, read back by the shim before the job starts the job is throttled, never refused; io limits without the io controller refuse to START (strict or not), and so does a path whose file system has no block device (tmpfs, overlay, a btrfs subvolume)
the shim itself wall + 30 s the runner (SHIM_GRACE): kills the shim, then writes cgroup.kill RunError::Setup "the shim did not finish in time"; nothing signed

Not implemented for this runner: the wasm limits fuel, table_elements, instances, tables, memories, stack_bytes and decoders are ignored. A job that hits the pids limit is refused with heimdall-limit-pids whatever its exit (a job that survives a failed fork and exits 0 is NOT accepted: its work may be incomplete); the reason names the limit, the refused forks and how the job ended. Its own outputs stay listed, like after a non-zero exit.

The job workspace (Policy::workspace)

A job that needs room to work (unpack, build, trace an install) asks for a writable scratch area: policy.workspace = Some(Workspace { bytes, exec }). It is mounted at /work (WORKSPACE_PATH) INSIDE the job's container only, and it is gone when the job ends. Only what the job writes to /out leaves it, through the usual output scan; a job copies its artifacts there itself.

bytes <= 1 GiB (WORKSPACE_TMPFS_MAX) bytes > 1 GiB
kind (Workspace::kind) Tmpfs: a tmpfs in the container's mount namespace HostDir: <work_dir>/workspaces/<job> (mode 0700) on the host disk, bind-mounted into that one container
cap size=bytes: a write past it fails with ENOSPC; the pages count against memory_bytes, so give the job at least that much memory the shim measures the folder (allocated blocks) every 200 ms and at the end; over the cap = SIGKILL, refused heimdall-limit-workspace, exit.killed = Workspace
deleted with the container by the runner after the job, whatever its end

Both kinds are nosuid,nodev,noexec, and Landlock gives /work read and write without execute. exec: true drops noexec and adds Landlock's execute right; it is for build and trace jobs only, and a runner refuses it (RunError::Unsupported) unless HeimdallConfig::allow_workspace_exec is set, so a scanner runner never gets exec. RLIMIT_FSIZE becomes the larger of max_output_file_bytes and bytes (a workspace file may be large; /out is still held to max_output_file_bytes by the output scan). Seccomp, the empty network namespace, the cgroup limits and the signing do not change. bytes = 0 and policy paths under /work are InvalidJob; heimdall-plain and the wasm runner refuse a workspace (Unsupported).

Measured on oden (kernel 7.0): buffered writes of more than the memory limit into a host-folder workspace can be OOM-killed (heimdall-limit-memory) before the cap is reached, because dirty page cache counts against memory.max; size memory_bytes for the job's write rate, or write with O_DIRECT.

features() and the kernel report

features() is fixed when the runner starts (admit in lib.rs):

runner "heimdall", container true, seccomp Default, landlock <L>,
broker <B>, wasm false, image_verified_each_start true

<B> is true when HeimdallConfig::broker is BrokerSetting::Auto and the kernel has USER_NOTIF and NOTIF_ADDFD (the broker's full mode). <L> is true when HeimdallConfig::landlock is LandlockMode::Auto and the kernel has Landlock. Each result's runner is the same, except that landlock is what the executor reported from inside that job, right before execve (fully or partially enforced both count as true). events holds the broker's decisions (see "The syscall broker").

HeimdallRunner::new probes the kernel (KernelReport::probe) and runner.kernel() returns it. Every signed result carries it as JobResult.kernel = Some(KernelFeatures { release, seccomp_filter, user_notif, notif_addfd, landlock_abi, cgroup_v2, cgroup_controllers, user_namespaces }), the same for every job of one runner (it is not re-probed per job). cgroup_controllers is what the jobs' parent cgroup offers. user_notif and notif_addfd choose the broker's mode.

Kernel / config strict = true (default) strict = false
no seccomp kill_process action refuses to start refuses to start
no Landlock refuses to start starts, landlock: false
LandlockMode::Off refuses to start starts, landlock: false
no cgroup v2, or memory / pids / cpu not delegated refuses to start starts
no unprivileged user namespaces refuses to start starts

A refusal to start is RunError::Unsupported { what }, where what names the kernel release and every missing feature at once.

Generated seccomp profiles

Policy.seccomp: Option<SeccompProfileRef> (serde default None, not serialized when None) names the profile heimdall profile gen wrote for the job's workload, pinned by sha256 = sha256:<hex> of its OCI seccomp JSON (profiles/<workload>/<arch>-<libc>.oci.json):

policy.seccomp = Some(SeccompProfileRef {
    source: ProfileSource::OciFile("/etc/heimdall/profiles/clamav/x86_64-glibc.oci.json".into()),
    // or ProfileSource::Workload("heimdall-wasm-host".into()): checked in, compiled into Heimdall
    sha256: "sha256:…".into(),
    fallback_to_default: false,
});

Errors and refusal codes

A signed refusal is a SignedResult with Verdict::Refused { code, reason }. HeimdallRunner produces exactly these codes, checked in this order:

Code When
heimdall-limit-wall the shim killed the job at wall
heimdall-limit-memory the job's cgroup counted an oom_kill
heimdall-limit-pids the job's cgroup counted a refused fork (pids.events max), unless it died from SIGSYS
heimdall-out-1 the output scan found an extra (see above); output_dir is emptied
heimdall-out-2 an output or the total is over its cap; output_dir is emptied
heimdall-exit-1 the job exited with a non-zero code
heimdall-seccomp-1 the job died from SIGSYS: a call its seccomp profile kills (the reason names the profile: fixed, or generated with its digest)
heimdall-exit-2 the job died from another signal (the reason names SIGXFSZ as the file size limit), or left no exit status

Otherwise (exit code 0) the verdict is Accepted. It never produces heimdall-out-3, heimdall-out-4, heimdall-wasm-<n> or guest-refused (those are the wasm runner's: an emitted file refused / the output audit failed, a guest trap or wasm limit, the guest's own refusal).

Self-consistency rule: every signed result's outputs is exactly what is in output_dir when run returns. Whenever the output scan refuses the directory (out-1, out-2, also when a limit code wins) the list is empty and the directory is emptied; otherwise every file the job left is listed.

An error is Err(RunError) from run; nothing is signed and there is no result to verify:

RunError When
Unsupported a wasm job; policy.net = Some(..); at start: the kernel table above
InvalidJob policy.seccomp names a profile that is missing, has another digest, is for another arch, is not enforcing (default not SCMP_ACT_KILL_PROCESS) or does not build, and does not allow the fallback (the reason starts seccomp profile (); bad image digest; empty argv; wall zero; cpus outside (0, 1024]; pids zero; memory_bytes under 4 MiB; a bad policy path or one that does not exist; input_dir / output_dir missing or not a directory; output_dir not empty; a job id already used on this runner (its bundle dir exists)
Image the image is refused (see the image table)
Setup the shim cannot start, fails, times out or answers garbage; the container could not be created or started; no report from inside the job; an executor error before execve (also Landlock failing to apply); no-new-privs not set; seccomp not in filter mode; Landlock promised but not applied; memory.swap.max not 0
Io creating the bundle, writing config.json, planning Landlock, reading output_dir
Sign the result failed validation before signing

Each job gets its own bundle <work_dir>/jobs/<job id hex>/, removed after the run whatever the outcome; its container id is heimdall-<job id> and its cgroup <cgroup_parent>/job-<job id>.

Verifying the answer (the caller side)

let pinned = runner.verifying_key();          // once per runner (per boot)
let ticket = job.ticket();                    // before run(job)
let signed = runner.run(job)?;                // Err: nothing signed, no outputs to trust
let result = heimdall_core::verify(&signed, &pinned)?;
if result.job_id != ticket.job_id || result.nonce != ticket.nonce { /* refuse */ }
heimdall_core::check_outputs(result, &output_dir)?;

Dynamic-trace jobs (heimdall-trace)

Heimdall is a defensive sandbox. A dynamic-trace job runs a package's install or import (pip install --no-index of a wheel or sdist from /in, npm install of a tarball with its scripts on, python3 -c 'import x') inside a HeimdallRunner job with no network, and returns a signed trace of what it did, so the caller can diff it against a baseline (a clean package's trace) and hold a package on new behaviour. It fails closed: a trace that could not be taken is TraceOutcome::Unknown, never clean.

heimdall-trace = { package = "nordisk-heimdall-trace", version = "0.3.0" }  # new crate, unreleased
use heimdall_trace::{run_trace, diff, TraceJob, TraceOutcome};
// once: the worker image (python3, pip, setuptools, node, npm) + the static tracer
let image = heimdall_trace::image::with_tracer(&layout, &worker_image, Path::new("heimdall-trace"))?;
let mut tj = TraceJob::new(image, vec!["/usr/bin/python3".into(), "-m".into(), "pip".into(),
    "install".into(), "--no-index".into(), "--no-deps".into(), "--no-build-isolation".into(),
    "--break-system-packages".into(), "--target".into(), "/tmp/site".into(), "/in/pkg.whl".into()]);
let (signed, outcome) = run_trace(&runner, &tj, input_dir, output_dir);  // runner: HeimdallRunner
match outcome {
    TraceOutcome::Traced { trace, job_code } => {
        let d = diff(&baseline, &trace);
        if !d.is_clean() { /* hold the package: d names the new behaviour */ }
    }
    TraceOutcome::Unknown { reason } => { /* hold: no trace to trust */ }
}

No new job kind or policy flag. A trace job is an ordinary JobKind::Native job whose argv starts the tracer (/heimdall/heimdall-trace --out /out/trace.json ... -- <command>) in a trace image; its one output is trace.json, listed with its SHA-256 in the SignedResult. Every runner, the signed wire and verify stay as they are. TraceJob::job() builds the Job (call ticket() on it before run), or run_trace does it all; verify_trace(signed, key, ticket, output_dir, max_bytes) checks a result from elsewhere.

The tracer (inside the job)

The static heimdall-trace binary (musl) is PID 1 of the job. It starts the command under heimdall-probe's learning filter (seccomp USER_NOTIF for every call, heimdall-seccomp's Launch with exec_by_path: a plain execve, since the job's fixed profile refuses execveat) and answers every call itself:

Calls Recorded Answer
every call per program (/proc/<tid>/exe): the call with heimdall-probe's argument facts (socket domain/type/protocol, open flags, ioctl, mmap/mprotect prot, clone flags, prctl, fcntl) as below, else continue
execve, execveat the caller, the path (absolute), argv (64 args × 1024 bytes at most) continue
connect, bind, sendto/sendmsg with an address op, family (inet, inet6, unix, netlink, ...), address EACCES for every family but AF_UNIX (and a unix bind outside the writable paths); continue for AF_UNIX
file calls: open*, creat, mkdir*, unlink*, rmdir, rename*, link*, symlink*, chmod/fchmodat*, chown/fchownat, truncate, mknod* op, path (absolute, normalised; a write's parent symlinks resolved), open flags, write, outside_tmp EACCES for a write outside the writable paths (/tmp by default, and /dev/null), into /out, or with an unreadable path; continue otherwise
a foreign arch or an unknown number in unknown ENOSYS

When the command ends, or at the tracer's deadline (the job's wall limit less 5 s), the tracer kills every process left (kill(-1, SIGKILL)), reaps them all, and only then writes the trace into the file it created before the command started. As PID 1 it cannot be signalled from inside the job, and nothing of the package runs while it writes. The tracer exits 0 whenever it wrote a document (an incomplete one says why); 71 when it could write none.

Not seen: calls the job's fixed profile answers before the listener (EPERM or kill: the stricter seccomp action wins), the vDSO time calls, a sendmsg on fd 1021 (the launch prelude's listener hand-off; closed at exec). Paths and addresses are read from the caller's memory when the call stops; another thread can change them before the kernel reads them again (USER_NOTIF Continue). Such a race can make a recorded path differ from the one the kernel used, never widen what the job may do: Landlock and the read-only root still decide, and a write that lands in /out makes the result Unknown (an extra output). The TOCTOU-safe way is the broker's (open on the caller's behalf, NOTIF_ADDFD).

A trace job runs without the broker. The kernel allows one seccomp listener per filter chain (a second NEW_LISTENER below one is EBUSY), and the tracer holds it: the trace says listener: "heimdall-trace", the result says broker: false, and file access stays enforced by the mounts and Landlock. A broker and a trace together are refused by name (heimdall_trace::job::BROKER_REFUSAL): run_trace on a runner whose features say broker: true signs nothing and is Unknown, and so is verify_trace of a result whose runner had a broker. The way to both is an observer hook in the broker (one listener for brokering and tracing; lane broker, later).

Node: node 22's libuv calls io_uring_setup at start (its epoll ring) whatever UV_USE_IO_URING says; the fixed profile refuses it with EPERM (not kill), and node carries on without a ring. io_uring_enter and io_uring_register still kill.

The trace document (heimdall.trace.v1)

Canonical JSON: compact, fields in this order, every list a sorted set without duplicates; Trace::from_canonical re-encodes and refuses any other encoding, a foreign format, unknown fields, contradicting fields, or a document over its own max_bytes or the reader's bound. No pid, time or per-entry count, so two runs of one package give the same sets.

{ format: "heimdall.trace.v1", argv: [str], status: "complete"|"truncated"|"incomplete",
  incomplete: str, exit: str ("exit 0", "killed by signal 9", "killed by seccomp (SIGSYS)", "timed out"),
  timed_out: bool, leftover_killed: u64, notifications: u64, dropped: u64, max_bytes: u64,
  listener: "heimdall-trace",
  exec:  [{program, path, argv: [str], argv_cut: bool}],
  net:   [{program, op, family, address, refused: bool}],
  files: [{program, op, path, flags: "0x..", write: bool, outside_tmp: bool, refused: bool}],
  syscalls: {program: [{syscall, args: {index: "0x.."}}]},
  unknown: [str] }

The size bound (TraceJob::max_bytes, default 4 MiB, at least 4096) holds for the whole document: once full, new entries are counted in dropped and the status is truncated (never silently complete).

The outcome and the diff

TraceOutcome When
Traced { trace, job_code: None } the result verifies (key, ticket), the outputs are exactly trace.json with the signed hash, the job was Accepted, the trace is canonical, within the bound and complete or truncated
Traced { trace, job_code: Some(code) } the same, but the job was refused heimdall-limit-pids or heimdall-limit-memory (a fork bomb): the tracer survived and the trace is still signed
Unknown { reason } anything else: the runner signed nothing, a bad signature/key/ticket, a changed or extra output, any other refusal (wall limit, exit, seccomp), a trace over the bound, not canonical, or incomplete

diff(baseline, trace) lists what the trace does that the baseline does not: exec by (program, path) (argv shown, not compared: temp names), net by (op, family, address, refused), files by (op, path, write, refused) leaving out non-refused calls inside /tmp, syscalls by (program, call), unknown calls. TraceDiff::is_clean() is false on anything new and whenever either trace is not complete. Holger holds a package unless the outcome is Traced and the diff against its baseline is clean.

CLI

`heimdall trace run --layout --image sha256:.. [--tracer ] --in

Wasm behind the trait

heimdall_run::WasmJobRunner runs JobKind::Wasm jobs through the same trait (signed guests verified and blake3-pinned on every job, no WASI); runner: "heimdall-wasm", wasm: true, container: false, seccomp: None.

Static-scanner guests (heimdall-scan, guests/static-scan)

Heimdall is a defensive sandbox. A static scanner is a signed wasm guest that judges one package artifact without running it: Holger's rule "static parsers run as Heimdall wasm guests". The contract is the crate nordisk-heimdall-scan (lib heimdall_scan, only serde; it builds for wasm32 and natively), shared by the guest and the caller. The first guest, guests/static-scan, runs five checks with modgunn's own code (modgunn 0.0.8 from crates.io, features scan + scip, unchanged):

Check What it finds modgunn code Data it needs
behavior install hooks, network at install, URLs, high-entropy or hex payloads, sensitive APIs (npm package.json; PyPI setup.py, pyproject.toml, setup.cfg; a wheel's top-level .pth import lines), plus the archive's own shape (below) behavior::parse_npm_package, parse_pypi_setup, parse_pyproject_toml, parse_setup_cfg the artifact
typosquat a name one edit from a popular name (PyPI names PEP 503-normalized first) scip::typosquat_findings popular-names
dependency-confusion an internal-only name resolved from a public registry scip::typosquat_findings internal-namespaces + param origin
registry a yanked (crates.io, PyPI), deprecated (npm) or retracted (Go) version registry::parse_crates_yanks, parse_npm_deprecations, parse_pypi_yanks, parse_go_retractions registry
malicious the package on the OpenSSF malicious-packages feed (MAL-…) feeds::parse_malicious malicious

The job

A plain JobKind::Wasm job (WasmJobRunner / heimdall run --job wasm): the input dir holds one file, the scan-input envelope; Job.params is a JSON object of strings.

magic    "heimdall-scan-input v1\n"
count    u32 big endian (1 ..= 16)
section  name_len u8 | name | body_len u64 big endian | body     (count times)
Section Body
artifact (required) the package file as the registry served it: npm .tgz, PyPI sdist (.tar.gz or .zip) or wheel, .crate, … (the kind is read from the magic bytes, never from a name)
popular-names popular names of the purl's ecosystem, UTF-8, one per line, # comments and blank lines skipped
internal-namespaces internal name prefixes (@acme/, acme-), same syntax; an empty body means "none"
registry the registry's document for this package, verbatim: crates.io /api/v1/crates/<name>, the npm packument, PyPI /pypi/<name>/json, or the module's go.mod
malicious OSV MAL- records, one record or a JSON array ([] is a real "none")

Unknown or repeated sections, a bad magic, a short body and trailing bytes are refused. Build it with heimdall_scan::ScanInput::new(artifact).with(DataKind::Registry, doc).encode(). There is no network in a guest: every snapshot comes in here, and the report names each by SHA-256.

Param Value Default
purl the package URL (required; pkg:npm/%40acme/x@1.0.0, pkg:pypi/pyyaml@5.3, pkg:cargo/smallvec@1.6.0, pkg:golang/github.com/a/b@v1.2.0) —
checks comma list of check names all five
origin public or internal: where the caller resolved the package none (dependency-confusion is then unknown)
max-unpacked-bytes bytes the archive may unpack to (at most 1 GiB) 64 MiB
max-entries archive members 100 000
max-manifest-bytes one manifest read into memory 1 MiB
max-depth path components of a member 32

An unknown key is refused, never ignored.

The answer

An accepted job emits exactly one file, findings.json (heimdall_scan::REPORT_NAME): the canonical findings document (heimdall_scan::Report, compact JSON, fields in declared order, no floats).

{"scanner":{"name":"heimdall-static-scan","version":"0.1.0","modgunn":"0.0.8"},
 "subject":{"purl":"pkg:npm/evil-pkg@1.0.0","ecosystem":"npm","name":"evil-pkg","version":"1.0.0","sha256":"…","bytes":231},
 "data":[{"name":"popular-names","sha256":"…","bytes":31}],
 "checks":[{"check":"behavior","verdict":"findings","findings":[{"id":"MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL","severity":"high","summary":"…","path":"package/package.json"}]},
           {"check":"typosquat","verdict":"clean","findings":[]},
           {"check":"registry","verdict":"unknown","findings":[],"unknown":{"code":"data-missing","detail":"no registry snapshot"}}],
 "verdict":"findings"}

A refusal (heimdall-wasm-1) means the guest could not read the job at all; no document is emitted. The reason starts with a bracketed code (heimdall_scan::codes): [heimdall-scan-1] bad envelope, [heimdall-scan-2] bad parameter, [heimdall-scan-3] missing or bad purl, [heimdall-scan-4] unknown check. Traps and limits are the host's heimdall-wasm-2 … -13. Treat every refusal as a hold too.

The caller (Holger)

use heimdall_scan::{in_toto_statement, DataKind, Report, ScanInput, Verdict, REPORT_NAME};

let input = ScanInput::new(artifact_bytes)
    .with(DataKind::PopularNames, popular)       // from Holger's own snapshot store
    .with(DataKind::Registry, registry_doc)
    .with(DataKind::Malicious, mal_feed)
    .with(DataKind::InternalNamespaces, prefixes)
    .encode();
std::fs::write(input_dir.join("scan-input"), &input)?;
let params = serde_json::json!({ "purl": purl, "origin": "public" });
// … run the signed guest as a JobKind::Wasm job, verify the SignedResult,
// check_outputs, then:
let report = Report::from_canonical_json(&std::fs::read(out_files.join(REPORT_NAME))?)?;
report.check_subject(&purl, &sha256_hex(&artifact_bytes))?;
let statement = in_toto_statement(&report);       // in-toto Statement v1
// sign `statement` (DSSE) into the evidence; hold unless report.verdict == Verdict::Clean

PREDICATE_TYPE is https://gunnar.rs/nordisk/heimdall/static-scan/v1; the statement's subject is the purl with the artifact's SHA-256. Report::from_canonical_json refuses a document that is not canonical, has a verdict that does not follow from its contents, or an unknown code.