language-operator 0.0.1 → 0.1.30
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/.rubocop.yml +125 -0
- data/CHANGELOG.md +53 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +284 -0
- data/LICENSE +229 -21
- data/Makefile +77 -0
- data/README.md +3 -11
- data/Rakefile +34 -0
- data/bin/aictl +7 -0
- data/completions/_aictl +232 -0
- data/completions/aictl.bash +121 -0
- data/completions/aictl.fish +114 -0
- data/docs/architecture/agent-runtime.md +585 -0
- data/docs/dsl/agent-reference.md +591 -0
- data/docs/dsl/best-practices.md +1078 -0
- data/docs/dsl/chat-endpoints.md +895 -0
- data/docs/dsl/constraints.md +671 -0
- data/docs/dsl/mcp-integration.md +1177 -0
- data/docs/dsl/webhooks.md +932 -0
- data/docs/dsl/workflows.md +744 -0
- data/examples/README.md +569 -0
- data/examples/agent_example.rb +86 -0
- data/examples/chat_endpoint_agent.rb +118 -0
- data/examples/github_webhook_agent.rb +171 -0
- data/examples/mcp_agent.rb +158 -0
- data/examples/oauth_callback_agent.rb +296 -0
- data/examples/stripe_webhook_agent.rb +219 -0
- data/examples/webhook_agent.rb +80 -0
- data/lib/language_operator/agent/base.rb +110 -0
- data/lib/language_operator/agent/executor.rb +440 -0
- data/lib/language_operator/agent/instrumentation.rb +54 -0
- data/lib/language_operator/agent/metrics_tracker.rb +183 -0
- data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
- data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
- data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
- data/lib/language_operator/agent/safety/content_filter.rb +93 -0
- data/lib/language_operator/agent/safety/manager.rb +207 -0
- data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
- data/lib/language_operator/agent/safety/safe_executor.rb +115 -0
- data/lib/language_operator/agent/scheduler.rb +183 -0
- data/lib/language_operator/agent/telemetry.rb +116 -0
- data/lib/language_operator/agent/web_server.rb +610 -0
- data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
- data/lib/language_operator/agent.rb +149 -0
- data/lib/language_operator/cli/commands/agent.rb +1252 -0
- data/lib/language_operator/cli/commands/cluster.rb +335 -0
- data/lib/language_operator/cli/commands/install.rb +404 -0
- data/lib/language_operator/cli/commands/model.rb +266 -0
- data/lib/language_operator/cli/commands/persona.rb +396 -0
- data/lib/language_operator/cli/commands/quickstart.rb +22 -0
- data/lib/language_operator/cli/commands/status.rb +156 -0
- data/lib/language_operator/cli/commands/tool.rb +537 -0
- data/lib/language_operator/cli/commands/use.rb +47 -0
- data/lib/language_operator/cli/errors/handler.rb +180 -0
- data/lib/language_operator/cli/errors/suggestions.rb +176 -0
- data/lib/language_operator/cli/formatters/code_formatter.rb +81 -0
- data/lib/language_operator/cli/formatters/log_formatter.rb +290 -0
- data/lib/language_operator/cli/formatters/progress_formatter.rb +53 -0
- data/lib/language_operator/cli/formatters/table_formatter.rb +179 -0
- data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
- data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
- data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
- data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
- data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
- data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
- data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
- data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
- data/lib/language_operator/cli/main.rb +232 -0
- data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
- data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
- data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
- data/lib/language_operator/client/base.rb +214 -0
- data/lib/language_operator/client/config.rb +136 -0
- data/lib/language_operator/client/cost_calculator.rb +37 -0
- data/lib/language_operator/client/mcp_connector.rb +123 -0
- data/lib/language_operator/client.rb +19 -0
- data/lib/language_operator/config/cluster_config.rb +101 -0
- data/lib/language_operator/config/tool_patterns.yaml +57 -0
- data/lib/language_operator/config/tool_registry.rb +96 -0
- data/lib/language_operator/config.rb +138 -0
- data/lib/language_operator/dsl/adapter.rb +124 -0
- data/lib/language_operator/dsl/agent_context.rb +90 -0
- data/lib/language_operator/dsl/agent_definition.rb +427 -0
- data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
- data/lib/language_operator/dsl/config.rb +119 -0
- data/lib/language_operator/dsl/context.rb +50 -0
- data/lib/language_operator/dsl/execution_context.rb +47 -0
- data/lib/language_operator/dsl/helpers.rb +109 -0
- data/lib/language_operator/dsl/http.rb +184 -0
- data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
- data/lib/language_operator/dsl/parameter_definition.rb +124 -0
- data/lib/language_operator/dsl/registry.rb +36 -0
- data/lib/language_operator/dsl/shell.rb +125 -0
- data/lib/language_operator/dsl/tool_definition.rb +112 -0
- data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
- data/lib/language_operator/dsl/webhook_definition.rb +106 -0
- data/lib/language_operator/dsl/workflow_definition.rb +259 -0
- data/lib/language_operator/dsl.rb +160 -0
- data/lib/language_operator/errors.rb +60 -0
- data/lib/language_operator/kubernetes/client.rb +279 -0
- data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
- data/lib/language_operator/loggable.rb +47 -0
- data/lib/language_operator/logger.rb +141 -0
- data/lib/language_operator/retry.rb +123 -0
- data/lib/language_operator/retryable.rb +132 -0
- data/lib/language_operator/tool_loader.rb +242 -0
- data/lib/language_operator/validators.rb +170 -0
- data/lib/language_operator/version.rb +1 -1
- data/lib/language_operator.rb +65 -3
- data/requirements/tasks/challenge.md +9 -0
- data/requirements/tasks/iterate.md +36 -0
- data/requirements/tasks/optimize.md +21 -0
- data/requirements/tasks/tag.md +5 -0
- data/test_agent_dsl.rb +108 -0
- metadata +503 -20
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parser/current'
|
|
4
|
+
|
|
5
|
+
module LanguageOperator
|
|
6
|
+
module Agent
|
|
7
|
+
module Safety
|
|
8
|
+
# Validates synthesized Ruby code for security before execution
|
|
9
|
+
# Performs static analysis to detect dangerous method calls
|
|
10
|
+
class ASTValidator
|
|
11
|
+
# Gems that are safe to require (allowlist)
|
|
12
|
+
# These are required for agent execution and are safe
|
|
13
|
+
ALLOWED_REQUIRES = %w[
|
|
14
|
+
language_operator
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
# Dangerous methods that should never be called in synthesized code
|
|
18
|
+
DANGEROUS_METHODS = %w[
|
|
19
|
+
system exec spawn fork ` eval instance_eval class_eval module_eval
|
|
20
|
+
require load autoload require_relative
|
|
21
|
+
send __send__ public_send method __method__
|
|
22
|
+
const_set const_get remove_const
|
|
23
|
+
define_method define_singleton_method
|
|
24
|
+
undef_method remove_method alias_method
|
|
25
|
+
exit exit! abort raise fail throw
|
|
26
|
+
trap at_exit
|
|
27
|
+
open
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# Dangerous constants that should not be accessed
|
|
31
|
+
DANGEROUS_CONSTANTS = %w[
|
|
32
|
+
File Dir IO FileUtils Pathname
|
|
33
|
+
Process Kernel ObjectSpace GC
|
|
34
|
+
Thread Fiber Mutex ConditionVariable
|
|
35
|
+
Socket TCPSocket UDPSocket TCPServer UDPServer
|
|
36
|
+
STDIN STDOUT STDERR
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# Safe DSL methods that are allowed in agent definitions
|
|
40
|
+
SAFE_AGENT_METHODS = %w[
|
|
41
|
+
agent description persona schedule objectives objective
|
|
42
|
+
workflow step tool params depends_on prompt
|
|
43
|
+
constraints budget max_requests rate_limit content_filter
|
|
44
|
+
output mode webhook as_mcp_server as_chat_endpoint
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
# Safe DSL methods for tool definitions
|
|
48
|
+
SAFE_TOOL_METHODS = %w[
|
|
49
|
+
tool description parameter type required default
|
|
50
|
+
execute
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# Safe helper methods available in execute blocks
|
|
54
|
+
SAFE_HELPER_METHODS = %w[
|
|
55
|
+
HTTP Shell
|
|
56
|
+
validate_url validate_phone validate_email
|
|
57
|
+
env_required env_get
|
|
58
|
+
truncate parse_csv
|
|
59
|
+
error success
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# Safe Ruby built-in methods and classes
|
|
63
|
+
SAFE_BUILTINS = %w[
|
|
64
|
+
String Array Hash Integer Float Symbol
|
|
65
|
+
puts print p pp warn
|
|
66
|
+
true false nil
|
|
67
|
+
if unless case when then else elsif end
|
|
68
|
+
while until for break next redo retry return
|
|
69
|
+
begin rescue ensure
|
|
70
|
+
lambda proc block_given? yield
|
|
71
|
+
attr_reader attr_writer attr_accessor
|
|
72
|
+
private protected public
|
|
73
|
+
initialize new
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
class SecurityError < StandardError; end
|
|
77
|
+
|
|
78
|
+
def initialize
|
|
79
|
+
@parser = Parser::CurrentRuby.new
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Validate code and raise SecurityError if dangerous methods found
|
|
83
|
+
# @param code [String] Ruby code to validate
|
|
84
|
+
# @param file_path [String] Path to file (for error messages)
|
|
85
|
+
# @raise [SecurityError] if code contains dangerous methods
|
|
86
|
+
def validate!(code, file_path = '(eval)')
|
|
87
|
+
ast = parse_code(code, file_path)
|
|
88
|
+
return if ast.nil? # Empty code is safe
|
|
89
|
+
|
|
90
|
+
violations = scan_ast(ast)
|
|
91
|
+
|
|
92
|
+
return if violations.empty?
|
|
93
|
+
|
|
94
|
+
raise SecurityError, format_violations(violations, file_path)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Validate code and return array of violations (non-raising version)
|
|
98
|
+
# @param code [String] Ruby code to validate
|
|
99
|
+
# @param file_path [String] Path to file (for error messages)
|
|
100
|
+
# @return [Array<Hash>] Array of violation hashes
|
|
101
|
+
def validate(code, file_path = '(eval)')
|
|
102
|
+
begin
|
|
103
|
+
ast = parse_code(code, file_path)
|
|
104
|
+
rescue SecurityError => e
|
|
105
|
+
# Convert SecurityError (which wraps Parser::SyntaxError) to violation
|
|
106
|
+
return [{ type: :syntax_error, message: e.message }]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
return [] if ast.nil?
|
|
110
|
+
|
|
111
|
+
scan_ast(ast)
|
|
112
|
+
rescue Parser::SyntaxError => e
|
|
113
|
+
[{ type: :syntax_error, message: e.message }]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def parse_code(code, file_path)
|
|
119
|
+
buffer = Parser::Source::Buffer.new(file_path)
|
|
120
|
+
buffer.source = code
|
|
121
|
+
@parser.parse(buffer)
|
|
122
|
+
rescue Parser::SyntaxError => e
|
|
123
|
+
raise SecurityError, "Syntax error in #{file_path}: #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def scan_ast(node, violations = [])
|
|
127
|
+
return violations if node.nil?
|
|
128
|
+
|
|
129
|
+
case node.type
|
|
130
|
+
when :send
|
|
131
|
+
check_method_call(node, violations)
|
|
132
|
+
when :const
|
|
133
|
+
check_constant(node, violations)
|
|
134
|
+
when :gvar
|
|
135
|
+
check_global_variable(node, violations)
|
|
136
|
+
when :xstr
|
|
137
|
+
# Backtick string execution (e.g., `command`)
|
|
138
|
+
violations << {
|
|
139
|
+
type: :backtick_execution,
|
|
140
|
+
location: node.location.line,
|
|
141
|
+
message: 'Backtick command execution is not allowed'
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Recursively scan all child nodes
|
|
146
|
+
node.children.each do |child|
|
|
147
|
+
scan_ast(child, violations) if child.is_a?(Parser::AST::Node)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
violations
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def check_method_call(node, violations)
|
|
154
|
+
receiver, method_name, *args = node.children
|
|
155
|
+
|
|
156
|
+
method_str = method_name.to_s
|
|
157
|
+
|
|
158
|
+
# Special handling for require - check if it's in the allowlist
|
|
159
|
+
if %w[require require_relative].include?(method_str)
|
|
160
|
+
required_gem = extract_require_argument(args)
|
|
161
|
+
|
|
162
|
+
# Allow if in the allowlist
|
|
163
|
+
return if required_gem && ALLOWED_REQUIRES.include?(required_gem)
|
|
164
|
+
|
|
165
|
+
# Otherwise, add violation
|
|
166
|
+
violations << {
|
|
167
|
+
type: :dangerous_method,
|
|
168
|
+
method: method_str,
|
|
169
|
+
location: node.location.line,
|
|
170
|
+
message: "Dangerous method '#{method_str}' is not allowed"
|
|
171
|
+
}
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Check for other dangerous methods
|
|
176
|
+
if DANGEROUS_METHODS.include?(method_str)
|
|
177
|
+
violations << {
|
|
178
|
+
type: :dangerous_method,
|
|
179
|
+
method: method_str,
|
|
180
|
+
location: node.location.line,
|
|
181
|
+
message: "Dangerous method '#{method_str}' is not allowed"
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Check for File/Dir/IO operations
|
|
186
|
+
if receiver && receiver.type == :const
|
|
187
|
+
const_name = receiver.children[1].to_s
|
|
188
|
+
if DANGEROUS_CONSTANTS.include?(const_name)
|
|
189
|
+
violations << {
|
|
190
|
+
type: :dangerous_constant,
|
|
191
|
+
constant: const_name,
|
|
192
|
+
method: method_str,
|
|
193
|
+
location: node.location.line,
|
|
194
|
+
message: "Access to #{const_name}.#{method_str} is not allowed"
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Check for backtick execution (e.g., `command`)
|
|
200
|
+
# Note: backticks are represented as send with method name :`
|
|
201
|
+
return unless method_str == '`'
|
|
202
|
+
|
|
203
|
+
violations << {
|
|
204
|
+
type: :backtick_execution,
|
|
205
|
+
location: node.location.line,
|
|
206
|
+
message: 'Backtick command execution is not allowed'
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def check_constant(node, violations)
|
|
211
|
+
_, const_name = node.children
|
|
212
|
+
const_str = const_name.to_s
|
|
213
|
+
|
|
214
|
+
# Check for dangerous constants being accessed directly
|
|
215
|
+
return unless DANGEROUS_CONSTANTS.include?(const_str)
|
|
216
|
+
|
|
217
|
+
violations << {
|
|
218
|
+
type: :dangerous_constant_access,
|
|
219
|
+
constant: const_str,
|
|
220
|
+
location: node.location.line,
|
|
221
|
+
message: "Direct access to #{const_str} constant is not allowed"
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def check_global_variable(node, violations)
|
|
226
|
+
var_name = node.children[0].to_s
|
|
227
|
+
|
|
228
|
+
# Block access to dangerous global variables
|
|
229
|
+
dangerous_globals = %w[$0 $PROGRAM_NAME $LOAD_PATH $: $LOADED_FEATURES $"]
|
|
230
|
+
|
|
231
|
+
return unless dangerous_globals.include?(var_name)
|
|
232
|
+
|
|
233
|
+
violations << {
|
|
234
|
+
type: :dangerous_global,
|
|
235
|
+
variable: var_name,
|
|
236
|
+
location: node.location.line,
|
|
237
|
+
message: "Access to global variable #{var_name} is not allowed"
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def extract_require_argument(args)
|
|
242
|
+
# args is an array of AST nodes representing the arguments to require
|
|
243
|
+
# We're looking for a string literal like 'language_operator' or "language_operator"
|
|
244
|
+
return nil if args.empty?
|
|
245
|
+
|
|
246
|
+
arg_node = args.first
|
|
247
|
+
return nil unless arg_node
|
|
248
|
+
|
|
249
|
+
# Check if it's a string literal (:str node)
|
|
250
|
+
return arg_node.children[0] if arg_node.type == :str
|
|
251
|
+
|
|
252
|
+
# If it's not a string literal (e.g., dynamic require), we can't verify it
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def format_violations(violations, file_path)
|
|
257
|
+
header = "Security violations detected in #{file_path}:\n\n"
|
|
258
|
+
|
|
259
|
+
violation_messages = violations.map do |v|
|
|
260
|
+
" Line #{v[:location]}: #{v[:message]}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
footer = "\n\nSynthesized code must only use safe DSL methods and approved helpers."
|
|
264
|
+
footer += "\nSafe methods include: #{SAFE_AGENT_METHODS.join(', ')}, #{SAFE_TOOL_METHODS.join(', ')}"
|
|
265
|
+
footer += "\nSafe helpers include: HTTP.*, Shell.run, validate_*, env_*"
|
|
266
|
+
|
|
267
|
+
header + violation_messages.join("\n") + footer
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../../logger'
|
|
5
|
+
require_relative '../../loggable'
|
|
6
|
+
|
|
7
|
+
module LanguageOperator
|
|
8
|
+
module Agent
|
|
9
|
+
module Safety
|
|
10
|
+
# Audit Logger for safety events
|
|
11
|
+
#
|
|
12
|
+
# Logs all safety-related events (blocked requests, budget exceeded, etc.)
|
|
13
|
+
# for compliance and debugging.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# audit = AuditLogger.new
|
|
17
|
+
# audit.log_blocked_request(reason: 'Budget exceeded', details: {...})
|
|
18
|
+
class AuditLogger
|
|
19
|
+
include LanguageOperator::Loggable
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@audit_log_path = ENV['AUDIT_LOG_PATH'] || '/tmp/langop-audit.jsonl'
|
|
23
|
+
logger.info('Audit logger initialized', log_path: @audit_log_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Log a blocked request
|
|
27
|
+
#
|
|
28
|
+
# @param reason [String] Reason for blocking
|
|
29
|
+
# @param details [Hash] Additional details
|
|
30
|
+
def log_blocked_request(reason:, details: {})
|
|
31
|
+
log_event(
|
|
32
|
+
event_type: 'blocked_request',
|
|
33
|
+
reason: reason,
|
|
34
|
+
details: details
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Log a budget event
|
|
39
|
+
#
|
|
40
|
+
# @param event [String] Event description
|
|
41
|
+
# @param details [Hash] Budget details
|
|
42
|
+
def log_budget_event(event:, details: {})
|
|
43
|
+
log_event(
|
|
44
|
+
event_type: 'budget_event',
|
|
45
|
+
event: event,
|
|
46
|
+
details: details
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Log a rate limit event
|
|
51
|
+
#
|
|
52
|
+
# @param event [String] Event description
|
|
53
|
+
# @param details [Hash] Rate limit details
|
|
54
|
+
def log_rate_limit_event(event:, details: {})
|
|
55
|
+
log_event(
|
|
56
|
+
event_type: 'rate_limit_event',
|
|
57
|
+
event: event,
|
|
58
|
+
details: details
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Log a content filter event
|
|
63
|
+
#
|
|
64
|
+
# @param event [String] Event description
|
|
65
|
+
# @param details [Hash] Filter details
|
|
66
|
+
def log_content_filter_event(event:, details: {})
|
|
67
|
+
log_event(
|
|
68
|
+
event_type: 'content_filter_event',
|
|
69
|
+
event: event,
|
|
70
|
+
details: details
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def logger_component
|
|
77
|
+
'Safety::AuditLogger'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def log_event(event_data)
|
|
81
|
+
event = {
|
|
82
|
+
timestamp: Time.now.utc.iso8601,
|
|
83
|
+
agent_name: ENV['AGENT_NAME'] || 'unknown',
|
|
84
|
+
**event_data
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Log to standard logger
|
|
88
|
+
logger.info('Audit event', **event_data)
|
|
89
|
+
|
|
90
|
+
# Append to audit log file
|
|
91
|
+
begin
|
|
92
|
+
File.open(@audit_log_path, 'a') do |f|
|
|
93
|
+
f.puts(JSON.generate(event))
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
logger.error('Failed to write audit log',
|
|
97
|
+
error: e.message,
|
|
98
|
+
log_path: @audit_log_path)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../logger'
|
|
4
|
+
require_relative '../../loggable'
|
|
5
|
+
|
|
6
|
+
module LanguageOperator
|
|
7
|
+
module Agent
|
|
8
|
+
module Safety
|
|
9
|
+
# Budget Tracker for enforcing cost and token limits
|
|
10
|
+
#
|
|
11
|
+
# Tracks cumulative costs and token usage per agent instance
|
|
12
|
+
# and enforces configured budgets.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# tracker = BudgetTracker.new(daily_budget: 10.0, hourly_budget: 1.0)
|
|
16
|
+
# tracker.check_budget!(estimated_cost: 0.05, estimated_tokens: 500)
|
|
17
|
+
class BudgetTracker
|
|
18
|
+
include LanguageOperator::Loggable
|
|
19
|
+
|
|
20
|
+
attr_reader :daily_budget, :hourly_budget, :token_budget
|
|
21
|
+
|
|
22
|
+
class BudgetExceededError < StandardError; end
|
|
23
|
+
|
|
24
|
+
def initialize(daily_budget: nil, hourly_budget: nil, token_budget: nil)
|
|
25
|
+
@daily_budget = daily_budget&.to_f
|
|
26
|
+
@hourly_budget = hourly_budget&.to_f
|
|
27
|
+
@token_budget = token_budget&.to_i
|
|
28
|
+
|
|
29
|
+
@daily_spending = 0.0
|
|
30
|
+
@hourly_spending = 0.0
|
|
31
|
+
@daily_tokens = 0
|
|
32
|
+
@hourly_tokens = 0
|
|
33
|
+
|
|
34
|
+
@day_start = Time.now
|
|
35
|
+
@hour_start = Time.now
|
|
36
|
+
|
|
37
|
+
logger.info('Budget tracker initialized',
|
|
38
|
+
daily_budget: @daily_budget&.round(2),
|
|
39
|
+
hourly_budget: @hourly_budget&.round(2),
|
|
40
|
+
token_budget: @token_budget)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if a request would exceed budget limits
|
|
44
|
+
#
|
|
45
|
+
# @param estimated_cost [Float] Estimated cost for the request
|
|
46
|
+
# @param estimated_tokens [Integer] Estimated token count
|
|
47
|
+
# @raise [BudgetExceededError] If budget would be exceeded
|
|
48
|
+
def check_budget!(estimated_cost: 0.0, estimated_tokens: 0)
|
|
49
|
+
reset_if_needed
|
|
50
|
+
|
|
51
|
+
# Check daily budget
|
|
52
|
+
if @daily_budget && (@daily_spending + estimated_cost) > @daily_budget
|
|
53
|
+
remaining = [@daily_budget - @daily_spending, 0].max
|
|
54
|
+
logger.error('Daily budget exceeded',
|
|
55
|
+
current: @daily_spending.round(4),
|
|
56
|
+
limit: @daily_budget,
|
|
57
|
+
remaining: remaining.round(4),
|
|
58
|
+
requested: estimated_cost.round(4))
|
|
59
|
+
raise BudgetExceededError,
|
|
60
|
+
"Daily budget exceeded: $#{@daily_spending.round(4)}/$#{@daily_budget} " \
|
|
61
|
+
"(requested: $#{estimated_cost.round(4)})"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check hourly budget
|
|
65
|
+
if @hourly_budget && (@hourly_spending + estimated_cost) > @hourly_budget
|
|
66
|
+
remaining = [@hourly_budget - @hourly_spending, 0].max
|
|
67
|
+
logger.error('Hourly budget exceeded',
|
|
68
|
+
current: @hourly_spending.round(4),
|
|
69
|
+
limit: @hourly_budget,
|
|
70
|
+
remaining: remaining.round(4),
|
|
71
|
+
requested: estimated_cost.round(4))
|
|
72
|
+
raise BudgetExceededError,
|
|
73
|
+
"Hourly budget exceeded: $#{@hourly_spending.round(4)}/$#{@hourly_budget} " \
|
|
74
|
+
"(requested: $#{estimated_cost.round(4)})"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Check token budget
|
|
78
|
+
if @token_budget && (@daily_tokens + estimated_tokens) > @token_budget
|
|
79
|
+
remaining = [@token_budget - @daily_tokens, 0].max
|
|
80
|
+
logger.error('Token budget exceeded',
|
|
81
|
+
current: @daily_tokens,
|
|
82
|
+
limit: @token_budget,
|
|
83
|
+
remaining: remaining,
|
|
84
|
+
requested: estimated_tokens)
|
|
85
|
+
raise BudgetExceededError,
|
|
86
|
+
"Token budget exceeded: #{@daily_tokens}/#{@token_budget} " \
|
|
87
|
+
"(requested: #{estimated_tokens})"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
logger.debug('Budget check passed',
|
|
91
|
+
estimated_cost: estimated_cost.round(4),
|
|
92
|
+
estimated_tokens: estimated_tokens)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Record actual spending after a request
|
|
96
|
+
#
|
|
97
|
+
# @param cost [Float] Actual cost of the request
|
|
98
|
+
# @param tokens [Integer] Actual token count
|
|
99
|
+
def record_spending(cost:, tokens:)
|
|
100
|
+
reset_if_needed
|
|
101
|
+
|
|
102
|
+
@daily_spending += cost
|
|
103
|
+
@hourly_spending += cost
|
|
104
|
+
@daily_tokens += tokens
|
|
105
|
+
@hourly_tokens += tokens
|
|
106
|
+
|
|
107
|
+
logger.debug('Spending recorded',
|
|
108
|
+
cost: cost.round(4),
|
|
109
|
+
tokens: tokens,
|
|
110
|
+
daily_total: @daily_spending.round(4),
|
|
111
|
+
hourly_total: @hourly_spending.round(4),
|
|
112
|
+
daily_tokens: @daily_tokens)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get current budget status
|
|
116
|
+
#
|
|
117
|
+
# @return [Hash] Budget status information
|
|
118
|
+
def status
|
|
119
|
+
reset_if_needed
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
daily: {
|
|
123
|
+
spending: @daily_spending.round(4),
|
|
124
|
+
budget: @daily_budget&.round(2),
|
|
125
|
+
remaining: @daily_budget ? (@daily_budget - @daily_spending).round(4) : nil,
|
|
126
|
+
percentage: @daily_budget ? ((@daily_spending / @daily_budget) * 100).round(1) : nil
|
|
127
|
+
},
|
|
128
|
+
hourly: {
|
|
129
|
+
spending: @hourly_spending.round(4),
|
|
130
|
+
budget: @hourly_budget&.round(2),
|
|
131
|
+
remaining: @hourly_budget ? (@hourly_budget - @hourly_spending).round(4) : nil,
|
|
132
|
+
percentage: @hourly_budget ? ((@hourly_spending / @hourly_budget) * 100).round(1) : nil
|
|
133
|
+
},
|
|
134
|
+
tokens: {
|
|
135
|
+
used: @daily_tokens,
|
|
136
|
+
budget: @token_budget,
|
|
137
|
+
remaining: @token_budget ? (@token_budget - @daily_tokens) : nil,
|
|
138
|
+
percentage: @token_budget ? ((@daily_tokens.to_f / @token_budget) * 100).round(1) : nil
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def logger_component
|
|
146
|
+
'Safety::BudgetTracker'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Reset counters if time periods have elapsed
|
|
150
|
+
def reset_if_needed
|
|
151
|
+
now = Time.now
|
|
152
|
+
|
|
153
|
+
# Reset daily counters
|
|
154
|
+
if (now - @day_start) >= 86_400 # 24 hours
|
|
155
|
+
logger.info('Resetting daily budget counters',
|
|
156
|
+
previous_spending: @daily_spending.round(4),
|
|
157
|
+
previous_tokens: @daily_tokens)
|
|
158
|
+
@daily_spending = 0.0
|
|
159
|
+
@daily_tokens = 0
|
|
160
|
+
@day_start = now
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Reset hourly counters
|
|
164
|
+
return unless (now - @hour_start) >= 3600 # 1 hour
|
|
165
|
+
|
|
166
|
+
logger.debug('Resetting hourly budget counters',
|
|
167
|
+
previous_spending: @hourly_spending.round(4))
|
|
168
|
+
@hourly_spending = 0.0
|
|
169
|
+
@hourly_tokens = 0
|
|
170
|
+
@hour_start = now
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../logger'
|
|
4
|
+
require_relative '../../loggable'
|
|
5
|
+
|
|
6
|
+
module LanguageOperator
|
|
7
|
+
module Agent
|
|
8
|
+
module Safety
|
|
9
|
+
# Content Filter for pre-prompt and post-response filtering
|
|
10
|
+
#
|
|
11
|
+
# Supports pattern matching for blocked keywords and topics.
|
|
12
|
+
# Can be extended to integrate with moderation APIs.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# filter = ContentFilter.new(blocked_patterns: ['password', 'secret'])
|
|
16
|
+
# filter.check_content!("Here is my password: 12345", direction: :input)
|
|
17
|
+
class ContentFilter
|
|
18
|
+
include LanguageOperator::Loggable
|
|
19
|
+
|
|
20
|
+
class ContentBlockedError < StandardError; end
|
|
21
|
+
|
|
22
|
+
def initialize(blocked_patterns: [], blocked_topics: [], case_sensitive: false)
|
|
23
|
+
@blocked_patterns = blocked_patterns || []
|
|
24
|
+
@blocked_topics = blocked_topics || []
|
|
25
|
+
@case_sensitive = case_sensitive
|
|
26
|
+
|
|
27
|
+
logger.info('Content filter initialized',
|
|
28
|
+
patterns: @blocked_patterns.length,
|
|
29
|
+
topics: @blocked_topics.length,
|
|
30
|
+
case_sensitive: @case_sensitive)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check content for blocked patterns
|
|
34
|
+
#
|
|
35
|
+
# @param content [String] Content to check
|
|
36
|
+
# @param direction [Symbol] :input or :output
|
|
37
|
+
# @raise [ContentBlockedError] If content contains blocked patterns
|
|
38
|
+
def check_content!(content, direction: :input)
|
|
39
|
+
return if @blocked_patterns.empty? && @blocked_topics.empty?
|
|
40
|
+
|
|
41
|
+
content_to_check = @case_sensitive ? content : content.downcase
|
|
42
|
+
|
|
43
|
+
# Check blocked patterns
|
|
44
|
+
@blocked_patterns.each do |pattern|
|
|
45
|
+
pattern_to_match = @case_sensitive ? pattern : pattern.downcase
|
|
46
|
+
next unless content_to_check.include?(pattern_to_match)
|
|
47
|
+
|
|
48
|
+
logger.warn('Blocked pattern detected',
|
|
49
|
+
direction: direction,
|
|
50
|
+
pattern: pattern,
|
|
51
|
+
content_preview: content[0..100])
|
|
52
|
+
raise ContentBlockedError,
|
|
53
|
+
"Content blocked: contains forbidden pattern '#{pattern}' in #{direction}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check blocked topics (using regex patterns)
|
|
57
|
+
@blocked_topics.each do |topic|
|
|
58
|
+
pattern = @case_sensitive ? Regexp.new(topic) : Regexp.new(topic, Regexp::IGNORECASE)
|
|
59
|
+
next unless content_to_check.match?(pattern)
|
|
60
|
+
|
|
61
|
+
logger.warn('Blocked topic detected',
|
|
62
|
+
direction: direction,
|
|
63
|
+
topic: topic,
|
|
64
|
+
content_preview: content[0..100])
|
|
65
|
+
raise ContentBlockedError,
|
|
66
|
+
"Content blocked: matches forbidden topic '#{topic}' in #{direction}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
logger.debug('Content check passed',
|
|
70
|
+
direction: direction,
|
|
71
|
+
content_length: content.length)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if content would be blocked (without raising)
|
|
75
|
+
#
|
|
76
|
+
# @param content [String] Content to check
|
|
77
|
+
# @return [Boolean] True if content would be blocked
|
|
78
|
+
def blocked?(content)
|
|
79
|
+
check_content!(content)
|
|
80
|
+
false
|
|
81
|
+
rescue ContentBlockedError
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def logger_component
|
|
88
|
+
'Safety::ContentFilter'
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|