From 88fa95303e0f3cd275dc1d1e4aaed5b127faff50 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Mon, 3 Aug 2026 17:41:01 -0400 Subject: [PATCH 1/4] bin/dev: scrub foreign bundler activation before Ruby boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A harness running under bundle exec leaks RUBYOPT/BUNDLE_* into every child, and the interpreter activates the caller's bundle before dev's Ruby half runs a single line — dev then can't load its own gems (observed live as ai-flow#44's LoadError). dev picks its own Ruby and gems; no caller's activation is ever wanted, so the sh shim unsets the activation keys itself, making every caller's scrub defense-in-depth instead of load-bearing. Closes #94 Co-authored-by: Cursor --- bin/dev | 12 ++++++++ test/dev/bin_dev_test.rb | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 test/dev/bin_dev_test.rb diff --git a/bin/dev b/bin/dev index 862e4a0..56d4a34 100755 --- a/bin/dev +++ b/bin/dev @@ -1,4 +1,16 @@ #!/bin/sh +# Self-defense against a caller's bundler activation (dev#94): a harness +# running under `bundle exec` leaks RUBYOPT=-r.../bundler/setup and BUNDLE_* +# into every child, and the interpreter activates the caller's bundle before +# this script's Ruby half runs a single line — dev then can't load its own +# gems. dev picks its own Ruby and gems; no caller's activation is ever +# wanted, so the unset happens here in the sh layer, ahead of the version +# probe below (which would itself crash under a hostile RUBYOPT). GEM_HOME/ +# GEM_PATH stay: they are legitimate user config, and the one resolution +# they can redirect is guarded at its call site (dev#90). +unset RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_APP_CONFIG \ + BUNDLE_BIN BUNDLE_BIN_PATH BUNDLER_VERSION BUNDLER_SETUP + # Use PATH ruby (rbenv) if >= 3.1, fall back to Homebrew Ruby for bootstrapping. # dev uses Ruby 3.1+ syntax (e.g. hash literal value omission). if command -v ruby >/dev/null 2>&1; then diff --git a/test/dev/bin_dev_test.rb b/test/dev/bin_dev_test.rb new file mode 100644 index 0000000..64f63ac --- /dev/null +++ b/test/dev/bin_dev_test.rb @@ -0,0 +1,61 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "open3" +require "tmpdir" + +# Integration tests spawning the real bin/dev shim: the harness-env defense +# happens in the sh layer before Ruby boots (a leaked RUBYOPT is processed +# by the interpreter ahead of the script's first line), so only a real +# spawn can exercise it. +transform!(RSpock::AST::Transformation) +class Dev::BinDevTest < Minitest::Test + BIN_DEV = File.expand_path("../../bin/dev", __dir__) + + # The env a `bundle exec` harness leaks into its children (ai-flow#44): + # RUBYOPT force-activates the caller's bundle at interpreter startup and + # BUNDLE_GEMFILE points it at a Gemfile dev has never heard of. + HOSTILE_BUNDLER_ENV = { + "RUBYOPT" => "-rbundler/setup", + "RUBYLIB" => "/harness/.ai-flow/lib", + "BUNDLE_GEMFILE" => "/nonexistent/harness/Gemfile", + "BUNDLE_PATH" => "/nonexistent/harness/vendor", + "BUNDLE_APP_CONFIG" => "/nonexistent/harness/.bundle", + "BUNDLE_BIN_PATH" => "/nonexistent/harness/bin/bundle", + "BUNDLER_VERSION" => "9.9.9", + }.freeze + + test "dev boots under a foreign bundler activation instead of loading the caller's bundle" do + Given "a directory with no dev.yml and a hostile bundle-exec environment" + dir = Dir.mktmpdir("dev-bin-test-") + + When "running bin/dev there" + _out, err, status = Open3.capture3(HOSTILE_BUNDLER_ENV, "sh", BIN_DEV, chdir: dir) + + Then "dev's own Ruby program ran — it reached its normal no-dev.yml refusal, not a bundler crash" + !status.success? + err.include?("no dev.yml found") + !err.include?("bundler") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "dev's children never see the foreign bundler activation" do + Given "a project whose dev.yml command prints the bundler keys, and a hostile environment" + dir = Dir.mktmpdir("dev-bin-test-") + probe = "ruby -e 'puts [ENV[%q(RUBYOPT)], ENV[%q(BUNDLE_GEMFILE)]].inspect'" + File.write(File.join(dir, "dev.yml"), "name: probe-project\ncommands:\n probe:\n run: #{probe.inspect}\n") + + When "running the probe through dev" + out, _err, status = Open3.capture3(HOSTILE_BUNDLER_ENV, "sh", BIN_DEV, "probe", chdir: dir) + + Then "the child sees neither key" + status.success? + out.include?("[nil, nil]") + + Cleanup + FileUtils.rm_rf(dir) + end +end From 049bbc8c6f833f700b0e0b38fb0a48da517842b3 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Mon, 3 Aug 2026 17:49:52 -0400 Subject: [PATCH 2/4] Probe the shim scrub with a stub ruby instead of a full dev command run The dev.yml probe dragged shadowenv provisioning into the test, which fails on CI runners; a PATH-stubbed ruby printing its env pins the exact unset list hermetically. Co-authored-by: Cursor --- test/dev/bin_dev_test.rb | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/test/dev/bin_dev_test.rb b/test/dev/bin_dev_test.rb index 64f63ac..9f00958 100644 --- a/test/dev/bin_dev_test.rb +++ b/test/dev/bin_dev_test.rb @@ -42,18 +42,40 @@ class Dev::BinDevTest < Minitest::Test FileUtils.rm_rf(dir) end - test "dev's children never see the foreign bundler activation" do - Given "a project whose dev.yml command prints the bundler keys, and a hostile environment" + # Pins the exact unset set: dropping a key from the shim's scrub would + # silently re-open the leak for that key. The stub ruby stands in for the + # real one so the assertion sees the env exactly as the shim hands it + # over, without dev's own Ruby machinery (or provisioning) in the way. + test "the shim strips every bundler-activation key before ruby ever runs" do + Given "a stub ruby printing the env it receives, and every scrub key planted hostile" dir = Dir.mktmpdir("dev-bin-test-") - probe = "ruby -e 'puts [ENV[%q(RUBYOPT)], ENV[%q(BUNDLE_GEMFILE)]].inspect'" - File.write(File.join(dir, "dev.yml"), "name: probe-project\ncommands:\n probe:\n run: #{probe.inspect}\n") + stub_bin = File.join(dir, "bin") + FileUtils.mkdir_p(stub_bin) + File.write(File.join(stub_bin, "ruby"), <<~SH) + #!/bin/sh + # The shim's version probe (`ruby -e ...`) passes; the real exec + # (`ruby -x bin/dev`) prints the env instead of running dev. + case "$1" in + -e) exit 0 ;; + *) env ;; + esac + SH + FileUtils.chmod(0o755, File.join(stub_bin, "ruby")) + scrub_keys = %w[ + RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_APP_CONFIG + BUNDLE_BIN BUNDLE_BIN_PATH BUNDLER_VERSION BUNDLER_SETUP + ] + hostile = scrub_keys.to_h { |key| [key, "/hostile/#{key}"] } + env = hostile.merge("PATH" => "#{stub_bin}:#{ENV.fetch("PATH")}") - When "running the probe through dev" - out, _err, status = Open3.capture3(HOSTILE_BUNDLER_ENV, "sh", BIN_DEV, "probe", chdir: dir) + When "running the shim" + out, _err, status = Open3.capture3(env, "sh", BIN_DEV, chdir: dir) - Then "the child sees neither key" + Then "no hostile key survives into the ruby process" status.success? - out.include?("[nil, nil]") + # Whole-name matches: the suite's own env carries inert BUNDLER_ORIG_* + # records whose names contain scrub keys as substrings. + (out.lines.map { |line| line.split("=", 2).first } & scrub_keys).empty? Cleanup FileUtils.rm_rf(dir) From f7197ac86005eb256b0854ba15f3b35ac0342901 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Mon, 3 Aug 2026 19:28:44 -0400 Subject: [PATCH 3/4] Drift guard: the unset list must cover what the running bundler exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite's own bundle exec activation is the live truth: any key where ENV differs from Bundler.original_env is something the locked bundler exported, so a bundler bump that exports a new activation key turns the build red naming it. Subset direction only — config-dependent keys the launch didn't export are free no-ops, so exact equality would just add flake. The guard caught its first drift before shipping: bundler 4 exports BUNDLE_LOCKFILE, which the hand-pinned list missed. Co-authored-by: Cursor --- bin/dev | 4 ++-- test/dev/bin_dev_test.rb | 30 ++++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/bin/dev b/bin/dev index 56d4a34..689ea91 100755 --- a/bin/dev +++ b/bin/dev @@ -8,8 +8,8 @@ # probe below (which would itself crash under a hostile RUBYOPT). GEM_HOME/ # GEM_PATH stay: they are legitimate user config, and the one resolution # they can redirect is guarded at its call site (dev#90). -unset RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_APP_CONFIG \ - BUNDLE_BIN BUNDLE_BIN_PATH BUNDLER_VERSION BUNDLER_SETUP +unset RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_LOCKFILE BUNDLE_PATH \ + BUNDLE_APP_CONFIG BUNDLE_BIN BUNDLE_BIN_PATH BUNDLER_VERSION BUNDLER_SETUP # Use PATH ruby (rbenv) if >= 3.1, fall back to Homebrew Ruby for bootstrapping. # dev uses Ruby 3.1+ syntax (e.g. hash literal value omission). diff --git a/test/dev/bin_dev_test.rb b/test/dev/bin_dev_test.rb index 9f00958..e4d9e68 100644 --- a/test/dev/bin_dev_test.rb +++ b/test/dev/bin_dev_test.rb @@ -42,6 +42,32 @@ class Dev::BinDevTest < Minitest::Test FileUtils.rm_rf(dir) end + # Drift guard: the shim's unset list is a hand-pinned denylist of what + # `bundle exec` exports into children, and that set grows across bundler + # versions — a new exported key would silently re-open the leak. The + # suite itself runs under bundle exec, so the live truth is right here: + # every key where ENV differs from Bundler.original_env is something the + # locked bundler exported. Subset direction only, on purpose: keys the + # shim lists but this launch didn't export (config-dependent ones like + # BUNDLE_PATH) are free no-ops, so exact equality would only add flake. + test "the unset list covers every key the running bundler exports" do + Given "the env diff bundler's own activation left on this process" + exported = (ENV.keys | Bundler.original_env.keys).select { |key| ENV[key] != Bundler.original_env[key] } + # BUNDLER_ORIG_* are bundler's inert restore records, not activation + # keys; GEM_* are deliberately out of the shim's scope (dev#94). + hostile = exported.grep(/\A(?:BUNDLE_|BUNDLER_(?!ORIG_)|RUBYOPT\z|RUBYLIB\z)/) + + Expect "a bundler-activated suite (or this guard proves nothing), with every exported key in the shim's unset list" + hostile.include?("BUNDLE_GEMFILE") + (hostile - shim_unset_keys).empty? + end + + # The scrub keys parsed out of the shim's `unset` line (with its + # backslash continuations), so the guard reads what actually ships. + def shim_unset_keys + File.read(BIN_DEV)[/^unset ((?:\\\n|[^\n])*)/, 1].gsub("\\\n", " ").split + end + # Pins the exact unset set: dropping a key from the shim's scrub would # silently re-open the leak for that key. The stub ruby stands in for the # real one so the assertion sees the env exactly as the shim hands it @@ -62,8 +88,8 @@ class Dev::BinDevTest < Minitest::Test SH FileUtils.chmod(0o755, File.join(stub_bin, "ruby")) scrub_keys = %w[ - RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_APP_CONFIG - BUNDLE_BIN BUNDLE_BIN_PATH BUNDLER_VERSION BUNDLER_SETUP + RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_LOCKFILE BUNDLE_PATH + BUNDLE_APP_CONFIG BUNDLE_BIN BUNDLE_BIN_PATH BUNDLER_VERSION BUNDLER_SETUP ] hostile = scrub_keys.to_h { |key| [key, "/hostile/#{key}"] } env = hostile.merge("PATH" => "#{stub_bin}:#{ENV.fetch("PATH")}") From 29b2e16af143d8abc28ff82667824465d4f02617 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Mon, 3 Aug 2026 19:38:04 -0400 Subject: [PATCH 4/4] Test readability: one aliased scrub list, one property per test The sh shim is the single source of truth for the scrub list (the unset must run before any Ruby exists), so the tests alias it by parsing bin/dev instead of keeping a pinned copy that could drift; the stub-ruby and env-parsing mechanics move into named helpers and the file comment states what each of the three tests proves. Co-authored-by: Cursor --- test/dev/bin_dev_test.rb | 138 ++++++++++++++++++++------------------- 1 file changed, 71 insertions(+), 67 deletions(-) diff --git a/test/dev/bin_dev_test.rb b/test/dev/bin_dev_test.rb index e4d9e68..5410771 100644 --- a/test/dev/bin_dev_test.rb +++ b/test/dev/bin_dev_test.rb @@ -5,35 +5,40 @@ require "open3" require "tmpdir" -# Integration tests spawning the real bin/dev shim: the harness-env defense -# happens in the sh layer before Ruby boots (a leaked RUBYOPT is processed -# by the interpreter ahead of the script's first line), so only a real -# spawn can exercise it. +# bin/dev opens with an `unset` of the caller's bundler-activation env +# (dev#94): a harness running under `bundle exec` leaks RUBYOPT/BUNDLE_* +# into every child, and the Ruby interpreter acts on RUBYOPT before the +# script's first line — so the defense lives in the sh layer, and only +# spawning the real shim can exercise it. Three tests, one property each: +# +# 1. dev still boots when the caller's env is hostile (the bug's symptom). +# 2. The shim hands Ruby an env with every scrub key removed (the fix, +# key by key). +# 3. The scrub list keeps up with bundler: whatever the locked bundler +# exports must be on it (the drift over time). transform!(RSpock::AST::Transformation) class Dev::BinDevTest < Minitest::Test BIN_DEV = File.expand_path("../../bin/dev", __dir__) - # The env a `bundle exec` harness leaks into its children (ai-flow#44): - # RUBYOPT force-activates the caller's bundle at interpreter startup and - # BUNDLE_GEMFILE points it at a Gemfile dev has never heard of. - HOSTILE_BUNDLER_ENV = { - "RUBYOPT" => "-rbundler/setup", - "RUBYLIB" => "/harness/.ai-flow/lib", - "BUNDLE_GEMFILE" => "/nonexistent/harness/Gemfile", - "BUNDLE_PATH" => "/nonexistent/harness/vendor", - "BUNDLE_APP_CONFIG" => "/nonexistent/harness/.bundle", - "BUNDLE_BIN_PATH" => "/nonexistent/harness/bin/bundle", - "BUNDLER_VERSION" => "9.9.9", - }.freeze + # The scrub list, parsed from the shim's `unset` line (joining its + # backslash continuations). The sh script is the single source of truth + # — the scrub must run before any Ruby exists, so the list cannot live + # in a Ruby constant; tests alias it by parsing rather than keeping a + # copy that could drift. + SHIM_UNSET_KEYS = File.read(BIN_DEV)[/^unset ((?:\\\n|[^\n])*)/, 1].gsub("\\\n", " ").split.freeze - test "dev boots under a foreign bundler activation instead of loading the caller's bundle" do - Given "a directory with no dev.yml and a hostile bundle-exec environment" + test "a hostile bundler env cannot stop dev from booting" do + Given "a directory with no dev.yml, and the env a bundle-exec harness leaks (ai-flow#44)" dir = Dir.mktmpdir("dev-bin-test-") + hostile = { + "RUBYOPT" => "-rbundler/setup", + "BUNDLE_GEMFILE" => "/nonexistent/harness/Gemfile", + } When "running bin/dev there" - _out, err, status = Open3.capture3(HOSTILE_BUNDLER_ENV, "sh", BIN_DEV, chdir: dir) + _out, err, status = Open3.capture3(hostile, "sh", BIN_DEV, chdir: dir) - Then "dev's own Ruby program ran — it reached its normal no-dev.yml refusal, not a bundler crash" + Then "dev reached its own no-dev.yml refusal — not a crash inside the caller's bundler" !status.success? err.include?("no dev.yml found") !err.include?("bundler") @@ -42,68 +47,67 @@ class Dev::BinDevTest < Minitest::Test FileUtils.rm_rf(dir) end - # Drift guard: the shim's unset list is a hand-pinned denylist of what - # `bundle exec` exports into children, and that set grows across bundler - # versions — a new exported key would silently re-open the leak. The - # suite itself runs under bundle exec, so the live truth is right here: - # every key where ENV differs from Bundler.original_env is something the - # locked bundler exported. Subset direction only, on purpose: keys the - # shim lists but this launch didn't export (config-dependent ones like - # BUNDLE_PATH) are free no-ops, so exact equality would only add flake. - test "the unset list covers every key the running bundler exports" do - Given "the env diff bundler's own activation left on this process" - exported = (ENV.keys | Bundler.original_env.keys).select { |key| ENV[key] != Bundler.original_env[key] } - # BUNDLER_ORIG_* are bundler's inert restore records, not activation - # keys; GEM_* are deliberately out of the shim's scope (dev#94). - hostile = exported.grep(/\A(?:BUNDLE_|BUNDLER_(?!ORIG_)|RUBYOPT\z|RUBYLIB\z)/) + test "the shim removes every scrub key from the env it hands to ruby" do + Given "every scrub key planted hostile, and a stub ruby that prints the env it receives" + dir = Dir.mktmpdir("dev-bin-test-") + hostile = SHIM_UNSET_KEYS.to_h { |key| [key, "/hostile/#{key}"] } + env = hostile.merge("PATH" => "#{write_stub_ruby(dir)}:#{ENV.fetch("PATH")}") - Expect "a bundler-activated suite (or this guard proves nothing), with every exported key in the shim's unset list" - hostile.include?("BUNDLE_GEMFILE") - (hostile - shim_unset_keys).empty? + When "running the shim" + out, _err, status = Open3.capture3(env, "sh", BIN_DEV, chdir: dir) + + Then "no scrub key survives into the ruby process" + status.success? + (env_names(out) & SHIM_UNSET_KEYS).empty? + + Cleanup + FileUtils.rm_rf(dir) end - # The scrub keys parsed out of the shim's `unset` line (with its - # backslash continuations), so the guard reads what actually ships. - def shim_unset_keys - File.read(BIN_DEV)[/^unset ((?:\\\n|[^\n])*)/, 1].gsub("\\\n", " ").split + # The scrub list is a denylist of what `bundle exec` exports, and that + # set grows across bundler versions — a new exported key would silently + # re-open the leak. This suite itself runs under bundle exec, so the + # live truth is at hand: every key where ENV differs from + # Bundler.original_env was exported by the locked bundler. Subset + # direction only: keys the shim lists but this launch didn't export + # (config-dependent ones like BUNDLE_PATH) are free no-ops, so exact + # equality would only add flake. + test "the scrub list covers every key the running bundler exports" do + Given "the env diff bundler's activation left on this test process" + exported = (ENV.keys | Bundler.original_env.keys).select { |key| ENV[key] != Bundler.original_env[key] } + # In scope: bundler/ruby activation keys. Out of scope: BUNDLER_ORIG_* + # (bundler's inert restore records) and GEM_* (legitimate user config, + # per dev#94). + activation_key = /\A(?:BUNDLE_|BUNDLER_(?!ORIG_)|RUBYOPT\z|RUBYLIB\z)/ + hostile = exported.grep(activation_key) + + Expect "a bundler-activated suite (else this guard proves nothing), fully covered by the scrub list" + hostile.include?("BUNDLE_GEMFILE") + (hostile - SHIM_UNSET_KEYS).empty? end - # Pins the exact unset set: dropping a key from the shim's scrub would - # silently re-open the leak for that key. The stub ruby stands in for the - # real one so the assertion sees the env exactly as the shim hands it - # over, without dev's own Ruby machinery (or provisioning) in the way. - test "the shim strips every bundler-activation key before ruby ever runs" do - Given "a stub ruby printing the env it receives, and every scrub key planted hostile" - dir = Dir.mktmpdir("dev-bin-test-") + # A PATH-front stub standing in for ruby: the shim's version probe + # (`ruby -e ...`) passes, and the real launch (`ruby -x bin/dev`) prints + # the received env instead of running dev — so the assertion sees the + # exact hand-off env without dev's Ruby machinery (or provisioning) + # getting involved. Returns the bin dir to prepend to PATH. + def write_stub_ruby(dir) stub_bin = File.join(dir, "bin") FileUtils.mkdir_p(stub_bin) File.write(File.join(stub_bin, "ruby"), <<~SH) #!/bin/sh - # The shim's version probe (`ruby -e ...`) passes; the real exec - # (`ruby -x bin/dev`) prints the env instead of running dev. case "$1" in -e) exit 0 ;; *) env ;; esac SH FileUtils.chmod(0o755, File.join(stub_bin, "ruby")) - scrub_keys = %w[ - RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_LOCKFILE BUNDLE_PATH - BUNDLE_APP_CONFIG BUNDLE_BIN BUNDLE_BIN_PATH BUNDLER_VERSION BUNDLER_SETUP - ] - hostile = scrub_keys.to_h { |key| [key, "/hostile/#{key}"] } - env = hostile.merge("PATH" => "#{stub_bin}:#{ENV.fetch("PATH")}") - - When "running the shim" - out, _err, status = Open3.capture3(env, "sh", BIN_DEV, chdir: dir) - - Then "no hostile key survives into the ruby process" - status.success? - # Whole-name matches: the suite's own env carries inert BUNDLER_ORIG_* - # records whose names contain scrub keys as substrings. - (out.lines.map { |line| line.split("=", 2).first } & scrub_keys).empty? + stub_bin + end - Cleanup - FileUtils.rm_rf(dir) + # Variable names from `env` output — whole names, because the suite's + # own BUNDLER_ORIG_* records contain scrub keys as substrings. + def env_names(env_output) + env_output.lines.map { |line| line.split("=", 2).first } end end