claude_swarm 0.1.8 → 0.1.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4694bdf654adfb2784e4156d504ace610c9b48bd7aedafb4d5571ee0483325e6
4
- data.tar.gz: 9f26361aac4c2815b11f95ab28a67fbb684377d691b7ee5a1b99c89a1a4fa3c1
3
+ metadata.gz: c6060c41e9f648806744e61b547a7f43dddeea50377bd0e323881babb7146bbc
4
+ data.tar.gz: c057462ec65b862136b128eefb01f3e073399972839abe0d08869a3fe0c046bd
5
5
  SHA512:
6
- metadata.gz: 8c5d95adfea48bb560e8ed04d1149284fa199e72a9051e85f7e3e23222a1c117c96b6eb977a418bc573d41a6e6c846d7071dd552121d3810a33b0937ac3228c4
7
- data.tar.gz: aaf42838c5caf597bde330171ebd0aca702089731794f2c45df395b177fe6871c96f27f5838da587a9454de7cc397ca8fae7f910c6f61b1f775f6c2911444823
6
+ metadata.gz: '08eea752236a46872e9baa3a645240fcc08fc6d432d3723fa446daefa1d6735c24a1ffa026f222fa8aa9c2ce2d0e9a3c0076b92c11dc66b7047cd01b10e31946'
7
+ data.tar.gz: 104021e0d2017b3fcf7ecf09e5c3ec281f4f5bc0b397da6f7dc6da6e504d570acee81bf38cf14c33a23a12381bc1838c8915411dccba2e2146658f9b2c498b98
data/.rubocop.yml CHANGED
@@ -62,4 +62,7 @@ Minitest/MultipleAssertions:
62
62
  Enabled: false
63
63
 
64
64
  Metrics/ParameterLists:
65
+ Enabled: false
66
+
67
+ Style/PerlBackrefs:
65
68
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## [0.1.9]
2
+
3
+ ### Added
4
+ - **Parameter-based tool patterns**: Custom tools now support explicit parameter patterns (e.g., `WebFetch(url:https://example.com/*)`)
5
+ - **Enhanced pattern matching**: File tools support brace expansion and complex glob patterns (e.g., `Read(~/docs/**/*.{txt,md})`)
6
+ - **Comprehensive test coverage**: Added extensive unit and integration tests for permission system
7
+
8
+ ### Changed
9
+ - **Breaking change**: Custom tools with patterns now require explicit parameter syntax - `Tool(param:pattern)` instead of `Tool(pattern)`
10
+ - **Improved pattern parsing**: Tool patterns are now parsed into structured hashes with `tool_name`, `pattern`, and `type` fields
11
+ - **Better pattern enforcement**: Custom tool patterns are now strictly enforced - requests with non-matching parameters are denied
12
+ - Tools without patterns (e.g., `WebFetch`) continue to accept any input parameters
13
+
14
+ ### Fixed
15
+ - Fixed brace expansion in file glob patterns by adding `File::FNM_EXTGLOB` flag
16
+ - Improved parameter pattern parsing to avoid conflicts with URL patterns containing colons
17
+
18
+ ### Internal
19
+ - Major refactoring of `PermissionMcpServer` and `PermissionTool` for better maintainability and readability
20
+ - Extracted pattern matching logic into focused, single-purpose methods
21
+ - Added constants for tool categories and pattern types
22
+ - Improved logging with structured helper methods
23
+
1
24
  ## [0.1.8]
2
25
 
3
26
  ### Added
@@ -171,9 +171,9 @@ module ClaudeSwarm
171
171
  end
172
172
 
173
173
  desc "tools-mcp", "Start a permission management MCP server for tool access control"
174
- method_option :allowed_tools, aliases: "-t", type: :array,
174
+ method_option :allowed_tools, aliases: "-t", type: :string,
175
175
  desc: "Comma-separated list of allowed tool patterns (supports wildcards)"
176
- method_option :disallowed_tools, type: :array,
176
+ method_option :disallowed_tools, type: :string,
177
177
  desc: "Comma-separated list of disallowed tool patterns (supports wildcards)"
178
178
  method_option :debug, type: :boolean, default: false,
179
179
  desc: "Enable debug output"
@@ -8,9 +8,22 @@ require_relative "permission_tool"
8
8
 
9
9
  module ClaudeSwarm
10
10
  class PermissionMcpServer
11
+ # Directory constants
11
12
  SWARM_DIR = ".claude-swarm"
