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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/persona.md +9 -0
  3. data/.claude/commands/task.md +46 -1
  4. data/.rubocop.yml +13 -0
  5. data/.rubocop_custom/use_ux_helper.rb +44 -0
  6. data/CHANGELOG.md +8 -0
  7. data/Gemfile.lock +12 -1
  8. data/Makefile +26 -7
  9. data/Makefile.common +50 -0
  10. data/bin/aictl +8 -1
  11. data/components/agent/Gemfile +1 -1
  12. data/components/agent/bin/langop-agent +7 -0
  13. data/docs/README.md +58 -0
  14. data/docs/{dsl/best-practices.md → best-practices.md} +4 -4
  15. data/docs/cli-reference.md +274 -0
  16. data/docs/{dsl/constraints.md → constraints.md} +5 -5
  17. data/docs/how-agents-work.md +156 -0
  18. data/docs/installation.md +218 -0
  19. data/docs/quickstart.md +299 -0
  20. data/docs/understanding-generated-code.md +265 -0
  21. data/docs/using-tools.md +457 -0
  22. data/docs/webhooks.md +509 -0
  23. data/examples/ux_helpers_demo.rb +296 -0
  24. data/lib/language_operator/agent/base.rb +11 -1
  25. data/lib/language_operator/agent/executor.rb +23 -6
  26. data/lib/language_operator/agent/safety/safe_executor.rb +41 -39
  27. data/lib/language_operator/agent/task_executor.rb +346 -63
  28. data/lib/language_operator/agent/web_server.rb +110 -14
  29. data/lib/language_operator/agent/webhook_authenticator.rb +39 -5
  30. data/lib/language_operator/agent.rb +88 -2
  31. data/lib/language_operator/cli/base_command.rb +17 -11
  32. data/lib/language_operator/cli/command_loader.rb +72 -0
  33. data/lib/language_operator/cli/commands/agent/base.rb +837 -0
  34. data/lib/language_operator/cli/commands/agent/code_operations.rb +102 -0
  35. data/lib/language_operator/cli/commands/agent/helpers/cluster_llm_client.rb +116 -0
  36. data/lib/language_operator/cli/commands/agent/helpers/code_parser.rb +115 -0
  37. data/lib/language_operator/cli/commands/agent/helpers/synthesis_watcher.rb +96 -0
  38. data/lib/language_operator/cli/commands/agent/learning.rb +289 -0
  39. data/lib/language_operator/cli/commands/agent/lifecycle.rb +102 -0
  40. data/lib/language_operator/cli/commands/agent/logs.rb +125 -0
  41. data/lib/language_operator/cli/commands/agent/workspace.rb +327 -0
  42. data/lib/language_operator/cli/commands/cluster.rb +129 -84
  43. data/lib/language_operator/cli/commands/install.rb +1 -1
  44. data/lib/language_operator/cli/commands/model/base.rb +215 -0
  45. data/lib/language_operator/cli/commands/model/test.rb +165 -0
  46. data/lib/language_operator/cli/commands/persona.rb +16 -34
  47. data/lib/language_operator/cli/commands/quickstart.rb +3 -2
  48. data/lib/language_operator/cli/commands/status.rb +40 -67
  49. data/lib/language_operator/cli/commands/system/base.rb +44 -0
  50. data/lib/language_operator/cli/commands/system/exec.rb +147 -0
  51. data/lib/language_operator/cli/commands/system/helpers/llm_synthesis.rb +183 -0
  52. data/lib/language_operator/cli/commands/system/helpers/pod_manager.rb +212 -0
  53. data/lib/language_operator/cli/commands/system/helpers/template_loader.rb +57 -0
  54. data/lib/language_operator/cli/commands/system/helpers/template_validator.rb +174 -0
  55. data/lib/language_operator/cli/commands/system/schema.rb +92 -0
  56. data/lib/language_operator/cli/commands/system/synthesis_template.rb +151 -0
  57. data/lib/language_operator/cli/commands/system/synthesize.rb +224 -0
  58. data/lib/language_operator/cli/commands/system/validate_template.rb +130 -0
  59. data/lib/language_operator/cli/commands/tool/base.rb +271 -0
  60. data/lib/language_operator/cli/commands/tool/install.rb +255 -0
  61. data/lib/language_operator/cli/commands/tool/search.rb +69 -0
  62. data/lib/language_operator/cli/commands/tool/test.rb +115 -0
  63. data/lib/language_operator/cli/commands/use.rb +29 -6
  64. data/lib/language_operator/cli/errors/handler.rb +20 -17
  65. data/lib/language_operator/cli/errors/suggestions.rb +3 -5
  66. data/lib/language_operator/cli/errors/thor_errors.rb +55 -0
  67. data/lib/language_operator/cli/formatters/code_formatter.rb +4 -11
  68. data/lib/language_operator/cli/formatters/log_formatter.rb +8 -15
  69. data/lib/language_operator/cli/formatters/progress_formatter.rb +6 -8
  70. data/lib/language_operator/cli/formatters/status_formatter.rb +26 -7
  71. data/lib/language_operator/cli/formatters/table_formatter.rb +47 -36
  72. data/lib/language_operator/cli/formatters/value_formatter.rb +75 -0
  73. data/lib/language_operator/cli/helpers/cluster_context.rb +5 -3
  74. data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +2 -1
  75. data/lib/language_operator/cli/helpers/label_utils.rb +97 -0
  76. data/lib/language_operator/{ux/concerns/provider_helpers.rb → cli/helpers/provider_helper.rb} +10 -29
  77. data/lib/language_operator/cli/helpers/schedule_builder.rb +21 -1
  78. data/lib/language_operator/cli/helpers/user_prompts.rb +19 -11
  79. data/lib/language_operator/cli/helpers/ux_helper.rb +538 -0
  80. data/lib/language_operator/{ux/concerns/input_validation.rb → cli/helpers/validation_helper.rb} +13 -66
  81. data/lib/language_operator/cli/main.rb +50 -40
  82. data/lib/language_operator/cli/templates/tools/generic.yaml +3 -0
  83. data/lib/language_operator/cli/wizards/agent_wizard.rb +12 -20
  84. data/lib/language_operator/cli/wizards/model_wizard.rb +271 -0
  85. data/lib/language_operator/cli/wizards/quickstart_wizard.rb +8 -34
  86. data/lib/language_operator/client/base.rb +28 -0
  87. data/lib/language_operator/client/config.rb +4 -1
  88. data/lib/language_operator/client/mcp_connector.rb +1 -1
  89. data/lib/language_operator/config/cluster_config.rb +3 -2
  90. data/lib/language_operator/config.rb +38 -11
  91. data/lib/language_operator/constants/kubernetes_labels.rb +80 -0
  92. data/lib/language_operator/constants.rb +13 -0
  93. data/lib/language_operator/dsl/http.rb +127 -10
  94. data/lib/language_operator/dsl.rb +153 -6
  95. data/lib/language_operator/errors.rb +50 -0
  96. data/lib/language_operator/kubernetes/client.rb +11 -6
  97. data/lib/language_operator/kubernetes/resource_builder.rb +58 -84
  98. data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
  99. data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
  100. data/lib/language_operator/type_coercion.rb +118 -34
  101. data/lib/language_operator/utils/secure_path.rb +74 -0
  102. data/lib/language_operator/utils.rb +7 -0
  103. data/lib/language_operator/validators.rb +54 -2
  104. data/lib/language_operator/version.rb +1 -1
  105. data/synth/001/Makefile +10 -2
  106. data/synth/001/agent.rb +16 -15
  107. data/synth/001/output.log +27 -10
  108. data/synth/002/Makefile +10 -2
  109. data/synth/003/Makefile +1 -1
  110. data/synth/003/README.md +205 -133
  111. data/synth/003/agent.optimized.rb +66 -0
  112. data/synth/003/agent.synthesized.rb +41 -0
  113. metadata +111 -35
  114. data/docs/dsl/agent-reference.md +0 -604
  115. data/docs/dsl/mcp-integration.md +0 -1177
  116. data/docs/dsl/webhooks.md +0 -932
  117. data/docs/dsl/workflows.md +0 -744
  118. data/lib/language_operator/cli/commands/agent.rb +0 -1712
  119. data/lib/language_operator/cli/commands/model.rb +0 -366
  120. data/lib/language_operator/cli/commands/system.rb +0 -1259
  121. data/lib/language_operator/cli/commands/tool.rb +0 -654
  122. data/lib/language_operator/cli/formatters/optimization_formatter.rb +0 -226
  123. data/lib/language_operator/cli/helpers/pastel_helper.rb +0 -24
  124. data/lib/language_operator/learning/adapters/base_adapter.rb +0 -149
  125. data/lib/language_operator/learning/adapters/jaeger_adapter.rb +0 -221
  126. data/lib/language_operator/learning/adapters/signoz_adapter.rb +0 -435
  127. data/lib/language_operator/learning/adapters/tempo_adapter.rb +0 -239
  128. data/lib/language_operator/learning/optimizer.rb +0 -319
  129. data/lib/language_operator/learning/pattern_detector.rb +0 -260
  130. data/lib/language_operator/learning/task_synthesizer.rb +0 -288
  131. data/lib/language_operator/learning/trace_analyzer.rb +0 -285
  132. data/lib/language_operator/templates/task_synthesis.tmpl +0 -98
  133. data/lib/language_operator/ux/base.rb +0 -81
  134. data/lib/language_operator/ux/concerns/README.md +0 -155
  135. data/lib/language_operator/ux/concerns/headings.rb +0 -90
  136. data/lib/language_operator/ux/create_agent.rb +0 -255
  137. data/lib/language_operator/ux/create_model.rb +0 -267
  138. data/lib/language_operator/ux/quickstart.rb +0 -594
  139. data/synth/003/agent.rb +0 -41
  140. data/synth/003/output.log +0 -68
  141. /data/docs/{architecture/agent-runtime.md → agent-internals.md} +0 -0
  142. /data/docs/{dsl/chat-endpoints.md → chat-endpoints.md} +0 -0
  143. /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 = File.expand_path('~/.aictl')
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.load_file(CONFIG_PATH) || default_config
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.to_i
113
+ Integer(value)
108
114
  when :float
