CX · Reference guideCapabilities and permissions
PlaygroundDownloadsAboutv0.18.0-pre.1

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 .cxs schema 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.