hyperprobe-agent 1.2.27.pre.1 → 1.2.27.pre.3

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.
@@ -4,85 +4,164 @@ require 'ripper'
4
4
 
5
5
  module HyperProbe
6
6
  module Core
7
+ # This is deliberately a small expression language, not read-only Ruby.
8
+ # Compile the entire tree before executing even a short-circuited branch.
7
9
  class SafeASTValidator
8
10
  MAX_EXPRESSION_LENGTH = 256
9
- CACHE_CAPACITY = 200
10
-
11
- FORBIDDEN_OPERATORS = %w[
12
- = += -= *= /= %= << >> |= &= ^=
13
- ].freeze
14
-
15
- FORBIDDEN_IDENTIFIERS = %w[
16
- system exec spawn fork exit exit! abort
17
- eval instance_eval class_eval module_eval
18
- send __send__ public_send define_method
19
- const_set const_get instance_variable_set remove_instance_variable
20
- delete delete! delete_all destroy destroy!
21
- update update! update_all save save! create create!
22
- truncate drop clear write require load
23
- ].freeze
24
-
25
- FORBIDDEN_CONSTANTS = %w[
26
- File Dir IO FileUtils Socket TCPSocket UDPSocket Net
27
- Process Kernel ObjectSpace GC System
28
- ].freeze
29
-
30
- @cache = {}
31
- @cache_mutex = Mutex.new
11
+ MAX_NODES = 128
12
+ MAX_DEPTH = 32
13
+ BINARY_OPERATORS = %i[+ - * / % == != < <= > >= && || and or].freeze
14
+ UNARY_OPERATORS = %i[+@ -@ ! not].freeze
15
+ CLASS = Object.instance_method(:class)
16
+ IDENTITY = BasicObject.instance_method(:equal?)
17
+ STRING_SIZE = String.instance_method(:bytesize)
18
+ STRING_SLICE = String.instance_method(:byteslice)
32
19
 
33
20
  class << self
34
21
  def validate!(expression)
35
- return if expression.nil? || expression.strip.empty?
22
+ parse!(expression)
23
+ nil
24
+ end
36
25
 
37
- clean_expr = expression.strip
38
- if clean_expr.length > MAX_EXPRESSION_LENGTH
39
- raise SecurityError, "Expression is too long (max #{MAX_EXPRESSION_LENGTH} characters)."
26
+ # Copy through a builtin, never dup/clone/to_str hooks on application data.
27
+ def source(expression, limit = MAX_EXPRESSION_LENGTH)
28
+ unless IDENTITY.bind(CLASS.bind(expression).call).call(String)
29
+ raise SecurityError, 'Expression must be an exact builtin String'
40
30
  end
41
-
42
- @cache_mutex.synchronize do
43
- return if @cache[clean_expr]
31
+ if STRING_SIZE.bind(expression).call > limit
32
+ raise SecurityError, "Expression is too long (max #{limit} bytes)"
44
33
  end
45
34
 
46
- tokens = Ripper.lex(clean_expr)
47
-
48
- tokens.each_with_index do |tok, idx|
49
- _pos, type, text, _state = tok
35
+ copy = STRING_SLICE.bind(expression).call(0, limit + 1)
36
+ raise SecurityError, "Expression is too long (max #{limit} bytes)" if copy.bytesize > limit
37
+ copy
38
+ end
50
39
 
51
- # 1. Reject Shell Backtick / Command Execution (`...` or %x{...})
52
- if type == :on_backtick || type == :on_xstring_beg
53
- raise SecurityError, 'Shell command execution is strictly forbidden in probe expressions.'
40
+ def parse!(expression)
41
+ return [:literal, nil] if IDENTITY.bind(expression).call(nil)
42
+
43
+ text = source(expression)
44
+ return [:literal, nil] if text.strip.empty?
45
+
46
+ tree = Ripper.sexp(text)
47
+ raise ArgumentError, 'Invalid expression syntax' unless tree
48
+
49
+ # Only ordinary quoted strings are supported. Decode their contents
50
+ # ourselves; Ripper returns escaped source, not evaluated strings.
51
+ strings = {}
52
+ quote = nil
53
+ Ripper.lex(text).each do |position, type, token, _state|
54
+ case type
55
+ when :on_tstring_beg
56
+ reject!('string syntax') unless ["'", '"'].include?(token)
57
+ quote = token
58
+ when :on_tstring_content
59
+ strings[position] = decode_string(token, quote)
60
+ when :on_tstring_end
61
+ quote = nil
62
+ when :on_embexpr_beg, :on_embvar, :on_heredoc_beg
63
+ reject!('string interpolation or heredoc')
54
64
  end
