Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 93 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,24 +100,35 @@ sandlock run -i -r /usr -r /lib -r /lib64 -r /bin -r /etc -w /tmp -- /bin/sh
# Resource limits + timeout
sandlock run -m 512M -P 20 -t 30 -- ./compute.sh

# Domain-based network isolation
sandlock run --net-allow-host api.openai.com -r /usr -r /lib -r /etc -- python3 agent.py
# Outbound allowlist — restrict to one host on one port
sandlock run --net-allow api.openai.com:443 -r /usr -r /lib -r /etc -- python3 agent.py

# Multiple ports for one host, plus a separate any-IP port
sandlock run --net-allow github.com:22,443 --net-allow :8080 \
-r /usr -r /lib -r /etc -- python3 agent.py

# UDP — opt in to UDP and allowlist the destination (e.g. DNS)
sandlock run --allow-udp --net-allow 1.1.1.1:53 --net-allow :443 \
-r /usr -r /lib -r /etc -- ./client

# HTTP-level ACL (method + host + path rules via transparent proxy)
# HTTP rules with concrete hosts auto-extend --net-allow with host:80,443
sandlock run \
--http-allow "GET docs.python.org/*" \
--http-allow "POST api.openai.com/v1/chat/completions" \
--http-deny "* */admin/*" \
-r /usr -r /lib -r /etc -- python3 agent.py

# HTTPS MITM with user-provided CA (enables ACL on port 443)
# Generate a CA, add the cert to the sandbox's trust store
# (e.g. /etc/ssl/certs/), then pass both files here.
sandlock run \
--http-allow "POST api.openai.com/v1/*" \
--https-ca ca.pem --https-key ca-key.pem \
-r /usr -r /lib -r /etc -- python3 agent.py

# TCP port restrictions (Landlock)
sandlock run --net-bind 8080 --net-connect 443 -r /usr -r /lib -r /etc -- python3 server.py
# Server listening on a port (Landlock --net-bind, separate from --net-allow)
sandlock run --net-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py

# Clean environment
sandlock run --clean-env --env CC=gcc \
Expand Down Expand Up @@ -273,7 +284,7 @@ def on_event(event, ctx):

