0122 - One capability model: explicit allow/deny grants, trusted config, one runner
Date: 2026-09-25
Status: Accepted (implementation tracked in #1255)
Area: runtime, host capabilities, cli
Supersedes: the flag and default decisions in
ADR 0068,
ADR 0103 (host filesystem
loading on by default),
ADR 0111 (flag spelling),
ADR 0119 (flag spelling), the
event names in ADR 0097, and the
--remote-imports flag proposed as ADR 0121 in #1054
Context
Host authority grew one flag at a time, and each flag used its own grammar:
--allowed-host, --fetch-deny-private-ranges, --unsafe-ffi,
--allow-node-modules[=ceiling], --no-host-filesystem, and the proposed
--remote-imports. Three problems followed.
- The defaults contradicted VISION. VISION promises
"no ambient file system", but host-filesystem module loading was on by
default, so
import x from "/abs/path" with { type: "bytes" }read any file the process could read. - Config granted itself authority. A
goccia.jsondiscovered by walking up from each script could enable FFI, network hosts,node_modules, and theunsafe-*escapes. Running a cloned project could reach the network or load native code without anyone typing a flag, and agit pullcould widen that silently. - Nothing was uniform. Flag options ignored their value
(
--unsafe-ffi=falseenabled FFI). There was no negation. Several binaries accepted capability options and ignored them. The embedding API spread grants acrossAttachRuntime,SetAllowedFetchHosts, the process-globalSetFetchRequestPolicy,AllowNodeModules, and extension installs.
Decision
One engine-owned capability set. Capabilities are an immutable set fixed
when the engine starts. The CLI, config files, and embedders only populate it.
Nested contexts (runScript children, ShadowRealm, workers) can narrow their
parent's set, never widen it. Fetch policy moves from process-global state to
the engine.
Deny by default. Without grants, nothing reaches outside the process. The
one exemption is the module graph: static imports with literal specifiers of
code and data modules inside the project. The project is the directory of the
nearest goccia.json, or the entry file's directory when there is none. There
are no interactive prompts; a missing capability is an error.
The exemption judges a file by where it lives, not by how a specifier finds
it, so it does not cover node_modules resolution. A bare specifier resolves
through node_modules only with an explicit import grant, even when the
node_modules directory is inside the project. Once granted, the resolved
package's canonical root (symlinks resolved) joins the module graph, and the
package's own literal imports inside that root need no read grant. A
relative or absolute import of a file under node_modules is judged like any
other path.
One grammar. --allow-<cap>[=scope,…] and --deny-<cap>[=scope,…]. A deny
always wins over an allow, regardless of order, so a deny can carve an
exception out of a broad allow. There is no --allow-all and no
environment-variable form of any grant.
| Capability | Scopes | Covers |
|---|---|---|
read | canonical paths (symlinks resolved), recursive | host reads beyond the module-graph exemption, including dynamic import() with non-literal specifiers |
net | host, host:port, *.domain, IP, CIDR, private | fetch; private and loopback ranges are denied unless named, and every redirect hop is re-checked |
ffi | library paths | opening native libraries |
import | node_modules[=ceiling], github, github:<owner>, github:<owner>/<repo> | non-local module sources |
CLI scopes are relative to the working directory; config scopes are relative to the config file.
Settings stay settings. Limits are not capabilities. They move to one
--max-<resource> family with unit suffixes: --max-memory,
--max-fetch-bytes, --max-fs-bytes, --max-fs-nodes, --max-instructions,
--max-stack. --timeout also accepts units. The --unsafe-* language
escapes, --compat-*, --experimental-*, and --deterministic remain
separate from the grammar.
Config requests; the user grants. A config's permissions block, its
unsafe-* keys, and its sandbox inputs are requests, not grants.
--trust <path>shows each declaring config under that path and the change since it was last trusted. It then records the config's path and a SHA-256 of its normalized effective block (afterextendsresolution) in a user-level store outside any repository.--untrustand--list-trustedmanage the store.- At startup, a config whose hash is not trusted fails the run and names the fix.
-Ptrusts the declared blocks for one invocation. Automation and CI use it.- Other config settings (
compat-*, import maps, aliases, limits) still apply automatically.
Binaries reject what they cannot honor. A capability flag that a binary does not support is an error. The same capability coming from config is a warning, so one project config can serve several binaries.
One runner with a sandbox mode. GocciaScriptLoader becomes GocciaRunner
and absorbs GocciaSandboxRunner. The runner has two filesystem modes.
- Host mode (the default) gives the module graph plus any granted capabilities.
- Sandbox mode gives a virtual filesystem, the
fsandgoccia($,runScript) modules, diffs, and write-back.
Sandbox mode is switched on by:
--copy <host>[=<sandbox>], which copies an input in read-only;--copy-rw, which also marks the input for write-back under ADR 0119's rules;- a trusted
sandboxconfig section; --sandbox.
The runner's other sandbox options:
- The positional entry is a host file, copied into the sandbox automatically.
--entrynames a sandbox-only entry. --diff[=json|unified]prints the diff.--diff-file=<path>writes it instead, inferring the format from the file extension.- Only
netis honored in sandbox mode.
Denials are visible and auditable. A denied operation throws a catchable
PermissionDenied. Its message names the capability and the requested scope,
never host paths (ADR 0108).
The host-side report adds the flag or --trust command that would grant it.
Audit event kinds are named after capabilities (net.fetch, read.file,
ffi.open, import.node-modules, import.provider), and every allow and deny
decision emits one. Two further events are added: config.permissions for
trust decisions and capabilities.effective for the final set.
Provider imports use import. An import-map entry whose address is
github:<owner>/<repo>@<ref>[/<path>] names a provider package; the ref is a
tag or a commit, never a branch. --allow-import=github, github:<owner>,
or github:<owner>/<repo> lets a run materialize the package's
lockfile-pinned files from the provider's fixed host into the project's
.goccia/ cache, and every file is hashed again from the bytes a module,
data import, or native library load actually uses. The materialized files
belong to the module graph as a resolved package; .goccia/ itself is not
covered by the module-graph exemption, so reaching it any other way needs
read. A run never resolves a ref or writes goccia.lock.json.
GocciaRunner's install mode (--add, --remove, --install, --update)
creates and updates the pins; the same import grant covers its network use,
except that a specifier typed on the command line is its own grant for that
invocation, and a deny from any source still wins. There are no default
provider hosts, no separate network grant for a provider, and native libraries
in a package still need ffi.
One breaking release. Old flags and config keys fail with an error naming their replacement, with no aliases, following ADRs 0046 and 0057. The model ships as one release.
Considered options
- Keep config grants automatic, deny from the CLI. Rejected: a cloned or pulled project would still grant itself authority before anyone reads the config.
- Deno's
-Palone. Rejected as the only mechanism: it applies whatever the file says at that moment, so a pull that widens permissions goes unnoticed. It remains as the explicit per-invocation form. - Config declares, only the CLI grants. Rejected: CLI grants apply to every file in a run, so per-folder needs (the test suite's FFI and fetch folders) cannot be expressed.
- A trust store keyed on content hash alone. Rejected: a repository could copy a popular project's permission block and inherit its trust. Entries are keyed on path plus hash.
- Named permission sets. Rejected: per-folder configs already scope permissions, and named sets add a second axis to hash and trust.
--allow-all/-A. Rejected: a sandbox-first runtime should not make "everything" a short flag; grants stay specific.- Interactive prompts. Rejected: they make runs non-deterministic, hurt embedding and CI, and would pause the engine mid-execution.
- A required install step before running provider packages. Rejected: resolve-time materialization under an explicit grant keeps one command and verified offline reuse, so runs never need a separate install step when the lock and cache are present. An install mode exists only to create and update pins, which a run never does.
- Keeping a separate sandbox binary. Rejected: once the default runner has no ambient authority, the sandbox is a filesystem mode, not a different program.
- Deprecation aliases for old flags. Rejected: aliases would keep the ambiguous value-ignoring flags alive, and the project is pre-1.0.
Consequences
- VISION's "no ambient file system" becomes literally true. Projects that read
files outside their directory, or through dynamic imports, need
--allow-read. - CI adds
-Pto the test-runner commands. Contributors runGocciaTestRunner --trust tests/once per change to a permission block. Tests that fetch from127.0.0.1declareprivate. - A capability can be traced to a flag, a trusted hash,
-P, or an embedding API call, and the audit log records which. - Trust covers what code may do, not what the code is. New code under an already-trusted block runs with those permissions.
- A single
gocciabinary with subcommands, which would give--trusta natural home asgoccia trust, is a separate follow-up (#1254). - ADR 0107's out-of-process
capabilitiessection should adopt these names. docs/permissions.mdbecomes the single reference;docs/build-system.md,docs/capability-audit.md,docs/embedding.md, and the website link to it.