65
+ end
55
66
 
56
- # 2. Reject Mutating Operators and Assignments (=, +=, -=, <<, etc.)
57
- if type == :on_op && FORBIDDEN_OPERATORS.include?(text)
58
- raise SecurityError, "Assignments and state mutations ('#{text}') are strictly forbidden in probe expressions."
59
- end
67
+ compile(tree, strings, [0], 0)
68
+ end
60
69
 
61
- # 3. Reject Forbidden System, File, and Metaprogramming Identifiers
62
- if (type == :on_ident || type == :on_kw) && FORBIDDEN_IDENTIFIERS.include?(text)
63
- raise SecurityError, "Method or function invocation '#{text}()' is strictly forbidden during probe evaluation."
64
- end
70
+ # Kept for callers that used to clear the lexical validator's cache.
71
+ # Parsing is bounded and no user-controlled expressions are retained.
72
+ def reset_cache!; end
65
73
 
66
- # 4. Reject Dangerous Class Invocations (File.delete, Net::HTTP, etc.)
67
- if type == :on_const && FORBIDDEN_CONSTANTS.include?(text)
68
- # Check if followed by a method call like File.read or Net::HTTP.get
69
- next_tokens = tokens[(idx + 1)..]
70
- has_method_call = next_tokens&.any? { |t| t[1] == :on_period || t[1] == :on_op && t[2] == '::' }
71
- if has_method_call
72
- raise SecurityError, "Access to system constant '#{text}' is strictly forbidden in probe expressions."
73
- end
74
- end
75
- end
74
+ private
75
+
76
+ def reject!(feature)
77
+ raise SecurityError, "Unsupported #{feature}: forbidden in probe expressions"
78
+ end
76
79
 
77
- @cache_mutex.synchronize do
78
- @cache.shift if @cache.size >= CACHE_CAPACITY
79
- @cache[clean_expr] = true
80
+ def decode_string(text, quote)
81
+ reject!('string syntax') unless quote
82
+ text.gsub(/\\(.)/m) do
83
+ char = Regexp.last_match(1)
84
+ if quote == "'"
85
+ ["'", '\\'].include?(char) ? char : "\\#{char}"
86
+ else
87
+ escapes = { 'n' => "\n", 'r' => "\r", 't' => "\t", '\\' => '\\', '"' => '"', "'" => "'", '#' => '#' }
88
+ reject!('string escape') unless escapes.key?(char)
89
+ escapes[char]
90
+ end
80
91
  end
81
92
  end
82
93
 
