feat(shell): keep the environment current at every prompt, without direnv - #339
Open
michael-herwig wants to merge 130 commits into
Open
feat(shell): keep the environment current at every prompt, without direnv#339michael-herwig wants to merge 130 commits into
michael-herwig wants to merge 130 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces direnv with a native per-prompt
ocxshell 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.mdin full. Closes #170. Subsumes #265 (theunsetdirective falls out of the constant-kind state machine).What lands
__OCX_ENV_STATEledger carries current/desired/last state; the reconciler drives the shell toward the composed environment on entering a project, leaving it, switching projects, and afterocx add --global/update/removein another process — no restart, nodirenv reload.config.toml(user configuration, neverocx.toml), with managed-tier and env-var channels for fleets and devcontainers.--hook/--no-hookon bothocx self setupandocx self activate, plusOCX_NO_HOOK, mirroring the shipped completions ladder.[shell] hookand[shell] completionsdecide when no flag is given.ocx shell state. Read-only introspection, never eval-able, with enumerated reasons for why the integration is inert.cdinto a project did not reconcile at all.Testing
analysis_shell_env_edge_cases.md, each with an exact reproduction and a tier.sh,dash,ash,bash,zsh,fish,pwsh,nushell,elvish) plus the Windows PowerShell 5.1 / 7 legs.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.