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/03-approved/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 'greeting.txt']
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)' at='2:1']
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 — here
beside a greeting.txt holding the line hello:
$ cx --allow-read read_greeting.cx
'hello\\n'
Grants combine — a program that reads the environment and writes a file needs both flags:
$ cx --allow-env --allow-write snapshot_home.cx
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/03-approved/core/security.md).
The grant list
Nine capabilities cover every effectful surface in the engine
and standard library, plus two broad grants. 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-common | (all but secret-reveal) | the common working set — everything above except declassifying secrets |
| --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 --allow-env home.cx
'/Users/ep'
Reading the clock:
[?lib 'cx-stdlib/time']
[$time:now]
$ cx --allow-clock now.cx
'2026-07-14T22:11:55.071838Z'
Asking for entropy:
[?lib 'cx-stdlib/uuid']
[$uuid:v4]
$ cx --allow-random id.cx
'2c9be10f-2a88-456f-b00d-9af606793775'
Spawning a subprocess:
[?lib 'cx-stdlib/process']
[$process:run ('echo', 'hi')]
$ cx --allow-subprocess run_echo.cx
[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 --allow-eval calc.cx
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.
--allow-net is the only grant that scopes. A resource
suffix on --allow-read, --allow-write or --allow-env is
now a usage error — cx --allow-read=./data prog.cx
refuses at startup with exit 2, before the program is
evaluated, and names the flag, the suffix it would have
ignored, and the bare spelling that is accepted. Earlier
versions accepted the suffix, discarded it, and granted the
blanket capability, so a caller who typed the narrower
form silently got the wider authority; refusing is the
fail-closed answer until scoping actually exists. Write
--allow-read and mean it, or scope the one grant that can
be scoped.
$ cx --allow-read=./data prog.cx
cx: --allow-read=./data: read scoping is NOT IMPLEMENTED — the resource `./data` would be IGNORED and the grant would be BLANKET read authority over the whole filesystem, not the narrowing this spells (cx-home/cx-private#1059)
the accepted spelling is the bare grant: --allow-read
it grants read over the whole filesystem — which is what the suffixed form has always done — so say it explicitly instead of appearing to narrow it
real per-path read scoping is tracked at cx-home/cx-private#1061
`--allow-net=host[:port]` is the one grant whose scope IS enforced today
Per-path and per-name enforcement is specified
(spec/03-approved/core/security.md, coarse-v1 scoping) and
lands per domain. The remaining capabilities — clock,
random, subprocess, eval, secret-reveal — still accept
a suffix and still ignore it; do not read one as a
boundary.
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-client' as=http]
[$http:get 'http://127.0.0.1:1/health']
$ cx --allow-net probe.cx
[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 --allow-net=127.0.0.1:8443 probe.cx
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 two broad grants
Enumerating capabilities per script gets tiresome while you are still exploring, so there are two broad grants. They differ in exactly one capability, and that one matters.
--allow-common grants the common working set: read, write,
net, env, clock, random, subprocess, eval. It does not
grant secret-reveal, so a [?reveal] under it still refuses
with CXER0271 — and the denial propagates out of the [out]
wrapper to the top level, printed as a bare [err …]
value with exit code 1. This is the grant to reach for while
you are learning, and the one these pages use.
[?let [= $s [?secret 'hunter2']]
[out [?reveal $s]]]
$ cx --allow-common reveal.cx
[err code=cx-err:CXER0271 message='E_CAP_DENIED: secret-reveal capability required for reveal; none granted (grant via --allow-secret-reveal)']
$ cx --allow-all reveal.cx
[out 'hunter2']
--allow-all grants everything, secret-reveal included. 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 either: 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, --allow-common included: it is the only grant
that bypasses the private-range deny set on network dials, so
a local-service example under --allow-common asks for the
scope it needs (--allow-net=127.0.0.1:PORT).
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 --allow-env narrowed.cx
[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.
Untrusted inputs and untrusted code
The capability model is one half of CX's security posture; this
subsection is the whole posture in one place. The normative
reference is the threat model
(spec/03-approved/process/threat-model.md), which assumes one
of three deployment shapes: A — trusted inputs
(operator-authored files; defaults suffice), B —
semi-trusted (PRs, CI artifacts, third-party config; pin the
toolchain and validate at the boundary), and C — untrusted
(network payloads, public uploads; everything below, plus
process isolation).
CX distinguishes two threats that get conflated elsewhere:
- Untrusted data, trusted code. Your program parses
attacker-supplied bytes. The parser is the attack surface;
the mitigations are structural: parsing needs no
capabilities at all, every regex call site routes through
the vendored RE2 engine (linear-time matching — no
catastrophic backtracking class, in the schema
[pattern]constraint included), invalid UTF-8 is a typed refusal rather than a silent substitution, and a.cxsschema at the boundary type-checks every field before anything downstream sees the value (schema-evolution). - Untrusted code. You evaluate a tree you did not author. The threat is reach, and reach is exactly what the capability set bounds: with no grants the code can compute, transform, hash, validate, and project — and cannot read a file, dial a host, spawn a process, read the clock or the environment, or declassify a secret. There is no ambient escape hatch to close because there is no ambient authority to begin with.
The idiom for running untrusted sub-computations inside a
granted process is narrowing: evaluate the foreign tree
under [?with-caps [deny …] …] (see the narrowing subsection
above) and its authority is bounded no matter what it contains
— a deny cannot be undone from inside. Dynamic evaluation is
itself a capability (--allow-eval), so a program that runs
runtime-supplied source says so on its command line, visibly.
What the capability model does not bound: total CPU and memory spent inside pure computation. A hostile-but-pure program can still burn cycles, so deployment shape C pairs the empty capability set with OS-level process isolation — memory caps, wall-clock timeouts, file-descriptor limits — and monitoring on the refusal codes. Output of untrusted evaluation is a value, not sanitized markup: HTML contexts route it through an escaping renderer, never string concatenation.
CX has not yet received an external security audit. The hardening above is real and continuously exercised (the conformance corpus, the capability-denial gates, reproducible builds), but until an audit lands, the recommended posture for shape C is the belt-and-suspenders one: empty grant set, narrowing around foreign trees, schema validation at the boundary, and process isolation around the whole thing.