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:inspectis currently refused at attach time — the sampled events land in a BPF ring buffer this reader cannot consume (ringbuf_map_get_next_key/lookup_elemreturn-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.decisionevidence event. The older in-kernel L7 block-event consumers stay off (no polling thread, nobpftool 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
tcfilter 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 onlowould 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. Requiresdockerandnsenter; 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),clangandnsenteron 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:inspectis 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_elemreturn-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 assandbox.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) │
└─────────────────────────────┘
NetworkRule.allow_domain("github.com")resolves via DNS to concrete IP addresses at attach time- NaZelo generates an eBPF TC classifier (compiled with
clang -O2 -target bpf) - 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 againstin6_addr - When IP constraints are configured for one protocol family only, the other family is dropped (fail-closed)
- Everything not explicitly allowed is dropped (fail-closed)
- Every allow/deny decision is recorded via
EvidenceRecorder - 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.decisionevidence 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:
- 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 throughHTTP_PROXY/HTTPS_PROXY. A client that ignores the env var still reaches only the proxy, because the kernel drops everything else. - 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.
- Every decision is sealed as a
sandbox.l7.decisionevidence 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.
| 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.
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 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
timeoutelapses (issued_at + ttl_secondsmust outlivenow + timeout). Sizettl_secondsat least as large as the longest command timeout, otherwiseexecute()raisesValueError. Critical-systems backends defaulttimeoutto 300s when it isNone.
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
EvidenceRecorderimplementation 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
egresswith 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
firecrackerbinary,/dev/kvm, a kernel image (NAZELO_FIRECRACKER_KERNEL), and a rootfs withnazelo-guest-agentembedded (NAZELO_FIRECRACKER_ROOTFS). Usefirecracker/build_alpine_rootfs.shto 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 privilege —
bpftool,tc,clangandnsentermust 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 raisesRuntimeError
Troubleshooting¶
RuntimeError: NAZELO_SIGNING_KEY environment variable is not set¶
Generate and export a signing key:
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 |
Related packages¶
| 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-criticalplugin
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-saasat the managed runtime boundary.