Capabilities and permissions
A CX evaluation runs under an explicit **capability set**, and the set is empty unless you grant something: **deny-by-default, no ambient authority**. Pure computation — parsing, canonical emit, CXPath selection, in-memory transforms — needs no capability at all. Any operation with an external effect (reading a file, dialing a host, spawning a process, even reading the clock) checks the active set at the effect point and raises the capability error `CXER0271` when the matching grant is absent. The model is specified in `spec/core/security.md`; this section is the practical guide to the `--allow-*` flags.
Deny-by-default
Run a program with no flags and it can compute anything but touch nothing. This program tries to read a file:
[?lib 'cx-stdlib/io']
[$io:read-file 'VERSION']
Without a grant, the read is refused at the effect point — before any filesystem access happens:
[err code=cx-err:CXER0271 message='E_CAP_DENIED: read capability required for io-read-file; none granted (grant via --allow-read)']
The error is **actionable by contract**: it names the missing capability, the operation that wanted it, and the exact flag that grants it. Add the grant and the same program works:
$ cx read_version.cx --allow-read
'0.12.0
'
Grants combine — a program that reads the environment and writes a file needs both flags:
$ cx snapshot_home.cx --allow-env --allow-write
A denied effect is refused **fail-closed, before any type or domain validation** — the path is never touched, the socket never opened. And because the check happens at the effect point, a `pure` function can never raise `CXER0271` under any capability set: purity is capability-freedom by construction (the effect-totality rule in `spec/core/security.md`).
The grant list
Nine capabilities cover every effectful surface in the engine and standard library, plus one blanket opt-out. This is the full set the binary accepts — there are no hidden grants, and a misspelled grant flag (`--alow-net`, `--allow-nett`, …) is a hard usage error (exit 2) naming the flag, never a silent no-grant:
| flag | capability | gates |
|---|---|---|
| --allow-read | read | filesystem reads, [?cx include], terminal input |
| --allow-write | write | filesystem writes |
| --allow-net[=host[:port]] | net | HTTP client and server, sockets, network stores; see the scoping subsection |
| --allow-env | env | environment-variable reads |
| --allow-clock | clock | wall-clock reads, profiling timers, non-mock [?sleep] |
| --allow-random | random | CSPRNG / OS entropy — crypto, uuid, random |
| --allow-subprocess | subprocess | process spawn ([$process:run] and friends) |
| --allow-eval | eval | dynamic evaluation — [?eval] tree-eval |
| --allow-secret-reveal | secret-reveal | declassifying a [?secret] value via [?reveal] |
| --allow-all | (all) | every capability — explicit trusted-local opt-out |
Each denial names its own grant. A few worked examples, each run live. Reading the environment:
[?lib 'cx-stdlib/env']
[$env:var 'HOME']
$ cx home.cx
[err code=cx-err:CXER0271 message='E_CAP_DENIED: env capability required for env-var; none granted (grant via --allow-env)']
$ cx home.cx --allow-env
'/Users/ep'
Reading the clock:
[?lib 'cx-stdlib/time']
[$time:now]
$ cx now.cx --allow-clock
'2026-07-14T22:11:55.071838Z'
Asking for entropy:
[?lib 'cx-stdlib/uuid']
[$uuid:v4]
$ cx id.cx --allow-random
'2c9be10f-2a88-456f-b00d-9af606793775'
Spawning a subprocess:
[?lib 'cx-stdlib/process']
[$process:run ('echo', 'hi')]
$ cx run_echo.cx --allow-subprocess
[proc-result exit-code=0 signaled=false timed-out=false stdout='hi
' stderr='']
Evaluating a constructed tree — dynamic evaluation is a capability like any other, so a program that builds and runs code at runtime says so on the command line:
[?eval [?quote [+ $a $b]] [context {a: 10, b: 5}]]
$ cx calc.cx --allow-eval
15
Secrets are the inverse case: **wrapping** a value with [?secret] needs no grant, and the wrapped value renders redacted everywhere — logs, output, serialization:
[?let [= $token [?secret 'hunter2']]
$token]
'‹redacted›'
Only **declassifying** it is gated: `[?reveal $token]` raises `CXER0271` unless the program runs with `--allow-secret-reveal`.
Grant scoping syntax such as `--allow-read=./data` or `--allow-env=HOME` is accepted on the command line, but in the current engine only the **net** grant enforces its scope (next subsection). For every other capability the resource suffix is carried, not yet enforced — the grant is all-or-nothing per capability. Per-path and per-name enforcement is specified (`spec/core/security.md`, coarse-v1 scoping) and lands per domain; do not rely on a path suffix as a security boundary today.
Scoping the network grant
`--allow-net` is the one grant with enforced resource scoping, because it is the one most worth narrowing. Three levels:
| flag | meaning |
|---|---|
| (none) | every dial and bind raises CXER0271 naming the host:port it wanted |
| --allow-net | any public host — but private, loopback, and link-local ranges stay denied (CXER4504) |
| --allow-net=HOST[:PORT] | least privilege: only the named host (and port) is dialable; a literal-IP or localhost scope also admits that private address |
The private-range deny set on the bare grant is deliberate: `--allow-net` means "talk to the internet", not "probe my loopback and LAN". A program handed a URL that resolves into `127.0.0.0/8`, `10.0.0.0/8`, `192.168.0.0/16`, and the other reserved ranges gets a refusal:
[?lib 'cx-stdlib/http']
[$http:get 'http://127.0.0.1:1/health']
$ cx probe.cx --allow-net
[err code=cx-err:CXER4504 message='E_NET_FORBIDDEN_ADDRESS: 127.0.0.1:1 resolves into a denied range with no admitting literal-IP/localhost grant (§4.5)']
To talk to a local service, name it — a literal-IP or `localhost` scope overrides the deny set for exactly that address:
$ cx probe.cx --allow-net=127.0.0.1:8443
And a host-scoped grant is a real boundary: with `--allow-net=api.example.com:443`, a dial to any other host is refused with a `CXER0271` naming the host and port it wanted — so the denial tells you exactly what to review before widening the grant.
The --allow-all opt-out
`--allow-all` grants everything. It exists because deny-by-default must stay ergonomic for fully-trusted local use — your own build script, a REPL experiment, a one-off transform on your own machine. It is an explicit, visible opt-out: anyone reading the invocation can see the program runs unconfined.
Where **not** to use it: anything long-running, anything network-facing, anything whose inputs you do not control, and any invocation you copy into documentation, CI, a systemd unit, or a Dockerfile. Those should carry the narrowest set that works — the flags are the audit trail. Note that `--allow-all` is strictly wider than granting all nine flags individually: it is also the only grant that bypasses the private-range deny set on network dials.
Narrowing with [?with-caps]
A program can **narrow** its own set for a dynamic extent — never widen it. `[?with-caps]` takes one or more `[deny CAP]` clauses and a body; inside the body the denied capabilities are gone, even if the process was launched with them, and they come back when the body exits:
[?lib 'cx-stdlib/env']
[?with-caps [deny env]
[$env:var 'HOME']]
$ cx narrowed.cx --allow-env
[err code=cx-err:CXER0271 message='E_CAP_DENIED: env capability required for env-var; none granted (grant via --allow-env)']
This is the tool for running untrusted sub-computations: evaluate a tree you did not author under `[?with-caps [deny net] [deny subprocess] …]` and its authority is bounded no matter what it contains. A `deny` cannot be undone inside the body — narrowing is one-way by design.
Denials are values
`CXER0271` is a normal error **value**, not a crash. It flows through the program like any other value, so a program can detect a missing grant and degrade gracefully with `[?match]` (or `[?else]` / `[?fallback]`):
[?lib 'cx-stdlib/io']
[?match [$io:read-file 'config.cx']
[case [err $e] 'no read grant or no config - using defaults']
[else 'loaded config']]
$ cx degrade.cx
'no read grant or no config - using defaults'
What a denial can never do is succeed silently: there is no fallback value, no partial effect, no environment where the denied operation "sort of" ran. Either the grant is present and the effect happens, or you hold an `[err]` naming the grant.
This guide eats the same dog food: every `cx` example on these pages is executed against the live binary by the docs-example gate, which grants **no** capabilities — a snippet whose only output is a `CXER0271` denial passes (the denial is environmental, not staleness), and anything else that errors fails the build.