Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/lemans/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Lemans
VERSION = "1.3.4"
VERSION = "1.3.5"
end
2 changes: 2 additions & 0 deletions lib/miniswen/environment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 19 additions & 10 deletions lib/miniswen/jail.rb
Original file line number Diff line number Diff line change
@@ -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),
Comment thread
ardecvz marked this conversation as resolved.
"unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc", "sh", "-c", setup,
pgroup: true, unsetenv_others: true
)
return self if @stdout.gets == "ready\n"

Expand All @@ -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 /
Comment thread
ardecvz marked this conversation as resolved.
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}",
Expand Down
2 changes: 0 additions & 2 deletions lib/miniswen/local.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand Down
2 changes: 1 addition & 1 deletion lib/miniswen/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Miniswen
VERSION = "1.3.4"
VERSION = "1.3.5"
end
8 changes: 8 additions & 0 deletions test/miniswen/cli_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 12 additions & 3 deletions test/miniswen/jail_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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