claude_agent 0.7.15 → 0.7.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/rules/conventions.md +66 -16
- data/CHANGELOG.md +2 -0
- data/CLAUDE.md +24 -4
- data/README.md +52 -1529
- data/docs/architecture.md +339 -0
- data/docs/client.md +526 -0
- data/docs/configuration.md +571 -0
- data/docs/conversations.md +461 -0
- data/docs/errors.md +127 -0
- data/docs/events.md +225 -0
- data/docs/getting-started.md +310 -0
- data/docs/hooks.md +380 -0
- data/docs/logging.md +96 -0
- data/docs/mcp.md +308 -0
- data/docs/messages.md +871 -0
- data/docs/permissions.md +611 -0
- data/docs/queries.md +227 -0
- data/docs/sessions.md +335 -0
- data/lib/claude_agent/configuration.rb +129 -0
- data/lib/claude_agent/conversation.rb +28 -3
- data/lib/claude_agent/errors.rb +3 -0
- data/lib/claude_agent/event_handler.rb +14 -0
- data/lib/claude_agent/hook_registry.rb +110 -0
- data/lib/claude_agent/mcp/server.rb +22 -0
- data/lib/claude_agent/mcp/tool.rb +24 -3
- data/lib/claude_agent/message.rb +93 -0
- data/lib/claude_agent/options.rb +10 -0
- data/lib/claude_agent/permission_policy.rb +174 -0
- data/lib/claude_agent/session.rb +100 -11
- data/lib/claude_agent/session_paths.rb +5 -2
- data/lib/claude_agent/version.rb +1 -1
- data/lib/claude_agent.rb +175 -0
- metadata +19 -1
|
@@ -226,13 +226,38 @@ module ClaudeAgent
|
|
|
226
226
|
[ callbacks, conversation_kwargs, options_kwargs ]
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
+
# Symbol → CLI permission mode mapping
|
|
230
|
+
SYMBOL_PERMISSION_MODES = {
|
|
231
|
+
default: "default",
|
|
232
|
+
accept_edits: "acceptEdits",
|
|
233
|
+
plan: "plan",
|
|
234
|
+
bypass_permissions: "bypassPermissions",
|
|
235
|
+
dont_ask: "dontAsk"
|
|
236
|
+
}.freeze
|
|
237
|
+
|
|
229
238
|
def build_options(options_kwargs, conversation_kwargs)
|
|
230
239
|
permission = conversation_kwargs[:on_permission]
|
|
231
240
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
241
|
+
case permission
|
|
242
|
+
when PermissionPolicy
|
|
243
|
+
options_kwargs[:can_use_tool] = permission.to_can_use_tool
|
|
244
|
+
when Symbol
|
|
245
|
+
if (mode = SYMBOL_PERMISSION_MODES[permission])
|
|
246
|
+
options_kwargs[:permission_mode] = mode
|
|
247
|
+
elsif permission == :queue
|
|
248
|
+
options_kwargs[:permission_queue] = true unless options_kwargs.key?(:can_use_tool)
|
|
249
|
+
end
|
|
250
|
+
when nil
|
|
235
251
|
options_kwargs[:permission_queue] = true unless options_kwargs.key?(:can_use_tool)
|
|
252
|
+
else
|
|
253
|
+
if permission.respond_to?(:call)
|
|
254
|
+
options_kwargs[:can_use_tool] = permission
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Handle HookRegistry in hooks option
|
|
259
|
+
if options_kwargs[:hooks].is_a?(HookRegistry)
|
|
260
|
+
options_kwargs[:hooks] = options_kwargs[:hooks].to_hooks_hash
|
|
236
261
|
end
|
|
237
262
|
|
|
238
263
|
Options.new(**options_kwargs)
|
data/lib/claude_agent/errors.rb
CHANGED
|
@@ -87,6 +87,9 @@ module ClaudeAgent
|
|
|
87
87
|
# Raised when an invalid option is provided
|
|
88
88
|
class ConfigurationError < Error; end
|
|
89
89
|
|
|
90
|
+
# Raised when a resource is not found (Stripe convention)
|
|
91
|
+
class NotFoundError < Error; end
|
|
92
|
+
|
|
90
93
|
# Raised when an operation is aborted/cancelled (TypeScript SDK parity)
|
|
91
94
|
#
|
|
92
95
|
# This error is raised when an operation is explicitly cancelled,
|
|
@@ -54,6 +54,20 @@ module ClaudeAgent
|
|
|
54
54
|
# All known events
|
|
55
55
|
EVENTS = (META_EVENTS + TYPE_EVENTS + DECOMPOSED_EVENTS).freeze
|
|
56
56
|
|
|
57
|
+
# Create an EventHandler with block-based DSL.
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# handler = ClaudeAgent::EventHandler.define do
|
|
61
|
+
# on_text { |t| print t }
|
|
62
|
+
# on_result { |r| puts "\nCost: $#{r.total_cost_usd}" }
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# @yield [self] Block evaluated in the context of the new handler
|
|
66
|
+
# @return [EventHandler]
|
|
67
|
+
def self.define(&block)
|
|
68
|
+
new.tap { |h| h.instance_eval(&block) }
|
|
69
|
+
end
|
|
70
|
+
|
|
57
71
|
def initialize
|
|
58
72
|
@handlers = Hash.new { |h, k| h[k] = [] }
|
|
59
73
|
@pending_tool_uses = {}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
# Declarative hook registry with Ruby-friendly method names.
|
|
5
|
+
#
|
|
6
|
+
# Maps idiomatic Ruby method names to CLI hook event names and
|
|
7
|
+
# compiles to the Hash format consumed by Options#hooks.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# hooks = ClaudeAgent::HookRegistry.new do |h|
|
|
11
|
+
# h.before_tool_use(/Bash/) { |input, ctx| { continue_: true } }
|
|
12
|
+
# h.after_tool_use("Read") { |input, ctx| { continue_: true } }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# @example Module-level convenience
|
|
16
|
+
# ClaudeAgent.hooks do |h|
|
|
17
|
+
# h.on_session_start { |input, ctx| { continue_: true } }
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
class HookRegistry
|
|
21
|
+
# Ruby method name → CLI event name mapping
|
|
22
|
+
EVENT_MAP = {
|
|
23
|
+
before_tool_use: "PreToolUse",
|
|
24
|
+
after_tool_use: "PostToolUse",
|
|
25
|
+
after_tool_use_failure: "PostToolUseFailure",
|
|
26
|
+
on_notification: "Notification",
|
|
27
|
+
on_user_prompt_submit: "UserPromptSubmit",
|
|
28
|
+
on_session_start: "SessionStart",
|
|
29
|
+
on_session_end: "SessionEnd",
|
|
30
|
+
on_stop: "Stop",
|
|
31
|
+
on_subagent_start: "SubagentStart",
|
|
32
|
+
on_subagent_stop: "SubagentStop",
|
|
33
|
+
before_compact: "PreCompact",
|
|
34
|
+
after_compact: "PostCompact",
|
|
35
|
+
on_permission_request: "PermissionRequest",
|
|
36
|
+
on_setup: "Setup",
|
|
37
|
+
on_teammate_idle: "TeammateIdle",
|
|
38
|
+
on_task_completed: "TaskCompleted",
|
|
39
|
+
on_elicitation: "Elicitation",
|
|
40
|
+
on_elicitation_result: "ElicitationResult",
|
|
41
|
+
on_config_change: "ConfigChange",
|
|
42
|
+
on_worktree_create: "WorktreeCreate",
|
|
43
|
+
on_worktree_remove: "WorktreeRemove",
|
|
44
|
+
on_instructions_loaded: "InstructionsLoaded"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# @param block [Proc] DSL block yielding self
|
|
48
|
+
def initialize(&block)
|
|
49
|
+
@matchers = Hash.new { |h, k| h[k] = [] }
|
|
50
|
+
yield self if block_given?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Define DSL methods for each event
|
|
54
|
+
EVENT_MAP.each do |method_name, cli_event|
|
|
55
|
+
define_method(method_name) do |matcher = nil, timeout: nil, &callback|
|
|
56
|
+
@matchers[cli_event] << HookMatcher.new(
|
|
57
|
+
matcher: normalize_matcher(matcher),
|
|
58
|
+
callbacks: [ callback ],
|
|
59
|
+
timeout: timeout
|
|
60
|
+
)
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Compile to the hooks Hash format consumed by Options.
|
|
66
|
+
#
|
|
67
|
+
# @return [Hash{String => Array<HookMatcher>}]
|
|
68
|
+
def to_hooks_hash
|
|
69
|
+
@matchers.transform_values(&:dup)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Merge another HookRegistry additively (concatenates matchers per event).
|
|
73
|
+
#
|
|
74
|
+
# @param other [HookRegistry] Registry to merge
|
|
75
|
+
# @return [HookRegistry] New registry with combined matchers
|
|
76
|
+
def merge(other)
|
|
77
|
+
merged = self.class.new
|
|
78
|
+
@matchers.each { |event, matchers| matchers.each { |m| merged.instance_variable_get(:@matchers)[event] << m } }
|
|
79
|
+
other.instance_variable_get(:@matchers).each { |event, matchers| matchers.each { |m| merged.instance_variable_get(:@matchers)[event] << m } }
|
|
80
|
+
merged
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Whether any hooks have been registered.
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def empty?
|
|
86
|
+
@matchers.empty?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Number of total matchers across all events.
|
|
90
|
+
# @return [Integer]
|
|
91
|
+
def size
|
|
92
|
+
@matchers.values.sum(&:size)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def normalize_matcher(matcher)
|
|
98
|
+
case matcher
|
|
99
|
+
when Regexp
|
|
100
|
+
matcher.source
|
|
101
|
+
when String
|
|
102
|
+
matcher
|
|
103
|
+
when nil
|
|
104
|
+
nil
|
|
105
|
+
else
|
|
106
|
+
matcher.to_s
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -35,11 +35,33 @@ module ClaudeAgent
|
|
|
35
35
|
# @param name [String] Server name
|
|
36
36
|
# @param tools [Array<Tool>] Tools to expose
|
|
37
37
|
# @param logger [Logger, nil] Optional logger instance
|
|
38
|
+
# @yield [self] Optional block for DSL-style tool definition
|
|
39
|
+
#
|
|
40
|
+
# @example Block DSL
|
|
41
|
+
# server = ClaudeAgent::MCP::Server.new(name: "calc") do |s|
|
|
42
|
+
# s.tool("add", "Add numbers", { a: :number, b: :number }) { |args| args[:a] + args[:b] }
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
38
45
|
def initialize(name:, tools: [], logger: nil)
|
|
39
46
|
@name = name.to_s
|
|
40
47
|
@tools = {}
|
|
41
48
|
@logger = logger
|
|
42
49
|
tools.each { |tool| add_tool(tool) }
|
|
50
|
+
yield self if block_given?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Define and add a tool in one step (DSL convenience)
|
|
54
|
+
#
|
|
55
|
+
# @param name [String] Tool name
|
|
56
|
+
# @param description [String] Tool description
|
|
57
|
+
# @param schema [Hash] Input schema
|
|
58
|
+
# @param annotations [Hash, nil] MCP tool annotations
|
|
59
|
+
# @yield [Hash] Tool arguments
|
|
60
|
+
# @return [Tool] The created tool
|
|
61
|
+
def tool(name, description, schema = {}, annotations: nil, &handler)
|
|
62
|
+
new_tool = Tool.new(name: name, description: description, schema: schema, annotations: annotations, &handler)
|
|
63
|
+
add_tool(new_tool)
|
|
64
|
+
new_tool
|
|
43
65
|
end
|
|
44
66
|
|
|
45
67
|
# Add a tool to the server
|
|
@@ -73,12 +73,12 @@ module ClaudeAgent
|
|
|
73
73
|
|
|
74
74
|
private
|
|
75
75
|
|
|
76
|
-
# Normalize schema from simple Ruby types to JSON Schema
|
|
76
|
+
# Normalize schema from simple Ruby types or symbol shortcuts to JSON Schema
|
|
77
77
|
def normalize_schema(schema)
|
|
78
78
|
return schema if json_schema?(schema)
|
|
79
79
|
|
|
80
|
-
# Convert simple {name: Type} format to JSON Schema
|
|
81
|
-
if schema.is_a?(Hash) && schema.values.all? { |v| v.is_a?(Class) || v.is_a?(Module) }
|
|
80
|
+
# Convert simple {name: Type} or {name: :type_symbol} format to JSON Schema
|
|
81
|
+
if schema.is_a?(Hash) && schema.values.all? { |v| v.is_a?(Class) || v.is_a?(Module) || v.is_a?(Symbol) }
|
|
82
82
|
{
|
|
83
83
|
type: "object",
|
|
84
84
|
properties: schema.transform_values { |type| type_to_schema(type) },
|
|
@@ -96,7 +96,28 @@ module ClaudeAgent
|
|
|
96
96
|
schema.key?(:properties) || schema.key?("properties")
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# Symbol type shortcuts for schema definitions
|
|
100
|
+
SYMBOL_TYPE_MAP = {
|
|
101
|
+
string: "string",
|
|
102
|
+
str: "string",
|
|
103
|
+
integer: "integer",
|
|
104
|
+
int: "integer",
|
|
105
|
+
number: "number",
|
|
106
|
+
float: "number",
|
|
107
|
+
numeric: "number",
|
|
108
|
+
boolean: "boolean",
|
|
109
|
+
bool: "boolean",
|
|
110
|
+
array: "array",
|
|
111
|
+
object: "object",
|
|
112
|
+
hash: "object"
|
|
113
|
+
}.freeze
|
|
114
|
+
|
|
99
115
|
def type_to_schema(type)
|
|
116
|
+
if type.is_a?(Symbol)
|
|
117
|
+
json_type = SYMBOL_TYPE_MAP[type] || "string"
|
|
118
|
+
return { type: json_type }
|
|
119
|
+
end
|
|
120
|
+
|
|
100
121
|
case type.to_s
|
|
101
122
|
when "String"
|
|
102
123
|
{ type: "string" }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
# Shared interface for all message and content block types.
|
|
5
|
+
#
|
|
6
|
+
# Provides a consistent API across the 22+ message types and 8 content
|
|
7
|
+
# block types without interfering with `Data.define` inheritance.
|
|
8
|
+
#
|
|
9
|
+
# @example Universal text extraction
|
|
10
|
+
# message.text_content # works on AssistantMessage, UserMessage, TextBlock, etc.
|
|
11
|
+
#
|
|
12
|
+
# @example Duck-type checks
|
|
13
|
+
# message.session_message? # has uuid + session_id?
|
|
14
|
+
# message.identifiable? # has uuid?
|
|
15
|
+
#
|
|
16
|
+
# @example Pattern matching
|
|
17
|
+
# case message
|
|
18
|
+
# in { type: :assistant }
|
|
19
|
+
# puts message.text_content
|
|
20
|
+
# in { type: :result }
|
|
21
|
+
# puts message.cost
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
module Message
|
|
25
|
+
# Universal text extraction across all message and content block types.
|
|
26
|
+
#
|
|
27
|
+
# @return [String] Extracted text (empty string if no text available)
|
|
28
|
+
def text_content
|
|
29
|
+
case self
|
|
30
|
+
when AssistantMessage
|
|
31
|
+
text
|
|
32
|
+
when UserMessage, UserMessageReplay
|
|
33
|
+
content.is_a?(String) ? content : ""
|
|
34
|
+
when TextBlock
|
|
35
|
+
text
|
|
36
|
+
when ThinkingBlock
|
|
37
|
+
thinking
|
|
38
|
+
when StreamEvent
|
|
39
|
+
delta_text || ""
|
|
40
|
+
when ToolProgressMessage
|
|
41
|
+
""
|
|
42
|
+
when ResultMessage
|
|
43
|
+
""
|
|
44
|
+
when GenericMessage
|
|
45
|
+
raw.is_a?(Hash) ? (raw[:text] || raw["text"] || "").to_s : ""
|
|
46
|
+
else
|
|
47
|
+
respond_to?(:text) ? (text || "").to_s : ""
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether this message carries session identity (uuid + session_id).
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def session_message?
|
|
55
|
+
respond_to?(:uuid) && respond_to?(:session_id) &&
|
|
56
|
+
!uuid.nil? && !session_id.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether this message has a UUID.
|
|
60
|
+
#
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def identifiable?
|
|
63
|
+
respond_to?(:uuid) && !uuid.nil?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Override deconstruct_keys to include :type for pattern matching.
|
|
67
|
+
#
|
|
68
|
+
# Allows `case msg; in { type: :assistant }` to work naturally.
|
|
69
|
+
#
|
|
70
|
+
# Data#deconstruct_keys stops early if it encounters a non-member key,
|
|
71
|
+
# so we filter out :type (a virtual key) before delegating to super.
|
|
72
|
+
#
|
|
73
|
+
# @param keys [Array<Symbol>, nil]
|
|
74
|
+
# @return [Hash]
|
|
75
|
+
def deconstruct_keys(keys)
|
|
76
|
+
if keys.nil?
|
|
77
|
+
{ type: type }.merge(super)
|
|
78
|
+
elsif keys.include?(:type)
|
|
79
|
+
member_keys = keys - [ :type ]
|
|
80
|
+
base = member_keys.empty? ? {} : super(member_keys)
|
|
81
|
+
{ type: type }.merge(base)
|
|
82
|
+
else
|
|
83
|
+
super
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Prepend Message in all message types (prepend needed to override Data#deconstruct_keys)
|
|
89
|
+
MESSAGE_TYPES.each { |klass| klass.prepend(Message) }
|
|
90
|
+
|
|
91
|
+
# Prepend Message in all content block types
|
|
92
|
+
CONTENT_BLOCK_TYPES.each { |klass| klass.prepend(Message) }
|
|
93
|
+
end
|
data/lib/claude_agent/options.rb
CHANGED
|
@@ -122,10 +122,20 @@ module ClaudeAgent
|
|
|
122
122
|
"Must set allow_dangerously_skip_permissions: true to use bypassPermissions mode"
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
# Auto-compile PermissionPolicy to can_use_tool lambda
|
|
126
|
+
if can_use_tool.is_a?(PermissionPolicy)
|
|
127
|
+
@can_use_tool = can_use_tool.to_can_use_tool
|
|
128
|
+
end
|
|
129
|
+
|
|
125
130
|
if can_use_tool && !can_use_tool.respond_to?(:call)
|
|
126
131
|
raise ConfigurationError, "can_use_tool must be callable (Proc, Lambda, or object responding to #call)"
|
|
127
132
|
end
|
|
128
133
|
|
|
134
|
+
# Auto-compile HookRegistry to hooks hash
|
|
135
|
+
if hooks.is_a?(HookRegistry)
|
|
136
|
+
@hooks = hooks.to_hooks_hash
|
|
137
|
+
end
|
|
138
|
+
|
|
129
139
|
if on_elicitation && !on_elicitation.respond_to?(:call)
|
|
130
140
|
raise ConfigurationError, "on_elicitation must be callable (Proc, Lambda, or object responding to #call)"
|
|
131
141
|
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
# Declarative permission policy builder.
|
|
5
|
+
#
|
|
6
|
+
# Compiles allow/deny rules into a `can_use_tool` lambda for use
|
|
7
|
+
# with Options and Conversation. Rules are evaluated in order;
|
|
8
|
+
# first match wins.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# policy = ClaudeAgent::PermissionPolicy.new do |p|
|
|
12
|
+
# p.allow "Read", "Grep", "Glob"
|
|
13
|
+
# p.deny "Bash", message: "Bash not allowed"
|
|
14
|
+
# p.deny_all
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example With regex matching
|
|
18
|
+
# policy = ClaudeAgent::PermissionPolicy.new do |p|
|
|
19
|
+
# p.allow_matching(/^mcp__/)
|
|
20
|
+
# p.deny_all
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example With custom handler for unmatched tools
|
|
24
|
+
# policy = ClaudeAgent::PermissionPolicy.new do |p|
|
|
25
|
+
# p.allow "Read"
|
|
26
|
+
# p.ask { |name, input, ctx| PermissionResultAllow.new }
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @example Module-level convenience
|
|
30
|
+
# ClaudeAgent.permissions do |p|
|
|
31
|
+
# p.allow "Read", "Grep"
|
|
32
|
+
# p.deny "Bash"
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
class PermissionPolicy
|
|
36
|
+
# @param block [Proc] DSL block yielding self
|
|
37
|
+
def initialize(&block)
|
|
38
|
+
@rules = []
|
|
39
|
+
@fallback = nil
|
|
40
|
+
yield self if block_given?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Allow specific tools by exact name.
|
|
44
|
+
#
|
|
45
|
+
# @param tool_names [Array<String>] Tool names to allow
|
|
46
|
+
# @return [self]
|
|
47
|
+
def allow(*tool_names)
|
|
48
|
+
tool_names.flatten.each do |name|
|
|
49
|
+
@rules << { type: :exact, name: name.to_s, action: :allow }
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Deny specific tools by exact name.
|
|
55
|
+
#
|
|
56
|
+
# @param tool_names [Array<String>] Tool names to deny
|
|
57
|
+
# @param message [String] Denial message
|
|
58
|
+
# @param interrupt [Boolean] Whether to interrupt the conversation
|
|
59
|
+
# @return [self]
|
|
60
|
+
def deny(*tool_names, message: "", interrupt: false)
|
|
61
|
+
tool_names.flatten.each do |name|
|
|
62
|
+
@rules << { type: :exact, name: name.to_s, action: :deny, message: message, interrupt: interrupt }
|
|
63
|
+
end
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Allow tools matching a pattern.
|
|
68
|
+
#
|
|
69
|
+
# @param pattern [Regexp, String] Pattern to match against tool names
|
|
70
|
+
# @return [self]
|
|
71
|
+
def allow_matching(pattern)
|
|
72
|
+
@rules << { type: :pattern, pattern: to_regexp(pattern), action: :allow }
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Deny tools matching a pattern.
|
|
77
|
+
#
|
|
78
|
+
# @param pattern [Regexp, String] Pattern to match against tool names
|
|
79
|
+
# @param message [String] Denial message
|
|
80
|
+
# @param interrupt [Boolean] Whether to interrupt the conversation
|
|
81
|
+
# @return [self]
|
|
82
|
+
def deny_matching(pattern, message: "", interrupt: false)
|
|
83
|
+
@rules << { type: :pattern, pattern: to_regexp(pattern), action: :deny, message: message, interrupt: interrupt }
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Set a custom handler for tools that don't match any rule.
|
|
88
|
+
#
|
|
89
|
+
# @yield [name, input, context] Called for unmatched tools
|
|
90
|
+
# @return [self]
|
|
91
|
+
def ask(&handler)
|
|
92
|
+
@fallback = handler
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Set fallback to allow all unmatched tools.
|
|
97
|
+
# @return [self]
|
|
98
|
+
def allow_all
|
|
99
|
+
@fallback = :allow
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Set fallback to deny all unmatched tools.
|
|
104
|
+
#
|
|
105
|
+
# @param message [String] Denial message for unmatched tools
|
|
106
|
+
# @return [self]
|
|
107
|
+
def deny_all(message: "Denied by policy")
|
|
108
|
+
@fallback = { action: :deny, message: message }
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Compile this policy into a `can_use_tool` lambda.
|
|
113
|
+
#
|
|
114
|
+
# @return [Proc] Lambda compatible with Options#can_use_tool
|
|
115
|
+
def to_can_use_tool
|
|
116
|
+
rules = @rules.dup.freeze
|
|
117
|
+
fallback = @fallback
|
|
118
|
+
|
|
119
|
+
->(tool_name, tool_input, context) {
|
|
120
|
+
# Check rules in order, first match wins
|
|
121
|
+
rules.each do |rule|
|
|
122
|
+
next unless matches?(rule, tool_name)
|
|
123
|
+
|
|
124
|
+
case rule[:action]
|
|
125
|
+
when :allow
|
|
126
|
+
return PermissionResultAllow.new
|
|
127
|
+
when :deny
|
|
128
|
+
return PermissionResultDeny.new(message: rule[:message], interrupt: rule[:interrupt])
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# No rule matched, use fallback
|
|
133
|
+
case fallback
|
|
134
|
+
when :allow
|
|
135
|
+
PermissionResultAllow.new
|
|
136
|
+
when Hash
|
|
137
|
+
PermissionResultDeny.new(message: fallback[:message])
|
|
138
|
+
when Proc
|
|
139
|
+
fallback.call(tool_name, tool_input, context)
|
|
140
|
+
else
|
|
141
|
+
# Default: allow (no policy restriction)
|
|
142
|
+
PermissionResultAllow.new
|
|
143
|
+
end
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Whether any rules have been defined.
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def empty?
|
|
150
|
+
@rules.empty? && @fallback.nil?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def matches?(rule, tool_name)
|
|
156
|
+
case rule[:type]
|
|
157
|
+
when :exact
|
|
158
|
+
rule[:name] == tool_name.to_s
|
|
159
|
+
when :pattern
|
|
160
|
+
rule[:pattern].match?(tool_name.to_s)
|
|
161
|
+
else
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def to_regexp(pattern)
|
|
167
|
+
case pattern
|
|
168
|
+
when Regexp then pattern
|
|
169
|
+
when String then Regexp.new(pattern)
|
|
170
|
+
else raise ArgumentError, "Pattern must be a Regexp or String, got #{pattern.class}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|