83
- def reset_cache!
84
- @cache_mutex.synchronize do
85
- @cache.clear
94
+ def compile(node, strings, budget, depth)
95
+ budget[0] += 1
96
+ reject!('expression complexity') if budget[0] > MAX_NODES || depth > MAX_DEPTH
97
+ child = ->(value) { compile(value, strings, budget, depth + 1) }
98
+
99
+ case node[0]
100
+ when :program, :paren
101
+ reject!('multiple statements') unless node[1]&.length == 1
102
+ child.call(node[1][0])
103
+ when :vcall, :var_ref
104
+ token = node[1]
105
+ if token[0] == :@ident
106
+ [:local, token[1]]
107
+ elsif token[0] == :@kw && %w[true false nil].include?(token[1])
108
+ [:literal, { 'true' => true, 'false' => false, 'nil' => nil }[token[1]]]
109
+ else
110
+ reject!('variable or constant access')
111
+ end
112
+ when :@int
113
+ [:literal, Integer(node[1])]
114
+ when :@float
115
+ [:literal, Float(node[1])]
116
+ when :string_literal
117
+ content = node[1]
118
+ reject!('string syntax') unless content[0] == :string_content
119
+ value = content.drop(1).map do |part|
120
+ reject!('string interpolation') unless part[0] == :@tstring_content
121
+ strings.fetch(part[2])
122
+ end.join
123
+ [:literal, value]
124
+ when :symbol_literal
125
+ symbol = node[1]
126
+ reject!('symbol syntax') unless symbol[0] == :symbol && %i[@ident @op @kw].include?(symbol[1][0])
127
+ [:literal, symbol[1][1].to_sym]
128
+ when :@label
129
+ [:literal, node[1][0...-1].to_sym]
130
+ when :array
131
+ [:array, (node[1] || []).map { |item| child.call(item) }]
132
+ when :hash
133
+ pairs = node[1]
134
+ reject!('hash syntax') if pairs && pairs[0] != :assoclist_from_args
135
+ [:hash, (pairs ? pairs[1] : []).map do |pair|
136
+ reject!('hash expansion') unless pair[0] == :assoc_new
137
+ [child.call(pair[1]), child.call(pair[2])]
138
+ end]
139
+ when :aref
140
+ args = node[2]
141
+ unless args && args[0] == :args_add_block && args[2] == false && args[1].length == 1
142
+ reject!('subscript arguments')
143
+ end
144
+ [:lookup, child.call(node[1]), child.call(args[1][0])]
145
+ when :call
146
+ unless (node[2][0] == :@period || node[2] == :'.') && node[3][0] == :@ident && %w[length size].include?(node[3][1])
147
+ reject!('method call')
148
+ end
149
+ [:length, child.call(node[1])]
150
+ when :method_add_arg
151
+ unless node[1][0] == :call && node[2] == [:arg_paren, nil]
152
+ reject!('method arguments or function call')
153
+ end
154
+ child.call(node[1])
155
+ when :binary
156
+ reject!('operator') unless BINARY_OPERATORS.include?(node[2])
157
+ [:binary, node[2], child.call(node[1]), child.call(node[3])]
158
+ when :unary
159
+ reject!('unary operator') unless UNARY_OPERATORS.include?(node[1])
160
+ [:unary, node[1], child.call(node[2])]
161
+ when :ifop
162
+ [:conditional, child.call(node[1]), child.call(node[2]), child.call(node[3])]
163
+ else
164
+ reject!("syntax #{node[0]}")
86
165
  end
87
166
  end
88
167
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'monitor'
4
+ require 'java' if RUBY_ENGINE == 'jruby'
5
+
3
6
  module HyperProbe
4
7
  module Core
5
8
  module AgentHealth
@@ -16,14 +19,20 @@ module HyperProbe
16
19
  SEVERE_LAG_WINDOW_SIZE = 5
17
20
  SEVERE_LAG_RED_BREACHES = 3
18
21
  SEVERE_LAG_MULTIPLIER = 4.0
22
+ HEAP_SAMPLE_INTERVAL_MS = 5000.0
23
+ HEAP_YELLOW_FRACTION = 0.15
24
+ HEAP_RED_FRACTION = 0.05
19
25
 
20
26
  attr_reader :health, :last_thread_lag_ms, :cumulative_pause_time, :max_lag_ms, :pause_budget_ms
21
27
 
22
- def initialize(on_state_change, max_lag_ms: 50.0, pause_budget_ms: 15.0, is_ephemeral_lambda: false)
28
+ def initialize(on_state_change, max_lag_ms: 50.0, pause_budget_ms: 15.0, is_ephemeral_lambda: false,
29
+ heap_runtime: (RUBY_ENGINE == 'jruby' ? Java::JavaLang::Runtime.get_runtime : nil))
23
30
  @on_state_change = on_state_change
24
31
  @max_lag_ms = max_lag_ms.to_f
