ruby-skill-bench 1.1.0 → 1.3.0

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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +171 -38
  3. data/docs/architecture.md +10 -1
  4. data/docs/docker.md +45 -0
  5. data/docs/first-eval-guide.md +7 -7
  6. data/docs/testing-guide.md +1 -1
  7. data/lib/skill_bench/agent/react_agent/loop_runner.rb +44 -9
  8. data/lib/skill_bench/agent/react_agent/step.rb +7 -1
  9. data/lib/skill_bench/cli/batch_result_printer.rb +45 -0
  10. data/lib/skill_bench/cli/eval/eval_options.rb +4 -0
  11. data/lib/skill_bench/cli/help_printer.rb +10 -2
  12. data/lib/skill_bench/cli/init_command.rb +2 -1
  13. data/lib/skill_bench/cli/result_printer.rb +1 -1
  14. data/lib/skill_bench/cli/run_command.rb +47 -9
  15. data/lib/skill_bench/cli/validate_command.rb +242 -0
  16. data/lib/skill_bench/cli.rb +3 -0
  17. data/lib/skill_bench/client.rb +43 -1
  18. data/lib/skill_bench/clients/all.rb +2 -0
  19. data/lib/skill_bench/clients/base_client.rb +12 -1
  20. data/lib/skill_bench/clients/base_url_validator.rb +105 -0
  21. data/lib/skill_bench/clients/provider_config.rb +41 -2
  22. data/lib/skill_bench/clients/provider_schemas.rb +4 -0
  23. data/lib/skill_bench/clients/providers/mistral.rb +47 -0
  24. data/lib/skill_bench/commands/init.rb +5 -0
  25. data/lib/skill_bench/commands/skill_new.rb +3 -1
  26. data/lib/skill_bench/config/applier.rb +2 -0
  27. data/lib/skill_bench/config/defaults.rb +2 -0
  28. data/lib/skill_bench/config/facade_readers.rb +7 -0
  29. data/lib/skill_bench/config/facade_writers.rb +17 -0
  30. data/lib/skill_bench/config/json_loader.rb +1 -1
  31. data/lib/skill_bench/config/store.rb +29 -0
  32. data/lib/skill_bench/config.rb +18 -0
  33. data/lib/skill_bench/constants.rb +27 -0
  34. data/lib/skill_bench/evaluation/runner.rb +20 -3
  35. data/lib/skill_bench/execution/context_hydrator.rb +52 -11
  36. data/lib/skill_bench/execution/docker/.dockerignore +2 -0
  37. data/lib/skill_bench/execution/docker/Dockerfile +24 -0
  38. data/lib/skill_bench/execution/sandbox.rb +113 -27
  39. data/lib/skill_bench/judge/judge.rb +4 -0
  40. data/lib/skill_bench/judge/prompt.rb +42 -6
  41. data/lib/skill_bench/models/config.rb +32 -0
  42. data/lib/skill_bench/output_formatter.rb +60 -1
  43. data/lib/skill_bench/package_verifier.rb +3 -1
  44. data/lib/skill_bench/rails/skill_templates.rb +19 -5
  45. data/lib/skill_bench/services/agent_spawner_service.rb +7 -3
  46. data/lib/skill_bench/services/batch_runner_service.rb +111 -0
  47. data/lib/skill_bench/services/compare_option_parser.rb +1 -0
  48. data/lib/skill_bench/services/cost_calculator.rb +91 -0
  49. data/lib/skill_bench/services/html_formatter.rb +289 -0
  50. data/lib/skill_bench/services/json_formatter.rb +19 -1
  51. data/lib/skill_bench/services/junit_formatter.rb +74 -24
  52. data/lib/skill_bench/services/provider_resolver.rb +5 -2
  53. data/lib/skill_bench/services/response_cache.rb +130 -0
  54. data/lib/skill_bench/services/runner_service.rb +88 -4
  55. data/lib/skill_bench/services/summary_formatter.rb +90 -0
  56. data/lib/skill_bench/services/template_registry.rb +43 -9
  57. data/lib/skill_bench/services/trend_recorder_service.rb +29 -2
  58. data/lib/skill_bench/tools/registry.rb +29 -3
  59. data/lib/skill_bench/tools/run_command.rb +171 -19
  60. data/lib/skill_bench/trend_tracker/persistence.rb +27 -10
  61. data/lib/skill_bench/trend_tracker.rb +5 -5
  62. data/lib/skill_bench/version.rb +1 -1
  63. data/lib/skill_bench.rb +2 -3
  64. metadata +18 -34
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module SkillBench
6
+ module Clients
7
+ # Validates a provider `base_url` before it is used to build an HTTP
8
+ # connection that may carry an API key / bearer token.
9
+ #
10
+ # Security rationale: `base_url` is taken verbatim from config/env input and
11
+ # the authenticated request attaches a credential to whatever host it names.
12
+ # Left unchecked this is an SSRF surface, and an `http://` URL would transmit
13
+ # the credential in cleartext. This service enforces:
14
+ #
15
+ # - the URL must be an absolute `http`/`https` URL with a host (empty/relative
16
+ # /garbage values are rejected);
17
+ # - when a credential will be attached, non-loopback hosts MUST use `https`;
18
+ # loopback hosts (`localhost`, `127.0.0.1`, `::1`) MAY use `http` — the
19
+ # legitimate self-hosted/Ollama case — and an explicit opt-in
20
+ # (`allow_insecure_base_url`) can permit cleartext for non-loopback hosts.
21
+ #
22
+ # A blank (`nil`/empty) `base_url` is allowed so providers may supply their
23
+ # own (https) default downstream. Error messages describe only the transport
24
+ # and never include the credential.
25
+ class BaseUrlValidator
26
+ # Hosts permitted to use cleartext `http` even with a credential attached.
27
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
28
+
29
+ # Raised when a base URL is structurally invalid or would leak a credential
30
+ # over cleartext transport. The message never contains the credential.
31
+ class InvalidBaseURLError < StandardError; end
32
+
33
+ # Validates a base URL and returns it unchanged when valid.
34
+ #
35
+ # @param base_url [String, nil] the URL to validate; blank values are
36
+ # returned as-is so a provider default can be applied later.
37
+ # @param has_credential [Boolean] whether a credential (api key/bearer
38
+ # token) will be attached to requests sent to this URL.
39
+ # @param allow_insecure [Boolean] explicit opt-in that permits cleartext
40
+ # `http` to a non-loopback host even when a credential is attached.
41
+ # @raise [InvalidBaseURLError] when the URL is invalid or insecure.
42
+ # @return [String, nil] the validated URL (blank input returned unchanged).
43
+ def self.call(base_url:, has_credential: false, allow_insecure: false)
44
+ new(base_url, has_credential, allow_insecure).call
45
+ end
46
+
47
+ # @param base_url [String, nil] the URL to validate.
48
+ # @param has_credential [Boolean] whether a credential will be attached.
49
+ # @param allow_insecure [Boolean] opt-in permitting cleartext non-loopback.
50
+ def initialize(base_url, has_credential, allow_insecure)
51
+ @base_url = base_url
52
+ @has_credential = has_credential
53
+ @allow_insecure = allow_insecure
54
+ end
55
+
56
+ # Runs the validation.
57
+ #
58
+ # @raise [InvalidBaseURLError] when the URL is invalid or insecure.
59
+ # @return [String, nil] the validated URL.
60
+ def call
61
+ return @base_url if blank?(@base_url)
62
+
63
+ validate_absolute_http_url!
64
+ validate_secure_transport!
65
+ @base_url
66
+ end
67
+
68
+ private
69
+
70
+ def blank?(value)
71
+ value.to_s.strip.empty?
72
+ end
73
+
74
+ def uri
75
+ @uri ||= URI.parse(@base_url.to_s)
76
+ rescue URI::InvalidURIError
77
+ nil
78
+ end
79
+
80
+ def validate_absolute_http_url!
81
+ return if uri.is_a?(URI::HTTP) && !blank?(uri.hostname)
82
+
83
+ raise InvalidBaseURLError,
84
+ "Invalid provider base_url #{@base_url.inspect}: " \
85
+ 'must be an absolute http(s) URL with a host.'
86
+ end
87
+
88
+ def validate_secure_transport!
89
+ return unless @has_credential
90
+ return if uri.scheme == 'https'
91
+ return if loopback?
92
+ return if @allow_insecure
93
+
94
+ raise InvalidBaseURLError,
95
+ 'Insecure provider base_url: refusing to send a credential over cleartext http ' \
96
+ "to non-loopback host #{uri.hostname.inspect}. Use https, target a loopback host, " \
97
+ 'or set allow_insecure_base_url: true to override.'
98
+ end
99
+
100
+ def loopback?
101
+ LOOPBACK_HOSTS.include?(uri.hostname)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../config'
4
+ require_relative 'base_url_validator'
4
5
 
