Skip to content
ocx
install

Shell Integration

Every time you sit down at a shell, OCX has to answer two questions before it can put anything on PATH. It asks should this project’s binaries be here at all, and are they still the right ones. The first question is about trust — a project’s ocx.toml can name any OCI registry, and nothing stops a clone from naming an attacker’s. The second is about staleness — an ocx --global add or an ocx update run five minutes ago should not need a fresh terminal to take effect. This page covers the mechanism that answers both: the per-prompt shell hook and the consent model that gates it. When a shell does not pick up its tools, Troubleshoot a shell covers what to check.

A freshly cloned repository with an ocx.toml you have never seen has to stay inert the moment you cd into it. Nothing runs at clone time, because OCX never executes package code during install. The risk is still real: the first binary invocation in that directory would put whatever ocx.toml names in front of cmake, cargo, or git on PATH. The history of mise is the cautionary tale. GHSA-436v-8fw5-4mj8 shipped for four months. A project’s own trust-control settings were read before the trust check that was supposed to gate them. A malicious repository could therefore self-declare itself trusted.

OCX closes that ordering gap structurally. The only project-supplied bytes read before consent is established are a directory walk and the lock file’s own source list. The check itself needs that much to decide whether to proceed. ocx.toml is never parsed until consent says yes. A fresh clone with no consent stamp, no matching grant, and no lock at all changes nothing and prints one hint line. See Consent grants for exactly what makes a project stop being inert.

Once a project is consented, OCX keeps PATH converged with what is declared, at every prompt, without an eval step you run by hand. The global toolchain needs no consent at all, since $OCX_HOME/ocx.toml is your own file. cd into a consented project and its locked binaries land on PATH at the next prompt. cd back out and they leave again. Add a package to the global toolchain from another terminal. The shell you are already sitting in picks it up on its next prompt too.

The recordings below run in a shell that carries no hook yet, so each one types the eval "$(ocx self activate --shell=bash)" line by hand. ocx self setup instead writes a profile block that sources the $OCX_HOME/env.sh shim. The shim runs ocx self activate once, at shell start. The commands before it are only the scaffolding that scenario needs. Everything after it — the ocx: +PATH and ocx: -PATH summaries — is the hook reconciling on its own at the next prompt, with no eval of yours involved.

ocx self setup writes one thin shim per shell family into $OCX_HOME. Each shim calls ocx self activate at shell start, which delegates the runtime logic to the binary: the PATH prepend, completion injection and the global toolchain environment. The shims are byte-identical across users; no install-time substitution occurs.

FileShellWired by
$OCX_HOME/env.shbash, zsh, dash, ash, ksha block-marker in ~/.profile, ~/.bashrc, ~/.zprofile and ~/.zshrc, plus ~/.bash_profile when it exists; ZDOTDIR moves the zsh files
$OCX_HOME/env.fishfish$XDG_CONFIG_HOME/fish/conf.d/ocx.fish, else ~/.config/fish/conf.d/ocx.fish
$OCX_HOME/env.nunushell$XDG_DATA_HOME/nushell/vendor/autoload/ocx.nu, else ~/.local/share/nushell/vendor/autoload/ocx.nu; auto-sourced
$OCX_HOME/env.elvelvisha block-marker in $XDG_CONFIG_HOME/elvish/rc.elv, else ~/.config/elvish/rc.elv
$OCX_HOME/env.ps1PowerShella block-marker in $PROFILE

Each shim resolves OCX_HOME at runtime with the shell’s own assign-if-unset syntax (: "${OCX_HOME:=$HOME/.ocx}" in POSIX shells). Activation therefore works in a fresh shell where OCX_HOME was never exported. The managed block in your profile uses the same ${OCX_HOME:-$HOME/.ocx} fallback, because it sources env.sh, which is what exports OCX_HOME. It existence-guards the source so a login shell never errors on a missing file. OCX_HOME also decides where ocx self setup writes the shims. Set it to a non-default path before running setup and the shims and the object store share that root.

