Skip to content

Shell Integration

Every time you sit down at a shell, OCX has to answer two questions before it can put anything on PATH: should this project's tools 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, the consent model that gates it, and the commands you have when something looks wrong.

From inert to active

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 — OCX never executes package code during install — but the risk is not hypothetical: the first tool invocation in that directory would put whatever ocx.toml names in front of cmake, cargo, or git on PATH. mise's own history is the cautionary tale, not a hypothetical one: GHSA-436v-8fw5-4mj8 shipped for four months because a project's own trust-control settings were read before the trust check that was supposed to gate them, so a malicious repository could 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 — and 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 — or for the global toolchain, which needs no consent at all, since $OCX_HOME/ocx.toml is your own file — OCX keeps PATH converged with what is declared, at every prompt, without an eval step you run by hand. cd into a consented project and its locked tools land on PATH at the next prompt. cd back out and they leave again. Add a tool to the global toolchain from another terminal and 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 that ocx self setup writes into your shell profile — in your own shell it has already run, 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.

Adding a tool applies it at the next prompt
Landing inside a consented project
Leaving a project takes its tools back off PATH

Same shape as mise's typed diff, not direnv's byte snapshot

mise's EnvDiffOperation::{Add,Change,Remove} and direnv's untyped {Prev,Next} snapshot solve the same problem two different ways. direnv's diff stores whole before/after values with no record of which tool wrote which key, so restoring it can clobber whatever else touched the environment since — the complaint behind direnv#82 and direnv#1249. OCX's reconciler follows mise's typed shape: every element it adds is provenance-tagged as its own, so reverting removes exactly what OCX put there and nothing a foreign edit added since.

The state carrier

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: a compact, base64-encoded ledger recording what is currently applied per scope (global and project, tracked separately) and 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.

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 rather than losing track of the fingerprint entirely, and the affected scope's tools 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 — one that fires on every prompt without you doing anything — 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 — the initial compose is unaffected — but nothing re-checks after that. If you add a global tool or cd into a newly consented project mid-session in one of those shells, 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, but does not yet 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 below.

A project created where you already are

The guard the hooked shells run on every prompt compares a freshness stamp against a set of file paths baked in when the hook was last emitted — the project file OCX resolved and the ocx.lock beside it, the global tier's pair, the config files that were read, and the project's consent stamp. That set carries the project entries only when a project was actually in effect, so a directory that had no ocx.toml gave the guard nothing to watch for one appearing: the carrier was set, the stamp was fresh and the directory had not changed, so ocx init (or a git checkout that brings a project in, or an editor writing the file) left the shell inert until the next cd or a new terminal.

So the guard carries one term evaluated against the shell's live working directory: an ocx.toml there that is newer than the stamp. Creating a project where you already stand now reaches the very next prompt, and so does the ocx shell allow that consents to it. The cost is one stat by a shell builtin on the quiet path, and nothing else changes — what counts as stale is still the fingerprint over the watch set, and a term that fires for a directory OCX ends up not activating costs one reconcile that changes nothing.

The watched project entry is the file OCX actually resolved, whatever it is called. That matters when you point OCX at a project by path rather than by walking to it: OCX_PROJECT names a file outright, and it may be named anything — set it to /work/build.ocx.toml and that is the file whose edits reconcile, not an ocx.toml beside it that does not exist. While OCX_PROJECT names a file that is not there yet, the reconcile has nothing to resolve and leaves the guard retrying, so the file appearing is picked up at the next prompt too. What no file term can see is the variable itself changing: exporting or re-pointing OCX_PROJECT in a shell that is already running moves nothing on disk, and takes effect at your next cd.

One case still waits for a cd: an ocx.toml appearing in a parent of where you are, rather than in the directory itself. Walking the whole ancestor chain would put one stat per level on every prompt to catch a case the directory change already catches a moment later. elvish has no term of this kind at all — see below — but its ocx wrapper covers the half that matters, since it clears the recorded directory after every ocx command.

Elvish's guard

