anzen 0.1.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.
data/lib/anzen/cli.rb ADDED
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module Anzen
7
+ # Command-line interface for Anzen safety monitoring
8
+ #
9
+ # Provides operator-facing commands to query protection status,
10
+ # configuration, and monitor state without code changes.
11
+ #
12
+ # @api public
13
+ class CLI
14
+ # Display current protection configuration and monitor state
15
+ #
16
+ # @param format [Symbol] Output format (:json or :text)
17
+ # @return [String] Formatted output
18
+ # @api public
19
+ def status(format: :text)
20
+ status_data = Anzen.status
21
+
22
+ case format
23
+ when :json
24
+ JSON.pretty_generate(status_data)
25
+ when :text
26
+ format_status_text(status_data)
27
+ else
28
+ raise ArgumentError, "Invalid format: #{format}. Use :json or :text"
29
+ end
30
+ end
31
+
32
+ # Display detailed configuration for all monitors or specific monitor
33
+ #
34
+ # @param monitor_name [String, nil] Specific monitor name, or nil for all
35
+ # @param format [Symbol] Output format (:json or :text)
36
+ # @return [String] Formatted output
37
+ # @api public
38
+ def config(monitor_name: nil, format: :text)
39
+ if monitor_name
40
+ monitor_config = get_monitor_config(monitor_name)
41
+ case format
42
+ when :json
43
+ JSON.pretty_generate(monitor_config)
44
+ when :text
45
+ format_monitor_config_text(monitor_name, monitor_config)
46
+ else
47
+ raise ArgumentError, "Invalid format: #{format}. Use :json or :text"
48
+ end
49
+ else
50
+ all_configs = get_all_configs
51
+ case format
52
+ when :json
53
+ JSON.pretty_generate(all_configs)
54
+ when :text
55
+ format_all_configs_text(all_configs)
56
+ else
57
+ raise ArgumentError, "Invalid format: #{format}. Use :json or :text"
58
+ end
59
+ end
60
+ end
61
+
62
+ # Display general information about the Anzen gem installation
63
+ #
64
+ # @param format [Symbol] Output format (:json or :text)
65
+ # @return [String] Formatted output
66
+ # @api public
67
+ def info(format: :text)
68
+ info_data = {
69
+ name: 'anzen',
70
+ version: Anzen::VERSION,
71
+ ruby_version: RUBY_VERSION,
72
+ platform: RUBY_PLATFORM,
73
+ monitors_available: %w[recursion memory],
74
+ license: 'MIT',
75
+ documentation_url: 'https://github.com/[org]/anzen'
76
+ }
77
+
78
+ case format
79
+ when :json
80
+ JSON.pretty_generate(info_data)
81
+ when :text
82
+ format_info_text(info_data)
83
+ else
84
+ raise ArgumentError, "Invalid format: #{format}. Use :json or :text"
85
+ end
86
+ end
87
+
88
+ # Display help documentation and command reference
89
+ #
90
+ # @param command [String, nil] Specific command name, or nil for general help
91
+ # @return [String] Help text
92
+ # @api public
93
+ def help(command: nil)
94
+ if command
95
+ format_command_help(command)
96
+ else
97
+ format_general_help
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def get_monitor_config(monitor_name)
104
+ status_data = Anzen.status
105
+ monitor = status_data[:monitors].find { |m| m[:name] == monitor_name }
106
+
107
+ unless monitor
108
+ available = status_data[:monitors].map { |m| m[:name] }
109
+ raise Anzen::MonitorNotFoundError.new(monitor_name)
110
+ end
111
+
112
+ {
113
+ monitor: monitor_name,
114
+ enabled: monitor[:enabled],
115
+ config: monitor[:thresholds]
116
+ }
117
+ end
118
+
119
+ def get_all_configs
120
+ status_data = Anzen.status
121
+ {
122
+ monitors: status_data[:monitors].each_with_object({}) do |monitor, hash|
123
+ hash[monitor[:name]] = {
124
+ enabled: monitor[:enabled],
125
+ config: monitor[:thresholds]
126
+ }
127
+ end
128
+ }
129
+ end
130
+
131
+ def format_status_text(status_data)
132
+ output = []
133
+ output << 'Anzen Safety Protection Status'
134
+ output << ('=' * 40)
135
+ output << ''
136
+ output << "Enabled Monitors: #{status_data[:enabled].join(", ")}"
137
+ output << ''
138
+
139
+ status_data[:monitors].each do |monitor|
140
+ output << "Monitor: #{monitor[:name]}"
141
+ output << " Status: #{monitor[:enabled] ? "enabled" : "disabled"}"
142
+ output << ' Thresholds:'
143
+
144
+ monitor[:thresholds].each do |key, value|
145
+ output << " #{key}: #{value}"
146
+ end
147
+
148
+ last_check = monitor[:last_check]
149
+ output << if last_check
150
+ " Last Check: #{last_check.strftime("%Y-%m-%d %H:%M:%S %Z")}"
151
+ else
152
+ ' Last Check: never'
153
+ end
154
+
155
+ output << " Violations Detected: #{monitor[:violations]}"
156
+ output << ''
157
+ end
158
+
159
+ output << "Total Violations: #{status_data[:violations_total]}"
160
+ output << "Setup Time: #{status_data[:setup_at].strftime("%Y-%m-%d %H:%M:%S %Z")}"
161
+
162
+ output.join("\n")
163
+ end
164
+
165
+ def format_monitor_config_text(monitor_name, config)
166
+ output = []
167
+ output << "Monitor: #{monitor_name}"
168
+ output << " Enabled: #{config[:enabled] ? "yes" : "no"}"
169
+ output << ' Configuration:'
170
+
171
+ config[:config].each do |key, value|
172
+ output << " #{key}: #{value}"
173
+ end
174
+
175
+ output.join("\n")
176
+ end
177
+
178
+ def format_all_configs_text(configs)
179
+ output = []
180
+ output << 'Anzen Configuration'
181
+ output << ('=' * 20)
182
+ output << ''
183
+
184
+ configs[:monitors].each do |name, config|
185
+ output << "Monitor: #{name}"
186
+ output << " Enabled: #{config[:enabled] ? "yes" : "no"}"
187
+ output << ' Configuration:'
188
+
189
+ config[:config].each do |key, value|
190
+ output << " #{key}: #{value}"
191
+ end
192
+
193
+ output << ''
194
+ end
195
+
196
+ output.join("\n")
197
+ end
198
+
199
+ def format_info_text(info_data)
200
+ output = []
201
+ output << 'Anzen Gem Information'
202
+ output << ('=' * 25)
203
+ output << ''
204
+ output << "Version: #{info_data[:version]}"
205
+ output << "Installed Location: #{Gem.loaded_specs["anzen"]&.full_gem_path || "Not installed as gem"}"
206
+ output << "Ruby Version: #{info_data[:ruby_version]}"
207
+ output << "Platform: #{info_data[:platform]}"
208
+ output << "Available Monitors: #{info_data[:monitors_available].join(", ")}"
209
+ output << "License: #{info_data[:license]}"
210
+ output << "Documentation: #{info_data[:documentation_url]}"
211
+
212
+ output.join("\n")
213
+ end
214
+
215
+ def format_general_help
216
+ <<~HELP
217
+ Anzen Safety Protection - Command Line Interface
218
+
219
+ Usage: anzen [command] [options]
220
+
221
+ Commands:
222
+ status Display protection status and monitor state
223
+ config Display monitor configuration details
224
+ info Display gem installation information
225
+ help Show this help message
226
+
227
+ Options:
228
+ --format Output format: json, text (default: text)
229
+ --help Show command help
230
+ -v, --version Show gem version
231
+
232
+ Examples:
233
+ anzen status # Show status
234
+ anzen config memory --format json # Show memory config as JSON
235
+ anzen info # Show gem info
236
+
237
+ For more information, see: https://github.com/[org]/anzen
238
+ HELP
239
+ end
240
+
241
+ def format_command_help(command)
242
+ case command
243
+ when 'status'
244
+ <<~HELP
245
+ anzen status - Display protection status and monitor state
246
+
247
+ Usage: anzen status [options]
248
+
249
+ Options:
250
+ --format FORMAT Output format: json, text (default: text)
251
+
252
+ Examples:
253
+ anzen status
254
+ anzen status --format json
255
+
256
+ Output includes:
257
+ - Monitor names and enabled/disabled state
258
+ - Configured thresholds for each monitor
259
+ - Last check timestamp
260
+ - Total violations detected
261
+ HELP
262
+ when 'config'
263
+ <<~HELP
264
+ anzen config - Display monitor configuration details
265
+
266
+ Usage: anzen config [monitor_name] [options]
267
+
268
+ Arguments:
269
+ monitor_name Optional: Show config for specific monitor
270
+
271
+ Options:
272
+ --format FORMAT Output format: json, text (default: text)
273
+
274
+ Examples:
275
+ anzen config
276
+ anzen config memory
277
+ anzen config --format json
278
+
279
+ Shows configuration for all monitors or specific monitor.
280
+ HELP
281
+ when 'info'
282
+ <<~HELP
283
+ anzen info - Display gem installation information
284
+
285
+ Usage: anzen info [options]
286
+
287
+ Options:
288
+ --format FORMAT Output format: json, text (default: text)
289
+
290
+ Examples:
291
+ anzen info
292
+ anzen info --format json
293
+
294
+ Shows gem version, Ruby version, platform, and other metadata.
295
+ HELP
296
+ else
297
+ "Unknown command '#{command}'. Use 'anzen help' for available commands."
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module Anzen
7
+ # Configuration manager for Anzen
8
+ #
9
+ # Loads and validates configuration from various sources.
10
+ # Supports dot-notation for accessing nested config values.
11
+ #
12
+ # @api public
13
+ class Configuration
14
+ # Known monitor names
15
+ KNOWN_MONITORS = %w[call_stack_depth recursion memory].freeze
16
+ private_constant :KNOWN_MONITORS
17
+ # Load configuration from environment variable
18
+ #
19
+ # Expects ANZEN_CONFIG to contain JSON or YAML config.
20
+ #
21
+ # @return [Configuration] configuration instance
22
+ # @raise [ConfigurationError] if config is invalid
23
+ def self.from_env
24
+ config_str = ENV.fetch('ANZEN_CONFIG', nil)
25
+ raise Anzen::ConfigurationError, 'ANZEN_CONFIG not set' unless config_str
26
+
27
+ config = nil
28
+ begin
29
+ # Try JSON first
30
+ config = JSON.parse(config_str)
31
+ rescue JSON::ParserError
32
+ # Fall back to YAML
33
+ config = YAML.safe_load(config_str)
34
+ end
35
+
36
+ # Ensure we got a hash back
37
+ raise Anzen::ConfigurationError, 'ANZEN_CONFIG must contain a valid JSON or YAML hash' unless config.is_a?(Hash)
38
+
39
+ new(config)
40
+ end
41
+
42
+ # Load configuration from file
43
+ #
44
+ # @param path [String] path to config file (JSON or YAML)
45
+ # @return [Configuration] configuration instance
46
+ # @raise [ConfigurationError] if file doesn't exist or is invalid
47
+ def self.from_file(path)
48
+ raise Anzen::ConfigurationError, "Config file not found: #{path}" unless File.exist?(path)
49
+
50
+ content = File.read(path)
51
+ config = if path.end_with?('.json')
52
+ JSON.parse(content)
53
+ else
54
+ YAML.safe_load(content)
55
+ end
56
+
57
+ new(config)
58
+ end
59
+
60
+ # Create configuration from hash (programmatic)
61
+ #
62
+ # @param config [Hash] configuration hash
63
+ # @return [Configuration] configuration instance
64
+ def self.programmatic(config = {})
65
+ new(config)
66
+ end
67
+
68
+ # Initialize Configuration
69
+ #
70
+ # @param config [Hash] configuration hash
71
+ def initialize(config = {})
72
+ @config = normalize_keys(config)
73
+ validate!
74
+ end
75
+
76
+ # Get configuration value by dot-notation path
77
+ #
78
+ # @param path [String] dot-notation path (e.g., "monitors.recursion.depth_limit")
79
+ # @return [Object] configuration value
80
+ # @raise [ConfigurationError] if path doesn't exist
81
+ def get(path)
82
+ keys = path.split('.')
83
+ value = @config
84
+
85
+ keys.each do |key|
86
+ case value
87
+ when Hash
88
+ value = value[key] || value[key.to_sym]
89
+ else
90
+ raise Anzen::ConfigurationError, "Cannot access '#{key}' in non-hash value"
91
+ end
92
+
93
+ raise Anzen::ConfigurationError, "Config path not found: #{path}" if value.nil?
94
+ end
95
+
96
+ value
97
+ end
98
+
99
+ # Check if monitor is enabled
100
+ #
101
+ # @param name [String] monitor name
102
+ # @return [Boolean] true if monitor is in enabled_monitors list
103
+ def monitor_enabled?(name)
104
+ enabled = begin
105
+ get('enabled_monitors')
106
+ rescue StandardError
107
+ []
108
+ end
109
+ enabled.include?(name)
110
+ end
111
+
112
+ # Get configuration for specific monitor
113
+ #
114
+ # @param name [String] monitor name
115
+ # @return [Hash] monitor configuration
116
+ # @raise [ConfigurationError] if monitor config not found
117
+ def monitor_config(name)
118
+ get("monitors.#{name}")
119
+ end
120
+
121
+ # Validate configuration schema
122
+ #
123
+ # @return [Boolean] true if valid
124
+ # @raise [ConfigurationError] if invalid
125
+ def validate!
126
+ # Normalize keys to strings for consistent access
127
+ normalized = normalize_keys(@config)
128
+
129
+ # enabled_monitors should be array if present and non-nil
130
+ if normalized.key?('enabled_monitors') && !normalized['enabled_monitors'].nil? && !normalized['enabled_monitors'].is_a?(Array)
131
+ raise Anzen::ConfigurationError, 'enabled_monitors must be an array'
132
+ end
133
+
134
+ # monitors should be hash if present and non-nil
135
+ if normalized.key?('monitors') && !normalized['monitors'].nil? && !normalized['monitors'].is_a?(Hash)
136
+ raise Anzen::ConfigurationError, 'monitors must be a hash'
137
+ end
138
+
139
+ # Check that all enabled monitors are known
140
+ enabled_monitors = normalized['enabled_monitors'] || []
141
+ unknown_monitors = enabled_monitors - KNOWN_MONITORS
142
+ unless unknown_monitors.empty?
143
+ raise Anzen::ConfigurationError, "Unknown monitor(s): #{unknown_monitors.join(", ")}"
144
+ end
145
+
146
+ # Validate monitor configs
147
+ monitors = normalized['monitors'] || {}
148
+ monitors.each do |name, config|
149
+ validate_monitor_config(name, config)
150
+ end
151
+
152
+ true
153
+ end
154
+
155
+ # Return raw configuration hash
156
+ #
157
+ # @return [Hash]
158
+ def to_h
159
+ deep_dup(@config)
160
+ end
161
+
162
+ private
163
+
164
+ # Deep copy of a hash
165
+ #
166
+ # @param obj [Object] object to deep copy
167
+ # @return [Object] deep copy
168
+ def deep_dup(obj)
169
+ case obj
170
+ when Hash
171
+ obj.each_with_object({}) do |(k, v), hash|
172
+ hash[k] = deep_dup(v)
173
+ end
174
+ when Array
175
+ obj.map { |item| deep_dup(item) }
176
+ else
177
+ begin
178
+ obj.dup
179
+ rescue StandardError
180
+ obj
181
+ end
182
+ end
183
+ end
184
+
185
+ # Normalize hash keys from symbols to strings (recursively)
186
+ #
187
+ # @param hash [Hash] hash to normalize
188
+ # @return [Hash] hash with string keys
189
+ def normalize_keys(hash)
190
+ return hash unless hash.is_a?(Hash)
191
+
192
+ hash.each_with_object({}) do |(key, value), new_hash|
193
+ string_key = key.is_a?(Symbol) ? key.to_s : key
194
+ new_hash[string_key] = value.is_a?(Hash) ? normalize_keys(value) : value
195
+ end
196
+ end
197
+
198
+ # Validate individual monitor configuration
199
+ #
200
+ # @param name [String] monitor name
201
+ # @param config [Hash] monitor configuration
202
+ # @raise [ConfigurationError] if invalid
203
+ def validate_monitor_config(name, config)
204
+ return unless config.is_a?(Hash)
205
+
206
+ case name
207
+ when 'recursion'
208
+ validate_recursion_config(config)
209
+ when 'memory'
210
+ validate_memory_config(config)
211
+ end
212
+ end
213
+
214
+ # Validate recursion monitor config
215
+ #
216
+ # @param config [Hash] recursion config
217
+ # @raise [ConfigurationError] if invalid
218
+ def validate_recursion_config(config)
219
+ normalized = normalize_keys(config)
220
+
221
+ if normalized['depth_limit'] && !normalized['depth_limit'].is_a?(Numeric)
222
+ raise Anzen::ConfigurationError, 'recursion.depth_limit must be numeric'
223
+ end
224
+
225
+ return unless normalized['depth_limit'] && normalized['depth_limit'] <= 0
226
+
227
+ raise Anzen::ConfigurationError, 'recursion.depth_limit must be positive'
228
+ end
229
+
230
+ # Validate memory monitor config
231
+ #
232
+ # @param config [Hash] memory config
233
+ # @raise [ConfigurationError] if invalid
234
+ def validate_memory_config(config)
235
+ normalized = normalize_keys(config)
236
+
237
+ if normalized['limit_mb'] && !normalized['limit_mb'].is_a?(Numeric)
238
+ raise Anzen::ConfigurationError, 'memory.limit_mb must be numeric'
239
+ end
240
+
241
+ if normalized['limit_mb'] && normalized['limit_mb'] <= 0
242
+ raise Anzen::ConfigurationError, 'memory.limit_mb must be positive'
243
+ end
244
+
245
+ if normalized['limit_percent'] && !normalized['limit_percent'].is_a?(Numeric)
246
+ raise Anzen::ConfigurationError, 'memory.limit_percent must be numeric'
247
+ end
248
+
249
+ if normalized['limit_percent'] && (normalized['limit_percent'] <= 0 || normalized['limit_percent'] > 100)
250
+ raise Anzen::ConfigurationError, 'memory.limit_percent must be between 0 and 100'
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anzen
4
+ # Base exception for all Anzen errors
5
+ class Error < StandardError; end
6
+
7
+ # Base exception for safety violations detected by monitors
8
+ #
9
+ # @api public
10
+ class ViolationError < Error; end
11
+
12
+ # Raised when recursion depth exceeds configured threshold
13
+ #
14
+ # @api public
15
+ class RecursionLimitExceeded < ViolationError
16
+ # @return [Integer] current recursion depth when violation detected
17
+ attr_reader :current_depth
18
+
19
+ # @return [Integer] configured depth threshold
20
+ attr_reader :threshold
21
+
22
+ # Initialize RecursionLimitExceeded exception
23
+ #
24
+ # @param current_depth [Integer] current call stack depth
25
+ # @param threshold [Integer] configured limit
26
+ def initialize(current_depth, threshold)
27
+ @current_depth = current_depth
28
+ @threshold = threshold
29
+ super("Recursion depth (#{current_depth}) exceeded threshold (#{threshold})")
30
+ end
31
+ end
32
+
33
+ # Raised when process memory usage exceeds configured threshold
34
+ #
35
+ # @api public
36
+ class MemoryLimitExceeded < ViolationError
37
+ # @return [Integer] current memory usage in MB
38
+ attr_reader :current_memory_mb
39
+
40
+ # @return [Integer] configured memory threshold in MB
41
+ attr_reader :threshold_mb
42
+
43
+ # Initialize MemoryLimitExceeded exception
44
+ #
45
+ # @param current_memory_mb [Integer] current process memory in MB
46
+ # @param threshold_mb [Integer] configured threshold in MB
47
+ def initialize(current_memory_mb, threshold_mb)
48
+ @current_memory_mb = current_memory_mb
49
+ @threshold_mb = threshold_mb
50
+ super("Memory usage (#{current_memory_mb}MB) exceeded threshold (#{threshold_mb}MB)")
51
+ end
52
+ end
53
+
54
+ # Raised when a monitor's check infrastructure fails (not a violation)
55
+ #
56
+ # Used to distinguish infrastructure errors from actual safety violations.
57
+ # For example: cannot read /proc/self/status when checking memory.
58
+ #
59
+ # @api public
60
+ class CheckFailedError < Error
61
+ # @return [String] name of the monitor that failed
62
+ attr_reader :monitor_name
63
+
64
+ # @return [String] reason for the check failure
65
+ attr_reader :reason
66
+
67
+ # @return [Exception, nil] original exception if available
68
+ attr_reader :original_error
69
+
70
+ # Initialize CheckFailedError
71
+ #
72
+ # @param monitor_name [String] name of the monitor that failed
73
+ # @param reason [String] description of what failed
74
+ # @param original_error [Exception, nil] exception from the failed check
75
+ def initialize(monitor_name, reason, original_error = nil)
76
+ @monitor_name = monitor_name
77
+ @reason = reason
78
+ @original_error = original_error
79
+
80
+ message = "Monitor '#{monitor_name}' check failed: #{reason}"
81
+ message += " (#{original_error.class}: #{original_error.message})" if original_error
82
+ super(message)
83
+ end
84
+ end
85
+
86
+ # Raised when Anzen configuration is invalid
87
+ #
88
+ # @api public
89
+ class ConfigurationError < Error; end
90
+
91
+ # Raised when attempting to access a non-existent monitor
92
+ #
93
+ # @api public
94
+ class MonitorNotFoundError < Error
95
+ # @param monitor_name [String] name of monitor that was not found
96
+ def initialize(monitor_name)
97
+ super("Monitor '#{monitor_name}' not found in registry")
98
+ end
99
+ end
100
+
101
+ # Raised when attempting to register an invalid monitor
102
+ #
103
+ # @api public
104
+ class InvalidMonitorError < Error
105
+ # @param reason [String] description of what makes monitor invalid
106
+ def initialize(reason)
107
+ super("Invalid monitor: #{reason}")
108
+ end
109
+ end
110
+
111
+ # Raised when attempting to register a monitor with a name that already exists
112
+ #
113
+ # @api public
114
+ class MonitorNameConflictError < Error
115
+ # @param monitor_name [String] name that caused conflict
116
+ def initialize(monitor_name)
117
+ super("Monitor name '#{monitor_name}' is already registered")
118
+ end
119
+ end
120
+
121
+ # Raised when Anzen.setup is called more than once
122
+ #
123
+ # @api public
124
+ class InitializationError < Error
125
+ # @param reason [String] description of initialization problem
126
+ def initialize(reason = 'Anzen already initialized')
127
+ super(reason)
128
+ end
129
+ end
130
+ end