Skip to content

feat: HTTP-level ACL for AI agent sandboxing#7

Merged
congwang-mk merged 26 commits intomainfrom
feat/http-acl
Apr 5, 2026
Merged

feat: HTTP-level ACL for AI agent sandboxing#7
congwang-mk merged 26 commits intomainfrom
feat/http-acl

Conversation

@congwang-mk
Copy link
Copy Markdown
Contributor

@congwang-mk congwang-mk commented Apr 4, 2026

Summary

  • Add HTTP-level access control (--http-allow / --http-deny) that enforces method + host + path rules on HTTP/HTTPS traffic
  • Transparent MITM proxy (hudsucker) runs inside the supervisor tokio runtime — child connections to port 80/443 are redirected via existing connect() interception
  • For HTTPS, auto-generated CA cert is injected via SSL_CERT_FILE
  • No existing AI agent sandbox (E2B, Daytona, Modal, Deno) offers HTTP-level ACL — this is a differentiator

Example

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

Changes across layers

Layer Files What
Policy policy.rs HttpRule struct, parsing, matching, http_acl_check()
Proxy http_acl.rs hudsucker-based MITM proxy with AclHandler
Supervisor network.rs, notif.rs Redirect port 80/443 connect() to proxy
Orchestrator sandbox.rs Spawn proxy, inject CA cert, wire state
CLI main.rs --http-allow, --http-deny args
FFI lib.rs sandlock_policy_builder_http_allow/deny
Python policy.py, _sdk.py http_allow/http_deny fields
Profiles profile.rs TOML profile support
Tests test_http_acl.rs 11 integration tests + 21 unit tests

Performance (wrk + nginx, 256B response, 1 thread, 1 connection)

                        Avg Latency    Req/sec
Bare (no sandbox)         29.1 us      35,537
Sandlock, no ACL          26.0 us      36,337
Sandlock, with ACL        26.3 us      36,660

The HTTP ACL proxy adds ~1 us per request (~4% at 36K rps). For AI agent workloads (100ms+ API latency), this is 0.001% overhead — completely invisible.

