Skip to content

gate: PATH-executable wrappers via centralized safe gate — gating in every shell - #30

Open
superbiche wants to merge 7 commits into
feat/version-aware-verdict-corefrom
feat/gate-path-wrappers
Open

gate: PATH-executable wrappers via centralized safe gate — gating in every shell#30
superbiche wants to merge 7 commits into
feat/version-aware-verdict-corefrom
feat/gate-path-wrappers

Conversation

@superbiche

Copy link
Copy Markdown
Owner

Stacked on #29 (version-aware verdict core). Part 2 of the safe overhaul.

Problem

Install gating was zsh-function-only: bash -c 'npm i evil', Makefile recipes, CI steps, child processes, and mise exec all resolved straight to the mise shims with no audit. The only non-interactive coverage was the npx/uvx/bunx symlinks.

Change

  • lib/gate-lib.sh (new, ~1650 lines): faithful bash port of the adversarially-reviewed zsh routing — leading-global-flag resolution, per-tool flag tables, exec gating (npm exec/x, pnpm dlx, yarn dlx, bun x, uv run --with, uv tool run, go run mod@ver) with the node_modules/.bin parent walk, project-scan preflight, gate exit mapping, host-allow/install-known fallbacks, target-selector threading from the audit: version-aware verdicts + install gate (fixes the CVE-remediation catch-22) #29 fix round.
  • safe gate <tool> -- <args…> entrypoint in bin/safe; marker-tagged 3-line wrappers in ~/.local/bin for npm pnpm pnpx yarn bun pip pip3 uv cargo go composer (written by install.sh, which refuses to overwrite unmarked files; uninstall.sh removes marked ones only). The delegate is the first non-wrapper executable on PATH, so mise per-project versions keep working. No env re-entry guard: recursion cannot loop by construction, and child processes that install should be gated — an env switch would be both a forgeable bypass and a coverage hole.
  • zsh wrappers retire to a stub that warns once interactively if gating is absent; degraded-mode guards are obsolete (an executable cannot be half-loaded). safe status probes the wrappers (installed/shadowed/not installed) from any shell.
  • Volta retirement: dead shim-backup resolution deleted from bin/safe-run; safe run link clears stale Volta runner paths; host-allow exec resolves the runner via mise which at exec time (never goes stale in config). Closes inbox 2026-07-13-codex-acp-retire-volta-safe-integration.md.
  • Deliberate deltas (documented in CHANGELOG): --version/--help switches pass through (previously exit 100 — fatal as a PATH wrapper); missing-safe-audit warning is per command, not per shell.

Verification

  • 88/88 differential parity harness against the old zsh implementation (exit codes + exact audit/real command logs) across leading-flag bypasses, exec selectors, post-positional --package, uv --with boundaries, go run value flags, alias/colon specs.
  • Suites: install 69/69 (gate mode; degraded zsh-snapshot cases retired, wrapper install/uninstall/pnpx/perf cases added), version-aware audit 50/50, dispatcher 15/15, safe-run integration 31/31.
  • Passthrough overhead: 16ms for npm --version through wrapper → gate → exec, zero audit spawns (asserted by a perf case).

Residual (accepted, doctor-visible): absolute-path invocation of shims and PATH manipulation still bypass — the gate is a seatbelt for cooperative-but-unaware processes, not a jail. mise install/use gating lands in part 3.


@superbiche · maintainer · drafted with Claude Fable 5.

https://claude.ai/code/session_01Rk1wZEXbFB23TamMZYuRi6

…n every shell

Install gating was zsh-function-only: bash -c, Makefiles, CI steps, and
child processes resolved straight to the version-manager shims, unaudited.

- lib/gate-lib.sh (new): faithful bash port of the reviewed zsh routing —
  leading-global-flag resolution, per-tool flag tables, exec gating with
  node_modules/.bin parent walk, project-scan preflight, audit gate exit
  mapping, host-allow/install-known fallbacks. 88/88 differential parity
  against the old zsh implementation (exit codes + exact audit/real logs).
- bin/safe: `safe gate <tool> -- <args…>` entrypoint; wrapper-probing
  `safe status`; SAFE_AUDIT_PATH honors the environment.