12
13
  SESSIONS_DIR = "sessions"
13
14
 
15
+ # Server configuration
16
+ SERVER_NAME = "claude-swarm-permissions"
17
+ SERVER_VERSION = "1.0.0"
18
+
19
+ # Tool categories
20
+ FILE_TOOLS = %w[Read Write Edit].freeze
21
+ BASH_TOOL = "Bash"
22
+
23
+ # Pattern matching
24
+ TOOL_PATTERN_REGEX = /^([^()]+)\(([^)]+)\)$/
25
+ PARAM_PATTERN_REGEX = /^(\w+)\s*:\s*(.+)$/
26
+
14
27
  def initialize(allowed_tools: nil, disallowed_tools: nil)
15
28
  @allowed_tools = allowed_tools
16
29
  @disallowed_tools = disallowed_tools
@@ -18,64 +31,160 @@ module ClaudeSwarm
18
31
  end
19
32
 
20
33
  def start
21
- # Parse allowed and disallowed tools
34
+ configure_permission_tool
35
+ create_and_start_server
36
+ end
37
+
38
+ private
39
+
40
+ def configure_permission_tool
22
41
  allowed_patterns = parse_tool_patterns(@allowed_tools)
23
42
  disallowed_patterns = parse_tool_patterns(@disallowed_tools)
24
43
 
25
- @logger.info("Starting permission MCP server with allowed patterns: #{allowed_patterns.inspect}, " \
26
- "disallowed patterns: #{disallowed_patterns.inspect}")
44
+ log_configuration(allowed_patterns, disallowed_patterns)
27
45
 
28
- # Set the patterns on the tool class
29
46
  PermissionTool.allowed_patterns = allowed_patterns
30
47
  PermissionTool.disallowed_patterns = disallowed_patterns
31
48
  PermissionTool.logger = @logger
49
+ end
32
50
 
51
+ def create_and_start_server
33
52
  server = FastMcp::Server.new(
34
- name: "claude-swarm-permissions",
35
- version: "1.0.0"
53
+ name: SERVER_NAME,
54
+ version: SERVER_VERSION
36
55
  )
37
56
 
38
- # Register the tool class
39
57
  server.register_tool(PermissionTool)
40
-
41
58
  @logger.info("Permission MCP server started successfully")
42
-
43
- # Start the stdio server
44
59
  server.start
45
60
  end
46
61
 
47
- private
48
-
49
62
  def setup_logging
50
- # Use environment variable for session timestamp if available
51
- # Otherwise create a new timestamp
52
- session_timestamp = ENV["CLAUDE_SWARM_SESSION_TIMESTAMP"] || Time.now.strftime("%Y%m%d_%H%M%S")
63
+ session_dir = create_session_directory
64
+ @logger = create_logger(session_dir)
65
+ @logger.info("Permission MCP server logging initialized")
66
+ end
53
67
 
54
- # Ensure the session directory exists
68
+ def create_session_directory
69
+ session_timestamp = ENV["CLAUDE_SWARM_SESSION_TIMESTAMP"] || Time.now.strftime("%Y%m%d_%H%M%S")
55
70
  session_dir = File.join(Dir.pwd, SWARM_DIR, SESSIONS_DIR, session_timestamp)
56
71
  FileUtils.mkdir_p(session_dir)
72
+ session_dir
73
+ end
57
74
 
58
- # Create logger with permissions.log filename
75
+ def create_logger(session_dir)
59
76
  log_path = File.join(session_dir, "permissions.log")
60
- @logger = Logger.new(log_path)
61
- @logger.level = Logger::DEBUG
77
+ logger = Logger.new(log_path)
78
+ logger.level = Logger::DEBUG
79
+ logger.formatter = log_formatter
80
+ logger
81
+ end
62
82
 
63
- # Custom formatter for better readability
64
- @logger.formatter = proc do |severity, datetime, _progname, msg|
83
+ def log_formatter
84
+ proc do |severity, datetime, _progname, msg|
65
85
  "[#{datetime.strftime("%Y-%m-%d %H:%M:%S.%L")}] [#{severity}] #{msg}\n"
66
86
  end
87
+ end
67
88
 
68
- @logger.info("Permission MCP server logging initialized")
89
+ def log_configuration(allowed_patterns, disallowed_patterns)
90
+ @logger.info("Starting permission MCP server with allowed patterns: #{allowed_patterns.inspect}, " \
91
+ "disallowed patterns: #{disallowed_patterns.inspect}")
69
92
  end