25
32
  @pause_budget_ms = pause_budget_ms.to_f
26
33
  @is_ephemeral_lambda = is_ephemeral_lambda
34
+ @heap_runtime = heap_runtime
35
+ @heap_available_fraction = 1.0
27
36
 
28
37
  @health = AgentHealth::GREEN
29
38
  @last_heartbeat = monotonic_ms
@@ -33,40 +42,61 @@ module HyperProbe
33
42
  @last_window_reset = monotonic_ms
34
43
 
35
44
  @mutex = Mutex.new
36
- @notification_mutex = Mutex.new
45
+ @notification_mutex = Monitor.new
46
+ @transition_generation = 0
37
47
  @running = false
38
48
  @thread = nil
39
49
  end
40
50
 
41
51
  def start
42
52
  @mutex.synchronize do
43
- return if @running
53
+ return if @running && @thread&.alive?
44
54
 
45
55
  @running = true
46
56
  @last_heartbeat = monotonic_ms
47
57
  @last_window_reset = monotonic_ms
58
+ @last_heap_sample = @last_window_reset
48
59
 
49
60
  @thread = Thread.new { monitor_loop }
50
61
  @thread.name = 'hyperprobe-safety' if @thread.respond_to?(:name=)
51
62
  end
52
63
  end
53
64
 
54
- def stop
65
+ def after_fork
55
66
  @mutex.synchronize do
56
67
  @running = false
68
+ @thread = nil
57
69
  end
58
- @thread&.wakeup if @thread&.alive?
59
- @thread&.join(1.0) rescue nil
60
- @thread = nil
70
+ start
71
+ end
72
+
73
+ def stop
74
+ thread = @mutex.synchronize do
75
+ @running = false
76
+ current = @thread
77
+ @thread = nil
78
+ current
79
+ end
80
+ return if !thread || thread == Thread.current
81
+
82
+ thread.kill
83
+ thread.join(0.1)
84
+ rescue Exception
85
+ # Stop is agent-owned cleanup, including when called from a trap.
61
86
  end
62
87
 
63
88
  def report_pause_duration(ms)
64
89
  callback_info = nil
65
- @mutex.synchronize do
90
+ return unless @mutex.try_lock
91
+ begin
66
92
  @cumulative_pause_time += ms.to_f
67
93
  callback_info = evaluate_health_locked
94
+ # A live monitor delivers transitions off the application thread.
95
+ @pending_notification = callback_info if @running && callback_info
96
+ ensure
97
+ @mutex.unlock
68
98
  end
69
- trigger_callback(callback_info) if callback_info
99
+ trigger_callback(callback_info) if callback_info && !@running
70
100
  end
71
101
 
72
102
  def record_thread_lag(thread_lag_ms)
@@ -82,6 +112,16 @@ module HyperProbe
82
112
  @mutex.synchronize { @health }
83
113
  end
84
114
 
115
+ def with_health(expected)
116
+ @notification_mutex.synchronize do
117
+ return unless @mutex.synchronize { @health == expected }
118
+
119
+ yield
120
+ end
121
+ rescue Exception
122
+ # Serialize cooldown actions with transition delivery, not state updates.
123
+ end
124
+
85
125
  private
86
126
 
87
127
  def monitor_loop
@@ -97,17 +137,25 @@ module HyperProbe
97
137
  thread_lag_ms = [0.0, now - wait_started - (SAMPLE_INTERVAL_SECONDS * 1000.0)].max
98
138
  record_thread_lag_locked(thread_lag_ms)
99
139
 
140
+ if @heap_runtime && !@is_ephemeral_lambda && now - @last_heap_sample >= HEAP_SAMPLE_INTERVAL_MS
141
+ sample_heap_locked
142
+ @last_heap_sample = now
143
+ end
144
+
100
145
  # Reset pause budget window every second
101
146
  if now - @last_window_reset > 1000.0
102
147
  @cumulative_pause_time = 0.0
103
148
  @last_window_reset = now
104
149
  end
105
150
 