- install.sh writes marker-tagged 3-line wrappers to ~/.local/bin for
  npm pnpm pnpx yarn bun pip pip3 uv cargo go composer (refuses to overwrite
  unmarked files); uninstall.sh removes marker-matched wrappers only.
- lib/install-wrappers.zsh thins to a stub (no functions) that warns once
  interactively when gating is absent. Degraded-mode guards are obsolete:
  an executable cannot be half-loaded.
- No env re-entry guard: recursion cannot loop (delegate resolution skips
  wrapper-marked files), and child processes that install SHOULD be gated —
  an env switch would be a forgeable bypass and a coverage hole.
- Volta retirement: dead shim-backup resolution deleted from bin/safe-run;
  `safe run link` clears stale Volta runner paths; host-allow exec falls
  back to `mise which` at exec time (never goes stale in config); volta()
  wrapper gone; docs updated. Closes inbox 2026-07-13 volta note.
- Deliberate behavior deltas (documented): --version/--help switches pass
  through in all wrapped tools (previously exit 100 — fatal as a PATH
  wrapper); the missing-safe-audit warning is per command, not per shell.
- tests/install/run.sh repointed to gate mode: 69 cases green (degraded
  zsh-snapshot cases retired, wrapper install/uninstall/pnpx/perf cases
  added); passthrough wall time 16ms with zero audit spawns asserted.

Co-implemented with a subagent; differential parity harness in tmp/.

Claude-Session: https://claude.ai/code/session_01Rk1wZEXbFB23TamMZYuRi6
The bash port predated the review fix round on the verdict-core branch;
bring gate-lib to parity with the fixed zsh logic it replaced:

- safe_gate_scan_target_flags (per tool family) threads --tag/--registry/
  --prefix/--cwd, python index flags, cargo --registry/--index, and composer
  --repository/--working-dir into the audit as --dist-tag/--registry/
  --project-dir (finding 3); yarn --cwd now reaches resolution.
- cargo install --version/--vers threads the pin into the audited spec
  (finding 7).
- The install-known offline fallback only accepts verdict-GO evidence
  (finding 5).

Suites: install 69/69, audit 50/50, dispatcher 15/15, safe-run 31/31.

Claude-Session: https://claude.ai/code/session_01Rk1wZEXbFB23TamMZYuRi6
…l-set probes, constrained mise, exact markers

Review verdict on 944045f: DO-NOT-SHIP 4 (+1 major, +1 minor). All closed:

- F1 uv index selectors reach the audit: safe_gate_uv scans target flags
  (like pip_like already did), and -i/--index/--default-index are value
  flags in the python extractor — dropping the value made the URL look like
  a package spec and FAIL-OPENED the extractor into an unaudited delegate.
  Sources accumulate (delta-2 parity): one untrusted --find-links floors
  the verdict even beside a trusted index; the stale reader is
  source-scoped and canonicalizes ecosystem keys.
- F2 one upgrade unit: install.sh refreshes ~/.config/safe/gate-lib.sh in
  EVERY mode that refreshes the safe binary whenever an installed copy or
  marked wrappers exist — library first, so a new dispatcher is never
  paired with an old library. Regression: stale-sentinel library +
  `install.sh --run` must end byte-identical to the repo library.
- F3 full-set health probes: safe status and safe doctor --json report
  per-tool wrapper state (installed/shadowed/missing/foreign) over the
  whole tool list; the aggregate is healthy only when every tool resolves
  to its owned wrapper; the zsh stub warns on the full set too.
- F4 constrained mise fallback: host_runner_binary resolves via
  `mise which <bin> --tool=<expected>` (npx→node, bunx→bun, uvx→uv) and
  validates the resolved path lives under installs/<expected>/ — a backend
  whose bin set ships a colliding npx can never be host-executed. safe run
  status shows the exec-time fallback instead of "not linked".
- F5 exact ownership: install/uninstall/status/stub accept only a REGULAR
  file whose second line is exactly `# safe-gate-wrapper v1 tool=<tool>`;
  marked symlinks and marker-substring foreign files are never overwritten,
  deleted, or counted as ours. (Delegate resolution keeps the broad marker
  skip: recursion safety must win there.)
- F6 docs sweep: index/architecture/command-reference/installation/
  integration-flows and safe-run help no longer describe the retired
  zsh-function topology; --no-wrappers semantics documented.

