Skip to content

NaZelo Reference

The complete technical reference for NaZelo: trust levels, network and L7 enforcement, process governance, capability tokens, evidence events, configuration, and the full CLI and Python API. For a guided first run, see Getting Started. For TL4 Firecracker artifacts, see firecracker/README.md.

Why NaZelo?

Designed for dev workflows

Trust levels TL1-TL4 map directly to development stages: rapid prototyping (bwrap), staging (Linux namespaces + cgroups), pre-production (gVisor), and hardened deployment (Firecracker microVMs). Each level uses real, named tools — not abstract "security levels".

Evidence-first

Every sandbox lifecycle event — create, exec, destroy — is captured as structured, verifiable evidence. BLAKE3-signed capability tokens provide tamper-evident audit trails. Plug the evidence API into any compliance engine (EU AI Act, IEC 62443, ISO 27001, DORA, SOC2) — NaZelo emits the events, your compliance layer interprets them.

Egress content inspection

Status: not yet delivered. egress:inspect is currently refused at attach time — the sampled events land in a BPF ring buffer this reader cannot consume (ringbuf_map_get_next_key/lookup_elem return -ENOTSUPP), so no inspection event could ever be recorded. See the egress callout under Presets for the full explanation. The design below describes the intended feature; lifting the refusal needs a real ring-buffer reader (libbpf). L7 protocol rules are enforced by the userspace stream proxy regardless (see L7 enforcement).

This limit is about content sampling only. Per-protocol L7 enforcement no longer runs in the kernel: the core enforces HTTP/TLS/Modbus/S7/MQTT in a userspace stream proxy (see L7 enforcement), which seals each allow/block as a sandbox.l7.decision evidence event. The older in-kernel L7 block-event consumers stay off (no polling thread, no bpftool map dump, block maps unpinned) until a real ring-buffer reader exists.

When egress:inspect is enabled, NaZelo samples outbound TCP payloads (1-in-1000 packets, up to 1024 bytes each) via an eBPF ring buffer. Sampled data is scanned for credential leaks and injection patterns:

