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.
Files changed (282) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1879 -37
  3. data/CONTRIBUTING.md +195 -137
  4. data/README.md +162 -520
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +620 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +415 -0
  19. data/docs/INTERNALS.md +415 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +197 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +39 -3
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +78 -24
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +40 -1
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +18 -13
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/credential_index.rb +5 -53
  64. data/lib/woods/console/credential_scanner.rb +15 -16
  65. data/lib/woods/console/dispatch_pipeline.rb +46 -34
  66. data/lib/woods/console/embedded_executor.rb +806 -257
  67. data/lib/woods/console/eval_guard.rb +27 -20
  68. data/lib/woods/console/input_contract.rb +78 -0
  69. data/lib/woods/console/model_validator.rb +24 -6
  70. data/lib/woods/console/rack_middleware.rb +62 -63
  71. data/lib/woods/console/redactor.rb +10 -24
  72. data/lib/woods/console/safe_context.rb +45 -45
  73. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  74. data/lib/woods/console/server.rb +136 -267
  75. data/lib/woods/console/sql_noise_stripper.rb +20 -51
  76. data/lib/woods/console/sql_table_scanner.rb +39 -90
  77. data/lib/woods/console/sql_validator.rb +455 -85
  78. data/lib/woods/console/table_gate.rb +2 -2
  79. data/lib/woods/console/tool_specs.rb +462 -88
  80. data/lib/woods/console/tools/tier1.rb +0 -3
  81. data/lib/woods/console/tools/tier4.rb +17 -7
  82. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  83. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  84. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  85. data/lib/woods/db/migrator.rb +3 -9
  86. data/lib/woods/db/schema_version.rb +47 -2
  87. data/lib/woods/dependency_graph.rb +898 -64
  88. data/lib/woods/embedding/fake.rb +138 -0
  89. data/lib/woods/embedding/indexer.rb +832 -40
  90. data/lib/woods/embedding/openai.rb +77 -19
  91. data/lib/woods/embedding/provider.rb +189 -11
  92. data/lib/woods/embedding/text_preparer.rb +1 -1
  93. data/lib/woods/embedding/token_counter.rb +0 -7
  94. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  95. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  96. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  97. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  98. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  99. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  100. data/lib/woods/evaluation/ablation_task.rb +66 -0
  101. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  102. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  103. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  104. data/lib/woods/evaluation/baseline.rb +60 -0
  105. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  106. data/lib/woods/evaluation/evaluator.rb +41 -8
  107. data/lib/woods/evaluation/query_set.rb +79 -13
  108. data/lib/woods/evaluation/report_generator.rb +20 -1
  109. data/lib/woods/export/unit_facts.rb +0 -11
  110. data/lib/woods/extracted_unit.rb +22 -63
  111. data/lib/woods/extractor.rb +2503 -192
  112. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  113. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  114. data/lib/woods/extractors/caching_extractor.rb +46 -12
  115. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  116. data/lib/woods/extractors/component_discovery.rb +123 -0
  117. data/lib/woods/extractors/concern_extractor.rb +17 -3
  118. data/lib/woods/extractors/controller_extractor.rb +389 -29
  119. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  120. data/lib/woods/extractors/engine_extractor.rb +53 -8
  121. data/lib/woods/extractors/event_extractor.rb +55 -4
  122. data/lib/woods/extractors/factory_extractor.rb +49 -11
  123. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  124. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  125. data/lib/woods/extractors/job_extractor.rb +51 -21
  126. data/lib/woods/extractors/lib_extractor.rb +23 -17
  127. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  128. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  129. data/lib/woods/extractors/manager_extractor.rb +19 -2
  130. data/lib/woods/extractors/migration_extractor.rb +22 -11
  131. data/lib/woods/extractors/model_extractor.rb +292 -57
  132. data/lib/woods/extractors/package_extractor.rb +154 -0
  133. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  134. data/lib/woods/extractors/policy_extractor.rb +6 -5
  135. data/lib/woods/extractors/poro_extractor.rb +13 -14
  136. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  137. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  138. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  139. data/lib/woods/extractors/reference_patterns.rb +38 -0
  140. data/lib/woods/extractors/route_extractor.rb +58 -2
  141. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  142. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  143. data/lib/woods/extractors/service_extractor.rb +11 -1
  144. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  145. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  146. data/lib/woods/extractors/source_nesting.rb +560 -0
  147. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  148. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  149. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  150. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  151. data/lib/woods/feedback/gap_detector.rb +9 -3
  152. data/lib/woods/feedback/store.rb +7 -1
  153. data/lib/woods/filename_utils.rb +29 -1
  154. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  155. data/lib/woods/flow_assembler.rb +63 -21
  156. data/lib/woods/flow_document.rb +1 -0
  157. data/lib/woods/flow_precomputer.rb +138 -22
  158. data/lib/woods/gem_mapper.rb +285 -0
  159. data/lib/woods/generation.rb +185 -0
  160. data/lib/woods/git_command.rb +38 -0
  161. data/lib/woods/git_provenance.rb +16 -2
  162. data/lib/woods/graph_analyzer.rb +408 -34
  163. data/lib/woods/index_artifact.rb +93 -23
  164. data/lib/woods/mcp/bearer_auth.rb +92 -22
  165. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  166. data/lib/woods/mcp/bootstrapper.rb +582 -77
  167. data/lib/woods/mcp/config_resolver.rb +66 -6
  168. data/lib/woods/mcp/errors.rb +60 -0
  169. data/lib/woods/mcp/index_reader.rb +836 -117
  170. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  171. data/lib/woods/mcp/origin_guard.rb +108 -23
  172. data/lib/woods/mcp/protocol_policy.rb +98 -0
  173. data/lib/woods/mcp/provider_probe.rb +45 -6
  174. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  175. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  176. data/lib/woods/mcp/server.rb +907 -154
  177. data/lib/woods/mcp/tasks/extension.rb +196 -0
  178. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  179. data/lib/woods/mcp/tasks/store.rb +518 -0
  180. data/lib/woods/mcp/tool_contract.rb +171 -0
  181. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  182. data/lib/woods/mcp/version_aware_tool_dispatch.rb +3 -9
  183. data/lib/woods/model_name_cache.rb +19 -1
  184. data/lib/woods/notion/client.rb +132 -36
  185. data/lib/woods/notion/exporter.rb +456 -61
  186. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  187. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  188. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  189. data/lib/woods/notion/mappers/shared.rb +45 -3
  190. data/lib/woods/notion/sync_manifest.rb +258 -0
  191. data/lib/woods/obsidian/errors.rb +6 -0
  192. data/lib/woods/obsidian/name_mapper.rb +40 -24
  193. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  194. data/lib/woods/operator/pipeline_guard.rb +118 -21
  195. data/lib/woods/operator/status_reporter.rb +20 -3
  196. data/lib/woods/path_dispatcher.rb +276 -0
  197. data/lib/woods/payload_store.rb +223 -0
  198. data/lib/woods/published_index/edge_shaper.rb +61 -0
  199. data/lib/woods/published_index/generation_catalog.rb +72 -0
  200. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  201. data/lib/woods/published_index.rb +287 -0
  202. data/lib/woods/railtie.rb +70 -38
  203. data/lib/woods/railtie_support.rb +167 -0
  204. data/lib/woods/release.rb +12 -0
  205. data/lib/woods/reload_policy.rb +206 -0
  206. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  207. data/lib/woods/resilience/index_validator.rb +296 -10
  208. data/lib/woods/resilience/retryable_provider.rb +71 -6
  209. data/lib/woods/resolved_config.rb +55 -11
  210. data/lib/woods/retrieval/context_assembler.rb +132 -40
  211. data/lib/woods/retrieval/query_classifier.rb +25 -6
  212. data/lib/woods/retrieval/ranker.rb +193 -28
  213. data/lib/woods/retrieval/search_executor.rb +206 -39
  214. data/lib/woods/retriever.rb +317 -71
  215. data/lib/woods/retry_after.rb +22 -2
  216. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  217. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  218. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  219. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  220. data/lib/woods/ruby_analyzer.rb +21 -5
  221. data/lib/woods/session_tracer/file_store.rb +138 -19
  222. data/lib/woods/session_tracer/redis_store.rb +122 -12
  223. data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
  224. data/lib/woods/session_tracer/session_flow_document.rb +52 -6
  225. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  226. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  227. data/lib/woods/session_tracer/store.rb +14 -1
  228. data/lib/woods/storage/metadata_store.rb +230 -26
  229. data/lib/woods/storage/pgvector.rb +180 -22
  230. data/lib/woods/storage/qdrant.rb +367 -41
  231. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  232. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  233. data/lib/woods/storage/snapshotter.rb +23 -5
  234. data/lib/woods/storage/vector_store.rb +49 -8
  235. data/lib/woods/storage_identity.rb +28 -0
  236. data/lib/woods/tasks.rb +53 -2
  237. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  238. data/lib/woods/temporal/snapshot_store.rb +139 -42
  239. data/lib/woods/unblocked/client.rb +119 -17
  240. data/lib/woods/unblocked/document_builder.rb +34 -2
  241. data/lib/woods/unblocked/exporter.rb +63 -27
  242. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  243. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  244. data/lib/woods/update_check.rb +24 -1
  245. data/lib/woods/util/uuid5.rb +124 -0
  246. data/lib/woods/version.rb +1 -1
  247. data/lib/woods/watch/daemon.rb +1345 -0
  248. data/lib/woods/watch/listen_watcher.rb +81 -0
  249. data/lib/woods/watch/polling_watcher.rb +137 -0
  250. data/lib/woods/watch/status.rb +169 -0
  251. data/lib/woods/watch/tree_scan.rb +163 -0
  252. data/lib/woods/watch/watcher.rb +100 -0
  253. data/lib/woods.rb +53 -9
  254. data/plugin/.claude-plugin/plugin.json +18 -0
  255. data/plugin/hooks/hooks.json +29 -0
  256. data/plugin/hooks/woods-post-edit.sh +226 -0
  257. data/plugin/hooks/woods-session-start.sh +77 -0
  258. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  259. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  260. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  261. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  262. data/plugin/skills/woods-setup/SKILL.md +99 -0
  263. metadata +102 -30
  264. data/lib/woods/console/adapter_family.rb +0 -39
  265. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  266. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  268. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  269. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  270. data/lib/woods/console/bridge.rb +0 -210
  271. data/lib/woods/console/credential_scanner_registry.rb +0 -36
  272. data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
  273. data/lib/woods/console/sql_output_policy.rb +0 -535
  274. data/lib/woods/console/sqlite_read_guard.rb +0 -46
  275. data/lib/woods/formatting/claude_adapter.rb +0 -98
  276. data/lib/woods/formatting/generic_adapter.rb +0 -56
  277. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  278. data/lib/woods/mcp/http_transport_options.rb +0 -15
  279. data/lib/woods/mcp/origin_policy.rb +0 -113
  280. data/lib/woods/notion/mapper.rb +0 -40
  281. data/lib/woods/observability/health_check.rb +0 -79
  282. 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