Elvish registers its reconcile the same append-safe way the other hooked shells do, just on a different seam: set edit:before-readline = [$@edit:before-readline { … }], elvish's documented idiom for adding a hook without discarding one another module already installed. Where bash, zsh, fish, and PowerShell compare a stamp against a watch set of file mtimes — ocx.toml, ocx.lock, the selected binary — elvish's guard has only two terms: the private carrier __OCX_ENV_STATE being empty, and the current directory differing from the one the last successful reconcile ran for. Elvish 0.21 has nothing to build a third term from: os:stat documents name, size, type, perm, special-modes, and sys as its fields and states timestamps are not exposed, and elvish ships no clock module, so there is no stamp and nothing to compare one against. Elvish's ocx wrapper compensates for the missing term: on the way out of every ocx invocation run in that same shell, it clears the recorded directory, so the next prompt reconciles regardless of what the command changed. That is a narrower net than it sounds, because only two things clear it — an ocx command typed in this shell, and a cd. The residual is anything OCX watches that changes by neither route: ocx.toml or ocx.lock edited by hand in an editor is one example, and so is an ocx add --global run in a different terminal, a git checkout that swaps ocx.lock on disk, a config file edited by hand, or ocx self update run from elsewhere. None of these reconcile in this elvish shell until its next cd or its next ocx command. Every other hooked shell (bash, zsh, fish, PowerShell) notices all of these immediately, at the very next prompt, because their guard compares file mtimes directly instead of relying on a wrapper. Reaching for an external test -nt on every prompt would add one process spawn to the quiet path — exactly the per-prompt cost this design exists to avoid.

The session PATH beside the hook

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 — which 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.

The reconciler cannot drop them

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, and the reconciler treats them as such even when it has been told to activate nothing at all.

Where they sit on PATH

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, so a globally installed cmake would shadow the project's — the tier inversion strict isolation exists to forbid.

Within the session block the install directory comes last, and ocx reads like every other name: the most specific toolchain that pinned it answers for it, and the installed binary is the floor beneath both. A toolchain may pin ocx itself — nothing refuses the name — so 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, and the one remaining bare-name case is settled where the lookup happens, by excluding trampoline directories from it.

Pinning your own ocx

ocx --global add ocx.sh/ocx/cli:0.9 puts that version in front of the one that installed it, for every shell started afterwards. command -v ocx shows which one answers. Interactive shells also carry an ocx function that ocx emits; it follows the same pin, and picks up a change at the next shell start.

What bin mode costs a prompt

In bin mode the reconciler emits a project's <home>/toolchain/active/bin only when a render stamp exists for that home and the directory still holds 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 — which is precisely the case the gate is built for, a hostile clone force-committing one extra trampoline beside an otherwise legitimate tree. Every filesystem condition on that walk is a mismatch rather than an error: an unreadable trampoline directory, an entry that vanished mid-walk, one replaced by a symlink after a good render, 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: absent, not a link, or pointing anywhere other than shells/default is a mismatch, not a new failure mode. 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 — pulling whatever ocx.toml names onto PATH with no confirmation — 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, and even when that project has a config of its own. That is why a fresh clone going inert does not take your global tools with it.

  • A consent stamp — written by ocx shell allow, by ocx init for the project it creates (pass --no-consent to skip it), and automatically the first time you run ocx add, ocx remove, ocx lock, ocx update, 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; and 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 tools 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, and 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 tools this machine has already fetched — a warm shared store, which is the fleet case — and stays inert on a cold one until the first ocx pull, which writes a consent stamp anyway — unless that pull 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 tools 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 tools; OCX withholds the table and prints a hint naming the fix: run ocx pull there once (which also writes a consent stamp, unless OCX_NO_CONSENT or --no-consent declines it — in which case the pull leaves the project exactly as inert as it found it), or list this exact directory — not a subtree grant — in [shell.consent] paths. 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, neither constrains the other, and 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, and a path grant is deliberately drift-blind, since an operator naming a checkout in advance cannot enumerate its future sources.