106
- callback_info = evaluate_health_locked
151
+ callback_info = evaluate_health_locked || @pending_notification
152
+ @pending_notification = nil
107
153
  end
108
154
 
109
155
  trigger_callback(callback_info) if callback_info
110
156
  end
157
+ rescue Exception
158
+ @running = false
111
159
  end
112
160
 
113
161
  def record_thread_lag_locked(lag_ms)
@@ -117,6 +165,17 @@ module HyperProbe
117
165
  @thread_lag_samples.shift while @thread_lag_samples.length > LAG_WINDOW_SIZE
118
166
  end
119
167
 
168
+ def sample_heap_locked
169
+ maximum = @heap_runtime.max_memory.to_f
170
+ return unless maximum.positive?
171
+
172
+ committed = @heap_runtime.total_memory
173
+ free = @heap_runtime.free_memory
174
+ @heap_available_fraction = (free + maximum - committed) / maximum
175
+ rescue Exception
176
+ # Preserve the last heap health and retry at the next sample.
177
+ end
178
+
120
179
  def evaluate_health_locked
121
180
  prev_health = @health
122
181
  reason = nil
@@ -125,7 +184,8 @@ module HyperProbe
125
184
  @health = AgentHealth::GREEN
126
185
  return nil if prev_health == AgentHealth::GREEN
127
186
 
128
- return [@health, 'System stabilized in Lambda mode.']
187
+ @transition_generation += 1
188
+ return [@health, 'System stabilized in Lambda mode.', @transition_generation]
129
189
  end
130
190
 
131
191
  recent_lags = @thread_lag_samples.dup
@@ -134,7 +194,18 @@ module HyperProbe
134
194
  severe_window = recent_lags.last(SEVERE_LAG_WINDOW_SIZE)
135
195
  severe_lag_breaches = severe_window.count { |lag| lag > severe_lag_ms }
136
196
 
137
- if severe_lag_breaches >= SEVERE_LAG_RED_BREACHES
197
+ if @heap_runtime
198
+ if @heap_available_fraction < HEAP_RED_FRACTION
199
+ @health = AgentHealth::RED
200
+ reason = "JVM heap headroom (#{(@heap_available_fraction * 100).round(1)}%) below 5% of maximum heap"
201
+ elsif @heap_available_fraction < HEAP_YELLOW_FRACTION
202
+ @health = AgentHealth::YELLOW
203
+ reason = "JVM heap headroom (#{(@heap_available_fraction * 100).round(1)}%) below 15% of maximum heap"
204
+ else
205
+ @health = AgentHealth::GREEN
206
+ reason = 'JVM heap headroom stabilized.' if prev_health != AgentHealth::GREEN
207
+ end
208
+ elsif severe_lag_breaches >= SEVERE_LAG_RED_BREACHES
138
209
  @health = AgentHealth::RED
139
210
  reason = "Severe Execution Thread Lag exceeded #{severe_lag_ms.round(1)}ms in #{severe_lag_breaches}/#{SEVERE_LAG_WINDOW_SIZE} recent readings"
140
211
  elsif lag_breaches >= LAG_RED_BREACHES
@@ -159,15 +230,21 @@ module HyperProbe
159
230
 
160
231
  return unless @health != prev_health
161
232
 
162
- [@health, reason]
233
+ @transition_generation += 1
234
+ [@health, reason, @transition_generation]
163
235
  end
164
236
 
165
237
  def trigger_callback(callback_info)
166
- health, reason = callback_info
238
+ health, reason, generation = callback_info
167
239
  @notification_mutex.synchronize do
240
+ # State transitions release @mutex before acquiring the delivery lock.
241
+ # Recheck here so a delayed notification cannot undo a newer one.
242
+ current = @mutex.synchronize { generation == @transition_generation && health == @health }
243
+ return unless current
244
+
168
245
  @on_state_change&.call(health, reason)
169
246
  end
170
- rescue StandardError
247
+ rescue Exception
171
248
  # Never crash application thread or safety monitor on callback error
172
249
  end
173
250