Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
eb4b92f
feat(policy): add HttpRule, glob_match, and http_acl_check for HTTP-l…
congwang-mk Apr 4, 2026
cb3013d
fix: propagate HTTP rule parse errors and improve function naming
congwang-mk Apr 4, 2026
44a6cdb
Add hudsucker dependency to sandlock-core
congwang-mk Apr 4, 2026
53d1517
feat(http-acl): implement HttpAclProxy with hudsucker MITM proxy
congwang-mk Apr 4, 2026
4a2fe53
feat(http-acl): wire proxy into SupervisorState and redirect connect()
congwang-mk Apr 4, 2026
1bad85c
feat: wire HTTP ACL proxy startup into Sandbox::do_spawn()
congwang-mk Apr 4, 2026
9c3daec
feat: Add --http-allow and --http-deny CLI arguments
congwang-mk Apr 4, 2026
f257f6e
feat: Add FFI bindings for HTTP ACL rules
congwang-mk Apr 4, 2026
7513126
feat(python): Add HTTP ACL support to Policy dataclass and FFI bindings
congwang-mk Apr 4, 2026
82a6e7c
test: add integration tests for HTTP ACL proxy
congwang-mk Apr 4, 2026
5facdaa
feat: Add HTTP ACL support to profiles
congwang-mk Apr 4, 2026
e33a531
test: add 7 HTTP ACL integration tests for method filtering, HTTPS MI…
congwang-mk Apr 4, 2026
e4a84cc
perf: cap tokio runtime to 2 worker threads
congwang-mk Apr 4, 2026
90c497f
refactor: user-provided CA cert for HTTPS MITM instead of auto-genera…
congwang-mk Apr 4, 2026
fe6bcbe
fix: defer HTTP rule parsing to build() and validate https_ca/https_k…
congwang-mk Apr 4, 2026
db054b5
fix: lazy dummy CA and graceful proxy shutdown for HTTP ACL
congwang-mk Apr 4, 2026
9ddfd2b
fix: remove redundant shutdown() method, Drop impl suffices
congwang-mk Apr 4, 2026
c229984
fix(security): prevent Host header spoofing in HTTP ACL proxy
congwang-mk Apr 4, 2026
307cf4f
perf: add DNS cache to Host header verification in HTTP ACL proxy
congwang-mk Apr 4, 2026
7505a62
feat: add --http-port for configurable HTTP ACL port interception
congwang-mk Apr 4, 2026
6c2752a
fix: prefix unused variable with underscore in test_policy_fn
congwang-mk Apr 4, 2026
1344471
fix: MCP deny-by-default now blocks all TCP via Landlock
congwang-mk Apr 4, 2026
0e36b6c
fix: IPv6 support, orig_dest memory leak, and TOCTOU race in HTTP ACL
congwang-mk Apr 5, 2026
60b7367
fix: replace httpbin.org tests with local servers, fix proxy forwarding
congwang-mk Apr 5, 2026
7db4003
fix(security): normalize HTTP paths before ACL matching
congwang-mk Apr 5, 2026
02bfaa1
docs: add HTTP ACL documentation to READMEs
congwang-mk Apr 5, 2026
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
1,833 changes: 1,750 additions & 83 deletions Cargo.lock

Large diffs are not rendered by default.

39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ protects your working directory automatically.
| Kernel | Shared | Shared | Separate guest |
| Filesystem isolation | Landlock + seccomp COW | Overlay | Block-level |
| Network isolation | Landlock + seccomp notif | Network namespace | TAP device |
| HTTP-level ACL | Method + host + path rules | N/A | N/A |
| Syscall filtering | seccomp-bpf | seccomp | N/A |
| Resource limits | seccomp notif + SIGSTOP | cgroup v2 | VM config |

Expand Down Expand Up @@ -102,6 +103,19 @@ 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

# HTTP-level ACL (method + host + path rules via transparent proxy)
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)
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

Expand Down Expand Up @@ -151,6 +165,14 @@ result = Sandbox(policy).run(["python3", "-c", "print('hello')"])
assert result.success
assert b"hello" in result.stdout

# HTTP ACL: only allow specific API calls
agent_policy = Policy(
fs_readable=["/usr", "/lib", "/etc"],
http_allow=["POST api.openai.com/v1/chat/completions"],
http_deny=["* */admin/*"],
)
result = Sandbox(agent_policy).run(["python3", "agent.py"])

# Confine the current process (Landlock filesystem only, irreversible)
confine(Policy(fs_readable=["/usr", "/lib"], fs_writable=["/tmp"]))

Expand Down Expand Up @@ -259,6 +281,14 @@ let policy = Policy::builder()
let result = Sandbox::run(&policy, &["echo", "hello"]).await?;
assert!(result.success());

// HTTP ACL: restrict API access at the HTTP level
let policy = Policy::builder()
.fs_read("/usr").fs_read("/lib").fs_read("/etc")
.http_allow("POST api.openai.com/v1/chat/completions")
.http_deny("* */admin/*")
.build()?;
let result = Sandbox::run(&policy, &["python3", "agent.py"]).await?;