70
93
 
71
94
  def parse_tool_patterns(tools)
72
95
  return [] if tools.nil? || tools.empty?
73
96
 
74
- # Handle both string and array inputs
75
- tool_list = tools.is_a?(Array) ? tools : tools.split(/[,\s]+/)
97
+ normalize_tool_list(tools).filter_map do |tool|
98
+ parse_single_tool_pattern(tool.strip)
99
+ end
100
+ end
101
+
102
+ def normalize_tool_list(tools)
103
+ tools.is_a?(Array) ? tools : tools.split(/[,\s]+/)
104
+ end
105
+
106
+ def parse_single_tool_pattern(tool)
107
+ return nil if tool.empty?
108
+
109
+ if (match = tool.match(TOOL_PATTERN_REGEX))
110
+ parse_tool_with_pattern(match[1], match[2])
111
+ elsif tool.include?("*")
112
+ create_wildcard_tool_pattern(tool)
113
+ else
114
+ create_exact_tool_pattern(tool)
115
+ end
116
+ end
117
+
118
+ def parse_tool_with_pattern(tool_name, pattern)
119
+ case tool_name
120
+ when *FILE_TOOLS
121
+ create_file_tool_pattern(tool_name, pattern)
122
+ when BASH_TOOL
123
+ create_bash_tool_pattern(tool_name, pattern)
124
+ else
125
+ create_custom_tool_pattern(tool_name, pattern)
126
+ end
127
+ end
128
+
129
+ def create_file_tool_pattern(tool_name, pattern)
130
+ {
131
+ tool_name: tool_name,
132
+ pattern: File.expand_path(pattern),
133
+ type: :glob
134
+ }
135
+ end
136
+
137
+ def create_bash_tool_pattern(tool_name, pattern)
138
+ {
139
+ tool_name: tool_name,
140
+ pattern: process_bash_pattern(pattern),
141
+ type: :regex
142
+ }
143
+ end
144
+
145
+ def process_bash_pattern(pattern)
146
+ if pattern.include?(":")
147
+ # Colon syntax: convert parts and join with spaces
148
+ pattern.split(":")
149
+ .map { |part| part.gsub("*", ".*") }
150
+ .join(" ")
151
+ else
152
+ # Literal pattern: escape asterisks
153
+ pattern.gsub("*", "\\*")
154
+ end
155
+ end
156
+
157
+ def create_custom_tool_pattern(tool_name, pattern)
158
+ {
159
+ tool_name: tool_name,
160
+ pattern: parse_parameter_patterns(pattern),
161
+ type: :params
162
+ }
163
+ end
164
+
165
+ def parse_parameter_patterns(pattern)
166
+ pattern.split(",").each_with_object({}) do |param_pair, params|
167
+ param_pair = param_pair.strip
168
+ if (match = param_pair.match(PARAM_PATTERN_REGEX))
169
+ params[match[1]] = match[2]
170
+ end
171
+ end
172
+ end
173
+
174
+ def create_wildcard_tool_pattern(tool)
175
+ {
176
+ tool_name: tool.gsub("*", ".*"),
177
+ pattern: nil,
178
+ type: :regex
179
+ }
180
+ end
76
181
 
77
- # Clean up and return
78
- tool_list.map(&:strip).reject(&:empty?)
182
+ def create_exact_tool_pattern(tool)
183
+ {
184
+ tool_name: tool,
185
+ pattern: nil,
186
+ type: :exact
187
+ }
79
188
  end
80
189
  end
81
190
  end
@@ -10,6 +10,17 @@ module ClaudeSwarm
10
10
  attr_accessor :allowed_patterns, :disallowed_patterns, :logger
11
11
  end
12
12
 
13
+ # Tool categories
14
+ FILE_TOOLS = %w[Read Write Edit].freeze
15
+ BASH_TOOL = "Bash"
16
+
17
+ # Response behaviors
18
+ BEHAVIOR_ALLOW = "allow"
19
+ BEHAVIOR_DENY = "deny"
20
+
21
+ # File matching flags
22
+ FILE_MATCH_FLAGS = File::FNM_DOTMATCH | File::FNM_PATHNAME | File::FNM_EXTGLOB
23
+
13
24
  tool_name "check_permission"
14
25
  description "Check if a tool is allowed to be used based on configured patterns"
15
26
 
@@ -19,69 +30,172 @@ module ClaudeSwarm
19
30
  end
