Skip to content

Configuration

API reference for OCX configuration files. For the rationale behind the tier model, the merge philosophy, and worked examples, see the Configuration in-depth page.

Config files are in TOML format and are optional. OCX works without any config file using compiled-in defaults.

File Locations

TierPathPurpose
System/etc/ocx/config.tomlMachine-wide defaults
User (Linux)$XDG_CONFIG_HOME/ocx/config.toml or ~/.config/ocx/config.tomlPer-user defaults
User (macOS)~/Library/Application Support/ocx/config.tomlPer-user defaults; XDG_CONFIG_HOME not consulted
OCX home$OCX_HOME/config.toml (default: ~/.ocx/config.toml)Co-located with data; survives a zip-and-move of $OCX_HOME

Missing files are silently skipped.

An unreadable system file is not. /etc/ocx/config.toml is where operator policy lives — the locked sections no lower tier can override — so a system file that exists and cannot be consulted (it is a symlink, a permission error, a stale mount) aborts the invocation with exit 78 rather than being skipped. Dropping it would drop every locked section along with it, on every invocation, behind a warning. The user and $OCX_HOME tiers stay best-effort: an unreadable candidate there is skipped with a warning and discovery continues. Absence is still absence at every tier, system included.

Explicit additions

Two mechanisms add a file on top of the discovery chain — they do not replace it. Missing files are an error in this case (explicit paths must exist).

  • --config FILE — CLI flag, before subcommand
  • OCX_CONFIG=/path/to/file.toml — environment variable

When both are set, --config layers on top of OCX_CONFIG. Setting OCX_CONFIG to the empty string disables an ambient value without unsetting it.

Discovery and Merge Precedence

Settings are resolved lowest-to-highest. Higher-precedence sources override lower ones.

PrioritySourceNotes
1 (lowest)Compiled defaultsBuilt into the OCX binary: [registries."ocx.sh"] index
2System config — /etc/ocx/config.tomlDiscovered tier
3User config — $XDG_CONFIG_HOME/ocx/config.toml (Linux) or ~/Library/Application Support/ocx/config.toml (macOS)Discovered tier
4OCX home config — $OCX_HOME/config.tomlDiscovered tier
5[managed] snapshotLocal, identity-gated; see Precedence and snapshot
6OCX_CONFIGLayered on top of the discovered chain and the managed snapshot
7--config FILELayered on top of OCX_CONFIG
8Environment variables (OCX_*)Win over any config file — except the five ladder variables below, which sit under the file that declares their key
9 (highest)CLI flagsPer-invocation; always win

Five variables invert row 8 and are the only ones that do: OCX_TOOLCHAIN_ACTIVATE, OCX_TOOLCHAIN_PINNED, OCX_TOOLCHAIN_DIR, OCX_LAZY_MODE and OCX_LAZY_REPORT. Each is the weakest tier of its own resolution ladder, so a file that states the key wins over an exported value and the variable decides only where no file speaks. The Environment Variable Override Table marks each one.

Merge rules

  • Scalars: the nearest (highest-precedence) value wins.
  • Tables (e.g. [registries.<name>]): merged key-by-key across tiers; inner keys use nearest-wins.
  • Layering: every file is loaded and merged in order. Explicit paths do not replace the discovered tiers.

Kill switch

OCX_NO_CONFIG=1 skips the discovered chain (tiers 2–4) and the [managed] snapshot (tier 5) — hermetic means hermetic, so the OCX_MANAGED_CONFIG env-override read is suppressed along with the candidate itself. Explicit paths (--config, OCX_CONFIG) still load, and so do the compiled defaults (tier 1) — they are part of the binary, not ambient host state.

GoalInvocation
Default(no flags)
Layer override on ambient--config extra.toml
Hermetic with a specific fileOCX_NO_CONFIG=1 --config ci.toml
Hermetic, no filesOCX_NO_CONFIG=1

Configuration Keys

Unknown keys and sections

OCX ignores what it does not recognize — an unknown top-level section, and an unknown key inside any known section, in every table below. The file still loads, and every setting this ocx does understand takes effect.

This is a deliberate trade, and the reason is the [managed] tier: one config.toml is read by every ocx version in a fleet at once. A file written against a newer ocx has to degrade to "the parts I understand" on an older binary. Rejecting it instead would take the whole file out of service on every host that had not upgraded yet — one new key in a central rollout, and the mirror map and patch registry vanish fleet-wide.

The cost is that a typo silently does nothing. [registries."ocx.sh"] indx = "..." sets no index; [mirrors."ghcr.io"] registr = "..." mirrors nothing. Two things blunt it:

  • A typo never becomes the field it resembles, and never widens anything: an unknown key cannot populate trusted_hosts or any other setting.
  • The config schema lists every key OCX knows. Point your editor at it and a typo is flagged as you write, which is where you want to catch it.

For a managed payload, ocx config update --check shows what the tier actually resolved to. Before publishing, ocx config test runs the same lookup against a candidate file that has not been pushed yet.

When ignoring is not enough. Tolerance covers added keys. It cannot cover a change in what an existing key means or what shape its value takes — an older binary would read the new value with the old meaning. Those changes travel by tag instead: the tier's source is an ordinary OCI reference, so publish the incompatible payload under a new tag (:user-2) and leave :user serving the old one. Fleets move over as they upgrade; nothing has to happen in lockstep. See Rolling out an incompatible change.

toolchain-dir

Type: string (path)
Default: (unset — each project renders its toolchain at <project>/.ocx/toolchain)
Related: OCX_TOOLCHAIN_DIR — the weaker tier, consulted only when no config file sets the key

Plenty of sites run a rule that tools never write inside a checkout: the working tree carries source and nothing else, and a rendered toolchain sitting at <project>/.ocx/toolchain breaks it on every machine at once. toolchain-dir relocates every project's tree under one root you choose.

A project's tree then lands at <root>/<project-key>/toolchain/. <project-key> is the same 16-hex key the projects/ GC ledger and state/projects/<key>/ already derive from the canonical project directory, so one root holds many projects without their trees ever colliding.

The global toolchain home ignores this key entirely. It is always $OCX_HOME/toolchain, whatever toolchain-dir resolves to.

toolchain-dir sits at the root of config.toml, outside every section. It is not an ocx.toml key — ocx.toml rejects every key it does not recognize, so writing it there is a parse error, exit 78.

toml
toolchain-dir = "~/.cache/ocx/toolchains"

The directory does not have to exist yet: OCX creates it the first time it renders a toolchain into it. Resolving the key itself writes nothing — no directory, no probe file.

Expansion: ~, and nothing else

Two rules, both absolute:

  • A leading ~ expands against the home directory — %USERPROFILE% on Windows. A ~user form is not supported, and a ~ in any component but the first is a literal directory name.
  • Nothing else expands. %VAR% is taken literally on every platform, Windows included, and so is $VAR.

The consequence bites on Windows. toolchain-dir = '%LOCALAPPDATA%\ocx\toolchain' names a directory whose first component is the literal text %LOCALAPPDATA% — a relative path, refused with exit 78. Spell it out instead:

toml
# Windows
toolchain-dir = '~\AppData\Local\ocx\toolchains'

A TOML literal string (single quotes) keeps the backslashes as written; in a basic string each one has to be doubled.

Refusals

The resolved root must be a directory beneath your home directory or beneath $OCX_HOME, and must not be either of those anchors itself, a system location, or inside the global toolchain home. Every refusal below exits 78 (ConfigError), applied in this order — each message names the tier that declared the value (config.toml `toolchain-dir` or OCX_TOOLCHAIN_DIR) so an exported variable is never blamed on a file:

Refused whenThe message reads
A leading ~ cannot be expanded — ~user, or no resolvable home directory… declares <path>, whose leading '~' cannot be expanded: …
The value is relative, tested after ~ expansion… is the relative path <path>; a toolchain-dir root must be absolute, or one project resolves a different home from every working directory
Any component is ..… declares <path>, which contains a '..' component; write the directory the root actually names
The root is a filesystem root, or sits inside one of 23 system locations — /usr, /bin, /sbin, /lib, /lib64, /etc, /opt, /boot, /dev, /proc, /sys, the /var subtrees, and the macOS /System, /Library, /Applications, /private; on Windows %SystemRoot%, %ProgramFiles%, %ProgramFiles(x86)% and %ProgramData%. There is no opt-out… resolves to the system location <path>
The root is a containment anchor rather than a directory beneath one… resolves to <path>, which is the containment anchor <anchor> itself; name a directory beneath it
The root is at or under $OCX_HOME/toolchain… resolves to <path>, inside the global toolchain home <home>; a global `ocx pull` would prune other projects' trees there
Neither a home directory nor $OCX_HOME resolves, so nothing can contain it… declares <path>, but neither a home directory nor $OCX_HOME could be resolved to contain it
The root is outside both the home directory and $OCX_HOME… resolves to <path>, which is outside both the home directory and $OCX_HOME
Inspecting the path chain fails — EACCES, ELOOP, and their kin… resolves to <path>, whose nearest existing directory <dir> cannot be inspected
The path exists and is not a directory — a file, socket, FIFO or device node… resolves to <path>, whose nearest existing path <p> is not a directory
Linux and macOS only — the directory is not owned by the invoking user… resolves to <path>, whose nearest existing directory <dir> is owned by <owner> rather than by the effective user <user>
Linux and macOS only — the directory grants write to group or world… resolves to <path>, whose nearest existing directory <dir> has mode <mode>, granting write to group or world

The $OCX_HOME/toolchain exclusion is not a style rule: a project tree there would land at $OCX_HOME/toolchain/<project-key>/toolchain, where <project-key> is indistinguishable from a group directory, and a bare global ocx pull would prune other projects' trees as orphan groups.

The two ownership checks do not run on Windows

The last two rows are Unix-only. A Windows directory's permissions are an ACL rather than three mode triples, and reading a directory's owner needs a security API OCX does not call, so on Windows the guarantees are containment, the system-location set, directory-ness, and that the path can be inspected — nothing about who owns it. The .. refusal, the relative refusal, the containment set and the directory-ness refusal all run everywhere.

When the root does not exist yet, the last four rows are checked against its nearest existing ancestor — the directory OCX will create under, and therefore the one whose permissions decide whether another account could plant a launcher in a tree that lands on PATH.

Rolling it out to a fleet

toolchain-dir is available in every tier, a [managed] payload included, so one publish moves every host's project trees onto a chosen volume. Both files below are config.toml; only their lifecycle differs.

toml
# Every project tree renders under this root instead of inside the checkout.
toolchain-dir = "~/.cache/ocx/toolchains"

[mirrors]
"ghcr.io" = "https://artifactory.corp/ghcr-remote"
toml
[managed]
source   = "internal.company.com/ocx-config:user"
required = true
refresh  = "notify"
interval = "1d"

A refused value in a managed payload is still exit 78, and the message still reports it as config.toml `toolchain-dir` — the operator's remedy is to edit the payload, which is a config.toml like any other.

[registry]

Global settings for the registry subsystem.

default

Type: string
Default: "ocx.sh"
Overridden by: OCX_DEFAULT_REGISTRY environment variable

The default registry used for bare package identifiers — those without an explicit registry prefix. When you write kitware/cmake:3.28, OCX expands it to <default>/cmake:3.28.

default is always a literal identifier prefix — the same string used as a [registries.<name>] table key. OCX never dereferences it through any other field; every [registries.<name>] key is an identifier prefix, always.

toml
[registry]
default = "ghcr.io"

System-locked

When [registry] is declared at the system scope (/etc/ocx/config.toml), it is locked unconditionally — unlike [patches]'s system-required posture, there is no required field to gate the lock on. A bare [registry] default = "..." at system scope is enough: no lower-precedence config-file tier (user, $OCX_HOME, OCX_CONFIG, --config, or a [managed] payload) can change default once the system tier sets it.

[registries.<name>]

Per-registry settings, keyed by the registry's identifier prefix — the same host[:port] string your package identifiers carry, matched exactly (ocx.sh, ghcr.io, registry.corp:5000). This is not a free-form alias: a key that matches no registry name configures nothing.

The plural form (registries, not registry) is deliberate: it mirrors Cargo's convention and avoids a TOML collision with the singular [registry] global-settings section.

v1 scope

index, trusted_hosts, and insecure are defined in v1. The [registries.<name>] table is reserved for per-registry settings — future fields (location rewrite, timeout, auth) will slot into the same entry without breaking existing configs. Unknown fields inside an entry are ignored, like unknown keys and sections everywhere else in the file — see Unknown keys and sections.

index

Type: string

Selects the resolution protocol for this namespace. An entry that sets index resolves through the ocx-index protocol (root document → OCI image index → platform selection) against that base URL; an entry without index — or no entry at all — resolves as a plain OCI registry.

There is exactly one resolution protocol per namespace, and the index is authoritative for the whole registry: OCX never falls back from the index protocol to plain OCI tags, or the reverse. A name the index has no root for fails the resolve with an error naming that index — it does not quietly try the registry underneath. Setting index = "" (below) is the only way to take a namespace off the index.

The compiled-defaults tier ships exactly this entry, so ocx.sh is index-bearing out of the box:

toml
[registries."ocx.sh"]
index = "https://index.ocx.sh"

Any tier above it can restate index with a different base URL to route ocx.sh through a private index. Setting it to the empty string is the off-switch: an empty base URL is not a kind marker, so the namespace resolves as a plain OCI registry.

toml
[registries."ocx.sh"]
index = ""                    # resolve ocx.sh as a plain OCI registry

Pinning the namespace at a [mirrors] registry endpoint is the second off-switch, and it is implicit. Such an entry suppresses the compiled-in index for ocx.sh, which then resolves as a plain OCI registry through the mirror:

toml
[mirrors]
"ocx.sh" = "https://artifactory.corp/ocx-remote"   # compiled-in index suppressed

[mirrors] is keyed by traffic host and rewrites the physical location a name resolves to, so it does not follow a namespace through the index protocol — an air-gapped site that routes ocx.sh to its own registry would otherwise start dialling index.ocx.sh, a host it never allow-listed. Declaring where a namespace's traffic goes answers the question.

Two limits keep the switch from firing where it was not meant to:

  • Only a config file you control — the compiled defaults, the discovered chain, and --config/OCX_CONFIG. A [mirrors] entry arriving through the [managed] tier or through OCX_MIRRORS redirects traffic like any other, but cannot suppress the index: neither a remotely-published payload nor an inherited environment variable may drop a namespace off the verified resolution path and its yank gate.
  • Only the registry role. The index role is applied keyed on the index endpoint's own host, so "ocx.sh" = { index = … } cannot redirect anything for the ocx.sh namespace and does not suppress. Redirecting the index endpoint itself keeps the index path, pointed at your host:
toml
[mirrors]
"index.ocx.sh" = { index = "https://artifactory.corp/ocx-index" }   # index kept, served by corp

To keep the index path and pin the physical registry, name the index explicitly — a written index outranks the compiled-in one and is never suppressed:

toml
[registries."ocx.sh"]
index = "https://index.ocx.sh"                     # explicit: survives the mirror entry

[mirrors]
"ocx.sh" = "https://artifactory.corp/ocx-remote"
"index.ocx.sh" = { index = "https://artifactory.corp/ocx-index" }

The suppression applies only to the compiled-in tier and is logged at warn level, naming the namespace it dropped.

index needs no <dialect>+ URL-scheme prefix, because OCX has exactly one index wire dialect — the field's presence is the kind marker, the same convention Cargo uses for its own [registries.NAME] index = "…". An entry with no index field still resolves as plain OCI — it can still declare trusted_hosts for its physical registry, index and trusted_hosts are independent fields. Omitting index from an entry does not clear an inherited value: tiers merge field-wise, so a [registries."ocx.sh"] entry that declares only trusted_hosts keeps the compiled-in index. Only an explicit index = "" clears it.

An index-bearing namespace has no registry fallback

The named index is the sole authority for its namespace — a yanked tag, a tampered index object, or an unreachable endpoint is a hard error, never a silent fall-through to a registry serving the same name (see index.ocx.sh). An index.ocx.sh outage therefore blocks ocx.sh/… resolution; other namespaces are unaffected.

Why the resolved physical pointer uses oci://, never http(s)

A derived index's local root document — the file ocx index update writes under $OCX_HOME/index/<source>/p/<ns>/<pkg>.json — records the package's resolved physical location as oci://<host>/<repository>, not http:// or https://. That scheme marks the reference kind — "an OCI registry repository" — not a transport to dial. Transport is a host-side decision: it comes from a [mirrors] entry's own scheme for that host, or the plain-HTTP allowance the host carries — insecure = true on its [registries.<name>] entry, or OCX_INSECURE_REGISTRIES. If the pointer itself carried http:// or https:// instead, a publisher able to write that shared identity data could force every consumer resolving it down to plaintext — a scheme belongs to the operator who configures the host, never to data that travels with a package's identity.

file:// bases

index also accepts a file:// base, read straight off disk with no server — the consuming half of serving a local index snapshot:

toml
[registries."corp"]
index = "file:///srv/ocx-index/corp"

Two requirements, checked at startup rather than at first fetch — except under --offline, where no index source is built at all, so neither check runs and the command exits 0:

  • Empty authority. file:///srv/ocx-index/corp (three slashes) is a local path; file://host/… or file://localhost/… is a UNC/remote form and is refused — a file base never dials a network.
  • Absolute path. A relative tail, the bare filesystem root (file:///), and a bare Windows drive designator (file:///C:/) are all refused rather than silently resolving against wherever ocx happened to be launched from.

A file base is never host-keyed, so it is not a valid [mirrors] override target: the index role there redirects a host's traffic, and a file:// base has no host to key on. Point index itself at the path instead of trying to reach it through [mirrors].

Beyond the two startup checks, every fetch through a file:// base carries the same guarantees the HTTPS transport has, adapted to a filesystem:

  • Read-only. There is no write path — nothing under this namespace's index base is ever created, modified, or deleted through the file:// transport.
  • Size-bounded. A document over the same size cap the HTTPS transport enforces is refused, not silently truncated — the read is bounded by bytes actually consumed, never trusted from file metadata.
  • Symlink-contained. A path staged with symlinks (an rsync, hardlink, or symlink layout) is followed, but the resolved target must stay under the configured root once both are canonicalized; one that resolves outside it is refused rather than served.
  • Regular files only. A directory, device node, FIFO, or anything else that is not a plain file is refused — including a mid-read swap that would otherwise slip one past the initial check.

Everything else about the namespace is unchanged: file:// is still the ocx-index protocol (root document → OCI image index → platform selection), just read from a directory tree instead of over HTTPS, and every object is still verified against its recorded digest.

trusted_hosts

Type: array of strings (hostnames or CIDR blocks)

The SSRF escape hatch for this namespace's physical hosts. Before OCX dereferences an index root's oci://<host>/<repository> pointer into a physical registry fetch, it refuses any host that resolves to a private, loopback, link-local, or cloud-metadata address — that pointer is remote-controlled data, and a compromised or mirrored index could otherwise aim it at an internal service. A private registry legitimately lives on such an address, so listing its host or network here restores access for exactly this namespace without weakening the guard anywhere else.

Each entry is either an exact hostname or a CIDR block; a listed target skips the address check.

toml
[registries."corp"]
index = "https://index.corp.example"
trusted_hosts = ["10.0.0.0/8", "registry.corp"]

The guard is default-on and needs no configuration for public registries. There is no command-line flag to widen the trust set — the exemption lives only on the config entry, so a system-locked entry's trusted_hosts cannot be broadened by a lower tier or a CLI override. A refused host exits with a configuration error that names the host and points back to trusted_hosts.

Under a configured HTTP proxy, OCX never resolves the physical host itself, so this list is judged by name alone — checked against trusted_hosts, then refused if it is itself a forbidden IP literal — with no DNS lookup in between. A name-based internal host that only resolves through the proxy's own network is outside what trusted_hosts can see or control; refusing it is the proxy's egress policy to enforce, not OCX's.

insecure

Type: boolean
Default: false (HTTPS)

Contact this registry over plain HTTP. This is the per-registry spelling of what OCX_INSECURE_REGISTRIES says as a flat list, and the two are a union — a host named in either is plaintext-eligible:

toml
[registries."registry.corp:5000"]
insecure = true

Neither source can take a host back out of the set on its own — insecure = false at the user or $OCX_HOME tier states the default explicitly, it does not revoke a host the environment granted. The one exception is the system tier: an insecure = false entry declared at system scope is locked and subtracts that host from the union, OCX_INSECURE_REGISTRIES included — the platform engineer's lever for forbidding plaintext to a host outright. A system tier that says nothing about insecure for a host locks nothing and subtracts nothing; only an explicit false at that scope does.

The name is matched exactly, host[:port] together — the same comparison the transport makes. An entry for registry.corp does not cover registry.corp:5001, and case matters. Write the resolved registry host — the one exception is docker.io, which resolves to index.docker.io and is never served over plain HTTP, so no spelling of it is accepted here.

The allowance covers transport and nothing else. An HTTPS registry that answers with a plaintext token-service realm is still refused, an insecure entry for one host never licenses cleartext for another, and an HTTPS request that is redirected to http:// is refused rather than followed. Plain HTTP sends registry credentials in the clear on the wire — the entry exists for a lab or an isolated corporate network, not for reaching a registry across the public internet.

A plaintext realm is allowed only when its host matches the registry's own, or that realm host is itself declared plaintext-eligible. If your plain-HTTP registry's authentication realm lives on a different host or port — a separate token service, for instance — declare that host too: one [registries.<name>] insecure = true entry per host, or both hosts listed in OCX_INSECURE_REGISTRIES. An entry for the registry alone does not cover it, and registry.corp:5000 / registry.corp:5001 are different hosts here, same as everywhere else in this feature.

System-locked

Each [registries.<name>] entry declared at the system scope is locked the same way as [registry] — unconditionally, per entry, covering index, trusted_hosts, and insecure. For index and trusted_hosts the lock is absolute: a lower tier cannot flip a locked entry's resolution protocol or widen its SSRF trust set, and neither field has an environment-variable counterpart to work around it.

insecure is the one field with such a counterpart — OCX_INSECURE_REGISTRIES normally unions with every config tier rather than being bound by them. The system tier is where that changes: a system-scope insecure = false entry additionally subtracts its host from the environment variable's contribution, the one case where a config tier can narrow the union instead of only widening it. A system tier that is silent about insecure for a host — no entry, or an entry that never mentions the field — locks nothing and subtracts nothing; only an explicit false at that scope does.

[mirrors]

A mirror replaces the network endpoint for one host — but a host can serve two different kinds of traffic. Registry traffic is the OCI /v2 distribution API (manifests, layers). Index traffic is the plain-HTTPS static files an ocx-index source serves (config.json, c/, p/). The two usually live on different hosts entirely — ghcr.io serves registry traffic for a package, index.ocx.sh serves index traffic for that same package's version pointer — so [mirrors] is keyed by whichever host is actually being redirected, and each entry states which role(s) the redirect covers.

toml
[mirrors]
"ghcr.io" = "https://artifactory.example.com/ghcr-remote"              # both roles → one host
"index.ocx.sh" = { index = "https://artifactory.corp/ocx-index" }      # index role only
"registry-1.docker.io" = { registry = "http://mirror.local:5000" }     # registry role only

A plain string value redirects both roles for that host — the common case, where one corporate proxy fronts everything a host serves. An object { registry?, index? } splits per role: registry redirects /v2 distribution traffic, index redirects the index static-file tree. A role field left out of the object means no redirect for that role — there is no fallthrough to the other form.

This is a source-replacement model: once a role is configured for a host, all matching read traffic for that host goes to the mirror. There is no origin fallback. An unreachable mirror is a hard error — in firewall-controlled networks, falling back to the open internet would silently defeat the point.

Value shape

Type: string, or an object with optional registry and index string fields
Required at startup: an entry with an empty string, or an object where every present field is empty, is a hard error when OCX resolves the mirror map — same enforcement point as the [registries] v1 scope.
Overridden by: OCX_MIRRORS — per-host, per-role; a role set in OCX_MIRRORS wins over the same role from the config entry

Each role's value is scheme://host[/repo-key-prefix]. For the registry role, OCX builds the full pull path as <mirror-host>/<prefix>/<upstream-repo>:

toml
# Artifactory path-based routing (repository-path method):
# ghcr.io/owner/tool:1.2  →  artifactory.example.com/ghcr-remote/owner/tool:1.2
[mirrors]
"ghcr.io" = "https://artifactory.example.com/ghcr-remote"

# Subdomain / host-only form (empty prefix):
# ghcr.io/owner/tool:1.2  →  ghcr-remote.artifactory.example.com/owner/tool:1.2
[mirrors]
"ghcr.io" = "https://ghcr-remote.artifactory.example.com"

Artifactory note. The registry-role value is the Docker/OCI pull path: <host>/<repo-key>. This is not the Artifactory admin REST path (/artifactory/api/docker/<repo-key>) — that path is for administrative operations and is not a valid Docker pull URL. The pull path is what you would use with docker pull or oras pull.

Nexus 3.83+ path-based routing uses the same <host>/<repo-key> shape as Artifactory — the repo-key alone, without any prefix:

toml
# Nexus Repository 3.83+ path-based routing (repo-key only, no /repository/ prefix):
# ghcr.io/owner/tool:1.2  →  nexus.corp/docker-proxy/owner/tool:1.2
[mirrors]
"ghcr.io" = "https://nexus.corp/docker-proxy"

Nexus legacy form

The legacy /repository/<name> URL form (e.g. https://nexus.corp/repository/docker-proxy) is not used with Nexus 3.83+ path routing. Use the repo-key alone as the path prefix, matching the Artifactory convention above.

Older Nexus deployments expose each repository on a per-repository port. Those use the host-only mirror form (https://nexus.corp:8082 — no path prefix).

Harbor follows the same <host>/<project-name>/<image> shape for its project-level proxy caches.

Docker Hub library/ images. OCX appends the repository path verbatim and does not expand Docker Hub short names. For Docker Hub official images, use the fully-qualified form (docker.io/library/alpine) so the mirror URL resolves to <mirror>/<prefix>/library/alpine.

Index role. The same scheme://host[/path-prefix] shape applies to index, and OCX contacts it for every root, index-object, and catalog fetch a resolved namespace's ocx-index protocol makes — content is still verified by SHA-256 against the digest recorded in the fetched object, so the mirror changes only where bytes come from, never whether they are trusted.

Same-host co-serving. The two roles are path-disjoint (/v2 versus config.json/c//p/), so an object entry can point both roles at the same host without collision if a deployment ever serves both from one proxy.

Scheme default. When a role's value has no scheme:// prefix (e.g., "nexus.corp/docker-proxy"), OCX defaults to https. Explicit https:// is recommended for clarity.

Plain-HTTP mirrors. A role value starting with http:// requires the mirror host to be plaintext-eligible — either insecure = true on its own [registries.<name>] entry or a listing in OCX_INSECURE_REGISTRIES. The same gate applies to both the registry and index roles. If the mirror host is in neither, OCX exits at startup with an actionable error naming the mirror host and both ways to allow it — it does not silently downgrade TLS. The check runs before any network activity.

Malformed values

[mirrors] values parse against a named shape — a string, or an object with only registry/index fields — with per-field errors rather than an opaque "did not match any variant" message. A role with a non-string value ({ registry = 5 }) is a parse error naming the offending host and field.

An unrecognized key is a different case: it is ignored, like unknown keys everywhere else in the file (see Unknown keys and sections). An entry left with no role OCX recognizes — { registr = "..." }, or an entry declaring only a role a future ocx will understand — contributes no mirror for that host and is skipped. Nothing else in the file is affected.

System-locked

A [mirrors] entry declared at the system scope locks unconditionally, per role — the same enforcement as [registry], narrowed to whichever role(s) the system-scope value covers. A plain-string system entry locks both roles for that host; an object entry with only index set locks the index role and leaves the registry role open to a lower tier — a corporate policy can pin where index traffic goes while leaving OCI mirror choice to the project. A lower-precedence tier cannot add, change, or remove a role the system tier already locked for a host; other roles for that host, and hosts the system tier did not mention, still resolve through ordinary merge.

Merge behavior

[mirrors] entries merge field-wise across config tiers, not whole-entry: OCX normalizes every value — string or object — to its two roles before merging, so a higher-precedence tier that sets only the index role for a host leaves a lower tier's registry role for that host untouched, and vice versa. A higher-precedence plain-string entry sets both roles and so overrides both, same as before.

OCX_MIRRORS overrides on the same per-host, per-role basis: a role present in a host's OCX_MIRRORS entry replaces the config entry for that role only; roles and hosts absent from OCX_MIRRORS still come from [mirrors].

Auth

Credentials are resolved against the mirror host, not the upstream. Configure them with OCX_AUTH_<mirror_slug>_* or via docker login against the mirror host. The upstream's credentials are never consulted on the read path. Static-file index endpoints have no OCI token flow, so there is no equivalent auth mechanism for the index role today — this is deferred until a deployment needs authenticated access to a mirrored index.

Interactions

ConcernBehavior
[registry] default / OCX_DEFAULT_REGISTRYDefault injection runs before mirror rewrite. A bare identifier expanded to the default registry is then mirrored if that registry has a [mirrors] entry.
--offlineNo network activity at all; mirrors are not consulted.
--remoteMutable lookups (tag list, tag→digest resolution) hit the mirror, not the origin.
ocx.lockStores canonical upstream coordinates and per-platform leaf digests — not the mirror host. A lock made behind a mirror is valid on a machine with direct egress, and vice versa.
pushPush is not mirror-redirected. The canonical upstream host is contacted. Remote/proxy repositories are read-only; redirecting push would fail confusingly.
ocx index catalog / ocx index updateAgainst a namespace resolving through the ocx-index protocol, every root, index-object, and catalog fetch honors that host's index role only — unrelated to the same host's registry role, if any. Against a plain OCI registry mirror, the catalog lists only repositories a proxy-type mirror has cached — a registry-side constraint, not an OCX behavior.

Diagnosing a failed mirror fetch

A mirror sits between OCX and the package it asked for, so a failure needs to say which side of that redirect broke. A fetch routed through a mirror that fails names both the physical reference it fetched and the upstream host the mirror is configured for, so the error is diagnosable without cross-referencing [mirrors] by hand.

A mirror that answers a manifest request with anything other than a manifest — an HTML portal page, a login redirect, a landing page served by a dead or misconfigured proxy repository — is refused before digest verification runs, and the command exits with a data error naming the response's content type. Without this check, that HTML body would otherwise reach the digest comparison and fail there instead, reporting a digest mismatch for a problem that was never about digests.

[patches] section

The [patches] tier points at an operator-controlled OCI registry that hosts patch descriptors. Descriptors map glob patterns over package identifiers to companion packages — small packages that carry site-specific environment overlays (CA bundles, proxy endpoint variables, license-server hints). At exec time OCX composes matched companions' interface environment entries on top of the base package's entries without modifying the base package.

The [patches] tier is the execution-environment twin of [mirrors]: [mirrors] adapts where bytes come from; [patches] adapts what environment a tool runs in. Both are opt-in and configured here.

toml
[patches]
registry = "registry.corp.example/ocx-patches"
path     = "{registry}/{repository}"
required = true

registry

Type: string
Required: no — omitting registry (or the whole [patches] section) simply leaves the patch tier inactive. Only a present-but-empty registry = "" is a hard error at config resolve time — same footgun-guard as an empty [mirrors] url.
Overridden by: OCX_PATCHES (JSON wire format forwarded to subprocesses)

The OCI registry root that hosts patch descriptors. The global descriptor (__ocx.patch at the reserved global repository, e.g. <registry>/global:__ocx.patch) applies to all packages; per-package descriptors live at sub-paths computed from the path template.

toml
[patches]
registry = "registry.corp.example/ocx-patches"

path

Type: string
Default: {registry}/{repository}

Template for per-package patch repository paths. Two placeholder tokens are substituted at runtime:

TokenExpands to
{registry}Slugified registry host of the base package (e.g. ocx.sh stays ocx.sh; localhost:5000 becomes localhost_5000)
{repository}Repository path of the base package verbatim (e.g. java for ocx.sh/java:21)

The default {registry}/{repository} is suitable for most setups. Customise only if the patch registry lays out sub-paths differently:

toml
[patches]
registry = "registry.corp.example/ocx-patches"
path     = "bases/{repository}"

The expanded path always produces a non-empty sub-path. The reserved global repository name is the fixed location of the global descriptor and must not be used as a per-package path.

required

Type: boolean
Default: true

Fail posture when a matched companion package is unavailable.

ValueBehavior
true (default)Execution aborts if a matched companion cannot be resolved. Use for security-critical companions (CA bundles, proxy config) where running without the companion is unsafe.
falseOCX logs a warning and continues. Use for non-security companions (metrics endpoints, license server hints).

Scopes and merge

The [patches] section follows the same multi-tier merge as [mirrors]. A higher-precedence config tier ($OCX_HOME scope > user scope > system scope) overrides fields field-by-field.

System-required posture. When [patches] is declared at the system scope (/etc/ocx/config.toml) with required = true — or with no required line, which defaults to true — the tier is locked as system-required. A system-required tier cannot be redirected, suppressed, or flipped to fail-open by any higher-precedence tier, including OCX_PATCHES or per-package no-patches. This is the fail-closed enforcement point for corporate CA distribution.

An explicit required = false in the system config is NOT locked; a higher-precedence tier may still override it.

Per-package opt-out

A project can opt a specific base package out of the user-scope or project-scope patch tier by adding a [package."<id>"] table with no-patches = true to ocx.toml:

toml
[package."ocx.sh/kitware/cmake:3.28"]
no-patches = true

The match is by canonical registry/repository — tag and digest are stripped, so the opt-out is version-independent: it follows every tag of ocx.sh/kitware/cmake, not just 3.28.

A system-required tier is never skipped by no-patches, regardless of which surface below resolved the opt-out.

Where the opt-out is honored. The opt-out is a project-toolchain concern: it only takes effect where a project's ocx.toml is directly in scope. That covers three commands — ocx exec, ocx env, and ocx direnv export — each of which reads the project config and composes the environment itself.

A fourth surface reaches the opt-out indirectly: a tool spawned by ocx exec that re-enters ocx through its own generated launcher (ocx launcher exec). ocx exec forwards the opt-out to that child process over OCX_PATCHES — including, for each opted-out base actually resolved that run, its content digest, since a launcher resolves its base via a synthetic content-addressed identifier with no real registry/repository to match against.

A direct launcher invocation — one not spawned by an ocx exec that forwarded the opt-out, for example a generated launcher run standalone, or reached through the OCI-tier ocx package exec — has no forwarded opt-out to decode and does not honor no-patches. It composes the same companion overlay ocx package env would for the same base.

See Patch Opt-Out Scope for the full forwarding mechanics.

[records] section

The [records] tier turns on the exec-time resolution record — one JSON file written to an operator-designated directory immediately before OCX starts a tool, naming the exact package digests that composed the environment. Where [patches] adapts what environment a tool runs in, [records] answers, after the fact, exactly what ran. Absent at every tier, nothing is written and the exec path gains no I/O.

toml
[records]
dir      = "/var/log/ocx/records"
name     = "{time}-{pid}-{rand}.json"
required = true

See the Execution Records reference for the full record format, the sink's no-clobber write behavior, and a working policy-check example.

dir

Type: string (directory path) Default: unset — recording is off Overridden by: OCX_RECORDS_DIR, then --records-dir on ocx exec / ocx package exec

The sink directory. Always a directory, never a single file — see why the sink is a directory.

toml
[records]
dir = "/var/log/ocx/records"

OCX does not create this directory. It must already exist and be writable before the first record is written, or the invocation fails with the same I/O error a permissions problem would produce. Create it out of band — a provisioning step, a container image layer, a mkdir -p in the job that sets dir — before pointing OCX at it.

name

Type: string (filename template) Default: "{time}-{pid}-{rand}.json"Overridden by: OCX_RECORDS_NAME, then --records-name

Filename template over a closed placeholder set ({time}, {pid}, {rand}, {host}) — see Filename grammar for the full expansion table. An unknown placeholder is a config error (exit 78) at resolve time, never a silently-unexpanded literal.

required

Type: boolean Default: false when no SYSTEM lock applies; true when one does Config-file only — there is no OCX_RECORDS_REQUIRED and no --records-required. Recording posture is an operator decision, not a per-invocation one.

Fail posture when a record cannot be written.

ValueBehavior
trueThe invocation aborts before the child starts — exit 74 for an unwritable sink, exit 78 when the sink resolves through a symlink.
false (default, unlocked)OCX prints a warning to stderr; the child still runs.

Setting required = true is not reserved for the SYSTEM scope — it is that scope's default when a [records] block is present there, but any tier, including an operator-published [managed] payload, may set it explicitly. A managed configuration asserting the strictest posture is the intended use of that tier, not a special case requiring its own error path.

required = true needs a dir to be reachable at some tier. Writing it alone is a configuration error (exit 78) rather than recording turned off — a block saying only "recording is mandatory" must not resolve to a policy with nowhere to write. A SYSTEM-scope block with neither key is the one exception: that is an operator locking recording off for the host, and it resolves cleanly.

A true posture also refuses the maintainer-preview exemption: ocx package test and ocx patch test normally write no record, but under a fail-closed policy they exit 74 instead of running unrecorded. See the exemption's bound.

Scopes and lock

A [records] section declared at the system scope (/etc/ocx/config.toml) locks the whole blockdir, name, and required together — rather than field by field, unlike [mirrors]'s per-role lock or [patches]'s system-required posture. A collector downstream depends on the sink location and the filename pattern together, so a partial override would break collection exactly as surely as redirecting dir alone. Once locked, no lower config tier, OCX_RECORDS_DIR/OCX_RECORDS_NAME, or --records-dir/--records-name can change any of the three fields.

Without a system-scope declaration, dir and name merge through the ordinary highest-wins fold — config file → environment variable → CLI flag — and required merges across config tiers only.

A system-scope lock is not ambient configuration a caller can step around: it survives OCX_NO_CONFIG=1, which prunes the discovered user/$OCX_HOME tiers and the [managed] tier but still loads /etc/ocx/config.toml's locked sections. See OCX_NO_CONFIG for the full interaction.

It also survives a system file that cannot be read: an unreadable /etc/ocx/config.toml aborts the invocation rather than being skipped like an unreadable user-tier one, so a locked [records] policy cannot be dropped by a symlink or a stale mount — see File Locations.

[managed] section

The [managed] tier is a seed pointer, not the settings themselves. It names an operator-published OCX package whose content is a plain config.toml — typically [mirrors], a [patches] pointer, and a default [registry] — synced into local state and merged above the user config on every invocation. Where [mirrors] and [patches] are configured by hand on every machine, [managed] lets an operator publish one package (via ocx config push, previewed locally first with ocx config test) and have every workstation and CI runner converge on it.

Unknown fields inside [managed] are ignored, and so is everything unrecognized in the payload it points at — see Unknown keys and sections, which exists because of this tier. A payload written against a newer ocx applies its known parts on older fleet binaries instead of taking the whole thing out of service on them.

toml
[managed]
source   = "internal.company.com/ocx-config:user"
required = true
refresh  = "notify"
interval = "1d"

This block is normally written by ocx config setup (or ocx self setup --managed-config <ref>, which runs the same adoption) rather than hand-edited — both re-serialize the same four fields with their resolved values. Bootstrapping this way performs a synchronous fetch before the fence is written, so a network failure leaves no partial seed. See the managed-configuration walkthrough for the full onboarding flow.

source

Type: string
Required: yes, at resolve time — omitting source (or the whole [managed] section) leaves the tier inactive. A present-but-empty source = "" is a hard error, the same footgun guard as [patches] registry and [mirrors] url.
Overridden by: OCX_MANAGED_CONFIG — invocation-only, never written back to the seed

The OCI reference for the managed-config package: <registry>/<repository>[:<tag>][@<digest>], parsed with the same Identifier grammar as any other package reference. A registry-less source resolves against the built-in default registry (ocx.sh), never a configured [registry] default — the managed tier's trust root can not be redirected by the very config it is about to replace. Use a fully qualified reference in corporate seeds.

A source pinned by digest (…@sha256:<hex>) binds the tier to that exact content: the required gate accepts only a snapshot carrying that digest, so a drifted registry (or a config update <VERSION> to anything else) fails closed until the seed pin is updated.

required

Type: boolean
Default: true

Fail posture when the tier contributes nothing.

ValueBehavior
true (default)Every command fails closed with SnapshotRequired (exit 78) until ocx config update (or ocx config setup / ocx self setup --managed-config) syncs a matching snapshot. Identical online and offline — the gate is on local disk state, not network reachability.
falseThe tier contributes nothing until synced. A throttle-gated stderr hint is printed instead of failing (no per-invocation warning).

The gate is on what actually reached the merged config, not merely on a file being present. A snapshot that matches source but whose payload does not parse as a config applies nothing, so required = true fails closed on it too — with SnapshotUnusable (also exit 78), which names the real problem instead of reporting a snapshot that is sitting right there as absent. Under required = false the same state is a warning and the tier stays empty. Note this is about a broken payload: unknown keys and sections are not broken (see Unknown keys and sections) and fold normally.

refresh

Type: string ("apply" | "notify" | "manual")
Default: "notify"

Background refresh posture, checked at most once per interval. ocx config update always bypasses this — it is explicit user intent, mirroring ocx self update.

ValueBehavior
applyDrift against the registry silently triggers a full fetch, persist, and snapshot swap.
notify (default)Drift prints a stderr advisory ("run ocx config update"); content is not fetched by the tick.
manualThe background tick is skipped entirely; only an explicit ocx config update refreshes the snapshot.

OCX_NO_CONFIG_REFRESH kills the background tick regardless of refresh; an explicit ocx config update still works — and so does the reconciling re-sync ocx self setup and ocx config setup run against an already-adopted seed on every invocation. This variable governs the background tick only; use --offline to skip the setup-time re-sync instead.

Activation conditions. The tick this posture governs only runs when all of the following hold: stderr is a terminal, the process is not running inside CI (CI unset), the invocation is not offline (--offline/OCX_OFFLINE), the tier is not paused (ocx config update --pause), and the interval throttle window has elapsed. Any one of those failing skips the tick outright — so refresh = "apply" never auto-converges a CI runner or another headless host; those hosts converge only through an explicit ocx config update.

interval

Type: string, \d+[smhd]? (bare digits = seconds)
Default: "1d"

Minimum spacing between background refresh probes. Governs only the automatic tick — ocx config update always bypasses it. interval = "0" (or "0s") disables the throttle: the tick probes the registry on every eligible invocation instead of waiting out a window.

Precedence and snapshot

The managed tier folds in as priority 5 in the precedence table — after the $OCX_HOME config tier and below OCX_CONFIG/--config. Resolution reads a local snapshot only; no network access happens during ordinary config loading.

The snapshot lives at $OCX_HOME/state/managed-config/snapshot.json and is written only by ocx config update, ocx config setup, or ocx self setup --managed-config. It records the source it was fetched from, the tag it tracked at that moment, the package's top-level manifest digest (the tier's drift identity), the fetch timestamp, and the payload text.

Before folding it in, OCX identity-gates the snapshot against the effective source (env override, then seed): the snapshot must come from the same registry and repository, and — when the seed pins a digest — carry exactly that digest. Tags float within a repository: a snapshot synced with ocx config update user-1.4.1 still satisfies a seed tracking :user, which is what makes per-host version pins and rollbacks safe under a fleet-wide floating tag. A cross-repository or pin-violating snapshot is treated as entirely absent, regardless of required; this closes a CI cache-poisoning path where a stale $OCX_HOME carries a snapshot fetched for a different source.

A content-bearing pause file ($OCX_HOME/state/managed-config/pause.json, written by ocx config update --pause) sits beside the snapshot: while in force it short-circuits the background tick — and nothing else. Expired or corrupt pause files read as absent.

One-hop rule

A [managed] section inside the fetched payload itself is stripped before merge, with a warning — the tier that fetched a payload can never be redirected or loosened by that same payload. Every other section in the payload ([mirrors], [patches], [registry], …) merges normally.

System-lock interaction

[managed] merges through the same Config::merge fold as every other tier, so a system-scope lock on [registry], [registries.<name>], or [mirrors] is never overridable by a managed payload — the lock applies before the managed tier's content is folded in, the same as it applies to any lower tier. [managed] also carries its own lock: a system-scope [managed] declaration with required = true (the default) is itself non-overridable by any lower tier, mirroring [patches]'s system-required posture. [[trust.policy]] locks differently, because it pools instead of replacing: a system-scope policy governs the scopes it matches alone, so a managed payload can neither outbid it with a narrower scope nor enroll a signer alongside it — it can only pin scopes the system tier never mentions.

[[trust.policy]]

ocx package verify checks a Sigstore signature's certificate against an expected identity and OIDC issuer, supplied either as flags (--certificate-identity / --certificate-oidc-issuer) or, once declared here, resolved automatically for any package whose identifier falls under a policy's scope.

toml
[[trust.policy]]
scope = "ghcr.io/acme/*"

signers = [
  { kind = "keyless", identity = "https://github.com/acme/tool/.github/workflows/release.yml@refs/heads/main",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

[[trust.policy]] is an array-of-tables — declare one entry per scope, with a signers array listing every signer accepted for it. It is valid in every config.toml tier (system, user, $OCX_HOME) and in the project ocx.toml. Reading it from ocx.toml is a deliberate exception: every other OCI-tier command ignores ocx.toml entirely, but a trust policy is a security posture the checkout owner controls, not toolchain-binding resolution. The two sources are not equal peers, though — see Tier precedence below.

Fields

scope, builder, and signers are declared directly on [[trust.policy]].

FieldTypeRequiredDescription
scopestring or tablenoWhich packages this policy applies to — one prefix pattern ("ghcr.io/acme/*"), or an { include, exclude } table of them. Omitting it is a catch-all: the policy governs every package. See Scope matching.
builderstringnoExpected SLSA provenance builder.id (byte-equal). Only consulted when verifying an attestation whose predicate is SLSA provenance (verify --attestation); ignored for a plain signature or any other predicate type. A mismatch is builder_mismatch (exit 65).
signersarray of tablesyesThe signers this policy accepts for its scope. See Signers below.

Unknown keys are ignored, like everywhere else in config.toml: a file written for a newer ocx must still load on an older one, so a typo'd key (e.g. scop) is silently dropped rather than rejected.

A dropped scope key widens the policy

scope is optional and an absent one is a catch-all, so the tolerance above cuts both ways: a policy whose scope key is misspelled loses its scope and then governs every package rather than none. The one place the tolerance stops is inside a scope table — see Include and exclude.

Signers

signers is an array of tables, each tagged kind = "keyless" or kind = "key". Every entry is one more way for a package under this policy's scope to pass verification.

Adding a signer widens acceptance — it never narrows it

signers is an ANY-of list: a signature passes if it satisfies any one entry. Adding a kind = "key" entry to a policy that already has a kind = "keyless" one does not switch the policy over to keys, or tighten it in any way — it just adds a second way in. Most readers hear "add a key policy" as tightening, which is the opposite of what happens. Narrowing a policy means removing entries, or — at the operator tier — declaring a system-locked policy that displaces the lower tiers wholesale.

An absent or [] signers array is a configuration error, not a catch-all — it fails closed rather than silently accepting everything or nothing.

kind = "keyless" fields:

FieldTypeRequiredDescription
identitystringXOR with identity_regexpExact expected certificate SAN (byte-equal).
identity_regexpstringXOR with identityRegex the certificate SAN must match in full. See Regex identities.
oidc_issuerstringyesExact expected OIDC issuer URL (byte-equal). No regex form in this release — issuer URLs are stable.

Exactly one of identity / identity_regexp must be set on each keyless entry — both present, or both absent, is a configuration error. What still fails the entry is a missing required field — a keyless signer without oidc_issuer is a parse error, and one that ends up with neither identity nor identity_regexp is rejected when the policy compiles, never silently treated as "trust anything".

kind = "key" fields:

FieldTypeRequiredDescription
keystringXOR with key_pemA key reference, [scheme://]<rest> in cosign's spelling — a bare path, or a file:// one, names a file; env://VAR holds the SPKI PEM in the environment variable VAR.
key_pemstringXOR with keyThe public key, as a verbatim SPKI PEM block.

Exactly one of key / key_pem must be set on each key entry — both present, or both absent, is a configuration error. There is no key_regexp: a public key is a fixed value, not a pattern, so a key signer always pins one exact key.

toml
[[trust.policy]]
scope   = "ghcr.io/acme/*"
signers = [
  { kind = "key", key = "etc/acme-release.pub" },
  { kind = "key", key_pem = """
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----""" },
]

A relative key reference resolves against the directory of the config.toml that declared it — the same ordinary path resolution [trust.sigstore] trusted_root already uses, not a containment check.

A key reference the verifier cannot read — absent, permission denied, or any other I/O failure — is exit 74 (io_error, with error.detail key_unreadable), the same code ocx package sign --key <path> answers for the same unreadable file. Not 78: the path is a filesystem problem, not a malformed policy. A path that is not a regular file at all — a directory, a device — is 74 for the same reason.

A file that reads fine and holds something that is not an SPKI public key is 65 (key_malformed), again matching sign: the path was usable and the material was not. Only an inline key_pem that is not a key stays 78, because there the config text itself is the thing that is wrong. One rule, keyed on what failed rather than on which command asked.

Mixing kind = "keyless" and kind = "key" entries in one policy is legal and is how a fleet migrates between signing models without touching scope:

toml
[[trust.policy]]
scope   = "ghcr.io/acme/*"
signers = [
  { kind = "keyless", identity = "ci@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
  { kind = "key",     key = "etc/acme-release.pub" },
]

Generating a key pair

OCX does not implement key generation. Run cosign generate-key-pair from an activated environment — cosign ships in the OCX index — to produce a cosign.key / cosign.pub pair. An encrypted private key's password is read from OCX_KEY_PASSWORD, never a flag; leaving it unset means the empty password, which cosign permits.

Publishing signers to a fleet

A key reference naming a path names it on the operator's disk, which means nothing on a consumer's — the same problem [trust.sigstore] trusted_root has. ocx config push refuses a managed-config payload that declares a kind = "key" signer by path, naming key_pem as the fix (exit 78, a configuration error): inline the key with key_pem before publishing it. A KMS reference is not a path and is not refused for this reason — it travels with the payload and means the same thing on every consumer — but a backend OCX has not implemented yet is refused as exit 85 (unsupported_key_backend), the same code --key and a local tier answer for it. Local tiers — project, operator, and user config on the author's own disk — leave key unrestricted; the refusal applies only to a published payload.

Scope matching

A scope matches the target's canonical registry/repository (tag and digest stripped) on path-segment boundaries — for the two safe forms below. A scope with no * matches the exact package or a package directly under it: scope = "ghcr.io/acme/tool" matches ghcr.io/acme/tool and ghcr.io/acme/tool/plugin, but not ghcr.io/acme/tool-cli (and scope = "ghcr.io/acme" never matches ghcr.io/acmecorp). A trailing /* is the explicit subtree glob (scope = "ghcr.io/acme/*" covers everything under ghcr.io/acme/, but not ghcr.io/acmecorp). An empty scope is a catch-all.

A mid-string * is a substring match, not segment-bounded

Segment-boundary matching holds only for the no-wildcard and trailing-/* forms. A * placed anywhere else globs on the literal text before it, with no / boundary enforced: scope = "ghcr.io/acme*" matches ghcr.io/acmecorp and ghcr.io/acme-evil because it is a plain literal-prefix (substring) match on ghcr.io/acme. Prefer ghcr.io/acme/* (or a bare ghcr.io/acme) unless you specifically intend the substring behavior.

Include and exclude

A scope can also be written as a table of patterns instead of a single one. Each pattern follows the per-pattern rules above unchanged — segment-bounded without a *, literal-prefix glob with one — and the table only says how they combine:

toml
[[trust.policy]]
scope = { include = ["ghcr.io/acme/*", "ocx.sh/cmake"], exclude = ["ghcr.io/acme/experimental/*"] }

signers = [
  { kind = "keyless", identity = "ci@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

A target matches when it matches at least one include and no exclude. The table must carry one of the two keys; the other then defaults to an empty list. An empty include reads as a catch-all — so exclude on its own is the carve-out form: govern everything, except one subtree.

toml
# Every package must be signed by CI — except the experimental namespace,
# which is left ungoverned and installs unverified as before.
[[trust.policy]]
scope = { exclude = ["ghcr.io/acme/experimental/*"] }

signers = [
  { kind = "keyless", identity = "ci@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

exclude beats include whenever both match. An excluded package is not "denied" — it is simply not covered by this policy, so resolution continues without it: another policy may still cover the package, and if none does, it is ungoverned and installs unverified. Carving a scope out of one policy therefore removes a pin; it never adds a prohibition.

exclude is segment-bounded too

exclude = ["ghcr.io/acme/tool"] carves out ghcr.io/acme/tool and everything under it, but not ghcr.io/acme/tool-cli — the same boundary rule that governs a plain scope string. Use ghcr.io/acme/tool* if you do mean the substring.

A table naming neither key is refused

scope = {} — or a table whose only keys ocx does not recognise, such as a misspelled includ — is a parse error, not a catch-all. This is the single exception to the unknown-key tolerance described under Fields: dropping an unrecognised key elsewhere narrows nothing, but here it would leave both lists empty and turn a narrow pin into one over every package. An unknown key riding alongside include or exclude is still dropped as usual. Write scope = "", or omit scope, when you do mean a catch-all.

There is no regex form for scopes, in either spelling. identity_regexp is the only regex surface here: a scope decides which packages a pin covers, where an over-broad pattern silently widens trust rather than failing loudly.

Resolution: most-specific-wins

When more than one policy's scope matches a target, the longest literal prefix wins:

toml
[[trust.policy]]                          # literal prefix "ghcr.io/acme/" (13 chars)
scope = "ghcr.io/acme/*"

signers = [
  { kind = "keyless", identity = "ci@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

[[trust.policy]]                          # literal prefix "ghcr.io/acme/secret-tool" (24 chars)
scope = "ghcr.io/acme/secret-tool"

signers = [
  { kind = "keyless", identity = "release-bot@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

Verifying ghcr.io/acme/secret-tool:1.0 only accepts release-bot@acme.example — the narrower policy wins outright, and the broader ghcr.io/acme/* policy still governs every other package under that prefix.

Among policies tied at the same winning specificity, evaluation is ANY-of: the signature passes if it satisfies any one of them. This is what makes signer rotation possible without a downtime window — declare both the old and the new identity at the same scope, and either one verifies until the old entry is removed:

toml
[[trust.policy]]                          # both scopes tie at "ghcr.io/acme/" (13 chars)
scope = "ghcr.io/acme/*"

signers = [
  { kind = "keyless", identity = "old-ci@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

[[trust.policy]]
scope = "ghcr.io/acme/*"

signers = [
  { kind = "keyless", identity = "new-ci@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

A policy whose scope is an { include, exclude } table ranks by the longest literal prefix among the includes that matched this target — measured per target, because one table can cover two packages through two different patterns. exclude patterns never contribute: they subtract coverage, so a long carve-out string cannot buy a policy a higher rank than the packages it actually governs. An include-free carve-all ranks 0, exactly like scope = "".

Regex identities

identity_regexp compiles to an anchored, full-string match, not a substring search — a pattern must match the entire certificate SAN, start to end. This mirrors cosign's --certificate-identity-regexp semantics and rules out a pattern like acme accidentally matching evil-acme-lookalike.

toml
[[trust.policy]]
scope = "ghcr.io/acme/*"

signers = [
  { kind = "keyless", identity_regexp = "^https://github\\.com/acme/.*/\\.github/workflows/release\\.yml@refs/tags/v[0-9.]+$",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

identity_regexp is useful when the SAN embeds a variable path component — a GitHub Actions workflow SAN carries the git ref it ran on (…/release.yml@refs/heads/main), so pinning one exact ref with identity would lock out every other branch or tag that same workflow signs from.

Tier precedence: operator-authoritative, not pooled

Every other section on this page replaces at higher-precedence tiers. Within the config.toml tiers themselves, [[trust.policy]] is the one exception — policies array-append (pool) across system, user, and $OCX_HOME instead of the nearest tier winning:

system config.toml  →  user config.toml  →  $OCX_HOME config.toml

Call the pooled result of those three tiers the operator trust set. The project ocx.toml's policies are not pooled into that set — they sit behind it, at lower priority:

  • If any operator policy matches the target package, only the operator trust set is evaluated; the project ocx.toml is ignored for that package, no matter how specific its scope is.
  • Only when no operator policy matches does the project ocx.toml apply. A project can therefore add trust for scopes the operator has not governed, but it can never step in front of a scope the operator already pins.

Within whichever set is chosen, most-specific-wins + ANY-of resolution still applies — signer rotation works within the operator set, and separately within the project set, but the two sets never mix for one target.

A project ocx.toml cannot weaken an operator policy

This is a deliberate security property: because the operator trust set wins outright whenever it matches, a compromised or careless project ocx.toml cannot override or narrow an operator-pinned identity by declaring a more specific scope. ocx.toml can only extend trust to packages the operator has left ungoverned.

System-locked

Pooling makes the three config.toml tiers peers on storage, but not on authority. A policy declared at the system scope is locked, unconditionally and per entry: for every scope it matches, it governs alone. Entries from the user, $OCX_HOME, and managed tiers are refused for those scopes — a narrower scope cannot take over, and an equally specific one cannot join the accepted set either.

toml
# literal prefix "ghcr.io/acme/" — 13 chars, locked
[[trust.policy]]
scope = "ghcr.io/acme/*"

signers = [
  { kind = "keyless", identity = "ci@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]
toml
# literal prefix "ghcr.io/acme/tool" — 17 chars
[[trust.policy]]
scope = "ghcr.io/acme/tool"

signers = [
  { kind = "keyless", identity = "someone-else@example.test",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

Verifying ghcr.io/acme/tool:1.0 accepts ci@acme.example only. Without the lock the narrower entry would win outright by most-specific-wins. With it, every lower-tier entry matching the pinned scope is discarded — a longer literal prefix, a shorter one (ghcr.io/*, say), and an exact tie at 13 characters alike.

Rotation therefore happens in the system tier: declare the outgoing and incoming identities as two locked entries, and both are accepted for the overlap window.

/etc/ocx/config.toml
toml
[[trust.policy]]
scope = "ghcr.io/acme/*"

signers = [
  { kind = "keyless", identity = "ci@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

[[trust.policy]]                          # same scope, second accepted signer
scope = "ghcr.io/acme/*"

signers = [
  { kind = "keyless", identity = "ci-2027@acme.example",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]

What the lock does and does not reach

The lock is per scope, not fleet-wide. It governs the scopes its own entries match, and nothing else: a lower tier is still free to pin any scope the system tier never mentions — ghcr.io/other/* in the example above — and does so with full authority there. A lock on ghcr.io/acme/* is not a statement about the rest of the registry.

Within a locked scope, though, no lower tier can add a signer, narrow the scope to carve one package out, or displace the operator's identity. Whoever writes the user tier, $OCX_HOME, or the managed-config payload cannot enroll a signer that ocx package verify will accept there; a refused entry is reported at debug level naming the pin that discarded it, so an operator whose policy went nowhere is not left staring at an identity mismatch.

No matching policy, no flags

--certificate-identity / --certificate-oidc-issuer on ocx package verify are optional exactly when a [[trust.policy]] scope matches the target. Passing both flags always overrides any policy — an exact-match pair, unchanged from flag-only verification. Passing neither flag with no matching scope, or passing only one of the two flags, is a usage error. See the package verify exit codes for the full behavior.

[trust.sigstore]

Where ocx package verify gets its trust root when the Sigstore stack is self-hosted rather than the public good, and which Fulcio/Rekor endpoints ocx package sign talks to by default.

toml
[trust.sigstore]
trusted_root = "sigstore/trusted-root.json"    # path, relative to THIS config file
fulcio_url   = "https://fulcio.corp.example"
rekor_url    = "https://rekor.corp.example"
rekor_upload = true

Every field is optional and the whole sub-table may be absent — omitting it reproduces public-good behaviour exactly. It is read from the config.toml tiers only: the project ocx.toml also parses a [trust] section (for [[trust.policy]]), but its sigstore sub-table is never consulted. A repository that could name its own Fulcio CA would be verifying its own signatures, which is the entire trust decision.

Fields

FieldTypeDescription
trusted_rootstringA bare path, or a file:// one, naming a Sigstore trusted-root JSON or a directory holding trusted_root.json — the two spellings signers[].key reads. A relative path resolves against the directory of the config.toml that declared it — rewritten to absolute at load time, so the value means the same file regardless of the process working directory. Mutually exclusive with trusted_root_json
trusted_root_jsonstringThe trusted-root document inlined verbatim. This is the form a fleet receives — see Publishing to a fleet. Mutually exclusive with trusted_root
fulcio_urlstringDefault Fulcio base URL for ocx package sign / attest when --fulcio-url is omitted. Precedence: an explicit flag wins, then this field, then the public-good builtin. ocx package push --sbom has no --fulcio-url flag at all, so this field is its only override
rekor_urlstringDefault Rekor base URL for ocx package sign / verify / attest / sbom when --rekor-url is omitted. Precedence: an explicit flag wins, then this field, then the public-good builtin. ocx package push --sbom and auto-verify expose no --rekor-url flag, so this field is their only override
rekor_uploadbooleanThe fleet-wide default for uploading a key-mode signature to the transparency log. Absent means off.

Setting both trusted_root and trusted_root_json is a configuration error — exit 78, trust_root_load. One trust root, one spelling.

rekor_upload governs key mode only

Under keyless signing, uploading to Rekor is a requirement, not a default governed by this field — a Fulcio certificate is valid for about ten minutes, and the Rekor timestamp is the only proof the signature happened inside that window. rekor_upload is silently ignored for a keyless signer, deliberately without a warning.

Where it sits in the ladder

Verify resolves its trust root through six rungs, first hit wins:

  1. --sigstore-trusted-root on ocx package verify
  2. OCX_SIGSTORE_TRUSTED_ROOT
  3. [trust.sigstore] trusted_root / trusted_root_json — this section
  4. $OCX_HOME/sigstore/trusted-root.json — a convention path, no config needed
  5. The trust-root cache under $OCX_HOME/state/trust_root/, written by a prior online verify
  6. The public-good Sigstore root, fetched over TUF

Rungs 1–3 are operator-named: a file that does not exist is an error, not a fall-through. Rung 4 is a convention: absent falls through, but present-and-unreadable fails. See Self-hosted Sigstore for choosing among them.

System-locked

Declared at the system scope (/etc/ocx/config.toml), the whole sub-table becomes non-overridable — the user, $OCX_HOME, and [managed] tiers cannot replace any of its fields. The lock is per-table, not per-field: a lower tier cannot supply a rekor_url alongside a system trusted_root.

This follows the [registry] precedent rather than the [[trust.policy]] one, because a scalar trust root cannot pool: two Fulcio CAs is not a merge, it is an ambiguity. Where the sub-table is not system-locked, higher tiers replace field by field — with the two trust-root spellings coupled, so a tier switching from a path to an inline document drops the path rather than leaving both set.

Publishing to a fleet

A path on the operator's disk means nothing on a consumer's, so ocx config push reads a path-form trusted_root at publish time, validates that it parses as a Sigstore trusted root, and publishes it as trusted_root_json. Comments, key order and every other field survive the rewrite.

The loader enforces the other half on the consuming side:

  • A path-form trusted_root arriving from the [managed] tier is ignored with a warning. A remote payload cannot name a path on this machine.
  • A trusted_root_json arriving from a [managed] source that is not digest-pinned is ignored with a warning. Otherwise the trust root arrives over the very channel it exists to verify; the circularity is broken by pinning the seed, not by policy.
  • fulcio_url and rekor_url arriving from a [managed] source that is not digest-pinned are ignored with a warning too, for the same reason — and fulcio_url more sharply so: it names where the OIDC identity token is sent, and ocx package push --sbom has no flag to oppose a config value, so an unpinned payload could hand a signing identity to a server of its choosing.

[shell]

Governs two independent concerns: whether OCX's per-prompt shell integration is active at all (hook, completions), and which projects that integration is permitted to touch at all ([shell.consent]). See Shell Integration for the full mechanism — the inert-to-active lifecycle, the per-prompt reconciler, and diagnosing a stuck shell with ocx shell state.

[shell] is never read from ocx.toml. A [shell] block in the project file is a parse error, not a silently-dropped section — a project-writable consent grant would let a clone consent to itself. [shell] is valid in every config.toml tier: system, user, $OCX_HOME, an explicit --config / OCX_CONFIG file, and — for hook / completions — the [managed] tier unconditionally. [shell.consent] arriving through [managed] is held to a narrower rule; see there.

toml
[shell]
hook        = true
completions = true

[shell.consent]
paths      = ["/workspaces/acme-monorepo"]
namespaces = "ocx.sh/acme-corp"

hook

Type: boolean
Default: unset — falls through to the auto rung below

Whether OCX installs its per-prompt shell reconciler: the mechanism that recomposes PATH and the rest of a project's [env] on cd, after a lock change, or after ocx update, without needing a fresh login shell. See Shell Integration for what the reconciler does once installed.

Resolved by a four-rung ladder, most specific first:

  1. --hook / --no-hook on ocx self activate — a one-shot override for this shell start only
  2. OCX_NO_HOOK — a truthy value forces the hook off
  3. [shell] hook
  4. auto — on for an interactive shell, off otherwise; see ocx self activate for how a shim states its own interactivity

Nothing in that ladder keys off CI directly, and that is deliberate. Rung 4 is the shell's own interactivity, and an ordinary CI step is already a non-interactive shell, so the hook is already off there with no dedicated rung needed. A CI-keyed rung would be redundant for that case, and it would additionally turn the hook off inside an interactive debugging shell started inside a CI container — exactly where it is wanted. Set [shell] hook = false, or export OCX_NO_HOOK, to force it off regardless of interactivity.

The only way to set this key without hand-editing config.toml is ocx self setup's own --hook / --no-hook pair, which writes [shell] hook to $OCX_HOME/config.toml and leaves the rest of that file — comments included — untouched:

sh
ocx self setup --hook       # writes [shell] hook = true
ocx self setup --no-hook    # writes [shell] hook = false

Merges scalar-wins-if-set across tiers: the nearest tier that sets it wins, in either direction, including a [managed] tier over your own $OCX_HOME/config.toml.

completions

Type: boolean
Default: unset — falls through to the same auto rung as hook

Whether ocx self activate injects shell-completion definitions at shell start. Resolved by the identical four-rung ladder above, evaluated completely independently of hook: --completion / --no-completion, then OCX_NO_COMPLETIONS, then [shell] completions, then auto. OCX_NO_HOOK never reaches this ladder, and OCX_NO_COMPLETIONS never reaches hook's — each rung 2 reads its own environment key and nothing else.

Set the same way as hook: ocx self setup's --completion / --no-completion pair, or a hand edit to any config.toml tier.

The activation whitelist: which projects the per-prompt reconciler — and ocx self activate's login-shell pass — is permitted to compose an environment for at all, independent of whether hook / completions are on. A project outside every grant below stays inert, and none of the tools its ocx.lock names reach PATH, until a consent stamp is written for it by an ordinary ocx add / ocx lock / ocx pull / ocx exec run against it. See Consent grants for the full three-grant model and What consent does not cover for its honest boundary.

The global toolchain needs no entry here

$OCX_HOME/ocx.toml, its [env], and every package it locks are always consented. It is your own file, on your own machine — requiring a [shell.consent] entry for it would be ceremony with no security value, and no entry here can ever withhold it. [shell.consent] gates projects only; it has no bearing on the global toolchain tier.

This is the one table in the whole configuration tree with strict parsing. Every other section in this reference ignores an unknown key (see Unknown keys and sections) — but a key here that an older ocx does not recognize could only ever narrow a grant, so silently dropping it would widen trust instead. An unrecognized key inside [shell.consent], or inside a namespaces table (below), is refused: the whole [shell.consent] table from that file is dropped with a warning rather than partially applied, while every other section in the same file — [registries], [mirrors], [[trust.policy]], [shell] hook / completions — still applies normally.

[shell.consent] is valid in the same tiers as hook / completions, with one narrower rule: arriving through the [managed] tier, it is honoured only when source is digest-pinned (@sha256:<hex>) — the same rule OCX already applies to a managed Sigstore trust root, because a remote payload naming its own consent grant would let whoever moves the tag consent projects to itself. An unpinned managed tag has the table stripped with a warning before merge; any namespaces.exclude carve-out inside it survives the strip, since a withdrawal can only ever narrow.

Every tier's contribution accumulates rather than overriding:

FieldTypeMerge across tiers
pathsarray of stringsappends, deduplicated
namespacesstring, or a tableincludeinclude, excludeexclude

Both fields only grow — no tier can override the union another tier already established, and exclude beats include wherever both match regardless of which tier contributed either half. That is what makes a carve-out expressible: a lower tier can withdraw one namespace a higher tier granted, without touching the grant itself.

Type: array of strings (paths)
Default: []
Union with: OCX_CONSENT_PATHS

Canonical project directories that activate unconditionally — modelled on git's safe.directory, with one difference: the subtree form here grants the named directory itself as well as everything beneath it, where git's /* covers only the repositories nested under it:

EntryGrants
/workspaces/acmethat directory, and nothing else
/workspaces/acme/*that directory and every project beneath it, at any depth

The subtree form matches component-wise, never as a string prefix, so /workspaces/acme/* covers /workspaces/acme/tools and never the attacker-planted sibling /workspaces/acme-evil. A * that names no directory — a bare *, or /* — grants nothing: git spells that "trust every repository on this machine", and OCX has no such token, for the same reason namespaces has no whole-registry one.

A paths grant reaches further than the entry alone

A subtree entry activates a directory that does not exist yet: clone a repository into a granted /* root next month and it activates the moment you cd into it, with no further gesture — the same reach any prefix-based allowlist has, git's safe.directory included. And unlike a namespaces grant, a paths grant — subtree or exact — opens the project's own [env] table too: that table has no publisher to hold accountable, so a relative type = "path" entry needs none, and one line of a clone's ocx.toml puts <clone>/bin in front of PATH. This is deliberate — it is what makes the CI-image and devcontainer case in Consent grants work.

Write the entry as the canonical, absolute path. A leading ~ is expanded against the current user's home directory, the same interpolation git's safe.directory applies; %(prefix) is still not interpolated. The expansion is textual, not a symlink resolution, and entries are otherwise compared literally after separator and trailing-slash normalization; the entry itself is never canonicalized, so a grant never silently follows a symlink an attacker controls on the parent. That cuts both ways for a ~-prefixed entry too: ~/dev/* where dev is itself a symlink onto another mount still fails to match the project's canonical directory, because only the leading ~ is substituted and the rest of the entry is compared as written — and, in the same breath, an entry naming a symlinked route to a project (/workspaces/acme where acme is a symlink onto a mounted volume) never matches either. There is no ~user/… form; only the invoking user's own home expands. The path to write is the one ocx shell state prints as the project directory.

ASCII case folds on Windows and nowhere else, because that is where the filesystem folds it too. Windows cannot hold C:\w\Acme and C:\w\acme at once, so treating them as one entry merges nothing that was ever distinct — the drive letter (c: and C:) has always folded for exactly that reason, and the components after it now follow the same rule, in the exact form and the subtree form alike. A case-sensitive filesystem can hold both, so on Linux and macOS every component is compared byte-exactly: a case-only mismatch is inert rather than matched, and surfaces in ocx shell state as a near-miss row. The fold is ASCII-only, matching the drive letter's — a full Unicode fold is locale- and version-dependent, and this comparison is a trust boundary.

A trailing /* is the grammar's only wildcard. ?, […] and every other glob metacharacter is an ordinary filename byte, in both channels: /w/acm? grants a directory literally named acm? and never /w/acm3. On Windows both separators are accepted — C:/w/acme and C:\w\acme are one entry — because / and \ are both separators there; on Unix a \ is a legal character in a directory name and is compared as one.

An entry that can never match — no matter what it is compared against — earns its own diagnostic row in ocx shell state naming the defect, instead of sitting silently inert next to an ordinary "no matching grant" verdict:

Entry shapeWhy it can never match
/w/*/toolsa * is a wildcard only as the entry's last component
/w/acme*a * is a whole component, never part of one
*, /*, C:\*a * with no directory before it would grant every directory on this machine, which has no spelling
/w/acme/..a canonical directory never carries a .. component
a leading ~ with no home directory resolvable on this machinethe expansion needs a home directory and none resolved
~user/x~user is never expanded — only the invoking user's own home is
a relative entry (dev/acme)a canonical project directory is always absolute

None of these is rejected at parse — a paths entry is a plain TOML string with no load-time grammar of its own, unlike a namespaces pattern — so ocx shell state is the one place the defect surfaces, one row per entry, naming the specific problem rather than folding it into the generic "no matching grant" reason.

The * classes above read * as the wildcard the grammar defines it to be, which is what every author of such an entry meant. The one case that defeats that reading is a directory literally named *mkdir '*' is legal, if unusual, on Unix — for which /w/acme* or /w/*/tools would be a legitimate exact entry; every other class holds without exception.

toml
[shell.consent]
paths = ["/workspaces/acme-monorepo", "/workspaces/acme-tools/*"]

This grant is deliberately drift-blind: it writes no consent stamp, so revoking it — removing the entry — is immediately effective on the next prompt.

Type: string, or a table with an include list (required) and an exclude list (optional)
Default: unset
Union with: OCX_CONSENT_NAMESPACES

OCI source namespaces (<host>[:<port>]/<org>) that activate a project whose whole lock resolved inside them. A trailing /* is accepted and equivalent here — ocx.sh/acme-corp and ocx.sh/acme-corp/* name the identical set, unlike a paths entry, where the same suffix is the entire difference between granting one directory and its whole subtree.

toml
[shell.consent]
namespaces = "ocx.sh/acme-corp"

# or, with a carve-out:
namespaces = { include = ["ocx.sh/acme-corp"], exclude = ["ocx.sh/acme-corp/experimental"] }

The match is against the package store's own record of the coordinate each locked digest was fetched under on this machine — never against ocx.lock's text, which a clone's author writes. See What consent does not cover for why, and for what a namespace grant does not authenticate.

Every pattern is validated at parse, and the first violation is reported with a reason naming its class:

RejectedBecause
the empty string, or * alonegrants nothing; there is no catch-all spelling
a bare <host> or <host>/*no whole-registry grant exists — it would drop the organization half of the bound this table enforces, on any host anyone can register on; name the organization instead
any uppercase byteno source is ever uppercase, so the pattern could never match
@ anywherea consent pattern names a source, never a digest-pinned reference
* anywhere but a single trailing /*matching is segment-bounded, not a substring glob
an empty path segment, or three or more segmentsa source is exactly two components, <host>/<org>

The table form's include is required — an empty one is a parse error, not a catch-all, so namespaces = { exclude = [...] } alone does not compile. Write the withdrawal alongside the grant it narrows, as in the carve-out example above.

Environment Variable Override Table

This table shows which OCX environment variables map to config file fields. Variables not listed here have no config equivalent.

Environment VariableConfig EquivalentNotes
OCX_DEFAULT_REGISTRY[registry] defaultEnv var wins when both are set
OCX_MIRRORS[mirrors]Env var wins per host, per role when both are set; roles/hosts absent from env var still come from config
OCX_PATCHES[patches] registry / path / requiredForwarded JSON wire format; overrides the config-file tier on process boundaries
OCX_MANAGED_CONFIG[managed] sourceInvocation-only override, never written back; ="" is treated as unset
OCX_LAZY_MODEtoolchain-level lazy-mode in ocx.tomlLowest tier of the five-level ladder — --lazy-mode, [package."<id>"], and [group.<name>] all outrank both the config key and this variable; not forwarded to child processes
OCX_LAZY_REPORTtoolchain-level lazy-report in ocx.tomlLowest tier of the four-level ladder; not forwarded to child processes
OCX_TOOLCHAIN_ACTIVATEtoolchain-level activate in ocx.tomlWeakest tier, not an override — a project that states activate wins over an exported value. An unrecognized value warns on stderr and falls through to the next tier rather than failing; not forwarded to child processes
OCX_TOOLCHAIN_PINNEDtoolchain-level pinned in ocx.tomlWeakest tier, not an override — below both --pinned / --no-pinned and the ocx.toml key. ="" reads as unset, and an explicit false stays distinguishable from absence
OCX_TOOLCHAIN_DIRtoolchain-dirWeakest tier, not an override — the config-file value wins when both are set; ="" reads as unset. Every refusal applies identically to a value exported here, exit 78. Forwarded to child ocx processes so a resolved root survives a re-entry
OCX_RECORDS_DIR[records] dirEnv var wins when both are set; a SYSTEM-scope [records] declaration locks the whole section and this variable has no effect once locked
OCX_RECORDS_NAME[records] nameSame SYSTEM-scope lock as dir
OCX_HOMENoneDetermines where config is loaded from; cannot be in a config file
OCX_CONFIGNoneMeta-variable pointing at the config file itself
OCX_NO_CONFIGNoneKill switch; also suppresses the [managed] snapshot candidate and the OCX_MANAGED_CONFIG env-override read. A SYSTEM-scope [records] lock survives it — the system file still loads, filtered to its locked sections
OCX_NO_CONFIG_REFRESHNoneKill switch for the [managed] background refresh tick only; explicit ocx config update, and the setup-time re-sync ocx self setup / ocx config setup run against an already-adopted seed, still work
OCX_OFFLINENonePer-invocation mode, not a persistent setting
OCX_REMOTENonePer-invocation debugging mode, not a persistent setting
OCX_BINARY_PINNoneSubprocess-only: set automatically by ocx on every spawn so child ocx invocations pin to the same binary
OCX_INSECURE_REGISTRIES[registries.<name>] insecureUnion, not an override: a host named in either source is plaintext-eligible, and neither can take one back out
OCX_NO_UPDATE_CHECKNoneCI-only concern; env var is sufficient
OCX_NO_MODIFY_PATHNoneInstall-time concern; env var is sufficient

OCX_OFFLINE and OCX_REMOTE are intentionally absent from the config file. Both are per-invocation modes — a persistent offline = true would silently break ocx package install on a fresh setup.

Error Reference

Literal sizes in the examples below reflect the current 64 KiB safety cap (MAX_CONFIG_SIZE in the loader source). Angle-bracket placeholders such as <SIZE> stand in for runtime values that depend on the offending file.

ErrorCauseResolution
error: config file not found: /path/to/file.toml (check --config or OCX_CONFIG)--config or OCX_CONFIG points to a non-existent fileCheck the path; unlike the three discovery tiers, explicit paths must exist. To disable an ambient OCX_CONFIG without unsetting it, set it to the empty string.
error: config file /path/to/file.toml exceeds maximum allowed size (<SIZE> bytes > 65536 bytes); OCX config files are typically under 1 KiB — did you point at the wrong fileA config file is larger than the 64 KiB safety capThe hint usually explains it — a --config flag or OCX_CONFIG env var pointed at a non-config file (e.g. an archive or binary).
error: invalid TOML at /path/to/file.toml: ...TOML syntax error in the config fileFix the TOML syntax error at the indicated location
error: failed to read config file /path/to/file.toml: ...The file exists but cannot be read — permission denied, the path is a directory, or another I/O failureCheck file permissions; --config and OCX_CONFIG must point to a regular, readable file.
error: cannot read system config file /etc/ocx/config.toml; it carries operator policy and is never skippedThe system tier exists but cannot be consulted — it is a symlink, or stat fails for any reason other than absencePoint /etc/ocx/config.toml at a regular file (copy the fleet file in rather than linking to it), or remove it. Unlike the user tiers, this one is never skipped — see File Locations.

Project Configuration — ocx.toml

The tiers above configure ocx itself. ocx.toml is a different file with a different lifecycle — see the Project Toolchain guide for discovery and locking. This section is the schema reference for the ocx.toml tables and keys that carry environment and resolve-time declarations: [group.<name>], [env], [package."<id>"], and the toolchain-level lazy-mode / lazy-report / activate / pinned keys.

One key that looks like it belongs here does not: toolchain-dir is a config.toml key, above.

[group.<name>]tools and env

Each named group is a table with exactly two optional sub-tables: tools (the same binding-name-to-identifier map the top-level [tools] table holds) and env (see the value grammar below). A group with neither sub-table is a valid, empty group.

toml
[tools]                       # default group's tools
foo = "ocx.sh/foo:1"

[env]                         # default group's env
CI = "1"

[group.ci.tools]              # named group's tools
bar = "ocx.sh/bar:1"

[group.ci.env]                # named group's env
SOURCE_DATE_EPOCH = "0"

A tool binding declared directly under [group.<name>] — not inside its tools sub-table — is a parse error naming the group and pointing at the fix, ExitCode::ConfigError (78):

error: group `ci` declares tool bindings directly
  --> ocx.toml
   |
   |  [group.ci]
   |  bar = "ocx.sh/bar:1"
   |
   = tool bindings belong under `[group.ci.tools]`
   = `[group.ci]` holds only the `tools` and `env` sub-tables

An unrecognized sub-table (a typo such as [group.ci.tolos]) is rejected the same way, naming the offending key. [group.default], [group.all] and [group.bin] are reserved names, rejected at parse regardless of their contents — see Names and reserved words below, and ocx exec for the full group-keyword semantics.

A group also accepts an optional lazy-mode scalar, overriding the lazy-mode resolution ladder for every tool declared under that group:

toml
[group.ci]
lazy-mode = "always"

[group.ci.tools]
shellcheck = "ocx.sh/shellcheck:0.11"

There is no group-tier lazy-report — see [package."<id>"] below for why.

The [tools], [group.<name>], [env] and [package."<id>"] declarations parse the same way in the --global tier file at $OCX_HOME/ocx.toml.

Names and reserved words

Group names and binding names both become path components of the rendered toolchain tree<home>/links/<group>/<entry>/ — and in link-following mode ocx env emits those paths as environment values. So the grammar is a validation rule, not a style preference.

Every name you declare sits one level below the tree's own directory names, under links/, so no name you pick can collide with the tree's structure: a group named links renders at links/links/<entry>, and a tool named bin renders at links/default/bin.

Every [group.<name>] name, every [tools] key, and every [group.<name>.tools] key must match:

^[A-Za-z0-9][A-Za-z0-9._-]*$

— at most 64 bytes. It is deliberately wider than an OCX slug by uppercase and ., because these names are yours to pick and read like the tools they bind (MSBuild, python3.13). A name that breaks either half is refused at parse, exit 78, and the message says which half:

error: [tools] name 'my tool' must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ and be at most 64 bytes (the character set is wrong)

Two names are reserved, both as group names only:

ReservedWhy
defaultnames the implicit top-level [tools] table
allthe CLI keyword that expands to every declared group

Both stay legal as binding names — [tools] default = "ocx.sh/x:1" and [group.ci.tools] all = "ocx.sh/y:1" both parse.

The reserved-word comparison folds ASCII case. [group.Default], [group.DEFAULT] and [group.default] are one reservation, and all three are refused — a [group.Default] quietly coexisting beside the implicit default group is exactly the collision the reservation exists to stop.

[package."<id>"]

Per-package resolve-time settings, keyed by the canonical registry/repository[:tag] string:

toml
[package."ocx.sh/kitware/cmake:3.28"]
no-patches = true
lazy-mode  = "always"
lazy-report = "progress"
FieldTypeDefaultDescription
no-patchesbooleanfalseDecline the site-tier patch companion overlay for this base — see Per-package opt-out above.
lazy-mode"never" | "always"(inherit)Package-tier override of the lazy-mode resolution ladder — the most specific config tier, only outranked by --lazy-mode.
lazy-report"silent" | "progress"(inherit)Package-tier override of the lazy-report ladder.

The match for every field in this table is by canonical registry/repository — tag and digest are stripped, so a [package."<id>"] entry follows every tag of that package, not just the one written in the key.

lazy-mode and lazy-report are both excluded from declaration_hash — like no-patches, they change when or how loudly a tool materializes, never which digest resolves, so editing either does not invalidate ocx.lock.

lazy-report is settable here even though there is no [group.<name>] tier for it. lazy-mode is resolved while composing, when the selected group is known; lazy-report is resolved later, inside the separate ocx launcher shim process a generated shim execs into on first invocation — a process that receives only a pinned identifier and a basename, with no way to learn which group composed the tool. See Deferred Tools for the full ladder and lifecycle.

Toolchain-level lazy-mode and lazy-report

Two more bare scalar keys sit at the top level of ocx.toml, alongside [tools] — the least specific config tier of each ladder, only outranked by [group.<name>], [package."<id>"], and the CLI flag:

toml
lazy-mode   = "always"
lazy-report = "silent"

[tools]
cmake = "ocx.sh/kitware/cmake:3.28"

Both accept the same value sets as their [package."<id>"] counterparts and are excluded from declaration_hash for the same reason. Below both of these, OCX_LAZY_MODE and OCX_LAZY_REPORT are the last tier before each ladder's floor (never / silent). See Deferred Tools for the full five-tier lazy-mode ladder and the four-tier lazy-report ladder.

Toolchain-level activate

A shell that recomposes a whole toolchain environment on every prompt is doing work you may not want it to do — a large toolchain, a slow filesystem, or simply a preference for a PATH that does not change under you. activate decides how a rendered toolchain reaches a shell at all. Both toolchain tiers read it: a project's own ocx.toml decides for that project, $OCX_HOME/ocx.toml for the global toolchain.

toml
activate = "bin"
ValueWhat reaches the shell
"env" (default)The toolchain environment is composed on every prompt: each tool's own PATH entries and declared variables land in the shell.
"bin"Only <home>/toolchain/active/bin goes on PATH. Nothing else is composed — a tool is resolved by its launcher trampoline when it runs, and the trampoline composes the environment at that moment.
"none"Neither. The reconciler withdraws whatever it owns and adds nothing.

An unrecognized value in ocx.toml is a parse error, exit 78 — the same treatment lazy-mode gets — for every command that loads the file. OCX_TOOLCHAIN_ACTIVATE is deliberately not symmetric: an unrecognized value there warns Environment variable 'OCX_TOOLCHAIN_ACTIVATE' ignored: invalid activate mode 'shim' (expected 'env', 'bin' or 'none') on stderr and falls through to the next tier, exit 0. A file you own may fail loudly; an inherited variable may not break every prompt in every project.

The per-prompt hook is the one exception, and it fails open. A malformed ocx.toml must not break every prompt on the machine, so the hook reads the file leniently: the whole file's keys go absent and activate falls through to OCX_TOOLCHAIN_ACTIVATE and then to env — the most-composing mode, not the safest-looking one. The hook also discards ocx's stderr, so nothing warns you that a typo like activate = "nnone" left your restriction unapplied. ocx shell state reports it: it reads the same file, resolves the same ladder, and prints a note: naming the manifest that would not parse. Run it whenever a mode you set does not seem to be in effect.

Resolution order

ocx.tomlOCX_TOOLCHAIN_ACTIVATEenv.

The environment tier is the weakest, not an override. An exported OCX_TOOLCHAIN_ACTIVATE=none loses to a file that states activate = "env"; the variable decides only where no file states the key. This is the opposite of the usual "environment beats config" reflex, and it is what makes the key mean something a collaborator can rely on.

The ladder is resolved per tier, over that tier's own file: a global activate = "bin" never reaches a project, and a project's never reaches the global toolchain.

There is no --activate flag, by design — the choice belongs in the file that a whole team reads. ocx self setup --toolchain-activate MODE writes the key into $OCX_HOME/ocx.toml rather than overriding it for one invocation, creating that file carrying only this key if it does not exist yet:

sh
ocx self setup --toolchain-activate bin

That targets the ocx home's own ocx.toml, never the project in effect — --project and OCX_PROJECT name a different toolchain and this flag does not redirect onto it. A project's own ocx.toml still decides for that project.

bin and none are the same PATH for the global toolchain

$OCX_HOME/toolchain/active/bin is a session-level directory: ocx self setup registers it on PATH once, a shell start prepends it again, and a prompt never withdraws it. So a global activate = "bin" and a global activate = "none" both leave the global toolchain reachable through its trampolines and compose nothing else — the same PATH, by the same route. The two values part company only for a project's toolchain, whose active/bin directory a prompt does add and remove.

Both halves of a shell honour the mode

Two moments put a global environment into a shell, and both read this key: the login stream ocx self activate emits at shell start, and the per-prompt reconciler runs at every prompt after that. In env mode the login stream carries an ocx --global env eval; under bin and none it does not, and the trampoline directory it always prepends is what resolves the tools instead. That matters most where no prompt ever runs — a script, an ssh host cmd, a git hook, a sh that registers no hook at all.

activate governs how a toolchain reaches a shell on its own, never what a command you typed prints. ocx --global env and ocx --global exec are explicit requests and compose the global tier in full, whatever the key says.

Toolchain-level pinned

A rendered toolchain carries links/<group>/<entry> links pointing at package roots, and a composed environment can name either those links or the digest paths directly. pinned chooses.

toml
pinned = true
ValueWhat the composed environment names
false (default)The rendered links/<group>/<entry> links are followed, so a later ocx update moves a root's paths with no re-render.
trueDigest paths — exactly what ocx.lock pins right now, consulting no link, and reading no tree.

true is the setting for an environment that must not shift underneath a long-running process or a captured export: the paths name content, and content-addressed paths never change meaning.

The key names a lane, and two things qualify what lands in it. A link that is absent, stale, or not a link degrades that one entry to its digest path, silently, while its siblings still compose through their links — and the digest path is the correct path, the same package directory under its other spelling. Only roots have links: a dependency's PATH contributions and every ${deps.<name>.installPath} are digest paths under false as well as true. Both are covered in full under --pinned.

pinned reaches five emitters, not the two that carry the flag: ocx env, ocx exec, ocx direnv export, the env-mode shell hook, and the env-mode login exporter. For the three that declare no flag, this key and OCX_TOOLCHAIN_PINNED are the only tiers that answer. The last two compose only when the global tier's activate is env; under bin or none neither runs, so no lane is chosen for them at all.

Resolution order

--pinned / --no-pinnedocx.tomlOCX_TOOLCHAIN_PINNED ▸ follow the links.

The environment tier is the weakest, not an override — same rule as activate, and for the same reason. --no-pinned exists precisely so a project that declares pinned = true can still be composed through the links for one invocation; see --pinned for the flag pair's own semantics.

toml
# <project>/ocx.toml
pinned = true

[tools]
cmake      = "ocx.sh/kitware/cmake:3.28"
shellcheck = "ocx.sh/shellcheck:0.11"

Both keys are resolve-time policy, not tool declarations, so both are excluded from declaration_hash — editing either does not invalidate ocx.lock. See Toolchain activation for the activate × pinned matrix and what each cell puts on PATH.

[env] value grammar

Each entry in [env] or [group.<name>.env] is either a bare string — a constant that replaces any earlier value for the same key — or a table with an explicit type:

toml
[env]
CI = "1"                                            # string → constant, same as below
JAVA_OPTS = { type = "constant", value = "-Xmx2g" }
PATH = { type = "path", value = "node_modules/.bin" }
GODEBUG = { type = "list", separator = ",", value = "gctrace=1" }
typeBehavior
constant (implicit for the bare-string form)Replaces any earlier value for the key.
pathPrepends to the key (typically PATH). A relative value resolves against the project root — the directory holding ocx.toml — never the process's current working directory; an absolute value passes through unchanged.
listAppends to the key, joined by separator, removing any earlier occurrence of the same contribution first.

list accepts one more field, valid only alongside it:

FieldRequiredDescription
separatorNoThe string this contribution joins to the key's existing value. Must be non-empty and must not contain =, a newline, or a carriage return when given — a footgun-guard error names the field, not a byte offset (exit 78). Omit it to inherit whatever separator another contributor to the same key already declared — a package's own list entry, another group's, or --env — falling back to a single space only when nothing established one. See Env Composition for the full per-key agreement rule. A separator alongside constant or path is rejected (exit 78).

There is no interpolation in v1 — every value is literal. The path type is what makes a project-local directory like node_modules/.bin expressible without one: no ${projectRoot} token is needed, because relative resolution already targets the project root.

The --env flag takes the same three types, written KEY[:TYPE[:SEP]]=VALUE, with one deliberate difference: a relative path value there resolves against the current directory, not the project root. A checked-in file must mean the same thing from any subdirectory; a flag is composed by whatever script invokes ocx, and the current directory is the one base that script can compute.

Two key classes are rejected everywhere [env] can appear — the project table, every [group.<name>.env], and the --env flag on ocx exec:

  • A key that is not a POSIX environment-variable name ([A-Za-z_][A-Za-z0-9_]*).
  • A key starting OCX_ or __OCX_. Without this rejection, a checked-in ocx.toml could set OCX_DEFAULT_REGISTRY, OCX_INDEX, OCX_OFFLINE, or any other resolution-affecting variable and reconfigure how ocx itself resolves for every contributor who clones the repository. Rejection happens at parse for [env] / [group.<name>.env] (ExitCode::ConfigError, 78) and at flag-parse for --env (ExitCode::UsageError, 64) — see --env and OCX_ENV for the flag form and the forwarded wire key.

[env] entries carry no visibility axis. Unlike a package's own declared env, a project is never a dependency of anything, so there is no interface/private surface to gate — which is also why the project-tier commands carry no --self flag at all. See Project Environment in the Environment Composition reference for where these entries land in the full resolution order.

JSON Schemas

OCX publishes JSON Schemas for every config, project, and patch file at stable URLs, plus one for the --format json output of every command. IDEs and language servers (taplo, yaml-language-server, VS Code, Zed) consume them for autocompletion, hover docs, and validation.

FileSchema URL
config.toml (any tier)https://ocx.sh/schemas/config/v1.json
ocx.toml (project)https://ocx.sh/schemas/project/v1.json
ocx.lock (project lock — machine-generated)https://ocx.sh/schemas/project-lock/v3.json
metadata.json (package)https://ocx.sh/schemas/metadata/v1.json
Patch descriptor (ocx patch publish --descriptor)https://ocx.sh/schemas/patch/v1.json
--format json output (every command)https://ocx.sh/schemas/reports/v1.json
Execution record ([records] sink)https://ocx.sh/schemas/execution-record/v1.json

ocx init writes a #:schema https://ocx.sh/schemas/project/v1.json directive on the first line of every generated ocx.toml, so taplo-aware editors pick the schema up automatically with no extra wiring. To opt other files in by hand, prepend the same directive at the top of the file. A patch descriptor is plain JSON, so add a "$schema": "https://ocx.sh/schemas/patch/v1.json" key to get the same autocompletion and validation while authoring it. The project-lock schema carries a top-level $comment flagging it as machine-generated — never hand-edit ocx.lock; rerun ocx lock instead.

The reports schema is for consumers rather than authors: it is generated from the Rust types the CLI serializes, and its reports object maps each command's JSON root to a definition under $defs. Its required sets say exactly which keys a command always writes, which is the distinction a hand-written sample cannot carry — a field declared Option<T> is always present and null when unset, while one carrying skip_serializing_if is omitted entirely and never null. An SDK or script that pins its parsers against this file cannot drift into reading a key OCX does not publish, or demanding one OCX may omit.

Future Config Keys

Not yet implemented in v1

These sections are documented here so the format design is stable before they land. They do not exist in the current release.

Per-registry fields beyond index, trusted_hosts, and insecure

The [registries.<name>] table is live in v1 with index, trusted_hosts, and insecure. Future per-registry fields will slot in without breaking existing configs:

toml
# Future shape (not in v1):
[registries."registry.company.example"]
index = "https://index.company.example"
location = "mirror.company.example"  # URL rewrite / mirror

[clean] section

Retention policy configuration will live under [clean]. Deferred to the retention policy feature.

Project-level ocx.toml

A project-level ocx.toml is now shipped — see the Project Toolchain section in the user guide for the schema, locking model, and activation hooks. The file name is deliberately different from config.toml so the data-directory tier and project tier are never confused: ocx.toml is loaded by a distinct API and never participates in the ambient config chain described above.