A path grant activates a checkout that carries no stamp

Path and namespace grants share the same [shell.consent] table wherever OCX reads configuration from — system, user, and home tiers, an explicit --config / OCX_CONFIG file, and the managed tier, but only when the managed source is digest-pinned; an unpinned managed tag has [shell.consent] stripped with a warning, the same rule OCX already applies to Sigstore trust roots. Two environment variables reach the same table without a file: OCX_CONSENT_PATHS and OCX_CONSENT_NAMESPACES. Every source unions — nothing in a lower tier overrides a higher one, only adds to it.

ocx.toml cannot carry [shell.consent]. The project's own config file rejects an unknown [shell] section outright — a hard parse error, not a silent skip — because a project-writable consent grant would let a clone consent to itself.

toml
# config.toml (system, user, or home tier — never ocx.toml)
[shell]
hook        = true
completions = true

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

# or the carve-out form, for withdrawing one namespace another tier granted:
# namespaces = { include = ["ocx.sh/acme-corp", "ocx.sh/acme-labs"], exclude = ["ocx.sh/acme-labs"] }

What consent grants — and what they cannot buy back

None of the three grants above authenticate who published the bytes a consented namespace resolves to. See What consent does not cover for the honest boundary and the control that actually answers that question.

Commands

Both the hook and shell completions follow the same --flag / --no-flag / OCX_NO_* / config-key / auto ladder ocx self setup already uses. self setup --hook / --no-hook and the newly added --completion / --no-completion write [shell] hook / [shell] completions to your home-tier config.toml; leaving a flag off writes nothing, and the previously configured (or default) value applies.

sh
ocx self setup --hook --completion       # write [shell] hook = true, completions = true
ocx self setup --no-hook                 # write [shell] hook = false

ocx self activate — the command your shim already calls at shell start — accepts the same --hook / --no-hook pair for a one-off override, and reads [shell] once, at shell start. It never reads configuration again on the per-prompt path; that reserved budget is what keeps an unchanged prompt effectively free.

Disabling the hook entirely, for a single shell or every shell, is OCX_NO_HOOK — see its reference entry for the exact rules, including why it only takes effect at the next shell start.

ocx shell state is the read-only counterpart to every switch above: it changes nothing and reports what all of them decided, plus the resolved toolchain home and the two effective toolchain settings a tool integrating with OCX needs. See Diagnosing a shell.

Diagnosing a shell

Everything documented so far is deliberately quiet: the hook logs at debug, an absent ledger is the ordinary first-prompt case, an inert project prints one hint line at most, and a yielded scope prints one info line. That is right for a path that runs on every keystroke's worth of prompts and wrong the moment you are staring at a missing tool wondering why.

ocx shell state is the read-only answer. It never mutates anything — no stamp, no ledger repair, no plan — and it exits 0 in every state it reports, including every flavor of "not active."

By default it answers the question you actually asked, in a handful of lines: where $OCX_HOME is, which project is in effect, which toolchain home that resolves to and how it activates, whether the integration is active, and — when it is not — why, plus the one line that says what to do about it. The reason is one of an enumerated set: a consent stamp missing, a stamp present but the lock outgrowing it, the hook disabled and which config tier decided that, a yield to direnv or mise naming the live signal it saw, a ledger reduced to a marker because it went over the size cap, or a ocx.lock that will not parse.

sh
ocx shell state
ocx home: /home/you/.ocx
project: /work/acme/api
toolchain: /work/acme/api/.ocx/toolchain
  bin: /work/acme/api/.ocx/toolchain/active/bin
  activate: env
  pinned: no

active: no
reason: no consent stamp, and no matching grant
  derived sources:
    - ocx.sh/acme
  paths tested:
    - /work/other
  namespaces tested:
    - ocx.sh/other
fix: run `ocx shell allow` here, or add this directory to [shell.consent] paths

The verdict, the reason and the fix are highlighted when stdout is a terminal; redirected to a file or a pipe the same text arrives without the escapes.