- # Manages the bridge process connection via Docker exec, direct spawn, or SSH.
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
- MAX_RETRIES = 5
31
- HEARTBEAT_INTERVAL = 30
32
-
33
- # @param config [Hash] Connection configuration
34
- # @option config [String] 'mode' Connection mode: 'docker', 'direct', or 'ssh'
35
- # @option config [String] 'command' Command to run the bridge
36
- # @option config [String] 'container' Docker container name (docker mode)
37
- # @option config [String] 'directory' Working directory (direct mode)
38
- # @option config [String] 'host' SSH host (ssh mode)
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['mode'] || 'direct'
43
- @command = config['command'] || 'bundle exec rails runner lib/woods/console/bridge.rb'
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
- # Spawn the bridge process.
34
+ # Return the argv used to launch the embedded MCP server.
52
35
  #
53
- # @return [void]
54
- # @raise [ConnectionError] if the process cannot be started
55
- def connect!
56
- cmd = build_command
57
- if @mode == 'direct' && @config['directory']
58
- Dir.chdir(@config['directory']) do
59
- @stdin, @stdout, @wait_thread = Open3.popen2(*cmd)
60
- end
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
- # Terminate the bridge process.
47
+ # Replace the current process with the embedded MCP server.
71
48
  #
72
- # @return [void]
73
- def disconnect!
74
- @stdin&.close
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 [Boolean]
104
- def alive?
105
- return false unless @wait_thread
106
-
107
- @wait_thread.alive?
108
- end
109
-
110
- # Check if a heartbeat is needed (30s since last communication).
111
- #
112
- # @return [Boolean]
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
- # Build the shell command based on connection mode.
122
- #
123
- # @return [Array<String>]
124
- def build_command
125
- case @mode
126
- when 'docker' then build_docker_command
127
- when 'ssh' then build_ssh_command
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 build_docker_command
76
+ def docker_command
134
77
  container = @config['container'] || raise(ConnectionError, 'Docker mode requires container name')