20
31
 
21
32
  def call(tool_name:, input:)
22
- logger = self.class.logger
23
- logger.info("Permission check requested for tool: #{tool_name}")
24
- logger.info("Tool input: #{input.inspect}")
33
+ @current_tool_name = tool_name
34
+ log_request(tool_name, input)
35
+
36
+ result = evaluate_permission(tool_name, input)
37
+ response = JSON.generate(result)
38
+
39
+ log_response(response)
40
+ response
41
+ end
25
42
 
26
- allowed_patterns = self.class.allowed_patterns || []
27
- disallowed_patterns = self.class.disallowed_patterns || []
43
+ private
44
+
45
+ def evaluate_permission(tool_name, input)
46
+ if explicitly_disallowed?(tool_name, input)
47
+ deny_response(tool_name, "explicitly disallowed")
48
+ elsif implicitly_allowed?(tool_name, input)
49
+ allow_response(input)
50
+ else
51
+ deny_response(tool_name, "not allowed by configured patterns")
52
+ end
53
+ end
54
+
55
+ def explicitly_disallowed?(tool_name, input)
56
+ check_patterns(disallowed_patterns, tool_name, input, "Disallowed")
57
+ end
28
58
 
29
- logger.info("Checking against allowed patterns: #{allowed_patterns.inspect}")
30
- logger.info("Checking against disallowed patterns: #{disallowed_patterns.inspect}")
59
+ def implicitly_allowed?(tool_name, input)
60
+ allowed_patterns.empty? || check_patterns(allowed_patterns, tool_name, input, "Allowed")
61
+ end
31
62
 
32
- # Check if tool matches any disallowed pattern first (takes precedence)
33
- disallowed = disallowed_patterns.any? do |pattern|
34
- match = matches_pattern?(tool_name, pattern)
35
- logger.info("Disallowed pattern '#{pattern}' vs '#{tool_name}': #{match}")
63
+ def check_patterns(patterns, tool_name, input, pattern_type)
64
+ patterns.any? do |pattern_hash|
65
+ match = matches_pattern?(tool_name, input, pattern_hash)
66
+ log_pattern_check(pattern_type, pattern_hash, tool_name, input, match)
36
67
  match
37
68
  end
69
+ end
70
+
71
+ def matches_pattern?(tool_name, input, pattern_hash)
72
+ return false unless tool_name_matches?(tool_name, pattern_hash)
73
+ return true if pattern_hash[:pattern].nil?
74
+
75
+ match_tool_specific_pattern(tool_name, input, pattern_hash)
76
+ end
38
77
 