The reconciler needs to remember, from one prompt to the next, exactly what it applied. Otherwise it cannot tell “a value OCX wrote” from “a value you typed,” and reverting one would risk clobbering the other. That memory lives in one private environment variable, __OCX_ENV_STATE. It is a compact, base64-encoded ledger recording what is currently applied per scope (global and project, tracked separately). It also records what each constant looked like before OCX touched it. It travels with the shell like any other variable, so a subshell or a nested bash -c inherits it along with the environment it describes.

It is a deliberate exception to the private __OCX_* convention. Every other __OCX_* variable is process-internal, but this one has to cross into your interactive shell. A child process cannot mutate its parent’s environment any other way. With a documented repair gesture it is a user-facing contract, whatever its name looks like. The whole __OCX_* namespace is still reserved: a package cannot declare one, --env cannot set one, and ocx exec strips them from anything it would otherwise forward unchanged. The earlier single-underscore _OCX_APPLIED is gone: current OCX neither reads nor writes it, so remove any reference to it from a shell profile.

The ledger is capped at 16 KiB. If it ever grows past that, an unlikely monorepo-scale case, OCX drops to a marker-only record. It never loses track of the fingerprint entirely. The affected scope’s binaries then stop being revertible for the rest of that session (one line printed once, not on every prompt). A ledger that is missing, truncated, or unreadable is treated the same way a first prompt is. OCX rebuilds what it knows from the project files on disk and removes anything under $OCX_HOME that it no longer recognizes as declared.

Per-shell coverage. A real per-prompt hook fires on every prompt without you doing anything. It needs an append-safe extension point in the shell’s own prompt machinery. Not every shell has one:

ShellPer-prompt hookWhat it reconciles
bash, zshPROMPT_COMMAND / precmd_functions appendglobal + project
fish--on-event fish_promptglobal + project
PowerShellwraps prompt, restoring the previous definitionglobal + project
nushellenv_change.PWD (fires on directory change, not every prompt)global toolchain only — no project reconcile, no revert, no consent gate, today
elvish$edit:before-readline appendglobal + project — guard is carrier-and-$pwd only, no watch-set mtime term
ash, dash, ksh, Batchnone — no append-safe prompt-hook point existsshell start only, both scopes

The four shells in the last row still activate correctly the moment the shell starts, because the initial compose is unaffected. Nothing re-checks after that. If you add a global package or cd into a consented project mid-session in such a shell, open a new one to see it. Nushell sits in between. Its env_change.PWD hook keeps the global toolchain live on every directory change. It does not reconcile a project scope at all: no activation, no revert, no consent check. A nushell project still goes through ocx direnv export or ocx exec. Elvish reconciles both scopes at every prompt, same as bash, zsh, fish, and PowerShell, but with a narrower guard. See Elvish’s guard.

The ledger has exactly one project slot, because at any prompt exactly one project is in effect. The walk from your working directory upward stops at the nearest ocx.toml (or at the first .git/ boundary it crosses). Whatever a farther ancestor declares is never read.

That matters the moment a repository holds a second ocx.toml below its first. Examples are a test/ tree that pins its own uv and pytest, a website/ that pins its own bun, and a vendored sub-repository with a toolchain of its own. Standing at the root, the root’s binaries are on PATH. cd test/ and they are gone. The prompt reconciles this as a switch: the root’s contributions are retired and test/’s applied in one pass. The summary line names the project you landed in, ocx: ~PATH (test). cd .. and the root’s binaries come back the same way. Nothing is pushed onto a stack, so nothing is ever popped out of order. Consent follows the same line. The inner project is its own project, so the outer one’s stamp does not cover it. Only a subtree path grant over the enclosing directory reaches both.

If a nested project needs a package the enclosing one declares, declare it there too. ocx add inside test/ writes it into test/ocx.toml. That file plus its ocx.lock is the whole answer for everything beneath it. ocx exec follows the same walk, so it composes the nearest file’s lock alone. To run against the enclosing project from inside the nested one, name it, as in ocx --project .. exec -- cmake --version. The walk never reaches past the nearer file.