// Confine the current process (Landlock filesystem only, irreversible)
let policy = Policy::builder()
.fs_read("/usr").fs_read("/lib")
Expand Down Expand Up @@ -342,7 +372,7 @@ The async notification supervisor (tokio) handles intercepted syscalls:
|---|---|
| `clone/fork/vfork` | Process count enforcement |
| `mmap/munmap/brk/mremap` | Memory limit tracking |
| `connect/sendto/sendmsg` | IP allowlist + on-behalf execution |
| `connect/sendto/sendmsg` | IP allowlist + on-behalf execution + HTTP ACL redirect |
| `bind` | On-behalf bind + port remapping |
| `openat` | /proc virtualization, COW interception |
| `unlinkat/mkdirat/renameat2` | COW write interception |
Expand Down Expand Up @@ -469,6 +499,13 @@ Policy(
net_bind=[8080], # TCP bind ports (Landlock ABI v4+)
net_connect=[443], # TCP connect ports

# HTTP ACL (transparent proxy)
http_allow=["POST api.openai.com/v1/*"], # Allow rules (METHOD host/path)
http_deny=["* */admin/*"], # Deny rules (checked first)
http_ports=[80], # Ports to intercept (default: [80])
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
Expand Down
40 changes: 38 additions & 2 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ enum Command {
net_allow: Vec<String>,
#[arg(long = "net-deny", value_name = "PROTO")]
net_deny: Vec<String>,
#[arg(long = "http-allow", value_name = "RULE")]
http_allow: Vec<String>,
#[arg(long = "http-deny", value_name = "RULE")]
http_deny: Vec<String>,
/// TCP ports to intercept for HTTP ACL (default: 80, plus 443 with --https-ca)
#[arg(long = "http-port", value_name = "PORT")]
http_ports: Vec<u16>,
/// PEM CA certificate for HTTPS MITM (enables port 443 interception)
#[arg(long = "https-ca", value_name = "PATH")]
https_ca: Option<String>,
/// PEM CA private key for HTTPS MITM (required with --https-ca)
#[arg(long = "https-key", value_name = "PATH")]
https_key: Option<String>,
#[arg(long)]
port_remap: bool,
#[arg(long)]
Expand Down Expand Up @@ -135,7 +148,7 @@ struct SandboxStatus {
signal: Option<i32>,
}

#[tokio::main]
#[tokio::main(worker_threads = 2)]
async fn main() -> Result<()> {
let cli = Cli::parse();

Expand All @@ -145,14 +158,15 @@ async fn main() -> Result<()> {
isolate_ipc, isolate_signals, 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,
http_allow, http_deny, http_ports, https_ca, https_key,
port_remap, no_randomize_memory, no_huge_pages, deterministic_dirs, hostname, no_coredump,
env_vars, exec_shell, interactive: _, fs_deny, 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,
&net_allow, &net_deny, &http_allow, &http_deny, &http_ports,
&num_cpus, &random_seed, &time_start, no_randomize_memory,
no_huge_pages, deterministic_dirs, &hostname, &chroot,
&image, &uid, &workdir, &cwd, &fs_isolation, &fs_storage,
Expand Down Expand Up @@ -217,6 +231,17 @@ async fn main() -> Result<()> {
for h in &base.net_allow_hosts { b = b.net_allow_host(h); }
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);
}
for rule in &base.http_deny {
let s = format!("{} {}{}", rule.method, rule.host, rule.path);
b = b.http_deny(&s);
}
for port in &base.http_ports {
b = b.http_port(*port);
}
if let Some(mem) = base.max_memory { b = b.max_memory(mem); }
b = b.max_processes(base.max_processes);
if let Some(cpu) = base.max_cpu { b = b.max_cpu(cpu); }
Expand Down Expand Up @@ -281,6 +306,11 @@ async fn main() -> Result<()> {
other => return Err(anyhow!("unknown --net-deny protocol: {}", other)),
}
}
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); }
if let Some(ref ca) = https_ca { builder = builder.https_ca(ca); }
if let Some(ref key) = https_key { builder = builder.https_key(key); }
if port_remap { builder = builder.port_remap(true); }
if !cpu_cores.is_empty() { builder = builder.cpu_cores(cpu_cores); }
if !gpu_devices.is_empty() { builder = builder.gpu_devices(gpu_devices); }
Expand Down Expand Up @@ -452,6 +482,9 @@ fn validate_no_supervisor(
net_connect: &[u16],
net_allow: &[String],
net_deny: &[String],
http_allow: &[String],
http_deny: &[String],
http_ports: &[u16],
num_cpus: &Option<u32>,
random_seed: &Option<u64>,
time_start: &Option<String>,
Expand Down Expand Up @@ -486,6 +519,9 @@ fn validate_no_supervisor(
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 !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"); }
if num_cpus.is_some() { bad.push("--num-cpus"); }
if random_seed.is_some() { bad.push("--random-seed"); }
if time_start.is_some() { bad.push("--time-start"); }
Expand Down
1 change: 1 addition & 0 deletions crates/sandlock-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ serde_json = "1"
walkdir = "2"
toml = "0.8"
pathdiff = "0.2"
hudsucker = "0.22"

[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
Expand Down
6 changes: 5 additions & 1 deletion crates/sandlock-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,11 @@ pub fn notif_syscalls(policy: &Policy) -> Vec<u32> {
nrs.push(libc::SYS_shmget as u32);
}

if !policy.net_allow_hosts.is_empty() || policy.policy_fn.is_some() {
if !policy.net_allow_hosts.is_empty()
|| policy.policy_fn.is_some()
|| !policy.http_allow.is_empty()
|| !policy.http_deny.is_empty()
{
nrs.push(libc::SYS_connect as u32);
nrs.push(libc::SYS_sendto as u32);
nrs.push(libc::SYS_sendmsg as u32);
Expand Down
Loading
Loading