Skip to content

feat(shell): keep the environment current at every prompt, without direnv - #339

Open
michael-herwig wants to merge 130 commits into
mainfrom
feat/shell-env-overhaul
Open

feat(shell): keep the environment current at every prompt, without direnv#339
michael-herwig wants to merge 130 commits into
mainfrom
feat/shell-env-overhaul

Conversation

@michael-herwig

Copy link
Copy Markdown
Contributor

Replaces direnv with a native per-prompt ocx shell hook, and makes the exported environment self-correcting: it reconciles the live shell toward the composed toolchain at every prompt, in every supported shell.

Implements .claude/artifacts/adr_shell_env_overhaul.md in full. Closes #170. Subsumes #265 (the unset directive falls out of the constant-kind state machine).

What lands

  • Per-prompt reconciler. A private __OCX_ENV_STATE ledger carries current/desired/last state; the reconciler drives the shell toward the composed environment on entering a project, leaving it, switching projects, and after ocx add --global / update / remove in another process — no restart, no direnv reload.
  • Consent. A fresh clone is inert. Activation requires a stamp bound to both the path and the project identity; source-set drift re-confirms. The whitelist lives in config.toml (user configuration, never ocx.toml), with managed-tier and env-var channels for fleets and devcontainers.
  • Enablement. --hook / --no-hook on both ocx self setup and ocx self activate, plus OCX_NO_HOOK, mirroring the shipped completions ladder. [shell] hook and [shell] completions decide when no flag is given.
  • ocx shell state. Read-only introspection, never eval-able, with enumerated reasons for why the integration is inert.
  • Coexistence. Yields to a live direnv or mise session rather than fighting it over PATH.
  • Correctness fixes found on the way. Six per-shell emitters were corrupting exported values; PATH elements were compared differently in-process and in the emitted stream; cd into a project did not reconcile at all.

Testing

  • 223 enumerated edge and known cases in analysis_shell_env_edge_cases.md, each with an exact reproduction and a tier.
  • Acceptance matrix across all nine shells (sh, dash, ash, bash, zsh, fish, pwsh, nushell, elvish) plus the Windows PowerShell 5.1 / 7 legs.
  • Every gate in this branch was demonstrated red before green, with the mutation proven landed. Four vacuous checks were caught that way during the run and rewritten.

Docs

A dedicated section under the user guide with worked examples and asciinema casts for adding a package and moving into and out of a project.

Design record

ADR, binding addendum closing every case the ADR left open, the edge-case register, the design spec and the execution plan are all committed under .claude/artifacts/, with an explicit precedence rule at the top of the plan.

…ions

Companion register to adr_shell_env_overhaul.md. Four parallel adversarial
passes — reconciler semantics (C/D/L, retirement, revert set, ledger forgery),
per-shell mechanics across the ten Shell arms, consent/whitelist/config, and
operational/environmental — deduplicated into one table keyed by stable IDs so
downstream tests can reference a row rather than re-derive it.

Every row is a reproduction, not a description, and names the decision its
expectation follows from. Tiers: rust-unit 112, pytest-hostshell 75,
pytest-shellzoo 30, manual-only 3. "Needs a Windows runner leg" is classified
as an automated hostshell case, not manual — Validation section Windows
already commits to that leg.

Closes with 51 UNSPECIFIED-BY-ADR gaps, each carrying a decisive
recommendation, and a coverage audit of test/tests/test_shell_activation.py
showing only EC-LIST-001 is reachable from the shipped matrix today.

Analysis only: no production code, no tests.
…ckages

/hex-plan high over adr_shell_env_overhaul.md. Adds the executable plan, the
design spec that is its spine, and three research artifacts.

- design_spec_shell_env_overhaul.md — C-001..C-052 contracts and S-001..S-045
  scenarios, each traced to an ADR Decision, testable without reading code.
- plan_shell_env_overhaul.md — 19 file-disjoint work packages in six waves,
  ADR-Decision traceability, per-WP model assignment, exit-code and CLI-grammar
  decisions, a Constitution Deviations table, and 12 assigned fault injections
  each with a named red state.
- research on cast recording for interactive prompt hooks, on how the field
  tests and benchmarks per-prompt hooks in CI, and a falsification pass over the
  ADR's external citations.

Review round 1 (opus spec + opus architect + sonnet SOTA) found six Block-tier
defects; all six are fixed here. Three were wrong claims about the shipped code,
verified directly before acting:

- project/hook.rs was listed as dead and scheduled for deletion in the commit
  that gates every other package. It is live — direnv_export.rs:11,94,96,102.
- The reserved-key gate was homed at Env::apply_entries, which conventions.rs
  ::emit_lines does not route through, leaving ocx env --shell and direnv export
  able to emit a package-declared __OCX_ENV_STATE into an eval'd stream. The
  gate moves to the resolver, which both consumers reach.
- ScopeSpec's deserializer drops unknown keys inside its table, so reusing it
  for [shell.consent] namespaces would silently widen consent on a future
  narrowing key. A strict ConsentScopeSpec wrapper ships instead, recorded as a
  constitution deviation with the arch-principles amendment scheduled.

Also: wave 0 now creates the nine stub files its own cargo check gate needs;
exit 78 on a malformed managed-tier consent pattern is corrected to strip-and-
continue, matching the shipped loader, so one typo cannot take a fleet down; the
nushell spike is split into a leaf so it blocks nothing; and the consent write
seam is located, with a per-caller opt-in guard against auto-granting on
ocx inspect and ocx env.

The oci-client fork needs no change: no file this plan touches imports
oci_client, and self activate never reaches Context::try_init.

Refs #170, #148, #152
…pec and plan

Four artifacts described the same feature and contradicted each other: the
addendum and the plan/design-spec were written concurrently and neither saw
the other.

Arbitration, recorded as plan §0: code wins over every document; the addendum
wins over the ADR and the design spec on semantics; the plan wins over the
design spec on decomposition. All 15 load-bearing code claims the addendum
reasons from were re-verified against the worktree and came back confirmed,
so the addendum's semantics stand and the losing wording was deleted rather
than softened.

- ADR: all 37 correction rows applied in place, plus a binding pointer at the
  top. Two citations fixed against the real code (project/registry.rs, not
  file_structure/registry.rs).
- Register: 51 UNSPECIFIED-BY-ADR cells now name their closing A-NN; 23 rows
  carry an addendum-override marker; the tier vocabulary is mapped onto the
  ADR's cost ladder and eight dangling id references are named. 220 rows in,
  220 out.
- Design spec: ten direct contradictions rewritten to the addendum's answer
  (over-cap marker, dir-never-gates-revert, list-appends, watch set, no
  startup diagnostics, no grant auto-stamp, file-then-parent key derivation,
  explicit tier over managed, OCX_BINARY_PIN cannot reach the hook), 32
  extension anchors, 13 scenarios corrected, §4 marked void, §6 item 1 closed.
- Plan: precedence rule, the A-NN <-> C-NNN diff as §1a (all 38 resolutions
  reach a work package; none needed a new one), three contradictions fixed,
  resolution ids folded into the Scope cells, owner calls recorded.