5
6
  module SkillBench
6
7
  module Clients
@@ -13,6 +14,8 @@ module SkillBench
13
14
  new(provider, options).call
14
15
  end
15
16
 
17
+ # @param provider [Symbol, String] provider identifier, coerced to a Symbol (e.g., :openai, :ollama)
18
+ # @param options [Hash] override options that take precedence over the loaded provider config
16
19
  def initialize(provider, options)
17
20
  @provider = provider.to_sym
18
21
  @options = options
@@ -21,8 +24,21 @@ module SkillBench
21
24
 
22
25
  # Loads and returns standardized provider configuration.
23
26
  #
27
+ # The resolved transport URLs (`base_url` and, for Azure, `endpoint`) are
28
+ # validated before being returned: they must be absolute http(s) URLs, and
29
+ # a credential is never sent over cleartext http to a non-loopback host.
30
+ #
31
+ # @raise [BaseUrlValidator::InvalidBaseURLError] when a transport URL is
32
+ # structurally invalid or would leak the credential over cleartext http.
24
33
  # @return [Hash] Standardized configuration with api_key, model, base_url, etc.
25
34
  def call
35
+ validate_transport_urls!
36
+ standardized_config
37
+ end
38
+
39
+ private
40
+
41
+ def standardized_config
26
42
  {
27
43
  api_key: fetch_config(:api_key),
28
44
  model: fetch_config(:model),
@@ -37,10 +53,33 @@ module SkillBench
37
53
  }