Suite: 73/73 (new: selective-install refresh, loose-marker ownership,
full-set status/doctor probes, uv index selectors incl. =-forms).

Claude-Session: https://claude.ai/code/session_01Rk1wZEXbFB23TamMZYuRi6
…t refresh, unresolved wrappers

Rebased onto the PR#29 round-5 head (7410348), which centralizes source
identity behind the `safe audit effective-sources` plumbing command, then
closed the four open delta findings.

F1 (blocker): the gate's offline stale-evidence reader derived its own
`${SAFE_GATE_REGISTRY:-default}` identity and matched receipts on
`(.source // "default")`, while safe-audit stamps every receipt
`implicit-default` or `explicit:<set>`. Every receipt therefore read as
unscoped-default and a custom-index receipt could vouch for a default-index
install on timeout. safe_gate_known_matches now asks safe-audit for the
identity — `effective-sources <name> --ecosystem <eco> [--registry <set>]
[--project-dir <d>]`, resolved through the same audit-binary gate the checks
use so SAFE_AUDIT_PATH is honored — instead of mirroring a derivation that
has to stay byte-identical across two files. No identity (missing binary,
non-zero exit, empty output) fails closed: stale evidence never applies
without a trustworthy match. Receipts compare on
`(.source // "implicit-default")`.

safe_gate_add_source picks up the round-5 accumulator semantics: strip
trailing slashes, and move a REPEATED value to the END rather than dropping
it, because npm is last-wins — `--registry A --registry B --registry A`
installs from A and the resolver's last-word read must agree.

F2 (major): install.sh refreshed the installed gate library only when an npm
wrapper was present, so a damaged installation keeping an owned pnpm wrapper
got a new dispatcher and no library. A gate_wrappers_exist helper scans the
whole GATE_TOOLS set instead.

F3 (blocker): gate_wrapper_state printed `installed` when `command -v <tool>`
resolved to nothing, so a machine whose PATH omits $SAFE_BIN_DIR (cron,
systemd) reported healthy while gating nothing. New `not-on-path` state,
counted unhealthy by both `safe status` and the doctor JSON aggregate.

F6 (minor): docs/integration-flows.md and docs/index.md still presented
sourcing the retired zsh stub as the activation step. Activation is PATH
placement of the generated wrappers, verified with `safe status`; the sourced
file is a compatibility/health-warning stub. Added pnpx to the documented
wrapper family in docs/command-reference.md.

Tests: the writer/reader loop in check_version_aware.sh drives the REAL
producer and the REAL consumer over the receipt it wrote (same-source reuse,
both cross-source directions, env- and .npmrc-derived identity, explicit
npmjs.org normalization, and the literal `--registry default` selector never
colliding with the implicit-default sentinel). The gate-mode suite inherits
round 5's repeated-registry case, which now exercises gate-lib's accumulator
and fails without the last-wins fix. Plus selective-install refresh from a
non-npm wrapper across --run/--audit/--no-wrappers, and a not-on-path case.

Claude-Session: https://claude.ai/code/session_01Rk1wZEXbFB23TamMZYuRi6
…p probes (R1)

Two follow-ups on the round-2 delta fixes, both operator-visibility rather
than gate behavior.

effective-sources failure now says so. The stale-evidence reader fails closed
when it cannot obtain a source identity, but doing that silently is the
invisible-breakage class the operator ruled must surface: reuse simply never
happens and the operator sees only occasional timeout refusals. Both failure
modes (no resolvable safe-audit, and a non-zero or empty effective-sources)
now emit `safe gate: effective-sources unavailable — stale-evidence reuse
disabled, run safe doctor` on stderr. The fail-closed outcome is unchanged —
the reader still returns 1 and the caller still refuses. bin/safe's
safe_install_known_matches deliberately stays byte-identical to the PR#29
reference; the note lives in gate-lib only, which is the daily surface.

R1: the ownership probe no longer narrates about binary neighbours. All four
call sites (bin/safe, install.sh, uninstall.sh, and the zsh stub) captured the
second line of an arbitrary file through command substitution, so a foreign
ELF at a wrapped-tool path made bash print "ignored null byte in input" from
status, doctor, and install. The exact whole-line comparison now runs as a
byte-stream pipeline (LC_ALL=C sed -n 2p | grep -qxF) with no capture: same
verdict on every input, no warning on any of them.

