DEV Community

Cover image for The capability ceiling — how ACT sandboxes third-party tools
Alexander Shishenko
Alexander Shishenko

Posted on

The capability ceiling — how ACT sandboxes third-party tools

Handing a third-party tool to your AI agent is the same problem as
handing a third-party binary to cron. The tool's author may be a
good actor or not. The agent may misuse the tool or not. The
operator — you — wants a floor on how bad either outcome can get.

ACT's policy layer is about installing that floor. This post walks
through how it works, from the wasmtime VM up to the DNS resolver.

Three layers, explicit

┌─────────────────────────────────────────────────────┐
│  ACT policy (declaration × operator intent)         │ ← what this post is about
├─────────────────────────────────────────────────────┤
│  WASI capabilities (wasi:filesystem, wasi:http, …)  │ ← capability imports
├─────────────────────────────────────────────────────┤
│  wasmtime VM (JIT, linear memory, no host syscalls) │ ← isolation
└─────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Confusing the bottom two layers is a common trap. The isolation
is wasmtime: a full WebAssembly VM with a JIT, linear-memory
boundaries, and no direct syscall access. A component can't read
/etc/passwd, open a raw socket, or execve. It can only call
imports the host chose to wire up.

WASI is the capability-oriented I/O surface. A component asks
for imports like wasi:filesystem or wasi:http; the host either
provides them, provides a gated shim, or leaves them unlinked. Those
imports are positive-only — there's no "deny" at the capability
level; a component either has the import or it doesn't.

ACT policy is the layer this post is about. It sits between the
component's declared capability intent and the operator's runtime
grants, and makes sure neither side escalates past the other.

Declarations are ceilings

Every ACT component ships a manifest (act.toml or a merged
equivalent from Cargo.toml / pyproject.toml / package.json).
If it needs filesystem access, it must declare it:

[std.capabilities."wasi:filesystem"]
description = "Stores the database file."

[[std.capabilities."wasi:filesystem".allow]]
path = "**"           # glob — "**" means any path
mode = "rw"           # "ro" or "rw"
Enter fullscreen mode Exit fullscreen mode

If it needs outbound HTTP:

[std.capabilities."wasi:http"]
description = "Fetches OpenAPI specs from public catalogs."

[[std.capabilities."wasi:http".allow]]
host = "petstore3.swagger.io"   # "*" = any, "*.suffix" = suffix, else exact
scheme = "https"                # optional
methods = ["GET"]               # optional
ports = [443]                   # optional
Enter fullscreen mode Exit fullscreen mode

act-build pack validates these at build time and embeds them in the
act:component custom section. act-build validate re-parses at
any point in the supply chain. A component with a missing declaration
— or one that declares the capability table but leaves allow empty
— is hard-deny at host-load time, full stop. There's no way to "oops,
I forgot to declare" yourself into ambient access.

Operator policy is the other half

Separately, the operator specifies what they'll grant:

act run <component> \
  --fs-policy allowlist \
  --fs-allow "/data/**" \
  --fs-allow "/tmp/work/db.sqlite" \
  --http-policy allowlist \
  --http-allow "host=api.example.com;scheme=https" \
  --http-deny "cidr=10.0.0.0/8" \
  --http-deny "cidr=169.254.169.254/32"
Enter fullscreen mode Exit fullscreen mode

Or packaged as a profile in ~/.config/act/config.toml:

[profile.sqlite-dev.policy]
fs = { mode = "allowlist", allow = [
  { path = "/Users/me/dev.sqlite", mode = "rw" },
]}
Enter fullscreen mode Exit fullscreen mode

This is the operator's intent, not the component's. It can be as
liberal or paranoid as you like.

The effective policy is the intersection

The host computes user ∩ declaration at component load. Concretely,
in the runtime code:

// runtime/effective.rs
pub fn effective_fs(
    user: &FsConfig,
    declared: &[FilesystemAllow],
) -> FsConfig {  }
Enter fullscreen mode Exit fullscreen mode

Every operator --fs-allow entry is checked against the declared
ceiling; entries that fall outside the ceiling are silently dropped.
Symmetrically, a declaration alone doesn't grant anything — the
operator still has to opt in.

This model has two nice properties:

  • A permissive operator (say, --fs-policy open) still can't let a component read files outside what it declared. The ceiling stops them.
  • A lazy component author can't silently reach outside the operator's policy. The WASI layer's capability imports come from the host; the host refuses to wire up more than the operator authorized.

DNS-layer deny

HTTP policy is the more interesting half because redirects, CIDR
rules, and DNS all interact.

The reqwest-backed HTTP client that ACT
uses has a custom DNS resolver that sits in front of every
outbound request. After a name resolves, every resolved IP
is checked against the operator's --http-deny cidr=… rules
before the client proceeds to connect.

reqwest → PolicyDnsResolver
           ├── resolve("api.example.com")
           │    → [1.2.3.4, 5.6.7.8]
           ├── check each IP vs deny-CIDRs
           ├── check each IP vs allow-CIDRs
           └── if none survives: DnsError
Enter fullscreen mode Exit fullscreen mode

A component that tries to phone home to 169.254.169.254 (the
cloud-metadata service IP) doesn't get a "connection refused" —
it gets a DnsError, before the socket is ever opened. That's a
deliberate choice: attackers can differentiate connection-refused
from DNS-not-resolved, and we want the failure indistinguishable
from the name never existing.

Per-hop redirect re-check

Every hop of an HTTP redirect chain is re-checked against the same
policy. A 302 to a denied host fails mid-chain instead of quietly
succeeding. The client uses a custom redirect::Policy that invokes
the same network::decide function used for the initial request:

// runtime/http_client.rs
fn build_redirect_policy(
    cfg: HttpConfig, declared: Vec<HttpAllow>
) -> reqwest::redirect::Policy {
    reqwest::redirect::Policy::custom(move |attempt| {
        match decide(&cfg, &declared, attempt.url()) {
            Decision::Allow => attempt.follow(),
            Decision::Deny(_) => attempt.stop(),
            
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

This closes the redirect-smuggling bypass that naive host-list
filters fall into.

Ancestor traversal, a practical detail

WASI path resolution stats every intermediate directory when opening
a nested file. The first cut of the policy bit us with a real
component on this: an --fs-allow /tmp/work/db.sqlite entry failed
to open the file because WASI needed to stat /tmp/work and /tmp
first, and neither was explicitly allowed.

The fix: an allow entry for /tmp/work/db.sqlite now implicitly
permits /tmp/work and /tmp for directory traversal, while sibling
files in those directories remain denied. The implementation walks
target.ancestors() during policy check:

// runtime/fs_matcher.rs
if self.allow_prefixes.iter().any(|prefix| is_ancestor(path, prefix)) {
    return FsDecision::Allow;
}
Enter fullscreen mode Exit fullscreen mode

No more "list every parent directory" dance. If you allow a leaf,
you get traversal to it for free.

What this buys us

All together: the component author states intent at build time, the
operator states grants at run time, the host computes the
intersection, the WASI imports are filtered accordingly, the HTTP
client re-checks each redirect hop, and the DNS resolver filters IPs
before a socket opens.

It's not "a sandbox". It's a VM plus an explicit, auditable
capability pipeline. A tool from a stranger — ghcr.io/somebody/…
— is safe to point your agent at in a way that npm install -g has
never been.

Where this fits

Sandboxing is the floor. On top of it, stateful capabilities
(sessions, events, resources) get the same treatment — declared at
build time, granted at run time, intersected by the host. The
sessions and bridges post walks
through that next layer, including how authentication lives inside
session args (validated against a typed JSON Schema) rather than
floating through per-call metadata.

Still pending:

  • Per-op filesystem gating under wasip3 — currently awaiting upstream wasmtime-wasi API.
  • A distribution post: OCI registries, signed SBOMs, reproducible builds, and what actpkg.dev will ship.

Questions welcome in Discussions.

Top comments (0)