39
- if disallowed
40
- logger.info("DENIED: Tool '#{tool_name}' matches disallowed pattern")
41
- result = {
42
- "behavior" => "deny",
43
- "message" => "Tool '#{tool_name}' is explicitly disallowed"
44
- }
78
+ def tool_name_matches?(tool_name, pattern_hash)
79
+ case pattern_hash[:type]
80
+ when :regex
81
+ tool_name.match?(/^#{pattern_hash[:tool_name]}$/)
45
82
  else
46
- # Check if the tool matches any allowed pattern
47
- allowed = allowed_patterns.empty? || allowed_patterns.any? do |pattern|
48
- match = matches_pattern?(tool_name, pattern)
49
- logger.info("Allowed pattern '#{pattern}' vs '#{tool_name}': #{match}")
50
- match
51
- end
52
-
53
- result = if allowed
54
- logger.info("ALLOWED: Tool '#{tool_name}' matches configured patterns")
55
- {
56
- "behavior" => "allow",
57
- "updatedInput" => input
58
- }
59
- else
60
- logger.info("DENIED: Tool '#{tool_name}' does not match any allowed patterns")
61
- {
62
- "behavior" => "deny",
63
- "message" => "Tool '#{tool_name}' is not allowed by configured patterns"
64
- }
65
- end
83
+ tool_name == pattern_hash[:tool_name]
66
84
  end
85
+ end
67
86
 
68
- # Return JSON-stringified result as per SDK docs
69
- response = JSON.generate(result)
70
- logger.info("Returning response: #{response}")
71
- response
87
+ def match_tool_specific_pattern(_tool_name, input, pattern_hash)
88
+ case pattern_hash[:tool_name]
89
+ when BASH_TOOL
90
+ match_bash_pattern(input, pattern_hash)
91
+ when *FILE_TOOLS
92
+ match_file_pattern(input, pattern_hash[:pattern])
93
+ else
94
+ match_custom_tool_pattern(input, pattern_hash)
95
+ end
72
96
  end
73
97
 
74
- private
98
+ def match_bash_pattern(input, pattern_hash)
99
+ command = extract_field_value(input, "command")
100
+ return false unless command
75
101
 
76
- def matches_pattern?(tool_name, pattern)
77
- if pattern.include?("*")
78
- # Convert wildcard pattern to regex
79
- regex_pattern = pattern.gsub("*", ".*")
80
- tool_name.match?(/^#{regex_pattern}$/)
102
+ if pattern_hash[:type] == :regex
103
+ command.match?(/^#{pattern_hash[:pattern]}$/)
81
104
  else
82
- # Exact match
83
- tool_name == pattern
105
+ command == pattern_hash[:pattern]
84
106
  end
85
107
  end
108
+
109
+ def match_file_pattern(input, pattern)
110
+ file_path = extract_field_value(input, "file_path")
111
+ unless file_path
112
+ log_missing_field("file_path", input)
113
+ return false
114
+ end
115
+
116
+ File.fnmatch(pattern, File.expand_path(file_path), FILE_MATCH_FLAGS)
117
+ end
118
+
119
+ def match_custom_tool_pattern(input, pattern_hash)
120
+ return false unless pattern_hash[:type] == :params && pattern_hash[:pattern].is_a?(Hash)
121
+ return false if pattern_hash[:pattern].empty?
122
+
123
+ match_parameter_patterns(input, pattern_hash[:pattern])
124
+ end
125
+
126
+ def match_parameter_patterns(input, param_patterns)
127
+ param_patterns.all? do |param_name, param_pattern|
128
+ value = extract_field_value(input, param_name.to_s)
129
+ return false unless value
130
+
131
+ regex_pattern = glob_to_regex(param_pattern)
132
+ value.to_s.match?(/^#{regex_pattern}$/)
133
+ end
134
+ end
135
+
136
+ def extract_field_value(input, field_name)
137
+ input[field_name] || input[field_name.to_sym]
138
+ end
139
+
140
+ def glob_to_regex(pattern)
141
+ Regexp.escape(pattern)
142
+ .gsub('\*', ".*")
143
+ .gsub('\?', ".")
144
+ end
145
+
146
+ # Response builders
147
+ def allow_response(input)
148
+ log_decision("ALLOWED", "matches configured patterns")
149
+ {
150
+ "behavior" => BEHAVIOR_ALLOW,
151
+ "updatedInput" => input
152
+ }
153
+ end
154
+
155
+ def deny_response(tool_name, reason)
156
+ log_decision("DENIED", "is #{reason}")
157
+ {
158
+ "behavior" => BEHAVIOR_DENY,
159
+ "message" => "Tool '#{tool_name}' is #{reason}"
160
+ }
161
+ end
162
+
163
+ # Logging helpers
164
+ def log_request(tool_name, input)
165
+ logger&.info("Permission check requested for tool: #{tool_name}")
166
+ logger&.info("Tool input: #{input.inspect}")
167
+ logger&.info("Checking against allowed patterns: #{allowed_patterns.inspect}")
168
+ logger&.info("Checking against disallowed patterns: #{disallowed_patterns.inspect}")
169
+ end
170
+
171
+ def log_response(response)
172
+ logger&.info("Returning response: #{response}")
173
+ end
174
+
175
+ def log_pattern_check(pattern_type, pattern_hash, tool_name, input, match)
176
+ logger&.info("#{pattern_type} pattern '#{pattern_hash.inspect}' vs '#{tool_name}' " \
177
+ "with input '#{input.inspect}': #{match}")
178
+ end
179
+
180
+ def log_decision(status, reason)
181
+ logger&.info("#{status}: Tool '#{@current_tool_name}' #{reason}")
182
+ end
183
+
184
+ def log_missing_field(field_name, input)
185
+ logger&.info("#{field_name} not found in input: #{input.inspect}")
186
+ end
187
+
188
+ # Convenience accessors
189
+ def logger
190
+ self.class.logger
191
+ end
192
+
193
+ def allowed_patterns
194
+ self.class.allowed_patterns || []
195
+ end
196
+
197
+ def disallowed_patterns
198
+ self.class.disallowed_patterns || []
199
+ end
86
200
  end
87
201
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "0.1.8"
4
+ VERSION = "0.1.9"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude_swarm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda