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 +4 -4
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +23 -0
- data/lib/claude_swarm/cli.rb +2 -2
- data/lib/claude_swarm/permission_mcp_server.rb +135 -26
- data/lib/claude_swarm/permission_tool.rb +163 -49
- data/lib/claude_swarm/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c6060c41e9f648806744e61b547a7f43dddeea50377bd0e323881babb7146bbc
|
4
|
+
data.tar.gz: c057462ec65b862136b128eefb01f3e073399972839abe0d08869a3fe0c046bd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: '08eea752236a46872e9baa3a645240fcc08fc6d432d3723fa446daefa1d6735c24a1ffa026f222fa8aa9c2ce2d0e9a3c0076b92c11dc66b7047cd01b10e31946'
|
7
|
+
data.tar.gz: 104021e0d2017b3fcf7ecf09e5c3ec281f4f5bc0b397da6f7dc6da6e504d570acee81bf38cf14c33a23a12381bc1838c8915411dccba2e2146658f9b2c498b98
|
data/.rubocop.yml
CHANGED
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
|
data/lib/claude_swarm/cli.rb
CHANGED
@@ -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: :
|
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: :
|
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
|
-
|
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
|
-
|
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:
|
35
|
-
version:
|
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
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
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
|
-
|
75
|
+
def create_logger(session_dir)
|
59
76
|
log_path = File.join(session_dir, "permissions.log")
|
60
|
-
|
61
|
-
|
77
|
+
logger = Logger.new(log_path)
|
78
|
+
logger.level = Logger::DEBUG
|
79
|
+
logger.formatter = log_formatter
|
80
|
+
logger
|
81
|
+
end
|
62
82
|
|
63
|
-
|
64
|
-
|
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
|
-
|
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
|
-
|
75
|
-
|
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
|
-
|
78
|
-
|
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
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
27
|
-
|
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
|
-
|
30
|
-
|
59
|
+
def implicitly_allowed?(tool_name, input)
|
60
|
+
allowed_patterns.empty? || check_patterns(allowed_patterns, tool_name, input, "Allowed")
|
61
|
+
end
|
31
62
|
|
32
|
-
|
33
|
-
|
34
|
-
match = matches_pattern?(tool_name,
|
35
|
-
|
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
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
|
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
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
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
|
-
|
98
|
+
def match_bash_pattern(input, pattern_hash)
|
99
|
+
command = extract_field_value(input, "command")
|
100
|
+
return false unless command
|
75
101
|
|
76
|
-
|
77
|
-
|
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
|
-
|
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
|
data/lib/claude_swarm/version.rb
CHANGED