diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c1901..c362dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## [Unreleased] +## [1.3.5] - 2026-09-15 + +- Docker: `--docker` no longer fails at exit. +- Miniswen: the jail makes the whole system read-only and does not leak env vars, which were already inaccessible. + ## [1.3.4] - 2026-09-15 - Miniswen: `--jail` runs every command in its own namespaces: none of the harness's environment, no network, read-only system, none of its files. `miniswen-installed` always runs jailed. diff --git a/lib/lemans/version.rb b/lib/lemans/version.rb index b89a690..82f5b96 100644 --- a/lib/lemans/version.rb +++ b/lib/lemans/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Lemans - VERSION = "1.3.4" + VERSION = "1.3.5" end diff --git a/lib/miniswen/environment.rb b/lib/miniswen/environment.rb index 655f4e6..3639e8b 100644 --- a/lib/miniswen/environment.rb +++ b/lib/miniswen/environment.rb @@ -10,5 +10,7 @@ def success? = exit_code.zero? # Execute a shell command def exec(cmd, timeout: nil, env: nil) = raise NotImplementedError + + def stop = nil end end diff --git a/lib/miniswen/jail.rb b/lib/miniswen/jail.rb index 57c91d1..a772a4b 100644 --- a/lib/miniswen/jail.rb +++ b/lib/miniswen/jail.rb @@ -1,27 +1,22 @@ # frozen_string_literal: true +require "shellwords" + require "miniswen/local" module Miniswen # Runs every command in its own namespaces: none of the harness's environment, # no network, read-only system, none of its files. class Jail < Local - SETUP = <<~SH - set -eu - ip link set lo up - for dir in /usr /etc /opt; do mount -o bind,ro "$dir" "$dir"; done - for dir in /tmp /run /root; do mkdir -p "/var/lib/miniswen$dir" && mount --bind "/var/lib/miniswen$dir" "$dir"; done - echo ready - exec sleep infinity - SH - def initialize(workdir: Dir.pwd) @workdir = workdir end def start _, @stdout, @stderr, @holder = Open3.popen3( - "unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc", "sh", "-c", SETUP, pgroup: true + ENV.to_h.slice(*container_variables), + "unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc", "sh", "-c", setup, + pgroup: true, unsetenv_others: true ) return self if @stdout.gets == "ready\n" @@ -34,6 +29,20 @@ def stop private + def setup = <<~SH + set -eu + # Bring loopback up + ip link set lo up + # Make the workdir its own mount + mount --bind #{Shellwords.escape(@workdir)} #{Shellwords.escape(@workdir)} + # Make private temp dirs their own mounts + for dir in /tmp /run /root; do mkdir -p "/var/lib/miniswen$dir" && mount --bind "/var/lib/miniswen$dir" "$dir"; done + # Make everything without its own mount read-only + mount -o remount,bind,ro / + echo ready + exec sleep infinity + SH + def spawn_arguments(command, env) [ ENV.to_h.merge(env.to_h).slice(*container_variables), "nsenter", "--target", @holder.pid.to_s, "--net", "--mount", "--pid=/proc/#{@holder.pid}/ns/pid_for_children", "--wd=#{@workdir}", diff --git a/lib/miniswen/local.rb b/lib/miniswen/local.rb index 18b53a8..fdb748e 100644 --- a/lib/miniswen/local.rb +++ b/lib/miniswen/local.rb @@ -27,8 +27,6 @@ def exec(command, timeout: nil, env: nil) end end - def stop = nil - private def spawn_arguments(command, env) = [ env || {}, "sh", "-c", command ] diff --git a/lib/miniswen/version.rb b/lib/miniswen/version.rb index d022cb2..79e1e0a 100644 --- a/lib/miniswen/version.rb +++ b/lib/miniswen/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Miniswen - VERSION = "1.3.4" + VERSION = "1.3.5" end diff --git a/test/miniswen/cli_test.rb b/test/miniswen/cli_test.rb index 54201d8..d60b872 100644 --- a/test/miniswen/cli_test.rb +++ b/test/miniswen/cli_test.rb @@ -188,6 +188,14 @@ def run_cli(*argv) ARGV.replace(original) end + def test_a_docker_run_exits_cleanly + Dir.mktmpdir do |dir| + assert_raises(RubyLLM::Test::Errors::NoResponseProvidedError) do + run_cli("-q", "--no-refresh-registry", "--docker", "c", "--results-path=#{dir}/result.json", "-m", "test", "-p", "task") + end + end + end + def test_a_crashed_run_still_writes_the_results_and_trajectory_files Dir.mktmpdir do |dir| results = File.join(dir, "result.json") diff --git a/test/miniswen/jail_test.rb b/test/miniswen/jail_test.rb index f08d6a1..7d4723f 100644 --- a/test/miniswen/jail_test.rb +++ b/test/miniswen/jail_test.rb @@ -5,15 +5,24 @@ class MiniswenJailTest < Minitest::Test def setup + @key = ENV.fetch("OPENROUTER_API_KEY", nil) + skip "needs root" unless Process.uid.zero? + ENV["OPENROUTER_API_KEY"] = "sk" @jail = Miniswen::Jail.new(workdir: Dir.tmpdir).start end - def teardown = @jail&.stop + def teardown + ENV["OPENROUTER_API_KEY"] = @key + @jail&.stop + end def test_commands_do_not_get_the_harness_environment - assert_equal "0\n", @jail.exec("env | grep -c OPENROUTER", env: { "OPENROUTER_API_KEY" => "sk" }).output + holder = @jail.instance_variable_get(:@holder).pid + + assert_equal "0\n", @jail.exec("env | grep -c OPENROUTER").output + refute_includes File.read("/proc/#{holder}/environ"), "OPENROUTER" end def test_commands_have_no_network_but_loopback @@ -22,7 +31,7 @@ def test_commands_have_no_network_but_loopback end def test_commands_cannot_touch_the_system_or_see_the_harness_files - assert_equal "read-only\n", @jail.exec("(touch /usr/x 2>/dev/null && echo writable) || echo read-only").output + assert_equal "read-only\n", @jail.exec("(touch /x 2>/dev/null && echo writable) || echo read-only").output assert_equal "0\n0\n", @jail.exec("ls -A /root | wc -l; ls -A /tmp | wc -l").output end end