lemans 1.3.3 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08d62b21b79425a5c1e99867270b4cf2dd41ccb07f8ab7f5e5381a416170907f'
4
- data.tar.gz: 802e7e3f665913b95a5409882f10b067f4118efe1e2de650a07dd93382f035af
3
+ metadata.gz: 5788e530e81eac31830bb91ab40a07e62edd6d6f8c371378d64384f716ee71a4
4
+ data.tar.gz: f20ff180fbf2c6bc3b2c6f15793f6a5e53d86ac052b462629cd2877e0e1e445e
5
5
  SHA512:
6
- metadata.gz: fabad5219961a7514c44f888bd79713db444a1dc5b8b20c6153ae1e55c4252cc4065939bb6671a4e6511a1809f4b89da00ff2a642825348c184969fdf49a7c15
7
- data.tar.gz: '08a08843cb79dcb1da14e27b1b11d2126fe0d8049dc7cdbcccb573c0c93d81f44613ed6f4e905ec23ab3e9f41e2f6eac44a49e9d520c50ebfca3f82a6d9f4f5f'
6
+ metadata.gz: 9791f737cafb403fe0a01999ea04c36254f5b9db32a59f7d2d38d22fa9fac79bf751d4f3c20efc2ff78296d27c95cf2d8243e3ad6a024a63ac0f2ea510db07f2
7
+ data.tar.gz: 4e0fd6ca35ed5803a79fd722f30e53ec19e21b1482f97c6e77b0d9d187122dc85ab234b8a87808cd6591be431b961766f3f73bc2811e006263da6c41957ec608
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.5] - 2026-09-15
4
+
5
+ - Docker: `--docker` no longer fails at exit.
6
+ - Miniswen: the jail makes the whole system read-only and does not leak env vars, which were already inaccessible.
7
+
8
+ ## [1.3.4] - 2026-09-15
9
+
10
+ - 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.
11
+ - Docker: containers start with SYS_ADMIN, NET_ADMIN and AppArmor unconfined, which the jail needs.
12
+ - Miniswen: retry model calls for ~10 min instead of ~5.
13
+ - Verifier: a failed restore raises an infrastructure error instead of scoring 0. Also, to be tamper-proof, the reporter aborts the run if the agent patched Minitest so tests cannot fail.
14
+
3
15
  ## [1.3.3] - 2026-09-11
4
16
 
5
17
  - Daytona: retry sandbox creation when the SDK gives up on a stalled start (the half-made sandbox is adopted or deleted first).
data/README.md CHANGED
@@ -15,6 +15,7 @@ lemans is a harness for benchmarking coding agents, the Ruby way:
15
15
  - Ruby 3.4+ is required to run `lemans`
16
16
  - Daytona account (API token) or Docker (for local sandboxes)
17
17
  - Some LLM provider/proxy credentials (e.g., OpenRouter)
18
+ - iproute2 in the sandbox image, so a jailed agent keeps loopback while cut off the internet
18
19
 
19
20
  ## Getting started
20
21
 
@@ -60,7 +60,7 @@ module Lemans
60
60
  end
61
61
 
62
62
  def command_for(task)
