woods 1.6.4 → 2.0.0.beta1
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 +1879 -37
- data/CONTRIBUTING.md +195 -137
- data/README.md +162 -520
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +620 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +415 -0
- data/docs/INTERNALS.md +415 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +197 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +39 -3
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +78 -24
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +40 -1
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +18 -13
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/credential_index.rb +5 -53
- data/lib/woods/console/credential_scanner.rb +15 -16
- data/lib/woods/console/dispatch_pipeline.rb +46 -34
- data/lib/woods/console/embedded_executor.rb +806 -257
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +24 -6
- data/lib/woods/console/rack_middleware.rb +62 -63
- data/lib/woods/console/redactor.rb +10 -24
- data/lib/woods/console/safe_context.rb +45 -45
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +136 -267
- data/lib/woods/console/sql_noise_stripper.rb +20 -51
- data/lib/woods/console/sql_table_scanner.rb +39 -90
- data/lib/woods/console/sql_validator.rb +455 -85
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +462 -88
- data/lib/woods/console/tools/tier1.rb +0 -3
- data/lib/woods/console/tools/tier4.rb +17 -7
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2503 -192
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +63 -21
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +138 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +408 -34
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +92 -22
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +108 -23
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +907 -154
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +3 -9
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +223 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +70 -38
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +25 -6
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
- data/lib/woods/session_tracer/session_flow_document.rb +52 -6
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +53 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +102 -30
- data/lib/woods/console/adapter_family.rb +0 -39
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/console/credential_scanner_registry.rb +0 -36
- data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
- data/lib/woods/console/sql_output_policy.rb +0 -535
- data/lib/woods/console/sqlite_read_guard.rb +0 -46
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/mcp/http_transport_options.rb +0 -15
- data/lib/woods/mcp/origin_policy.rb +0 -113
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'json'
|
|
4
|
-
require 'open3'
|
|
5
3
|
require 'shellwords'
|
|
6
4
|
|
|
7
5
|
# @see Woods
|
|
@@ -11,162 +9,79 @@ module Woods
|
|
|
11
9
|
module Console
|
|
12
10
|
class ConnectionError < Woods::Error; end
|
|
13
11
|
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# Spawns and manages the bridge process, sends JSON-lines requests,
|
|
17
|
-
# receives responses. Implements heartbeat (30s) and reconnect with
|
|
18
|
-
# exponential backoff (max 5 retries).
|
|
19
|
-
#
|
|
20
|
-
# @example
|
|
21
|
-
# manager = ConnectionManager.new(config: {
|
|
22
|
-
# 'mode' => 'direct',
|
|
23
|
-
# 'command' => 'bundle exec rails runner bridge.rb'
|
|
24
|
-
# })
|
|
25
|
-
# manager.connect!
|
|
26
|
-
# response = manager.send_request({ 'id' => 'r1', 'tool' => 'status', 'params' => {} })
|
|
27
|
-
# manager.disconnect!
|
|
12
|
+
# Resolves and launches an embedded Console MCP process.
|
|
28
13
|
#
|
|
14
|
+
# The former implementation spoke a private JSON-lines bridge protocol to
|
|
15
|
+
# a scaffold that returned static data. The supported path now forwards the
|
|
16
|
+
# MCP client's stdio directly to the real embedded server by replacing this
|
|
17
|
+
# process with a direct, Docker, or SSH command.
|
|
29
18
|
class ConnectionManager
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
# @
|
|
34
|
-
# @option config [String] '
|
|
35
|
-
# @option config [String] '
|
|
36
|
-
# @option config [String] 'container'
|
|
37
|
-
# @option config [String] '
|
|
38
|
-
# @option config [String] '
|
|
39
|
-
# @option config [String] 'user' SSH user (ssh mode)
|
|
19
|
+
DEFAULT_COMMAND = 'bundle exec rake woods:console'
|
|
20
|
+
|
|
21
|
+
# @param config [Hash] Process-launch configuration
|
|
22
|
+
# @option config [String] 'mode' direct, docker, or ssh (default: direct)
|
|
23
|
+
# @option config [String] 'command' Embedded server command
|
|
24
|
+
# @option config [String] 'directory' Working directory for direct mode
|
|
25
|
+
# @option config [String] 'container' Container name for docker mode
|
|
26
|
+
# @option config [String] 'host' SSH host for ssh mode
|
|
27
|
+
# @option config [String] 'user' Optional SSH user
|
|
40
28
|
def initialize(config:)
|
|
41
29
|
@config = config
|
|
42
|
-
@mode = config
|
|
43
|
-
@
|
|
44
|
-
@stdin = nil
|
|
45
|
-
@stdout = nil
|
|
46
|
-
@wait_thread = nil
|
|
47
|
-
@retries = 0
|
|
48
|
-
@last_heartbeat = nil
|
|
30
|
+
@mode = config.fetch('mode', 'direct')
|
|
31
|
+
@embedded_command = config.fetch('command', DEFAULT_COMMAND)
|
|
49
32
|
end
|
|
50
33
|
|
|
51
|
-
#
|
|
34
|
+
# Return the argv used to launch the embedded MCP server.
|
|
52
35
|
#
|
|
53
|
-
# @return [
|
|
54
|
-
# @raise [ConnectionError]
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
else
|
|
62
|
-
@stdin, @stdout, @wait_thread = Open3.popen2(*cmd)
|
|
36
|
+
# @return [Array<String>]
|
|
37
|
+
# @raise [ConnectionError] when the mode is invalid or incomplete
|
|
38
|
+
def command
|
|
39
|
+
case @mode
|
|
40
|
+
when 'direct' then embedded_argv
|
|
41
|
+
when 'docker' then docker_command
|
|
42
|
+
when 'ssh' then ssh_command
|
|
43
|
+
else raise ConnectionError, "Unknown connection mode: #{@mode}"
|
|
63
44
|
end
|
|
64
|
-
@last_heartbeat = Time.now
|
|
65
|
-
@retries = 0
|
|
66
|
-
rescue StandardError => e
|
|
67
|
-
raise ConnectionError, "Failed to connect (#{@mode}): #{e.message}"
|
|
68
45
|
end
|
|
69
46
|
|
|
70
|
-
#
|
|
47
|
+
# Replace the current process with the embedded MCP server.
|
|
71
48
|
#
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@stdout&.close
|
|
76
|
-
@wait_thread&.value
|
|
77
|
-
@stdin = nil
|
|
78
|
-
@stdout = nil
|
|
79
|
-
@wait_thread = nil
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Send a request to the bridge and read the response.
|
|
83
|
-
#
|
|
84
|
-
# @param request [Hash] JSON-serializable request hash
|
|
85
|
-
# @return [Hash] Parsed response hash
|
|
86
|
-
# @raise [ConnectionError] if communication fails after retries
|
|
87
|
-
def send_request(request)
|
|
88
|
-
ensure_connected!
|
|
89
|
-
@stdin.puts(JSON.generate(request))
|
|
90
|
-
@stdin.flush
|
|
91
|
-
line = @stdout.gets
|
|
92
|
-
raise ConnectionError, 'Bridge process closed unexpectedly' unless line
|
|
93
|
-
|
|
94
|
-
@last_heartbeat = Time.now
|
|
95
|
-
JSON.parse(line)
|
|
96
|
-
rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
|
|
97
|
-
reconnect_or_raise!(e)
|
|
98
|
-
retry
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Check if the bridge process is alive.
|
|
49
|
+
# Process replacement gives the MCP client direct ownership of lifecycle:
|
|
50
|
+
# EOF, INT, and TERM reach the real server without an intermediate process
|
|
51
|
+
# that can hang while waiting for a child.
|
|
102
52
|
#
|
|
103
|
-
# @return [
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def heartbeat_needed?
|
|
114
|
-
return false unless @last_heartbeat
|
|
115
|
-
|
|
116
|
-
(Time.now - @last_heartbeat) >= HEARTBEAT_INTERVAL
|
|
53
|
+
# @return [void]
|
|
54
|
+
# @raise [ConnectionError] when the process cannot be launched
|
|
55
|
+
def replace_process!
|
|
56
|
+
if @mode == 'direct' && @config['directory']
|
|
57
|
+
Dir.chdir(@config['directory']) { exec(*command) }
|
|
58
|
+
else
|
|
59
|
+
exec(*command)
|
|
60
|
+
end
|
|
61
|
+
rescue SystemCallError, ArgumentError => e
|
|
62
|
+
raise ConnectionError, "Failed to launch embedded Console MCP (#{@mode}): #{e.message}"
|
|
117
63
|
end
|
|
118
64
|
|
|
119
65
|
private
|
|
120
66
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
when 'direct' then build_direct_command
|
|
129
|
-
else raise ConnectionError, "Unknown connection mode: #{@mode}"
|
|
130
|
-
end
|
|
67
|
+
def embedded_argv
|
|
68
|
+
argv = @embedded_command.to_s.shellsplit
|
|
69
|
+
raise ConnectionError, 'Console command must not be empty' if argv.empty?
|
|
70
|
+
|
|
71
|
+
argv
|
|
72
|
+
rescue ArgumentError => e
|
|
73
|
+
raise ConnectionError, "Invalid console command: #{e.message}"
|
|
131
74
|
end
|
|
132
75
|
|
|
133
|
-
def
|
|
76
|
+
def docker_command
|
|
134
77
|
container = @config['container'] || raise(ConnectionError, 'Docker mode requires container name')
|
|
135
|
-
['docker', 'exec', '-i', container] +
|
|
78
|
+
['docker', 'exec', '-i', container] + embedded_argv
|
|
136
79
|
end
|
|
137
80
|
|
|
138
|
-
def
|
|
81
|
+
def ssh_command
|
|
139
82
|
host = @config['host'] || raise(ConnectionError, 'SSH mode requires host')
|
|
140
|
-
|
|
141
|
-
target
|
|
142
|
-
['ssh', target] + @command.shellsplit
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def build_direct_command
|
|
146
|
-
@command.shellsplit
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# Ensure the connection is active.
|
|
150
|
-
def ensure_connected!
|
|
151
|
-
return if alive?
|
|
152
|
-
|
|
153
|
-
connect!
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Attempt reconnection with exponential backoff.
|
|
157
|
-
#
|
|
158
|
-
# @param error [StandardError] The original error
|
|
159
|
-
# @raise [ConnectionError] if max retries exceeded
|
|
160
|
-
def reconnect_or_raise!(error)
|
|
161
|
-
@retries += 1
|
|
162
|
-
if @retries > MAX_RETRIES
|
|
163
|
-
raise ConnectionError,
|
|
164
|
-
"Connection failed after #{MAX_RETRIES} retries: #{error.message}"
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
sleep((2**(@retries - 1)) * 0.1)
|
|
168
|
-
disconnect!
|
|
169
|
-
connect!
|
|
83
|
+
target = @config['user'] ? "#{@config['user']}@#{host}" : host
|
|
84
|
+
['ssh', target] + embedded_argv
|
|
170
85
|
end
|
|
171
86
|
end
|
|
172
87
|
end
|
|
@@ -46,7 +46,7 @@ module Woods
|
|
|
46
46
|
# index.redact("token: sk_live_actual_secret_value")
|
|
47
47
|
# # => "token: [REDACTED:credential]"
|
|
48
48
|
#
|
|
49
|
-
class CredentialIndex
|
|
49
|
+
class CredentialIndex
|
|
50
50
|
# Captured at require time so the mtime-check warning has a stable
|
|
51
51
|
# reference point even if the clock skews later. Frozen immediately
|
|
52
52
|
# to prevent accidental mutation.
|
|
@@ -68,7 +68,6 @@ module Woods
|
|
|
68
68
|
MISSING_KEY_ERRORS = %w[
|
|
69
69
|
ActiveSupport::EncryptedConfiguration::MissingKeyError
|
|
70
70
|
ActiveSupport::EncryptedFile::MissingKeyError
|
|
71
|
-
ActiveSupport::EncryptedFile::MissingContentError
|
|
72
71
|
ActiveSupport::MessageEncryptor::InvalidMessage
|
|
73
72
|
].freeze
|
|
74
73
|
|
|
@@ -91,19 +90,13 @@ module Woods
|
|
|
91
90
|
# @return [CredentialIndex] populated index, or an empty index when
|
|
92
91
|
# the credentials store can't be opened.
|
|
93
92
|
def build(rails_app:)
|
|
94
|
-
|
|
93
|
+
new(secrets: collect_secrets(rails_app))
|
|
95
94
|
rescue StandardError => e
|
|
96
95
|
raise unless missing_key_error?(e)
|
|
97
96
|
|
|
98
97
|
new(secrets: [])
|
|
99
98
|
end
|
|
100
99
|
|
|
101
|
-
# Strict refresh: construct the replacement completely before a caller
|
|
102
|
-
# installs it. Unlike boot-time .build, errors must preserve the old index.
|
|
103
|
-
def refresh(rails_app:)
|
|
104
|
-
new(secrets: collect_secrets(rails_app))
|
|
105
|
-
end
|
|
106
|
-
|
|
107
100
|
# Emit a structured log warning when any of the given credentials
|
|
108
101
|
# files has a modification time newer than `process_start`. This
|
|
109
102
|
# catches the common "rotated credentials but forgot to restart"
|
|
@@ -143,23 +136,12 @@ module Woods
|
|
|
143
136
|
private
|
|
144
137
|
|
|
145
138
|
def collect_secrets(rails_app)
|
|
146
|
-
config =
|
|
139
|
+
config = rails_app.credentials.config
|
|
147
140
|
collected = []
|
|
148
141
|
walk(config, collected)
|
|
149
142
|
collected
|
|
150
143
|
end
|
|
151
144
|
|
|
152
|
-
def fresh_credentials(credentials)
|
|
153
|
-
return credentials unless defined?(ActiveSupport::EncryptedConfiguration) &&
|
|
154
|
-
credentials.is_a?(ActiveSupport::EncryptedConfiguration)
|
|
155
|
-
|
|
156
|
-
require_relative 'encrypted_credential_snapshot'
|
|
157
|
-
EncryptedCredentialSnapshot.new(
|
|
158
|
-
config_path: credentials.content_path, key_path: credentials.key_path,
|
|
159
|
-
env_key: credentials.env_key, raise_if_missing_key: true
|
|
160
|
-
)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
145
|
def walk(node, collected)
|
|
164
146
|
case node
|
|
165
147
|
when Hash then node.each_value { |v| walk(v, collected) }
|
|
@@ -187,10 +169,7 @@ module Woods
|
|
|
187
169
|
def initialize(secrets:)
|
|
188
170
|
filtered = Array(secrets).select { |s| s.is_a?(String) && s.length >= MIN_LENGTH }
|
|
189
171
|
@secrets = filtered.to_set.freeze
|
|
190
|
-
|
|
191
|
-
# only the start of a longer indexed credential and expose its suffix.
|
|
192
|
-
longest_first = @secrets.each_with_index.sort_by { |secret, idx| [-secret.length, idx] }.map(&:first)
|
|
193
|
-
@pattern = @secrets.empty? ? nil : Regexp.union(longest_first)
|
|
172
|
+
@pattern = @secrets.empty? ? nil : Regexp.union(@secrets.to_a)
|
|
194
173
|
end
|
|
195
174
|
|
|
196
175
|
# @return [Boolean] true when no secrets were collected (missing key,
|
|
@@ -215,34 +194,7 @@ module Woods
|
|
|
215
194
|
def redact(str)
|
|
216
195
|
return str if empty? || !str.is_a?(String) || !@pattern.match?(str)
|
|
217
196
|
|
|
218
|
-
|
|
219
|
-
parts = []
|
|
220
|
-
redaction_spans(str).each do |start, finish|
|
|
221
|
-
parts << str[offset...start] << REDACTED
|
|
222
|
-
offset = finish
|
|
223
|
-
end
|
|
224
|
-
parts << str[offset..]
|
|
225
|
-
parts.join
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
private
|
|
229
|
-
|
|
230
|
-
# Search from each match's start, rather than its end, so an overlapping
|
|
231
|
-
# credential cannot expose its suffix after an earlier replacement.
|
|
232
|
-
# Union the covered spans before changing the original string.
|
|
233
|
-
def redaction_spans(str)
|
|
234
|
-
spans = []
|
|
235
|
-
offset = 0
|
|
236
|
-
while (match = @pattern.match(str, offset))
|
|
237
|
-
start, finish = match.offset(0)
|
|
238
|
-
if spans.last && start < spans.last[1]
|
|
239
|
-
spans.last[1] = [spans.last[1], finish].max
|
|
240
|
-
else
|
|
241
|
-
spans << [start, finish]
|
|
242
|
-
end
|
|
243
|
-
offset = start + 1
|
|
244
|
-
end
|
|
245
|
-
spans
|
|
197
|
+
str.gsub(@pattern, REDACTED)
|
|
246
198
|
end
|
|
247
199
|
end
|
|
248
200
|
end
|
|
@@ -145,7 +145,7 @@ module Woods
|
|
|
145
145
|
|
|
146
146
|
# Scan a value (String, Hash, Array, or any other object) for credentials.
|
|
147
147
|
#
|
|
148
|
-
# Strings
|
|
148
|
+
# Strings are gsub'd against every active pattern. Hash values and Array
|
|
149
149
|
# elements are walked recursively; keys and non-string scalars
|
|
150
150
|
# (Integer, Float, true/false, nil) pass through untouched.
|
|
151
151
|
#
|
|
@@ -155,18 +155,17 @@ module Woods
|
|
|
155
155
|
# for patterns that fired — callers should treat a missing key as zero.
|
|
156
156
|
def scan(value)
|
|
157
157
|
counts = {}
|
|
158
|
-
scanned = walk(value, counts
|
|
158
|
+
scanned = walk(value, counts)
|
|
159
159
|
[scanned, counts]
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
private
|
|
163
163
|
|
|
164
|
-
def walk(value, counts
|
|
164
|
+
def walk(value, counts)
|
|
165
165
|
case value
|
|
166
|
-
when String then scan_string(value, counts
|
|
167
|
-
when
|
|
168
|
-
when
|
|
169
|
-
when Array then value.map { |item| walk(item, counts, index) }
|
|
166
|
+
when String then scan_string(value, counts)
|
|
167
|
+
when Hash then walk_hash(value, counts)
|
|
168
|
+
when Array then value.map { |item| walk(item, counts) }
|
|
170
169
|
else value
|
|
171
170
|
end
|
|
172
171
|
end
|
|
@@ -178,19 +177,19 @@ module Woods
|
|
|
178
177
|
# credential-shaped string is emitted as a Symbol after redaction
|
|
179
178
|
# (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
|
|
180
179
|
# non-Symbol keys (Integer, etc.) pass through unchanged.
|
|
181
|
-
def walk_hash(hash, counts
|
|
180
|
+
def walk_hash(hash, counts)
|
|
182
181
|
hash.each_with_object({}) do |(key, val), out|
|
|
183
182
|
scanned_key = case key
|
|
184
|
-
when String then scan_string(key, counts
|
|
185
|
-
when Symbol then scan_string(key.to_s, counts
|
|
183
|
+
when String then scan_string(key, counts)
|
|
184
|
+
when Symbol then scan_string(key.to_s, counts).to_sym
|
|
186
185
|
else key
|
|
187
186
|
end
|
|
188
|
-
out[scanned_key] = walk(val, counts
|
|
187
|
+
out[scanned_key] = walk(val, counts)
|
|
189
188
|
end
|
|
190
189
|
end
|
|
191
190
|
|
|
192
|
-
def scan_string(str, counts
|
|
193
|
-
result = redact_indexed_secrets(str, counts
|
|
191
|
+
def scan_string(str, counts)
|
|
192
|
+
result = redact_indexed_secrets(str, counts)
|
|
194
193
|
result = scan_encoded_forms(result, counts)
|
|
195
194
|
@active_patterns.inject(result) do |acc, (name, pattern)|
|
|
196
195
|
acc.gsub(pattern) do
|
|
@@ -291,10 +290,10 @@ module Woods
|
|
|
291
290
|
printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
|
|
292
291
|
end
|
|
293
292
|
|
|
294
|
-
def redact_indexed_secrets(str, counts
|
|
295
|
-
return str unless
|
|
293
|
+
def redact_indexed_secrets(str, counts)
|
|
294
|
+
return str unless @secret_index&.match?(str)
|
|
296
295
|
|
|
297
|
-
redacted =
|
|
296
|
+
redacted = @secret_index.redact(str)
|
|
298
297
|
counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
|
|
299
298
|
redacted
|
|
300
299
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require_relative 'response_context'
|
|
5
|
+
require_relative 'input_contract'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Console
|
|
@@ -10,8 +11,7 @@ module Woods
|
|
|
10
11
|
# Encapsulates everything that happens between MCP's `define_tool` block
|
|
11
12
|
# firing and the `MCP::Tool::Response` returning to the client:
|
|
12
13
|
#
|
|
13
|
-
# 1.
|
|
14
|
-
# `"10"` for an `integer` property — we normalize before dispatch.
|
|
14
|
+
# 1. Strictly parse integer strings and enforce schema bounds.
|
|
15
15
|
# 2. Run the Layer 1 table gate ({ResponseContext#enforce!}).
|
|
16
16
|
# 3. Translate the tool args into a bridge/executor request via the
|
|
17
17
|
# handler proc supplied in the {ToolSpec}.
|
|
@@ -31,8 +31,7 @@ module Woods
|
|
|
31
31
|
class DispatchPipeline
|
|
32
32
|
# @param tool_name [String]
|
|
33
33
|
# @param handler [#call] Maps a Hash of tool args to a bridge request Hash.
|
|
34
|
-
# @param
|
|
35
|
-
# in the tool schema.
|
|
34
|
+
# @param properties [Hash] ToolSpec JSON Schema properties.
|
|
36
35
|
# @param conn_mgr [#send_request] ConnectionManager or EmbeddedExecutor.
|
|
37
36
|
# @param ctx [ResponseContext] Bundles the three response-safety layers.
|
|
38
37
|
# Defaults to the Null context so tests can stub minimally.
|
|
@@ -40,11 +39,11 @@ module Woods
|
|
|
40
39
|
# @param logger [#warn, nil] Structured logger used for credential-scan
|
|
41
40
|
# and table-gate telemetry. Failures are swallowed so observability
|
|
42
41
|
# issues never break a tool response.
|
|
43
|
-
def initialize(tool_name:, handler:,
|
|
42
|
+
def initialize(tool_name:, handler:, properties:, conn_mgr:, # rubocop:disable Metrics/ParameterLists
|
|
44
43
|
ctx: NullResponseContext.instance, renderer: nil, logger: nil)
|
|
45
44
|
@tool_name = tool_name
|
|
46
45
|
@handler = handler
|
|
47
|
-
@
|
|
46
|
+
@properties = properties
|
|
48
47
|
@conn_mgr = conn_mgr
|
|
49
48
|
@ctx = ctx
|
|
50
49
|
@renderer = renderer
|
|
@@ -56,52 +55,61 @@ module Woods
|
|
|
56
55
|
# @param args [Hash] Tool arguments (symbol keys from MCP).
|
|
57
56
|
# @return [MCP::Tool::Response]
|
|
58
57
|
def call(args)
|
|
59
|
-
|
|
58
|
+
InputContract.normalize!(args, @properties)
|
|
60
59
|
@ctx.enforce!(args)
|
|
61
60
|
request = @handler.call(args).transform_keys(&:to_s)
|
|
62
61
|
send_to_bridge(request)
|
|
63
62
|
rescue TableGateError => e
|
|
64
63
|
log_table_gate_rejection(args, e)
|
|
65
|
-
error_response(e.message)
|
|
66
|
-
rescue SqlValidationError, ForbiddenExpressionError => e
|
|
67
|
-
error_response(e.message)
|
|
64
|
+
error_response(scan_early_error(e.message))
|
|
65
|
+
rescue InputContract::ValidationError, SqlValidationError, ForbiddenExpressionError => e
|
|
66
|
+
error_response(scan_early_error(e.message))
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
# Catch-all for pipeline bugs — a renderer NoMethodError, an encoding
|
|
69
|
+
# oddity, a handler defect (L9). Without it the exception escaped into
|
|
70
|
+
# the `mcp` gem's own handling of a raising tool block, which can echo
|
|
71
|
+
# `Class: message` to the client: a leak channel the executor's
|
|
72
|
+
# responses are already sanitized against. Answer with the same
|
|
73
|
+
# sanitized shape the executor uses (class name only, details logged
|
|
74
|
+
# server-side), still routed through the credential scan so nothing an
|
|
75
|
+
# exception message picked up reaches the client either.
|
|
76
|
+
log_unexpected_error(e)
|
|
77
|
+
error_response(scan_early_error(sanitized_unexpected_error(e)))
|
|
68
78
|
end
|
|
69
79
|
|
|
70
80
|
private
|
|
71
81
|
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
# Client-safe stand-in for an unexpected exception. Names the tool so an
|
|
83
|
+
# agent can retry or report, and nothing else — the class and message
|
|
84
|
+
# go to the log instead.
|
|
85
|
+
def sanitized_unexpected_error(_error)
|
|
86
|
+
"Internal error in #{@tool_name} (details logged server-side)"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def log_unexpected_error(error)
|
|
90
|
+
return unless @logger
|
|
91
|
+
|
|
92
|
+
@logger.warn(
|
|
93
|
+
'console.dispatch.unexpected_error',
|
|
94
|
+
tool: @tool_name,
|
|
95
|
+
error_class: error.class.name,
|
|
96
|
+
message: error.message
|
|
97
|
+
)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
74
100
|
end
|
|
75
101
|
|
|
76
102
|
def send_to_bridge(request)
|
|
77
103
|
response = @conn_mgr.send_request(request)
|
|
78
104
|
return error_from_response(response, request) unless response['ok']
|
|
79
105
|
|
|
80
|
-
|
|
81
|
-
rescue ConnectionError => e
|
|
82
|
-
scanned = scan_for_credentials("Connection error: #{e.message}", request)
|
|
83
|
-
error_response(scanned)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def render_response(result, request)
|
|
87
|
-
# Remove protected values before custom serializers run, then scan
|
|
88
|
-
# the materialized representation consumed by both response renderers.
|
|
89
|
-
result = @ctx.redact(result)
|
|
90
|
-
result = JSON.parse(JSON.generate(result))
|
|
91
|
-
result = @ctx.redact(result)
|
|
106
|
+
result = @ctx.redact(response['result'])
|
|
92
107
|
result = scan_for_credentials(result, request)
|
|
93
108
|
text = @renderer ? @renderer.render_default(result) : JSON.pretty_generate(result)
|
|
94
109
|
success_response(text)
|
|
95
|
-
rescue
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
rendering_error_response(request)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def rendering_error_response(request)
|
|
102
|
-
error_response(scan_for_credentials('Console response could not be serialized safely.', request))
|
|
103
|
-
rescue StandardError
|
|
104
|
-
error_response('[REDACTION_FAILED]')
|
|
110
|
+
rescue ConnectionError => e
|
|
111
|
+
scanned = scan_for_credentials("Connection error: #{e.message}", request)
|
|
112
|
+
error_response(scanned)
|
|
105
113
|
end
|
|
106
114
|
|
|
107
115
|
def error_from_response(response, request)
|
|
@@ -116,6 +124,10 @@ module Woods
|
|
|
116
124
|
scanned
|
|
117
125
|
end
|
|
118
126
|
|
|
127
|
+
def scan_early_error(message)
|
|
128
|
+
scan_for_credentials(message, 'tool' => @tool_name.delete_prefix('console_'))
|
|
129
|
+
end
|
|
130
|
+
|
|
119
131
|
def log_credential_hits(request, counts)
|
|
120
132
|
return unless @logger
|
|
121
133
|
|