A [shell.consent] paths entry can be wrong in two different ways, and ocx shell state tells them apart. An entry that would grant the project if the comparison were looser — differing from the canonical directory only by ASCII case, say — earns a near-miss row naming the entry and the directory it almost matched. That row is a case-sensitive-filesystem answer: on Windows, ASCII case is folded as part of the match, so such an entry simply grants. An entry that can never grant any project, regardless of directory — a * inside a path component, a * that is not the entry's last component, a bare * on its own, a .., a relative entry, a ~user/… form, or a leading ~ on a machine with no resolvable home directory — earns a different row instead, naming the defect directly:

note: a paths entry can never match any project
  entry: /w/*/tools
  problem: '*' is a wildcard only as the entry's last component; write '<directory>/*' to grant a subtree

Neither shape is a TOML syntax error, so without these rows a broken entry just sits in [shell.consent] paths granting nothing — indistinguishable from an entry that was never meant to cover this project at all.

--verbose adds the evidence behind the answer — the material for a support conversation rather than for a "is it working" question:

  • the decoded ledger, as fields rather than base64 — what is currently applied, per scope, and how many of the carrier's 16 KiB it takes;
  • fingerprint status: the watch set OCX is comparing against, each member's size and mtime, and whether the fold still matches what is recorded;
  • whether the priors needed to restore a constant on scope exit are still intact;
  • the project's state key and whether a consent stamp exists for it;
  • which rung of the [shell] hook ladder decided the hook's enablement, and in which config tier.
sh
ocx shell state --verbose

The carrier's byte count is ocx's own budget, not the shell's. bytes: N of 16384 measures what OCX contributes through __OCX_ENV_STATE and nothing else. The real ceiling on a process environment is the operating system's combined argv + envp limit, which OCX does not account for and cannot: a shell whose environment is already near that limit fails at execve with E2BIG, which surfaces through the ordinary spawn-failure path rather than as anything this report can predict.

--verbose is a rendering tier, not a payload. The structured form carries every field at every verbosity:

sh
ocx --format json shell state   # complete, with or without --verbose

Four of its fields are a contract other tools read rather than a diagnostic a person reads — the supported way for an editor, a devcontainer feature or a CI step to discover a toolchain from outside ocx:

FieldWhat it carries
toolchain_homeThe resolved toolchain home: <project>/.ocx/toolchain, or <toolchain-dir>/<project-key>/toolchain when toolchain-dir relocates it, or $OCX_HOME/toolchain when no project resolves. Always present, never null — a toolchain-dir the containment rules refuse is rejected at config load, so a report that exists at all has a spellable home.
toolchain_binThe directory to put on PATH for that same home — the trampolines bin mode exposes. Present exactly when toolchain_home is, so no consumer has to branch, and it names a path that need not exist yet: a home that has never been pulled reports the directory it would hold. Read this field rather than joining anything onto toolchain_home. The two are not one path component apart, and a hand-built join produces a plausible string that names nothing — silently, because the tool that consumed it will not fail until several steps later.
activateThe mode the project in effect resolves to — env, bin or none — through that project's own activate key and then the environment, never one tier's raw value. With no project in effect the file tier is absent and the environment answers.
pinnedThe boolean, resolved through the same two tiers. true means a composing emitter yields digest paths and consults no links/<group>/<entry> link; false means it follows the rendered links, so an ocx update takes effect with no re-render.

Without toolchain_home the only route to that directory is re-deriving a 16-hex project key from a path the caller would also have to canonicalize exactly the way ocx does. All four appear in the human rendering too, at both verbosity tiers: where a toolchain lives is an answer, not a diagnostic.

Reading the toolchain state

Its output is never eval-able — no line is valid shell-assignment syntax in any supported shell, at either detail tier, coloured or not — on purpose. ocx self activate emits text meant to be evaluated; ocx shell state emits text meant to be read, and a surface where those two are interchangeable is one copy-paste away from evaluating a diagnostic dump into your live shell.

Exit code 0 covers every reportable state. The only non-zero exit is 74, and only when $OCX_HOME itself cannot be read.

Coexisting with direnv and mise

direnv and mise both prepend to PATH from a per-prompt hook of their own, and mise's own documentation is upfront that combining the two is not a supported configuration — two hooks racing to reorder the same PATH has no well-defined outcome. OCX's mechanism is the same shape, so it is the same class of collision, and OCX does not try to referee it. Instead, it yields.

The yield check looks at live session state, never a file on disk: DIRENV_DIR naming the current project, or MISE_SHELL / __MISE_ORIG_PATH. A .envrc or mise.toml checked into a repository whose owner is not actually hooked into this shell is not evidence of a live hook — it is evidence of someone else's workflow, and treating it as a yield signal would leave the project silently managed by nobody. When either tool is genuinely active for the current directory, OCX narrows to the global toolchain only, reverts any project scope it had already applied, and prints one info line naming the tool it yielded to.

Hook registration order relative to direnv's or mise's own entries is unspecified and deliberately not refereed — no reordering logic, no cross-tool coordination, no retry. If ocx's hook runs first on the prompt where the other tool activates, the project scope may apply and then revert within that one prompt, and the shell is converged by the next prompt.

direnv and mise are not mutually aware either

This is not a gap unique to OCX: direnv's stdlib and mise's own hooks make no attempt to detect each other, so the same "two hooks, one PATH" ordering question exists between them already. OCX's yield rule keeps it a two-party problem instead of a three-party one.

Ordering with powerlevel10k's instant prompt

OCX's hook prints nothing at shell startup. What can still reach powerlevel10k's POWERLEVEL9K_INSTANT_PROMPT capture is the first prompt: its diagnostics ride the same eval'd --reconcile stream the hook always uses, appended via add-zsh-hook precmd — so a .zshrc that sources the ocx activation line before p10k's instant-prompt preamble puts that precmd ahead of p10k's own file-descriptor restore, and p10k flags it the same way it flags direnv, mise and nvm. Source the activation line after the instant-prompt preamble instead, and the ordering resolves the same way it already does for direnv and mise.

Repairing a stuck shell

If a shell's ledger falls out of sync with reality — a write landed inside the same filesystem-timestamp granularity as OCX's change-detection window, or the carrier itself looks corrupted — the repair gesture is unset __OCX_ENV_STATE. Clearing the variable makes the next prompt see an absent ledger, which is a state the reconciler already handles in full: it rebuilds the desired environment from the project files on disk, removes anything under $OCX_HOME it no longer recognizes, and leaves any other constant alone rather than guessing at it.

Its cost, stated plainly. Clearing the ledger destroys the recorded priors — the values OCX saw before it applied anything. If you had, say, a hand-set JAVA_HOME before entering a project, the priors bullet is what remembers that value so leaving the project can restore it. After unset __OCX_ENV_STATE, that memory is gone: JAVA_HOME keeps whatever the project set for the rest of that shell's life, because nothing records what it was before. A brand-new shell is the clean floor whenever one is cheap to open — it starts with no priors, so it has nothing to lose.

The gesture is silent by construction: OCX cannot distinguish a deliberately cleared ledger from the ordinary absence on a shell's first prompt, and that first-prompt case logs at debug by design. ocx shell state is how you confirm the repair actually took.

What consent does not cover

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 — 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, which 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 — a clone can name any source it likes — so 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 patternocx.sh/* and a bare ocx.sh are both refused at parse, because 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, and the record says which coordinate the digest was fetched under, never who signed it. The record is that coordinate as you named it, so 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.

This mitigation is opt-in, not default

With no [[trust.policy]] configured, automatic verification is a no-op — someone who cloned a repository and wrote no operator config gets no signature check on the hook's path. Turning it on means writing an operator-tier [[trust.policy]] yourself; see Signing for the full model. A project's own ocx.toml cannot enable this on your behalf, by the same logic that keeps [shell.consent] out of it.