A-27's grammar rejected the ADR's own carve-out example, which no document
had noticed: carve-outs are at source granularity, so they subtract an org
from a registry grant, never a repository from an org. Fixed in three places.

WP-2 moves S->XL (A-15..A-21 add a behaviour change to three shipped emitters
plus a new Shell primitive); WP-6 moves L->M (A-26 deletes the rule it was
going to implement).
WP-0 of plan_shell_env_overhaul: one writer, one commit, so the eighteen
parallel packages compile against a fixed API from their first commit.

Creates nine files with unimplemented!() bodies, each doc-comment naming the
C-NNN contract (and A-NN resolution) it realizes:

  shell/reconcile.rs   WP-1  Ledger/LedgerEntry/Plan codec + plan()
  shell/hook.rs        WP-3  per-shell registration + wrapper emission
  shell/coexistence.rs WP-4  typed direnv/mise Yield verdict
  config/shell.rs      WP-7  ShellConfig/ShellConsent FULL field shapes
  project/consent.rs   WP-6  ConsentStamp, load/record/evaluate, Reason
  options/hook.rs      WP-9  options::Hook + the five-rung ladder
  command/shell_state.rs      WP-13 ocx shell state
  api/data/shell_state.rs     WP-13 its Printable report
  setup/shell_config.rs       WP-10 surgical toml_edit [shell] writer

Plus Shell::remove_list_element (C-014), Config.shell + its merge arm,
StateStore's three C-022 accessors, Identifier::first_path_segment (C-026),
Deserialize on ModifierKind (C-001), and the module/subcommand declarations.

Deletes the two dead-code orphans the ledger builder would otherwise find and
copy the wrong shape from: shell/applied_set.rs and package_manager/tasks/
hook.rs, both zero-consumer remnants of the removed _OCX_APPLIED mechanism.
project/hook.rs stays -- direnv_export.rs calls it.

Three deviations from the artifacts, resolved under code > addendum > ADR >
spec:

  - ConsentStamp.stamped_at is an RFC 3339 String, not the spec's
    time::OffsetDateTime: ocx_lib depends on no time crate, and every shipped
    state file (ManagedConfigSnapshot.fetched_at) uses the string form.
  - consent::record is pub, not A-29's pub(crate): its six callers live in
    ocx_cli. A-29's own sentence already concedes the allowlist is enforced by
    the acceptance test rather than by visibility; the doc carries it.
  - The Shell subcommand variant is State, not ShellState -- clap derives the
    command name from the variant, and C-050 spells the command 'ocx shell
    state'. Matches the shipped Completion(ShellCompletion) pairing.
…-env overhaul

WP-17 of plan_shell_env_overhaul.md. Rewrites subsystem-cli.md and
subsystem-cli-commands.md's `ocx shell` taxonomy to show the new
`ocx shell state` diagnostic beside `completion` (hook/init/env stay
deleted, tombstones intact); adds the `state/projects/<key>/` consent-stamp
layout and its one-exception amendment to the "state/ not walked by ocx
clean" bullet in subsystem-file-structure.md, naming the two colliding
`projects/` directories; replaces arch-principles.md's superseded
adr_live_env_reload.md ADR-index row and adds the consent-bearing-table
carve-out to the Deviation-=-Bug row (Decision 4: a consent table's unknown
narrowing key must not silently widen trust); reconciles
handshake_toolchain_cli.md's §2 shell-hook table and extends its §7a
stale-doc register for this ADR's second pass over the same file set; and
applies the ADR's three external citation corrections (nix#7166, conda#12769,
fish-shell#7776) plus the sudo -E/SendEnv Security-NFR sentence.

Also completes test_ai_config.py's `_STEM_REWRITES` map with the
`shell_state` -> `shell state` entry the CLI-command coverage test needed
once shell_state.rs's WP-0 stub landed, and rules.md catalog parity for the
new shell-activation concern.
WP-0 review panel (reviewer:spec, reviewer:quality — both PASS, 0 Block) raised
five cross-work-package findings. Fixed here rather than left to the nine wave-1
owners, because each spans two packages that would otherwise have to agree:

- `Reason::YieldedTo` redeclared `Observation`'s two fields verbatim. Folded onto
  `Reason::YieldedTo(Observation)` — WP-4 owns the struct, WP-6 owns the enum, and
  two definitions that must stay byte-identical is a defect waiting for one of them
  to move.
- `#[allow(dead_code)]` on the temporary stub surfaces became
  `#[expect(dead_code, reason = ...)]`, the repo's shipped self-removing form
  (`package/metadata/authoring.rs:95`). It paid for itself immediately: the
  suppression on `options::Hook` was unfulfilled — suppressing nothing — and is
  now gone.
- Dropped `validate_consent_pattern` from the crate-root re-export; no CLI caller
  is planned and C-030/C-031 are `ocx_lib`-internal.
- Fixed an intra-doc link through the private `state_store` module.
- Deleted the two tombstone paragraphs describing the removed `_OCX_APPLIED`
  mechanism. The old form goes as if it never existed.
…ate `__OCX_ENV_STATE` ledger

Implements WP-1 of the shell-environment overhaul: the `__OCX_ENV_STATE`
ledger, its `<tag>.<payload>` envelope codec, the three forgery rules on the
carrier, and the pure three-way `plan` that diffs desired against current.

Codec (C-003, C-004, A-01): encoder `1` is base64url of compact JSON via
`base64` + `serde_json` — no hand-rolled serializer. Decode is total: every
failure means "treat the ledger as absent" (C-006), including an unrecognised
tag, a truncated payload, an unknown schema `v`, or a raw value over 16 KiB.
Over the cap `encode` emits a marker-only ledger keeping `fp`, `verdict` and
`over_cap` rather than omitting the variable — omission destroys the
fingerprint and makes every later prompt recompose for the shell's life.

Planner (C-010, C-013, C-015…C-018, A-07…A-10): apply is routed per kind —
path prepends, list appends its whole opaque contribution, constants are set
only where the composed value actually changed. Retirement is subtractive:
every prefix-owned segment of the current value that D no longer wants is
removed, naming the segment as it appears in C so selection and removal share
one byte-exact operand. Ownership is component-wise `Path::starts_with`, so
`.ocx-backup` and `.ocxevil` are foreign. The revert set is scoped to the
ledger and never intersected with D, so leaving a project restores its
constants instead of leaking them into the rest of the session.

Forgery rules (C-007, A-02, A-03, A-06): `key` and `dir` are advisory labels
that construct no path and gate no revert; `kind` and `separator` are
re-derived from D for every key D declares; `PATH`/`PATHEXT` are refused as
constant-kind at `plan` and discarded on decode. The privilege-crossing
residual A-06 documents is pinned by a test rather than assumed away.

`Plan` carries a structural `"v": 1` and renders `sets` through
`LedgerEntry`'s spelling, `type` included, so the nushell applier reads one
vocabulary. It never contains shell text — escaping stays at emit time, per
arm.

Fault injection (plan §6.6): making the repair additive instead of
subtractive flips the digest-duplicate assertion in
`c016_a_digest_bump_leaves_zero_stale_package_directories_on_path` from 0 to
1 stale `…/packages/<old>/bin` entries; restoring the subtractive push
returns it to green.
…h, fish and PowerShell