Test plan

  • 21 unit tests for HttpRule parsing, matching, and ACL evaluation logic
  • 11 integration tests (require network, #[ignore]):
    • Allow/deny basic HTTP requests
    • Method-level filtering (GET vs POST)
    • HTTPS through MITM proxy (allow + deny)
    • Multiple allow rules, wildcard host with specific deny
    • Non-HTTP port bypasses proxy, HTTP ACL + IP allowlist interaction
  • Benchmarked with wrk + nginx: ~1us proxy overhead per request
  • Manual test with real AI agent

Generated with Claude Code

congwang-mk and others added 26 commits April 4, 2026 19:26
…evel ACL

Add HTTP access control data structures and matching logic to the policy
module. This includes HttpRule parsing from "METHOD host/path" format,
glob-based path matching, and an ACL check function with deny-first
evaluation semantics. Fields added to Policy and PolicyBuilder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Change http_allow and http_deny builder methods to propagate parse errors
   using expect() instead of silently dropping malformed rules. This is critical
   for security as silently ignoring a rule could bypass intended restrictions.

2. Rename glob_match to prefix_or_exact_match to accurately reflect the limited
   matching capabilities. Update doc comment to clarify that only trailing '*'
   is supported, not mid-pattern wildcards.

All existing tests pass with the updated names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
HTTP/HTTPS MITM proxy library built on hyper + tokio-rustls + rcgen.
Will be used to implement the HTTP ACL proxy in the next task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add http_acl module with AclHandler (hudsucker HttpHandler) that
enforces HTTP ACL allow/deny rules via http_acl_check, and
spawn_http_acl_proxy() that generates a CA cert, binds to a random
local port, and spawns the proxy on the tokio runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add http_acl_addr to SupervisorState and has_http_acl to NotifPolicy so
the supervisor intercepts connect() when HTTP ACL is active. In
connect_on_behalf(), redirect port 80/443 connections to the proxy
address while preserving the existing IP allowlist check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Spawn the HTTP ACL proxy before fork when http_allow/http_deny rules are
configured, inject SSL_CERT_FILE into the child environment, add the CA
cert path to fs_readable, and set http_acl_addr on the supervisor state
so connect() can redirect traffic through the proxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add http_allow and http_deny fields to the Run variant with RULE value names
- Wire into PolicyBuilder with loops that call builder.http_allow() and builder.http_deny()
- Add http_allow and http_deny parameters to validate_no_supervisor() function
- Add validation checks to reject --http-allow and --http-deny in --no-supervisor mode
- Add http_allow and http_deny to command destructuring pattern in main()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add sandlock_policy_builder_http_allow and sandlock_policy_builder_http_deny
functions following the same pattern as existing builder methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add http_allow and http_deny fields to Policy dataclass
- Register FFI functions for http_allow and http_deny builder calls
- Wire rules into policy builder construction
- Add fields to _HANDLED_FIELDS for validation

Format: "METHOD host/path" with glob matching support.
When http_allow is set, all other HTTP requests are denied by default.
A transparent MITM proxy is spawned in the supervisor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 4 integration tests verifying HTTP ACL proxy behavior end-to-end:
- test_http_allow_get: allowed rule permits matching requests
- test_http_deny_non_matching: non-matching paths are blocked (403)
- test_http_deny_precedence: deny rules take precedence over allow
- test_http_no_acl_unrestricted: no rules means unrestricted access

All tests are marked #[ignore] since they require network access to
httpbin.org.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Parse http_allow and http_deny from TOML profiles in parse_profile().
Wire HTTP ACL rules from loaded profiles into the CLI builder, converting
HttpRule structs back to "METHOD host/path" string format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…TM, wildcards, and more

Cover method-level filtering (GET vs POST), HTTPS MITM proxy allow/deny,
multiple allow rules, wildcard host with deny precedence, non-HTTP port
passthrough, and HTTP ACL combined with net_allow_hosts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
One thread for the seccomp notification loop, one for the HTTP ACL
proxy. The supervisor is I/O-bound so more threads just waste memory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tion

- Remove auto-generated CA cert and SSL_CERT_FILE injection
- Add --https-ca and --https-key CLI args for user-provided CA
- Only intercept port 443 when CA cert is provided; port 80 always
  intercepted when HTTP ACL rules exist
- Add https_ca/https_key to Policy, PolicyBuilder, FFI, and Python SDK
- No CA = HTTP-only ACL (port 80). With CA = HTTP + HTTPS (port 80+443)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ey pairing

PolicyBuilder.http_allow()/http_deny() no longer panic on invalid input.
Rules are stored as raw strings and parsed in build(), which returns
PolicyError on malformed rules. Also rejects configurations where only
one of --https-ca/--https-key is provided.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Avoid expensive RSA keygen on every proxy spawn in HTTP-only mode by
caching a dummy CA via LazyLock. Add graceful shutdown support: the
proxy handle now uses a oneshot channel with with_graceful_shutdown(),
and a Drop impl ensures cleanup when the sandbox is dropped. The handle
is stored on the Sandbox struct so it lives as long as the child process.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A sandboxed process could bypass HTTP ACL rules by setting a spoofed
Host header (e.g. Host: allowed.com) while connecting to a different
IP. The proxy had no way to verify the claim.

Fix: the supervisor now records a mapping of (local_socket_addr →
original_dest_ip) after each redirected connect(). The proxy handler
looks up the original destination via ctx.client_addr, resolves the
claimed Host to IPs, and rejects the request if none match. This
closes the Host header spoofing vector.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The verify_host() check resolves the claimed Host to IPs on every
request, which adds latency. Add a TTL-based DNS cache (30s) so
repeated requests to the same host skip the lookup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously the proxy only intercepted hardcoded ports 80 and 443.
HTTP on non-standard ports (e.g. 8080) bypassed ACL entirely.

Add --http-port <PORT> (repeatable) to specify which TCP ports the
HTTP ACL proxy intercepts. When omitted, defaults to [80] (plus 443
when --https-ca is set). This closes the exfiltration gap on custom
ports.

Wired through CLI, profiles, FFI, and Python bindings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
policy_for_tool() set net_connect=[] intending "block all", but the
SDK treated empty lists as "not set" so Landlock never activated TCP
filtering. Fix: use net_connect=[0] and net_bind=[0] (port 0 is never
a real target) to activate Landlock network restrictions while allowing
nothing. Also enable no_udp for complete network deny-by-default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Support IPv6 connections through HTTP ACL proxy by redirecting via
  IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) with sockaddr_in6