63
- argv = [ "miniswen", "-q", "--no-refresh-registry",
63
+ argv = [ "miniswen", "-q", "--no-refresh-registry", "--jail",
64
64
  "-m", model.to_s, "-p", task.instruction,
65
65
  "--results-path", RESULTS_PATH,
66
66
  "--max-steps", profile.step_limit, "--max-time", profile.timeout.to_i,
@@ -1,8 +1,8 @@
1
1
  FROM ruby:4-slim
2
2
 
3
- # The harness seals, snapshots, and grades through git.
3
+ # The harness seals, snapshots, and grades through git. The agent's jail brings loopback up with ip.
4
4
  RUN apt-get update \
5
- && apt-get install -y --no-install-recommends git ca-certificates \
5
+ && apt-get install -y --no-install-recommends git ca-certificates iproute2 \
6
6
  && rm -rf /var/lib/apt/lists/*
7
7
 
8
8
  RUN gem install minitest --no-document
@@ -102,6 +102,7 @@ module Lemans
102
102
  def run_args
103
103
  args = [ "--detach", "--init", "--name", @name,
104
104
  "--cpus", resources.cpus.to_s, "--memory", "#{resources.memory}m",
105
+ "--cap-add", "SYS_ADMIN", "--cap-add", "NET_ADMIN", "--security-opt", "apparmor=unconfined",
105
106
  "--entrypoint", "sh" ]
106
107
  args += [ "--network", "none" ] if network.none?
107
108
  env.each { |key, value| args += [ "--env", "#{key}=#{value}" ] }
@@ -36,17 +36,17 @@ module Lemans
36
36
  @baseline = tree
37
37
  end
38
38
 
39
- def restore! # rubocop:disable Naming/PredicateMethod
40
- return true if paths.empty?
39
+ def restore!
40
+ return if paths.empty?
41
41
  raise VerifierError, "restore is declared but no baseline was sealed" unless baseline
42
42
 
43
43
  escaped_paths = Shellwords.join(paths)
44
44
 
45
- environment.exec(
45
+ environment.exec!(
46
46
  "cd #{Shellwords.escape(workdir)} && #{git} cat-file -e #{baseline} && " \
47
47
  "rm -rf -- #{escaped_paths} && #{git} checkout #{baseline} -- #{escaped_paths}",
48
48
  timeout:
49
- ).success?
49
+ )
50
50
  end
51
51
 
52
52
  private
@@ -36,6 +36,8 @@ module LemansReport
36
36
  end
37
37
 
38
38
  def report
39
+ abort "lemans: a false assertion no longer raises, so no result can be trusted" if tampered?
40
+
39
41
  graded = @results.select { graded?(it) }
40
42
  prior = existing.fetch("checks", {})
41
43
  return if graded.empty? && prior.empty?
@@ -63,6 +65,13 @@ module LemansReport
63
65
 
64
66
  private
65
67
 
68
+ def tampered?
69
+ Minitest::Test.new("probe").assert(false)
70
+ true
71
+ rescue Minitest::Assertion
72
+ false
73
+ end
74
+
66
75
  def graded?(result)
67
76
  dir = ENV["TESTS"]
68
77
  # The trailing slash matters: /testsuite must not count as /tests.
@@ -22,11 +22,6 @@ module Lemans
22
22
 
23
23
  VERIFY_BIN = "verify"
24
24
 
25
- # The message a person finds where the suite output would have been.
26
- TAMPERED = "The graded surfaces could not be restored from the sealed baseline: the sandbox no " \
27
- "longer holds the tree sealed before the agent's first turn. Removing or rewriting " \
28
- "it is a failed check, so this run scores 0.\n"
29
-
30
25
  private attr_reader :task, :environment, :snapshot, :timeout
31
26
 
32
27
  def initialize(task, environment, snapshot)
@@ -40,8 +35,7 @@ module Lemans
40
35
  upload_tests!
41
36
  prepare_env!
42
37
 
43
- # A baseline the agent made unrestorable is a verdict, not an error.
44
- return Verification.new(reward: 0.0, credit: 0.0, logs: TAMPERED) unless snapshot.restore!
38
+ snapshot.restore!
45
39
 
46
40
  verification = run_tests!
47
41
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.3.3"
4
+ VERSION = "1.3.5"
5
5
  end
data/lib/miniswen/cli.rb CHANGED
@@ -22,6 +22,7 @@ module Miniswen
22
22
  @atif_path = nil
23
23
  @refresh_registry = false
24
24
  @skip_registry_refresh = false
25
+ @jail = false
25
26
  end
26
27
 
27
28
  def run
@@ -42,6 +43,9 @@ module Miniswen
42
43
  if @docker_id
43
44
  require "miniswen/environment/docker"
44
45
  Environment::Docker.new(@docker_id)
46
+ elsif @jail
47
+ require "miniswen/jail"
48
+ Jail.new.start
45
49
  else
46
50
  Local.new
47
51
  end
@@ -53,6 +57,8 @@ module Miniswen
53
57
  rescue StandardError => e
54
58
  write_results(agent.partial_result(error_message(e)))
55
59
  raise
60
+ ensure
61
+ environment.stop
56
62
  end
57
63
 
58
64
  write_results(result)
@@ -151,6 +157,10 @@ module Miniswen
151
157
  @docker_id = v
152
158
  end
153
159
 
160
+ opts.on("--jail", "Run every command in its own namespaces: none of the harness's environment, no network, read-only system, none of its files") do
161
+ @jail = true
162
+ end
163
+
154
164
  opts.on("--refresh-registry", "Refresh the model registry, persist it, and exit") do
155
165
  @refresh_registry = true
156
166
  end
@@ -10,5 +10,7 @@ module Miniswen
10
10
 
11
11
  # Execute a shell command
12
12
  def exec(cmd, timeout: nil, env: nil) = raise NotImplementedError
13
+
14
+ def stop = nil
13
15
  end
14
16
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ require "miniswen/local"
6
+
7
+ module Miniswen
8
+ # Runs every command in its own namespaces: none of the harness's environment,
9
+ # no network, read-only system, none of its files.
10
+ class Jail < Local
11
+ def initialize(workdir: Dir.pwd)
12
+ @workdir = workdir
13
+ end
14
+
15
+ def start
16
+ _, @stdout, @stderr, @holder = Open3.popen3(
17
+ ENV.to_h.slice(*container_variables),
18
+ "unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc", "sh", "-c", setup,
19
+ pgroup: true, unsetenv_others: true
20
+ )
21
+ return self if @stdout.gets == "ready\n"
22
+
23
+ raise InfrastructureError, "jail did not start: #{@stderr.read}"
24
+ end
25
+
26
+ def stop
27
+ Process.kill(:KILL, -@holder.pid) if @holder.alive?
28
+ end
29
+
30
+ private
31
+
32
+ def setup = <<~SH
33
+ set -eu
34
+ # Bring loopback up
35
+ ip link set lo up
36
+ # Make the workdir its own mount
37
+ mount --bind #{Shellwords.escape(@workdir)} #{Shellwords.escape(@workdir)}
38
+ # Make private temp dirs their own mounts
39
+ for dir in /tmp /run /root; do mkdir -p "/var/lib/miniswen$dir" && mount --bind "/var/lib/miniswen$dir" "$dir"; done
40
+ # Make everything without its own mount read-only
41
+ mount -o remount,bind,ro /
42
+ echo ready
43
+ exec sleep infinity
44
+ SH
45
+
46
+ def spawn_arguments(command, env)
47
+ [ ENV.to_h.merge(env.to_h).slice(*container_variables),
48
+ "nsenter", "--target", @holder.pid.to_s, "--net", "--mount", "--pid=/proc/#{@holder.pid}/ns/pid_for_children", "--wd=#{@workdir}",
49
+ "--", "setpriv", "--bounding-set=-all", "--inh-caps=-all", "--no-new-privs", "--", "sh", "-c", command ]
50
+ end
51
+
52
+ def spawn_options = super.merge(unsetenv_others: true)
53
+
54
+ def container_variables
55
+ @container_variables ||= File.read("/proc/1/environ").split("\0").map { it.split("=").first } | Agent::EXEC_ENV.keys
56
+ end
57
+ end
58
+ end
@@ -12,7 +12,7 @@ module Miniswen
12
12
  # Always through a shell: Ruby execs a metacharacter-free string directly,
13
13
  # and a missing binary would then raise ENOENT here instead of exiting 127.
14
14
  def exec(command, timeout: nil, env: nil)
15
- Open3.popen2e(env || {}, "sh", "-c", command, pgroup: true) do |stdin, io, wait_thr|
15
+ Open3.popen2e(*spawn_arguments(command, env), **spawn_options) do |stdin, io, wait_thr|
16
16
  stdin.close
17
17
  reader = Thread.new { io.read }
18
18
 
@@ -29,6 +29,10 @@ module Miniswen
29
29
 
30
30
  private
31
31
 
32
+ def spawn_arguments(command, env) = [ env || {}, "sh", "-c", command ]
33
+
34
+ def spawn_options = { pgroup: true }
35
+
32
36
  def kill_group(pid)
33
37
  Process.kill(:KILL, -pid)
34
38
  rescue Errno::ESRCH, Errno::EPERM
@@ -8,10 +8,10 @@ RubyLLM.configure do |config|
8
8
  config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1"
9
9
  end
10
10
 
11
- # About five minutes of retries (1, 2, 4, ... 128s plus jitter): provider
11
+ # About ten minutes of retries (1, 2, 4, ... 256s plus jitter): provider
12
12
  # outages and rate-limit windows outlast the minute this used to allow.
13
13
  RubyLLM.configure do |config|
14
- config.max_retries = 8
14
+ config.max_retries = 9
15
15
  config.retry_interval = 1
16
16
  end
17
17
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.3.3"
4
+ VERSION = "1.3.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lemans
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.3
4
+ version: 1.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -236,6 +236,7 @@ files:
236
236
  - lib/miniswen/cli/reporter.rb
237
237
  - lib/miniswen/environment.rb
238
238
  - lib/miniswen/environment/docker.rb
239
+ - lib/miniswen/jail.rb
239
240
  - lib/miniswen/local.rb
240
241
  - lib/miniswen/ruby_llm.rb
241
242
  - lib/miniswen/testing.rb