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.
- checksums.yaml +7 -0
- data/lib/swarm_sdk/agent/builder.rb +333 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
- data/lib/swarm_sdk/agent/chat.rb +779 -0
- data/lib/swarm_sdk/agent/context.rb +108 -0
- data/lib/swarm_sdk/agent/definition.rb +335 -0
- data/lib/swarm_sdk/configuration.rb +251 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +163 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +143 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +83 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +46 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
- data/lib/swarm_sdk/swarm/builder.rb +240 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
- data/lib/swarm_sdk/swarm.rb +837 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/delegate.rb +152 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +231 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +73 -0
- data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
- data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
- data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +69 -0
- 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
|