The per-prompt hook can only run where there is a prompt. An IDE, a desktop launcher and a background service each start without one, so nothing in the mechanism above ever reaches them. That is why a PATH that is right in the terminal can be wrong everywhere else on the same machine.

ocx self setup answers that separately. It writes two directories — the ocx installation’s bin directory and $OCX_HOME/toolchain/active/bin — into the one store per platform that a whole login session reads, once, at install time. That registration is not the hook and does not depend on it. Every process started afterwards inherits those directories whether or not it ever runs a shell. The stores, the per-platform mechanisms and the six limits the registration does not promise are in the user guide and the command-line reference.

The two mechanisms then share one PATH. Three things decide how they fit.

Both session directories are in the reconciler’s desired set on every prompt, in every activate mode — none included. That is not a courtesy to the installer; it is what stops the two mechanisms from fighting.

$OCX_HOME is an owned prefix, and the repair pass removes every segment under an owned prefix that the desired set does not contribute. Both directories sit under $OCX_HOME. So an arm that composed nothing and returned early without them would not leave them alone. It would delete the registration ocx self setup had just written, at the first prompt of the first shell. They are session-level facts rather than activation decisions. The reconciler treats them as such even when it has been told to activate nothing at all.

Front to back, a converged PATH reads:

  1. the project scope’s composed entries (env mode only)
  2. the project’s <home>/toolchain/active/bin (bin mode only)
  3. $OCX_HOME/toolchain/active/bin
  4. the ocx installation’s bin directory
  5. the global toolchain’s composed entries

The session block sits between the two tiers, and that position is the contract rather than an arrangement. Putting it after both would place the global tier’s composed entries ahead of a project’s own trampoline directory. A globally installed cmake would then shadow the project’s. That is the tier inversion strict isolation exists to forbid.

Within the session block the install directory comes last. ocx reads like every other name: the most specific toolchain that pinned it answers for it. The installed binary is the floor beneath both. A toolchain may pin ocx itself, because nothing refuses the name. An order that kept the installed binary in front would leave such a pin rendered and permanently unreachable. The bare-word ocx a trampoline falls back to when no install path exists is not what the ordering defends. A rendered trampoline bakes an absolute ocx path. The one remaining bare-name case is settled where the lookup happens, by excluding trampoline directories from it.

In bin mode the reconciler emits a project’s <home>/toolchain/active/bin only when a render stamp exists for that home. The directory must also still hold exactly what the stamp recorded. The check is one directory read plus one stat per entry, and a content hash only for the entries the cheap comparison already found suspect. No compose, no metadata read, no network — that budget is most of why bin mode exists.

It is set equality in both directions: every name the stamp records must be present, and every name present must be recorded. A one-way lookup over the stamp’s own keys would pass the moment each recorded entry matched. That is precisely the case the gate is built for. A hostile clone can force-commit one extra trampoline beside an otherwise legitimate tree. Every filesystem condition on that walk is a mismatch rather than an error. Examples are an unreadable trampoline directory, an entry that vanished mid-walk. Others are an entry replaced by a symlink after a good render and a name that is not UTF-8. The entry is withheld and PATH does not change.

The active link the path runs through is held to the same standard, and to the same effect. It is a mismatch, not a new failure mode, when the link is absent, not a link, or points anywhere other than shells/default. There is one symptom and one remedy for every state on this list.

A mismatch and a missing stamp have one behaviour, so they get one sentence. The prompt emits nothing for that project and prints the line that names the fix:

ocx: /work/acme/api: its toolchain has not been rendered for this lock; run `ocx pull` here

The stale window between a lock change and the next ocx pull is designed, not something that line apologises for. The prompt path never prunes, and none may be added. Pruning here would be a whole-directory delete inside a repository-writable tree, running before every command you type, with no --dry-run in front of it. The stale trampolines stay exactly where they are, and the render that replaces them is a command you ran on purpose.