policy = Policy(
fs_readable=["/usr", "/lib", "/etc"],
net_allow_hosts=["api.example.com"],
net_allow=["api.example.com:443"],
)
result = Sandbox(policy, policy_fn=on_event).run(["python3", "agent.py"])
```
Expand Down Expand Up @@ -493,6 +504,75 @@ Map and reduce run in separate sandboxes with independent policies —
the mapper has data access, the reducer doesn't. Each clone inherits
Landlock + seccomp confinement. `CLONE_ID=0..N-1` is set automatically.

### Network Model

Outbound traffic is gated by a single endpoint allowlist. Each
`--net-allow` rule names a `(host, ports)` pair, multiple rules are
OR'd, and a destination is permitted iff `(IP, port)` matches at least
one rule. The same allowlist applies to TCP `connect()` and to UDP
`sendto` / `sendmsg` destinations — the latter only relevant when
`--allow-udp` is set, since UDP socket creation is denied by default.

```
--net-allow <spec> repeatable; no rules = deny all outbound
<spec> = host:port[,port,...] (IP-restricted)
| :port | *:port (any IP)
```

**Defaults.** With no `--net-allow` and no HTTP ACL flags, Landlock
denies every TCP `connect()`, UDP and raw socket creation are denied
at the seccomp layer, and there is no on-behalf path active. There is
no "allow-all networking" mode — opt in with explicit endpoints.

**Resolution.** Concrete hostnames are resolved once at sandbox start
and pinned in a synthetic `/etc/hosts`. The synthetic file replaces
the real one only when `--net-allow` includes at least one concrete
host; pure `:port` rules leave the real `/etc/hosts` and DNS visible.

**Wildcards.** Hostnames are matched literally. `--net-allow
*.example.com:443` is **not** supported — list each domain you need.
The `*` form is only valid as the host part of a `*:port` rule (alias
for `:port`).

**Implementation.** Two enforcement paths:

* **Direct path** — pure `:port` policies (no concrete host) and no
HTTP ACL. Landlock enforces the TCP port allowlist at the kernel
level; no per-syscall overhead. UDP is not covered by Landlock and
therefore always uses the on-behalf path when allowed.
* **On-behalf path** — any concrete host, any HTTP ACL rule, or
`--allow-udp`. Seccomp traps `connect()`, `sendto()`, and
`sendmsg()`; the supervisor checks the `(ip, port)` against the
resolved allowlist and performs the syscall. The HTTP/HTTPS proxy
redirect (when configured) happens here too.

**HTTP / HTTPS interception.** `--http-allow` / `--http-deny` route
matching ports through a transparent proxy. Each rule with a concrete
host auto-extends `--net-allow` with `host:80` (and `host:443` when
`--https-ca` is set) so the proxy's intercept ports are reachable;
wildcard hosts auto-add `:80` / `:443` (any IP). HTTPS MITM is opt-in:
pass `--https-ca <cert>` and `--https-key <key>` for a CA *you generate*
and trust inside the sandbox (typically install the cert into the
workload's `/etc/ssl/certs/`). Without `--https-ca`, port 443 is not
intercepted — `--net-allow host:443` permits raw TLS to the host with
no content inspection.

**Bind.** `--net-bind <port>` is independent from `--net-allow` and
governs server-side `bind()`. Landlock enforces it; `--port-remap` adds
on-behalf virtualization for binding.

**UDP, ICMP, unix.** Default-deny, opt in via dedicated flags:

* `--allow-udp` enables UDP socket creation. Outbound UDP
destinations are then gated by the same `--net-allow` allowlist
used for TCP — the seccomp on-behalf path also covers `sendto` /
`sendmsg`. Example: `--allow-udp --net-allow 1.1.1.1:53` for DNS.
* `--allow-icmp` narrowly permits `socket(AF_INET, SOCK_RAW,
IPPROTO_ICMP)` and the IPv6 equivalent only — enough for `ping`.
Other raw socket types stay denied.
* AF_UNIX sockets are governed by Landlock's
`LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET`.

### Port Virtualization

Each sandbox gets a full virtual port space. Multiple sandboxes can bind
Expand Down Expand Up @@ -553,10 +633,11 @@ Policy(
deny_syscalls=None, # None = default blocklist
allow_syscalls=None, # Allowlist mode (stricter)

# Network
net_allow_hosts=["api.example.com"], # Domain allowlist
net_bind=[8080], # TCP bind ports (Landlock ABI v4+)
net_connect=[443], # TCP connect ports
# Network — see "Network Model" above. Each entry is `host:port[,port,...]`,
# `:port`, or `*:port`. Empty list = deny all outbound. Same allowlist
# gates UDP destinations when allow_udp=True (e.g. `:53` for DNS).
net_allow=["api.example.com:443", "github.com:22,443", ":8080"],
net_bind=[8080], # TCP bind ports (Landlock; ABI v4+)

# HTTP ACL (transparent proxy)
http_allow=["POST api.openai.com/v1/*"], # Allow rules (METHOD host/path)
Expand All @@ -565,9 +646,9 @@ Policy(
https_ca="ca.pem", # CA cert for HTTPS MITM (adds port 443)
https_key="ca-key.pem", # CA key for HTTPS MITM

# Socket restrictions
no_raw_sockets=True, # Block SOCK_RAW (default)
no_udp=False, # Block SOCK_DGRAM
# Socket restrictions (raw sockets and UDP denied by default)
allow_udp=False, # CLI: --allow-udp; outbound UDP still gated by net_allow
allow_icmp=False, # CLI: --allow-icmp; permits ICMP raw only (AF_INET/AF_INET6 + SOCK_RAW + IPPROTO_ICMP[V6])

# Resources
max_memory="512M", # Memory limit
Expand Down
92 changes: 50 additions & 42 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ enum Command {
max_processes: Option<u32>,
#[arg(short = 't', long)]
timeout: Option<u64>,
#[arg(long = "net-allow-host")]
net_allow_host: Vec<String>,
/// Outbound endpoint allow rule (TCP, plus UDP when
/// `--allow-udp` is set). Repeatable. Each value is
/// `host:port[,port,...]` (IP-restricted), `:port` or `*:port`
/// (any IP). Examples: `api.openai.com:443`,
/// `github.com:22,443`, `:8080`, `1.1.1.1:53`.
/// See README "Network Model".
#[arg(long = "net-allow", value_name = "SPEC")]
net_allow: Vec<String>,
#[arg(long = "net-bind")]
net_bind: Vec<u16>,
#[arg(long = "net-connect")]
net_connect: Vec<u16>,
#[arg(long)]
time_start: Option<String>,
#[arg(long)]
Expand Down Expand Up @@ -65,10 +69,18 @@ enum Command {
fs_storage: Option<String>,
#[arg(long = "max-disk")]
max_disk: Option<String>,
#[arg(long = "net-allow", value_name = "PROTO")]
net_allow: Vec<String>,
#[arg(long = "net-deny", value_name = "PROTO")]
net_deny: Vec<String>,
/// Allow UDP socket creation. UDP is denied by default; this
/// turns it back on. Outbound UDP destinations are still
/// gated by `--net-allow` (the same endpoint allowlist used
/// for TCP).
#[arg(long = "allow-udp")]
allow_udp: bool,
/// Allow ICMP raw sockets only — `socket(AF_INET, SOCK_RAW,
/// IPPROTO_ICMP)` and the IPv6 equivalent. Other `SOCK_RAW`
/// types stay denied. Useful for `ping` without granting full
/// packet-crafting capability.
#[arg(long = "allow-icmp")]
allow_icmp: bool,
#[arg(long = "http-allow", value_name = "RULE")]
http_allow: Vec<String>,
#[arg(long = "http-deny", value_name = "RULE")]
Expand Down Expand Up @@ -163,19 +175,19 @@ async fn main() -> Result<()> {

match cli.command {
Command::Run { fs_read, fs_write, max_memory, max_processes, timeout,
net_allow_host, net_bind, net_connect, time_start, random_seed,
net_allow, net_bind, time_start, random_seed,
clean_env, num_cpus, profile: profile_name, status_fd,
max_cpu, max_open_files, chroot, uid, workdir, cwd,
fs_isolation, fs_storage, max_disk, net_allow, net_deny,
fs_isolation, fs_storage, max_disk, allow_udp, allow_icmp,
http_allow, http_deny, http_ports, https_ca, https_key,
port_remap, no_randomize_memory, no_huge_pages, deterministic_dirs, name, no_coredump,
env_vars, exec_shell, interactive: _, fs_deny, fs_mount, cpu_cores, gpu_devices, image, dry_run, no_supervisor, cmd } =>
{
if no_supervisor {
validate_no_supervisor(
&max_memory, &max_processes, &max_cpu, &max_open_files,
&timeout, &net_allow_host, &net_bind, &net_connect,
&net_allow, &net_deny, &http_allow, &http_deny, &http_ports,
&timeout, &net_allow, &net_bind,
allow_udp, allow_icmp, &http_allow, &http_deny, &http_ports,
&num_cpus, &random_seed, &time_start, no_randomize_memory,
no_huge_pages, deterministic_dirs, &name, &chroot,
&image, &uid, &workdir, &cwd, &fs_isolation, &fs_storage,
Expand Down Expand Up @@ -235,12 +247,13 @@ async fn main() -> Result<()> {
for p in &base.fs_readable { b = b.fs_read(p); }
for p in &base.fs_writable { b = b.fs_write(p); }
for p in &base.fs_denied { b = b.fs_deny(p); }
if let Some(hosts) = &base.net_allow_hosts {
b = b.net_restrict_hosts();
for h in hosts { b = b.net_allow_host(h); }
for rule in &base.net_allow {
let port_csv: Vec<String> = rule.ports.iter().map(|p| p.to_string()).collect();
let host_part = rule.host.as_deref().unwrap_or("");
let spec = format!("{}:{}", host_part, port_csv.join(","));
b = b.net_allow(spec);
}
for p in &base.net_bind { b = b.net_bind_port(*p); }
for p in &base.net_connect { b = b.net_connect_port(*p); }
for rule in &base.http_allow {
let s = format!("{} {}{}", rule.method, rule.host, rule.path);
b = b.http_allow(&s);
Expand All @@ -257,8 +270,8 @@ async fn main() -> Result<()> {
if let Some(cpu) = base.max_cpu { b = b.max_cpu(cpu); }
if let Some(seed) = base.random_seed { b = b.random_seed(seed); }
if let Some(n) = base.num_cpus { b = b.num_cpus(n); }
b = b.no_raw_sockets(base.no_raw_sockets);
b = b.no_udp(base.no_udp);
b = b.allow_udp(base.allow_udp);
b = b.allow_icmp(base.allow_icmp);
b = b.clean_env(base.clean_env);
if let Some(ref w) = base.workdir { b = b.workdir(w); }
if let Some(ref c) = base.cwd { b = b.cwd(c); }
Expand All @@ -272,9 +285,8 @@ async fn main() -> Result<()> {
for p in &fs_write { builder = builder.fs_write(p); }
if let Some(ref m) = max_memory { builder = builder.max_memory(ByteSize::parse(m)?); }
if let Some(n) = max_processes { builder = builder.max_processes(n); }
for h in &net_allow_host { builder = builder.net_allow_host(h); }
for spec in &net_allow { builder = builder.net_allow(spec); }
for p in &net_bind { builder = builder.net_bind_port(*p); }
for p in &net_connect { builder = builder.net_connect_port(*p); }
if let Some(seed) = random_seed { builder = builder.random_seed(seed); }
if clean_env { builder = builder.clean_env(true); }
if let Some(n) = num_cpus { builder = builder.num_cpus(n); }
Expand Down Expand Up @@ -306,19 +318,12 @@ async fn main() -> Result<()> {
}
if let Some(ref path) = fs_storage { builder = builder.fs_storage(path); }
if let Some(ref s) = max_disk { builder = builder.max_disk(ByteSize::parse(s)?); }
for proto in &net_allow {
match proto.as_str() {
"icmp" => { builder = builder.no_raw_sockets(false); }
other => return Err(anyhow!("unknown --net-allow protocol: {}", other)),
}
}
for proto in &net_deny {
match proto.as_str() {
"raw" => { builder = builder.no_raw_sockets(true); }
"udp" => { builder = builder.no_udp(true); }
other => return Err(anyhow!("unknown --net-deny protocol: {}", other)),
}
}
if allow_udp { builder = builder.allow_udp(true); }
// --allow-icmp narrowly permits ICMP raw sockets; arbitrary
// raw sockets stay denied. The seccomp filter inspects the
// protocol arg of `socket()` so non-ICMP `SOCK_RAW` is
// still rejected.
if allow_icmp { builder = builder.allow_icmp(true); }
for rule in &http_allow { builder = builder.http_allow(rule); }
for rule in &http_deny { builder = builder.http_deny(rule); }
for port in &http_ports { builder = builder.http_port(*port); }
Expand Down Expand Up @@ -410,9 +415,14 @@ async fn main() -> Result<()> {
sb.spawn(&cmd_strs).await?;

let pid = sb.pid().unwrap_or(0);
let registered_hosts: Vec<String> = policy
.net_allow
.iter()
.filter_map(|r| r.host.clone())
.collect();
if let Err(e) = network_registry::register(
&sandbox_name, pid, std::collections::HashMap::new(),
policy.net_allow_hosts.clone().unwrap_or_default(),
registered_hosts,
None, // virtual_etc_hosts populated by core at runtime
) {
eprintln!("sandlock: network registry: {}", e);
Expand Down Expand Up @@ -581,11 +591,10 @@ fn validate_no_supervisor(
max_cpu: &Option<u8>,
max_open_files: &Option<u32>,
timeout: &Option<u64>,
net_allow_host: &[String],
net_bind: &[u16],
net_connect: &[u16],
net_allow: &[String],
net_deny: &[String],
net_bind: &[u16],
allow_udp: bool,
allow_icmp: bool,
http_allow: &[String],
http_deny: &[String],
http_ports: &[u16],
Expand Down Expand Up @@ -619,11 +628,10 @@ fn validate_no_supervisor(
if max_cpu.is_some() { bad.push("--max-cpu"); }
if max_open_files.is_some() { bad.push("--max-open-files"); }
if timeout.is_some() { bad.push("--timeout"); }
if !net_allow_host.is_empty() { bad.push("--net-allow-host"); }
if !net_bind.is_empty() { bad.push("--net-bind"); }
if !net_connect.is_empty() { bad.push("--net-connect"); }
if !net_allow.is_empty() { bad.push("--net-allow"); }
if !net_deny.is_empty() { bad.push("--net-deny"); }
if !net_bind.is_empty() { bad.push("--net-bind"); }
if allow_udp { bad.push("--allow-udp"); }
if allow_icmp { bad.push("--allow-icmp"); }
if !http_allow.is_empty() { bad.push("--http-allow"); }
if !http_deny.is_empty() { bad.push("--http-deny"); }
if !http_ports.is_empty() { bad.push("--http-port"); }
Expand Down
Loading
Loading