Pattern What it catches
aws_access_key AWS access key IDs (AKIA...)
pem_private_key PEM-encoded private keys (-----BEGIN ... PRIVATE KEY-----)
jwt_token JSON Web Tokens (eyJ...)
generic_high_entropy_b64 Long base64 blobs (potential secrets)
sql_injection Common SQL injection patterns (UNION SELECT, '; DROP TABLE)

Only pattern names are recorded in evidence — never the matched secret itself.

Standalone & self-hosted

No cloud dependency. Install with pip, run with nazelo. Integrates into orchestrators, IDEs, CI/CD pipelines, or agent frameworks.

Trust Levels

Level Name Tool Isolation Use case Status
TL1 LOCAL bwrap (bubblewrap) Filesystem + read-only mounts Fast dev iteration, <10ms overhead Stable
TL2 CONTAINED unshare PID/net/mount namespaces + cgroup v2 Staging, resource-limited execution Stable
TL3 ISOLATED runsc (gVisor) User-space kernel, OCI bundles Pre-production, untrusted code Stable
TL4 HARDENED firecracker Hardware microVM + vsock + guest agent Production, regulatory workloads, cross-arch IoT/Edge Requires Firecracker + rootfs

Presets

Presets bundle trust level, capabilities, network rules, and TTL into a single call.

CLI

# Dev: fast sandbox (TL1), GitHub + PyPI + private networks allowed
nazelo run --preset dev "python3 script.py"

# CI: namespace-isolated (TL2), only CI registries allowed
nazelo run --preset ci "make build && make test"

# Untrusted code: gVisor-isolated (TL3), no network, read-only workspace
nazelo run --preset untrusted "python3 untrusted_sketch.py"

# Override preset network rules with --allow
nazelo run --preset ci --allow private-registry.corp.com "pip install internal-pkg"

Egress requires Docker: the sandbox gets a network namespace of its own. The egress classifier matches on packet headers and has no notion of which sandbox a packet came from (no cgroup id, no pid, no uid), so a tc filter governs every packet leaving the interface it is attached to. It only means "this sandbox" if that interface belongs to a namespace only this sandbox uses — inside the host's namespace the same filter would govern the whole machine's traffic, and host-local services on lo would stay reachable anyway. TL1/TL2 therefore run an egress-granted workload inside an anchor container that owns the namespace (--network bridge, which supplies the address, route, NAT and DNS), and attach the filter there. Requires docker and nsenter; without them, egress is refused rather than silently downgraded to the host's namespace. TL3 (gVisor) does not have an anchor yet and refuses egress. TL4 in this package gives its guest no network interface at all — the microVM is reached over vsock — so it refuses egress too, and says that rather than naming a namespace or a missing tool. --preset untrusted (no egress) needs none of this.

On a tap, the sandbox's outbound traffic is the tap's INGRESS. A tier that does give its guest a network (the microVM OT backend in nazelo-critical) reaches it through a tap, and the guest is the far side: a frame it sends is written into the tap by the VMM, so the kernel sees it arrive. The filter goes on the ingress hook there. On an interface inside the sandbox's own namespace it stays on egress, which is every other tier.

eBPF tooling and privilege. Egress additionally needs bpftool, tc (iproute2), clang and nsenter on a Linux host, and CAP_SYS_ADMIN to enter the sandbox's network namespace. Without either, attach fails closed rather than running with unfiltered networking.

L7 function-code filtering runs in a userspace stream proxy, not in the kernel. The eBPF allow-list forces every governed host through the proxy (the bypass lock); the proxy parses HTTP, TLS, Modbus, S7 and MQTT and applies the per-host policy, blocking anything it cannot classify (fail-closed). A Modbus segment that is not a clean MBAP PDU boundary is blocked. See L7 enforcement.

egress:inspect is refused. Its events land in a BPF ring buffer that the kernel will not expose through the map-iteration API this reader polls (ringbuf_map_get_next_key/lookup_elem return -ENOTSUPP), so no content-inspection event could ever be recorded. This is about content sampling only; L7 protocol enforcement is done by the stream proxy, which seals its allow/block decisions as sandbox.l7.decision. Lifting the content-inspection refusal needs a real ring-buffer reader (libbpf), not a polling loop.

Python API

from nazelo import (
    SandboxProfile, ExecutionRequest, issue_token, new_execution_profile,
    TrustLevel1Backend, TrustLevel2Backend, GVisorExecutionBackend,
)
from pathlib import Path

# Dev preset — one line
profile = SandboxProfile.for_dev(owner="alice")

# CI preset — one line
profile = SandboxProfile.for_ci(owner="ci-bot")

# Untrusted code — one line
profile = SandboxProfile.for_untrusted_code(owner="security-audit")

# For critical-systems presets (Modbus, MQTT, S7, FHIR, CAN, ICAO 9303,
# FIX Protocol, Open Banking, nuclear_diode, ...) install the
# `nazelo-critical` package — see its README for usage.

# Execute
exec_profile = new_execution_profile(profile)
token = issue_token(exec_profile)
backend = TrustLevel2Backend(workspace_path=Path.cwd(), token=token, profile=exec_profile)
result = backend.execute(ExecutionRequest(command="make test", timeout=60))
print(result.exit_code, result.verified)

Preset details

Preset Trust level Capabilities Network rules Default TTL
for_dev TL1 (bwrap) exec, fs:read+write, egress GitHub, PyPI, RFC 1918 ranges 10 min
for_ci TL2 (unshare) exec, fs:read+write, egress GitHub, GitLab, PyPI, npm, crates.io 5 min
for_untrusted_code TL3 (gVisor) exec, fs:read:workspace No egress (deny all) 2 min

None of these presets request egress:inspect: it is refused at attach time (see the egress callout above) because its events land in a BPF ring buffer this reader cannot consume. Requesting it made an egress run fail at attach while delivering no inspection, so it was dropped from the presets rather than kept inert.

The for_dev, for_ci, and for_untrusted_code presets accept extra_network_rules for overrides and custom ttl_seconds. Critical-systems presets (for_ot_simulation, for_serial_simulation, for_automotive_simulation, for_identity_simulation) ship in the nazelo-critical package.

Network rules

Control what a sandbox can reach — using domain names, not port numbers.

CLI

# Allow specific domains
nazelo run -t 2 --allow github.com --allow pypi.org "pip install requests"

# Allow a private CIDR range
nazelo run -t 2 --allow 10.0.0.0/8 --allow port:5432 "psql -h db.internal ..."

# Use a network preset
nazelo run -t 2 --allow preset:ci-pipeline "npm install"

# Available network presets: no-egress, ci-pipeline, internal-only

Python API

from nazelo import SandboxProfile, NetworkRule, TrustLevel

profile = SandboxProfile(
    trust_level=TrustLevel.CONTAINED,
    capabilities=frozenset({"exec", "egress"}),
    ttl_seconds=300,
    owner="my-agent",
    network_rules=(
        NetworkRule.allow_domain("github.com"),
        NetworkRule.allow_domain("pypi.org", ports=[443]),
        NetworkRule.allow_cidr("10.0.0.0/8"),
        NetworkRule.allow_port(5432),
    ),
)

How it works

User Command
┌─────────────────────────────┐
│  CLI / Python API           │
│  Profile + Token + Rules    │
└──────────┬──────────────────┘
┌─────────────────────────────┐
│  Backend (bwrap/unshare/    │
│  gVisor)                    │
│  Namespace isolation        │
└──────────┬──────────────────┘
┌─────────────────────────────┐
│  eBPF Egress Controller     │
│  TC classifier on egress    │
│  IPv4 + IPv6 CIDR masks     │
│  Content inspection (opt)   │
└──────────┬──────────────────┘
┌─────────────────────────────┐
│  Evidence Recorder          │
│  Every decision logged      │
│  Pluggable (your recorder)  │
└─────────────────────────────┘
  1. NetworkRule.allow_domain("github.com") resolves via DNS to concrete IP addresses at attach time
  2. NaZelo generates an eBPF TC classifier (compiled with clang -O2 -target bpf)
  3. Both IPv4 and IPv6 are enforced — CIDR ranges use proper mask-based checks (daddr & mask == network), and IPv6 destinations are filtered via 32-bit word comparisons against in6_addr
  4. When IP constraints are configured for one protocol family only, the other family is dropped (fail-closed)
  5. Everything not explicitly allowed is dropped (fail-closed)
  6. Every allow/deny decision is recorded via EvidenceRecorder
  7. Layer-7 semantics the kernel allow-list cannot see (HTTP method, payload size, Modbus/S7 function codes, MQTT topics, and — under TLS — even the host) are enforced by a userspace stream proxy, not in-kernel. The eBPF allow-list is reduced to the proxy's own address (the "bypass lock"), so every governed host resolves to the proxy and the workload has no other egress path. The proxy parses the protocol, allows or blocks per the host's policy, and seals each decision as a sandbox.l7.decision evidence event. See L7 enforcement

No pre-filter window. When a profile grants egress, the workload is held behind a launch gate — a tiny sh prologue blocking on a sentinel in a dedicated private gate directory that each backend bind-mounts into the sandbox — and is released only after EbpfEgressController.attach() succeeds. If attach fails, the sentinel is never created and the command never runs (fail-closed). No user command can perform network I/O before enforcement is live, regardless of which fs:read capabilities are granted.

L7 enforcement — the userspace stream proxy

The eBPF allow-list works on IP and port; it cannot see an HTTP method, a payload size, a Modbus function code, or — under TLS — even the host. Those Layer-7 constraints are enforced by a userspace stream proxy (nazelo.l7_proxy, wired in by nazelo.l7_integration.L7ProxyManager), not in the kernel.

How it fits together:

  1. The eBPF allow-list is reduced to the proxy's own address — the bypass lock (proxy_egress_rules). Every governed host resolves to the proxy via the sandbox hosts file, and the workload is pointed at it through HTTP_PROXY / HTTPS_PROXY. A client that ignores the env var still reaches only the proxy, because the kernel drops everything else.
  2. The proxy reads the opening bytes, classifies the protocol, and applies the per-host policy. It parses HTTP, TLS, Modbus, S7, and MQTT — no other protocol. Anything it cannot classify to one of these is blocked (fail-closed), never relayed.
  3. Every decision is sealed as a sandbox.l7.decision evidence event.

What each parser enforces:

Protocol Enforced
HTTP Allowed methods, payload-size cap, and (rich contract) JSON-Schema body, field allow-list, forbidden data classes
TLS The host, via SNI. Method and size are unseeable without terminating the connection, so the decision is sealed observation: unavailable — never presented as if the semantics were checked, and a run carrying one is capped below the top assurance class. This is the only TLS mode available today: the code also defines a terminating mode and a cooperative-token mode, and neither is reachable yet — a host set to either is refused rather than inspected
Modbus/TCP Blocked function codes (fail-closed on anything that is not a clean MBAP PDU boundary)
S7comm Blocked S7 functions over TPKT / COTP
MQTT Blocked PUBLISH topics (with # / + wildcards); non-PUBLISH control packets pass

The per-host policies come from the governance layer (the signed BIM, via NaZelo Trust / nazelo-critical); the core ships the proxy and the parsers, not the policies. An unlisted host gets a deny-all policy.

Which data classes it can detect. A contract's blocked_data_classes names classes from a catalog the proxy actually inspects the body for — it is a closed set, not free text:

Class Detects
pci Card and bank fields — PAN, CVV, expiry, IBAN, BIC, routing and account numbers
phi Health data — diagnosis, ICD codes, MRN, patient id, prescription, lab result
secret Credentials — password, API key, token, private key, session id
pii Personal data — email, phone, address, date of birth, national id, name, geolocation
public Everything else — the class a field gets when nothing above matches

Detection runs two ways, both deterministic: on the field NAME (a substring match) and on the VALUE (a regex), so a card number in a field called note is still caught. Matching is case-insensitive, so PCI and pci name the same class. A body the proxy cannot read fails closed.

Installing nazelo-critical adds a critical class (control and safety values over a physical process). A deployment adds its own by setting NAZELO_DATA_CLASSES to a JSON {class: [patterns]} map, or by calling nazelo.data_classes.register_data_class(...). nazelo.data_classes.known_classes() returns whatever is active — catalog, extension and registrations together — and is the list to check a contract against.

A class outside that set would be a rule that can never fire, so the governance layer refuses the run rather than starting it — naming the class and the file that declares it.

Available rules

Rule What it does
NetworkRule.allow_domain("github.com") Allow HTTP/HTTPS (ports 80, 443) to github.com
NetworkRule.allow_domain("db.corp.com", ports=[5432]) Allow PostgreSQL to db.corp.com
NetworkRule.allow_cidr("10.0.0.0/8") Allow all TCP/UDP traffic to private range
NetworkRule.allow_cidr("2001:db8::/32") Allow all TCP/UDP traffic to IPv6 prefix
NetworkRule.allow_port(443) Allow HTTPS to any destination
NetworkRule.allow_port(47808, transport="udp") Allow BACnet/IP UDP only
NetworkRule.allow_cidr("10.0.0.0/8", transport="tcp") Allow TCP only to private range
NetworkRule.deny_all() Explicit deny-all (already the default — for documentation clarity)

All rule constructors accept an optional transport parameter: "tcp", "udp", or "any" (default). The eBPF classifier enforces transport-specific rules — a UDP-only port drops TCP traffic on that port and vice versa.

Network presets

Preset Rules
no-egress deny all
ci-pipeline github.com, gitlab.com, pypi.org, registry.npmjs.org, crates.io
internal-only 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16

Protocol-specific presets (Modbus, MQTT, S7, FHIR, AFDX, OPC UA, FIX, Open Banking, nuclear_diode, ICAO 9303, ...) ship in the nazelo-critical package.

CLI reference

nazelo run

Execute a command inside a sandbox.

nazelo run [OPTIONS] COMMAND
Option Default Description
-t, --trust-level 1 Trust level (1-4)
-p, --preset Use a preset: dev, ci, untrusted
-c, --capability Add capability (repeatable): exec, fs:read:/path, egress, etc.
-a, --allow Add network rule (repeatable): domain, CIDR, port:N, preset:NAME
-w, --workspace cwd Workspace directory to mount
--timeout 30 Timeout in seconds
--json off Output as JSON
--run-id generated Correlation id stamped into every evidence event, so a run is traceable across NaZelo, PimaTika and Litatoli by one key. Printed to stderr when omitted

nazelo run streams the sandboxed command's stdout to stdout and its stderr to stderr, then exits with the same exit code as the sandboxed process. With --json it prints a structured result:

{
  "run_id": "…",
  "exit_code": 0,
  "stdout": "42\n",
  "stderr": "",
  "backend": "sandbox_tl1",
  "verified": true
}

These are the only fields currently emitted by nazelo run --json. run_id is the correlation key stamped into every evidence event.

nazelo backends

List available backends and their status.

nazelo backends [--json]

Example output (the exact columns emitted):

TL   Label        Backend                      Tool           Status
----------------------------------------------------------------------
1    LOCAL        sandbox_tl1                  bwrap          OK
2    CONTAINED    sandbox_tl2                  unshare        OK
3    ISOLATED     sandbox_tl3                  runsc          --
4    HARDENED     sandbox_tl4                  firecracker    --

OK means the backend's probe succeeded; -- means the required tool is not on PATH or the backend could not be instantiated.

nazelo token issue

Issue a signed capability token.

nazelo token issue -t LEVEL --owner OWNER [--ttl SECS] [-c CAPABILITY...]

nazelo token verify

Verify a token's signature and TTL.

nazelo token verify SIGNATURE -t LEVEL --owner OWNER --sandbox-id ID --ttl SECS -c CAPABILITY... [--extra-bind-path PATH...] --issued-at ISO

If the token was issued with extra_bind_paths (caller-supplied read-only host mounts), pass each one with --extra-bind-path (order-independent): those paths are part of the signed payload, so omitting or mutating them fails verification.

nazelo info

Show the package header, the trust-level table (label, underlying tool, tool availability, description for TL1–TL4), the running Python version, and the detected platform (sys.platform). Deeper host probes (kernel version, cgroup v2 support, eBPF readiness) are not currently reported — use nazelo backends to check backend availability.

Advanced CLI

# Run with explicit trust level and capabilities
nazelo run -t 3 -c exec -c "fs:read:/workspace" "cat /workspace/data.txt"

# Issue a capability token
nazelo token issue --owner ci-bot -t 2 -c exec -c "fs:read:/workspace"

API reference

ExecutionRequest

ExecutionRequest(
    command: str | list[str],       # Command to execute (string is shlex-split)
    timeout: int | None = None,      # Timeout in seconds (backends default to 300 if None)
    verify: bool = True,            # Require exit_code == 0 for verified=True
    unsafe_shell: bool = False,     # Pass raw string to /bin/sh -c (DANGEROUS)
    cwd: Path | None = None,        # Working directory override
    env: dict | None = None,        # Additional environment variables
    max_output_bytes: int | None = None,  # Per-stream stdout/stderr cap (None → NAZELO_MAX_OUTPUT_BYTES, 10 MiB)
)

Token lifetime vs. timeout — a backend refuses to run if its capability token would expire before timeout elapses (issued_at + ttl_seconds must outlive now + timeout). Size ttl_seconds at least as large as the longest command timeout, otherwise execute() raises ValueError. Critical-systems backends default timeout to 300s when it is None.

ExecutionResult

ExecutionResult(
    command: str,                              # The command that was executed
    exit_code: int,                            # Process exit code (-1 for timeout/error)
    stdout: str,                               # Captured stdout
    stderr: str,                               # Captured stderr
    backend_name: str,                         # e.g. "sandbox_tl1", "sandbox_tl2", "sandbox_ot"
    verification_status: VerificationStatus = NOT_REQUESTED,  # NOT_REQUESTED | PASSED | FAILED | ERROR
    verification_result: VerificationResult | None = None,    # Structured evidence (if PASSED)
    verification_error: str | None = None,     # Error description (if FAILED/ERRORED)
    truncated: bool = False,                   # True if output hit max_output_bytes
)

# `verified` is a read-only property, not a field:
#   result.verified  ==  (result.verification_status is VerificationStatus.PASSED)
# It collapses the four-case status to a bool; a gate keying on it treats
# "not asked", "failed", and "setup errored" alike, so read the status directly
# when the distinction matters.

SandboxProfile

SandboxProfile(
    trust_level: TrustLevel,                         # TL1-TL4
    capabilities: frozenset[str],                     # e.g. {"exec", "fs:read:/workspace"}
    ttl_seconds: int,                                 # Token time-to-live
    owner: str,                                       # Identity string
    network_rules: tuple[NetworkRule, ...] = (),       # Egress rules
    sandbox_id: str = "",                              # Auto-generated if empty
)

# Class methods (presets):
SandboxProfile.for_dev(owner, ttl_seconds=600, extra_network_rules=())
SandboxProfile.for_ci(owner, ttl_seconds=300, extra_network_rules=())
SandboxProfile.for_untrusted_code(owner, ttl_seconds=120, extra_network_rules=())

SandboxProfile also carries optional fields consumed by nazelo-critical to bind a profile to a specific ghost image and protocol preset. Nine ghost image hashes — ghost_plc_image_hash, ghost_chip_image_hash, ghost_ecu_image_hash, ghost_http_image_hash, ghost_hl7_image_hash, ghost_dicom_image_hash, ghost_fix_image_hash, ghost_nmea_image_hash, ghost_signalk_image_hash — plus ot_preset, target_arch, custom_l7_port, custom_l7_blocked_fcs, and blocked_can_ids. Each hash that is set is signed into the capability token (see tokens._canonical_payload). They default to empty and are ignored by the generic backends.

Backend constructors

All backends share this signature:

Backend(
    workspace_path: Path | None = None,
    *,
    token: CapabilityToken | None = None,
    profile: SandboxProfile | None = None,
    egress_controller: EbpfEgressController | None = None,
    evidence_recorder: EvidenceRecorder | None = None,
)

Available backends: TrustLevel1Backend, TrustLevel2Backend, GVisorExecutionBackend, MicroVMExecutionBackend. Critical-systems backends (OTSandboxBackend, FirecrackerOTBackend, CANSandboxBackend, SerialSandboxBackend, GhostChipSandboxBackend) ship in the nazelo-critical package.

The OTInspectableBackend protocol — ExecutionBackend + get_inspection_interface() -> str — is exported from nazelo.protocols so plugin backends can advertise where their eBPF TC filter should attach. nazelo.sandbox_api exposes the public helpers (emit_evidence, write_state, setup_cgroup, deny_real_serial_devices, ...) plugin backends consume.

Advanced library usage

For full control over the sandbox profile:

from nazelo import (
    TrustLevel,
    SandboxProfile,
    NetworkRule,
    TrustLevel1Backend,
    ExecutionRequest,
    issue_token,
    new_execution_profile,
)
from pathlib import Path

template = SandboxProfile(
    trust_level=TrustLevel.LOCAL,
    capabilities=frozenset({"exec", "fs:read:/workspace"}),
    ttl_seconds=300,
    owner="my-agent",
    network_rules=(
        NetworkRule.allow_domain("github.com"),
    ),
)
profile = new_execution_profile(template)
token = issue_token(profile)

backend = TrustLevel1Backend(workspace_path=Path.cwd(), token=token, profile=profile)
result = backend.execute(ExecutionRequest(command="python3 -c 'print(42)'", timeout=30))
print(result.stdout)     # "42\n"
print(result.verified)   # True

Evidence & audit

Every execution produces structured evidence via the EvidenceRecorder protocol.

Evidence events

Event When
sandbox.create Sandbox instantiated
sandbox.exec Command executed (includes exit_code, command, pid)
sandbox.destroy Sandbox terminated
sandbox.network.grant eBPF egress filter attached (includes policy, interfaces)
sandbox.network.revoke eBPF egress filter detached
sandbox.l7.decision L7 stream proxy sealed its per-host allow/block decisions
sandbox.process.blocked A spawn was blocked and observed by the seccomp USER_NOTIF supervisor (proc:observe-spawn)
sandbox.network.egress Content inspection pattern matched (includes pattern name, data length)

sandbox.network.egress is produced only by the content-inspection reader, which drains a BPF ring buffer. That reader is currently refused (egress:inspect never attaches — see the egress callout under Presets), so this event is not emitted in practice today. L7 allow/block evidence is now sealed by the stream proxy as sandbox.l7.decision (see L7 enforcement); the older in-kernel *_blocked events for the eBPF egress controller (sandbox.network.modbus_blocked, mqtt_blocked, s7_blocked, fhir_blocked, can_blocked) go to the same unreadable ring buffer and are not recorded. sandbox.create / exec / destroy / network.grant / network.revoke, plus sandbox.l7.decision (L7 runs) and sandbox.process.blocked (proc:observe-spawn runs), are the events actually emitted.

Custom recorder

from datetime import datetime
from nazelo import (
    VerificationResult, EvidenceRecorder, NullEvidenceRecorder,
    SandboxProfile, TrustLevel2Backend, issue_token, new_execution_profile,
)
from pathlib import Path

class MyRecorder:
    def __init__(self):
        self.audit_log = []

    def record_sandbox_event(self, event: str, details: dict) -> VerificationResult:
        self.audit_log.append({"event": event, **details})
        return VerificationResult(
            verified=True, evidence_type=event,
            details=details, timestamp=datetime.now(),
        )

profile = new_execution_profile(SandboxProfile.for_ci(owner="ci-bot"))
token = issue_token(profile)
backend = TrustLevel2Backend(
    workspace_path=Path.cwd(),
    token=token,
    profile=profile,
    evidence_recorder=MyRecorder(),
)

Sandbox state & metrics

Each sandbox creates a state file at .nazelo/sandboxes/{sandbox_id}.json containing: - sandbox_id, trust_level, pid, status, owner - Live metrics (when cgroup v2 is available): CPU usage, memory current, I/O bytes read/written

State files use atomic writes with file locking for consistency.

Configuration reference

Variable Required Description
NAZELO_SIGNING_KEY Yes (for token ops) 64-hex-char (32-byte) BLAKE3 signing key
NAZELO_FIRECRACKER_KERNEL TL4 only Path to Firecracker kernel image (vmlinux)
NAZELO_FIRECRACKER_ROOTFS TL4 only Path to Firecracker rootfs (rootfs.ext4, must contain nazelo-guest-agent)
NAZELO_EXEC_TIMEOUT_S No (default 300) Default per-command timeout when the request omits one. A per-run request.timeout still wins.
NAZELO_CGROUP_MEMORY_MAX_BYTES No (default 536870912 = 512 MiB) TL2 memory.max
NAZELO_CGROUP_CPU_MAX No (default 100000 100000 = 1 CPU) TL2 cpu.max (quota period, µs)
NAZELO_FIRECRACKER_VCPU_COUNT No (default 1) TL4 microVM vCPUs
NAZELO_FIRECRACKER_MEM_MIB No (default 256) TL4 microVM memory (MiB)
NAZELO_ANCHOR_IMAGE No (default alpine:3.20) Image for the network anchor container (pin by digest in prod)
NAZELO_MAX_OUTPUT_BYTES No (default 10485760 = 10 MiB) Per-stream stdout/stderr cap; overflow sets result.truncated. Overridable per-run via ExecutionRequest.max_output_bytes.
NAZELO_ENV_PASSTHROUGH No (default empty) Comma-separated extra env var names copied from the host into the sandbox. Security-sensitive — each name widens the sandbox env allowlist and can re-expose host secrets to the workload.
NAZELO_ANCHOR_START_TIMEOUT_S No (default 30) Seconds to wait for the network anchor container to come up.
NAZELO_ANCHOR_CMD_TIMEOUT_S No (default 10) Per-command timeout for anchor control commands (nsenter into the anchor).
NAZELO_GATE_POLL_INTERVAL_S No (default 0.02) Poll interval while waiting on the exec gate.

Every cap above is a named constant with an env override — no source patch is needed to raise a timeout or a memory limit. NAZELO_EXEC_TIMEOUT_S is shared with nazelo-critical.

Supported target_arch values: amd64 (default), aarch64 / arm64, armv7 / armhf. Mapped to Docker --platform flags for cross-architecture Ghost PLC images.

State directory: .nazelo/sandboxes/ (relative to workspace). Contains per-sandbox JSON state files with atomic locking.

Cgroup prefix: nazelo (produces /sys/fs/cgroup/nazelo-{sandbox_id}/). TL2 applies resource limits: cpu.max=100000, memory.max=512MB.

Security model

What's enforced at each tier

Aspect TL1 (bwrap) TL2 (unshare) TL3 (gVisor) TL4 (Firecracker)
Filesystem isolation Read-only mounts Full namespace User-space kernel Separate VM rootfs
Process isolation Same PID namespace Separate PID namespace Separate kernel Hardware VM boundary
Network isolation None (host network) Separate net namespace Separate net namespace vsock only
Resource limits None cgroup v2 (CPU, memory) OCI limits VM resource config
eBPF egress filtering With egress controller With egress controller With egress controller VM-level isolation

Process governance — no-spawn

When a profile carries the proc:no-spawn capability, the workload cannot create any new process — so it cannot fork a curl or a shell that egresses outside the enforced path. NaZelo builds a classic seccomp BPF program by hand (no libseccomp) that denies clone / fork / vfork / clone3 with EPERM, and kills any syscall arriving under a non-x86_64 ABI (closing the foreign-ABI bypass). bwrap installs it via --seccomp <fd> just before it execves the workload. It is fail-closed: if the filter cannot be armed, the run is refused rather than run un-governed.

proc:no-spawn refuses the spawn but does not observe it — only the workload's own "Cannot fork" reaches the chain, and a workload's self-report proves nothing. The proc:observe-spawn capability makes the denial enforcer-observed instead: the spawn syscalls return SECCOMP_RET_USER_NOTIF, and a supervisor running inside the sandbox reads each notification, records it, and responds EPERM. Every blocked clone is then sealed as a sandbox.process.blocked evidence event.

What NaZelo trusts

  • The host kernel (TL1-TL2) or gVisor's sentry (TL3)
  • The BLAKE3 signing key holder
  • DNS resolution at attach time (no re-resolution for long-running sandboxes)
  • The EvidenceRecorder implementation provided by the caller

What NaZelo does NOT trust

  • The sandboxed command (treated as untrusted at all tiers)
  • Network destinations not in the allow-list
  • Expired or tampered capability tokens

Limitations

What NaZelo does not enforce, and why:

  • UDP supported, ICMP default-denied — eBPF egress filtering inspects TCP and UDP destination ports and IPs. When an egress allow-list is attached, the classifier drops IPv4 non-TCP/UDP traffic (ICMP, SCTP, GRE, ESP…) and all ICMPv6 except the control types IPv6 needs to function (Packet-Too-Big for PMTUD, Neighbor/Router Discovery), closing ICMP-tunnel/C2 exfil channels. (A bare egress with no rules compiles a pass-all classifier — attach an allow-list to get default-deny.) L7 stream-proxy enforcement (HTTP, TLS, Modbus, S7, MQTT) is TCP-only; UDP packets are port-filtered but not L7-inspected
  • TL4 (Firecracker) requires external setup — the backend is fully implemented but requires the firecracker binary, /dev/kvm, a kernel image (NAZELO_FIRECRACKER_KERNEL), and a rootfs with nazelo-guest-agent embedded (NAZELO_FIRECRACKER_ROOTFS). Use firecracker/build_alpine_rootfs.sh to build both artifacts. Cross-architecture builds (aarch64, armv7) require Docker buildx with QEMU binfmt registered
  • Content inspection is sampled — 1-in-1000 packets are captured (via skb->hash % 1000), not exhaustive. Sampling is designed for detecting persistent leaks, not single-packet exfiltration
  • DNS resolution is point-in-time — domain rules resolve IPs at attach() time. If a domain's IP changes during a long-running sandbox, the new IP is not automatically allowed
  • No stateful connection tracking — the eBPF classifier is stateless; it does not track TCP connection state or match reply packets
  • eBPF requires tooling and privilegebpftool, tc, clang and nsenter must be installed, and the process must hold CAP_SYS_ADMIN. Without either, EbpfEgressController.unavailable_reason() returns the code and the sentence saying which one is missing, and attach raises RuntimeError

Troubleshooting

RuntimeError: NAZELO_SIGNING_KEY environment variable is not set

Generate and export a signing key:

export NAZELO_SIGNING_KEY="$(python3 -c 'import os; print(os.urandom(32).hex())')"
For persistent use, add this to your shell profile or CI secrets.

cannot enforce egress here [ebpf_tooling_missing]

The message names the binaries that are absent. Install them:

# Debian/Ubuntu
sudo apt-get install linux-tools-common linux-tools-$(uname -r) iproute2 clang util-linux

# Fedora
sudo dnf install bpftool iproute-tc clang util-linux

cannot enforce egress here [ebpf_privilege_missing]

The tooling is installed but the process may not enter the sandbox's network namespace. That needs CAP_SYS_ADMIN: run under sudo, or grant the capability to the process. Installing more packages will not change this.

cannot enforce egress here [ebpf_not_linux]

eBPF egress enforcement runs on Linux only. Use a preset that grants no network (--preset untrusted), or run on Linux.

ValueError: Capability token ... is invalid or expired

The token's TTL has elapsed. Default TTLs: dev=10min, ci=5min, untrusted=2min. Increase with ttl_seconds or re-issue the token.

Backend shows "not found"

Run nazelo backends to see which tools are installed. Install the missing tool:

# TL1: bwrap (bubblewrap)
sudo apt-get install bubblewrap

# TL2: unshare (usually included with util-linux)
sudo apt-get install util-linux

# TL3: gVisor (runsc)
# See: https://gvisor.dev/docs/user_guide/install/

TL4 (Firecracker) requires kernel + rootfs

TL4 requires a Firecracker kernel, rootfs image, and the nazelo-guest-agent embedded in the rootfs:

# Build rootfs + kernel (includes guest agent) — default amd64
cd firecracker && sudo ./build_alpine_rootfs.sh

# Cross-arch for IoT/Edge targets
sudo ./build_alpine_rootfs.sh --arch aarch64   # RPi 4/5, Jetson Nano
sudo ./build_alpine_rootfs.sh --arch armv7     # RPi 3 32-bit

# Configure
export NAZELO_FIRECRACKER_KERNEL="firecracker/vmlinux"
export NAZELO_FIRECRACKER_ROOTFS="firecracker/rootfs.ext4"

The rootfs includes Alpine Linux, nazelo-guest-agent (Rust, native AF_VSOCK on port 1024), and OpenRC init scripts. See firecracker/README.md for details on production rootfs (Yocto/Balena).

Command timed out after Xs

The command exceeded the timeout (default 30s). Increase with --timeout (CLI) or ExecutionRequest(timeout=...) (API).

Architecture overview

Aspect NaZelo
Trust model 4-tier with named tools (bwrap, unshare, gVisor, Firecracker)
Network control Domain-based rules + eBPF enforcement (IPv4 + IPv6 CIDR) + content inspection
Evidence BLAKE3-signed tokens, structured audit logs, pluggable evidence API
Compliance Evidence API plugs into any compliance engine
Cross-arch target_arch field: amd64, aarch64, armv7 — BLAKE3-signed, Docker --platform passthrough
Presets for_dev, for_ci, for_untrusted_code (critical-systems presets in nazelo-critical)
Observability Sandbox state files + cgroup metrics (CPU, memory, I/O)
Integration Python-native, CLI, any orchestrator
License BSL 1.1 → Apache-2.0 on 2030-07-30
Package Purpose
nazelo-critical Multi-domain critical-systems sandbox plugin — Ghost PLC, Ghost Chip, serial PTY, CAN bus, Firecracker backends covering ICS/OT, aerospace, automotive, medical, rail, maritime, energy, and identity protocol simulation
litatoli Cryptographic evidence engine — signs execution outcomes with BLAKE3 chain-linked audit trails
pimatika In-process governance enforcer — judges egress against a signed Build Intent Manifest, emits litatoli-format evidence
jagora-critical Multi-domain critical-systems orchestration — blueprints, TRIZ contradictions, framework compliance assessment for the 9 critical domains
Jagora AIDE Full orchestrator — code generation, compliance, TalaSala developer HUD

Integration with Jagora AIDE

NaZelo is the sandbox engine used by Jagora AIDE for:

  • Sandboxed code generation and refactoring
  • Evidence pipeline feeding into compliance reporting
  • Multi-agent governance with capability-based access control
  • Critical-systems workloads (OT, aerospace, automotive, medical, rail, maritime, energy, finance, identity) via the nazelo-critical plugin

Jagora AIDE layers orchestration, compliance interpretation (EU AI Act, IEC 62443, ISO 27001), TalaSala developer HUD, and vertical integrations on top.

Positioning

NaZelo          = "I don't trust the code"                          (sandboxed execution)
litatoli        = "I don't trust what the agent claims"             (evidence verification)
nazelo-critical = "I don't trust the critical-systems environment"  (multi-domain industrial protocol isolation)
pimatika        = "I don't trust runtime drift from build intent"   (in-process governance enforcement)
Jagora          = "I control all three, in a complete workflow"

Roadmap

  • ~~UDP/ICMP filtering — Extend eBPF classifier beyond TCP~~ ✅ Done — TCP and UDP destination ports are enforced by the eBPF TC classifier, and non-TCP/UDP traffic (ICMP/SCTP/GRE/ESP, and ICMPv6 outside the control types) is default-denied when an allow-list is attached
  • DNS re-resolution — Periodic re-resolve for long-running sandboxes
  • Stateful connection tracking — Track TCP state in eBPF for reply-packet matching
  • Sigstore attestation — Sign evidence entries with Sigstore/in-toto

License

Business Source License 1.1 — see LICENSE. It converts to Apache License 2.0 (see LICENSE-APACHE) on 2030-07-30.

What it does not restrict: reading the source, building it, running it in production, auditing it. The single restriction is offering this code to third parties as a hosted sandbox service.

The license check is not enforced in this package's own code — enforcement is handled by nazelo-saas at the managed runtime boundary.