A project activating on cd would pull whatever ocx.toml names onto PATH with no confirmation. That is the same shape of risk mise’s GHSA-436v-8fw5-4mj8 exploited, just aimed the other direction. Instead of a project declaring itself trusted, a project here would simply be trusted by default. OCX refuses that default. Activation requires one of three independent grants; without any of them, a project is inert regardless of what it declares.

Only projects are gated. The global toolchain is always consented — $OCX_HOME/ocx.toml, its [env], and every package it locks. It is your own file, on your own machine, so it needs no grant, and no [shell.consent] entry can withhold it. It composes on every prompt even while the project you are standing in is inert. It also composes when that project has a config of its own. That is why a fresh clone going inert does not take your global packages with it.

  • A consent stamp — written by ocx shell allow, and by ocx init for the project it creates (pass --no-consent to skip it). It is also written automatically the first time you run ocx add, ocx remove, ocx lock, ocx update, ocx upgrade, ocx pull, or ocx exec against a project. Running a mutating ocx command in a directory is itself consent. It records which OCI sources that project’s lock resolved against at the time. Re-running one of those commands after the lock picks up a source outside the stamped set re-confirms. Ordinary growth inside already-consented sources does not. The stamp lives under $OCX_HOME/state/ — remove it with ocx shell revoke, or delete the file; either simply makes the project inert again until you consent afresh. A machine driving those commands against a checkout nobody chose declines the stamp with OCX_NO_CONSENT=1, or per-command with --no-consent on ocx init, ocx pull and ocx exec. The flag outranks the variable, so a single step opts back in with --consent. ocx shell allow ignores both — it is the gesture the variable exists to tell automation apart from. Because a stamp is a grant that no config.toml records, ocx shell state names which of the three clauses activated a project, and when a stamp was written. ocx clean does this for you once a project’s directory is confirmed gone, in the same pass that garbage-collects its packages. See Remove and clean up for the full behavior.
  • A path grant — a canonicalized directory an operator has pre-authorized, without knowing in advance what that checkout will resolve against. This is the devcontainer feature and CI-image case: the image build knows the checkout path but not its eventual lock contents. An entry naming one directory grants that directory alone. A trailing /* grants that directory and everything beneath it, matched component-wise so /workspaces/acme/* never reaches a /workspaces/acme-evil sibling. A subtree entry also activates a repository cloned into that tree later, with no further gesture. Unlike a namespace grant, a path grant, subtree or exact, opens the project’s own [env] table too, since that table has no publisher to hold accountable. Entries are compared as written — write the canonical path, not a symlinked route to it. A leading ~ is the one exception: it expands to the current user’s home directory, the same interpolation git’s own safe.directory applies. The expansion stops at the ~ itself, so it does not turn the rest of the entry into a canonical path. ~/dev/* where dev is a symlink onto another mount still misses the project it appears to cover.
  • A namespace grant — a set of OCI sources (<registry>/<org>) an operator trusts, without knowing every path a matching project might be checked out to. This is the fleet case: pre-approve ocx.sh/acme-corp once, and a project whose packages all came from inside that namespace activates. What is matched is not the lock’s text. The package store records, for every package it fetched, the coordinate it resolved and got digest-verified content for. The grant is decided against that record: a lock naming ocx.sh/acme-corp/anything buys nothing unless this machine genuinely fetched those digests under ocx.sh/acme-corp. The practical consequence is that a namespace grant activates a project whose packages this machine has already fetched. That is a warm shared store, which is the fleet case. The project stays inert on a cold one until the first ocx pull. That pull writes a consent stamp anyway, unless it declined to, under OCX_NO_CONSENT or --no-consent. ocx shell state names the gap when a lock’s claim and the store’s record disagree. A namespace grant also stops at the package boundary. It authorizes the packages ocx.lock resolved, never the project’s own [env] table in ocx.toml, because that table has no publisher to hold accountable. A bare type = "path" entry works from clone content alone, with no registry involved. A namespace-granted project that declares [env] still gets its packages. OCX withholds the table and prints a hint naming the fix. Run ocx pull there once, or list this exact directory in [shell.consent] paths. The directory entry must be exact, not a subtree grant. The pull also writes a consent stamp, unless OCX_NO_CONSENT or --no-consent declines it. In that case the pull leaves the project exactly as inert as it found it. See What consent does not cover for what it still does not buy.

Path and namespace grants are independent and additive. Either alone is sufficient, and neither constrains the other. An absent or empty grant means nothing is authorized, never “everything is.” Neither grant ever writes a consent stamp. Drift for a namespace grant is re-checked on every prompt against the current lock. A path grant is deliberately drift-blind, since an operator naming a checkout in advance cannot enumerate its future sources. The configuration tiers a grant can live in, and the rules for parsing and merging its entries, are in Consent grammar.

Declining the stamp from automation. Running a mutating command in a directory is normally consent, which is right for a person at a keyboard. A Bazel repository rule, a CI job or a generated launcher makes the same gesture on someone’s behalf. The stamp it leaves would authorize that project’s [env] table on every later cd once the hook is installed. OCX_NO_CONSENT is how automation declines that question. The eight commands that record a stamp (add, remove, lock, update, upgrade, pull, exec and init) then run without writing one. Nothing else changes: the pull still pulls and the lock is still written. The variable is forwarded to child ocx processes, ocx exec --clean included, so a script that itself calls ocx pull inherits the same answer. ocx exec --no-consent sets it on the child’s environment too, because a command line does not cross a spawn. The reverse is deliberately not true. ocx exec --consent answers for the one project it targets and does not clear a variable the child would otherwise inherit. So refusal inherits downward and permission does not.

Do not export it in your interactive profile. A stamp records the sources its lock resolved against, and growing the lock past that set re-confirms by re-recording. With the variable exported, that never happens. ocx add pulls in a package from a source the stamp does not cover, and nothing re-stamps. The project you were working in is inert at the next prompt. Set it in the pipeline, image or build rule that needs it. ocx shell state names the state and ocx shell allow clears it.

Consent answers one question: may this project’s toolchain reach my PATH at all. It does not answer a second, related one: did the identity I expect actually publish these bytes. Within an already-consented namespace, whoever can publish gets code in front of your PATH with no signal from the consent mechanism. That is accepted by design, because content-hash re-confirmation on every ordinary git pull or ocx update would train users to click through prompts without reading them. That is worse.

A namespace grant sits inside that same residual, and it is deliberately not decided by the lock. ocx.lock is project-supplied text, and a clone can name any source it likes. Matching a namespace pattern against it would let a clone borrow a trusted organization’s name for content that never came from there. The grant is decided instead against the package store’s own record of the coordinate each locked digest was materialized under on this machine. A lock is text a clone’s author writes; the record takes an act of pulling here under that name. On a machine that has not seen the content those coincide. The bytes come off the wire under that name, and publishing into a listed organization needs that organization’s publish credential. Where the layer cache already holds the digest they do not: one ocx pull naming the granted organization writes the record with no registry in the loop. The record is evidence about what this machine did, not about what a registry attested. That is also why there is no whole-registry pattern. ocx.sh/* and a bare ocx.sh are both refused at parse. A grant spanning every organization on a host trusts every publisher on it wherever anyone can register. List organizations one at a time.

What the grant still cannot tell you is whether the expected identity published those bytes. A credential inside a listed organization is enough. The record says which coordinate the digest was fetched under, never who signed it. The record is that coordinate as you named it. If you have configured a [mirrors] entry or an index that redirects it, the bytes came from your own routing rather than from the upstream host. The content is digest-verified either way, and who published it is the same open question. That is the residual above, and the control for it is the [[trust.policy]] below.

The real control for that residual already exists and answers the right question: an operator-tier [[trust.policy]] plus ocx package verify. Consent decides whether to run a project’s toolchain at all; a Sigstore signature decides whether the expected identity published what you are about to run. They are deliberately separate mechanisms — folding signature verification into the consent stamp would produce one system doing both jobs worse.