Tests: the loose-marker ownership case grows a NUL-carrying binary at a
wrapped-tool path and asserts it reads as `foreign` in both `safe status` and
the doctor JSON, that it survives install and uninstall untouched, and that
install, status, and doctor all stay quiet about it. The writer/reader loop
gains a trailing-slash selector proving writer and reader canonicalize a
source element identically (round 5 strips trailing slashes per element).

Claude-Session: https://claude.ai/code/session_01Rk1wZEXbFB23TamMZYuRi6
Rebased onto the shipped PR#29 head (a253c52) and carried the scanner
changes from rounds 6-10 into the bash gate. Reference for every item is
`a253c52:lib/install-wrappers.zsh` (the zsh scanner gate-lib was ported
from) and `a253c52:bin/safe`.

- Scoped selectors thread as KEYED tokens: `--@scope:registry URL` and
  `--@scope:registry=URL` accumulate `@scope:registry=URL`, not the bare
  URL. Flattening lost the key and let the resolver pick a different
  scope's registry (delta-6 3.2b). `--@*:registry` also joins the npm
  package-parser value table so the URL is never read as a package.
- `--userconfig`/`--globalconfig` thread as PATHS
  (SAFE_GATE_NPM_USERCONFIG/GLOBALCONFIG -> `--npm-userconfig` /
  `--npm-globalconfig`) on BOTH the check invocation and the
  effective-sources reader call. The gate never opens a config file:
  extracting values lost key and precedence semantics and copied
  credentials into argv (delta-7 3.2b/N2).
- `--find-links`/`-f` records its actual endpoint instead of the
  `local:find-links` sentinel, so an operator can trust one location
  rather than every local source (delta-5 3.2, operator-ruled). `--no-index`
  keeps its sentinel: a boolean switch has no endpoint to record.
- `safe_gate_add_source` stays operational — no redaction. Credentials are
  needed for authenticated packument fetches; redaction is central in
  safe-audit at every identity/receipt/display sink (delta-6 N2 reverted the
  wrapper-side strip).
- The R1 ownership probes anchor their path argument (`sed -n '2p' --`), the
  same dash-leading-filename class round 9 fixed audit-side.

Tests: the merged suite's scoped/userconfig, credentials-preserved,
find-links-endpoint and repeated-registry cases now exercise gate-lib and
pass unchanged. `case_wrapper_state_cleared_after_gate` asserted that a
long-lived shell clears the scanned set — structurally unfalsifiable in gate
mode, where the scan lives in a process that exits, so it was passing
vacuously after the merge. Replaced with the equivalent live risk on this
branch: the credential-bearing set must not reach the DELEGATED tool's
environment, detected by a new ENVLEAK line in the tool stub (verified to
fire when the variable is present). My audit-suite sections re-splice as
62-64 behind the new case 61.

Claude-Session: https://claude.ai/code/session_01Rk1wZEXbFB23TamMZYuRi6
@superbiche
superbiche force-pushed the feat/gate-path-wrappers branch from a1edd2a to efa4093 Compare July 31, 2026 17:14
…ne, anchored readlink

- F6: docs/install-wrappers.md lists the five actual per-tool states
  (installed/shadowed/not-on-path/missing/foreign) instead of the retired
  three-token vocabulary.
- P2: safe_gate_exec_real de-exports all five SAFE_GATE_* scanner globals
  before the delegate exec — bash assignment preserves a caller's export
  attribute, which would have carried the credential-bearing operational
  set into the delegated tool's environment. The ENVLEAK stub detector now
  sweeps all five names and the case pre-exports sentinels on both routes,
  asserting the audit sees scanned values, the tool none, and the calling
  shell keeps its original sentinel.
- N2: gate_wrapper_state anchors both readlink operands (--) and requires
  BOTH canonicalizations to succeed before comparing — a dash-leading
  relative SAFE_BIN_DIR no longer yields empty==empty falsely-installed
  with readlink noise; an unresolvable wrapper path reads not-on-path.
  New host-independent case probes both cwd shapes with clean stderr.

Tests: install suite 81/81, check_version_aware 159/159, others exit 0.

Claude-Session: https://claude.ai/code/session_01Rk1wZEXbFB23TamMZYuRi6
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.

1 participant