claude_swarm 1.0.1 → 1.0.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 +4 -4
- data/.claude/commands/release.md +1 -1
- data/.claude/hooks/lint-code-files.rb +65 -0
- data/.rubocop.yml +22 -2
- data/CHANGELOG.md +14 -1
- data/CLAUDE.md +1 -1
- data/CONTRIBUTING.md +69 -0
- data/README.md +27 -2
- data/Rakefile +71 -3
- data/analyze_coverage.rb +94 -0
- data/docs/v2/CHANGELOG.swarm_cli.md +43 -0
- data/docs/v2/CHANGELOG.swarm_memory.md +379 -0
- data/docs/v2/CHANGELOG.swarm_sdk.md +362 -0
- data/docs/v2/README.md +308 -0
- data/docs/v2/guides/claude-code-agents.md +262 -0
- data/docs/v2/guides/complete-tutorial.md +3088 -0
- data/docs/v2/guides/getting-started.md +1456 -0
- data/docs/v2/guides/memory-adapters.md +998 -0
- data/docs/v2/guides/plugins.md +816 -0
- data/docs/v2/guides/quick-start-cli.md +1745 -0
- data/docs/v2/guides/rails-integration.md +1902 -0
- data/docs/v2/guides/swarm-memory.md +599 -0
- data/docs/v2/reference/cli.md +729 -0
- data/docs/v2/reference/ruby-dsl.md +2154 -0
- data/docs/v2/reference/yaml.md +1835 -0
- data/docs-team-swarm.yml +2222 -0
- data/examples/learning-assistant/assistant.md +7 -0
- data/examples/learning-assistant/example-memories/concept-example.md +90 -0
- data/examples/learning-assistant/example-memories/experience-example.md +66 -0
- data/examples/learning-assistant/example-memories/fact-example.md +76 -0
- data/examples/learning-assistant/example-memories/memory-index.md +78 -0
- data/examples/learning-assistant/example-memories/skill-example.md +168 -0
- data/examples/learning-assistant/learning_assistant.rb +34 -0
- data/examples/learning-assistant/learning_assistant.yml +20 -0
- data/examples/v2/dsl/01_basic.rb +44 -0
- data/examples/v2/dsl/02_core_parameters.rb +59 -0
- data/examples/v2/dsl/03_capabilities.rb +71 -0
- data/examples/v2/dsl/04_llm_parameters.rb +56 -0
- data/examples/v2/dsl/05_advanced_flags.rb +73 -0
- data/examples/v2/dsl/06_permissions.rb +80 -0
- data/examples/v2/dsl/07_mcp_server.rb +62 -0
- data/examples/v2/dsl/08_swarm_hooks.rb +53 -0
- data/examples/v2/dsl/09_agent_hooks.rb +67 -0
- data/examples/v2/dsl/10_all_agents_hooks.rb +67 -0
- data/examples/v2/dsl/11_delegation.rb +60 -0
- data/examples/v2/dsl/12_complete_integration.rb +137 -0
- data/examples/v2/file_tools_swarm.yml +102 -0
- data/examples/v2/hooks/01_basic_hooks.rb +133 -0
- data/examples/v2/hooks/02_usage_tracking.rb +201 -0
- data/examples/v2/hooks/03_production_monitoring.rb +429 -0
- data/examples/v2/hooks/agent_stop_exit_0.yml +21 -0
- data/examples/v2/hooks/agent_stop_exit_1.yml +21 -0
- data/examples/v2/hooks/agent_stop_exit_2.yml +26 -0
- data/examples/v2/hooks/multiple_hooks_all_pass.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_first_fails.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_second_fails.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_warnings.yml +37 -0
- data/examples/v2/hooks/post_tool_use_exit_0.yml +24 -0
- data/examples/v2/hooks/post_tool_use_exit_1.yml +24 -0
- data/examples/v2/hooks/post_tool_use_exit_2.yml +24 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_0.yml +26 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_1.yml +26 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_2.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_exit_0.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_exit_1.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_exit_2.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_0.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_1.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_2.yml +27 -0
- data/examples/v2/hooks/swarm_summary.sh +44 -0
- data/examples/v2/hooks/user_prompt_exit_0.yml +21 -0
- data/examples/v2/hooks/user_prompt_exit_1.yml +21 -0
- data/examples/v2/hooks/user_prompt_exit_2.yml +21 -0
- data/examples/v2/hooks/validate_bash.rb +59 -0
- data/examples/v2/multi_directory_permissions.yml +221 -0
- data/examples/v2/node_context_demo.rb +127 -0
- data/examples/v2/node_workflow.rb +173 -0
- data/examples/v2/path_resolution_demo.rb +216 -0
- data/examples/v2/simple-swarm-v2.rb +90 -0
- data/examples/v2/simple-swarm-v2.yml +62 -0
- data/examples/v2/swarm.yml +71 -0
- data/examples/v2/swarm_with_hooks.yml +61 -0
- data/examples/v2/swarm_with_hooks_simple.yml +25 -0
- data/examples/v2/think_tool_demo.rb +62 -0
- data/exe/swarm +6 -0
- data/lib/claude_swarm/claude_mcp_server.rb +0 -6
- data/lib/claude_swarm/cli.rb +10 -3
- data/lib/claude_swarm/commands/ps.rb +19 -20
- data/lib/claude_swarm/commands/show.rb +1 -1
- data/lib/claude_swarm/configuration.rb +10 -12
- data/lib/claude_swarm/mcp_generator.rb +10 -1
- data/lib/claude_swarm/orchestrator.rb +73 -49
- data/lib/claude_swarm/system_utils.rb +37 -11
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +1 -0
- data/lib/claude_swarm/yaml_loader.rb +22 -0
- data/lib/claude_swarm.rb +6 -2
- data/lib/swarm_cli/cli.rb +201 -0
- data/lib/swarm_cli/command_registry.rb +61 -0
- data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
- data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
- data/lib/swarm_cli/commands/migrate.rb +55 -0
- data/lib/swarm_cli/commands/run.rb +173 -0
- data/lib/swarm_cli/config_loader.rb +97 -0
- data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
- data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
- data/lib/swarm_cli/interactive_repl.rb +918 -0
- data/lib/swarm_cli/mcp_serve_options.rb +44 -0
- data/lib/swarm_cli/mcp_tools_options.rb +59 -0
- data/lib/swarm_cli/migrate_options.rb +54 -0
- data/lib/swarm_cli/migrator.rb +132 -0
- data/lib/swarm_cli/options.rb +151 -0
- data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
- data/lib/swarm_cli/ui/components/content_block.rb +120 -0
- data/lib/swarm_cli/ui/components/divider.rb +57 -0
- data/lib/swarm_cli/ui/components/panel.rb +62 -0
- data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
- data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
- data/lib/swarm_cli/ui/formatters/number.rb +58 -0
- data/lib/swarm_cli/ui/formatters/text.rb +77 -0
- data/lib/swarm_cli/ui/formatters/time.rb +73 -0
- data/lib/swarm_cli/ui/icons.rb +59 -0
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
- data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
- data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
- data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
- data/lib/swarm_cli/version.rb +5 -0
- data/lib/swarm_cli.rb +44 -0
- data/lib/swarm_memory/adapters/base.rb +141 -0
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +845 -0
- data/lib/swarm_memory/chat_extension.rb +34 -0
- data/lib/swarm_memory/cli/commands.rb +306 -0
- data/lib/swarm_memory/core/entry.rb +37 -0
- data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
- data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
- data/lib/swarm_memory/core/path_normalizer.rb +75 -0
- data/lib/swarm_memory/core/semantic_index.rb +244 -0
- data/lib/swarm_memory/core/storage.rb +288 -0
- data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
- data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
- data/lib/swarm_memory/dsl/memory_config.rb +113 -0
- data/lib/swarm_memory/embeddings/embedder.rb +36 -0
- data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
- data/lib/swarm_memory/errors.rb +21 -0
- data/lib/swarm_memory/integration/cli_registration.rb +30 -0
- data/lib/swarm_memory/integration/configuration.rb +43 -0
- data/lib/swarm_memory/integration/registration.rb +31 -0
- data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
- data/lib/swarm_memory/optimization/analyzer.rb +244 -0
- data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
- data/lib/swarm_memory/prompts/memory.md.erb +109 -0
- data/lib/swarm_memory/prompts/memory_assistant.md.erb +181 -0
- data/lib/swarm_memory/prompts/memory_researcher.md.erb +281 -0
- data/lib/swarm_memory/prompts/memory_retrieval.md.erb +78 -0
- data/lib/swarm_memory/search/semantic_search.rb +112 -0
- data/lib/swarm_memory/search/text_search.rb +42 -0
- data/lib/swarm_memory/search/text_similarity.rb +80 -0
- data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
- data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
- data/lib/swarm_memory/tools/load_skill.rb +313 -0
- data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
- data/lib/swarm_memory/tools/memory_delete.rb +99 -0
- data/lib/swarm_memory/tools/memory_edit.rb +185 -0
- data/lib/swarm_memory/tools/memory_glob.rb +160 -0
- data/lib/swarm_memory/tools/memory_grep.rb +247 -0
- data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
- data/lib/swarm_memory/tools/memory_read.rb +123 -0
- data/lib/swarm_memory/tools/memory_write.rb +231 -0
- data/lib/swarm_memory/utils.rb +50 -0
- data/lib/swarm_memory/version.rb +5 -0
- data/lib/swarm_memory.rb +166 -0
- data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
- data/lib/swarm_sdk/agent/builder.rb +461 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
- data/lib/swarm_sdk/agent/chat.rb +1159 -0
- data/lib/swarm_sdk/agent/context.rb +112 -0
- data/lib/swarm_sdk/agent/context_manager.rb +309 -0
- data/lib/swarm_sdk/agent/definition.rb +556 -0
- data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
- data/lib/swarm_sdk/configuration.rb +296 -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 +197 -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 +147 -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 +51 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +75 -0
- data/lib/swarm_sdk/model_aliases.json +5 -0
- data/lib/swarm_sdk/models.json +1 -0
- data/lib/swarm_sdk/models.rb +120 -0
- data/lib/swarm_sdk/node/agent_config.rb +49 -0
- data/lib/swarm_sdk/node/builder.rb +439 -0
- data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
- data/lib/swarm_sdk/node_context.rb +170 -0
- data/lib/swarm_sdk/node_orchestrator.rb +384 -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/plugin.rb +147 -0
- data/lib/swarm_sdk/plugin_registry.rb +101 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -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 +334 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
- data/lib/swarm_sdk/swarm/builder.rb +586 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +419 -0
- data/lib/swarm_sdk/swarm.rb +982 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/clock.rb +44 -0
- data/lib/swarm_sdk/tools/delegate.rb +164 -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/html_converter.rb +101 -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 +228 -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 +93 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
- data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/think.rb +95 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +261 -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 +157 -0
- data/llm.v2.txt +13407 -0
- data/rubocop/cop/security/no_reflection_methods.rb +47 -0
- data/rubocop/cop/security/no_ruby_llm_logger.rb +32 -0
- data/swarm_cli.gemspec +57 -0
- data/swarm_memory.gemspec +28 -0
- data/swarm_sdk.gemspec +41 -0
- data/team.yml +1 -1
- data/team_full.yml +1875 -0
- data/{team_v2.yml → team_sdk.yml} +121 -52
- metadata +247 -4
- data/EXAMPLES.md +0 -164
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmSDK
|
|
4
|
+
# NodeOrchestrator executes a multi-node workflow
|
|
5
|
+
#
|
|
6
|
+
# Each node represents a mini-swarm execution stage. The orchestrator:
|
|
7
|
+
# - Builds execution order from node dependencies (topological sort)
|
|
8
|
+
# - Creates a separate swarm instance for each node
|
|
9
|
+
# - Passes output from one node as input to dependent nodes
|
|
10
|
+
# - Supports input/output transformers for data flow customization
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# orchestrator = NodeOrchestrator.new(
|
|
14
|
+
# swarm_name: "Dev Team",
|
|
15
|
+
# agent_definitions: { backend: def1, tester: def2 },
|
|
16
|
+
# nodes: { planning: node1, implementation: node2 },
|
|
17
|
+
# start_node: :planning
|
|
18
|
+
# )
|
|
19
|
+
# result = orchestrator.execute("Build auth system")
|
|
20
|
+
class NodeOrchestrator
|
|
21
|
+
attr_reader :swarm_name, :nodes, :start_node
|
|
22
|
+
|
|
23
|
+
def initialize(swarm_name:, agent_definitions:, nodes:, start_node:)
|
|
24
|
+
@swarm_name = swarm_name
|
|
25
|
+
@agent_definitions = agent_definitions
|
|
26
|
+
@nodes = nodes
|
|
27
|
+
@start_node = start_node
|
|
28
|
+
|
|
29
|
+
validate!
|
|
30
|
+
@execution_order = build_execution_order
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Execute the node workflow
|
|
34
|
+
#
|
|
35
|
+
# Executes nodes in topological order, passing output from each node
|
|
36
|
+
# to its dependents. Supports streaming logs if block given.
|
|
37
|
+
#
|
|
38
|
+
# @param prompt [String] Initial prompt for the workflow
|
|
39
|
+
# @yield [Hash] Log entry if block given (for streaming)
|
|
40
|
+
# @return [Result] Final result from last node execution
|
|
41
|
+
def execute(prompt, &block)
|
|
42
|
+
logs = []
|
|
43
|
+
current_input = prompt
|
|
44
|
+
results = {}
|
|
45
|
+
@original_prompt = prompt # Store original prompt for NodeContext
|
|
46
|
+
|
|
47
|
+
# Setup logging if block given
|
|
48
|
+
if block_given?
|
|
49
|
+
# Register callback to collect logs and forward to user's block
|
|
50
|
+
LogCollector.on_log do |entry|
|
|
51
|
+
logs << entry
|
|
52
|
+
block.call(entry)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Set LogStream to use LogCollector as emitter
|
|
56
|
+
LogStream.emitter = LogCollector
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@execution_order.each do |node_name|
|
|
60
|
+
node = @nodes[node_name]
|
|
61
|
+
node_start_time = Time.now
|
|
62
|
+
|
|
63
|
+
# Emit node_start event
|
|
64
|
+
emit_node_start(node_name, node)
|
|
65
|
+
|
|
66
|
+
# Transform input if node has transformer (Ruby block or bash command)
|
|
67
|
+
skip_execution = false
|
|
68
|
+
skip_content = nil
|
|
69
|
+
|
|
70
|
+
if node.has_input_transformer?
|
|
71
|
+
# Build NodeContext based on dependencies
|
|
72
|
+
#
|
|
73
|
+
# For single dependency: previous_result has original Result metadata,
|
|
74
|
+
# transformed_content has output from previous transformer
|
|
75
|
+
# For multiple dependencies: previous_result is hash of Results
|
|
76
|
+
# For no dependencies: previous_result is initial prompt string
|
|
77
|
+
previous_result = if node.dependencies.size > 1
|
|
78
|
+
# Multiple dependencies: pass hash of original results
|
|
79
|
+
node.dependencies.to_h { |dep| [dep, results[dep]] }
|
|
80
|
+
elsif node.dependencies.size == 1
|
|
81
|
+
# Single dependency: pass the original result
|
|
82
|
+
results[node.dependencies.first]
|
|
83
|
+
else
|
|
84
|
+
# No dependencies: initial prompt
|
|
85
|
+
current_input
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Create NodeContext for input transformer
|
|
89
|
+
input_context = NodeContext.for_input(
|
|
90
|
+
previous_result: previous_result,
|
|
91
|
+
all_results: results,
|
|
92
|
+
original_prompt: @original_prompt,
|
|
93
|
+
node_name: node_name,
|
|
94
|
+
dependencies: node.dependencies,
|
|
95
|
+
transformed_content: node.dependencies.size == 1 ? current_input : nil,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Apply input transformer (passes current_input for bash command fallback)
|
|
99
|
+
# Bash transformer exit codes:
|
|
100
|
+
# - Exit 0: Use STDOUT as transformed content
|
|
101
|
+
# - Exit 1: Skip node execution, use current_input unchanged (STDOUT ignored)
|
|
102
|
+
# - Exit 2: Halt workflow with error from STDERR (STDOUT ignored)
|
|
103
|
+
transformed = node.transform_input(input_context, current_input: current_input)
|
|
104
|
+
|
|
105
|
+
# Check if transformer requested skipping execution
|
|
106
|
+
# (from Ruby block returning hash OR bash command exit 1)
|
|
107
|
+
if transformed.is_a?(Hash) && transformed[:skip_execution]
|
|
108
|
+
skip_execution = true
|
|
109
|
+
skip_content = transformed[:content] || transformed["content"]
|
|
110
|
+
else
|
|
111
|
+
current_input = transformed
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Execute node (or skip if requested)
|
|
116
|
+
if skip_execution
|
|
117
|
+
# Skip execution: return result immediately with provided content
|
|
118
|
+
result = Result.new(
|
|
119
|
+
content: skip_content,
|
|
120
|
+
agent: "skipped:#{node_name}",
|
|
121
|
+
logs: [],
|
|
122
|
+
duration: 0.0,
|
|
123
|
+
)
|
|
124
|
+
elsif node.agent_less?
|
|
125
|
+
# Agent-less node: run pure computation without LLM
|
|
126
|
+
result = execute_agent_less_node(node, current_input)
|
|
127
|
+
else
|
|
128
|
+
# Normal node: build mini-swarm and execute with LLM
|
|
129
|
+
# NOTE: Don't pass block to mini-swarm - LogCollector already captures all logs
|
|
130
|
+
mini_swarm = build_swarm_for_node(node)
|
|
131
|
+
result = mini_swarm.execute(current_input)
|
|
132
|
+
|
|
133
|
+
# If result has error, log it with backtrace
|
|
134
|
+
if result.error
|
|
135
|
+
RubyLLM.logger.error("NodeOrchestrator: Node '#{node_name}' failed: #{result.error.message}")
|
|
136
|
+
RubyLLM.logger.error(" Backtrace: #{result.error.backtrace&.first(5)&.join("\n ")}")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
results[node_name] = result
|
|
141
|
+
|
|
142
|
+
# Transform output for next node using NodeContext
|
|
143
|
+
output_context = NodeContext.for_output(
|
|
144
|
+
result: result,
|
|
145
|
+
all_results: results,
|
|
146
|
+
original_prompt: @original_prompt,
|
|
147
|
+
node_name: node_name,
|
|
148
|
+
)
|
|
149
|
+
current_input = node.transform_output(output_context)
|
|
150
|
+
|
|
151
|
+
# For agent-less nodes, update the result with transformed content
|
|
152
|
+
# This ensures all_results contains the actual output, not the input
|
|
153
|
+
if node.agent_less? && current_input != result.content
|
|
154
|
+
results[node_name] = Result.new(
|
|
155
|
+
content: current_input,
|
|
156
|
+
agent: result.agent,
|
|
157
|
+
logs: result.logs,
|
|
158
|
+
duration: result.duration,
|
|
159
|
+
error: result.error,
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Emit node_stop event
|
|
164
|
+
node_duration = Time.now - node_start_time
|
|
165
|
+
emit_node_stop(node_name, node, result, node_duration, skip_execution)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
results.values.last
|
|
169
|
+
ensure
|
|
170
|
+
# Reset logging state for next execution
|
|
171
|
+
LogCollector.reset!
|
|
172
|
+
LogStream.reset!
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# Emit node_start event
|
|
178
|
+
#
|
|
179
|
+
# @param node_name [Symbol] Name of the node
|
|
180
|
+
# @param node [Node::Builder] Node configuration
|
|
181
|
+
# @return [void]
|
|
182
|
+
def emit_node_start(node_name, node)
|
|
183
|
+
return unless LogStream.emitter
|
|
184
|
+
|
|
185
|
+
LogStream.emit(
|
|
186
|
+
type: "node_start",
|
|
187
|
+
node: node_name.to_s,
|
|
188
|
+
agent_less: node.agent_less?,
|
|
189
|
+
agents: node.agent_configs.map { |ac| ac[:agent].to_s },
|
|
190
|
+
dependencies: node.dependencies.map(&:to_s),
|
|
191
|
+
timestamp: Time.now.utc.iso8601,
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Emit node_stop event
|
|
196
|
+
#
|
|
197
|
+
# @param node_name [Symbol] Name of the node
|
|
198
|
+
# @param node [Node::Builder] Node configuration
|
|
199
|
+
# @param result [Result] Node execution result
|
|
200
|
+
# @param duration [Float] Node execution duration in seconds
|
|
201
|
+
# @param skipped [Boolean] Whether execution was skipped
|
|
202
|
+
# @return [void]
|
|
203
|
+
def emit_node_stop(node_name, node, result, duration, skipped)
|
|
204
|
+
return unless LogStream.emitter
|
|
205
|
+
|
|
206
|
+
LogStream.emit(
|
|
207
|
+
type: "node_stop",
|
|
208
|
+
node: node_name.to_s,
|
|
209
|
+
agent_less: node.agent_less?,
|
|
210
|
+
skipped: skipped,
|
|
211
|
+
agents: node.agent_configs.map { |ac| ac[:agent].to_s },
|
|
212
|
+
duration: duration.round(3),
|
|
213
|
+
timestamp: Time.now.utc.iso8601,
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Execute an agent-less (computation-only) node
|
|
218
|
+
#
|
|
219
|
+
# Agent-less nodes run pure Ruby code without LLM execution.
|
|
220
|
+
# Creates a minimal Result object with the transformed content.
|
|
221
|
+
#
|
|
222
|
+
# @param node [Node::Builder] Agent-less node configuration
|
|
223
|
+
# @param input [String] Input content
|
|
224
|
+
# @return [Result] Result with transformed content
|
|
225
|
+
def execute_agent_less_node(node, input)
|
|
226
|
+
# For agent-less nodes, the "content" is just the input passed through
|
|
227
|
+
# The output transformer will do the actual work
|
|
228
|
+
Result.new(
|
|
229
|
+
content: input,
|
|
230
|
+
agent: "computation:#{node.name}",
|
|
231
|
+
logs: [],
|
|
232
|
+
duration: 0.0,
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Validate orchestrator configuration
|
|
237
|
+
#
|
|
238
|
+
# @return [void]
|
|
239
|
+
# @raise [ConfigurationError] If configuration is invalid
|
|
240
|
+
def validate!
|
|
241
|
+
# Validate start_node exists
|
|
242
|
+
unless @nodes.key?(@start_node)
|
|
243
|
+
raise ConfigurationError,
|
|
244
|
+
"start_node '#{@start_node}' not found. Available nodes: #{@nodes.keys.join(", ")}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Validate all nodes
|
|
248
|
+
@nodes.each_value(&:validate!)
|
|
249
|
+
|
|
250
|
+
# Validate node dependencies reference existing nodes
|
|
251
|
+
@nodes.each do |node_name, node|
|
|
252
|
+
node.dependencies.each do |dep|
|
|
253
|
+
unless @nodes.key?(dep)
|
|
254
|
+
raise ConfigurationError,
|
|
255
|
+
"Node '#{node_name}' depends on unknown node '#{dep}'"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Validate all agents referenced in nodes exist (skip agent-less nodes)
|
|
261
|
+
@nodes.each do |node_name, node|
|
|
262
|
+
next if node.agent_less? # Skip validation for agent-less nodes
|
|
263
|
+
|
|
264
|
+
node.agent_configs.each do |config|
|
|
265
|
+
agent_name = config[:agent]
|
|
266
|
+
unless @agent_definitions.key?(agent_name)
|
|
267
|
+
raise ConfigurationError,
|
|
268
|
+
"Node '#{node_name}' references undefined agent '#{agent_name}'"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Validate delegation targets exist
|
|
272
|
+
config[:delegates_to].each do |delegate|
|
|
273
|
+
unless @agent_definitions.key?(delegate)
|
|
274
|
+
raise ConfigurationError,
|
|
275
|
+
"Node '#{node_name}' agent '#{agent_name}' delegates to undefined agent '#{delegate}'"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Build a swarm instance for a specific node
|
|
283
|
+
#
|
|
284
|
+
# Creates a new Swarm with only the agents specified in the node,
|
|
285
|
+
# configured with the node's delegation topology.
|
|
286
|
+
#
|
|
287
|
+
# @param node [Node::Builder] Node configuration
|
|
288
|
+
# @return [Swarm] Configured swarm instance
|
|
289
|
+
def build_swarm_for_node(node)
|
|
290
|
+
swarm = Swarm.new(name: "#{@swarm_name}:#{node.name}")
|
|
291
|
+
|
|
292
|
+
# Add each agent specified in this node
|
|
293
|
+
node.agent_configs.each do |config|
|
|
294
|
+
agent_name = config[:agent]
|
|
295
|
+
delegates_to = config[:delegates_to]
|
|
296
|
+
|
|
297
|
+
# Get global agent definition
|
|
298
|
+
agent_def = @agent_definitions[agent_name]
|
|
299
|
+
|
|
300
|
+
# Clone definition with node-specific delegation
|
|
301
|
+
node_specific_def = clone_with_delegation(agent_def, delegates_to)
|
|
302
|
+
|
|
303
|
+
swarm.add_agent(node_specific_def)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Set lead agent
|
|
307
|
+
swarm.lead = node.lead_agent
|
|
308
|
+
|
|
309
|
+
swarm
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Clone an agent definition with different delegates_to
|
|
313
|
+
#
|
|
314
|
+
# @param agent_def [Agent::Definition] Original definition
|
|
315
|
+
# @param delegates_to [Array<Symbol>] New delegation targets
|
|
316
|
+
# @return [Agent::Definition] Cloned definition
|
|
317
|
+
def clone_with_delegation(agent_def, delegates_to)
|
|
318
|
+
config = agent_def.to_h
|
|
319
|
+
config[:delegates_to] = delegates_to
|
|
320
|
+
Agent::Definition.new(agent_def.name, config)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Build execution order using topological sort (Kahn's algorithm)
|
|
324
|
+
#
|
|
325
|
+
# Processes all nodes in dependency order, starting from start_node.
|
|
326
|
+
# Ensures all nodes are reachable from start_node.
|
|
327
|
+
#
|
|
328
|
+
# @return [Array<Symbol>] Ordered list of node names
|
|
329
|
+
# @raise [CircularDependencyError] If circular dependency detected
|
|
330
|
+
def build_execution_order
|
|
331
|
+
# Build in-degree map and adjacency list
|
|
332
|
+
in_degree = {}
|
|
333
|
+
adjacency = Hash.new { |h, k| h[k] = [] }
|
|
334
|
+
|
|
335
|
+
@nodes.each do |node_name, node|
|
|
336
|
+
in_degree[node_name] = node.dependencies.size
|
|
337
|
+
node.dependencies.each do |dep|
|
|
338
|
+
adjacency[dep] << node_name
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Start with nodes that have no dependencies
|
|
343
|
+
queue = in_degree.select { |_, degree| degree == 0 }.keys
|
|
344
|
+
order = []
|
|
345
|
+
|
|
346
|
+
while queue.any?
|
|
347
|
+
# Process nodes with all dependencies satisfied
|
|
348
|
+
node_name = queue.shift
|
|
349
|
+
order << node_name
|
|
350
|
+
|
|
351
|
+
# Reduce in-degree for dependent nodes
|
|
352
|
+
adjacency[node_name].each do |dependent|
|
|
353
|
+
in_degree[dependent] -= 1
|
|
354
|
+
queue << dependent if in_degree[dependent] == 0
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Check for circular dependencies
|
|
359
|
+
if order.size < @nodes.size
|
|
360
|
+
unprocessed = @nodes.keys - order
|
|
361
|
+
raise CircularDependencyError,
|
|
362
|
+
"Circular dependency detected. Unprocessed nodes: #{unprocessed.join(", ")}"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Verify start_node is in the execution order
|
|
366
|
+
unless order.include?(@start_node)
|
|
367
|
+
raise ConfigurationError,
|
|
368
|
+
"start_node '#{@start_node}' is not reachable in the dependency graph"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Verify start_node is actually first (or rearrange to make it first)
|
|
372
|
+
# This ensures we start from the declared start_node
|
|
373
|
+
start_index = order.index(@start_node)
|
|
374
|
+
if start_index && start_index > 0
|
|
375
|
+
# start_node has dependencies - this violates the assumption
|
|
376
|
+
raise ConfigurationError,
|
|
377
|
+
"start_node '#{@start_node}' has dependencies: #{@nodes[@start_node].dependencies.join(", ")}. " \
|
|
378
|
+
"start_node must have no dependencies."
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
order
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
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
|