38
54
  end
39
55
 
40
- private
56
+ # Validates every transport URL that could carry the credential. Both
57
+ # `base_url` and Azure's `endpoint` are user-supplied URLs that the
58
+ # authenticated request targets, so both are checked with one helper.
59
+ #
60
+ # @raise [BaseUrlValidator::InvalidBaseURLError] on an invalid/insecure URL.
61
+ # @return [void]
62
+ def validate_transport_urls!
63
+ has_credential = !fetch_config(:api_key).to_s.empty?
64
+ allow_insecure = truthy?(fetch_config(:allow_insecure_base_url))
65
+
66
+ [fetch_config(:base_url), fetch_config(:endpoint)].each do |url|
67
+ BaseUrlValidator.call(base_url: url, has_credential: has_credential, allow_insecure: allow_insecure)
68
+ end
69
+ end
41
70
 
71
+ def truthy?(value)
72
+ value == true || value.to_s.strip.casecmp?('true')
73
+ end
74
+
75
+ # Prefer an option when the key is present — including an explicit `nil`,
76
+ # which means "unset" and must not fall through to global Config (a prior
77
+ # `||` treated nil as missing and leaked stored API keys into clients).
42
78
  def fetch_config(key)
43
- @options[key] || @config[key]
79
+ return @options[key] if @options.key?(key)
80
+ return @options[key.to_s] if @options.key?(key.to_s)
81
+
82
+ @config[key] || @config[key.to_s]
44
83
  end
