Docs
Policy and permissions
Permissions in ADM are decided by two people at two different times. The developer decides, when the program is built, which libraries get in and what they may do. The user decides, when the program runs, what it may do on their machine. This page describes each side and how they fit together.
Two people, two moments
Most of a program is code written by other people. A library pulled from the internet runs inside the process with the user's rights, and in most languages nothing stops an image decoder from reading ~/.ssh or an analytics helper from sending data to a server abroad. ADM routes every way out of the process through the services, and each of those doors is a named permission.
The developer, at build time, wants to know that the libraries they pulled in do not do more than they claim. They can read a call chain, a file name and a symbol, so their tools work in those terms: adm audit, the checklist adm get shows, adm.lock, the build's refusals and the IDE's Security tab. What the developer accepts is the ceiling. The finished program can do that much and no more.
The user, at run time, has downloaded a program that plays music. They do not know what ADM, a library or a C binding is, and the program should not show them those words. What they care about is what the program does on their computer: read files, talk to a server, read the clipboard, start another program, show notifications. Their tools are the question the program asks, policy.toml, policy.log, and the operating-system sandbox for the case where a library lies. The user can narrow the developer's ceiling but cannot raise it.
This split decides where each feature lives. Something that is true about the code, for example that a library binds C, is the developer's business and is settled at build time. It is not a permission, it does not appear in the policy file and the program does not ask about it. Something the program does in the world is the user's business: it is checked at the call, described in plain words, and the user can be asked, can refuse, and can read about it in the log.
For the developer
Permissions and gates
A permission is declared by the service that owns the capability: a name, a one-sentence description in the user's words, and optionally a spec, the type whose fields a rule can narrow.
service Storage {
permissions = [
Permission{name: "read", description: "Read files and list directories", spec: PathSpec},
Permission{name: "write", description: "Create, change and delete files", spec: PathSpec},
]
...
}
The full name is <owner>.<service>.<name>: adm.storage.read for the builtin services, acme.imaging.cache.write for a service Cache in the library acme.imaging. A service can only declare names under its own prefix, so the tree is owned and cannot collide. Names form a tree: holding adm.storage covers adm.storage.read, and adm.storage.* means the same in a narrowing list.
The standard library declares these permissions today. Each one describes something a user can picture the program doing:
| Permission | What the program would do | Rule fields |
|---|---|---|
adm.storage.read | Read files and folders on this computer | path |
adm.storage.write | Create, change and delete files on this computer | path |
adm.network.connect | Connect to other computers over the network | host, protocol, ... |
adm.network.listen | Accept connections from other computers | host, ... |
adm.notifications.show | Show desktop notifications | none: ruled on as a whole |
adm.clipboard.read, adm.clipboard.write | Read what was copied to the clipboard; put text on it | none |
adm.runtime.spawn | Run other programs on this computer | none |
A permission protects functions. A service commit or query names the permission it needs in its annotation; a plain function or method in a module uses @requires. Both go through the same check. A gated function must return an errorable, because the call can be refused.
service Storage {
// Every call to open() needs adm.storage.read on the whole call chain.
@commit(policy = "read")
def open(path Path, mode string) !int { ... }
}
module acme.imaging {
// A module function can be gated too.
@requires("adm.network.connect")
def fetch(url string) !Image { ... }
}
A parameter typed with the permission's spec, such as Path for adm.storage.read or Endpoint for adm.network.connect, is constrained: before the body runs, the argument is checked against the user's rules for that field. A plain string parameter cannot be constrained, which is deliberate pressure on library authors to take a Path.
When a gate refuses, the call fails with PolicyDenied, an ordinary error the library handles like any other: the feature that needed the network fails, the rest of the program keeps working. A library is expected to be written that way, since the person installing it may not tick every box, and the person running it may say no.
The whole call chain
The subject of every decision is a package: the application, or one installed library or plugin. Every package on the call stack must hold the permission, not only the one that made the gated call.
The reason is the confused deputy. Your application uses library B, which connects to the network; both are granted. Library C, which was never granted network access, calls B's function that connects. With only the direct caller checked, B would connect on C's behalf. With the whole chain checked, C is on the stack and the call is refused. The runtime keeps a per-task stack of packages; a push and a pop at each package boundary, nothing per ordinary call.
The standard library is never a subject of its own: a chain that passes through std.os.open is attributed to the package that called it. Observers are gated with the commit they watch: @on(Storage.open) in a package without adm.storage.read is refused at build, because the event would otherwise hand that package every call's arguments.
The audit
Nobody writes the permission list by hand, and a hand-written list would be wrong. adm audit walks the code a package can reach, lists every gated function it can call with the chain that gets there, and then names the files that bind C. Calls through interfaces and function values count every implementation they could reach.
acme.imaging requires
adm.storage.read via imaging.load → os.open → Storage.open
adm.network.connect via imaging.fetch → http.get → Network.connect
contains foreign code: imaging/native/webp.adm
adm publish runs the same computation and writes the result into the package manifest: the permissions under requires, the binding files under foreign. That manifest is what the install checklist, adm lib requires and the registry show, so what a library asks for is exactly what its code reaches, no more and no less. --json prints the same report for tools, and --library NAME audits one library of a workspace.
A package may narrow what it wants to be granted in its adm.toml:
[requires]
permissions = ["adm.storage.read", "adm.network.*"]
The published set is the intersection, and publish fails if the code reaches something the narrowing excludes, since the package could not run.
The checklist and the lock
adm get and adm install show one checklist across everything being installed: one row per permission with its description, and one more row when the package binds C. The ticked subset goes into adm.lock:
Permissions to grant (tick what each package may do):
acme:imaging 1.4.0
[x] adm.storage.read Read files and folders on this computer
[ ] adm.network.connect Connect to other computers over the network
[x] observe Storage.open
[ ] foreign code: native/webp.adm, native/png.adm This library binds C code the compiler cannot check. Accepting it means trusting the library completely.
acme:core 2.1.0
[x] adm.runtime.info Read system information
Scripts pass --accept all, --accept none, or a list such as --accept adm.storage.read,adm.network.*,foreign, where the token foreign is the foreign-code row. An unticked permission is not an error: the library's calls under it fail at run time with PolicyDenied. A newer version that asks for more than the lock records, or that starts binding C when the locked one did not, is refused until it is accepted again, so an update cannot quietly grow a library's reach.
acme:imaging:9f3c…:1.4.0 publisher=k7… permissions=adm.storage.read foreign=yes
acme:core:41ab…:2.1.0 publisher=k7… permissions=adm.runtime.info
The lock is the compiled layer: the grants baked into the binary, per package. The application itself holds everything its own code reaches, because its author wrote those calls. A package that was never asked holds nothing.
Foreign code: an acceptance, not a permission
Interop is a language feature. A module binds a C library with @link, @c, @native or @extern. An application's own bindings are the author's choice; there is no lint, warning or question about them.
@link("glfw")
module std.ui.renderer.opengl.glfw {
@c("GLFW/glfw3.h", ret = "int")
def glfwInit() int;
...
}
A dependency that binds C is a different matter. That code runs outside every gate with the process's rights, and the compiler cannot check what it does. Since this is a fact about the code, the developer settles it once, at install: the checklist lists the binding files as a row of their own, the lock records the answer as foreign=yes, and the build refuses to link a dependency whose C bindings were not accepted. It is not a permission under any name. It has no table in policy.toml, the user is not asked about it, and a rule for it could not do anything anyway, because the binary is already linked by the time the file is read. A library that binds popen to get around adm.runtime.spawn has to be accepted as "trust this library completely", and the audit names the file that does it.
If the developer does not want a library's C code, they leave the row unticked and use another library. If they accept it and it turns out to be hostile, what protects the user is the sandbox.
What the build refuses
- A dependency that binds C in a file the lock did not accept:
acme:imaging binds C code in native/webp.adm that was not accepted at install (adm install --accept foreign). - A dependency observing a gated commit under a permission it was not granted.
- A library whose code reaches a permission its own
[requires]narrowing excludes, at publish. - An install of a version that asks for more than the lock accepted, until
--acceptor the checklist says so again.
Which library did that
To the user, the application is the one asking: "MusicPlayer wants to connect to 203.0.113.5", not "acme:telemetry wants to connect". Which library inside the program made the call is written to policy.log as subject= on every line, and that is where the developer looks it up. Per-package tables in policy.toml are for the same reader, a developer or a power user who wants one library held below the rest of the program.
For the user
The question
An ask rule makes the program ask before it acts. With nothing else set up, the question appears as a desktop notification with Allow, Always allow and Deny buttons. Dismissing it or leaving it unanswered counts as Deny. The text is the application's name and the permission's description:
MusicPlayer asks for permission
Connect to other computers over the network
adm.network.connect: 203.0.113.5
An Always answer is written into the policy file under the package that asked, so the question does not come back. A program with its own interface can register a PolicyResolver and ask in its own window instead; the possible answers are AllowOnce, DenyOnce, AllowAlways and DenyAlways. ADM_POLICY_PROMPT=0 turns the desktop notification off, and an ask then denies; adm test sets it so a suite never waits on a dialog.
The policy file
The developer's grants are the most the program can do. The person running it can deny, ask or restrict within that through policy.toml in the application's home folder. The file cannot grant anything that was not compiled in, and it is read once, at startup.
adm app policy init writes a commented template listing every permission the program can ask for, with its description, its fields and the grants each package holds, so a rule is a line to uncomment rather than a spec to read. adm app policy set edits one rule from the command line:
[adm.network.connect]
host.deny = ["*.example.net", "10.0.0.0/8"]
There is one table per permission, for the whole program or for one package. A rule names a field, a kind and a list: field.allow, field.deny or field.ask. The field * stands for the permission as a whole. It is the only rule a permission without fields can have, and the only value that means anything for it is *:
# the whole permission: never, ask first, or always
[adm.clipboard.read]
"*".deny = ["*"]
[adm.runtime.spawn]
"*".ask = ["*"]
# field rules narrow a permission the program keeps
[adm.network.connect]
protocol.allow = ["http", "https"]
host.deny = ["203.0.113.0/24", "*.example.net"]
host.ask = ["*"]
maxConnections = 20
[adm.storage.read]
path.allow = ["${app.home}/**", "~/Music/**"]
path.deny = ["~/Music/private/**"]
# one library only, below the rest of the program
["acme:imaging".adm.network.connect]
host.allow = ["https://cdn.acme.com"]
adm app policy set adm.clipboard.read deny writes the first table above: a kind on its own means the whole permission. Values are globs (*.example.com, ${app.home}/**) or IPv4 networks. Rules merge per field and kind across the program-wide and per-package tables: allow and ask lists replace each other, deny lists add up, so a denial can only grow. Within one decision the order is deny, then allow, then ask, and the * rule is decided before the field rules. A field with no rule passes; the package still had to hold the permission in the compiled layer to get this far.
An unknown field or kind is a load error and the program does not start, because a misspelled rule that silently did nothing would be worse. A table for a permission this build does not know, left behind by an older version, is skipped with a line in the log. The policy state itself, the file and the log, is off-limits to every subject including the application's own code: only the Policy service writes there. A library with disk access cannot grant itself anything or undo what you denied.
The decision log
Every denial, every question and every live answer is recorded in policy.log beside the policy file, and emitted as the Policy.decided event for the program to observe. adm app policy log prints it, --clear deletes it.
2026-09-06T02:34:39.094Z deny adm.network.connect subject=acme:imaging field=host denied by rule *.example.net
2026-09-06T02:34:39.101Z ask adm.storage.read subject=* field=path ~/Music/holiday.mp3
2026-09-06T02:34:41.220Z allow adm.storage.read subject=* field=path allowed always: ~/Music/**
2026-09-06T02:34:42.005Z deny adm.clipboard.read subject=* field=* denied by rule
2026-09-06T02:34:39.094Z ignore adm.old.name subject=* unknown permission in policy.toml, table skipped
2026-09-06T02:34:39.000Z sandbox * subject=* applied: landlock, seccomp
Policy.explain(request) answers "why" for one request: which rule, in which layer, made the decision; a whole-permission rule shows up as the * constraints.
The sandbox
Everything above happens inside the process, and inside one process there is no complete safety: a runtime bug, or C code the developer accepted, has the process's rights. The compiled policy table is the union of everything the program may ever do, and the runtime can hand it to the operating system before the first thread starts, so every thread inherits the limits. The sandbox does not care which code made the system call, and that is what makes it the user's protection against a library that lies.
Turn it on in the manifest, or per run with an environment variable:
[policy]
sandbox = true
| Variable | Effect |
|---|---|
ADM_SANDBOX=1 | Apply the sandbox at startup from the compiled table |
ADM_SANDBOX=0 | Turn off a sandbox compiled in through adm.toml |
ADM_SCHED_DEBUG=1 | Also print what was applied to stderr |
On Linux this is Landlock for files and TCP and seccomp for the rest. Without adm.storage.read only the system directories, the executable's directory, the application folder and /tmp stay readable; without adm.storage.write only the application folder, /tmp and /dev stay writable; without adm.network.listen a TCP bind is refused, without adm.network.connect a TCP connect is, and with no network permission at all no socket can be opened. Starting a program (execve) is always refused under the sandbox, whatever the table says, so a sandboxed program cannot use Runtime.run or the clipboard service, which drives a clipboard program. The report goes to policy.log as a sandbox line. macOS and Windows report that nothing was applied, for now.
Where the two meet
The developer's acceptances set the maximum and the user's rules cut it down. Nothing in the file can add what was not compiled in, and nothing done at build time can override what the user denies. The two sides do not share a vocabulary: the developer reads package names, chains and files, the user reads sentences about their computer. The log carries both, a permission in the user's words next to the package that asked for it.
Today a program asks only where a rule says ask. A program with no policy file uses every permission the developer compiled in without saying so. A default that makes user-facing programs ask on the first use of each permission, switched on in adm.toml, is designed but not built. Until then, adm app policy init followed by one "*".ask = ["*"] line per permission gives a user the same thing.
What it does not do
- It does not protect a program from its own author. The application holds everything its code reaches, by construction.
- It does not undo an open door: a connection already open to a host you then deny is not cut. Change the file and restart.
- Inside the process it trusts the compiler, the runtime and the operating system. Accepted C code and started programs are outside the gates; the sandbox is what limits them.
- Without the sandbox,
policy.tomlgoverns ADM code going through services, which is all of a well-behaved library, and nothing else. - Nothing asks by default. A user who writes no rule and does not turn on the sandbox gets exactly what the developer compiled in.
Quick reference
| Command | Does |
|---|---|
adm audit [DIR] [--library NAME] [--json] | Lists the permissions a package's code reaches, with chains, and the files binding C; --catalog lists every permission the program's services declare instead |
adm get, adm install [--accept all|none|list] | Install and show the checklist: permission rows and a foreign-code row; record the answers in adm.lock. In a list, foreign accepts the C bindings |
adm lib requires [--markdown] | Print what an installed library asks for and which files bind C, from its manifest |
adm app policy init [--dir DIR] [--force] | Write the commented policy.toml template for an application |
adm app policy set <permission> <field.kind> <values> | Add or replace one field rule |
adm app policy set <permission> allow|deny|ask | Rule on the permission as a whole ("*".kind = ["*"]) |
adm app policy get [--json] | Print every rule of the file, with * as the field of a whole-permission rule |
adm app policy log [--clear] | Print or delete the decision log |
| Environment | Meaning |
|---|---|
ADM_SANDBOX | 1 applies the OS sandbox from the compiled table; 0 turns a compiled-in sandbox off |
ADM_POLICY_PROMPT | 0 turns off the desktop prompt for ask rules; the ask then denies |
ADM_APP_HOME | Where policy.toml and policy.log live, when not the default application folder |
ADM_SCHED_DEBUG | Also reports the sandbox on stderr |
| Files | |
|---|---|
adm.toml [requires] permissions | A package narrowing what it wants to be granted |
adm.toml [policy] sandbox | Compile the sandbox in |
adm.lock | The grants accepted per installed package (permissions=) and whether its C bindings were accepted (foreign=yes) |
<app home>/policy.toml | The user's rules, read at startup |
<app home>/policy.log | Every decision, with the package that asked |