135
- ['docker', 'exec', '-i', container] + @command.shellsplit
78
+ ['docker', 'exec', '-i', container] + embedded_argv
136
79
  end
137
80
 
138
- def build_ssh_command
81
+ def ssh_command
139
82
  host = @config['host'] || raise(ConnectionError, 'SSH mode requires host')
140
- user = @config['user']
141
- target = user ? "#{user}@#{host}" : host
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 # rubocop:disable Metrics/ClassLength
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
- refresh(rails_app: rails_app)
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 = fresh_credentials(rails_app.credentials).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
- # Regexp alternatives match in order: a shorter prefix must not consume
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
- offset = 0
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 and Symbols are scanned against every active pattern. Hash values and Array
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, @secret_index)
158
+ scanned = walk(value, counts)
159
159
  [scanned, counts]
160
160
  end
161
161
 
162
162
  private
163
163
 
164
- def walk(value, counts, index)
164
+ def walk(value, counts)
165
165
  case value
166
- when String then scan_string(value, counts, index)
167
- when Symbol then scan_string(value.to_s, counts, index).to_sym
168
- when Hash then walk_hash(value, counts, index)
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, index)
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, index)
185
- when Symbol then scan_string(key.to_s, counts, index).to_sym
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, index)
187
+ out[scanned_key] = walk(val, counts)
189
188
  end
190
189
  end
191
190
 
192
- def scan_string(str, counts, index)
193
- result = redact_indexed_secrets(str, counts, index)
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, index)
295
- return str unless index&.match?(str)
293
+ def redact_indexed_secrets(str, counts)
294
+ return str unless @secret_index&.match?(str)
296
295
 
297
- redacted = index.redact(str)
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. Coerce integer-typed args from strings. MCP clients sometimes send
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 integer_keys [Array<Symbol>] Property keys declared as `integer`
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:, integer_keys:, conn_mgr:, # rubocop:disable Metrics/ParameterLists
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
- @integer_keys = integer_keys
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
- coerce_integer_args!(args)
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
- def coerce_integer_args!(args)
73
- @integer_keys.each { |k| args[k] = args[k].to_i if args[k].is_a?(String) }
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
- render_response(response['result'], request)
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 StandardError
96
- # SDK 0.x includes uncaught exception messages in its tool errors.
97
- # Serializer/parser/renderer messages may contain unscanned values.
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