Emit the per-prompt hook and the `ocx` wrapper function for the four arms
that host an append-safe prompt-hook point (C-043, C-045).

Registration is append-only in every arm: `PROMPT_COMMAND` in both its
string and its Bash 5.1 array form (stripping a trailing separator before
concatenating, the Warp#5219 syntax error), `add-zsh-hook precmd` and
never a `precmd()` definition, a named `--on-event fish_prompt` handler,
and a PowerShell `prompt` that wraps and calls through to the captured
previous definition. Re-sourcing an activation stream registers nothing a
second time.

The unchanged prompt execs nothing at all (C-044): the emitted body carries
the watch set and compares it against a session stamp with shell builtins
only. An empty carrier counts as changed, so the first prompt of every
shell reconciles (A-21) and `unset __OCX_ENV_STATE` takes effect at the
next one.

Never breaks a prompt: `$?` is captured on entry and returned unchanged;
the reconcile call's stderr is discarded and its status ignored, so a
binary that predates `--reconcile` prints nothing; the pwsh body runs under
its own error preferences, restored in a `finally`, and restores `$?` and
`$LASTEXITCODE` (A-22); and a restricted shell (`rbash` / `rksh`), which
forbids both setting `PATH` and invoking any command containing `/`,
detects on `$-` and silently registers nothing.

The wrapper returns the wrapped command's own exit status, captured before
anything else runs, so `ocx install nonexistent; echo $?` still reports the
real failure (A-35). On pwsh the wrapped call keeps the caller's error
preferences, so a hardened profile throws exactly as it would without the
wrapper, and the reconcile still runs from a `finally`.

No emitted snippet ever calls bare `ocx` — the wrapper shares that name, so
every call site uses the resolved absolute path — and none reads
`OCX_BINARY_PIN`: the hook always resolves through `current` (A-34).
Nothing here emits a diagnostic; A-21 deletes the startup message channel
outright and every message rides the first `--reconcile` run's own output.
…tters

`Shell::export_constant` escaped every value for a *double-quoted* context
on all ten arms. Measured, that is a byte corruption rather than a
hardening, and it broke six different arms in five different ways:

- POSIX (ash/ksh/dash/bash/zsh) turned `!` into `\!` for history-expansion
  safety, but `\!` is *literal* inside double quotes in every measured
  shell — non-interactive dash/ksh/bash/zsh and interactive bash with
  histexpand on — so `a!b` was exported as `a\!b`.
- elvish emitted `\$` and `` \` `` into a double-quoted string, which
  elvish rejects as an *invalid escape sequence*: a `$`-bearing constant
  was a parse error, not a wrong value.
- nushell escaped `$`, `(` and `)` into a **plain**, non-interpolating
  string where all three are inert, so `$env.HOME` became `\$env.HOME`.
- Batch escaped `^&<>|` inside `SET "KEY=…"`, where cmd does not process
  them, so the carets survived into the value.

Every arm now uses its own escaper and quoting context: a single-quoted
literal for the POSIX family, PowerShell and elvish, where no expansion
can fire at all, and the reduced double-quoted escapers for fish, nushell
and Batch. Four further emit defects go with them:

- `export_path` refused nothing for an empty value and emitted a leading
  empty PATH segment, which POSIX resolves as the current working
  directory — a privilege-escalation primitive. It is now a no-op comment,
  matching `utility::path::move_to_front`.
- The bash/zsh and fish arms kept ambient empty segments that the other
  six arms and `move_to_front` all strip.
- The PowerShell arm compared segments with `-ne`, which is
  case-INsensitive: adding `/opt/bin` on Linux silently deleted a
  genuinely different `/opt/Bin`. It now uses `[String]::Equals` with a
  `StringComparison` chosen at emit time, and strips one surrounding pair
  of `"` per segment before comparing.
- Batch refuses `%`, LF and CR outright: `%VAR:search=%` cannot escape a
  literal `%`, so the delete half never matched and every re-source
  prepended another copy.

Emitted and in-process values are now byte-identical in every arm, proven
against real interpreters — the precondition for the per-prompt
reconciler's `C == L.applied` exit guard, whose two operands are exactly
these two products.

Refs: adr_shell_env_addenda.md A-15, A-16, A-17, A-18, A-19, A-20;
design_spec_shell_env_overhaul.md C-009, C-021.
…, per shell

Two primitives the per-prompt reconciler needs and no shell had.

`Shell::remove_list_element(key, value, separator)` is the inverse of
`export_list` and of `utility::list::append_unique`: flank-delimited
removal of one whole contribution, never a segment op, so a value that
itself carries the separator is still removed as one span. Delete-if-found
— absence is not an error, and removal commutes with foreign prepends and
appends, which is what makes a revert safe against every other tool that
edited the variable since.

The separator parameter is mandatory to the contract, not a convenience:
`CFLAGS` declared `{ type = "list", separator = " " }` applies through
`export_list` with a space, and a remover that assumed the platform PATH
separator would emit `:` flanks and leave the contribution permanently
unremovable. `None` selects path-kind and means the platform separator.

The two kinds differ only where their appliers differ. Path-kind collapses
ambient empty segments, as `export_path` and `move_to_front` do, and
compares segment-exact after stripping one surrounding pair of `"`,
ordinally on Unix and case-insensitively on Windows. List-kind preserves
empty elements verbatim and compares byte-exact and case-sensitively,
because list elements are opaque option strings where `-DFOO=1` and
`-Dfoo=1` are different options.

Nine of the ten shells emit; `Shell::Batch` returns `None` — not because
cmd cannot express a removal (`export_path` does one) but because its only
substring-replace primitive is case-insensitive with no case-sensitive
form. Batch also hosts no prompt hook, so nothing consumes it.

`Shell::emit_message(text)` emits shell code that prints on stderr when
evaluated. The shim that invokes the reconcile call discards the binary's
own stderr, so a diagnostic written there is lost; and the text rides as a
format argument, never as the format string, so a `%` in a project path is
not consumed as a conversion specifier. `Batch` returns `None`.

Escaping is per arm, never one shared escaper: routing every arm through
the fish/nushell double-quote escaper would ship a shell injection, since
it deliberately leaves `'` untouched and a PATH element `/tmp/a';id;'b` —
reachable from a project `[env]` value — would then execute at every
prompt. Each arm's emitted result is proven against a real interpreter to
equal the in-process primitive it mirrors.

Refs: design_spec_shell_env_overhaul.md C-014, C-021;
adr_shell_env_addenda.md A-08, A-10, A-19, A-21.
Adds the `[shell]` config section: the `hook`/`completions` enablement
rungs and the activation consent whitelist that decides whether a
project's environment may be applied at all.

`[shell.consent]` is the one table where the fleet forward-compat
tolerance stops. Everything reachable from `Config` ignores an unknown
key so a payload written for a newer ocx degrades to its known parts on
an older fleet host — but on a consent-bearing table dropping an unknown
NARROWING key WIDENS trust, the one direction that posture must not
take. `ShellConsent` therefore carries `deny_unknown_fields`, and
`namespaces` deserializes through a new strict `ConsentScopeSpec`
wrapper. The shipped `ScopeSpec` used by `[[trust.policy]]` drops
unknown keys inside its own table and is deliberately unchanged;
reusing it here would have delivered the opposite of the stated
property.

`namespaces` patterns are validated at parse by one validator shared
with the `OCX_CONSENT_NAMESPACES` env channel. Exactly three spellings
are accepted -- `<host>/<org>`, `<host>/<org>/*` and `<host>/*`, the
wildcard mandatory for the whole-registry form so it can never be
reached by dropping a segment. A trailing `/*` is stripped at parse so
matching takes `pattern_matches`' segment-bounded branch: `ocx.sh/acme`
and `ocx.sh/acme/*` cover the same set, and neither matches
`ocx.sh/acme-evil`. Uppercase, `@`, a misplaced `*`, an empty component
and a three-component (repository) pattern are refused.

Per channel the consequence differs, never the rule: a rejected pattern
in a user's own config tier fails that file (exit 78); the same pattern
in a managed payload leaves the tier contributing nothing with one WARN;
on the env channel the whole contribution is discarded with one warning
and the config tiers stand alone -- no channel may break a prompt. Empty
tokens are dropped before any pattern is constructed, because an empty
pattern is a catch-all.

Three further seams land with it:

- `Config::merge` now records which tier set `hook`/`completions`, via
  `#[serde(skip)]` runtime fields following the `system_locked`
  precedent, so a diagnostic can name the tier that actually decided
  rather than asserting one. The shipped order stands: the managed tier
  beats system/user/home, `--config` / `OCX_CONFIG` beats the managed
  tier.
- A managed payload's `[shell.consent]` is honoured only behind a
  digest-pinned `[managed] source`, in `guard_managed_sigstore_trust`'s
  idiom and for its reason -- otherwise the consent material arrives
  over the very channel it exists to authorise. `hook`/`completions`
  still merge unconditionally: they grant nothing, and consent still
  gates every project. The strip reason is recorded on the config as
  well as logged, because `ocx self activate`'s stderr is discarded by
  the shell shims; `ocx about` surfaces it.
- The project tier can never contribute `[shell]`: `fold_project_tier`
  strips it structurally, so consent read from a repository's own
  `ocx.toml` cannot let a clone consent to itself.

The config JSON schema was generated by no test, so a broken
`ShellConfig` impl would have compiled clean and passed `task verify`.
`ocx_schema` now walks `shell.consent.namespaces` to `ScopeSpec`'s
hand-written `oneOf` and pins the closed consent table.
…ment, and report which rung did

`Hook::enabled` and `Completion::enabled` now resolve one five-rung ladder —
`--no-X`, `--X`, `OCX_NO_X`, `[shell] X`, then session interactivity — so a
user who has written `hook = false` can expect `completions = false` to behave
identically (C-038, C-039).

Both gain a sibling `rung()` accessor naming which rung decided, so
`ocx shell state` reads the decision instead of re-deriving a
security-relevant enablement ladder in the CLI crate.

One `resolve_ladder` implementation serves both keys: a precedence that
differed between them would be invisible and unlearnable. `OCX_NO_HOOK` is a
bare `env::flag` literal, following `OCX_NO_COMPLETIONS`'s shipped precedent
rather than moving a new key into `ocx_lib`'s `env::keys`.

Fault injection: swapping the `FlagOn` and `EnvOptOut` arms (making
`OCX_NO_HOOK` outrank `--hook`) reds exactly
`rung_two_flag_on_outranks_env_config_and_auto`'s first assertion; restored
and re-proved green.
…jects/

`StateStore` gains three accessors for the per-project state tree that carries
shell-activation consent: `project_state_root()` (the sweep root `ocx clean`
walks), `project_state_dir(key)` and `consent_stamp_file(key)`.

`key` is `ReferenceManager::name_for_path` of the project's canonical
directory — the same 16-hex key `refs/symlinks/` and the project ledger use.
The canonical directory is derived by canonicalizing the resolved project
FILE, taking its parent, then `dunce::canonicalize`, in that order. Both calls
are load-bearing: the order fixes the Windows form the ledger keys on, and
canonicalizing the file rather than the directory is the safer direction,
because `resolve_explicit_project_path` follows symlinks by design and returns
the un-canonicalized path — so `OCX_PROJECT=/w/fake/ocx.toml` pointing at
`/attacker/ocx.toml` would otherwise key under a `paths` grant on `/w/fake`.

`$OCX_HOME/state/projects/` is not `$OCX_HOME/projects/`: same key
derivation, opposite lifetimes. The ledger is GC truth tied to installs; this
tree is deletable at any time.

C-022 and A-30 of .claude/artifacts/adr_shell_env_overhaul.md.
…y is gone

`ocx clean` now walks `$OCX_HOME/state/projects/` — the one exception to
`state/` not being GC-walked — and removes `state/projects/<key>/` iff the
consent stamp inside records a `project_dir` that no longer exists. Liveness
is answered by the stamp itself, never by the `projects/` symlink ledger: the
ledger's population rule is strictly narrower than the consent writers' (it
needs a lock save and an `ocx.lock` on disk), so a ledger-derived sweep would
revoke an `[env]`-only project's consent on every run, silently, forever.

Four guards, each retaining on doubt:

- a symlinked or non-directory `state/projects/<key>` is skipped, never
  followed into `remove_dir_all`;
- `.tmp-*` staging names are skipped;
- an indeterminate probe of `project_dir` — any I/O error that is not
  `NotFound` — retains, so a permission flip or an unreachable mount cannot
  make the sweep eat live consent;
- the recorded directory is re-probed immediately before removal, so a project
  recreated since the walk is not swept.

A-31 adds the parse precondition: an unreadable, malformed or unknown-`v`
stamp is retained. "I cannot read it" is not "it is garbage" — an unusable
stamp is already inert at `evaluate`, so collecting it buys nothing, while
under-retention deletes consent a newer or rolled-back binary wrote.

`--dry-run` reports and removes nothing. Every swept stamp is carried in
`CleanResult.consent` and logged at info, so a real `ocx clean` never revokes
consent silently. `--force` waives the project registry, which this sweep
never consults, so its guards stay intact under it.

C-022/C-023, S-033 (tier 1) and A-31 of
.claude/artifacts/adr_shell_env_overhaul.md.
Package metadata was the one env surface with no gate against the
`OCX_*` / `__OCX_*` namespace ocx reserves for its own configuration.
`Var.key` is a plain `String`, and `is_reserved_ocx_key` had three call
sites — project `[env]`, `ocx run --env`, the forwarded `OCX_ENV`
payload — none of them on the composition path.

That is a consent bypass, not a wrong diff: the env channel is additive,
so a publisher inside one already-consented namespace could ship
`OCX_CONSENT_NAMESPACES = "*/*"`, have it composed into the shell at the
next prompt, and inherited by every child process from there.

Write path: `validate_env_reserved_keys` refuses the first offending key
in declaration order, from `validate_for_publish` — so `ocx package
create` and `ocx package push` reject it as `DataError`, exit 65. No new
`ExitCode` variant.

Read path stays permanently compatible: an already-published package
carrying such a key still parses and still resolves, with the key
skipped and warned once per compose.

The read-path gate is at the resolver — `resolve_env_with_attribution`,
which `resolve_env` and `resolve_env_with_patch_boundary` both funnel
through — not at `Env::apply_entries`. `conventions::emit_lines` never
calls `apply_entries`: it dispatches `Entry` straight to `Shell::export_*`
for `ocx env --shell`, `ocx direnv export` and `ocx package env`, all
three of which the caller `eval`s. A gate at `apply_entries` alone leaves
that stream exporting the key verbatim.

Filtered per region rather than once over the finished vector, because
callers read `compose_count` and the overlay's aligned `provenance` as
index ranges into it. The patch-companion overlay is gated too: a
companion is metadata from a different publisher, admitted by a site
rule, so it is the same bypass with one more hop.
A-19 settled one rule for "is this the same PATH element?" — segment-exact
after stripping one surrounding pair of `"`, case-sensitive on Unix and
ASCII-case-insensitive on Windows. The emitted half shipped across ten
shell arms; the in-process half, `utility::path::move_to_front` and
`remove_segment`, still compared raw bytes case-sensitively everywhere and
never stripped the operand's quotes.

On Windows that made the two halves disagree: `Shell::export_path`'s
PowerShell arm folds `C:\Opt\Bin` into `C:\opt\bin`, `move_to_front` kept
both — so the composer added a slot the reconciler could not retire, and
the `C == L.applied` guard compared two different answers.

`same_element` is now the single predicate, selecting its comparison under
`cfg!(windows)` exactly as `env::PATH_SEPARATOR` does, and `remove_segment`
reuses the emitter's own `strip_one_quote_pair` rather than growing a
second copy of the normalisation.

`move_to_front` still prepends its value verbatim: the applier normalises
what it compares against, never what it writes (Invariant L-1).

The parity tests pin each half to the other — they read the emitted
PowerShell line and assert its `StringComparison` agrees with what the
in-process function just did, so changing either one alone goes red.
Implements WP-6 of the shell-environment overhaul (C-024…C-028, S-011,
S-012, S-013, A-25, A-26, A-29, A-30).

`project::consent` ships the stamp (`state/projects/<key>/consent.json`,
written through `write_bytes_atomic`, replaced never edited), the reader,
and the three-clause activation predicate. Activation is permitted iff a
valid stamp covers the lock's source set, or the whole non-empty source
set matches `[shell.consent] namespaces`, or the canonical project
directory is named by `[shell.consent] paths`. Everything else is inert
with an enumerated reason.

A-26 — grants do not stamp. Clause 3 activates unconditionally, every
prompt, writing nothing, so revoking a `paths` grant is immediately
effective. C-027 is therefore a negative contract and its test asserts
`state/projects/<key>/` stays absent.

A-29 — the write seam is a closed allowlist of six commands (`add`,
`remove`, `lock`, `update`, `pull`, `run`) and it is per-caller opt-in.
`load_project_with_lock` has six call sites and only two are members:
`inspect`, `patch freeze`, `ocx env` and `ocx lock --check` share it, so
a blanket stamp there would auto-grant consent on read-only commands.
Two structural guards assert both halves.

A-30 — one project identity: canonicalize the resolved config FILE, then
take its parent, then canonicalize that; a symlinked `ocx.toml` resolves
to its target's directory, which is the stricter outcome.

C-026 — a source is `<registry>/<first path segment>` off the LOGICAL
coordinate the lock records, never a re-derived physical address.
`Identifier::first_path_segment` is the new accessor.

C-033 (the project-tier half) — `[shell]` in an `ocx.toml` is refused by
its own named arm at exit 78, with a message that names `config.toml`,
rather than falling out of `deny_unknown_fields` as "unknown field".
C-049's detection half: DIRENV_DIR naming the resolved project, or
MISE_SHELL / __MISE_ORIG_PATH, read as independent live-session
sentinels rather than a file on disk (C-020), so an .envrc or
mise.toml with the tool not hooked into this shell never suppresses
ocx. Both sentinels fire independently per A-37 -- neither is a
precondition for the other. Returns a typed Yield verdict for WP-11
to act on.
The emitted hook body now spells `--offline` before the subcommand, so the
needle `self activate --reconcile` matched one token late and read
`--offline ` as the text preceding the invocation - the exact shape the guard
exists to reject. Anchor on the full emitted prefix instead.

`--offline` is a root flag on `ContextOptions` and is not declared `global`,
so it can only appear before the subcommand; the needle is therefore exact,
not approximate.
`Verdict` cached exactly one state - a project resolved AND refused by
consent - so `is_stat_only` could only ever fire inside a consent-refused
project. Every `cd` in an ordinary, non-project directory left `verdict ==
None` and paid `Context::try_init` + `resolve_global_pinned_env` + a full
plan to recompose a global tier the fingerprint had already proved
unchanged. That is the overwhelmingly common prompt.

Adds `Verdict::NoProject`, written when the CWD walk resolved nothing. It
is NOT consent-derived - there is no project to consent to - so C-007 is
untouched: `Activate` is still never cached, and the new verdict can only
ever make ocx do less. `project_dir` is already folded into the
fingerprint, so entering any project expires it at the next prompt.

`Outcome` gains `resolved` because `slot: None` conflates two states: no
project found, and a project found that yielded to direnv/mise. Only the
first is cacheable - the yield hangs off a `DIRENV_DIR`/`MISE_SHELL`
sentinel `fingerprint` does not fold, so it must recompose every prompt.

Wire: an additive optional value on an existing field, so per A-04 it
bumps neither `v` nor the envelope tag. A binary predating the variant
fails `Ledger::decode` on `"noproject"` and reads the carrier as absent -
C-006's fail-safe direction, reachable only across a `self update`
mid-session, and the next prompt recomposes from scratch.

Measured, real bash + the real emitted hook, min of three runs each,
paired against a one-line mutation back to the two-way form:

  cd, no project : 21210 us -> 4709 us
  cd, inert proj :  4788 us -> 4754 us  (unchanged, as expected)
  quiet prompt   :    123 us ->  119 us  (unchanged)
… coverage

Four gates in the shell-env edge-case suite reported green in states where a
red was unreachable. Every fix below was demonstrated red on a mutation and
green after restore; the mutations are restored byte-exactly.

T1 [Block] `_parse_register` mis-split three rows on an unbalanced backtick
run, so `Test tier` / `Coverage` held the wrong cell or none, and gate 2
classified EC-QUOTE-008 / EC-QUOTE-012 / EC-VER-006 as non-pytest and skipped
them. `assert len(register) == 223` stayed green because only the cell contents
were garbage. Both halves taken: a per-row structural assertion the count
cannot substitute for (`len(cells) == len(header)`), and CommonMark-correct
delimiters for the three offending rows in the register. Proof: rewriting
EC-QUOTE-008's Coverage to a phantom test was invisible before and reds all
three rows now.

T2 [High] the gates accepted an assertion-free `pytest.skip` body as coverage —
nine rows executed no assertion on any leg, in any CI job, including three that
claim a `rust-unit` tier no Rust test implements. A fourth gate now rejects a
row whose every citing test contains no `ast.Assert` and no `pytest.fail`,
unless the Coverage cell declares the register's own `uncovered` vocabulary;
the nine are marked accordingly and pinned, so a tenth cannot join them
quietly. The gate independently rederived exactly those nine.

T5 [Warn] the Python half of the citation scan split the file on `^(?=def )`,
so a section banner between two functions counted as the preceding function's
coverage. It now uses the function's own AST range, the rule the Rust half
already applied. That immediately exposed two false claims: EC-PROC-011 and
EC-PROC-013 named `test_ec_proc_014_*` as coverage on the strength of a
module-level comment. Struck.

T6 [Warn] `test:shells` mounted the 210-test edge-case module only when a path
check passed, so a rename would have run two modules instead of three and still
exited 0. The mount is unconditional and a missing mount source now aborts the
leg. The guard is a `cmds:` entry, not a `preconditions:` one: `task --force`
skips preconditions.

EC-NU-006's strict xfail is converted to the positive assertion it always
carried, now that the nushell four-way apply dispatch has landed — a strict
xfail against a fixed defect reds on the unexpected pass. Verified on a real
`nu` in the zoo, and shown to discriminate: the old two-way loop yields
`-DGLOBAL`, the shipped one `-DUSER -DGLOBAL`.

`_require` in both pytest modules honours `__OCX_TESTING_REQUIRE_LIVE_SHELLS`,
the Python twin of the Rust seam, so a missing interpreter reds where the image
is supposed to ship it. The zoo run sets it to the six arms both zoo images
carry; pwsh/nu/elvish stay skip-if-absent because the Alpine image omits them
by design.
… reverts

`Scopes.global` recorded what it applied and no priors, and `Ledger::prior`
read the project map alone. So `ocx remove --global <pkg>` retired a global
constant with no operand to restore, and C-006 forbids guess-unsetting one -
ocx's value stayed in the shell for its whole life. Worse, where a project
constant shadowed a global one and both retired in the same prompt, the
project's prior was restored: captured after global applied (C-018), it holds
GLOBAL's value, so the user's own was gone for good.

The spec justified the omission with "the global tier is the user's own file
and is never *left*", which conflates the scope never being exited with a key
never being removed from it. `ocx remove --global` removes keys.

`Scopes.global_priors` is a SIBLING field, not a `priors` member inside
`global`: turning that JSON array into an object would fail every live
carrier's decode, the fleet-wide `priors` loss A-04 exists to forbid. As an
optional additive field it bumps neither `v` nor the envelope tag (A-04),
and it is omitted from the wire when empty, so a shell with no global
constants pays nothing against the 16 KiB cap. A-02's decode-side strip
covers it, so a forged carrier cannot make a revert write a whole `PATH`.

`Ledger::prior` chains rather than preferring one map: where a project prior
holds the exact value the global scope recorded as its own constant, it is
global's value and not the user's, so the lookup hops to `global_priors`.
The hop is unreachable while global still declares the key -
`retire_recorded_constant` returns early for a key `desired` still declares -
and a project prior that is not global's recorded value is restored verbatim.

`capture_priors`' `previous` parameter now carries the applied list and the
priors map as a pair, because the two scopes store them differently.
`next_ledger` takes the PRE-global environment and derives the post-global one
itself: C-018's ordering is the difference between the two capture points, and
leaving it in the caller put it somewhere no test of `next_ledger` could see.

W2's two `r1_known_gap_*` characterization tests asserted the broken behaviour
by design; both are flipped to assert the correct one, joined by a guard that
the chain hop fires only on global's own value, an A-02 strip test, both A-04
carrier directions, and four producer-side tests for the capture point.
…n rule

`emittable`'s per-entry predicate was a second copy of `shell::is_emittable`,
which E6 introduced precisely so the reconciler and `conventions::emit_lines`
could not drift - the export path had no admission rule at all, and a
`type = "path"` value embedding the separator grew PATH without bound on ksh,
dash and pwsh. It now delegates, keeping only the warn-once dedup around it.

A-02's "PATH and PATHEXT are never constant-kind" stays reconciler-local and
deliberately so: it is a revert-shaped rule, not an emit-shaped one. Every arm
can emit `export PATH=...`; the refusal exists because a constant claim on
those two keys makes the whole variable ocx's to overwrite, and only the ledger
has that stake.

Three stale comments alongside it:

- The EC-QUOTE-014 doc named `escape_posix_single_quoted`, which E8 moved to
  `shell/escape.rs` as `posix_single_quoted`.
- The C-010/A-10 doc recorded A-10's second half as a deliberate omission
  ("the gate is here rather than at the `[env]` parse boundary"). E7
  implemented it: `parse_env_value` now refuses a path-kind value embedding
  the separator with `EnvPathSeparatorInValue` at exit 65. "Independently" is
  the addendum's own word - neither gate stands in for the other.
- `test/taskfile.yml` step 3 said the C-044 injection is "deliberately NOT an
  extra ConfigLoader pass". P1/T4 moved it in-binary onto the hook path
  (`shell::hook::registration`, `__testing`-gated), which is what makes a gate
  pointed at a hookless command fail the `--expect-fail` run.
E4 fixed the segment side of the pwsh comparison - `-replace` strips one
surrounding pair of `"` on Windows only, matching `std::env::split_paths`.
It left the operand side raw in both halves, so on Windows the comparison was
asymmetric and neither half ever recognised the quoted value it had itself
written a prompt earlier: `$env:PATH` and the in-process `PATH` each grew by
one copy per prompt, without bound.

`export_path`'s pwsh arm now runs `$__ocx_p` through the same
`path_segment_normalisation()` as `$_`, so the comparison is symmetric by
construction rather than by two literals agreeing. `$__ocx_p` itself stays
raw, so the prepend is still byte-exact.

`utility::path::move_to_front` gets the matching `comparison_operand`, gated
on `cfg!(windows)` for the same reason the case fold is. `remove_segment`
keeps its unconditional strip and that is not an inconsistency: it never
prepends, so it has no written copy for a stripped operand to stop matching -
which is also why the gate is not shared through `same_element`, where it
would take a second pair off `remove_segment`'s already-stripped operand.

The behaviour is Windows-only and therefore invisible on a Unix host, which is
how the residual survived E4. Both mutations are still caught here: making the
strip unconditional reds three tests, and deleting the call leaves
`comparison_operand` unused, which `-D warnings` reds. On a Windows runner the
two `cfg!(windows)`-parameterised tests red behaviourally.
The gate's `--expect-fail` step injects a delay consumed inside
`shell::hook::registration`, which is compiled out unless `ocx/__testing`
is on. Both latency legs were pointed at a release-clean artifact, so the
injection landed nowhere and the step could not go red — it failed for
exactly that reason on `debian-shells`, which is the check working.

Build a second artifact of the same target with `features: ocx/__testing`
and let nothing but C-044 consume it. The shell-zoo matrix keeps the
release-clean binary: its claim is "the shipped binary activates in every
shell", which a binary carrying compile-gated test seams cannot make. Both
builds share one job and one target dir, so the second recompiles only
`ocx_lib` and `ocx` — the feature reaches no third-party crate.

`shell-activation-deep.yml`'s macOS leg carried the identical defect and
gets the same split. `build-rust` grows an `artifact_suffix` input because
artifact names are unique per run and two same-named uploads collide; it
suffixes the emitted filename too, so a job building one target twice
cannot have step order decide which binary a later step runs.

`.github/actions/build-rust/action.yml` joins the PR path filter — the
composite action now decides which binary carries the feature, so a change
there has to run this workflow.

Verified locally on both binaries, same command, one variable changed:
release-clean stayed at 0.580 ms under an 8 ms injection and reproduced
the CI error verbatim (task exit 201); `__testing` rose to 9.056 ms and
the gate failed as required (exit 0). actionlint proven red on a mutated
`matrix.image` and green after restore.
…y prompt

`apply_set` emitted every path and list entry on every fire, on the stated
grounds that those applications are idempotent. They are — but not free: the
emitted form is a `while`-loop of in-shell string surgery over the user's whole
PATH, measured at 1.3 ms per prompt for six entries over a 46-segment PATH, and
it scales with (entries x segments). The reconciler therefore had no fixed
point: a prompt with nothing changed re-applied everything, forever, and the
summary line claimed a change on every fire.

Path and list keys now settle against the live environment, per key and
all-or-nothing: fold the desired entries into a copy of `current` with
`Env::apply_entries` — the same move_to_front/append_unique the emitted arms
are contracted to equal byte for byte — and drop the key's entries where nothing
moved. Constants keep their ledger comparison; settling one from the live value
would re-assert ocx's value over a mid-session override.

C-015 gains rule 0 in the design spec and Decision 3 is amended in the ADR: the
plan now depends on the live environment for path kinds, which costs the
lost-ledger repair nothing (that path reads `current` directly).

The same change closes C-044's unmet half. The per-prompt reconcile Δ was
stated at 2 ms, shipped at ~16 ms, and demoted to a CI `::warning` nobody could
action. Root cause is not the reconciler: every command that constructs the
package manager pays the same (`ocx --global env` measures within a millisecond
of it). Measured on the applying path — 14.3 ms on a 4-cpu GitHub runner,
16.7 ms on a WSL2 box — C-044's reconcile Δ is restated at 25 ms and the warning
is promoted to an assert. Its red state is a fault injection in
`hook::checkpoint`, which only `--reconcile` emits, and `--expect-fail-gate`
makes each injected run name the gate it must red so one red cannot stand in for
another. The latency arena now builds a consented `[env]`-only project: it was
measuring a 95-byte stream from an inert one, which made the shell-side eval
figure decorative and would have let the new fixed-point gate pass vacuously.

Closes #342
Closes #340
…run it

The hook's `auto` rung read `stderr().is_terminal()`, and every shipped shim
invokes `self activate` inside a command substitution with `2>/dev/null`. So
the probe answered false in every real shell and no shell ever registered a
prompt hook through the install path — measured against the Debian shim, 0 hook
lines with the redirect and 5 without. Nothing caught it because every test
that drove the hook passed `--hook` explicitly; none drove the shim. The signal
is now stdin, the one descriptor no shim redirects, with stderr kept as an
`or` for a direct invocation at a prompt. It stays a signal, never the
decision: `--no-hook`, `OCX_NO_HOOK` and `[shell] hook` still outrank it, which
is why the shims must not paper over it by passing `--hook`.

Elvish gains the per-prompt hook it was recorded as being unable to have. The
premise was false — `$edit:before-readline` is documented with a safe-append
idiom and direnv ships a hook on it. What elvish genuinely lacks is a clock:
0.21's `os:stat` exposes no timestamp and there is no time module, so the guard
carries no watch-set term and is carrier-and-`$pwd` only. The wrapper
compensates by clearing the recorded directory rather than calling the guard —
with no watch term a call could only reconcile unconditionally, the 14.4x tax
on every read-only ocx command that C-045's guard reuse exists to avoid. Named
residual: an `ocx.toml` edited in place with no `cd` and no ocx command
reconciles at the next `cd` or the next ocx invocation. Registration and
wrapper both ride inside `eval` of a string, because `edit:` is interactive-only
and elvish resolves a chunk's variables before executing any of it.

That gave elvish an `ocx` wrapper, which turned its pinned C-045 exemption into
the defect it was exempted from: `has-external ocx` is a name lookup, and every
shim re-runs the activation stream on each shell start, so a second source
would have found the wrapper and captured its output into the env stream. The
elvish global-env line now probes `?(test -x '<path>')` and calls the resolved
absolute binary; its strict xfail is unpinned. The nushell one stays pinned.

Closes #341
A `[shell.consent] namespaces` grant has exactly one bound: for a clone's lock
to buy activation, someone must have published into a namespace the operator
listed, which needs that namespace's publish credential. `<host>/*` — and a
bare `<host>`, which meant the same thing at source granularity — voids that
bound on any host where anyone can register, and ocx cannot tell an open
registry from a closed one. The ADR stated the bound as the residual's whole
justification while shipping the spelling that makes it false.

Both spellings are now refused at parse, as one `WholeRegistry` error class so
neither becomes the way to spell what the other no longer says. An operator who
trusts a whole private registry lists its organizations, or uses a `paths`
grant. Consequence, stated rather than discovered: `exclude` no longer subtracts
from a wide grant — it withdraws an organization another tier included, which
is what accumulate's exclusion-wins rule is for.

This is the narrowing available without leaving the PR's scope. The residual
underneath it is unchanged and stays open on #344: the lock is a claim, not
evidence, and nothing binds a locally materialized package to the repository
the lock names it under. Requiring materialization before clause 2 grants was
considered and rejected as a no-op — composition is offline and local-only, so
an unmaterialized source contributes nothing to the environment either way, and
the restriction would change only the reported verdict.

Refs #344
…ble on Windows

settled_keys compared whole OsStrings byte-exact, but the fold it runs retains
segments that came out of std::env::split_paths, which unquotes on Windows. A
PATH carrying a quoted segment therefore never compared equal, the key never
settled, and every prompt re-emitted it — #342 fixed on Unix and shipped
unfixed on Windows. The compare now goes through element_eq, the module's own
A-019 rule, rather than a second normaliser.

Also covers the constants guard in settled_keys, which no test reached: the
empty-candidates early return meant deleting it left every test green.
…e config file

A refused consent pattern propagated a serde error to the whole Config, so a
discovered tier failed every ocx invocation and a managed payload was discarded
wholesale — dropping [registries], [mirrors] and [[trust.policy]] fleet-wide
under required = false. A commit whose subject was a narrowing widened the
effective posture.

The loader now re-parses with [shell.consent] removed and records a
consent_strip_reason, following guard_managed_shell_consent's precedent: the
grant disappears (fail closed) while the rest of the file survives. The strip is
narrow — only [shell.consent], and only when removing it is what makes the file
parse.
…le in a nested shell

Registration idempotency was keyed on the pid. exec elvish replaces the process
image but keeps the pid, so the fresh shell read its own pid back out of the
marker and ran unhooked for its whole life. It now scans $edit:before-readline
for a sentinel, which a new process image starts empty; __OCX_ENV_HOOK is gone.

__OCX_ENV_PWD was exported, so a nested elvish inherited a matching value and
its first prompt was silent — the only arm where that happened, and the one that
carries A-21's deferred messages. It now records a pid-qualified composite.

Also: the escapers move to shell::escape, whose consolidation had already landed;
the mktemp stamp gains an exit cleanup per arm; and the elvish arm gains the
exact-text pins, the checkpoint test and the quadrupled-quote case its four
siblings already had.
…robing a redirected descriptor

No descriptor answers this from inside the binary, in either direction. Every
shim runs the activation with stderr redirected, so a stderr probe read false in
every real shell. stdin is wrong the other way: ssh -t host 'bash -lc ...'
allocates a pty for a shell that never renders a prompt.

A hidden --interactive/--no-interactive pair now carries the answer each shim
already computes — case "$-" on POSIX, status is-interactive on fish,
[Console]::IsInputRedirected on pwsh, test -t 0 on elvish. It feeds rung 5's
input and is never a rung, so OCX_NO_HOOK and [shell] hook keep their standing.
The binary-side probe remains the fallback for a direct invocation and for a
shim an older self setup wrote.

pwsh could not use [Environment]::UserInteractive: it is hardcoded true on .NET
for Unix, so every script, CI step and ssh host pwsh -Command would have
declared itself interactive.
… directories every command

detect_and_cache ran in Context::try_init on every invocation that builds a
Context, and its scan made ~7,800 per-entry tokio::fs::file_type().await hops —
each an asyncify round-trip for a syscall-free d_type read. Measured at 15.4 ms,
on a path whose result does not change a byte of any emitted stream.

The scan is now one spawn_blocking over std::fs, the scan roots are
canonical-deduped so a usrmerge host walks two trees rather than four, and the
result persists at $OCX_HOME/state/host/capabilities.json beside the referrers
and trust-root caches. The record carries every loader path that classified
positive and is honoured only while all of them still exist, so removing a libc
invalidates it on the next invocation rather than on a clock.

Per-prompt reconcile delta: 17.9 ms -> 1.0 ms. Emitted stream byte-identical.
…ompt gate

The pty helper fed lines on a wall-clock guess, so under parallel load a line
landed while the pty was still canonical and two inputs merged — a flaky gate,
which is worse than no gate. It now feeds a line only once the previous one went
quiet and a line editor owns the pty, read from the line discipline itself.

Driving ptys through script(1) is gone: neither shell-zoo image ships it, so
three tests — including the regression gate for the hook that never registered —
skipped silently and CI stayed green. Every interpreter lookup in the activation
module now routes through the REQUIRE_LIVE_SHELLS seam, and the registration
gate covers bash, zsh, fish and pwsh rather than bash alone.

The latency gate asserts C-044's original 2 ms again, now that the 16 ms it was
amended for turned out to be the libc scan. Its own vacuous states are closed:
the None branch that made a skipped measurement pass by default is deleted,
--expect-fail-gate's red state is covered, the self-check tally is derived, and
the startup positive control gets a measured floor — > 0 passed a same-work
control in six of eight reps.
…hook's real residual

C-044's 25 ms amendment is withdrawn: the cost was never the reconciler, so the
original 2 ms budget stands and the record says why the first diagnosis was
wrong. C-019, C-044 and C-045/A-35 gain the elvish carve-outs the arm has always
needed, C-038 states the interactivity pair, and S-043 stops promising a consent
spelling that no longer parses.

The elvish residual was stated as a hand-edited ocx.toml; it is any watch-set
change not caused by an ocx command in this shell and not accompanied by a cd —
another shell's ocx add --global, a git checkout that rewrites ocx.lock, a
config-tier grant, a managed-snapshot refresh.
…ag pair

The reference pages still listed four hooked shells, described interactivity as
a stderr probe, and documented an __OCX_ENV_HOOK that no longer exists.
__OCX_ENV_PWD now holds a pid-qualified composite, which is what makes a nested
elvish reconcile on its first prompt.
… ocx lookup

The reference documented `if command -v ocx >/dev/null 2>&1; then eval "$(ocx
--global env ...)"; fi`, a $PATH name lookup that a shell function or an
earlier PATH entry could shadow — the exact shape C-045 forbids. Every arm but
nushell guards on a path test against the resolved absolute binary, and nushell
probes by name only because it applies the global env as JSON data rather than
evaluating a string.
… about

Two guards keyed on a substring of the thing they were guarding.

Elvish scanned `to-string $edit:before-readline` for a bare sentinel. That
renders every closure's &def and the full &src of its defining eval unit into
one string, so a user closure, comment or string literal merely *mentioning*
the marker made the condition false and left the shell with no hook at all.
The probe now reads each element's `arg-names` — a parsed declaration, which
no comment or string value can forge — and the closure carries the marker as
its own rest parameter, so the marker cannot outlive the registration.

bash and zsh probed `command -v __ocx_prompt_hook`, which also resolves $PATH
executables. A file of that name on $PATH read as 'already registered' and left
the shell unhooked — and the wrapper's next word *runs* what it found, so it
was executed after every ocx command. All four sites now use `typeset -f`,
a builtin in both, matching shell functions only.

Audited every remaining arm: zsh's add-zsh-hook array match, fish's
functions -q, and PowerShell's variable- and function-namespace tests are all
scoped already, verified rather than assumed.
The record carried os_features and loaders as two independent lists, so
{os_features: ["libc.musl"], loaders: []} passed every check — an empty list
makes 'all recorded loaders still exist' vacuously true — and OCX then selected
artifacts the host cannot execute. Measured on a glibc-only host: the forged
record was honoured and 'ocx version --verbose' reported libc.musl.

os_features is gone. The feature set is derived from the recorded loaders, each
carrying the family it classified, so the claim and its evidence cannot disagree.
An empty list is a valid recorded answer that now claims nothing.

Existence was also too weak a freshness test: replacing a loader in place keeps
the path, so a container-layer swap or a package reinstall left the family
trusted for the rest of the hour. Each loader now records (device, inode, size,
mtime) — the read path already paid one stat per loader, so this costs nothing.
Content hashing was measured and rejected at 0.41 ms per loader against the
2.14 ms the record saves.

The 0o600 file in the user's own OCX_HOME is not a trust boundary and the module
now says so; deny_unknown_fields and the version bump exist so the reader accepts
only records this writer could produce.
…under contention

The gate forced --hook, so it drove rung 2 and never the auto rung the
interactivity work changed — the same shape as the outage this branch exists to
fix, where every test passed --hook and none drove the shipped path. The
measured argv is now recorded from the shipped env.sh rather than written down:
'self activate --shell=bash --no-completion --interactive', rung 5. Proven by
mutating Interactive::resolve and, separately, the shim's emission, with --hook
left working: the new gate reds on both, the previous one exits 0.

A hard 2 ms budget also reds on a loaded runner without any regression, which
trains people to re-run until green. The floor's own p90-minus-min now
classifies an over-budget delta: tighter than the delta means FAIL, wider means
no verdict. The classifier reads only the floor series, and injected delay never
lands there, so a real regression still fails on any measurable machine.
Calibrated over 41 runs; the budget itself is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: native ocx shell hook for local project toolchain (direnv-free)

1 participant