swarm_sdk 2.0.0.pre.2

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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/lib/swarm_sdk/agent/builder.rb +333 -0
  3. data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
  4. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  5. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
  6. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
  7. data/lib/swarm_sdk/agent/chat.rb +779 -0
  8. data/lib/swarm_sdk/agent/context.rb +108 -0
  9. data/lib/swarm_sdk/agent/definition.rb +335 -0
  10. data/lib/swarm_sdk/configuration.rb +251 -0
  11. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  12. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  13. data/lib/swarm_sdk/context_compactor.rb +340 -0
  14. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  15. data/lib/swarm_sdk/hooks/context.rb +163 -0
  16. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  17. data/lib/swarm_sdk/hooks/error.rb +29 -0
  18. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  19. data/lib/swarm_sdk/hooks/registry.rb +143 -0
  20. data/lib/swarm_sdk/hooks/result.rb +150 -0
  21. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  22. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  23. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  24. data/lib/swarm_sdk/log_collector.rb +83 -0
  25. data/lib/swarm_sdk/log_stream.rb +69 -0
  26. data/lib/swarm_sdk/markdown_parser.rb +46 -0
  27. data/lib/swarm_sdk/permissions/config.rb +239 -0
  28. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  29. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  30. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  31. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  32. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
  33. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  34. data/lib/swarm_sdk/result.rb +97 -0
  35. data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
  36. data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
  37. data/lib/swarm_sdk/swarm/builder.rb +240 -0
  38. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  39. data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
  40. data/lib/swarm_sdk/swarm.rb +837 -0
  41. data/lib/swarm_sdk/tools/bash.rb +274 -0
  42. data/lib/swarm_sdk/tools/delegate.rb +152 -0
  43. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  44. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  45. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  46. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  47. data/lib/swarm_sdk/tools/edit.rb +150 -0
  48. data/lib/swarm_sdk/tools/glob.rb +158 -0
  49. data/lib/swarm_sdk/tools/grep.rb +231 -0
  50. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  51. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  52. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  53. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  54. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  55. data/lib/swarm_sdk/tools/read.rb +251 -0
  56. data/lib/swarm_sdk/tools/registry.rb +73 -0
  57. data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
  58. data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
  59. data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
  60. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  61. data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
  62. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  63. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  64. data/lib/swarm_sdk/tools/write.rb +117 -0
  65. data/lib/swarm_sdk/utils.rb +50 -0
  66. data/lib/swarm_sdk/version.rb +5 -0
  67. data/lib/swarm_sdk.rb +69 -0
  68. metadata +169 -0
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Hooks
5
+ # Represents a tool call in the hooks system
6
+ #
7
+ # This is a simple value object that wraps tool call information
8
+ # from RubyLLM in a consistent, immutable format for hooks.
9
+ #
10
+ # @example Access tool call information in a hook
11
+ # swarm.add_callback(:pre_tool_use) do |context|
12
+ # puts "Tool: #{context.tool_call.name}"
13
+ # puts "Parameters: #{context.tool_call.parameters.inspect}"
14
+ # end
15
+ class ToolCall
16
+ attr_reader :id, :name, :parameters
17
+
18
+ # @param id [String] Unique identifier for this tool call
19
+ # @param name [String] Name of the tool being called
20
+ # @param parameters [Hash] Parameters passed to the tool
21
+ def initialize(id:, name:, parameters:)
22
+ @id = id
23
+ @name = name
24
+ @parameters = parameters
25
+ end
26
+
27
+ # Convert to hash representation
28
+ #
29
+ # @return [Hash] Hash with id, name, and parameters
30
+ def to_h
31
+ { id: @id, name: @name, parameters: @parameters }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Hooks
5
+ # Represents the result of a tool execution
6
+ #
7
+ # This is a simple value object that wraps tool execution results
8
+ # in a consistent format for post-tool-use hooks.
9
+ #
10
+ # @example Access tool result in a hook
11
+ # swarm.add_callback(:post_tool_use) do |context|
12
+ # if context.tool_result.success?
13
+ # puts "Tool succeeded: #{context.tool_result.content}"
14
+ # else
15
+ # puts "Tool failed: #{context.tool_result.error}"
16
+ # end
17
+ # end
18
+ class ToolResult
19
+ attr_reader :tool_call_id, :tool_name, :content, :success, :error
20
+
21
+ # @param tool_call_id [String] ID of the tool call this result corresponds to
22
+ # @param tool_name [String] Name of the tool that was executed
23
+ # @param content [String, nil] Result content (if successful)
24
+ # @param success [Boolean] Whether the tool execution succeeded
25
+ # @param error [String, nil] Error message (if failed)
26
+ def initialize(tool_call_id:, tool_name:, content: nil, success: true, error: nil)
27
+ @tool_call_id = tool_call_id
28
+ @tool_name = tool_name
29
+ @content = content
30
+ @success = success
31
+ @error = error
32
+ end
33
+
34
+ # Check if the tool execution succeeded
35
+ #
36
+ # @return [Boolean] true if successful
37
+ def success?
38
+ @success
39
+ end
40
+
41
+ # Check if the tool execution failed
42
+ #
43
+ # @return [Boolean] true if failed
44
+ def failure?
45
+ !@success
46
+ end
47
+
48
+ # Convert to hash representation
49
+ #
50
+ # @return [Hash] Hash with all result attributes
51
+ def to_h
52
+ {
53
+ tool_call_id: @tool_call_id,
54
+ tool_name: @tool_name,
55
+ content: @content,
56
+ success: @success,
57
+ error: @error,
58
+ }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # LogCollector manages subscriber callbacks for log events.
5
+ #
6
+ # This module acts as an emitter implementation that forwards events
7
+ # to user-registered callbacks. It's designed to be set as the LogStream
8
+ # emitter during swarm execution.
9
+ #
10
+ # ## Usage
11
+ #
12
+ # # Register a callback (before execution starts)
13
+ # LogCollector.on_log do |event|
14
+ # puts JSON.generate(event)
15
+ # end
16
+ #
17
+ # # Freeze callbacks (after all registrations, before Async execution)
18
+ # LogCollector.freeze!
19
+ #
20
+ # # During execution, LogStream calls emit
21
+ # LogCollector.emit(type: "user_prompt", agent: :backend)
22
+ #
23
+ # ## Fiber Safety
24
+ #
25
+ # LogCollector is fiber-safe because:
26
+ # - All callbacks registered before Async execution starts
27
+ # - freeze! makes @callbacks immutable
28
+ # - emit() only reads the frozen array (no mutations)
29
+ #
30
+ module LogCollector
31
+ class << self
32
+ # Register a callback to receive log events
33
+ #
34
+ # Must be called before freeze! is called.
35
+ #
36
+ # @yield [Hash] Log event entry
37
+ # @raise [StateError] If called after freeze!
38
+ def on_log(&block)
39
+ raise StateError, "Cannot register callbacks after LogCollector is frozen" if @frozen
40
+
41
+ @callbacks ||= []
42
+ @callbacks << block
43
+ end
44
+
45
+ # Emit an event to all registered callbacks
46
+ #
47
+ # @param entry [Hash] Log event entry
48
+ # @return [void]
49
+ def emit(entry)
50
+ # Use defensive copy for fiber safety
51
+ Array(@callbacks).each do |callback|
52
+ callback.call(entry)
53
+ end
54
+ end
55
+
56
+ # Freeze the callbacks array (call before Async execution)
57
+ #
58
+ # This prevents new callbacks from being registered and makes
59
+ # the array immutable for fiber safety.
60
+ #
61
+ # @return [void]
62
+ def freeze!
63
+ @callbacks&.freeze
64
+ @frozen = true
65
+ end
66
+
67
+ # Reset the collector (for test cleanup)
68
+ #
69
+ # @return [void]
70
+ def reset!
71
+ @callbacks = []
72
+ @frozen = false
73
+ end
74
+
75
+ # Check if collector is frozen
76
+ #
77
+ # @return [Boolean]
78
+ def frozen?
79
+ @frozen
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # LogStream provides a module-level singleton for emitting log events.
5
+ #
6
+ # This allows any component (tools, providers, agents) to emit structured
7
+ # log events without needing references to logger instances.
8
+ #
9
+ # ## Usage
10
+ #
11
+ # # Emit an event from anywhere in the SDK
12
+ # LogStream.emit(
13
+ # type: "user_prompt",
14
+ # agent: :backend,
15
+ # model: "claude-sonnet-4",
16
+ # message_count: 5
17
+ # )
18
+ #
19
+ # ## Fiber Safety
20
+ #
21
+ # LogStream is fiber-safe when following this pattern:
22
+ # 1. Set emitter BEFORE starting Async execution
23
+ # 2. During Async execution, only emit() (reads emitter)
24
+ # 3. Each event includes agent context for identification
25
+ #
26
+ # ## Testing
27
+ #
28
+ # # Inject a test emitter
29
+ # LogStream.emitter = TestEmitter.new
30
+ # # ... run tests ...
31
+ # LogStream.reset!
32
+ #
33
+ module LogStream
34
+ class << self
35
+ # Emit a log event
36
+ #
37
+ # Adds timestamp and forwards to the registered emitter.
38
+ #
39
+ # @param data [Hash] Event data (type, agent, and event-specific fields)
40
+ # @return [void]
41
+ def emit(**data)
42
+ return unless @emitter
43
+
44
+ entry = data.merge(timestamp: Time.now.utc.iso8601).compact
45
+
46
+ @emitter.emit(entry)
47
+ end
48
+
49
+ # Set the emitter (for dependency injection in tests)
50
+ #
51
+ # @param emitter [#emit] Object responding to emit(Hash)
52
+ attr_accessor :emitter
53
+
54
+ # Reset the emitter (for test cleanup)
55
+ #
56
+ # @return [void]
57
+ def reset!
58
+ @emitter = nil
59
+ end
60
+
61
+ # Check if logging is enabled
62
+ #
63
+ # @return [Boolean] true if an emitter is configured
64
+ def enabled?
65
+ !@emitter.nil?
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ class MarkdownParser
5
+ FRONTMATTER_PATTERN = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
6
+
7
+ class << self
8
+ def parse(content, agent_name = nil)
9
+ new(content, agent_name).parse
10
+ end
11
+ end
12
+
13
+ def initialize(content, agent_name = nil)
14
+ @content = content
15
+ @agent_name = agent_name
16
+ end
17
+
18
+ def parse
19
+ if @content =~ FRONTMATTER_PATTERN
20
+ frontmatter_yaml = Regexp.last_match(1)
21
+ prompt_content = Regexp.last_match(2).strip
22
+
23
+ frontmatter = YAML.safe_load(frontmatter_yaml, permitted_classes: [Symbol], aliases: true)
24
+
25
+ unless frontmatter.is_a?(Hash)
26
+ raise ConfigurationError, "Invalid frontmatter format in agent definition"
27
+ end
28
+
29
+ # Symbolize keys for AgentDefinition
30
+ config = Utils.symbolize_keys(frontmatter).merge(system_prompt: prompt_content)
31
+
32
+ name = @agent_name || frontmatter["name"]
33
+ unless name
34
+ raise ConfigurationError, "Agent definition must include 'name' in frontmatter or be specified externally"
35
+ end
36
+
37
+ # Convert name to symbol
38
+ name = name.to_sym
39
+
40
+ Agent::Definition.new(name, config)
41
+ else
42
+ raise ConfigurationError, "Invalid Markdown agent definition format. Expected YAML frontmatter followed by prompt content."
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Permissions
5
+ # Config parses and validates permission configuration for tools
6
+ #
7
+ # Handles:
8
+ # - Allowed path patterns (allowlist)
9
+ # - Denied path patterns (explicit denylist)
10
+ # - Allowed command patterns (regex for Bash tool)
11
+ # - Denied command patterns (regex for Bash tool)
12
+ # - Relative paths converted to absolute based on agent directory
13
+ # - Glob pattern matching with absolute paths
14
+ #
15
+ # All paths and patterns are converted to absolute:
16
+ # - Patterns starting with / are kept as-is
17
+ # - Relative patterns are expanded against the agent's base directory
18
+ # - Paths starting with / are kept as-is
19
+ # - Relative paths are expanded against the agent's base directory
20
+ #
21
+ # Example:
22
+ # config = Config.new(
23
+ # {
24
+ # allowed_paths: ["tmp/**/*"],
25
+ # denied_paths: ["tmp/secrets/**"],
26
+ # allowed_commands: ["^git (status|diff|log)$"],
27
+ # denied_commands: ["^rm -rf"]
28
+ # },
29
+ # base_directories: ["/home/user/project"]
30
+ # )
31
+ # config.allowed?("tmp/file.txt") # => true (checks /home/user/project/tmp/file.txt)
32
+ # config.allowed?("tmp/secrets/key.pem") # => false (denied takes precedence)
33
+ # config.command_allowed?("git status") # => true
34
+ # config.command_allowed?("rm -rf /") # => false (denied takes precedence)
35
+ class Config
36
+ attr_reader :allowed_patterns, :denied_patterns, :allowed_commands, :denied_commands
37
+
38
+ # Initialize permission configuration
39
+ #
40
+ # @param config_hash [Hash] Permission configuration with :allowed_paths, :denied_paths, :allowed_commands, :denied_commands
41
+ # @param base_directory [String] Base directory for the agent
42
+ def initialize(config_hash, base_directory:)
43
+ # Use agent's directory as the base for path resolution
44
+ @base_directory = File.expand_path(base_directory)
45
+
46
+ # Expand all patterns to absolute paths
47
+ @allowed_patterns = expand_patterns(config_hash[:allowed_paths] || [])
48
+ @denied_patterns = expand_patterns(config_hash[:denied_paths] || [])
49
+
50
+ # Parse command patterns (regex strings)
51
+ @allowed_commands = compile_regex_patterns(config_hash[:allowed_commands] || [])
52
+ @denied_commands = compile_regex_patterns(config_hash[:denied_commands] || [])
53
+ end
54
+
55
+ # Check if a path is allowed according to this configuration
56
+ #
57
+ # Rules:
58
+ # 1. Denied patterns take precedence and always block
59
+ # 2. If allowed_paths specified: must match at least one pattern (allowlist)
60
+ # 3. If allowed_paths NOT specified: allow everything (except denied)
61
+ # 4. All paths are converted to absolute for consistent matching
62
+ # 5. For directories used as search bases (Glob/Grep), allow if any pattern would match inside
63
+ #
64
+ # @param path [String] Path to check (relative or absolute)
65
+ # @param directory_search [Boolean] True if this is a directory search base (Glob/Grep)
66
+ # @return [Boolean] True if path is allowed
67
+ def allowed?(path, directory_search: false)
68
+ # Convert path to absolute
69
+ absolute_path = to_absolute_path(path)
70
+
71
+ # Denied patterns take precedence - check first
72
+ return false if matches_any?(@denied_patterns, absolute_path)
73
+
74
+ # If no allowed patterns, allow everything (except denied)
75
+ return true if @allowed_patterns.empty?
76
+
77
+ # For directory searches, check if directory is a prefix of any allowed pattern
78
+ # Don't check if directory exists - allow non-existent directories as search bases
79
+ if directory_search
80
+ return true if allowed_as_search_base?(absolute_path)
81
+ end
82
+
83
+ # Must match at least one allowed pattern
84
+ matches_any?(@allowed_patterns, absolute_path)
85
+ end
86
+
87
+ # Find the specific pattern that denies or doesn't allow a path
88
+ #
89
+ # @param path [String] Path to check (relative or absolute)
90
+ # @param directory_search [Boolean] True if this is a directory search base (Glob/Grep)
91
+ # @return [String, nil] The pattern that blocks this path, or nil if allowed
92
+ def find_blocking_pattern(path, directory_search: false)
93
+ absolute_path = to_absolute_path(path)
94
+
95
+ # Check denied patterns first
96
+ denied_match = @denied_patterns.find { |pattern| PathMatcher.matches?(pattern, absolute_path) }
97
+ return denied_match if denied_match
98
+
99
+ # Check allowed patterns
100
+ if @allowed_patterns.any?
101
+ # For directory searches, check if allowed as search base
102
+ # Don't check if directory exists - allow non-existent directories as search bases
103
+ if directory_search
104
+ return if allowed_as_search_base?(absolute_path)
105
+ end
106
+
107
+ # Check if path matches any allowed pattern
108
+ return if @allowed_patterns.any? { |pattern| PathMatcher.matches?(pattern, absolute_path) }
109
+
110
+ # Path doesn't match any allowed pattern
111
+ return "(not in allowed list)"
112
+ end
113
+
114
+ nil
115
+ end
116
+
117
+ # Convert a path to absolute form
118
+ #
119
+ # @param path [String] Path to convert
120
+ # @return [String] Absolute path
121
+ def to_absolute(path)
122
+ to_absolute_path(path)
123
+ end
124
+
125
+ # Check if a command is allowed according to this configuration
126
+ #
127
+ # Rules:
128
+ # 1. Denied command patterns take precedence and always block
129
+ # 2. If allowed_commands specified: must match at least one pattern (allowlist)
130
+ # 3. If allowed_commands NOT specified: allow everything (except denied)
131
+ #
132
+ # @param command [String] Command to check
133
+ # @return [Boolean] True if command is allowed
134
+ def command_allowed?(command)
135
+ # Denied patterns take precedence - check first
136
+ return false if matches_any_regex?(@denied_commands, command)
137
+
138
+ # If no allowed patterns, allow everything (except denied)
139
+ return true if @allowed_commands.empty?
140
+
141
+ # Must match at least one allowed pattern
142
+ matches_any_regex?(@allowed_commands, command)
143
+ end
144
+
145
+ # Find the specific pattern that denies or doesn't allow a command
146
+ #
147
+ # @param command [String] Command to check
148
+ # @return [String, nil] The pattern that blocks this command, or nil if allowed
149
+ def find_blocking_command_pattern(command)
150
+ # Check denied patterns first
151
+ denied_match = @denied_commands.find { |pattern| pattern.match?(command) }
152
+ return denied_match.source if denied_match
153
+
154
+ # Check allowed patterns
155
+ if @allowed_commands.any?
156
+ # Check if command matches any allowed pattern
157
+ return if @allowed_commands.any? { |pattern| pattern.match?(command) }
158
+
159
+ # Command doesn't match any allowed pattern
160
+ return "(not in allowed list)"
161
+ end
162
+
163
+ nil
164
+ end
165
+
166
+ private
167
+
168
+ # Expand patterns to absolute paths
169
+ #
170
+ # Patterns starting with / are kept as-is
171
+ # Relative patterns are joined with base directory
172
+ def expand_patterns(patterns)
173
+ Array(patterns).map do |pattern|
174
+ if pattern.to_s.start_with?("/")
175
+ pattern.to_s
176
+ else
177
+ File.join(@base_directory, pattern.to_s)
178
+ end
179
+ end
180
+ end
181
+
182
+ # Convert path to absolute
183
+ #
184
+ # Paths starting with / are kept as-is
185
+ # Relative paths are expanded against base directory
186
+ def to_absolute_path(path)
187
+ if path.start_with?("/")
188
+ path
189
+ else
190
+ File.expand_path(path, @base_directory)
191
+ end
192
+ end
193
+
194
+ # Check if path matches any pattern in the list
195
+ def matches_any?(patterns, path)
196
+ patterns.any? { |pattern| PathMatcher.matches?(pattern, path) }
197
+ end
198
+
199
+ # Check if a directory is allowed as a search base
200
+ #
201
+ # A directory is allowed as a search base if any allowed pattern
202
+ # would match files or directories inside it.
203
+ #
204
+ # @param directory_path [String] Absolute path to directory
205
+ # @return [Boolean] True if directory can be used as search base
206
+ def allowed_as_search_base?(directory_path)
207
+ # Normalize directory path (ensure trailing slash for comparison)
208
+ dir_with_slash = directory_path.end_with?("/") ? directory_path : "#{directory_path}/"
209
+
210
+ @allowed_patterns.any? do |pattern|
211
+ # Check if the pattern starts with this directory
212
+ # This means files inside this directory would match the pattern
213
+ pattern.start_with?(dir_with_slash) || pattern == directory_path
214
+ end
215
+ end
216
+
217
+ # Compile regex patterns from strings
218
+ #
219
+ # @param patterns [Array<String>] Array of regex pattern strings
220
+ # @return [Array<Regexp>] Array of compiled regex objects
221
+ def compile_regex_patterns(patterns)
222
+ Array(patterns).map do |pattern|
223
+ Regexp.new(pattern)
224
+ rescue RegexpError => e
225
+ raise ConfigurationError, "Invalid regex pattern '#{pattern}': #{e.message}"
226
+ end
227
+ end
228
+
229
+ # Check if command matches any regex pattern in the list
230
+ #
231
+ # @param patterns [Array<Regexp>] Array of compiled regex patterns
232
+ # @param command [String] Command to check
233
+ # @return [Boolean] True if command matches any pattern
234
+ def matches_any_regex?(patterns, command)
235
+ patterns.any? { |pattern| pattern.match?(command) }
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Permissions
5
+ # ErrorFormatter generates user-friendly error messages for permission violations
6
+ class ErrorFormatter
7
+ class << self
8
+ # Generate a permission denied error message
9
+ #
10
+ # @param path [String] The path that was denied
11
+ # @param allowed_patterns [Array<String>] List of allowed path patterns
12
+ # @param denied_patterns [Array<String>] List of denied path patterns
13
+ # @param matching_pattern [String, nil] The specific pattern that blocked this path
14
+ # @param tool_name [String] Name of the tool that was denied
15
+ # @return [String] Formatted error message with system reminder
16
+ def permission_denied(path:, allowed_patterns:, denied_patterns: [], matching_pattern: nil, tool_name:)
17
+ operation_verb = case tool_name.to_s
18
+ when "Read" then "read"
19
+ when "Write" then "write to"
20
+ when "Edit", "MultiEdit" then "edit"
21
+ when "Glob" then "access directory"
22
+ when "Grep" then "search in"
23
+ else "access"
24
+ end
25
+
26
+ # Build policy explanation
27
+ policy_info = if matching_pattern && matching_pattern != "(not in allowed list)"
28
+ # Show the specific denied pattern that blocked this path
29
+ "Blocked by policy: #{matching_pattern}"
30
+ elsif matching_pattern == "(not in allowed list)" && allowed_patterns.any?
31
+ # Show allowed patterns when path doesn't match any
32
+ patterns = allowed_patterns.map { |p| " - #{p}" }.join("\n")
33
+ "Path not in allowed list. Allowed paths:\n#{patterns}"
34
+ elsif denied_patterns.any?
35
+ # Show denied patterns
36
+ patterns = denied_patterns.map { |p| " - #{p}" }.join("\n")
37
+ "Denied paths:\n#{patterns}"
38
+ elsif allowed_patterns.any?
39
+ # Show allowed patterns
40
+ patterns = allowed_patterns.map { |p| " - #{p}" }.join("\n")
41
+ "Allowed paths (not matched):\n#{patterns}"
42
+ else
43
+ "No access policy configured"
44
+ end
45
+
46
+ reminder = <<~REMINDER
47
+
48
+ <system-reminder>
49
+ PERMISSION DENIED: You do not have permission to #{operation_verb} '#{path}'.
50
+
51
+ #{policy_info}
52
+
53
+ This is an UNRECOVERABLE error set by user policy. You MUST stop trying to access files matching this pattern.
54
+
55
+ Policy explanation:
56
+ - This policy blocks ALL files matching the pattern, not just this specific file
57
+ - Do not attempt to access other files matching this pattern - they will also be denied
58
+ - Do not try to work around this restriction by using different tool arguments
59
+ - The user has explicitly denied access to these resources via security policy
60
+
61
+ You should inform the user that you cannot proceed due to permission restrictions on this file pattern.
62
+ </system-reminder>
63
+ REMINDER
64
+
65
+ "Permission denied: Cannot #{operation_verb} '#{path}'#{reminder}"
66
+ end
67
+
68
+ # Generate a command permission denied error message
69
+ #
70
+ # @param command [String] The command that was denied
71
+ # @param allowed_patterns [Array<Regexp>] List of allowed command regex patterns
72
+ # @param denied_patterns [Array<Regexp>] List of denied command regex patterns
73
+ # @param matching_pattern [String, nil] The specific pattern that blocked this command
74
+ # @param tool_name [String] Name of the tool (typically "bash")
75
+ # @return [String] Formatted error message with system reminder
76
+ def command_permission_denied(command:, allowed_patterns:, denied_patterns: [], matching_pattern: nil, tool_name:)
77
+ # Build policy explanation
78
+ policy_info = if matching_pattern && matching_pattern != "(not in allowed list)"
79
+ # Show the specific denied pattern that blocked this command
80
+ "Blocked by policy: #{matching_pattern}"
81
+ elsif matching_pattern == "(not in allowed list)" && allowed_patterns.any?
82
+ # Show allowed patterns when command doesn't match any
83
+ patterns = allowed_patterns.map { |p| " - #{p.source}" }.join("\n")
84
+ "Command not in allowed list. Allowed command patterns:\n#{patterns}"
85
+ elsif denied_patterns.any?
86
+ # Show denied patterns
87
+ patterns = denied_patterns.map { |p| " - #{p.source}" }.join("\n")
88
+ "Denied command patterns:\n#{patterns}"
89
+ elsif allowed_patterns.any?
90
+ # Show allowed patterns
91
+ patterns = allowed_patterns.map { |p| " - #{p.source}" }.join("\n")
92
+ "Allowed command patterns (not matched):\n#{patterns}"
93
+ else
94
+ "No command policy configured"
95
+ end
96
+
97
+ reminder = <<~REMINDER
98
+
99
+ <system-reminder>
100
+ PERMISSION DENIED: You do not have permission to execute command '#{command}'.
101
+
102
+ #{policy_info}
103
+
104
+ This is an UNRECOVERABLE error set by user policy. You MUST stop trying to execute commands matching this pattern.
105
+
106
+ Policy explanation:
107
+ - This policy blocks ALL commands matching the pattern, not just this specific command
108
+ - Do not attempt to execute other commands matching this pattern - they will also be denied
109
+ - Do not try to work around this restriction by modifying the command slightly
110
+ - The user has explicitly denied access to these commands via security policy
111
+
112
+ You should inform the user that you cannot proceed due to permission restrictions on this command.
113
+ </system-reminder>
114
+ REMINDER
115
+
116
+ "Permission denied: Cannot execute command '#{command}'#{reminder}"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Permissions
5
+ # PathMatcher handles glob pattern matching for file paths
6
+ #
7
+ # Supports gitignore-style glob patterns with:
8
+ # - Standard globs: *, **, ?, [abc], {a,b}
9
+ # - Recursive matching: **/* matches all nested files
10
+ # - Negation: !pattern to explicitly deny
11
+ #
12
+ # Examples:
13
+ # PathMatcher.matches?("tmp/**/*", "tmp/foo/bar.rb") # => true
14
+ # PathMatcher.matches?("*.log", "debug.log") # => true
15
+ # PathMatcher.matches?("src/**/*.{rb,js}", "src/a/b.rb") # => true
16
+ class PathMatcher
17
+ class << self
18
+ # Check if a path matches a glob pattern
19
+ #
20
+ # @param pattern [String] Glob pattern to match against
21
+ # @param path [String] File path to check
22
+ # @return [Boolean] True if path matches pattern
23
+ def matches?(pattern, path)
24
+ # Remove leading ! for negation patterns (handled by caller)
25
+ pattern = pattern.delete_prefix("!")
26
+
27
+ # Use File.fnmatch with pathname and extglob flags
28
+ # FNM_PATHNAME: ** matches directories recursively
29
+ # FNM_EXTGLOB: Support {a,b} patterns
30
+ File.fnmatch(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end