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.
- checksums.yaml +4 -4
- data/README.md +171 -38
- data/docs/architecture.md +10 -1
- data/docs/docker.md +45 -0
- data/docs/first-eval-guide.md +7 -7
- data/docs/testing-guide.md +1 -1
- data/lib/skill_bench/agent/react_agent/loop_runner.rb +44 -9
- data/lib/skill_bench/agent/react_agent/step.rb +7 -1
- data/lib/skill_bench/cli/batch_result_printer.rb +45 -0
- data/lib/skill_bench/cli/eval/eval_options.rb +4 -0
- data/lib/skill_bench/cli/help_printer.rb +10 -2
- data/lib/skill_bench/cli/init_command.rb +2 -1
- data/lib/skill_bench/cli/result_printer.rb +1 -1
- data/lib/skill_bench/cli/run_command.rb +47 -9
- data/lib/skill_bench/cli/validate_command.rb +242 -0
- data/lib/skill_bench/cli.rb +3 -0
- data/lib/skill_bench/client.rb +43 -1
- data/lib/skill_bench/clients/all.rb +2 -0
- data/lib/skill_bench/clients/base_client.rb +12 -1
- data/lib/skill_bench/clients/base_url_validator.rb +105 -0
- data/lib/skill_bench/clients/provider_config.rb +41 -2
- data/lib/skill_bench/clients/provider_schemas.rb +4 -0
- data/lib/skill_bench/clients/providers/mistral.rb +47 -0
- data/lib/skill_bench/commands/init.rb +5 -0
- data/lib/skill_bench/commands/skill_new.rb +3 -1
- data/lib/skill_bench/config/applier.rb +2 -0
- data/lib/skill_bench/config/defaults.rb +2 -0
- data/lib/skill_bench/config/facade_readers.rb +7 -0
- data/lib/skill_bench/config/facade_writers.rb +17 -0
- data/lib/skill_bench/config/json_loader.rb +1 -1
- data/lib/skill_bench/config/store.rb +29 -0
- data/lib/skill_bench/config.rb +18 -0
- data/lib/skill_bench/constants.rb +27 -0
- data/lib/skill_bench/evaluation/runner.rb +20 -3
- data/lib/skill_bench/execution/context_hydrator.rb +52 -11
- data/lib/skill_bench/execution/docker/.dockerignore +2 -0
- data/lib/skill_bench/execution/docker/Dockerfile +24 -0
- data/lib/skill_bench/execution/sandbox.rb +113 -27
- data/lib/skill_bench/judge/judge.rb +4 -0
- data/lib/skill_bench/judge/prompt.rb +42 -6
- data/lib/skill_bench/models/config.rb +32 -0
- data/lib/skill_bench/output_formatter.rb +60 -1
- data/lib/skill_bench/package_verifier.rb +3 -1
- data/lib/skill_bench/rails/skill_templates.rb +19 -5
- data/lib/skill_bench/services/agent_spawner_service.rb +7 -3
- data/lib/skill_bench/services/batch_runner_service.rb +111 -0
- data/lib/skill_bench/services/compare_option_parser.rb +1 -0
- data/lib/skill_bench/services/cost_calculator.rb +91 -0
- data/lib/skill_bench/services/html_formatter.rb +289 -0
- data/lib/skill_bench/services/json_formatter.rb +19 -1
- data/lib/skill_bench/services/junit_formatter.rb +74 -24
- data/lib/skill_bench/services/provider_resolver.rb +5 -2
- data/lib/skill_bench/services/response_cache.rb +130 -0
- data/lib/skill_bench/services/runner_service.rb +88 -4
- data/lib/skill_bench/services/summary_formatter.rb +90 -0
- data/lib/skill_bench/services/template_registry.rb +43 -9
- data/lib/skill_bench/services/trend_recorder_service.rb +29 -2
- data/lib/skill_bench/tools/registry.rb +29 -3
- data/lib/skill_bench/tools/run_command.rb +171 -19
- data/lib/skill_bench/trend_tracker/persistence.rb +27 -10
- data/lib/skill_bench/trend_tracker.rb +5 -5
- data/lib/skill_bench/version.rb +1 -1
- data/lib/skill_bench.rb +2 -3
- 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
|
-
|
|
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]
|
|
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
|
|
@@ -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
|
data/lib/skill_bench/config.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
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)
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
|
126
|
+
# Builds the XML structure wrapping the already-read context file contents.
|
|
85
127
|
#
|
|
86
|
-
# @param context_files [Array<
|
|
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 |
|
|
94
|
-
relative_path = Pathname.new(
|
|
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,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"]
|