claude_swarm 0.1.15 ā 0.1.17
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/CHANGELOG.md +43 -0
- data/CLAUDE.md +9 -5
- data/README.md +119 -37
- data/claude-swarm.yml +18 -34
- data/example/microservices-team.yml +17 -17
- data/example/test-generation.yml +5 -5
- data/examples/monitoring-demo.yml +26 -0
- data/examples/multi-directory.yml +26 -0
- data/lib/claude_swarm/claude_code_executor.rb +19 -7
- data/lib/claude_swarm/claude_mcp_server.rb +2 -1
- data/lib/claude_swarm/cli.rb +86 -21
- data/lib/claude_swarm/commands/ps.rb +148 -0
- data/lib/claude_swarm/commands/show.rb +158 -0
- data/lib/claude_swarm/configuration.rb +20 -3
- data/lib/claude_swarm/mcp_generator.rb +6 -22
- data/lib/claude_swarm/orchestrator.rb +68 -9
- data/lib/claude_swarm/task_tool.rb +5 -2
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm.rb +0 -2
- data/llms.txt +4 -5
- metadata +6 -4
- data/lib/claude_swarm/permission_mcp_server.rb +0 -189
- data/lib/claude_swarm/permission_tool.rb +0 -201
- /data/{sdk-docs.md ā claude-sdk-docs.md} +0 -0
@@ -8,6 +8,8 @@ require_relative "process_tracker"
|
|
8
8
|
|
9
9
|
module ClaudeSwarm
|
10
10
|
class Orchestrator
|
11
|
+
RUN_DIR = File.expand_path("~/.claude-swarm/run")
|
12
|
+
|
11
13
|
def initialize(configuration, mcp_generator, vibe: false, prompt: nil, stream_logs: false, debug: false,
|
12
14
|
restore_session_path: nil)
|
13
15
|
@config = configuration
|
@@ -17,6 +19,7 @@ module ClaudeSwarm
|
|
17
19
|
@stream_logs = stream_logs
|
18
20
|
@debug = debug
|
19
21
|
@restore_session_path = restore_session_path
|
22
|
+
@session_path = nil
|
20
23
|
end
|
21
24
|
|
22
25
|
def start
|
@@ -29,9 +32,13 @@ module ClaudeSwarm
|
|
29
32
|
|
30
33
|
# Use existing session path
|
31
34
|
session_path = @restore_session_path
|
35
|
+
@session_path = session_path
|
32
36
|
ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
|
33
37
|
ENV["CLAUDE_SWARM_START_DIR"] = Dir.pwd
|
34
38
|
|
39
|
+
# Create run symlink for restored session
|
40
|
+
create_run_symlink
|
41
|
+
|
35
42
|
unless @prompt
|
36
43
|
puts "š Using existing session: #{session_path}/"
|
37
44
|
puts
|
@@ -59,10 +66,14 @@ module ClaudeSwarm
|
|
59
66
|
# Generate and set session path for all instances
|
60
67
|
session_path = SessionPath.generate(working_dir: Dir.pwd)
|
61
68
|
SessionPath.ensure_directory(session_path)
|
69
|
+
@session_path = session_path
|
62
70
|
|
63
71
|
ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
|
64
72
|
ENV["CLAUDE_SWARM_START_DIR"] = Dir.pwd
|
65
73
|
|
74
|
+
# Create run symlink for new session
|
75
|
+
create_run_symlink
|
76
|
+
|
66
77
|
unless @prompt
|
67
78
|
puts "š Session files will be saved to: #{session_path}/"
|
68
79
|
puts
|
@@ -90,8 +101,13 @@ module ClaudeSwarm
|
|
90
101
|
unless @prompt
|
91
102
|
puts "š Launching main instance: #{@config.main_instance}"
|
92
103
|
puts " Model: #{main_instance[:model]}"
|
93
|
-
|
94
|
-
|
104
|
+
if main_instance[:directories].size == 1
|
105
|
+
puts " Directory: #{main_instance[:directory]}"
|
106
|
+
else
|
107
|
+
puts " Directories:"
|
108
|
+
main_instance[:directories].each { |dir| puts " - #{dir}" }
|
109
|
+
end
|
110
|
+
puts " Allowed tools: #{main_instance[:allowed_tools].join(", ")}" if main_instance[:allowed_tools].any?
|
95
111
|
puts " Disallowed tools: #{main_instance[:disallowed_tools].join(", ")}" if main_instance[:disallowed_tools]&.any?
|
96
112
|
puts " Connections: #{main_instance[:connections].join(", ")}" if main_instance[:connections].any?
|
97
113
|
puts " š Vibe mode ON for this instance" if main_instance[:vibe]
|
@@ -119,8 +135,9 @@ module ClaudeSwarm
|
|
119
135
|
log_thread.join
|
120
136
|
end
|
121
137
|
|
122
|
-
# Clean up child processes
|
138
|
+
# Clean up child processes and run symlink
|
123
139
|
cleanup_processes
|
140
|
+
cleanup_run_symlink
|
124
141
|
end
|
125
142
|
|
126
143
|
private
|
@@ -140,6 +157,7 @@ module ClaudeSwarm
|
|
140
157
|
Signal.trap(signal) do
|
141
158
|
puts "\nš Received #{signal} signal, cleaning up..."
|
142
159
|
cleanup_processes
|
160
|
+
cleanup_run_symlink
|
143
161
|
exit
|
144
162
|
end
|
145
163
|
end
|
@@ -152,6 +170,35 @@ module ClaudeSwarm
|
|
152
170
|
puts "ā ļø Error during cleanup: #{e.message}"
|
153
171
|
end
|
154
172
|
|
173
|
+
def create_run_symlink
|
174
|
+
return unless @session_path
|
175
|
+
|
176
|
+
FileUtils.mkdir_p(RUN_DIR)
|
177
|
+
|
178
|
+
# Session ID is the last part of the session path
|
179
|
+
session_id = File.basename(@session_path)
|
180
|
+
symlink_path = File.join(RUN_DIR, session_id)
|
181
|
+
|
182
|
+
# Remove stale symlink if exists
|
183
|
+
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
184
|
+
|
185
|
+
# Create new symlink
|
186
|
+
File.symlink(@session_path, symlink_path)
|
187
|
+
rescue StandardError => e
|
188
|
+
# Don't fail the process if symlink creation fails
|
189
|
+
puts "ā ļø Warning: Could not create run symlink: #{e.message}" unless @prompt
|
190
|
+
end
|
191
|
+
|
192
|
+
def cleanup_run_symlink
|
193
|
+
return unless @session_path
|
194
|
+
|
195
|
+
session_id = File.basename(@session_path)
|
196
|
+
symlink_path = File.join(RUN_DIR, session_id)
|
197
|
+
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
198
|
+
rescue StandardError
|
199
|
+
# Ignore errors during cleanup
|
200
|
+
end
|
201
|
+
|
155
202
|
def start_log_streaming
|
156
203
|
Thread.new do
|
157
204
|
session_log_path = File.join(ENV.fetch("CLAUDE_SWARM_SESSION_PATH", nil), "session.log")
|
@@ -208,9 +255,17 @@ module ClaudeSwarm
|
|
208
255
|
if @vibe || instance[:vibe]
|
209
256
|
parts << "--dangerously-skip-permissions"
|
210
257
|
else
|
258
|
+
# Build allowed tools list including MCP connections
|
259
|
+
allowed_tools = instance[:allowed_tools].dup
|
260
|
+
|
261
|
+
# Add mcp__instance_name for each connection
|
262
|
+
instance[:connections].each do |connection_name|
|
263
|
+
allowed_tools << "mcp__#{connection_name}"
|
264
|
+
end
|
265
|
+
|
211
266
|
# Add allowed tools if any
|
212
|
-
if
|
213
|
-
tools_str =
|
267
|
+
if allowed_tools.any?
|
268
|
+
tools_str = allowed_tools.join(",")
|
214
269
|
parts << "--allowedTools"
|
215
270
|
parts << tools_str
|
216
271
|
end
|
@@ -221,10 +276,6 @@ module ClaudeSwarm
|
|
221
276
|
parts << "--disallowedTools"
|
222
277
|
parts << disallowed_tools_str
|
223
278
|
end
|
224
|
-
|
225
|
-
# Add permission prompt tool unless in vibe mode
|
226
|
-
parts << "--permission-prompt-tool"
|
227
|
-
parts << "mcp__permissions__check_permission"
|
228
279
|
end
|
229
280
|
|
230
281
|
if instance[:prompt]
|
@@ -234,6 +285,14 @@ module ClaudeSwarm
|
|
234
285
|
|
235
286
|
parts << "--debug" if @debug
|
236
287
|
|
288
|
+
# Add additional directories with --add-dir
|
289
|
+
if instance[:directories].size > 1
|
290
|
+
instance[:directories][1..].each do |additional_dir|
|
291
|
+
parts << "--add-dir"
|
292
|
+
parts << additional_dir
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
237
296
|
mcp_config_path = @generator.mcp_config_path(@config.main_instance)
|
238
297
|
parts << "--mcp-config"
|
239
298
|
parts << mcp_config_path
|
@@ -4,7 +4,7 @@ module ClaudeSwarm
|
|
4
4
|
class TaskTool < FastMcp::Tool
|
5
5
|
tool_name "task"
|
6
6
|
description "Execute a task using Claude Code. There is no description parameter."
|
7
|
-
annotations(
|
7
|
+
annotations(read_only_hint: true, open_world_hint: false, destructive_hint: false)
|
8
8
|
|
9
9
|
arguments do
|
10
10
|
required(:prompt).filled(:string).description("The task or question for the agent")
|
@@ -22,11 +22,14 @@ module ClaudeSwarm
|
|
22
22
|
}
|
23
23
|
|
24
24
|
# Add allowed tools from instance config
|
25
|
-
options[:allowed_tools] = instance_config[:
|
25
|
+
options[:allowed_tools] = instance_config[:allowed_tools] if instance_config[:allowed_tools]&.any?
|
26
26
|
|
27
27
|
# Add disallowed tools from instance config
|
28
28
|
options[:disallowed_tools] = instance_config[:disallowed_tools] if instance_config[:disallowed_tools]&.any?
|
29
29
|
|
30
|
+
# Add connections from instance config
|
31
|
+
options[:connections] = instance_config[:connections] if instance_config[:connections]&.any?
|
32
|
+
|
30
33
|
response = executor.execute(prompt, options)
|
31
34
|
|
32
35
|
# Return just the result text as expected by MCP
|
data/lib/claude_swarm/version.rb
CHANGED
data/lib/claude_swarm.rb
CHANGED
@@ -7,8 +7,6 @@ require_relative "claude_swarm/mcp_generator"
|
|
7
7
|
require_relative "claude_swarm/orchestrator"
|
8
8
|
require_relative "claude_swarm/claude_code_executor"
|
9
9
|
require_relative "claude_swarm/claude_mcp_server"
|
10
|
-
require_relative "claude_swarm/permission_tool"
|
11
|
-
require_relative "claude_swarm/permission_mcp_server"
|
12
10
|
require_relative "claude_swarm/session_path"
|
13
11
|
require_relative "claude_swarm/session_info_tool"
|
14
12
|
require_relative "claude_swarm/reset_session_tool"
|
data/llms.txt
CHANGED
@@ -225,10 +225,10 @@ cat ~/.claude-swarm/sessions/PROJECT/TIMESTAMP/session.log.json
|
|
225
225
|
- Check `main:` references valid instance key
|
226
226
|
**"Circular dependency detected"**
|
227
227
|
- Remove circular connections between instances
|
228
|
-
**"
|
228
|
+
**"Tool not allowed"**
|
229
229
|
- Check `allowed_tools` includes the tool
|
230
230
|
- Check `disallowed_tools` doesn't block it
|
231
|
-
- Use
|
231
|
+
- Use `vibe: true` to skip all checks (dangerous)
|
232
232
|
**"MCP server failed to start"**
|
233
233
|
- Check the command/URL is correct
|
234
234
|
- Verify MCP server is installed
|
@@ -362,12 +362,11 @@ bundle exec rake release
|
|
362
362
|
- `CLAUDE_SWARM_HOME`: Override session storage location (default: ~/.claude-swarm)
|
363
363
|
- `ANTHROPIC_MODEL`: Default Claude model if not specified
|
364
364
|
## Security Considerations
|
365
|
-
- Tool restrictions enforced
|
366
|
-
- All permissions logged for audit
|
365
|
+
- Tool restrictions enforced through configuration
|
367
366
|
- Vibe mode bypasses ALL restrictions - use carefully
|
368
367
|
- Session files may contain sensitive data
|
369
368
|
- Each instance runs with its directory context
|
370
|
-
- MCP servers inherit instance
|
369
|
+
- MCP servers inherit instance tool configurations
|
371
370
|
## Limitations
|
372
371
|
- Tree hierarchy only (no circular dependencies)
|
373
372
|
- Session restoration is experimental
|
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.
|
4
|
+
version: 0.1.17
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Paulo Arruda
|
@@ -59,21 +59,24 @@ files:
|
|
59
59
|
- README.md
|
60
60
|
- RELEASING.md
|
61
61
|
- Rakefile
|
62
|
+
- claude-sdk-docs.md
|
62
63
|
- claude-swarm.yml
|
63
64
|
- example/claude-swarm.yml
|
64
65
|
- example/microservices-team.yml
|
65
66
|
- example/session-restoration-demo.yml
|
66
67
|
- example/test-generation.yml
|
68
|
+
- examples/monitoring-demo.yml
|
69
|
+
- examples/multi-directory.yml
|
67
70
|
- exe/claude-swarm
|
68
71
|
- lib/claude_swarm.rb
|
69
72
|
- lib/claude_swarm/claude_code_executor.rb
|
70
73
|
- lib/claude_swarm/claude_mcp_server.rb
|
71
74
|
- lib/claude_swarm/cli.rb
|
75
|
+
- lib/claude_swarm/commands/ps.rb
|
76
|
+
- lib/claude_swarm/commands/show.rb
|
72
77
|
- lib/claude_swarm/configuration.rb
|
73
78
|
- lib/claude_swarm/mcp_generator.rb
|
74
79
|
- lib/claude_swarm/orchestrator.rb
|
75
|
-
- lib/claude_swarm/permission_mcp_server.rb
|
76
|
-
- lib/claude_swarm/permission_tool.rb
|
77
80
|
- lib/claude_swarm/process_tracker.rb
|
78
81
|
- lib/claude_swarm/reset_session_tool.rb
|
79
82
|
- lib/claude_swarm/session_info_tool.rb
|
@@ -81,7 +84,6 @@ files:
|
|
81
84
|
- lib/claude_swarm/task_tool.rb
|
82
85
|
- lib/claude_swarm/version.rb
|
83
86
|
- llms.txt
|
84
|
-
- sdk-docs.md
|
85
87
|
homepage: https://github.com/parruda/claude-swarm
|
86
88
|
licenses: []
|
87
89
|
metadata:
|
@@ -1,189 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "json"
|
4
|
-
require "fast_mcp_annotations"
|
5
|
-
require "logger"
|
6
|
-
require "fileutils"
|
7
|
-
require_relative "permission_tool"
|
8
|
-
require_relative "session_path"
|
9
|
-
require_relative "process_tracker"
|
10
|
-
|
11
|
-
module ClaudeSwarm
|
12
|
-
class PermissionMcpServer
|
13
|
-
# Server configuration
|
14
|
-
SERVER_NAME = "claude-swarm-permissions"
|
15
|
-
SERVER_VERSION = "1.0.0"
|
16
|
-
|
17
|
-
# Tool categories
|
18
|
-
FILE_TOOLS = %w[Read Write Edit].freeze
|
19
|
-
BASH_TOOL = "Bash"
|
20
|
-
|
21
|
-
# Pattern matching
|
22
|
-
TOOL_PATTERN_REGEX = /^([^()]+)\(([^)]+)\)$/
|
23
|
-
PARAM_PATTERN_REGEX = /^(\w+)\s*:\s*(.+)$/
|
24
|
-
|
25
|
-
def initialize(allowed_tools: nil, disallowed_tools: nil)
|
26
|
-
@allowed_tools = allowed_tools
|
27
|
-
@disallowed_tools = disallowed_tools
|
28
|
-
setup_logging
|
29
|
-
end
|
30
|
-
|
31
|
-
def start
|
32
|
-
configure_permission_tool
|
33
|
-
create_and_start_server
|
34
|
-
end
|
35
|
-
|
36
|
-
private
|
37
|
-
|
38
|
-
def configure_permission_tool
|
39
|
-
allowed_patterns = parse_tool_patterns(@allowed_tools)
|
40
|
-
disallowed_patterns = parse_tool_patterns(@disallowed_tools)
|
41
|
-
|
42
|
-
log_configuration(allowed_patterns, disallowed_patterns)
|
43
|
-
|
44
|
-
PermissionTool.allowed_patterns = allowed_patterns
|
45
|
-
PermissionTool.disallowed_patterns = disallowed_patterns
|
46
|
-
PermissionTool.logger = @logger
|
47
|
-
end
|
48
|
-
|
49
|
-
def create_and_start_server
|
50
|
-
# Track this process
|
51
|
-
session_path = SessionPath.from_env
|
52
|
-
if session_path && File.exist?(session_path)
|
53
|
-
tracker = ProcessTracker.new(session_path)
|
54
|
-
tracker.track_pid(Process.pid, "mcp_permissions")
|
55
|
-
end
|
56
|
-
|
57
|
-
server = FastMcp::Server.new(
|
58
|
-
name: SERVER_NAME,
|
59
|
-
version: SERVER_VERSION
|
60
|
-
)
|
61
|
-
|
62
|
-
server.register_tool(PermissionTool)
|
63
|
-
@logger.info("Permission MCP server started successfully")
|
64
|
-
server.start
|
65
|
-
end
|
66
|
-
|
67
|
-
def setup_logging
|
68
|
-
session_path = SessionPath.from_env
|
69
|
-
SessionPath.ensure_directory(session_path)
|
70
|
-
@logger = create_logger(session_path)
|
71
|
-
@logger.info("Permission MCP server logging initialized")
|
72
|
-
end
|
73
|
-
|
74
|
-
def create_logger(session_path)
|
75
|
-
log_path = File.join(session_path, "permissions.log")
|
76
|
-
logger = Logger.new(log_path)
|
77
|
-
logger.level = Logger::DEBUG
|
78
|
-
logger.formatter = log_formatter
|
79
|
-
logger
|
80
|
-
end
|
81
|
-
|
82
|
-
def log_formatter
|
83
|
-
proc do |severity, datetime, _progname, msg|
|
84
|
-
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S.%L")}] [#{severity}] #{msg}\n"
|
85
|
-
end
|
86
|
-
end
|
87
|
-
|
88
|
-
def log_configuration(allowed_patterns, disallowed_patterns)
|
89
|
-
@logger.info("Starting permission MCP server with allowed patterns: #{allowed_patterns.inspect}, " \
|
90
|
-
"disallowed patterns: #{disallowed_patterns.inspect}")
|
91
|
-
end
|
92
|
-
|
93
|
-
def parse_tool_patterns(tools)
|
94
|
-
return [] if tools.nil? || tools.empty?
|
95
|
-
|
96
|
-
normalize_tool_list(tools).filter_map do |tool|
|
97
|
-
parse_single_tool_pattern(tool.strip)
|
98
|
-
end
|
99
|
-
end
|
100
|
-
|
101
|
-
def normalize_tool_list(tools)
|
102
|
-
tools.is_a?(Array) ? tools : tools.split(/[,\s]+/)
|
103
|
-
end
|
104
|
-
|
105
|
-
def parse_single_tool_pattern(tool)
|
106
|
-
return nil if tool.empty?
|
107
|
-
|
108
|
-
if (match = tool.match(TOOL_PATTERN_REGEX))
|
109
|
-
parse_tool_with_pattern(match[1], match[2])
|
110
|
-
elsif tool.include?("*")
|
111
|
-
create_wildcard_tool_pattern(tool)
|
112
|
-
else
|
113
|
-
create_exact_tool_pattern(tool)
|
114
|
-
end
|
115
|
-
end
|
116
|
-
|
117
|
-
def parse_tool_with_pattern(tool_name, pattern)
|
118
|
-
case tool_name
|
119
|
-
when *FILE_TOOLS
|
120
|
-
create_file_tool_pattern(tool_name, pattern)
|
121
|
-
when BASH_TOOL
|
122
|
-
create_bash_tool_pattern(tool_name, pattern)
|
123
|
-
else
|
124
|
-
create_custom_tool_pattern(tool_name, pattern)
|
125
|
-
end
|
126
|
-
end
|
127
|
-
|
128
|
-
def create_file_tool_pattern(tool_name, pattern)
|
129
|
-
{
|
130
|
-
tool_name: tool_name,
|
131
|
-
pattern: File.expand_path(pattern),
|
132
|
-
type: :glob
|
133
|
-
}
|
134
|
-
end
|
135
|
-
|
136
|
-
def create_bash_tool_pattern(tool_name, pattern)
|
137
|
-
{
|
138
|
-
tool_name: tool_name,
|
139
|
-
pattern: process_bash_pattern(pattern),
|
140
|
-
type: :regex
|
141
|
-
}
|
142
|
-
end
|
143
|
-
|
144
|
-
def process_bash_pattern(pattern)
|
145
|
-
if pattern.include?(":")
|
146
|
-
# Colon syntax: convert parts and join with spaces
|
147
|
-
pattern.split(":")
|
148
|
-
.map { |part| part.gsub("*", ".*") }
|
149
|
-
.join(" ")
|
150
|
-
else
|
151
|
-
# Literal pattern: escape asterisks
|
152
|
-
pattern.gsub("*", "\\*")
|
153
|
-
end
|
154
|
-
end
|
155
|
-
|
156
|
-
def create_custom_tool_pattern(tool_name, pattern)
|
157
|
-
{
|
158
|
-
tool_name: tool_name,
|
159
|
-
pattern: parse_parameter_patterns(pattern),
|
160
|
-
type: :params
|
161
|
-
}
|
162
|
-
end
|
163
|
-
|
164
|
-
def parse_parameter_patterns(pattern)
|
165
|
-
pattern.split(",").each_with_object({}) do |param_pair, params|
|
166
|
-
param_pair = param_pair.strip
|
167
|
-
if (match = param_pair.match(PARAM_PATTERN_REGEX))
|
168
|
-
params[match[1]] = match[2]
|
169
|
-
end
|
170
|
-
end
|
171
|
-
end
|
172
|
-
|
173
|
-
def create_wildcard_tool_pattern(tool)
|
174
|
-
{
|
175
|
-
tool_name: tool.gsub("*", ".*"),
|
176
|
-
pattern: nil,
|
177
|
-
type: :regex
|
178
|
-
}
|
179
|
-
end
|
180
|
-
|
181
|
-
def create_exact_tool_pattern(tool)
|
182
|
-
{
|
183
|
-
tool_name: tool,
|
184
|
-
pattern: nil,
|
185
|
-
type: :exact
|
186
|
-
}
|
187
|
-
end
|
188
|
-
end
|
189
|
-
end
|
@@ -1,201 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "json"
|
4
|
-
require "fast_mcp_annotations"
|
5
|
-
|
6
|
-
module ClaudeSwarm
|
7
|
-
class PermissionTool < FastMcp::Tool
|
8
|
-
# Class variables to store allowed/disallowed patterns and logger
|
9
|
-
class << self
|
10
|
-
attr_accessor :allowed_patterns, :disallowed_patterns, :logger
|
11
|
-
end
|
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
|
-
|
24
|
-
tool_name "check_permission"
|
25
|
-
description "Check if a tool is allowed to be used based on configured patterns"
|
26
|
-
|
27
|
-
arguments do
|
28
|
-
required(:tool_name).filled(:string).description("The tool requesting permission")
|
29
|
-
required(:input).value(:hash).description("The input for the tool")
|
30
|
-
end
|
31
|
-
|
32
|
-
def call(tool_name:, input:)
|
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
|
42
|
-
|
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
|
58
|
-
|
59
|
-
def implicitly_allowed?(tool_name, input)
|
60
|
-
allowed_patterns.empty? || check_patterns(allowed_patterns, tool_name, input, "Allowed")
|
61
|
-
end
|
62
|
-
|
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)
|
67
|
-
match
|
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
|
77
|
-
|
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]}$/)
|
82
|
-
else
|
83
|
-
tool_name == pattern_hash[:tool_name]
|
84
|
-
end
|
85
|
-
end
|
86
|
-
|
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
|
96
|
-
end
|
97
|
-
|
98
|
-
def match_bash_pattern(input, pattern_hash)
|
99
|
-
command = extract_field_value(input, "command")
|
100
|
-
return false unless command
|
101
|
-
|
102
|
-
if pattern_hash[:type] == :regex
|
103
|
-
command.match?(/^#{pattern_hash[:pattern]}$/)
|
104
|
-
else
|
105
|
-
command == pattern_hash[:pattern]
|
106
|
-
end
|
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
|
200
|
-
end
|
201
|
-
end
|
File without changes
|