109
- value.to_f
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
- value = get(*keys)
188
- return default if value.nil?
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
- value.to_i
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
- value = get(*keys)
205
- return default if value.nil?
222
+ keys.each do |key|
223
+ value = ENV[key.to_s]
224
+ next unless value
206
225
 
207
- %w[true 1 yes on].include?(value.to_s.downcase)
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
- value = get(*keys)
221
- return default if value.nil? || value.empty?
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
- value.split(separator).map(&:strip).reject(&:empty?)
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
- uri = parse_uri(url)
24
- return { error: "Invalid URL: #{url}" } unless uri
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
- uri = parse_uri(url)
36
- return { error: "Invalid URL: #{url}" } unless uri
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
- uri = parse_uri(url)
58
- return { error: "Invalid URL: #{url}" } unless uri
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
- uri = parse_uri(url)
79
- return { error: "Invalid URL: #{url}" } unless uri
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
- uri = parse_uri(url)
93
- return { error: "Invalid URL: #{url}" } unless uri
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
- code = File.read(file_path)
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
- executor = Agent::Safety::SafeExecutor.new(context)
110
- executor.eval(code, file_path)
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
- code = File.read(file_path)
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
- executor = Agent::Safety::SafeExecutor.new(context)
128
- executor.eval(code, file_path)
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