- Fix orig_dest map memory leak: clean up entries on all 403 paths,
  not just on allowed requests
- Fix TOCTOU race: write orig_dest mapping before connect() by
  explicitly binding the socket first, eliminating the window where
  the proxy could receive a request before the mapping exists
- Add 4 IPv6 integration tests: allow, deny, non-intercepted port,
  and remote AAAA connectivity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rewrite all HTTP ACL integration tests to use local TCP servers
  spawned in Rust test threads — no external network dependency
- Fix transparent proxy forwarding: reconstruct absolute URI from Host
  header so hudsucker knows where to forward allowed requests (was
  returning 502 for relative URIs like "GET /path")
- Fix seccomp notif list: add connect/sendto/sendmsg/bind to
  notification syscalls when http_allow/http_deny are set (was only
  triggered by net_allow_hosts, so HTTP ACL without IP allowlist
  silently bypassed the proxy)
- All 12 HTTP ACL tests now run without #[ignore], 0 external deps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add normalize_path() that decodes percent-encoding, collapses duplicate
slashes, and resolves . and .. segments before matching against ACL
rules. Without this, attackers could bypass deny rules via:
  /v1//admin/settings  (double slash)
  /v1/../admin/settings  (dot-dot traversal)
  /%61dmin/settings  (percent-encoded 'a')

Applied to both incoming request paths (in matches()) and rule paths
(in parse()) so both sides are in canonical form. Includes 12 new unit
tests covering normalization and bypass prevention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add HTTP ACL feature documentation:
- CLI examples (--http-allow, --http-deny, --https-ca/--https-key)
- Python API example with http_allow/http_deny
- Rust API example with http_allow/http_deny
- Feature comparison table entry
- Policy reference with all HTTP ACL fields
- Python SDK README: new HTTP ACL section with parameter table,
  rule format docs, and code example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@congwang-mk congwang-mk merged commit c54684d into main Apr 5, 2026
4 checks passed
congwang-mk added a commit that referenced this pull request Apr 5, 2026
Add HTTP access control (--http-allow / --http-deny) that enforces
method + host + path rules on HTTP/HTTPS traffic. A transparent MITM
proxy (hudsucker) runs inside the supervisor's tokio runtime — child
connections to intercepted ports are redirected via seccomp connect()
interception.

Rule format: "METHOD host/path" with wildcard (*) and trailing-*
prefix matching. Deny rules are evaluated first; when allow rules
exist, non-matching requests are denied by default. Paths are
normalized (percent-decoding, dot-segment resolution, slash collapsing)
to prevent ACL bypasses.

Key design decisions:
- User-provided CA for HTTPS MITM (--https-ca/--https-key) rather
  than auto-generated certs — explicit trust, no surprise injection
- Configurable port interception (--http-port) — defaults to 80,
  adds 443 when CA is provided
- Host header verification via orig_dest map prevents spoofing
- DNS cache (30s TTL) on Host verification to reduce latency
- Lazy dummy CA for HTTP-only mode avoids per-spawn keygen cost
- Graceful proxy shutdown via Drop impl on the proxy handle
- IPv6 support via IPv4-mapped addresses (::ffff:127.0.0.1)
- TOCTOU-safe: orig_dest written before connect(), not after

Wired through all layers: CLI, profiles, FFI, Python SDK, and MCP.
Includes 12 integration tests (local servers, no external deps) and
49 unit tests covering rule parsing, matching, normalization, and
bypass prevention.

Also fixes MCP deny-by-default to actually block TCP via Landlock
(net_connect=[0] instead of empty list).

Signed-off-by: Cong Wang <cwang@multikernel.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant