policy

What the agent is allowed to do. Declared in two places — a profile sets the ceiling for your machine, an agent narrows within it — and enforced by two layers.

export default defineAgent({
    policy: {
        fs:    { read: ["./src"], write: ["./output"] },
        net:   { allow: ["api.github.com:443"] },
        shell: { allow: ["git", "bun"], raw: false },
        env:   { allow: ["GITHUB_TOKEN"] },
        tools: { fs: { read: true, remove: false }, "*": "escalate" },
        limits: { memory: "2G", wall: "30m" },
    },
})

Declaring nothing means unrestricted, not denied. An agent on your own machine with no policy is a personal tool with your privileges — which is the right default for local work and the wrong one for anything published.

Two layers

The same block drives both. They differ in what they can express.

OS confinement runs on Linux, and it is the wall. fs becomes mount-namespace bind mounts, so a path you did not grant does not exist inside the box — a forbidden read fails as "no such file". net becomes a network namespace with a default-drop nftables ruleset, so a raw socket cannot bypass it. limits becomes cgroup caps. env becomes an environment built from nothing.

The mediator runs everywhere. It gates tool calls and program execution, returning typed denials, raising escalations, and emitting a span per decision. It is the only layer that can ask you — an OS wall cannot pause for approval, and a denial arriving as ENOENT teaches the model nothing.

The box wraps the whole agent process and everything it spawns. The cgroup is outermost, so a subprocess cannot multiply the budget by spawning helpers.

Where they overlap, the OS layer is the truth and the mediator is the polite error in front of it. macOS and Windows get the mediator onlyfs, net, limits and env have no OS enforcement there, and that is a real reduction rather than a formality.

The shape follows what can be enforced

Each surface is written to match its enforcement mechanism rather than to look uniform with its neighbours. net is a list with no escalation because nftables filters packets and cannot ask a question. shell names binaries rather than command lines because a string matcher cannot survive four spellings of one command. Everywhere this API is irregular, that is why — a shape that lets you express what the kernel will not do is worse than an irregular one.

isolation

Which OS box to build. Linux only; elsewhere the mediator still runs.

TierWhat it is
"none"No OS wall. Mediator only, agent runs as you.
"auto"Rootless: own filesystem view, pid and network namespaces, nft egress filtering, cgroup caps. No privilege, no setup.
"container"The container is the box. For hosted runtimes that cannot nest another sandbox.
"hardened"auto plus a dedicated OS user and system cgroups. Needs axon install.

You rarely set this: declaring any fs, net, env or limits rule turns it on as "auto". A requested tier the host cannot provide is a boot error, never a silent downgrade.

Under "container" your declared fs/net/limits get no OS enforcement — the container boundary handles tenant isolation and the mediator is the only per-agent layer. That is stated plainly because it is weaker per-agent, which is also why the tier is declared and never inferred.

fs

Paths the box can see. Anything not granted does not exist inside it.

fs: {
    read:  ["./src", "./package.json"],   // mounted read-only
    write: ["./output"],                   // mounted read-write
}

The agent's own directory and the runtime are always mounted; these extend that. Symlinks cannot escape: a link resolves inside the box, where an ungranted target is simply absent.

net

Destinations the box may reach.

net: {
    allow: ["api.github.com:443", "10.0.0.0/8"],
    deny:  ["169.254.169.254"],
    dns:   "allowlist",
}

Absent means no network at all — the box gets no network stack whatsoever, which is stronger than an empty allowlist and costs nothing. Present means default-drop plus exactly what is listed. A bare host matches every port; host:port matches one. Deny beats allow.

Hostnames are resolved when the box is built and pinned as addresses, because nftables matches packets on address and port. Two consequences, stated rather than hidden:

  • A host whose addresses rotate mid-run (a CDN, a load balancer) can drop out from under a long-lived agent.
  • A name on shared hosting grants that address, which may serve other names too.

dns narrows the first of those: "allowlist" (the default) gives the box a resolver that answers only for granted names, so it cannot discover an address it was not given. "open" allows unfiltered lookups; "off" means only literal addresses are reachable. An allow entry that does not resolve is a boot error — a grant you believe you made and did not.

shell

Program execution.

shell: {
    allow: ["git", "bun"],
    deny:  ["curl"],
    args:  { git: { deny: ["push --force*"] } },
    raw:   false,
    spawn: { max: 8 },
}

allow and deny name programs, not command lines, and are matched after the call is resolved to a real binary — through env, nice, timeout, and an absolute path. This is the whole reason the surface changed shape: git push --force, git push --force, env git push --force and /usr/bin/git push --force are one program and four strings, and a glob over the command line catches one of them.

raw governs whether a shell (sh -c, bash -c) may be invoked at all. It is its own switch because a shell turns one grant into arbitrary execution — reading allow: ["git"] should not require you to work out that sh was also in scope. It defaults to off whenever any shell rule is declared, including shell: true.

args gates the arguments of an already-admitted program. It is advisory and says so: a program you allowed can usually be driven to the same end another way, so this catches mistakes and model misfires rather than a determined bypass. The durable statements are which binaries exist (allow) and whether a shell is one of them (raw).

spawn is a separate privilege from running a command: max caps concurrent long-lived children.

env

Environment variables the agent receives. The box starts empty.

env: { allow: ["GITHUB_TOKEN"] }

Three things reach the agent, and nothing else:

  1. The runtime floorHOME, PATH, and Axon's own plumbing. Not a grant; the box cannot start without it, and it never appears in axon policy as though you asked.
  2. The agent's own .env, beside its code and already gitignored. This is where an agent's credentials belong, and it is why deny-by-default costs nothing: the common case needs no rule here at all.
  3. Whatever this block names — the escape hatch, for a variable that genuinely belongs to the host rather than the agent: something CI injects, a token shared across agents.

Inference credentials are in none of them, deliberately. The provider key is held by the supervisor and the agent asks for a role, so there is no engine key inside the box to leak. A variable set on your machine but not granted produces a denial that names the variable and the line that fixes it, rather than a downstream 401.

This also makes local match deployed, where .env is uploaded and there is no shell to inherit from in the first place.

tools

Tool permissions, addressed exactly as the agent's code addresses them. Every export from src/tools/ lands in the agent's scope under its own name, and this map mirrors that scope one-for-one — so a rule and a call site are the same string.

tools: {
    "*": "escalate",                     // anything not named below
    fs: { read: true, remove: false },   // one bag, per-member rules
    tavily: false,                       // the whole bag
}

Resolution walks the address from most specific to least: fs.removefs*. Nesting is one level deep, because that is what the scope has — a global is a function or a bag of functions, never deeper.

This is the high-granularity escape hatch, not the primary control. Locking the filesystem or the network itself is the durable statement, since those hold however the tool surface changes.

limits

Resource caps, applied to the whole process tree.

limits: { memory: "2G", cpu: "100%", pids: 512, disk: "1G", wall: "30m" }

memory is a hard cap — the tree is OOM-killed on breach, swap included. cpu is a percent of one core, so "200%" is two. pids is the fork-bomb cap. wall is a wall-clock ceiling on one run, and the cheapest guard against a loop that burns tokens.

disk bounds the box's own scratch space, not a granted fs.write path — cgroup v2 has no disk-space controller, so a grant to write into a real directory can still fill the filesystem behind it.

Limits are ignored under isolation: "none".

Profile and agent

A profile declares the same shape under policy in its own config. It is the ceiling: an agent can narrow within it and can never widen past it.

Profile saysAgent saysResult
nothinganythingthe agent's rule
trueanythingthe agent's rule
falseanythingdenied
"escalate"anything but falseescalate
a globa globboth carried, evaluated per call

The last row is why the ceiling holds. Two glob rules are never merged into one, because allow: ["git status"] unioned with allow: ["git push"] would permit a command neither layer allowed alone. Both are kept and evaluated against the actual subject at call time, so widening is impossible by construction and a denial can name which layer decided.

List surfaces intersect instead: net.allow and env.allow keep only what both layers admit, denylists union, and shell.raw is an AND — a profile that turned the shell off cannot be re-enabled by an agent.

A profile "*": "escalate" therefore applies to every capability the agent names — a blanket a specific grant cannot punch through.

See also

Kernel & Policy — why the split exists. Escalations — answering a paused call.