45
84
  end
46
85
  end
@@ -39,6 +39,10 @@ module SkillBench
39
39
  api_key: nil,
40
40
  model: 'deepseek-chat'
41
41
  }.freeze,
42
+ mistral: {
43
+ api_key: nil,
44
+ model: 'mistral-large-latest'
45
+ }.freeze,
42
46
  opencode: {
43
47
  api_key: nil,
44
48
  model: 'opencode-model',
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_client'
4
+ require_relative '../provider_registry'
5
+
6
+ module SkillBench
7
+ module Clients
8
+ module Providers
9
+ # Mistral (la Plateforme) LLM client.
10
+ # Uses Mistral's OpenAI-compatible chat completions API with bearer-token auth.
11
+ #
12
+ # NOTE: AWS Bedrock access to Mistral models (which requires SigV4 request
13
+ # signing rather than a static bearer token) is intentionally not handled
14
+ # here and is left as a follow-up.
15
+ class Mistral < BaseClient
16
+ SkillBench::Clients::ProviderRegistry.register(:mistral, self)
17
+
18
+ # Returns the provider identifier.
19
+ #
20
+ # @return [Symbol]
21
+ def provider_name
22
+ :mistral
23
+ end
24
+
25
+ protected
26
+
27
+ # Returns the base URL for the Mistral API.
28
+ #
29
+ # The Mistral API base is https://api.mistral.ai/v1; the version segment
30
+ # lives in {#request_path} so Faraday does not drop it (an absolute
31
+ # request path replaces any path component of the connection base URL).
32
+ #
33
+ # @return [String]
34
+ def base_url
35
+ @base_url_config || 'https://api.mistral.ai'
36
+ end
37
+
38
+ # Returns the request path for chat completions.
39
+ #
40
+ # @return [String]
41
+ def request_path
42
+ @request_path_config || '/v1/chat/completions'
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -24,10 +24,15 @@ module SkillBench
24
24
 
25
25
  # Generates configuration hash for a specific provider.
26
26
  #
27
+ # The built-in `:mock` provider needs no credentials, so it produces a
28
+ # minimal offline config without a nested `config:` block.
29
+ #
27
30
  # @param provider [Symbol] LLM provider name
28
31
  # @return [Hash] Single-provider configuration
29
32
  # @raise [ArgumentError] if provider is not registered
30
33
  def self.config_for_provider(provider)
34
+ return { provider: :mock, max_execution_time: 30 } if provider == :mock
35
+
31
36
  {
32
37
  provider: provider,
33
38
  max_execution_time: 30,
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
- require_relative '../rails/skill_templates'
5
4
 
6
5
  module SkillBench
7
6
  module Commands
@@ -107,6 +106,9 @@ module SkillBench
107
106
  file_name = RAILS_TEMPLATES[template]
108
107
  raise ArgumentError, "Invalid template: #{template}. Use one of: #{RAILS_TEMPLATES.keys.join(', ')}." unless file_name
109
108
 
109
+ # Lazily load the scaffold generator so a normal `skill-bench run` does
110
+ # not pull it (and its dependencies) in at boot.
111
+ require_relative '../rails/skill_templates'
110
112
  content = Rails::SkillTemplates.public_send(template.to_sym, name)
111
113
  File.write(File.join(path, file_name), content)
112
114
  end
@@ -41,6 +41,8 @@ module SkillBench
41
41
  assign_current_provider
42
42
  @store.assign_max_execution_time(@data[:max_execution_time]) if @data.key?(:max_execution_time)
43
43
  @store.assign_allowed_commands(@data[:allowed_commands]) if @data.key?(:allowed_commands)
44
+ @store.assign_allow_host_execution(@data[:allow_host_execution]) if @data.key?(:allow_host_execution)
45
+ @store.assign_command_argument_constraints(@data[:command_argument_constraints]) if @data.key?(:command_argument_constraints)
44
46
  @store.skill_sources = @data[:skill_sources] if @data.key?(:skill_sources)
45
47
  end
46
48
 
@@ -19,6 +19,8 @@ module SkillBench
19
19
  current_llm_provider: :openai,
20
20
  max_execution_time: 30,
21
21
  allowed_commands: nil,
22
+ allow_host_execution: false,
23
+ command_argument_constraints: {},
22
24
  skill_sources: {},
23
25
  llm_providers_config: {
24
26
  openai: { api_key: nil, model: 'gpt-4o' },
@@ -25,6 +25,13 @@ module SkillBench
25
25
  store.allowed_commands
26
26
  end
27
27
 
28
+ # Returns whether un-isolated host command execution is permitted.
29
+ #
30
+ # @return [Boolean, nil] true when host execution is explicitly allowed
31
+ def allow_host_execution
32
+ store.allow_host_execution
33
+ end
34
+
28
35
  # Returns provider configuration.
29
36
  #
30
37
  # @return [Hash] provider configuration by provider name
@@ -102,6 +102,23 @@ module SkillBench
102
102
  store.assign_allowed_commands(value)
103
103
  end
104
104
 
105
+ # Sets whether un-isolated host command execution is permitted.
106
+ #
107
+ # @param value [Boolean] true to permit un-isolated host execution
108
+ # @return [Boolean] assigned host execution flag
109
+ def allow_host_execution=(value)
110
+ store.assign_allow_host_execution(value)
111
+ end
112
+
113
+ # Sets the optional per-command argument constraints.
114
+ #
115
+ # @param value [Hash, nil] base command => disallowed argument
116
+ # substrings/flags
117
+ # @return [Hash, nil] assigned constraints
118
+ def command_argument_constraints=(value)
119
+ store.assign_command_argument_constraints(value)
120
+ end
121
+
105
122
  # Replaces provider configuration.
106
123
  #
107
124
  # @param value [Hash] provider configuration
@@ -29,7 +29,7 @@ module SkillBench
29
29
  data = JSON.parse(File.read(@path), symbolize_names: true)
30
30
  return warn_invalid_config unless data.is_a?(Hash)
31
31
 
32
- success_data = data.slice(:current_llm_provider, :max_execution_time, :allowed_commands, :skill_sources).compact
32
+ success_data = data.slice(:current_llm_provider, :max_execution_time, :allowed_commands, :allow_host_execution, :command_argument_constraints, :skill_sources).compact
33
33
  success_data[:current_llm_provider] ||= data[:provider] if data.key?(:provider)
34
34
  success(success_data.merge(providers: normalized_providers(data[:providers])))
35
35
  rescue JSON::ParserError => e
@@ -19,6 +19,18 @@ module SkillBench
19
19
  # @return [Array<String>, nil] allowed commands
20
20
  attr_accessor :allowed_commands
21
21
 
22
+ # Returns whether running commands directly on the host is permitted
23
+ # when no real sandbox isolation (container) is active.
24
+ #
25
+ # @return [Boolean, nil] true when host execution is explicitly allowed
26
+ attr_reader :allow_host_execution
27
+
28
+ # Returns the optional per-command argument constraints.
29
+ #
30
+ # @return [Hash, nil] base command => disallowed argument
31
+ # substrings/flags, or nil when unconfigured
32
+ attr_reader :command_argument_constraints
33
+
22
34
  # Returns provider configuration.
23
35
  #
24
36
  # @return [Hash, nil] provider configuration by provider name
@@ -109,6 +121,23 @@ module SkillBench
109
121
  @allowed_commands = value
110
122
  end
111
123
 
124
+ # Sets whether host command execution is permitted without isolation.
125
+ #
126
+ # @param value [Boolean] true to permit un-isolated host execution
127
+ # @return [Boolean] assigned host execution flag
128
+ def assign_allow_host_execution(value)
129
+ @allow_host_execution = value
130
+ end
131
+
132
+ # Sets the optional per-command argument constraints.
133
+ #
134
+ # @param value [Hash, nil] base command => disallowed argument
135
+ # substrings/flags
136
+ # @return [Hash, nil] assigned constraints
137
+ def assign_command_argument_constraints(value)
138
+ @command_argument_constraints = value
139
+ end
140
+
112
141
  # Sets provider configuration.
113
142
  #
114
143
  # @param value [Hash] provider configuration
@@ -95,6 +95,24 @@ module SkillBench
95
95
  store.allowed_commands
96
96
  end
97
97
 
98
+ # Returns whether commands may run directly on the host when no sandbox
99
+ # isolation (container) is active. Defaults to false (fail closed).
100
+ #
101
+ # @return [Boolean] true when un-isolated host execution is explicitly enabled
102
+ def allow_host_execution
103
+ store.allow_host_execution || false
104
+ end
105
+
106
+ # Returns the optional per-command argument constraints.
107
+ #
108
+ # When unconfigured, returns an empty Hash meaning no argument constraints
109
+ # apply (the allowlist remains the only command-authorization control).
110
+ #
111
+ # @return [Hash] base command => disallowed argument substrings/flags
112
+ def command_argument_constraints
113
+ store.command_argument_constraints || {}
114
+ end
115
+
98
116
  # Returns max execution time from configuration.
99
117
  #
100
118
  # @return [Integer] Maximum execution time in seconds
@@ -30,6 +30,33 @@ module SkillBench
30
30
  # Sandbox Configuration
31
31
  module Sandbox
32
32
  DOCKER_IMAGE_NAME = 'evaluator-sandbox'
33
+
34
+ # Relative path of the Docker build context as packaged in the gem.
35
+ DOCKER_CONTEXT_RELATIVE = 'lib/skill_bench/execution/docker'
36
+
37
+ # Absolute path to the Docker build context shipped next to the library code.
38
+ # Resolved from this file so gem installs and git checkouts agree.
39
+ #
40
+ # @return [String] absolute path to the docker/ directory
41
+ def self.docker_context_path
42
+ File.expand_path('execution/docker', __dir__)
43
+ end
44
+
45
+ # Versioned image reference used for build and run.
46
+ #
47
+ # @return [String] e.g. "evaluator-sandbox:1.2.0"
48
+ def self.image_ref
49
+ require_relative 'version' unless defined?(SkillBench::VERSION)
50
+
51
+ "#{DOCKER_IMAGE_NAME}:#{SkillBench::VERSION}"
52
+ end
53
+
54
+ # Floating latest tag applied alongside the versioned tag on build.
55
+ #
56
+ # @return [String] e.g. "evaluator-sandbox:latest"
57
+ def self.latest_image_ref
58
+ "#{DOCKER_IMAGE_NAME}:latest"
59
+ end
33
60
  end
34
61
 
35
62
  # Tool Execution Configuration
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'parallel'
4
+
3
5
  module SkillBench
4
6
  module Evaluation
5
7
  # Orchestrates the evaluation pipeline.
@@ -39,10 +41,8 @@ module SkillBench
39
41
  #
40
42
  # @return [Hash] Service response with report or error.
41
43
  def call
42
- baseline_judge = judge_run(baseline_output, nil)
44
+ baseline_judge, context_judge = run_judges_concurrently
43
45
  return baseline_judge unless baseline_judge[:success]
44
-
45
- context_judge = judge_run(context_output, skill_context)
46
46
  return context_judge unless context_judge[:success]
47
47
 
48
48
  compute_deltas(baseline_judge, context_judge)
@@ -55,6 +55,23 @@ module SkillBench
55
55
 
56
56
  attr_reader :task, :criteria, :skill_context, :baseline_output, :context_output, :judge_params
57
57
 
58
+ # Judges the baseline and context outputs concurrently.
59
+ #
60
+ # The two runs are independent blind evaluations that share no mutable
61
+ # state, so they execute on separate threads (the LLM round-trip is
62
+ # I/O-bound and releases the GIL). +Parallel.map+ preserves input order,
63
+ # so the baseline result is always first and the context result second;
64
+ # callers still apply the sequential failure precedence afterwards.
65
+ #
66
+ # @return [Array(Hash, Hash)] Baseline and context judge results, in order.
67
+ def run_judges_concurrently
68
+ runs = [
69
+ -> { judge_run(baseline_output, nil) },
70
+ -> { judge_run(context_output, skill_context) }
71
+ ]
72
+ Parallel.map(runs, in_threads: runs.size, &:call)
73
+ end
74
+
58
75
  def judge_run(output, context)
59
76
  prompt_result = Judge::Prompt.call(
60
77
  task: task,
@@ -12,6 +12,11 @@ module SkillBench
12
12
  # Error message returned when context hydration fails.
13
13
  HYDRATION_FAILED = 'Failed to hydrate context from source path'
14
14
 
15
+ # Immutable record pairing a context file's path with the content and byte
16
+ # size captured during a single filesystem pass, so the total-size check and
17
+ # the XML build can reuse them without a second `stat` or `read`.
18
+ ContextFile = Struct.new(:path, :content, :bytesize)
19
+
15
20
  # Loads and formats source context files.
16
21
  #
17
22
  # @param params [Hash] The configuration for context hydration.
@@ -43,7 +48,7 @@ module SkillBench
43
48
  full_path = @base_path.join(@source_path).expand_path
44
49
  base_expanded = @base_path.expand_path
45
50
 
46
- return missing_path_result unless full_path.to_path.start_with?(base_expanded.to_path)
51
+ return missing_path_result unless within_base?(full_path, base_expanded)
47
52
  return missing_path_result unless full_path.exist? && full_path.directory?
48
53
 
49
54
  context_files = collect_context_files(full_path)
@@ -59,19 +64,56 @@ module SkillBench
59
64
 
60
65
  private
61
66
 
67
+ # Determines whether the resolved path is contained within the base directory.
68
+ # Uses a separator-aware boundary so a sibling directory whose name merely shares
69
+ # the base directory's prefix (e.g. base `/tmp/foo` vs `/tmp/foo-evil`) is rejected.
70
+ #
71
+ # @param full_path [Pathname] The expanded source path to validate.
72
+ # @param base_expanded [Pathname] The expanded base directory.
73
+ # @return [Boolean] true when full_path is the base directory or a descendant of it.
74
+ def within_base?(full_path, base_expanded)
75
+ full = full_path.to_path
76
+ base = base_expanded.to_path
77
+ full == base || full.start_with?(base + File::SEPARATOR)
78
+ end
79
+
62
80
  def missing_path_result
63
81
  { success: false, response: { error: { message: "Source path #{@source_path} does not exist or is not a directory" } } }
64
82
  end
65
83
 
84
+ # Collects readable context files in a single filesystem pass. Symlinks are
85
+ # rejected and oversized files are skipped via a cheap `File.size` pre-check
86
+ # so a huge file is never read into memory; each surviving file is read
87
+ # exactly once, capturing its content and byte size for downstream reuse.
88
+ #
89
+ # @param full_path [Pathname] The validated, in-base source directory.
90
+ # @return [Array<ContextFile>] Sorted records of path, content, and byte size.
66
91
  def collect_context_files(full_path)
67
92
  pattern = full_path.join("*{#{Constants::ContextHydration::TEXT_EXTENSIONS.join(',')}}").to_s
68
- Dir.glob(pattern).reject { |f| File.symlink?(f) }
69
- .select { |f| File.size(f) <= Constants::ContextHydration::MAX_FILE_SIZE }
70
- .sort
93
+ Dir.glob(pattern)
94
+ .reject { |file_path| File.symlink?(file_path) }
95
+ .select { |file_path| File.size(file_path) <= Constants::ContextHydration::MAX_FILE_SIZE }
96
+ .map { |file_path| read_context_file(file_path) }
71
97
  end
72
98
 
99
+ # Reads a single in-limit file once, pairing its content with the byte size
100
+ # derived from that content so no second `stat` is required.
101
+ #
102
+ # @param file_path [String] Absolute path to an in-limit context file.
103
+ # @return [ContextFile] The path, content, and byte size record.
104
+ def read_context_file(file_path)
105
+ content = File.read(file_path)
106
+ ContextFile.new(file_path, content, content.bytesize)
107
+ end
108
+
109
+ # Validates that the combined byte size of the already-read context files
110
+ # stays within the total-size cap, reusing the sizes captured during
111
+ # collection instead of re-stat-ing each file.
112
+ #
113
+ # @param context_files [Array<ContextFile>] The collected context records.
114
+ # @return [Boolean] true when the total size is within the cap.
73
115
  def validate_total_size?(context_files)
74
- total_size = context_files.sum { |f| File.size(f) }
116
+ total_size = context_files.sum(&:bytesize)
75
117
  return true if total_size <= Constants::ContextHydration::MAX_TOTAL_CONTEXT_SIZE
76
118
 
77
119
  SkillBench::ErrorLogger.log_error(
@@ -81,21 +123,20 @@ module SkillBench
81
123
  false
82
124
  end
83
125
 
84
- # Builds the XML structure wrapping the contents of the context files.
126
+ # Builds the XML structure wrapping the already-read context file contents.
85
127
  #
86
- # @param context_files [Array<String>] List of absolute paths to context files.
128
+ # @param context_files [Array<ContextFile>] The collected context records.
87
129
  # @return [String] The combined XML representation of the file contents.
88
130
  def build_xml(context_files)
89
131
  return '' if context_files.empty?
90
132
 
91
133
  xml = ['<agent_context>']
92
134
 
93
- context_files.each do |file_path|
94
- relative_path = Pathname.new(file_path).relative_path_from(@base_path).to_s
95
- content = File.read(file_path)
135
+ context_files.each do |context_file|
136
+ relative_path = Pathname.new(context_file.path).relative_path_from(@base_path).to_s
96
137
 
97
138
  xml << " <file path=\"#{CGI.escapeHTML(relative_path)}\">"
98
- xml << CGI.escapeHTML(content).gsub(/^/, ' ')
139
+ xml << CGI.escapeHTML(context_file.content).gsub(/^/, ' ')
99
140
  xml << ' </file>'
100
141
  end
101
142
 
@@ -0,0 +1,2 @@
1
+ *
2
+ !Dockerfile
@@ -0,0 +1,24 @@
1
+ # SkillBench evaluator-sandbox image contract (MVP)
2
+ #
3
+ # Purpose: long-lived container for `docker exec` of allowlisted agent commands.
4
+ # Runtime flags (from Sandbox#start_container): --network none, non-root uid/gid,
5
+ # --security-opt no-new-privileges, --cap-drop ALL (+ CHOUN/DAC_OVERRIDE).
6
+ #
7
+ # Assumptions:
8
+ # - No package installs or gem fetches during an eval (network is disabled at runtime).
9
+ # - Ruby major tracks CI (3.3/3.4). Bookworm image provides a modern baseline.
10
+ # - git is available for tools that shell out to git inside the sandbox mount.
11
+ # - Multi-arch: build on the host arch (arm64 Mac / amd64 CI) via docker build.
12
+ #
13
+ # See docs/docker.md and milestone container-isolation-v1.
14
+
15
+ FROM ruby:3.4-bookworm
16
+
17
+ RUN apt-get update \
18
+ && apt-get install -y --no-install-recommends git \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ WORKDIR /sandbox
22
+
23
+ # Keep the container alive so repeated `docker exec` sessions can run commands.
24
+ CMD ["sleep", "infinity"]