language-operator 0.1.61 → 0.1.62
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/.claude/commands/persona.md +9 -0
- data/.claude/commands/task.md +46 -1
- data/.rubocop.yml +13 -0
- data/.rubocop_custom/use_ux_helper.rb +44 -0
- data/CHANGELOG.md +8 -0
- data/Gemfile.lock +12 -1
- data/Makefile +26 -7
- data/Makefile.common +50 -0
- data/bin/aictl +8 -1
- data/components/agent/Gemfile +1 -1
- data/components/agent/bin/langop-agent +7 -0
- data/docs/README.md +58 -0
- data/docs/{dsl/best-practices.md → best-practices.md} +4 -4
- data/docs/cli-reference.md +274 -0
- data/docs/{dsl/constraints.md → constraints.md} +5 -5
- data/docs/how-agents-work.md +156 -0
- data/docs/installation.md +218 -0
- data/docs/quickstart.md +299 -0
- data/docs/understanding-generated-code.md +265 -0
- data/docs/using-tools.md +457 -0
- data/docs/webhooks.md +509 -0
- data/examples/ux_helpers_demo.rb +296 -0
- data/lib/language_operator/agent/base.rb +11 -1
- data/lib/language_operator/agent/executor.rb +23 -6
- data/lib/language_operator/agent/safety/safe_executor.rb +41 -39
- data/lib/language_operator/agent/task_executor.rb +346 -63
- data/lib/language_operator/agent/web_server.rb +110 -14
- data/lib/language_operator/agent/webhook_authenticator.rb +39 -5
- data/lib/language_operator/agent.rb +88 -2
- data/lib/language_operator/cli/base_command.rb +17 -11
- data/lib/language_operator/cli/command_loader.rb +72 -0
- data/lib/language_operator/cli/commands/agent/base.rb +837 -0
- data/lib/language_operator/cli/commands/agent/code_operations.rb +102 -0
- data/lib/language_operator/cli/commands/agent/helpers/cluster_llm_client.rb +116 -0
- data/lib/language_operator/cli/commands/agent/helpers/code_parser.rb +115 -0
- data/lib/language_operator/cli/commands/agent/helpers/synthesis_watcher.rb +96 -0
- data/lib/language_operator/cli/commands/agent/learning.rb +289 -0
- data/lib/language_operator/cli/commands/agent/lifecycle.rb +102 -0
- data/lib/language_operator/cli/commands/agent/logs.rb +125 -0
- data/lib/language_operator/cli/commands/agent/workspace.rb +327 -0
- data/lib/language_operator/cli/commands/cluster.rb +129 -84
- data/lib/language_operator/cli/commands/install.rb +1 -1
- data/lib/language_operator/cli/commands/model/base.rb +215 -0
- data/lib/language_operator/cli/commands/model/test.rb +165 -0
- data/lib/language_operator/cli/commands/persona.rb +16 -34
- data/lib/language_operator/cli/commands/quickstart.rb +3 -2
- data/lib/language_operator/cli/commands/status.rb +40 -67
- data/lib/language_operator/cli/commands/system/base.rb +44 -0
- data/lib/language_operator/cli/commands/system/exec.rb +147 -0
- data/lib/language_operator/cli/commands/system/helpers/llm_synthesis.rb +183 -0
- data/lib/language_operator/cli/commands/system/helpers/pod_manager.rb +212 -0
- data/lib/language_operator/cli/commands/system/helpers/template_loader.rb +57 -0
- data/lib/language_operator/cli/commands/system/helpers/template_validator.rb +174 -0
- data/lib/language_operator/cli/commands/system/schema.rb +92 -0
- data/lib/language_operator/cli/commands/system/synthesis_template.rb +151 -0
- data/lib/language_operator/cli/commands/system/synthesize.rb +224 -0
- data/lib/language_operator/cli/commands/system/validate_template.rb +130 -0
- data/lib/language_operator/cli/commands/tool/base.rb +271 -0
- data/lib/language_operator/cli/commands/tool/install.rb +255 -0
- data/lib/language_operator/cli/commands/tool/search.rb +69 -0
- data/lib/language_operator/cli/commands/tool/test.rb +115 -0
- data/lib/language_operator/cli/commands/use.rb +29 -6
- data/lib/language_operator/cli/errors/handler.rb +20 -17
- data/lib/language_operator/cli/errors/suggestions.rb +3 -5
- data/lib/language_operator/cli/errors/thor_errors.rb +55 -0
- data/lib/language_operator/cli/formatters/code_formatter.rb +4 -11
- data/lib/language_operator/cli/formatters/log_formatter.rb +8 -15
- data/lib/language_operator/cli/formatters/progress_formatter.rb +6 -8
- data/lib/language_operator/cli/formatters/status_formatter.rb +26 -7
- data/lib/language_operator/cli/formatters/table_formatter.rb +47 -36
- data/lib/language_operator/cli/formatters/value_formatter.rb +75 -0
- data/lib/language_operator/cli/helpers/cluster_context.rb +5 -3
- data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +2 -1
- data/lib/language_operator/cli/helpers/label_utils.rb +97 -0
- data/lib/language_operator/{ux/concerns/provider_helpers.rb → cli/helpers/provider_helper.rb} +10 -29
- data/lib/language_operator/cli/helpers/schedule_builder.rb +21 -1
- data/lib/language_operator/cli/helpers/user_prompts.rb +19 -11
- data/lib/language_operator/cli/helpers/ux_helper.rb +538 -0
- data/lib/language_operator/{ux/concerns/input_validation.rb → cli/helpers/validation_helper.rb} +13 -66
- data/lib/language_operator/cli/main.rb +50 -40
- data/lib/language_operator/cli/templates/tools/generic.yaml +3 -0
- data/lib/language_operator/cli/wizards/agent_wizard.rb +12 -20
- data/lib/language_operator/cli/wizards/model_wizard.rb +271 -0
- data/lib/language_operator/cli/wizards/quickstart_wizard.rb +8 -34
- data/lib/language_operator/client/base.rb +28 -0
- data/lib/language_operator/client/config.rb +4 -1
- data/lib/language_operator/client/mcp_connector.rb +1 -1
- data/lib/language_operator/config/cluster_config.rb +3 -2
- data/lib/language_operator/config.rb +38 -11
- data/lib/language_operator/constants/kubernetes_labels.rb +80 -0
- data/lib/language_operator/constants.rb +13 -0
- data/lib/language_operator/dsl/http.rb +127 -10
- data/lib/language_operator/dsl.rb +153 -6
- data/lib/language_operator/errors.rb +50 -0
- data/lib/language_operator/kubernetes/client.rb +11 -6
- data/lib/language_operator/kubernetes/resource_builder.rb +58 -84
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
- data/lib/language_operator/type_coercion.rb +118 -34
- data/lib/language_operator/utils/secure_path.rb +74 -0
- data/lib/language_operator/utils.rb +7 -0
- data/lib/language_operator/validators.rb +54 -2
- data/lib/language_operator/version.rb +1 -1
- data/synth/001/Makefile +10 -2
- data/synth/001/agent.rb +16 -15
- data/synth/001/output.log +27 -10
- data/synth/002/Makefile +10 -2
- data/synth/003/Makefile +1 -1
- data/synth/003/README.md +205 -133
- data/synth/003/agent.optimized.rb +66 -0
- data/synth/003/agent.synthesized.rb +41 -0
- metadata +111 -35
- data/docs/dsl/agent-reference.md +0 -604
- data/docs/dsl/mcp-integration.md +0 -1177
- data/docs/dsl/webhooks.md +0 -932
- data/docs/dsl/workflows.md +0 -744
- data/lib/language_operator/cli/commands/agent.rb +0 -1712
- data/lib/language_operator/cli/commands/model.rb +0 -366
- data/lib/language_operator/cli/commands/system.rb +0 -1259
- data/lib/language_operator/cli/commands/tool.rb +0 -654
- data/lib/language_operator/cli/formatters/optimization_formatter.rb +0 -226
- data/lib/language_operator/cli/helpers/pastel_helper.rb +0 -24
- data/lib/language_operator/learning/adapters/base_adapter.rb +0 -149
- data/lib/language_operator/learning/adapters/jaeger_adapter.rb +0 -221
- data/lib/language_operator/learning/adapters/signoz_adapter.rb +0 -435
- data/lib/language_operator/learning/adapters/tempo_adapter.rb +0 -239
- data/lib/language_operator/learning/optimizer.rb +0 -319
- data/lib/language_operator/learning/pattern_detector.rb +0 -260
- data/lib/language_operator/learning/task_synthesizer.rb +0 -288
- data/lib/language_operator/learning/trace_analyzer.rb +0 -285
- data/lib/language_operator/templates/task_synthesis.tmpl +0 -98
- data/lib/language_operator/ux/base.rb +0 -81
- data/lib/language_operator/ux/concerns/README.md +0 -155
- data/lib/language_operator/ux/concerns/headings.rb +0 -90
- data/lib/language_operator/ux/create_agent.rb +0 -255
- data/lib/language_operator/ux/create_model.rb +0 -267
- data/lib/language_operator/ux/quickstart.rb +0 -594
- data/synth/003/agent.rb +0 -41
- data/synth/003/output.log +0 -68
- /data/docs/{architecture/agent-runtime.md → agent-internals.md} +0 -0
- /data/docs/{dsl/chat-endpoints.md → chat-endpoints.md} +0 -0
- /data/docs/{dsl/SCHEMA_VERSION.md → schema-versioning.md} +0 -0
|
@@ -2,19 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require_relative '../utils/secure_path'
|
|
5
6
|
|
|
6
7
|
module LanguageOperator
|
|
7
8
|
module Config
|
|
8
9
|
# Manages cluster configuration in ~/.aictl/config.yaml
|
|
9
10
|
class ClusterConfig
|
|
10
|
-
CONFIG_DIR =
|
|
11
|
+
CONFIG_DIR = LanguageOperator::Utils::SecurePath.expand_home_path('.aictl')
|
|
11
12
|
CONFIG_PATH = File.join(CONFIG_DIR, 'config.yaml')
|
|
12
13
|
|
|
13
14
|
class << self
|
|
14
15
|
def load
|
|
15
16
|
return default_config unless File.exist?(CONFIG_PATH)
|
|
16
17
|
|
|
17
|
-
YAML.
|
|
18
|
+
YAML.safe_load_file(CONFIG_PATH, permitted_classes: [Symbol], aliases: true) || default_config
|
|
18
19
|
rescue StandardError => e
|
|
19
20
|
warn "Warning: Failed to load config from #{CONFIG_PATH}: #{e.message}"
|
|
20
21
|
default_config
|
|
@@ -75,16 +75,21 @@ module LanguageOperator
|
|
|
75
75
|
|
|
76
76
|
# Convert a string value to the specified type
|
|
77
77
|
#
|
|
78
|
+
# For integer and float types, uses strict conversion that raises ArgumentError
|
|
79
|
+
# for invalid input (e.g., non-numeric strings).
|
|
80
|
+
#
|
|
78
81
|
# @param value [String, nil] Raw string value from environment
|
|
79
82
|
# @param type [Symbol] Target type (:string, :integer, :boolean, :float, :array)
|
|
80
83
|
# @param separator [String] Separator for array type (default: ',')
|
|
81
84
|
# @return [Object] Converted value
|
|
85
|
+
# @raise [ArgumentError] When integer/float conversion fails
|
|
82
86
|
#
|
|
83
87
|
# @example String conversion
|
|
84
88
|
# Config.convert_type('hello', :string) # => "hello"
|
|
85
89
|
#
|
|
86
90
|
# @example Integer conversion
|
|
87
91
|
# Config.convert_type('42', :integer) # => 42
|
|
92
|
+
# Config.convert_type('abc', :integer) # raises ArgumentError
|
|
88
93
|
#
|
|
89
94
|
# @example Boolean conversion
|
|
90
95
|
# Config.convert_type('true', :boolean) # => true
|
|
@@ -94,6 +99,7 @@ module LanguageOperator
|
|
|
94
99
|
#
|
|
95
100
|
# @example Float conversion
|
|
96
101
|
# Config.convert_type('3.14', :float) # => 3.14
|
|
102
|
+
# Config.convert_type('xyz', :float) # raises ArgumentError
|
|
97
103
|
#
|
|
98
104
|
# @example Array conversion
|
|
99
105
|
# Config.convert_type('a,b,c', :array) # => ["a", "b", "c"]
|
|
@@ -104,9 +110,9 @@ module LanguageOperator
|
|
|
104
110
|
when :string
|
|
105
111
|
value.to_s
|
|
106
112
|
when :integer
|
|
107
|
-
value
|
|
113
|
+
Integer(value)
|
|
108
114
|
when :float
|
|
109
|
-
value
|
|
115
|
+
Float(value)
|
|
110
116
|
when :boolean
|
|
111
117
|
%w[true 1 yes on].include?(value.to_s.downcase)
|
|
112
118
|
when :array
|
|
@@ -184,10 +190,22 @@ module LanguageOperator
|
|
|
184
190
|
# @example
|
|
185
191
|
# Config.get_int('MAX_WORKERS', default: 4)
|
|
186
192
|
def self.get_int(*keys, default: nil)
|
|
187
|
-
|
|
188
|
-
|
|
193
|
+
keys.each do |key|
|
|
194
|
+
value = ENV[key.to_s]
|
|
195
|
+
next unless value
|
|
196
|
+
|
|
197
|
+
begin
|
|
198
|
+
return Integer(value)
|
|
199
|
+
rescue ArgumentError, TypeError => e
|
|
200
|
+
suggestion = "Please set #{key} to a valid integer (e.g., export #{key}=4)"
|
|
201
|
+
raise ArgumentError, "Invalid integer value '#{value}' in environment variable '#{key}'. #{suggestion}. Error: #{e.message}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return default if default
|
|
189
206
|
|
|
190
|
-
|
|
207
|
+
# No variables found
|
|
208
|
+
raise ArgumentError, "Missing required integer configuration. Checked environment variables: #{keys.join(', ')}. Please set one of these variables."
|
|
191
209
|
end
|
|
192
210
|
|
|
193
211
|
# Get environment variable as boolean
|
|
@@ -201,10 +219,14 @@ module LanguageOperator
|
|
|
201
219
|
# @example
|
|
202
220
|
# Config.get_bool('USE_TLS', 'ENABLE_TLS', default: true)
|
|
203
221
|
def self.get_bool(*keys, default: false)
|
|
204
|
-
|
|
205
|
-
|
|
222
|
+
keys.each do |key|
|
|
223
|
+
value = ENV[key.to_s]
|
|
224
|
+
next unless value
|
|
206
225
|
|
|
207
|
-
|
|
226
|
+
return %w[true 1 yes on].include?(value.to_s.downcase)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
default
|
|
208
230
|
end
|
|
209
231
|
|
|
210
232
|
# Get environment variable as array (split by separator)
|
|
@@ -217,10 +239,15 @@ module LanguageOperator
|
|
|
217
239
|
# @example
|
|
218
240
|
# Config.get_array('ALLOWED_HOSTS', separator: ',')
|
|
219
241
|
def self.get_array(*keys, default: [], separator: ',')
|
|
220
|
-
|
|
221
|
-
|
|
242
|
+
keys.each do |key|
|
|
243
|
+
value = ENV[key.to_s]
|
|
244
|
+
next unless value
|
|
245
|
+
next if value.empty?
|
|
246
|
+
|
|
247
|
+
return value.split(separator).map(&:strip).reject(&:empty?)
|
|
248
|
+
end
|
|
222
249
|
|
|
223
|
-
|
|
250
|
+
default
|
|
224
251
|
end
|
|
225
252
|
|
|
226
253
|
# Check if environment variable is set (even if empty string)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LanguageOperator
|
|
4
|
+
module Constants
|
|
5
|
+
# Kubernetes label constants and builder methods for consistent metadata across all resources
|
|
6
|
+
module KubernetesLabels
|
|
7
|
+
# Standard Kubernetes labels
|
|
8
|
+
NAME = 'app.kubernetes.io/name'
|
|
9
|
+
COMPONENT = 'app.kubernetes.io/component'
|
|
10
|
+
MANAGED_BY = 'app.kubernetes.io/managed-by'
|
|
11
|
+
PART_OF = 'app.kubernetes.io/part-of'
|
|
12
|
+
VERSION = 'app.kubernetes.io/version'
|
|
13
|
+
|
|
14
|
+
# Language Operator specific values
|
|
15
|
+
PROJECT_NAME = 'language-operator'
|
|
16
|
+
MANAGED_BY_AICTL = 'aictl'
|
|
17
|
+
COMPONENT_AGENT = 'agent'
|
|
18
|
+
COMPONENT_TEST_AGENT = 'test-agent'
|
|
19
|
+
|
|
20
|
+
# Custom Language Operator labels
|
|
21
|
+
TOOL_LABEL = 'langop.io/tool'
|
|
22
|
+
LEARNING_DISABLED_LABEL = 'langop.io/learning-disabled'
|
|
23
|
+
KIND_LABEL = 'langop.io/kind'
|
|
24
|
+
CLUSTER_LABEL = 'langop.io/cluster'
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Build standard agent labels for deployments and pods
|
|
28
|
+
#
|
|
29
|
+
# @param agent_name [String] The name of the agent
|
|
30
|
+
# @return [Hash] Hash of labels for Kubernetes resources
|
|
31
|
+
def agent_labels(agent_name)
|
|
32
|
+
{
|
|
33
|
+
NAME => agent_name,
|
|
34
|
+
COMPONENT => COMPONENT_AGENT,
|
|
35
|
+
MANAGED_BY => MANAGED_BY_AICTL,
|
|
36
|
+
PART_OF => PROJECT_NAME
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Build test agent labels for temporary test pods
|
|
41
|
+
#
|
|
42
|
+
# @param name [String] The name of the test agent
|
|
43
|
+
# @return [Hash] Hash of labels for test Kubernetes resources
|
|
44
|
+
def test_agent_labels(name)
|
|
45
|
+
{
|
|
46
|
+
NAME => name,
|
|
47
|
+
COMPONENT => COMPONENT_TEST_AGENT,
|
|
48
|
+
MANAGED_BY => MANAGED_BY_AICTL,
|
|
49
|
+
PART_OF => PROJECT_NAME
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Build a label selector string for finding agent pods
|
|
54
|
+
#
|
|
55
|
+
# @param agent_name [String] The normalized agent name
|
|
56
|
+
# @return [String] Label selector string for kubectl commands
|
|
57
|
+
def agent_selector(agent_name)
|
|
58
|
+
"#{NAME}=#{agent_name}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Build a label selector string for finding tool pods
|
|
62
|
+
#
|
|
63
|
+
# @param tool_name [String] The tool name
|
|
64
|
+
# @return [String] Label selector string for kubectl commands
|
|
65
|
+
def tool_selector(tool_name)
|
|
66
|
+
"#{TOOL_LABEL}=#{tool_name}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Common cluster management labels
|
|
70
|
+
#
|
|
71
|
+
# @return [Hash] Hash of labels for cluster management resources
|
|
72
|
+
def cluster_management_labels
|
|
73
|
+
{
|
|
74
|
+
MANAGED_BY => MANAGED_BY_AICTL
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -33,6 +33,12 @@ module LanguageOperator
|
|
|
33
33
|
|
|
34
34
|
mode_string = mode_string.to_s.downcase.strip
|
|
35
35
|
|
|
36
|
+
# Handle empty/whitespace mode strings with specific error message
|
|
37
|
+
if mode_string.empty?
|
|
38
|
+
raise ArgumentError, 'AGENT_MODE environment variable is required but is unset or empty. ' \
|
|
39
|
+
"Please set AGENT_MODE to one of: #{ALL_MODE_ALIASES.join(', ')}"
|
|
40
|
+
end
|
|
41
|
+
|
|
36
42
|
EXECUTION_MODES.each do |primary, aliases|
|
|
37
43
|
return primary.to_s if aliases.include?(mode_string)
|
|
38
44
|
end
|
|
@@ -50,5 +56,12 @@ module LanguageOperator
|
|
|
50
56
|
|
|
51
57
|
ALL_MODE_ALIASES.include?(mode_string.to_s.downcase.strip)
|
|
52
58
|
end
|
|
59
|
+
|
|
60
|
+
# Kubernetes Custom Resource Definitions (CRD) kinds
|
|
61
|
+
# These replace magic strings scattered across CLI commands
|
|
62
|
+
RESOURCE_AGENT = 'LanguageAgent'
|
|
63
|
+
RESOURCE_MODEL = 'LanguageModel'
|
|
64
|
+
RESOURCE_TOOL = 'LanguageTool'
|
|
65
|
+
RESOURCE_PERSONA = 'LanguagePersona'
|
|
53
66
|
end
|
|
54
67
|
end
|
|
@@ -4,6 +4,8 @@ require 'English'
|
|
|
4
4
|
require 'net/http'
|
|
5
5
|
require 'uri'
|
|
6
6
|
require 'json'
|
|
7
|
+
require 'ipaddr'
|
|
8
|
+
require 'socket'
|
|
7
9
|
|
|
8
10
|
module LanguageOperator
|
|
9
11
|
module Dsl
|
|
@@ -20,8 +22,10 @@ module LanguageOperator
|
|
|
20
22
|
class HTTP
|
|
21
23
|
# Perform a GET request
|
|
22
24
|
def self.get(url, headers: {}, follow_redirects: true, timeout: 30)
|
|
23
|
-
|
|
24
|
-
return
|
|
25
|
+
validation_result = validate_url(url)
|
|
26
|
+
return validation_result unless validation_result[:success]
|
|
27
|
+
|
|
28
|
+
uri = validation_result[:uri]
|
|
25
29
|
|
|
26
30
|
http = build_http(uri, timeout: timeout)
|
|
27
31
|
request = Net::HTTP::Get.new(uri)
|
|
@@ -32,8 +36,10 @@ module LanguageOperator
|
|
|
32
36
|
|
|
33
37
|
# Perform a POST request
|
|
34
38
|
def self.post(url, body: nil, json: nil, headers: {}, auth: nil, timeout: 30)
|
|
35
|
-
|
|
36
|
-
return
|
|
39
|
+
validation_result = validate_url(url)
|
|
40
|
+
return validation_result unless validation_result[:success]
|
|
41
|
+
|
|
42
|
+
uri = validation_result[:uri]
|
|
37
43
|
|
|
38
44
|
http = build_http(uri, timeout: timeout)
|
|
39
45
|
request = Net::HTTP::Post.new(uri)
|
|
@@ -54,8 +60,10 @@ module LanguageOperator
|
|
|
54
60
|
|
|
55
61
|
# Perform a PUT request
|
|
56
62
|
def self.put(url, body: nil, json: nil, headers: {}, auth: nil, timeout: 30)
|
|
57
|
-
|
|
58
|
-
return
|
|
63
|
+
validation_result = validate_url(url)
|
|
64
|
+
return validation_result unless validation_result[:success]
|
|
65
|
+
|
|
66
|
+
uri = validation_result[:uri]
|
|
59
67
|
|
|
60
68
|
http = build_http(uri, timeout: timeout)
|
|
61
69
|
request = Net::HTTP::Put.new(uri)
|
|
@@ -75,8 +83,10 @@ module LanguageOperator
|
|
|
75
83
|
|
|
76
84
|
# Perform a DELETE request
|
|
77
85
|
def self.delete(url, headers: {}, auth: nil, timeout: 30)
|
|
78
|
-
|
|
79
|
-
return
|
|
86
|
+
validation_result = validate_url(url)
|
|
87
|
+
return validation_result unless validation_result[:success]
|
|
88
|
+
|
|
89
|
+
uri = validation_result[:uri]
|
|
80
90
|
|
|
81
91
|
http = build_http(uri, timeout: timeout)
|
|
82
92
|
request = Net::HTTP::Delete.new(uri)
|
|
@@ -89,8 +99,10 @@ module LanguageOperator
|
|
|
89
99
|
|
|
90
100
|
# Get just the headers from a URL
|
|
91
101
|
def self.head(url, headers: {}, timeout: 30)
|
|
92
|
-
|
|
93
|
-
return
|
|
102
|
+
validation_result = validate_url(url)
|
|
103
|
+
return validation_result unless validation_result[:success]
|
|
104
|
+
|
|
105
|
+
uri = validation_result[:uri]
|
|
94
106
|
|
|
95
107
|
http = build_http(uri, timeout: timeout)
|
|
96
108
|
request = Net::HTTP::Head.new(uri)
|
|
@@ -111,12 +123,117 @@ module LanguageOperator
|
|
|
111
123
|
class << self
|
|
112
124
|
private
|
|
113
125
|
|
|
126
|
+
def validate_url(url)
|
|
127
|
+
return { error: 'URL cannot be nil', success: false } if url.nil?
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
uri = URI.parse(url)
|
|
131
|
+
rescue URI::InvalidURIError
|
|
132
|
+
return { error: 'Invalid URL format', success: false }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
return { error: 'URL cannot be empty', success: false } if uri.nil?
|
|
136
|
+
|
|
137
|
+
# Validate URL scheme
|
|
138
|
+
unless %w[http https].include?(uri.scheme&.downcase)
|
|
139
|
+
return {
|
|
140
|
+
error: "URL scheme '#{uri.scheme}' not allowed. Only HTTP and HTTPS are permitted for security reasons.",
|
|
141
|
+
success: false
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Validate host
|
|
146
|
+
host_validation = validate_host(uri.host)
|
|
147
|
+
return host_validation unless host_validation[:success]
|
|
148
|
+
|
|
149
|
+
{ success: true, uri: uri }
|
|
150
|
+
end
|
|
151
|
+
|
|
114
152
|
def parse_uri(url)
|
|
115
153
|
URI.parse(url)
|
|
116
154
|
rescue URI::InvalidURIError
|
|
117
155
|
nil
|
|
118
156
|
end
|
|
119
157
|
|
|
158
|
+
def validate_host(host)
|
|
159
|
+
return { error: 'Host cannot be empty', success: false } if host.nil? || host.empty?
|
|
160
|
+
|
|
161
|
+
# Resolve hostname to IP if needed
|
|
162
|
+
begin
|
|
163
|
+
ip_addr = IPAddr.new(host)
|
|
164
|
+
rescue IPAddr::InvalidAddressError
|
|
165
|
+
# If it's a hostname, resolve it to IP
|
|
166
|
+
begin
|
|
167
|
+
resolved_ips = Addrinfo.getaddrinfo(host, nil, nil, :STREAM)
|
|
168
|
+
# Check all resolved IPs - if any are blocked, reject the request
|
|
169
|
+
resolved_ips.each do |addr_info|
|
|
170
|
+
ip_addr = IPAddr.new(addr_info.ip_address)
|
|
171
|
+
safe_result = safe_ip(ip_addr)
|
|
172
|
+
unless safe_result[:success]
|
|
173
|
+
return {
|
|
174
|
+
error: "Host '#{host}' resolves to blocked IP address #{ip_addr}: #{safe_result[:error]}",
|
|
175
|
+
success: false
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
return { success: true }
|
|
180
|
+
rescue SocketError
|
|
181
|
+
return { error: "Unable to resolve hostname: #{host}", success: false }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
safe_result = safe_ip(ip_addr)
|
|
186
|
+
unless safe_result[:success]
|
|
187
|
+
return {
|
|
188
|
+
error: "IP address #{ip_addr} is blocked: #{safe_result[:error]}",
|
|
189
|
+
success: false
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
{ success: true }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def safe_ip(ip_addr)
|
|
197
|
+
# Block private IP ranges (RFC 1918)
|
|
198
|
+
private_ranges = [
|
|
199
|
+
{ range: IPAddr.new('10.0.0.0/8'), description: 'private IP range (RFC 1918)' },
|
|
200
|
+
{ range: IPAddr.new('172.16.0.0/12'), description: 'private IP range (RFC 1918)' },
|
|
201
|
+
{ range: IPAddr.new('192.168.0.0/16'), description: 'private IP range (RFC 1918)' }
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
# Block loopback addresses
|
|
205
|
+
loopback_ranges = [
|
|
206
|
+
{ range: IPAddr.new('127.0.0.0/8'), description: 'loopback address' },
|
|
207
|
+
{ range: IPAddr.new('::1/128'), description: 'IPv6 loopback address' }
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
# Block link-local addresses
|
|
211
|
+
link_local_ranges = [
|
|
212
|
+
{ range: IPAddr.new('169.254.0.0/16'), description: 'link-local address (AWS metadata endpoint)' },
|
|
213
|
+
{ range: IPAddr.new('fe80::/10'), description: 'IPv6 link-local address' }
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
# Block broadcast address
|
|
217
|
+
broadcast_ranges = [
|
|
218
|
+
{ range: IPAddr.new('255.255.255.255/32'), description: 'broadcast address' }
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# Check if IP is in any blocked range
|
|
222
|
+
all_blocked_ranges = private_ranges + loopback_ranges + link_local_ranges + broadcast_ranges
|
|
223
|
+
all_blocked_ranges.each do |blocked_range|
|
|
224
|
+
if blocked_range[:range].include?(ip_addr)
|
|
225
|
+
return {
|
|
226
|
+
error: "access to #{blocked_range[:description]} not allowed for security reasons",
|
|
227
|
+
success: false
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
{ success: true }
|
|
233
|
+
rescue IPAddr::InvalidAddressError
|
|
234
|
+
{ error: 'invalid IP address format', success: false }
|
|
235
|
+
end
|
|
236
|
+
|
|
120
237
|
def build_http(uri, timeout: 30)
|
|
121
238
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
122
239
|
http.use_ssl = (uri.scheme == 'https')
|
|
@@ -98,16 +98,43 @@ module LanguageOperator
|
|
|
98
98
|
#
|
|
99
99
|
# @param file_path [String] Path to the tool definition file
|
|
100
100
|
# @return [Registry] The global registry with loaded tools
|
|
101
|
+
# @raise [PathTraversalError] When the file path attempts path traversal
|
|
102
|
+
# @raise [FileNotFoundError] When the file doesn't exist
|
|
103
|
+
# @raise [FilePermissionError] When the file can't be read due to permissions
|
|
104
|
+
# @raise [FileSyntaxError] When the file contains invalid Ruby syntax
|
|
101
105
|
#
|
|
102
106
|
# @example
|
|
103
107
|
# LanguageOperator::Dsl.load_file("mcp/tools.rb")
|
|
104
108
|
def load_file(file_path)
|
|
105
|
-
|
|
109
|
+
# Validate file path to prevent path traversal attacks
|
|
110
|
+
validated_path = validate_file_path!(file_path, context: 'tool definition file loading')
|
|
111
|
+
|
|
112
|
+
# Check if file exists
|
|
113
|
+
raise FileNotFoundError, Errors.file_not_found(file_path, 'tool definition file') unless File.exist?(validated_path)
|
|
114
|
+
|
|
115
|
+
# Attempt to read the file
|
|
116
|
+
begin
|
|
117
|
+
code = File.read(validated_path)
|
|
118
|
+
rescue Errno::EACCES
|
|
119
|
+
raise FilePermissionError, Errors.file_permission_denied(file_path, 'tool definition file')
|
|
120
|
+
rescue Errno::EISDIR
|
|
121
|
+
raise FileNotFoundError, Errors.file_not_found(file_path, 'tool definition file')
|
|
122
|
+
rescue SystemCallError => e
|
|
123
|
+
raise FileLoadError, "Error reading tool definition file '#{file_path}': #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
|
|
106
126
|
context = Context.new(registry)
|
|
107
127
|
|
|
108
128
|
# Execute in sandbox with validation
|
|
109
|
-
|
|
110
|
-
|
|
129
|
+
begin
|
|
130
|
+
executor = Agent::Safety::SafeExecutor.new(context)
|
|
131
|
+
executor.eval(code, validated_path)
|
|
132
|
+
rescue SyntaxError => e
|
|
133
|
+
raise FileSyntaxError, Errors.file_syntax_error(file_path, e.message, 'tool definition file')
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
# Re-raise with additional context for other execution errors
|
|
136
|
+
raise FileLoadError, "Error executing tool definition file '#{file_path}': #{e.message}"
|
|
137
|
+
end
|
|
111
138
|
|
|
112
139
|
registry
|
|
113
140
|
end
|
|
@@ -116,16 +143,43 @@ module LanguageOperator
|
|
|
116
143
|
#
|
|
117
144
|
# @param file_path [String] Path to the agent definition file
|
|
118
145
|
# @return [AgentRegistry] The global agent registry
|
|
146
|
+
# @raise [PathTraversalError] When the file path attempts path traversal
|
|
147
|
+
# @raise [FileNotFoundError] When the file doesn't exist
|
|
148
|
+
# @raise [FilePermissionError] When the file can't be read due to permissions
|
|
149
|
+
# @raise [FileSyntaxError] When the file contains invalid Ruby syntax
|
|
119
150
|
#
|
|
120
151
|
# @example
|
|
121
152
|
# LanguageOperator::Dsl.load_agent_file("agents/news-summarizer.rb")
|
|
122
153
|
def load_agent_file(file_path)
|
|
123
|
-
|
|
154
|
+
# Validate file path to prevent path traversal attacks
|
|
155
|
+
validated_path = validate_file_path!(file_path, context: 'agent definition file loading')
|
|
156
|
+
|
|
157
|
+
# Check if file exists
|
|
158
|
+
raise FileNotFoundError, Errors.file_not_found(file_path, 'agent definition file') unless File.exist?(validated_path)
|
|
159
|
+
|
|
160
|
+
# Attempt to read the file
|
|
161
|
+
begin
|
|
162
|
+
code = File.read(validated_path)
|
|
163
|
+
rescue Errno::EACCES
|
|
164
|
+
raise FilePermissionError, Errors.file_permission_denied(file_path, 'agent definition file')
|
|
165
|
+
rescue Errno::EISDIR
|
|
166
|
+
raise FileNotFoundError, Errors.file_not_found(file_path, 'agent definition file')
|
|
167
|
+
rescue SystemCallError => e
|
|
168
|
+
raise FileLoadError, "Error reading agent definition file '#{file_path}': #{e.message}"
|
|
169
|
+
end
|
|
170
|
+
|
|
124
171
|
context = AgentContext.new(agent_registry)
|
|
125
172
|
|
|
126
173
|
# Execute in sandbox with validation
|
|
127
|
-
|
|
128
|
-
|
|
174
|
+
begin
|
|
175
|
+
executor = Agent::Safety::SafeExecutor.new(context)
|
|
176
|
+
executor.eval(code, validated_path)
|
|
177
|
+
rescue SyntaxError => e
|
|
178
|
+
raise FileSyntaxError, Errors.file_syntax_error(file_path, e.message, 'agent definition file')
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
# Re-raise with additional context for other execution errors
|
|
181
|
+
raise FileLoadError, "Error executing agent definition file '#{file_path}': #{e.message}"
|
|
182
|
+
end
|
|
129
183
|
|
|
130
184
|
agent_registry
|
|
131
185
|
end
|
|
@@ -155,6 +209,99 @@ module LanguageOperator
|
|
|
155
209
|
def create_server(server_name: 'langop-tools', server_context: {})
|
|
156
210
|
Adapter.create_mcp_server(registry, server_name: server_name, server_context: server_context)
|
|
157
211
|
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
# Validate file path to prevent path traversal attacks
|
|
216
|
+
#
|
|
217
|
+
# @param file_path [String] The file path to validate
|
|
218
|
+
# @param context [String] Context for error messages
|
|
219
|
+
# @return [String] The validated and resolved absolute path
|
|
220
|
+
# @raise [PathTraversalError] When path traversal is detected
|
|
221
|
+
def validate_file_path!(file_path, context: 'file loading')
|
|
222
|
+
# Check for suspicious patterns before path resolution
|
|
223
|
+
raise PathTraversalError, Errors.path_traversal_blocked(context) if contains_path_traversal_patterns?(file_path)
|
|
224
|
+
|
|
225
|
+
# Resolve the path to handle relative paths and symlinks
|
|
226
|
+
begin
|
|
227
|
+
resolved_path = File.expand_path(file_path)
|
|
228
|
+
rescue ArgumentError => e
|
|
229
|
+
raise PathTraversalError, "Invalid file path during #{context}: #{e.message}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Get allowed base directories
|
|
233
|
+
allowed_bases = get_allowed_base_paths
|
|
234
|
+
|
|
235
|
+
# Check if resolved path is within any allowed base directory
|
|
236
|
+
raise PathTraversalError, Errors.path_traversal_blocked(context) unless allowed_bases.any? { |base| path_within_base?(resolved_path, base) }
|
|
237
|
+
|
|
238
|
+
resolved_path
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Check for common path traversal patterns in the raw path
|
|
242
|
+
#
|
|
243
|
+
# @param file_path [String] The file path to check
|
|
244
|
+
# @return [Boolean] True if suspicious patterns are detected
|
|
245
|
+
def contains_path_traversal_patterns?(file_path)
|
|
246
|
+
# List of suspicious patterns that indicate path traversal attempts
|
|
247
|
+
# Focus on actual traversal patterns, not just any relative path
|
|
248
|
+
patterns = [
|
|
249
|
+
/\.\./, # Parent directory references (classic traversal)
|
|
250
|
+
/\x00/, # Null byte injection
|
|
251
|
+
/%2e%2e/i, # URL-encoded parent directory
|
|
252
|
+
/%2f/i, # URL-encoded path separator
|
|
253
|
+
/%5c/i, # URL-encoded backslash
|
|
254
|
+
/\\+\.\./, # Windows-style parent directory with backslashes
|
|
255
|
+
%r{/\.\.+}, # Multiple dots after slash
|
|
256
|
+
%r{\.\.[/\\]} # Parent directory followed by path separator
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
patterns.any? { |pattern| file_path.match?(pattern) }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Get list of allowed base directories for file operations
|
|
263
|
+
#
|
|
264
|
+
# @return [Array<String>] List of allowed base directory paths
|
|
265
|
+
def get_allowed_base_paths
|
|
266
|
+
# Start with current working directory
|
|
267
|
+
allowed_paths = [File.expand_path('.')]
|
|
268
|
+
|
|
269
|
+
# Add paths from environment variable if set
|
|
270
|
+
if ENV['LANGOP_ALLOWED_PATHS']
|
|
271
|
+
custom_paths = ENV['LANGOP_ALLOWED_PATHS'].split(':').map { |path| File.expand_path(path.strip) }
|
|
272
|
+
allowed_paths.concat(custom_paths)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Add common subdirectories for typical usage patterns
|
|
276
|
+
%w[agents tools examples].each do |subdir|
|
|
277
|
+
subdir_path = File.expand_path(subdir)
|
|
278
|
+
allowed_paths << subdir_path if Dir.exist?(subdir_path)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# In test environment, be more permissive (allow /tmp and similar)
|
|
282
|
+
if defined?(RSpec) || ENV['RAILS_ENV'] == 'test' || ENV['RACK_ENV'] == 'test'
|
|
283
|
+
allowed_paths.concat([
|
|
284
|
+
'/tmp',
|
|
285
|
+
File.expand_path('spec'),
|
|
286
|
+
File.expand_path('test')
|
|
287
|
+
].map { |path| File.expand_path(path) })
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
allowed_paths.uniq
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Check if a resolved path is within an allowed base directory
|
|
294
|
+
#
|
|
295
|
+
# @param resolved_path [String] The resolved absolute path to check
|
|
296
|
+
# @param base_path [String] The base directory path
|
|
297
|
+
# @return [Boolean] True if path is within the base directory
|
|
298
|
+
def path_within_base?(resolved_path, base_path)
|
|
299
|
+
# Ensure base path ends with separator for accurate prefix matching
|
|
300
|
+
normalized_base = File.join(base_path, '')
|
|
301
|
+
|
|
302
|
+
# Allow exact matches or paths that start with the base directory
|
|
303
|
+
resolved_path == base_path || resolved_path.start_with?(normalized_base)
|
|
304
|
+
end
|
|
158
305
|
end
|
|
159
306
|
end
|
|
160
307
|
end
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module LanguageOperator
|
|
4
|
+
# Base exception class for all Language Operator errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# File loading related errors
|
|
8
|
+
class FileLoadError < Error; end
|
|
9
|
+
class FileNotFoundError < FileLoadError; end
|
|
10
|
+
class FilePermissionError < FileLoadError; end
|
|
11
|
+
class FileSyntaxError < FileLoadError; end
|
|
12
|
+
|
|
13
|
+
# Security related errors
|
|
14
|
+
class SecurityError < Error; end
|
|
15
|
+
class PathTraversalError < SecurityError; end
|
|
16
|
+
|
|
4
17
|
# Standardized error formatting module for consistent error messages across tools
|
|
5
18
|
module Errors
|
|
6
19
|
# Resource not found error
|
|
@@ -56,5 +69,42 @@ module LanguageOperator
|
|
|
56
69
|
def self.empty_field(field_name)
|
|
57
70
|
"Error: #{field_name} cannot be empty"
|
|
58
71
|
end
|
|
72
|
+
|
|
73
|
+
# File not found error
|
|
74
|
+
# @param file_path [String] Path to the file that wasn't found
|
|
75
|
+
# @param context [String] Additional context about what the file is for
|
|
76
|
+
# @return [String] Formatted error message
|
|
77
|
+
def self.file_not_found(file_path, context = 'file')
|
|
78
|
+
"Error: #{context.capitalize} not found at '#{file_path}'. " \
|
|
79
|
+
'Please check the file path exists and is accessible.'
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# File permission error
|
|
83
|
+
# @param file_path [String] Path to the file with permission issues
|
|
84
|
+
# @param context [String] Additional context about what the file is for
|
|
85
|
+
# @return [String] Formatted error message
|
|
86
|
+
def self.file_permission_denied(file_path, context = 'file')
|
|
87
|
+
"Error: Permission denied reading #{context} '#{file_path}'. " \
|
|
88
|
+
'Please check file permissions or run with appropriate access rights.'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# File syntax error
|
|
92
|
+
# @param file_path [String] Path to the file with syntax errors
|
|
93
|
+
# @param original_error [String] Original error message from parser
|
|
94
|
+
# @param context [String] Additional context about what the file is for
|
|
95
|
+
# @return [String] Formatted error message
|
|
96
|
+
def self.file_syntax_error(file_path, original_error, context = 'file')
|
|
97
|
+
"Error: Syntax error in #{context} '#{file_path}': #{original_error}. " \
|
|
98
|
+
'Please check the file for valid Ruby syntax.'
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Path traversal security error
|
|
102
|
+
# @param context [String] Context about what operation was attempted
|
|
103
|
+
# @return [String] Formatted error message
|
|
104
|
+
def self.path_traversal_blocked(context = 'file operation')
|
|
105
|
+
"Error: Path traversal attempt blocked during #{context}. " \
|
|
106
|
+
'File path must be within allowed directories. ' \
|
|
107
|
+
'Use relative paths or configure LANGOP_ALLOWED_PATHS if needed.'
|
|
108
|
+
end
|
|
59
109
|
end
|
|
60
110
|
end
|