diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fbbb75..95f0081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,37 @@ host-allow permits the current invocation, and the stale readers use the canonical ecosystem key (cargo/composer entries are findable again). +- Install gating now works in **every shell**, not just interactive zsh. The + zsh wrapper functions are retired and replaced by executable PATH wrappers + (`~/.local/bin/{npm,pnpm,pnpx,yarn,bun,pip,pip3,uv,cargo,go,composer}`), each + a three-line shim that execs the new `safe gate -- ` + subcommand. Previously `bash -c 'npm i evil'`, a Makefile recipe, a CI step, + or an agent harness bypassed the gate entirely by hitting the version-manager + shim directly. All routing lives in `lib/gate-lib.sh` (installed at + `~/.config/safe/gate-lib.sh`), a faithful bash port of the reviewed zsh + routing tables, so gate upgrades ship with `safe` and the wrappers are never + rewritten. The real tool is the first non-wrapper executable of that name on + PATH, so mise/asdf shims and per-project tool versions keep working. + `install.sh` refuses to overwrite an unmarked file of a wrapped name (it + reports and skips); `uninstall.sh` removes only marked wrappers. + `safe status` and `safe doctor --json` now probe every wrapper in the set + (`installed` / `shadowed` / `not-on-path` / `missing` / `foreign`) instead of + shell function state, and report the set healthy only when every tool + resolves to its own wrapper — `not-on-path` covers the cron/service shape + where the wrapper files exist but `$SAFE_BIN_DIR` is not on PATH, so nothing + is actually gated. + `lib/install-wrappers.zsh` remains, as a stub that defines no wrappers and + warns once in an interactive shell when gating is absent. + Two consequences of the process model: the "safe audit not installed" warning + is now once per command rather than once per shell, and the degraded-mode + guards are gone — an executable wrapper cannot be half-loaded. The Volta + wrapper is retired (no `volta` wrapper is installed). The version/help + switches (`--version`, `-v`/`-V`, `--help`, `-h`) are now classified as the + valueless switches they are, so `npm --version` and friends pass through + instead of failing closed. Volta's dead shim-backup resolution is deleted + from `bin/safe-run`; `safe run link` clears stale Volta runner paths and + host-allow exec resolves the real runner through `mise which` at exec time. + - Orthogonal-review hardening of the version-aware gate (PR#29 findings): version-scoped OSV results are server-authoritative — the local range comparator annotates but can never downgrade a hit to GO; OSV pagination is diff --git a/bin/safe b/bin/safe index f37d8de..a660b90 100755 --- a/bin/safe +++ b/bin/safe @@ -7,7 +7,10 @@ SAFE_DIR="$(dirname "$SAFE_SELF")" SAFE_CONFIG_ROOT="${SAFE_CONFIG_DIR:-$HOME/.config/safe}" SAFE_DATA_ROOT="${SAFE_DATA_DIR:-$HOME/.local/share/safe}" SAFE_RUN_PATH="$SAFE_DIR/safe-run" -SAFE_AUDIT_PATH="$SAFE_DIR/safe-audit" +# Env override so a test harness (or a side-by-side build) can point the +# dispatcher and the install gate at a specific safe-audit. +SAFE_AUDIT_PATH="${SAFE_AUDIT_PATH:-$SAFE_DIR/safe-audit}" +SAFE_BIN_ROOT="${SAFE_BIN_DIR:-$HOME/.local/bin}" SAFE_ZSH_COMPLETION_DIR="${SAFE_ZSH_COMPLETION_DIR:-$HOME/.local/share/zsh/site-functions}" err() { printf 'safe: %s\n' "$*" >&2; } @@ -22,6 +25,7 @@ safe: consolidated safe run, safe audit, and install wrapper tooling USAGE safe run safe audit + safe gate -- safe install [-g|--global] [--yes] [...] safe install --manager npm|pnpm|yarn|bun|composer -g [--yes] [--trust-host] [...] safe install --sandbox [--allow-scripts] [...] @@ -51,19 +55,20 @@ WHAT IS GATED from a project node_modules/.bin (bare unversioned names, current or parent dir), an allowlist, a Podman sandbox, or not at all. - npm / pnpm / yarn / bun / pip / pip3 / uv / cargo / go / composer / volta - zsh wrapper functions audit install, update, and - exec subcommands with `safe audit` before - delegating to the real tool: install/add/ci/require - (and global variants), update/up/upgrade families, - and exec-style fetch-and-run (npm exec|x, pnpm dlx, - yarn dlx, bun x, uv run --with|-w, uv tool run, - go run @). Project-local commands - pass through, including npm exec of a bare-named - installed ./node_modules/.bin tool (versioned specs - are still audited), pnpm exec, composer exec (vendor - binaries only), and volta run (official runtimes - only). + npm / pnpm / pnpx / yarn / bun / pip / pip3 / uv / cargo / go / composer + PATH wrappers (~/.local/bin) exec `safe gate `, + which audits install, update, and exec subcommands + with `safe audit` before delegating to the real tool: + install/add/ci/require (and global variants), + update/up/upgrade families, and exec-style + fetch-and-run (npm exec|x, pnpm dlx, pnpx, yarn dlx, + bun x, uv run --with|-w, uv tool run, + go run @). This applies in every + shell, not just interactive zsh. Project-local + commands pass through, including npm exec of a + bare-named installed ./node_modules/.bin tool + (versioned specs are still audited), pnpm exec, and + composer exec (vendor binaries only). safe install Audited, confirmed install path (multi-manager). HOW A REFUSAL LOOKS @@ -71,7 +76,7 @@ HOW A REFUSAL LOOKS stating what was blocked, why, and the operator command that allows it. EXIT CODES (policy refusals; 127 means a genuinely missing command) - 100 blocked by policy (blocklist, degraded wrappers, fail-closed audit, unrecognized/unsupported runner-native flags) + 100 blocked by policy (blocklist, fail-closed audit, unrecognized/unsupported runner-native flags) 101 host-allow version pin mismatch 102 interactive operator confirmation required (non-TTY refusal) 103 invalid package name rejected @@ -83,9 +88,9 @@ WHAT AN AGENT SHOULD DO verbatim. Trust escalations (safe run host-allow add/update) only work in the operator's interactive terminal — never attempt them yourself. 3. After the operator allows, retry the original command unchanged. - 4. Exit 127 from a wrapped tool means a genuinely missing command, never - a policy block — partial wrapper loads refuse legibly with exit 100. - A silent 127 would be a safe bug: report it, don't bypass. + 4. Exit 127 from a wrapped tool means a genuinely missing command (or a + `safe` missing from PATH), never a policy block — the gate always + refuses legibly with exit 100. Report a silent 127, don't bypass. OPERATOR ALLOW FLOWS safe run host-allow add @ --reason "..." trusted host exec (npm) @@ -95,12 +100,128 @@ OPERATOR ALLOW FLOWS EOF } +cmd_gate_usage() { + cat <<'EOF' +usage: safe gate -- + +Audits the install, update, and exec-style subcommands of with +`safe audit`, then delegates to the real on PATH. Called by the +generated wrappers in ~/.local/bin (see install.sh); not normally run by hand. + +Supported tools: npm pnpm pnpx yarn bun pip pip3 uv cargo go composer +EOF +} + +# Installed layout keeps the library beside the other safe config +# (~/.config/safe/gate-lib.sh); a dev checkout runs it straight from lib/. +gate_lib_path() { + local candidate + for candidate in \ + "${SAFE_GATE_LIB:-}" \ + "$SAFE_DIR/../lib/gate-lib.sh" \ + "$SAFE_CONFIG_ROOT/gate-lib.sh"; do + [[ -n "$candidate" && -r "$candidate" ]] || continue + printf '%s' "$candidate" + return 0 + done + return 1 +} + +cmd_gate() { + local tool="${1:-}" + + case "$tool" in + ""|-h|--help) + cmd_gate_usage + return 0 + ;; + esac + shift + + if [[ "${1:-}" == "--" ]]; then + shift + fi + + local lib + # Fail closed: without the routing tables nothing can vouch for the command. + lib="$(gate_lib_path)" || refuse 100 "BLOCKED ${tool} — safe gate library not found (looked beside safe and in $SAFE_CONFIG_ROOT); to allow: ask the operator to re-run safe's install.sh; details: safe explain" + + # shellcheck source=/dev/null + source "$lib" + + local rc=0 + set +e + safe_gate_main "$tool" "$@" + rc=$? + set -e + exit "$rc" +} + cmd_version() { echo "safe $SAFE_VERSION" "$SAFE_RUN_PATH" version "$SAFE_AUDIT_PATH" --version } +# Keep in sync with GATE_TOOLS in install.sh and the gate-lib routing table. +SAFE_GATE_TOOL_LIST=(npm pnpm pnpx yarn bun pip pip3 uv cargo go composer) + +# A generated install wrapper: regular (never symlinked) file whose second +# line is EXACTLY the per-tool marker (loose substring matching let foreign +# files pass as ours — PR#30 review finding 5). +gate_wrapper_marked() { + local path="$1" tool="$2" + [[ -f "$path" && ! -L "$path" ]] || return 1 + # Byte-stream comparison, never a command substitution: these paths hold + # arbitrary foreign executables, and capturing an ELF second line into a + # shell variable made bash emit "ignored null byte in input" warnings from + # status/doctor/install (PR#30 round regression R1). Same exact whole-line + # test, no capture. + LC_ALL=C sed -n '2p' -- "$path" 2>/dev/null \ + | LC_ALL=C grep -qxF -- "# safe-gate-wrapper v1 tool=${tool}" +} + +# Per-tool wrapper state: installed | shadowed | not-on-path | missing | foreign. +# The wrapper set is healthy only when EVERY tool resolves to its owned +# wrapper — one ungated tool defeats the seatbelt (PR#30 review finding 3). +# An owned wrapper that resolves to NOTHING is not installed in any useful +# sense: invoked from a cron/service PATH without $SAFE_BIN_ROOT the file +# exists but gates nothing, and reporting it as installed made doctor claim +# health for a fully ungated machine (PR#30 delta finding 3). +gate_wrapper_state() { + local tool="$1" wrapper="$SAFE_BIN_ROOT/$1" resolved="" + if [[ -e "$wrapper" || -L "$wrapper" ]]; then + if ! gate_wrapper_marked "$wrapper" "$tool"; then + printf 'foreign' + return 0 + fi + else + printf 'missing' + return 0 + fi + resolved="$(command -v "$tool" 2>/dev/null || true)" + if [[ -z "$resolved" ]]; then + printf 'not-on-path' + return 0 + fi + # Anchor both operands (SAFE_BIN_DIR is operator-settable and a relative + # dash-leading value reads as readlink options) and require BOTH + # canonicalizations to succeed — empty==empty would otherwise report a + # falsely healthy `installed` (PR#30 delta-2 finding N2). + local resolved_canon wrapper_canon + resolved_canon="$(readlink -f -- "$resolved" 2>/dev/null || true)" + wrapper_canon="$(readlink -f -- "$wrapper" 2>/dev/null || true)" + if [[ -z "$resolved_canon" || -z "$wrapper_canon" ]]; then + printf 'not-on-path' + return 0 + fi + if [[ "$resolved_canon" != "$wrapper_canon" ]]; then + printf 'shadowed' + return 0 + fi + printf 'installed' +} + cmd_status() { echo "safe $SAFE_VERSION" echo @@ -111,12 +232,23 @@ cmd_status() { "$SAFE_AUDIT_PATH" status echo echo "=== install ===" - if [[ -n "${ZSH_VERSION:-}" && -n "${functions[safe_install_check]:-}" ]]; then - echo "wrappers: loaded" - elif [[ -f "$SAFE_CONFIG_ROOT/install-wrappers.zsh" ]]; then - echo "wrappers: installed" + # Probe EVERY PATH wrapper, not shell state and not just npm: gating is + # executable-based and one ungated tool defeats the seatbelt. + local tool state healthy=0 unhealthy=() + for tool in "${SAFE_GATE_TOOL_LIST[@]}"; do + state="$(gate_wrapper_state "$tool")" + if [[ "$state" == "installed" ]]; then + healthy=$((healthy + 1)) + else + unhealthy+=("$tool:$state") + fi + done + if [[ "${#unhealthy[@]}" -eq 0 ]]; then + echo "wrappers: ok (${healthy}/${#SAFE_GATE_TOOL_LIST[@]} installed in $SAFE_BIN_ROOT)" else - echo "wrappers: not installed" + echo "wrappers: DEGRADED (${healthy}/${#SAFE_GATE_TOOL_LIST[@]} healthy) — ${unhealthy[*]}" + echo " foreign/shadowed/missing/not-on-path tools are NOT gated; fix and re-run install.sh" + echo " not-on-path means the wrapper exists but $SAFE_BIN_ROOT is not on PATH here" fi } @@ -391,6 +523,16 @@ doctor_payload_json() { local run_config_dir="$base_config/run" local audit_config_dir="$base_config/audit" local install_wrapper="$base_config/install-wrappers.zsh" + + # Per-tool wrapper states for the executable gate (finding: an aggregate + # derived from npm alone concealed missing/shadowed siblings). + local gate_wrappers_json='{}' gate_wrappers_healthy=true + local gw_tool gw_state + for gw_tool in "${SAFE_GATE_TOOL_LIST[@]}"; do + gw_state="$(gate_wrapper_state "$gw_tool")" + [[ "$gw_state" == "installed" ]] || gate_wrappers_healthy=false + gate_wrappers_json=$(jq -cn --argjson acc "$gate_wrappers_json" --arg t "$gw_tool" --arg s "$gw_state" '$acc + {($t): $s}') + done local completion_file="$SAFE_ZSH_COMPLETION_DIR/_safe" local paths_safe_json paths_run_json paths_audit_json @@ -598,6 +740,8 @@ doctor_payload_json() { --argjson safe_audit_machines_file "$( [[ -f "$audit_config_dir/machines.json" ]] && printf 'true' || printf 'false' )" \ --argjson safe_audit_tools_file "$( [[ -f "$audit_config_dir/tools.json" ]] && printf 'true' || printf 'false' )" \ --argjson wrapper_present "$( [[ -f "$install_wrapper" ]] && printf 'true' || printf 'false' )" \ + --argjson gate_wrappers "$gate_wrappers_json" \ + --argjson gate_wrappers_healthy "$gate_wrappers_healthy" \ --argjson completion_present "$( [[ -f "$completion_file" ]] && printf 'true' || printf 'false' )" \ '{ command: "safe doctor", @@ -664,6 +808,8 @@ doctor_payload_json() { tools_file: $safe_audit_tools_file }, install_wrappers: { + gate_wrappers: $gate_wrappers, + gate_wrappers_healthy: $gate_wrappers_healthy, script_present: $wrapper_present, completion_present: $completion_present }, @@ -1425,6 +1571,7 @@ case "${1:-}" in fi exec "$SAFE_AUDIT_PATH" "$@" ;; + gate) shift; cmd_gate "$@" ;; install) shift; cmd_install "$@" ;; vendor) shift; cmd_vendor "$@" ;; status) shift; cmd_status "$@" ;; diff --git a/bin/safe-run b/bin/safe-run index c3d44ea..5fafcf1 100755 --- a/bin/safe-run +++ b/bin/safe-run @@ -668,102 +668,53 @@ mark_sandbox_known() { # --------------------------------------------------------------------------- # Execution: host (host-allow tier) # --------------------------------------------------------------------------- -is_volta_shim_backup() { +# A saved runner path under a retired Volta install is stale by definition +# (Volta was replaced by mise, 2026-07; its shim-backup resolution logic is +# gone). Refuse legibly and point at the repair. +is_stale_volta_path() { local path="$1" case "$path" in - "$HOME/.volta/bin/"*.original|*/.volta/bin/*.original) return 0 ;; + "$HOME/.volta/"*|*/.volta/*) return 0 ;; *) return 1 ;; esac } -is_volta_npx_backup() { - local path="$1" - case "$path" in - "$HOME/.volta/tools/image/node/"*/bin/npx.original|*/.volta/tools/image/node/*/bin/npx.original) return 0 ;; - *) return 1 ;; - esac -} - -volta_npx_backup_for_version() { - local version="${1#v}" - local real_npx="$HOME/.volta/tools/image/node/${version}/bin/npx.original" - if [[ -x "$real_npx" ]]; then - printf '%s' "$real_npx" - return 0 - fi - return 1 -} - -resolve_volta_npx_backup() { - local version path resolved candidate - - if [[ -f "$PWD/package.json" ]]; then - version=$(jq -r '.volta.node // empty' "$PWD/package.json" 2>/dev/null || true) - if [[ -n "$version" ]]; then - if volta_npx_backup_for_version "$version"; then - return 0 - fi - err "project pins Volta node $version, but its npx.original backup was not found" - err "expected: $HOME/.volta/tools/image/node/${version#v}/bin/npx.original" - return 1 - fi - - version=$(jq -r '.engines.node // empty' "$PWD/package.json" 2>/dev/null || true) - if [[ "$version" =~ ^v?[0-9]+(\.[0-9]+){2}$ ]] && volta_npx_backup_for_version "$version"; then - return 0 - fi - fi - - version=$(node --version 2>/dev/null | sed 's/^v//' || true) - if [[ -n "$version" ]] && volta_npx_backup_for_version "$version"; then - return 0 - fi - - if command -v volta >/dev/null 2>&1; then - path=$(volta which node 2>/dev/null || true) - if [[ "$path" =~ /node/([^/]+)/bin/node$ ]] && volta_npx_backup_for_version "${BASH_REMATCH[1]}"; then - return 0 - fi - fi - - path="$HOME/.volta/tools/user/packages/node/default" - if [[ -e "$path" ]]; then - resolved=$(readlink -f "$path" 2>/dev/null || true) - if [[ "$resolved" =~ /node/([^/]+)(/|$) ]] && volta_npx_backup_for_version "${BASH_REMATCH[1]}"; then - return 0 - fi - fi - - path="$HOME/.volta/tools/image/node" - if [[ -d "$path" ]]; then - candidate=$(find "$path" -mindepth 3 -maxdepth 3 -path '*/bin/npx.original' -type f -perm -u=x -print 2>/dev/null | sort -V | tail -n 1) - if [[ -n "$candidate" && -x "$candidate" ]]; then - printf '%s' "$candidate" - return 0 - fi - fi - - return 1 -} - host_runner_binary() { local kind="$1" local saved saved=$(jq -r --arg k "${kind}_real" '.runners[$k] // empty' "$CONFIG_FILE") - if [[ "$kind" == "npx" && -n "$saved" ]] && { is_volta_shim_backup "$saved" || is_volta_npx_backup "$saved"; }; then - local resolved - if resolved=$(resolve_volta_npx_backup); then - printf '%s' "$resolved" - return 0 - fi - err "Volta npx backup cannot be resolved for the active Node version: $saved" - err "no executable per-version npx.original found under $HOME/.volta/tools/image/node" - return 1 + if [[ -n "$saved" ]] && is_stale_volta_path "$saved"; then + warn "configured ${kind}_real points into a retired Volta install: $saved (repair: safe run link)" + saved="" fi if [[ -n "$saved" && -x "$saved" ]]; then printf '%s' "$saved" return 0 fi + # Fallback: resolve through mise, the runtime manager that replaced Volta. + # CONSTRAINED to the runner's expected provider tool: a bare `mise which + # npx` is a bin-name lookup across every active tool, so any backend whose + # bin set ships a colliding `npx` could be executed on the host with + # npx-shaped flags it need not honor (PR#30 review finding 4). + local expected_tool="" + case "$kind" in + npx) expected_tool="node" ;; + bunx) expected_tool="bun" ;; + uvx) expected_tool="uv" ;; + *) return 1 ;; + esac + if command -v mise >/dev/null 2>&1; then + local mise_real + mise_real=$(mise which "$kind" --tool="$expected_tool" 2>/dev/null || true) + if [[ -n "$mise_real" && -x "$mise_real" ]]; then + case "$(readlink -f "$mise_real")" in + */installs/"$expected_tool"/*) ;; + *) return 1 ;; + esac + printf '%s' "$mise_real" + return 0 + fi + fi return 1 } @@ -1620,10 +1571,12 @@ cmd_status() { echo "real runners (host-allow exec):" local k for k in npx bunx uvx; do - local path + local path fallback path=$(jq -r --arg k "${k}_real" '.runners[$k] // empty' "$CONFIG_FILE") - if [[ -n "$path" && -x "$path" ]]; then + if [[ -n "$path" && -x "$path" ]] && ! is_stale_volta_path "$path"; then printf " [x] %s -> %s\n" "$k" "$path" + elif fallback=$(host_runner_binary "$k" 2>/dev/null); then + printf " [x] %s -> %s (mise, resolved at exec time)\n" "$k" "$fallback" else printf " [ ] %s (not linked; run: safe run link)\n" "$k" fi @@ -1733,13 +1686,6 @@ _link_collect_targets() { LINK_TARGET_PATHS+=("$path") done - local volta_dir="$HOME/.volta/tools/image/node" - if [[ -d "$volta_dir" ]]; then - while IFS= read -r -d '' path; do - LINK_TARGET_KEYS+=("npx-volta") - LINK_TARGET_PATHS+=("$path") - done < <(find "$volta_dir" -maxdepth 4 -name npx -print0 2>/dev/null) - fi } _link_preflight_targets() { @@ -1814,19 +1760,23 @@ _link_one() { info "$k: linked $path -> safe run" } -_link_fix_volta_npx_real() { - local saved resolved tmp - saved=$(jq -r '.runners.npx_real // empty' "$CONFIG_FILE") - if [[ -n "$saved" ]] && is_volta_shim_backup "$saved"; then - if resolved=$(resolve_volta_npx_backup); then +# Clear runner entries left pointing into a retired Volta install (replaced +# by mise, 2026-07): the paths are dead and host-allow exec would fail on +# them. host_runner_binary falls back to `mise which` at exec time. +_link_repair_stale_runners() { + local k saved tmp + for k in npx bunx uvx pipx; do + saved=$(jq -r --arg k "${k}_real" '.runners[$k] // empty' "$CONFIG_FILE") + if [[ -n "$saved" ]] && is_stale_volta_path "$saved"; then tmp=$(mktemp) - jq --arg v "$resolved" '.runners.npx_real = $v' "$CONFIG_FILE" > "$tmp" + jq --arg k "${k}_real" '.runners[$k] = null' "$CONFIG_FILE" > "$tmp" mv "$tmp" "$CONFIG_FILE" - info "npx: using Volta per-version backup for host-allow: $resolved" - else - warn "npx: Volta shim backup saved; host-allow will require a per-version npx.original" + info "$k: cleared stale Volta runner path ($saved)" fi - fi + done + tmp=$(mktemp) + jq 'del(.runners["npx-volta_real"])' "$CONFIG_FILE" > "$tmp" + mv "$tmp" "$CONFIG_FILE" } cmd_link() { @@ -1851,7 +1801,7 @@ cmd_link() { path="${LINK_TARGET_PATHS[$i]}" _link_one "$k" "$path" "$target_script" || { _link_rollback_run; } done - _link_fix_volta_npx_real + _link_repair_stale_runners # Convenience symlinks under ~/.local/bin (does not affect pipx itself). local convenience @@ -1889,48 +1839,7 @@ cmd_unlink() { mv "$tmp" "$CONFIG_FILE" done - # If npx_real was corrected to a per-version Volta binary for host-allow, - # the top-level Volta npx shim still has its own backup to restore. - local volta_shim="$HOME/.volta/bin/npx" - local volta_shim_backup="$HOME/.volta/bin/npx.original" - if [[ -e "$volta_shim_backup" ]]; then - if [[ -L "$volta_shim" ]]; then - local target - target=$(readlink -f "$volta_shim" 2>/dev/null || true) - if [[ "$target" == "$HOME/.local/bin/safe-run" ]]; then - _sudo_if_needed "$(dirname "$volta_shim")" rm -f "$volta_shim" - _sudo_if_needed "$(dirname "$volta_shim")" mv "$volta_shim_backup" "$volta_shim" - info "volta: restored $volta_shim" - else - warn "volta: $volta_shim does not point to safe run compatibility binary; skipping" - fi - elif [[ ! -e "$volta_shim" ]]; then - _sudo_if_needed "$(dirname "$volta_shim")" mv "$volta_shim_backup" "$volta_shim" - info "volta: restored $volta_shim" - else - warn "volta: $volta_shim exists and is not a symlink; not touching" - fi - fi - - # Restore Volta npx shims (with same target-verification). - local volta_dir="$HOME/.volta/tools/image/node" - if [[ -d "$volta_dir" ]]; then - local backup_path original target - while IFS= read -r -d '' backup_path; do - original="${backup_path%.original}" - if [[ -L "$original" ]]; then - target=$(readlink -f "$original" 2>/dev/null || true) - if [[ "$target" != "$HOME/.local/bin/safe-run" ]]; then - warn "volta: $original does not point to safe run compatibility binary; skipping" - continue - fi - rm -f "$original" - fi - mv "$backup_path" "$original" - info "volta: restored $original" - done < <(find "$volta_dir" -maxdepth 4 -name 'npx.original' -print0 2>/dev/null) - fi - + # Drop the retired Volta bookkeeping key if it survived an old install. local tmp; tmp=$(mktemp) jq 'del(.runners["npx-volta_real"])' "$CONFIG_FILE" > "$tmp" mv "$tmp" "$CONFIG_FILE" @@ -2085,7 +1994,7 @@ DECISION TIERS (in order) 6. unknown TTY: prompt; non-TTY: BLOCK EXIT CODES (policy refusals; distinguish from 127 = command not found) - 100 blocked by policy (blocklist, degraded wrappers, fail-closed audit, unrecognized/unsupported runner-native flags) + 100 blocked by policy (blocklist, missing gate library, fail-closed audit, unrecognized/unsupported runner-native flags) 101 host-allow version pin mismatch 102 interactive operator confirmation required (non-TTY refusal) 103 invalid package name rejected diff --git a/docs/agents.md b/docs/agents.md index 392ea80..1d740db 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -23,7 +23,7 @@ the operator verbatim, wait, then retry the original command unchanged. | Surface | Mechanism | Refusal source | | --- | --- | --- | | `npx`, `bunx`, `uvx` | Binaries symlinked to `safe run` | `safe run: BLOCKED ...` + exit 100–104 | -| `npm`, `pnpm`, `yarn`, `bun`, `pip`, `pip3`, `uv`, `cargo`, `go`, `composer`, `volta` | zsh wrapper functions audit install, update, and exec-style subcommands via `safe audit` | `safe: BLOCKED ...` + exit 100/102/104 | +| `npm`, `pnpm`, `pnpx`, `yarn`, `bun`, `pip`, `pip3`, `uv`, `cargo`, `go`, `composer` | PATH wrappers exec `safe gate `, which audits install, update, and exec-style subcommands via `safe audit` before delegating | `safe: BLOCKED ...` + exit 100/102/104 | | `safe install ` | Audited, confirmed install path | `safe: BLOCKED ...` + exit 100/102/104 | Audited subcommand families: installs (`install`/`add`/`ci`/`require` and @@ -33,14 +33,17 @@ exec-style fetch-and-run (`npm exec|x`, `pnpm dlx`, `yarn dlx`, `bun x`, unrecognized space-form value flag before the command fails closed (escapable with `--flag=value`). +The wrappers are executables on PATH, so gating applies in every shell — +`bash -c`, Makefiles, CI, and agent harnesses included, not just an +interactive zsh. + Passthrough by design (no registry fetch involved): non-install subcommands -(`npm run`, `npm --version`, `volta list`, ...), `npm exec ` and +(`npm run`, `npm --version`, ...), `npm exec ` and `npx ` for a **bare, unversioned** name already present in `node_modules/.bin` of the current or a parent directory (a versioned or aliased spec is still audited), -`pnpm exec` and `composer exec` (project/vendor binaries only), `volta run` -(fetches only official runtimes), `uv run` without `--with`, and `go run` -of local paths. `npx --no-install ` / `--no` are honored strictly: +`pnpm exec` and `composer exec` (project/vendor binaries only), `uv run` +without `--with`, and `go run` of local paths. `npx --no-install ` / `--no` are honored strictly: the local binary runs, or the refusal is exit 100 — never a registry fetch (modern npx would fetch anyway; safe restores the flag's meaning). @@ -62,30 +65,23 @@ distinguish a block from a missing binary (127): | Code | Meaning | | --- | --- | -| 100 | Blocked by policy (blocklist, degraded wrappers, fail-closed audit, unrecognized/unsupported runner-native flags) | +| 100 | Blocked by policy (blocklist, fail-closed audit, unrecognized/unsupported runner-native flags) | | 101 | host-allow version pin mismatch | | 102 | Interactive operator confirmation required (non-TTY refusal) | | 103 | Invalid package name rejected | | 104 | `safe audit` BLOCK verdict | -## Degraded wrappers (partial shell snapshots) - -Some agent harnesses snapshot the interactive shell and strip helper -functions (Claude Code drops single-underscore function names). The public -wrappers detect this and degrade legibly instead of dying with a silent 127: +## No partial-load state -- The audited subcommand families above refuse with a `safe: BLOCKED` line - and exit 100 (degraded mode cannot audit, so it fails closed where a - healthy shell would have audited). -- Degraded guards can't run the full parser, so they are conservative: they - may over-refuse a few non-fetch invocations but never under-refuse a real - fetch. Specifically `uv run` refuses if `--with`/`-w` appears anywhere, - `go run` refuses if any argument contains `@`, and `npm exec`/`bun x` of a - local `./node_modules/.bin` tool is refused. `pnpm exec`, `composer exec`, - and `volta run` pass through as in a healthy shell. +Gating is executable-based, so the old "degraded wrapper" mode (a shell +snapshot keeping wrapper functions while stripping their helpers, which died +with a silent 127) no longer exists: a wrapper either runs the full gate or is +not on PATH at all. If `safe gate` cannot load its routing tables it refuses +with the usual `safe: BLOCKED` line and exit 100 rather than delegating. -If an agent still sees a bare, silent 127 from a wrapped tool, that is a bug -worth reporting to the operator — never a reason to bypass. +If an agent sees a bare, silent 127 from a wrapped tool, that means the command +genuinely is not installed — or that `safe` itself is missing from PATH, which +is worth reporting to the operator. It is never a reason to bypass. ## Allow flows (operator only) diff --git a/docs/architecture.md b/docs/architecture.md index 4f7d7e3..a3e7b76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,10 +45,11 @@ global command. After successful installs of exact npm versions, interactive runs can add that exact version to host-allow; `--trust-host` makes that step explicit. `safe install --sandbox` keeps the isolated `safe run install` workflow. -Install wrappers are zsh functions that shadow package-manager commands. They -run `safe audit check` for package installs or `safe audit scan --project .` -for project-local installs, then delegate to the real command with `command - "$@"`. +Install wrappers are executable shims on PATH that exec `safe gate `. +The gate runs `safe audit check` (install-gate mode) for package installs or +`safe audit scan --project .` for project-local installs, then delegates to +the first non-wrapper executable of that name on PATH — so mise/asdf shims +and per-project tool versions keep working, in every shell. `safe vendor update` wraps explicit vendor-native updater commands. It cannot intercept in-process auto-updaters automatically, but it records a durable audit diff --git a/docs/command-reference.md b/docs/command-reference.md index 5d88eda..de6d07c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -26,7 +26,7 @@ format, policy exit codes, and the operator-only allow flows. See ### Policy exit codes ```text -100 blocked by policy (blocklist, degraded wrappers, fail-closed audit, unrecognized/unsupported runner-native flags) +100 blocked by policy (blocklist, missing gate library, fail-closed audit, unrecognized/unsupported runner-native flags) 101 host-allow version pin mismatch 102 interactive operator confirmation required (non-TTY refusal) 103 invalid package name rejected @@ -169,15 +169,14 @@ safe install --trust-host -g cowsay@1.6.0 `safe install --sandbox ...` preserves the isolated `safe run install` workflow. -The zsh wrappers cover these command families: +The PATH-executable gate wrappers cover these command families: ```text -npm, pnpm, yarn, bun +npm, pnpm, pnpx, yarn, bun uv, pip, pip3 cargo go composer -volta ``` They run package checks for explicit package installs and project scans for lockfile or manifest based project operations. diff --git a/docs/index.md b/docs/index.md index f61545b..3e959c2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,9 +6,9 @@ It combines three workflows: - `safe run`: sandboxed package execution for `npx`, `bunx`, `uvx`, and `pipx`-style workflows. - `safe audit`: dependency, SBOM, release, binary, vulnerability, and IOC checks across local and SSH-accessible machines. -- install wrappers: zsh functions that guard persistent package-manager installs before the real command runs. +- install wrappers: executable PATH wrappers (`~/.local/bin/npm` and friends) that route every install, update, and exec-style fetch through `safe gate` in every shell — interactive or not — before the real command runs. -The repo installs a top-level `safe` dispatcher, direct component binaries, one config tree under `~/.config/safe`, one data tree under `~/.local/share/safe`, and one zsh completion file. +The repo installs a top-level `safe` dispatcher, direct component binaries, one config tree under `~/.config/safe` (including the gate routing library `gate-lib.sh`), one data tree under `~/.local/share/safe`, and one zsh completion file. ## Start Here @@ -50,12 +50,14 @@ manifest evidence only, `safe audit scan --full --project .` for the complete tree, and `safe audit scan --verbose --project .` to inspect the resolved scan scope. -Guard persistent package installs: +Guard persistent package installs. `install.sh` writes PATH wrappers to +`~/.local/bin`, so every shell — interactive or not — routes package managers +through the gate once that directory precedes the real tools on PATH: ```bash safe install -g cowsay@1.6.0 -source "$HOME/.config/safe/install-wrappers.zsh" -npm install express +safe status # confirm: wrappers: ok (11/11 installed) +npm install express # gated by ~/.local/bin/npm -> safe gate npm ``` ## Main Safety Idea diff --git a/docs/install-wrappers.md b/docs/install-wrappers.md index 7ecb0ed..d7a1baa 100644 --- a/docs/install-wrappers.md +++ b/docs/install-wrappers.md @@ -1,20 +1,45 @@ # Install Wrappers -The install wrappers protect persistent host installs in zsh. They are installed at: +The install wrappers protect persistent host installs in **every shell** — +interactive zsh, `bash -c`, Makefile recipes, CI steps, and agent harnesses +alike. They are executables on PATH, installed by `install.sh` at: ```text -~/.config/safe/install-wrappers.zsh +~/.local/bin/{npm,pnpm,pnpx,yarn,bun,pip,pip3,uv,cargo,go,composer} ``` -and loaded from `.zshrc` with: +Each one is a three-line shim: ```bash -source "$HOME/.config/safe/install-wrappers.zsh" +#!/usr/bin/env bash +# safe-gate-wrapper v1 tool=npm +exec safe gate npm -- "$@" ``` +All routing lives in `safe gate` (`~/.config/safe/gate-lib.sh`), so upgrading +safe upgrades the gate — the wrappers themselves never need rewriting. + +They used to be zsh functions sourced from `.zshrc`. That only gated an +interactive zsh: a non-interactive shell went straight to the version-manager +shim and was never audited. `~/.config/safe/install-wrappers.zsh` still exists +and is still sourced (it now only warns, once, when an interactive shell finds +no wrapper installed), but it defines no wrapper functions. + +An existing file of a wrapped name that does not carry the +`# safe-gate-wrapper` marker is never overwritten: `install.sh` reports it and +skips, leaving that tool ungated. `uninstall.sh` removes only marked wrappers. +Check the wiring with `safe status`, which reports one of five states per +tool: `installed`, `shadowed` (something earlier on PATH wins), +`not-on-path` (the wrapper exists but nothing resolves — the bin directory +is missing from PATH), `missing` (no wrapper file), or `foreign` (a file of +that name exists but safe does not own it). + ## Behavior -The wrappers shadow package-manager commands with zsh functions, run a check or scan, then delegate to the real command with `command "$@"`. +A wrapper execs `safe gate -- "$@"`, which runs a check or scan, then +delegates to the real tool: the first executable of that name on PATH that is +not itself a wrapper. For node tools that is normally the mise/asdf shim, so +per-project tool versions keep resolving. Package installs run: @@ -49,7 +74,6 @@ pip3 install pytest==8.3.0 cargo install cargo-edit go install golang.org/x/tools/cmd/stringer@latest composer require vendor/package -volta install pnpm@10.11.0 ``` ## Wrapped Project Operations @@ -102,7 +126,7 @@ disambiguate. command still selects the fetched package and is audited; the command's own flags after it are ignored. `--with-requirements ` (uv) names a file of packages that cannot be vetted inline, so it fails closed with a legible -refusal in both healthy and degraded modes. `go run` classifies build flags +refusal. `go run` classifies build flags the same way (value vs switch, validated against `go help build`) and fails closed on an unrecognized flag before the run target, so a value flag like `-C dir` or `-mod mode` cannot hide a later `module@version`. @@ -120,8 +144,8 @@ for named specs): `npm update|u|up|upgrade|udpate`, `npm it|install-test`, `yarn global upgrade`. Passthrough by design — these never fetch registry packages: `pnpm exec` -and `composer exec` (project/vendor binaries only), `volta run` (official -runtimes only), `uv run` without `--with`/`-w`, and `go run` of local paths. +and `composer exec` (project/vendor binaries only), `uv run` without +`--with`/`-w`, and `go run` of local paths. `npm exec ` / `bun x ` pass through only for a **bare** command name backed by `node_modules/.bin/` in the physical cwd or a parent directory (npm's own bin resolution, covering hoisted monorepos); a versioned or aliased spec @@ -130,7 +154,7 @@ is always audited even when a same-named local bin exists. ## Refusal Contract -Every wrapper refusal is a single stderr line in the shape: +Every gate refusal is a single stderr line in the shape: ```text safe: BLOCKED ; to allow: ; details: safe explain @@ -141,35 +165,17 @@ from a missing binary (127): `100` policy block, `102` interactive operator confirmation required, `104` `safe audit` BLOCK verdict. See the [Agent Contract](agents.md) page and `safe explain`. -## Degraded Mode (partial shell snapshots) - -Some agent harnesses (Claude Code) snapshot interactive shell functions but -strip single-underscore names. The `safe_install_*` helpers deliberately -avoid a leading underscore so snapshots keep them, and every public wrapper -carries an inlined guard for the case where helpers are still missing: - -- The audited subcommand families (installs, updates, exec-style - fetch-and-run) refuse with a `safe: BLOCKED` line and exit 100 instead of - failing with a silent 127. -- All other subcommands pass through, mirroring what healthy shells pass - through by design: `pnpm exec`, `composer exec`, `volta run`. - -Degraded guards cannot run the full argument parser, so they are -**conservative**: they refuse whenever a fetch *might* be requested and may -over-refuse a few non-fetch invocations. They never under-refuse a real -fetch. Concretely versus a healthy shell: - -- `uv run` refuses if `--with`/`-w` appears anywhere (a healthy shell only - audits `--with` before the command, so degraded may over-refuse a `--with` - that is actually the program's own argument). -- `go run` refuses if any argument contains `@` (a healthy shell parses the - run target, so degraded may over-refuse a local run whose program args - contain `@`). -- `npm exec`/`bun x` of a local `node_modules/.bin` tool is refused, - because the guard cannot safely verify the local-bin condition. - -Do not rename wrapper helpers to `_`-prefixed names; that reintroduces the -silent-127 failure inside harness snapshots. +## Snapshot-stripped shells are no longer a special case + +The zsh wrappers carried inlined "degraded mode" guards for harnesses that +snapshot interactive shell functions but strip helpers, which used to produce +a silent 127 mid-install. Executable wrappers have no such state: a wrapper +either exists on PATH and execs `safe gate`, or it does not exist and the tool +is ungated. Nothing can be half-loaded. + +The one comparable failure — `safe gate` unable to find its routing tables +(`gate-lib.sh` missing) — fails closed with the usual `safe: BLOCKED` line and +exit 100, never a silent passthrough. ## Timeouts diff --git a/docs/installation.md b/docs/installation.md index dbdc67e..cc0655d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -32,7 +32,12 @@ bash install.sh --wrappers bash install.sh --no-wrappers ``` -`--no-wrappers` installs the run and audit tools but leaves persistent zsh package-manager wrappers disabled. +`--no-wrappers` installs the run and audit tools without generating the +PATH gate wrappers. On an existing installation the gate library +(`~/.config/safe/gate-lib.sh`) is still refreshed together with the `safe` +binary — the dispatcher and its routing library are one upgrade unit, so +already-installed wrappers always load the library matching the installed +`safe`. ## First Run diff --git a/docs/integration-flows.md b/docs/integration-flows.md index c64b4ef..1ea6f61 100644 --- a/docs/integration-flows.md +++ b/docs/integration-flows.md @@ -26,12 +26,23 @@ safe audit setup --all safe audit scan --all ``` -Enable persistent install protection by starting a new zsh session or sourcing: +Install protection is active as soon as `install.sh` has written the PATH +wrappers — no shell integration and no sourcing involved. Each wrapper in +`$SAFE_BIN_DIR` (default `~/.local/bin`) execs `safe gate `, which loads +the routing tables from `~/.config/safe/gate-lib.sh` and delegates to the real +tool. What activates it is PATH order: `$SAFE_BIN_DIR` must precede the +directory holding the real package managers (a version-manager shim dir, for +instance). Verify with: ```bash -source "$HOME/.config/safe/install-wrappers.zsh" +safe status # wrappers: ok (11/11 installed in ~/.local/bin) ``` +`~/.config/safe/install-wrappers.zsh` is a compatibility stub for existing +`.zshrc` lines. It defines no wrapper functions and cannot enable protection; +it only prints a one-time warning in an interactive shell when the wrappers are +missing. + ## Unknown Package Execution ```bash @@ -71,13 +82,13 @@ npm install express Flow: -1. The zsh wrapper detects a package install. +1. The PATH wrapper execs `safe gate npm`, which detects a package install. 2. If the current directory looks like an npm project, it runs `safe audit scan --project .`. -3. It extracts package specs and runs `safe audit check @ --ecosystem npm`. -4. Only `GO` proceeds for package checks. -5. The real command runs through `command npm install express`. +3. It extracts package specs and runs `safe audit check --ecosystem npm --gate install`. +4. Only a passing gate proceeds. +5. The real command runs through the first non-wrapper `npm` on PATH. -Equivalent wrapper patterns exist for pnpm, yarn, bun, uv, pip, pip3, cargo, go, composer, and volta. +Equivalent gate routing exists for pnpm, pnpx, yarn, bun, uv, pip, pip3, cargo, go, and composer (Volta is retired). ## External Binary Review diff --git a/install.sh b/install.sh index a155189..81e31c3 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,11 @@ RUN_DATA_DIR="$DATA_BASE/run" AUDIT_CONFIG_DIR="$CONFIG_BASE/audit" AUDIT_DATA_DIR="$DATA_BASE/audit" WRAPPER_TARGET="$CONFIG_BASE/install-wrappers.zsh" +GATE_LIB_TARGET="$CONFIG_BASE/gate-lib.sh" +# Tools that get a PATH wrapper. Every wrapper is a dumb 3-line shim into +# `safe gate`; all routing lives in gate-lib.sh, so upgrading safe upgrades the +# gate without rewriting a single wrapper. +GATE_TOOLS=(npm pnpm pnpx yarn bun pip pip3 uv cargo go composer) COMPLETION_DIR="${SAFE_ZSH_COMPLETION_DIR:-$HOME/.local/share/zsh/site-functions}" COMPLETION_TARGET="$COMPLETION_DIR/_safe" ZSHRC="${SAFE_ZSHRC:-$HOME/.zshrc}" @@ -94,8 +99,69 @@ for script in "$REPO_DIR/bin/safe" "$REPO_DIR/bin/safe-run" "$REPO_DIR/bin/safe- [[ -f "$script" ]] || die "missing source file: $script" done [[ -f "$REPO_DIR/lib/install-wrappers.zsh" ]] || die "missing wrapper source" +[[ -f "$REPO_DIR/lib/gate-lib.sh" ]] || die "missing gate library source" [[ -f "$REPO_DIR/lib/completions/_safe" ]] || die "missing zsh completion" +# Ownership check: a regular (never symlinked) file whose second line is +# EXACTLY our per-tool marker. A loose substring match let a foreign file +# carrying the phrase be overwritten/deleted, and a marked symlink let a +# reinstall truncate its target outside BIN_DIR (PR#30 review finding 5). +gate_wrapper_marked() { + local path="$1" tool="$2" + [[ -f "$path" && ! -L "$path" ]] || return 1 + # Byte-stream comparison, never a command substitution: these paths hold + # arbitrary foreign executables, and capturing an ELF second line into a + # shell variable made bash emit "ignored null byte in input" warnings from + # status/doctor/install (PR#30 round regression R1). Same exact whole-line + # test, no capture. + LC_ALL=C sed -n '2p' -- "$path" 2>/dev/null \ + | LC_ALL=C grep -qxF -- "# safe-gate-wrapper v1 tool=${tool}" +} + +# Any owned wrapper anywhere in the set means gating is live on this machine +# and the library it loads must be refreshed. Probing npm alone missed an +# installation that kept, say, an owned pnpm wrapper and no npm one, leaving +# the library unrestored (PR#30 delta finding 2). +gate_wrappers_exist() { + local tool + for tool in "${GATE_TOOLS[@]}"; do + gate_wrapper_marked "$BIN_DIR/$tool" "$tool" && return 0 + done + return 1 +} + +# PATH wrappers are what makes gating work in every shell (bash -c, Makefiles, +# CI, agent harnesses), not just an interactive zsh. A pre-existing file +# without our marker is somebody else's binary: report it and leave it alone. +install_gate_wrappers() { + local tool target + local -a written=() skipped=() + + mkdir -p "$BIN_DIR" + for tool in "${GATE_TOOLS[@]}"; do + target="$BIN_DIR/$tool" + if [[ -e "$target" || -L "$target" ]] && ! gate_wrapper_marked "$target" "$tool"; then + skipped+=("$tool") + continue + fi + rm -f "$target" + cat > "$target" < -- `, the centralized +# entrypoint the generated PATH wrappers exec into. This is a faithful port of +# the retired zsh install wrappers (lib/install-wrappers.zsh through PR #28): +# the routing tables, flag classifications, and fail-closed refusals below were +# hardened over six adversarial review rounds and are load-bearing security +# logic — change them only with the same scrutiny. +# +# What did NOT come across from the zsh original, deliberately: +# - the inlined degraded-mode guards (they defended against a zsh shell +# snapshot stripping helper FUNCTIONS; an executable wrapper either runs or +# does not exist, so the failure mode is gone); +# - `volta()` (retired from the safe integration). +# +# Sourced by bin/safe. It defines functions only — sourcing must never exit or +# mutate the caller's shell state. Entry point: safe_gate_main . + +SAFE_GATE_TIMEOUT_SECONDS="${SAFE_INSTALL_TIMEOUT_SECONDS:-30}" +SAFE_GATE_WARNED_MISSING=0 +SAFE_GATE_SUBCMD="" +SAFE_GATE_SUBCMD_IDX=0 +SAFE_GATE_SUBCMD_BADFLAG="" +SAFE_GATE_GVAL="" +SAFE_GATE_GBOOL="" +SAFE_GATE_NPM_USERCONFIG="" +SAFE_GATE_NPM_GLOBALCONFIG="" + +safe_gate_err() { + printf '%s\n' "$*" >&2 +} + +# Exact membership in a '|'-separated flag table. The zsh original used glob +# alternation (`[[ $arg == (${~alt}) ]]`); every table entry is a literal flag, +# so exact per-entry comparison is the same test. Substring matching would not +# be: it can classify a crafted argument as a known flag instead of failing +# closed. +safe_gate_alt_match() { + local needle="$1" alt="$2" + [[ -n "$alt" ]] || return 1 + local -a parts=() + local part + IFS='|' read -r -a parts <<< "$alt" + for part in "${parts[@]}"; do + [[ "$needle" == "$part" ]] && return 0 + done + return 1 +} + +safe_gate_has_arg() { + local needle="$1" + shift + local arg + + for arg in "$@"; do + [[ "$arg" == "$needle" ]] && return 0 + done + + return 1 +} + +safe_gate_has_prefix_arg() { + local prefix="$1" + shift + local arg + + for arg in "$@"; do + [[ "$arg" == "${prefix}"* ]] && return 0 + done + + return 1 +} + +safe_gate_warn_missing() { + # Per-process: unlike the zsh wrappers (one long-lived shell), each gated + # command is its own process, so this warns once per invocation. + if (( SAFE_GATE_WARNED_MISSING == 0 )); then + safe_gate_err "safe audit not installed, skipping pre-install check" + SAFE_GATE_WARNED_MISSING=1 + fi +} + +# --------------------------------------------------------------------------- +# Real-tool resolution +# --------------------------------------------------------------------------- + +# A generated wrapper carries `# safe-gate-wrapper` in its first two lines. +safe_gate_is_wrapper() { + local path="$1" line i=0 + while (( i < 2 )) && IFS= read -r line; do + i=$((i + 1)) + case "$line" in + *'# safe-gate-wrapper'*) return 0 ;; + esac + done < "$path" 2>/dev/null + return 1 +} + +# First executable on PATH that is not one of our wrappers. For node +# tools that is normally the version-manager shim (mise), which is what we +# want: per-project tool versions keep resolving. +safe_gate_resolve_real() { + local tool="$1" dir candidate + local -a dirs=() + + IFS=':' read -r -a dirs <<< "${PATH}" + for dir in "${dirs[@]}"; do + [[ -n "$dir" ]] || dir="." + candidate="${dir}/${tool}" + [[ -f "$candidate" && -x "$candidate" ]] || continue + safe_gate_is_wrapper "$candidate" && continue + printf '%s\n' "$candidate" + return 0 + done + + return 1 +} + +# Delegate to the real tool. Never returns. +safe_gate_exec_real() { + local tool="$1" + shift + local real + + real="$(safe_gate_resolve_real "$tool")" + if [[ -z "$real" ]]; then + # 127 is the agent contract's "genuinely missing command" (safe explain); + # a policy refusal is never 127. + safe_gate_err "safe: gate: ${tool}: command not found (no non-wrapper ${tool} on PATH)" + exit 127 + fi + + # Bash assignment PRESERVES a pre-existing export attribute: if the caller + # exported any scanner name, the scan would overwrite it with the + # credential-bearing operational set and the delegate would inherit it + # (PR#30 delta-2 finding P2). De-export all five before every delegate + # exec — the audit child receives its values via argv flags, never env. + export -n SAFE_GATE_DIST_TAG SAFE_GATE_REGISTRY SAFE_GATE_PROJECT_DIR \ + SAFE_GATE_NPM_USERCONFIG SAFE_GATE_NPM_GLOBALCONFIG 2>/dev/null || true + + exec "$real" "$@" +} + +# --------------------------------------------------------------------------- +# Audit invocation +# --------------------------------------------------------------------------- + +safe_gate_resolve_audit_bin() { + if [[ -n "${SAFE_AUDIT_BIN:-}" && -x "${SAFE_AUDIT_BIN}" ]]; then + printf '%s\n' "${SAFE_AUDIT_BIN}" + return 0 + fi + if [[ -n "${SAFE_AUDIT_PATH:-}" && -x "${SAFE_AUDIT_PATH}" ]]; then + printf '%s\n' "${SAFE_AUDIT_PATH}" + return 0 + fi + local found + found="$(command -v safe-audit 2>/dev/null || true)" + [[ -n "$found" ]] || return 1 + printf '%s\n' "$found" +} + +safe_gate_audit_available() { + if [[ -z "${SAFE_GATE_AUDIT_BIN+x}" ]]; then + SAFE_GATE_AUDIT_BIN="$(safe_gate_resolve_audit_bin)" + fi + [[ -n "${SAFE_GATE_AUDIT_BIN}" ]] +} + +safe_gate_run_audit() { + local rc=0 + local -a extra=() + [[ -n "${SAFE_GATE_DIST_TAG:-}" ]] && extra+=(--dist-tag "${SAFE_GATE_DIST_TAG}") + [[ -n "${SAFE_GATE_REGISTRY:-}" ]] && extra+=(--registry "${SAFE_GATE_REGISTRY}") + [[ -n "${SAFE_GATE_PROJECT_DIR:-}" ]] && extra+=(--project-dir "${SAFE_GATE_PROJECT_DIR}") + [[ -n "${SAFE_GATE_NPM_USERCONFIG:-}" ]] && extra+=(--npm-userconfig "${SAFE_GATE_NPM_USERCONFIG}") + [[ -n "${SAFE_GATE_NPM_GLOBALCONFIG:-}" ]] && extra+=(--npm-globalconfig "${SAFE_GATE_NPM_GLOBALCONFIG}") + if command -v timeout >/dev/null 2>&1; then + timeout "${SAFE_GATE_TIMEOUT_SECONDS}" "${SAFE_GATE_AUDIT_BIN}" check "$@" "${extra[@]}" + rc=$? + else + "${SAFE_GATE_AUDIT_BIN}" check "$@" "${extra[@]}" + rc=$? + fi + return "$rc" +} + +# Target-altering selectors change what the package manager actually installs +# (tag, source registry/index, project dir). They were previously stripped +# from the audit while staying in the real command — the audit then judged +# the wrong target (PR#29 review finding 3). Scanned from the original argv; +# safe-audit resolves with them or fails closed. +# Source selectors are cumulative (pip considers --find-links AND the index): +# accumulate every one — a later trusted selector must never erase an earlier +# untrusted one (PR#29 delta-2 finding 3). +safe_gate_add_source() { + local value="$1" + while [[ "${value}" == */ ]]; do value="${value%/}"; done + # The accumulated set stays OPERATIONAL (credentials included): safe-audit + # fetches packuments from it, and pre-redacting here broke authenticated + # registries (PR#29 delta-6 finding N2). Redaction happens centrally in + # safe-audit at every identity/receipt/display sink — never here. + [[ -n "${value}" ]] || return 0 + # A repeat moves to the END rather than being dropped: npm is last-wins, so + # `--registry A --registry B --registry A` installs from A, and the + # resolver's last-word read must see A last (delta-4 finding 3.1). + local out="" w + for w in ${SAFE_GATE_REGISTRY}; do + [[ "$w" == "$value" ]] || out="${out:+$out }$w" + done + SAFE_GATE_REGISTRY="${out:+$out }$value" +} + +safe_gate_scan_target_flags() { + local family="$1" + shift + SAFE_GATE_DIST_TAG="" + SAFE_GATE_REGISTRY="" + SAFE_GATE_PROJECT_DIR="" + SAFE_GATE_NPM_USERCONFIG="" + SAFE_GATE_NPM_GLOBALCONFIG="" + local prev="" arg + for arg in "$@"; do + case "${family}" in + npm) + case "${prev}" in + --tag) SAFE_GATE_DIST_TAG="${arg}" ;; + # --@scope:registry is a scoped source selector npm accepts on the + # command line. It is threaded as a KEYED token (@scope:registry=URL) + # — flattening the bare URL loses the key and lets the resolver pick + # a different scope's registry (PR#29 delta-6 finding 3.2b). + # --userconfig/--globalconfig swap in whole config files whose + # registry keys become effective; the PATH is threaded to safe-audit, + # which reads them (delta-7/8 config-path threading) — the gate never + # parses a config file itself. + --registry) safe_gate_add_source "${arg}" ;; + --@*:registry) safe_gate_add_source "${prev#--}=${arg}" ;; + --userconfig) SAFE_GATE_NPM_USERCONFIG="${arg}" ;; + --globalconfig) SAFE_GATE_NPM_GLOBALCONFIG="${arg}" ;; + --prefix|-C|--cwd|--dir) SAFE_GATE_PROJECT_DIR="${arg}" ;; + esac + case "${arg}" in + --tag=*) SAFE_GATE_DIST_TAG="${arg#*=}" ;; + --registry=*) safe_gate_add_source "${arg#*=}" ;; + --@*:registry=*) safe_gate_add_source "${arg#--}" ;; + --userconfig=*) SAFE_GATE_NPM_USERCONFIG="${arg#*=}" ;; + --globalconfig=*) SAFE_GATE_NPM_GLOBALCONFIG="${arg#*=}" ;; + --prefix=*|--cwd=*|--dir=*) SAFE_GATE_PROJECT_DIR="${arg#*=}" ;; + esac + ;; + python) + case "${prev}" in + --index-url|-i|--extra-index-url|--default-index|--index) safe_gate_add_source "${arg}" ;; + # find-links names a real endpoint: record it so operators can trust + # a specific location instead of a blanket sentinel (PR#29 delta-5 + # finding 3.2 mitigation). --no-index keeps its sentinel: it is a + # boolean switch with no endpoint to record. + --find-links|-f) safe_gate_add_source "${arg}" ;; + esac + case "${arg}" in + --index-url=*|--extra-index-url=*|--default-index=*|--index=*) safe_gate_add_source "${arg#*=}" ;; + --find-links=*) safe_gate_add_source "${arg#*=}" ;; + --no-index) safe_gate_add_source "local:no-index" ;; + esac + ;; + cargo) + case "${prev}" in + --registry|--index) safe_gate_add_source "${arg}" ;; + esac + case "${arg}" in + --registry=*|--index=*) safe_gate_add_source "${arg#*=}" ;; + esac + ;; + composer) + case "${prev}" in + --repository) safe_gate_add_source "${arg}" ;; + --working-dir|-d) SAFE_GATE_PROJECT_DIR="${arg}" ;; + esac + case "${arg}" in + --repository=*) safe_gate_add_source "${arg#*=}" ;; + --working-dir=*) SAFE_GATE_PROJECT_DIR="${arg#*=}" ;; + esac + ;; + esac + prev="${arg}" + done +} + +# Update-family subcommands resolve in-range instead of to the dist-tag; the +# audit needs to know which semantics apply to resolve the real target. +safe_gate_audit_op() { + case "${SAFE_GATE_SUBCMD:-}" in + update|u|up|upgrade|udpate) printf '%s' "update" ;; + *) printf '%s' "install" ;; + esac +} + +# Persistent record of install-gate decisions, mirroring safe-run's audit_log +# field format (ts | runner | pkg | tier | context | decision | extra). +safe_gate_audit_log() { + local ecosystem="$1" package="$2" decision="$3" extra="${4:-}" + local data_dir="${SAFE_RUN_DATA_DIR:-${SAFE_DATA_DIR:-$HOME/.local/share/safe}/run}" + local context="non-tty" + [[ -t 0 && -t 1 ]] && context="interactive" + mkdir -p "${data_dir}" 2>/dev/null || return 0 + local line + line="$(date -Iseconds) | install:${ecosystem} | ${package} | GATE | ${context} | ${decision}${extra:+ | ${extra}}" + printf '%s\n' "${line//[$'\r\n']/ }" >> "${data_dir}/audit.log" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# Offline / allow fallbacks +# --------------------------------------------------------------------------- + +safe_gate_run_config_dir() { + printf '%s' "${SAFE_RUN_CONFIG_DIR:-${SAFE_CONFIG_DIR:-$HOME/.config/safe}/run}" +} + +safe_gate_split_spec() { + local spec="$1" + local name version + + if [[ "${spec}" == @*/*@* ]]; then + name="${spec%@*}" + version="${spec##*@}" + elif [[ "${spec}" == *@* && "${spec}" != @* ]]; then + name="${spec%@*}" + version="${spec##*@}" + else + name="${spec}" + version="latest" + fi + + printf '%s\t%s\n' "${name}" "${version}" +} + +# Offline fallback: when the audit itself timed out, an exact-version request +# matching a fresh install-known entry (a previously recorded clean check) may +# proceed on that stale evidence. Unpinned requests never qualify. +safe_gate_known_matches() { + local package="$1" + local ecosystem="$2" + local known_file name version entry ttl_days entry_epoch now_epoch + + # install-known entries are keyed by the canonical resolver ecosystem + # (safe-audit normalizes at check entry); the gate speaks tool labels. + case "${ecosystem}" in + cargo) ecosystem="rust" ;; + composer) ecosystem="php" ;; + pip|uv) ecosystem="python" ;; + esac + + command -v jq >/dev/null 2>&1 || return 1 + known_file="$(safe_gate_run_config_dir)/install-known.json" + [[ -r "${known_file}" ]] || return 1 + + IFS=$'\t' read -r name version <<< "$(safe_gate_split_spec "${package}")" + [[ -n "${name}" && -n "${version}" && "${version}" != "latest" ]] || return 1 + + # Only fully clean GO evidence may satisfy the offline fallback — a + # tolerated-WARN record is not clean evidence (PR#29 review finding 5). + # The source identity (implicit-default vs explicit:, delta-3 + # finding 5) must be byte-identical to what safe-audit's receipt writer + # recorded; the derivation (argv + env + rc precedence, per ecosystem) is + # centralized in safe-audit — ask it instead of mirroring (delta-4 + # findings 3.2 and N1). Local-only plumbing, no network. No identity -> + # fail closed: stale evidence never applies without a trustworthy match. + # + # A disabled reuse path must not be invisible: silently never reusing + # evidence looks like flaky refusals, so the failure says so once, on + # stderr, without changing the fail-closed outcome (same principle as the + # Socket-outage ruling). + if ! safe_gate_audit_available; then + safe_gate_err "safe gate: effective-sources unavailable — stale-evidence reuse disabled, run safe doctor" + return 1 + fi + local -a es_args=("${name}" --ecosystem "${ecosystem}") + [[ -n "${SAFE_GATE_REGISTRY:-}" ]] && es_args+=(--registry "${SAFE_GATE_REGISTRY}") + [[ -n "${SAFE_GATE_PROJECT_DIR:-}" ]] && es_args+=(--project-dir "${SAFE_GATE_PROJECT_DIR}") + [[ -n "${SAFE_GATE_NPM_USERCONFIG:-}" ]] && es_args+=(--npm-userconfig "${SAFE_GATE_NPM_USERCONFIG}") + [[ -n "${SAFE_GATE_NPM_GLOBALCONFIG:-}" ]] && es_args+=(--npm-globalconfig "${SAFE_GATE_NPM_GLOBALCONFIG}") + local current_source + if ! current_source="$("${SAFE_GATE_AUDIT_BIN}" effective-sources "${es_args[@]}" 2>/dev/null)" \ + || [[ -z "${current_source}" ]]; then + safe_gate_err "safe gate: effective-sources unavailable — stale-evidence reuse disabled, run safe doctor" + return 1 + fi + entry="$(jq -r --arg k "${ecosystem}:${name}" --arg v "${version}" --arg src "${current_source}" \ + '.packages[$k] | select(.version == $v and .verdict == "GO" and ((.source // "implicit-default") == $src)) | .first_allowed // empty' "${known_file}" 2>/dev/null || true)" + [[ -n "${entry}" ]] || return 1 + + ttl_days="$(jq -r '.install.auto_allow_ttl_days // 30' \ + "$(safe_gate_run_config_dir)/config.json" 2>/dev/null || printf '30')" + [[ "${ttl_days}" =~ ^[0-9]+$ ]] || ttl_days=30 + entry_epoch="$(date -d "${entry}" +%s 2>/dev/null || true)" + [[ -n "${entry_epoch}" ]] || return 1 + now_epoch="$(date +%s)" + (( now_epoch - entry_epoch <= ttl_days * 86400 )) +} + +safe_gate_host_allow_matches() { + local package="$1" + local ecosystem="$2" + local host_allow_file name version entry_version entry_ecosystem + + [[ "${ecosystem}" == "npm" ]] || return 1 + command -v jq >/dev/null 2>&1 || return 1 + + host_allow_file="$(safe_gate_run_config_dir)/host-allow.json" + [[ -r "${host_allow_file}" ]] || return 1 + + IFS=$'\t' read -r name version <<< "$(safe_gate_split_spec "${package}")" + [[ -n "${name}" && -n "${version}" && "${version}" != "latest" ]] || return 1 + + entry_version="$(jq -r --arg p "${name}" '.packages[$p].version // empty' "${host_allow_file}" 2>/dev/null || true)" + entry_ecosystem="$(jq -r --arg p "${name}" '.packages[$p].ecosystem // "npm"' "${host_allow_file}" 2>/dev/null || true)" + + [[ "${entry_version}" == "${version}" && "${entry_ecosystem}" == "npm" ]] +} + +# --------------------------------------------------------------------------- +# Project scan preflight +# --------------------------------------------------------------------------- + +safe_gate_confirm_critical() { + if [[ -t 0 && -t 1 ]]; then + local reply + printf 'Proceed anyway? [y/N] ' + read -r reply + [[ "${reply}" == "y" || "${reply}" == "Y" || "${reply}" == "yes" || "${reply}" == "YES" ]] + return $? + fi + + safe_gate_err "safe: BLOCKED install — safe audit scan found critical findings and this shell is non-interactive; to allow: ask the operator to re-run interactively and review; details: safe explain" + return 102 +} + +safe_gate_scan_project() { + local scan_output + local scan_status + + if ! safe_gate_audit_available; then + safe_gate_warn_missing + return 0 + fi + + scan_output="$("${SAFE_GATE_AUDIT_BIN}" scan --project . 2>&1)" + scan_status=$? + + [[ -n "${scan_output}" ]] && printf '%s\n' "${scan_output}" + + if (( scan_status == 0 )); then + return 0 + fi + + if [[ "${scan_output,,}" == *critical* || "${scan_status}" -ge 2 ]]; then + # Preamble only ahead of the interactive prompt; the non-TTY path emits a + # single self-contained BLOCKED line instead. + if [[ -t 0 && -t 1 ]]; then + safe_gate_err "safe install: safe audit scan reported critical findings" + fi + safe_gate_confirm_critical + return $? + fi + + safe_gate_err "safe install: safe audit scan failed with exit ${scan_status}; proceeding" + return 0 +} + +safe_gate_any_file() { + local file + + for file in "$@"; do + [[ -e "${file}" ]] && return 0 + done + + return 1 +} + +safe_gate_npm_project_present() { + safe_gate_any_file package-lock.json npm-shrinkwrap.json package.json +} + +safe_gate_pnpm_project_present() { + safe_gate_any_file pnpm-lock.yaml package.json +} + +safe_gate_yarn_project_present() { + safe_gate_any_file yarn.lock package.json +} + +safe_gate_bun_project_present() { + safe_gate_any_file bun.lock bun.lockb package.json +} + +safe_gate_uv_project_present() { + safe_gate_any_file uv.lock pyproject.toml +} + +safe_gate_composer_project_present() { + safe_gate_any_file composer.lock composer.json +} + +safe_gate_cargo_project_present() { + safe_gate_any_file Cargo.lock Cargo.toml +} + +safe_gate_go_project_present() { + safe_gate_any_file go.sum go.mod +} + +safe_gate_pip_project_install() { + local arg + local next_is_requirement=0 + local next_is_editable=0 + + for arg in "$@"; do + if (( next_is_requirement )); then + [[ "${arg}" == requirements.txt || "${arg}" == requirements-*.txt ]] && return 0 + next_is_requirement=0 + continue + fi + + if (( next_is_editable )); then + return 0 + fi + + case "${arg}" in + -r|--requirement) + next_is_requirement=1 + ;; + --requirement=requirements.txt|--requirement=requirements-*.txt) + return 0 + ;; + -e|--editable|--editable=*) + return 0 + ;; + esac + done + + return 1 +} + +# --------------------------------------------------------------------------- +# Gate decision +# --------------------------------------------------------------------------- + +# One-line operator hint for a refused package. host-allow only unlocks npm +# installs, so other ecosystems point at the audit detail command instead. +# Fallback hint when the audit could not supply a pinned suggestion (the gate +# prints resolved-version hints itself). Must never render an @latest shape: +# allow entries are always pinned to an exact resolved version. +safe_gate_allow_hint() { + local package="$1" + local ecosystem="$2" + local name version + + IFS=$'\t' read -r name version <<< "$(safe_gate_split_spec "${package}")" + if [[ "${ecosystem}" == "npm" ]]; then + if [[ -n "${version}" && "${version}" != "latest" ]]; then + printf 'to allow: ask the operator to run: safe run host-allow add %s --reason "..." — then retry' "${package}" + else + printf 'to allow: pin an exact version first (see the resolved-version hint above) — allow entries are never @latest' + fi + else + printf 'to allow: operator review — safe audit check %s --ecosystem %s' "${package}" "${ecosystem}" + fi +} + +# Check one package via safe audit's install gate. +# Returns: 0=GO/proceed, 100=policy refusal, 104=audit BLOCK verdict. +safe_gate_check() { + local package="$1" + local ecosystem="$2" + local audit_status + + if ! safe_gate_audit_available; then + safe_gate_warn_missing + return 0 + fi + + safe_gate_run_audit "${package}" --ecosystem "${ecosystem}" \ + --gate install --op "$(safe_gate_audit_op)" + audit_status=$? + + case "${audit_status}" in + 0) + safe_gate_audit_log "${ecosystem}" "${package}" "PROCEED" + return 0 + ;; + 1|10) + if safe_gate_host_allow_matches "${package}" "${ecosystem}"; then + safe_gate_err "safe install: safe audit warned for ${package}; exact host-allow entry permits install" + safe_gate_audit_log "${ecosystem}" "${package}" "HOST_ALLOW_OVERRIDE" + return 0 + fi + safe_gate_err "safe: BLOCKED ${ecosystem} install of ${package} — safe audit verdict WARN; $(safe_gate_allow_hint "${package}" "${ecosystem}"); details: safe explain" + safe_gate_audit_log "${ecosystem}" "${package}" "REFUSED_WARN" + return 100 + ;; + 2|20) + safe_gate_err "safe: BLOCKED ${ecosystem} install of ${package} — safe audit verdict BLOCK; $(safe_gate_allow_hint "${package}" "${ecosystem}"); details: safe explain" + safe_gate_audit_log "${ecosystem}" "${package}" "REFUSED_BLOCK" + return 104 + ;; + 124|137) + if safe_gate_known_matches "${package}" "${ecosystem}"; then + safe_gate_err "safe install: safe audit timed out; proceeding on recorded clean check for ${package} (stale evidence)" + safe_gate_audit_log "${ecosystem}" "${package}" "STALE_EVIDENCE" + return 0 + fi + safe_gate_err "safe: BLOCKED ${ecosystem} install of ${package} — safe audit timed out (fail closed); retry or ask the operator; details: safe explain" + safe_gate_audit_log "${ecosystem}" "${package}" "TIMEOUT_FAILCLOSED" + return 100 + ;; + *) + safe_gate_err "safe: BLOCKED ${ecosystem} install of ${package} — safe audit failed with exit ${audit_status} (fail closed); ask the operator; details: safe explain" + safe_gate_audit_log "${ecosystem}" "${package}" "REFUSED_AUDIT_ERROR" "exit=${audit_status}" + return 100 + ;; + esac +} + +safe_gate_check_many() { + local ecosystem="$1" + shift + local package + + for package in "$@"; do + safe_gate_check "${package}" "${ecosystem}" || return $? + done + + return 0 +} + +# --------------------------------------------------------------------------- +# Per-ecosystem package extractors +# --------------------------------------------------------------------------- + +safe_gate_print_list() { + (( $# > 0 )) || return 0 + printf '%s\n' "$@" +} + +safe_gate_npm_like_packages() { + local -a packages=() + local arg + local skip_next=0 + local after_double_dash=0 + + for arg in "$@"; do + if (( skip_next )); then + skip_next=0 + continue + fi + + if (( after_double_dash )); then + packages+=("${arg}") + continue + fi + + case "${arg}" in + --) + after_double_dash=1 + continue + ;; + -g|--global|-f|--force|-D|--dev|-P|--peer|-O|--optional|-E|--exact|-T|--tilde|--cached|--save|--save-prod|--save-dev|--save-optional|--save-peer|--no-save|--prefer-offline|--prefer-online|--legacy-peer-deps|--strict-peer-deps|--dry-run|--package-lock-only|--workspace-root|--ignore-scripts|--foreground-scripts) + continue + ;; + --tag|--registry|--@*:registry|--cache|--prefix|--userconfig|--globalconfig|--workspace|-w|--filter|--omit|--include|--install-strategy|--save-prefix|--mode|--cwd) + skip_next=1 + continue + ;; + --tag=*|--registry=*|--cache=*|--prefix=*|--userconfig=*|--globalconfig=*|--workspace=*|--filter=*|--omit=*|--include=*|--install-strategy=*|--save-prefix=*|--mode=*|--cwd=*|--ignore-scripts=*) + continue + ;; + -*) + continue + ;; + *) + packages+=("${arg}") + ;; + esac + done + + safe_gate_print_list "${packages[@]}" +} + +safe_gate_npm_packages() { + safe_gate_npm_like_packages "$@" +} + +safe_gate_pnpm_packages() { + safe_gate_npm_like_packages "$@" +} + +safe_gate_bun_packages() { + safe_gate_npm_like_packages "$@" +} + +safe_gate_yarn_packages() { + safe_gate_npm_like_packages "$@" +} + +safe_gate_cargo_packages() { + local -a packages=() + local arg + local skip_next=0 + local capture_version=0 + local crate_version="" + + for arg in "$@"; do + if (( capture_version )); then + crate_version="${arg}" + capture_version=0 + continue + fi + if (( skip_next )); then + skip_next=0 + continue + fi + + case "${arg}" in + --version|--vers) + # `cargo install --version ` pins the crate; dropping the + # value would audit the bare name (PR#29 review finding 7). + capture_version=1 + continue + ;; + --version=*|--vers=*) + crate_version="${arg#*=}" + continue + ;; + --index|--registry|--root|--target|--features|--bin|--example) + skip_next=1 + continue + ;; + --index=*|--registry=*|--root=*|--target=*|--features=*|--bin=*|--example=*) + continue + ;; + --locked|--offline|--quiet|--debug|--force|-f|--list|--no-track|--all-features|--no-default-features) + continue + ;; + -*) + continue + ;; + *) + packages+=("${arg}") + ;; + esac + done + + if [[ -n "${crate_version}" && ${#packages[@]} -gt 0 ]]; then + local -a versioned=() + for arg in "${packages[@]}"; do + versioned+=("${arg}@${crate_version}") + done + packages=("${versioned[@]}") + fi + + safe_gate_print_list "${packages[@]}" +} + +safe_gate_go_packages() { + local -a packages=() + local arg + local skip_next=0 + + for arg in "$@"; do + if (( skip_next )); then + skip_next=0 + continue + fi + + case "${arg}" in + .|./*|../*|/*) + return 1 + ;; + -tags|-modfile|-overlay|-p|-gcflags|-ldflags|-asmflags|-buildmode|-compiler|-gccgoflags) + skip_next=1 + continue + ;; + -tags=*|-modfile=*|-overlay=*|-p=*|-gcflags=*|-ldflags=*|-asmflags=*|-buildmode=*|-compiler=*|-gccgoflags=*) + continue + ;; + -*) + continue + ;; + *) + packages+=("$(safe_gate_go_spec "${arg}")") + ;; + esac + done + + safe_gate_print_list "${packages[@]}" +} + +safe_gate_composer_packages() { + local -a packages=() + local arg + local skip_next=0 + + for arg in "$@"; do + if (( skip_next )); then + skip_next=0 + continue + fi + + case "${arg}" in + --dev|--update-no-dev|--update-with-dependencies|--update-with-all-dependencies|--ignore-platform-reqs|--no-update|--no-scripts|--no-progress|--no-install|--prefer-source|--prefer-dist|--prefer-install|--sort-packages|--optimize-autoloader|--classmap-authoritative|--apcu-autoloader) + continue + ;; + --working-dir|--repository) + skip_next=1 + continue + ;; + --working-dir=*|--repository=*|--prefer-install=*) + continue + ;; + -*) + continue + ;; + *) + packages+=("$(safe_gate_colon_spec "${arg}")") + ;; + esac + done + + safe_gate_print_list "${packages[@]}" +} + +safe_gate_python_packages() { + local -a packages=() + local arg + local skip_next=0 + + for arg in "$@"; do + if (( skip_next )); then + skip_next=0 + continue + fi + + case "${arg}" in + -r|--requirement|--requirement=*|-e|--editable|--editable=*) + return 1 + ;; + -c|--constraint|-i|--index-url|--index|--default-index|--extra-index-url|--find-links|--trusted-host|--platform|--python-version|--implementation|--abi|--target|--prefix|--src|--upgrade-strategy|--config-settings|-C) + # -i/--index/--default-index take a value: dropping it made the URL + # look like a package spec and fail-opened the extractor (PR#30 + # review finding 1 sibling). The selector itself is threaded to the + # audit by safe_gate_scan_target_flags. + skip_next=1 + continue + ;; + --constraint=*|--index-url=*|--index=*|--default-index=*|--extra-index-url=*|--find-links=*|--trusted-host=*|--platform=*|--python-version=*|--implementation=*|--abi=*|--target=*|--prefix=*|--src=*|--upgrade-strategy=*|--config-settings=*) + continue + ;; + --upgrade|-U|--force-reinstall|--ignore-installed|--user|--break-system-packages|--no-deps|--pre) + continue + ;; + -*) + continue + ;; + .|./*|../*|/*|git+*|http://*|https://*) + return 1 + ;; + *) + packages+=("$(safe_gate_python_spec "${arg}")") + ;; + esac + done + + safe_gate_print_list "${packages[@]}" +} + +# --------------------------------------------------------------------------- +# Spec builders +# --------------------------------------------------------------------------- + +safe_gate_npm_spec() { + local spec="$1" + local name version + + if [[ "${spec}" == *@npm:* ]]; then + spec="${spec#*@npm:}" + fi + + if [[ "${spec}" == @*/*:* ]]; then + name="${spec%:*}" + version="${spec##*:}" + elif [[ "${spec}" != @* && "${spec}" == *:* ]]; then + name="${spec%:*}" + version="${spec##*:}" + elif [[ "${spec}" == @*/*@* ]]; then + name="${spec%@*}" + version="${spec##*@}" + elif [[ "${spec}" == *@* && "${spec}" != @* ]]; then + name="${spec%@*}" + version="${spec##*@}" + else + name="${spec}" + version="latest" + fi + + safe_gate_print_spec "${name}" "${version}" +} + +safe_gate_python_spec() { + local spec="$1" + local name version + + if [[ "${spec}" == *"=="* ]]; then + name="${spec%%==*}" + version="${spec#*==}" + elif [[ "${spec}" == *@* && "${spec}" != @* ]]; then + name="${spec%@*}" + version="${spec##*@}" + else + name="${spec}" + version="latest" + fi + + safe_gate_print_spec "${name}" "${version}" +} + +safe_gate_colon_spec() { + local spec="$1" + local name version + + if [[ "${spec}" == *":"* ]]; then + name="${spec%%:*}" + version="${spec#*:}" + else + name="${spec}" + version="latest" + fi + + safe_gate_print_spec "${name}" "${version}" +} + +safe_gate_go_spec() { + local spec="$1" + local name version + + if [[ "${spec}" == *@* ]]; then + name="${spec%@*}" + version="${spec##*@}" + else + name="${spec}" + version="latest" + fi + + safe_gate_print_spec "${name}" "${version}" +} + +# An unpinned request is audited as the bare name: safe audit resolves the +# real target version itself (a fabricated @latest is version-blind and made +# pinned host-allow entries unmatchable). +safe_gate_print_spec() { + local name="$1" + local version="$2" + + if [[ -z "${version}" || "${version}" == "latest" ]]; then + printf '%s\n' "${name}" + else + printf '%s\n' "${name}@${version}" + fi +} + +# Collect the newline-separated output of an extractor into an array, dropping +# empty entries. Usage: safe_gate_collect +# The locals are underscore-prefixed on purpose: a plain name would shadow the +# caller's variable of the same name and the nameref would resolve to the local. +safe_gate_collect() { + local -n _safe_gate_out_ref="$1" + local _safe_gate_raw="$2" + local _safe_gate_line + + _safe_gate_out_ref=() + [[ -n "${_safe_gate_raw}" ]] || return 0 + while IFS= read -r _safe_gate_line; do + [[ -n "${_safe_gate_line}" ]] && _safe_gate_out_ref+=("${_safe_gate_line}") + done <<< "${_safe_gate_raw}" + return 0 +} + +# A bare command name has no package-spec syntax: no version/tag/alias. +# Only such names are eligible for the local-bin passthrough, because a +# versioned or aliased spec (foo@latest, foo@npm:bar) can still fetch +# remotely even when a same-named local bin exists (npm matches a local +# dep only on exact name AND version). +safe_gate_is_bare_name() { + case "$1" in + *@*|*:*) return 1 ;; + *) return 0 ;; + esac +} + +# Project-local bin lookup for the exec-gate passthrough: walk +# node_modules/.bin from the PHYSICAL cwd upward, nearest first — npm's own +# bin resolution, so hoisted monorepo workspaces resolve their tools. +# Builtins and parameter expansion only: exported functions or PATH shadowing +# must never steer the walk (mirrors find_local_project_bin in bin/safe-run; +# see PR #19 review rounds 1-3 for the symlinked-cwd and shadowing attacks). +safe_gate_local_bin_exists() { + local name="$1" dir + dir=$(builtin pwd -P 2>/dev/null) || return 1 + [[ -n "$dir" ]] || return 1 + while :; do + if [[ -x "$dir/node_modules/.bin/$name" && -f "$dir/node_modules/.bin/$name" ]]; then + return 0 + fi + [[ "$dir" == "/" ]] && return 1 + dir="${dir%/*}" + [[ -n "$dir" ]] || dir="/" + done +} + +# --------------------------------------------------------------------------- +# Subcommand resolution +# --------------------------------------------------------------------------- + +# Locate the real subcommand token, skipping any leading GLOBAL flags a tool +# accepts before its command (`npm --loglevel=error install evil`, +# `yarn --cwd sub add evil`, `pnpm --filter x dlx cmd`). Without this the +# wrappers read the subcommand as $1 and a leading flag slips the whole gate. +# +# Sets SAFE_GATE_SUBCMD (token, "" if none) and SAFE_GATE_SUBCMD_IDX +# (1-based index in "$@", 0 if none). Returns 0 on success (subcommand found +# or genuinely absent), 2 fail-closed on an ambiguous unrecognized space-form +# flag (SAFE_GATE_SUBCMD_BADFLAG names it). +# +# Classification discipline (both directions can bypass, so correctness — not +# completeness — is load-bearing): +# - `=`-form flags (`--x=y`) are unambiguous → always skipped. +# - : flags KNOWN to take a space-form value → skip flag + its value. +# A boolean wrongly listed here eats the real subcommand → bypass. +# - : flags KNOWN to take NO value → skip flag only. +# A value-taker wrongly listed here exposes its value as the subcommand → +# bypass. +# - Any OTHER space-form dash flag is ambiguous → FAIL CLOSED (return 2), +# escapable by rewriting as `--flag=value`. So gaps in either list merely +# over-refuse; only MISCLASSIFICATION bypasses. Lists validated per tool +# against its --help and are load-bearing. +safe_gate_locate_subcommand() { + local val_alt="$1" bool_alt="$2"; shift 2 + local arg i=0 skip=0 boolval=0 + SAFE_GATE_SUBCMD=""; SAFE_GATE_SUBCMD_IDX=0; SAFE_GATE_SUBCMD_BADFLAG="" + for arg in "$@"; do + i=$((i + 1)) + if (( skip )); then skip=0; continue; fi + if (( boolval )); then + boolval=0 + # A bare true/false immediately after a boolean flag is that flag's + # explicit space-form value: npm/pnpm config booleans accept it + # (`npm --global false install ...` means --global=false, subcommand + # install). Consume it. This can never swallow a real subcommand — no + # gated subcommand is named true/false — so it is safe on every tool; + # any OTHER token here is the subcommand or next flag → fall through. + case "${arg,,}" in + true|false) continue ;; + esac + fi + case "${arg}" in + --) + # Options terminated: the next token, if any, is the subcommand. + if (( i < $# )); then + SAFE_GATE_SUBCMD="${@:i+1:1}"; SAFE_GATE_SUBCMD_IDX=$((i + 1)) + fi + return 0 ;; + --*=*|-*=*) continue ;; + -?*) + if safe_gate_alt_match "${arg}" "${val_alt}"; then + skip=1; continue + elif safe_gate_alt_match "${arg}" "${bool_alt}"; then + boolval=1; continue + fi + SAFE_GATE_SUBCMD_BADFLAG="${arg}"; return 2 ;; + *) SAFE_GATE_SUBCMD="${arg}"; SAFE_GATE_SUBCMD_IDX=$i; return 0 ;; + esac + done + return 0 +} + +# Per-tool leading-global-flag tables (val|bool), kept deliberately small: a +# gap only over-refuses (fail-closed, escapable via =form); a MISclassified +# flag bypasses. `go` accepts no pre-command flags → empty tables (any leading +# flag fails closed, matching go's own grammar). +# +# Added over the zsh tables: the version/help switches (--version, -v or -V per +# tool, --help, -h). They were a gap, not a policy: the zsh wrappers refused a +# bare `npm --version`, which nothing noticed while gating only existed inside +# an interactive zsh. As PATH wrappers they are on the path of every tool +# detection script, and they take no value in any of these tools, so they are +# classified as switches like any other boolean. +safe_gate_global_flags() { + case "$1" in + npm) + SAFE_GATE_GVAL='--loglevel|--prefix|--cache|--registry|--userconfig|--globalconfig|--workspace|-w|--script-shell|--node-options|--omit|--include|--tag' + SAFE_GATE_GBOOL='--version|-v|--help|-h|-q|--quiet|--silent|-d|-dd|-ddd|--verbose|--global|-g|--foreground-scripts|--no-fund|--no-audit|--offline|--prefer-offline|--prefer-online|--ignore-scripts|--workspaces|--include-workspace-root|--no-workspaces|--no-color|--json' ;; + pnpm) + # --config is a no-value switch on `pnpm add` (configurational deps), + # NOT a value-taker — keep it out of GVAL or it eats the subcommand. + SAFE_GATE_GVAL='--filter|-F|--dir|-C|--reporter|--workspace-concurrency' + SAFE_GATE_GBOOL='--version|-v|--help|-h|-w|--workspace-root|-r|--recursive|--stream|--silent|--no-color|--global|-g|--aggregate-output|--config' ;; + bun) + # bun's --cwd/--config/-c are EQUALS-ONLY (`--config=`); bun does + # not consume a following space-form token, so they must not sit in the + # space-form value table (that would eat the subcommand). The =form is + # covered by the generic --*=* skip; bare space-form fails closed. + SAFE_GATE_GVAL='' + SAFE_GATE_GBOOL='--version|-v|--help|-h|--silent|--global|-g|--no-cache|--no-progress' ;; + yarn) + SAFE_GATE_GVAL='--cwd|--registry|--modules-folder|--cache-folder|--mutex|--network-timeout|--network-concurrency|--proxy|--https-proxy' + SAFE_GATE_GBOOL='--version|-v|--help|-h|--verbose|--silent|-s|--offline|--prefer-offline|--no-progress|--json|--flat|--force|--ignore-scripts|--non-interactive|--no-lockfile|--frozen-lockfile' ;; + pip|pip3) + # --use-feature takes a value (e.g. fast-deps) — GVAL, not GBOOL. + SAFE_GATE_GVAL='--log|--proxy|--retries|--timeout|--cache-dir|--python|--index-url|-i|--cert|--client-cert|--use-feature' + SAFE_GATE_GBOOL='--version|-V|--help|-h|-q|--quiet|-v|-vv|-vvv|--verbose|--isolated|--no-input|--no-color|--require-virtualenv|--no-cache-dir|--disable-pip-version-check' ;; + uv) + SAFE_GATE_GVAL='--cache-dir|--config-file|--directory|--project|--color' + SAFE_GATE_GBOOL='--version|-V|--help|-h|--offline|-q|--quiet|-v|--verbose|--native-tls|--no-cache|-n|--no-progress|--no-config' ;; + cargo) + SAFE_GATE_GVAL='--color|--config|-Z|-C' + SAFE_GATE_GBOOL='--version|-V|--help|-h|-v|--verbose|-q|--quiet|--offline|--frozen|--locked' ;; + composer) + SAFE_GATE_GVAL='-d|--working-dir' + SAFE_GATE_GBOOL='-v|-vv|-vvv|--verbose|-q|--quiet|-n|--no-interaction|--profile|--no-plugins|--no-scripts|--ansi|--no-ansi|-h|--help|-V|--version' ;; + go|*) + SAFE_GATE_GVAL='' + SAFE_GATE_GBOOL='' ;; + esac +} + +# Resolve the subcommand for over "$@"; on ambiguity print the legible +# fail-closed refusal and return 100 so the caller can `|| return $?`. +safe_gate_route() { + local tool="$1"; shift + safe_gate_global_flags "${tool}" + safe_gate_locate_subcommand "${SAFE_GATE_GVAL}" "${SAFE_GATE_GBOOL}" "$@" + case $? in + 2) + safe_gate_err "safe: BLOCKED ${tool} — cannot find the subcommand past unrecognized flag '${SAFE_GATE_SUBCMD_BADFLAG}'; to allow: rewrite it as '${SAFE_GATE_SUBCMD_BADFLAG}=', then retry; details: safe explain" + return 100 ;; + esac + return 0 +} + +# --------------------------------------------------------------------------- +# Exec-style (fetch-and-run) gate +# --------------------------------------------------------------------------- + +# Gate exec-style invocations that can fetch and run registry packages +# (npm exec/x, bun x, pnpm dlx, yarn dlx, uv tool run). Audits every package +# the invocation would fetch, then the caller delegates to the real tool. +# +# Args: