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.