woods 2.0.0.beta1 → 2.0.0.beta3

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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +400 -1
  3. data/CONTRIBUTING.md +224 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +233 -13
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +158 -2
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +15 -7
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +71 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/atomic_file.rb +133 -3
  56. data/lib/woods/builder.rb +21 -5
  57. data/lib/woods/cache/cache_middleware.rb +28 -7
  58. data/lib/woods/cache/cache_store.rb +4 -5
  59. data/lib/woods/change_set.rb +5 -4
  60. data/lib/woods/console/credential_index.rb +20 -2
  61. data/lib/woods/console/credential_scanner.rb +14 -14
  62. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  63. data/lib/woods/console/embedded_executor.rb +1 -1
  64. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  65. data/lib/woods/console/rack_middleware.rb +22 -13
  66. data/lib/woods/console/server.rb +18 -16
  67. data/lib/woods/dependency_graph.rb +65 -13
  68. data/lib/woods/embedding/corpus.rb +94 -0
  69. data/lib/woods/embedding/indexer.rb +90 -46
  70. data/lib/woods/embedding/openai.rb +17 -6
  71. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  72. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  73. data/lib/woods/export/typed_reader.rb +56 -0
  74. data/lib/woods/extractor.rb +557 -228
  75. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  76. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  77. data/lib/woods/extractors/caching_extractor.rb +3 -1
  78. data/lib/woods/extractors/concern_extractor.rb +64 -6
  79. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  80. data/lib/woods/extractors/controller_extractor.rb +13 -4
  81. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  82. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  83. data/lib/woods/extractors/engine_extractor.rb +3 -1
  84. data/lib/woods/extractors/event_extractor.rb +4 -2
  85. data/lib/woods/extractors/factory_extractor.rb +3 -1
  86. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  87. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  88. data/lib/woods/extractors/job_extractor.rb +6 -19
  89. data/lib/woods/extractors/lib_extractor.rb +3 -1
  90. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  91. data/lib/woods/extractors/manager_extractor.rb +3 -1
  92. data/lib/woods/extractors/method_parameters.rb +53 -0
  93. data/lib/woods/extractors/middleware_argument.rb +65 -0
  94. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  95. data/lib/woods/extractors/migration_extractor.rb +3 -1
  96. data/lib/woods/extractors/model_extractor.rb +39 -33
  97. data/lib/woods/extractors/package_extractor.rb +24 -4
  98. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  99. data/lib/woods/extractors/policy_extractor.rb +3 -1
  100. data/lib/woods/extractors/poro_extractor.rb +3 -1
  101. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  102. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  103. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  104. data/lib/woods/extractors/route_extractor.rb +3 -1
  105. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  106. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  107. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  108. data/lib/woods/extractors/service_extractor.rb +3 -1
  109. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  110. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  111. data/lib/woods/extractors/source_nesting.rb +1 -1
  112. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  113. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  114. data/lib/woods/extractors/validator_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  116. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  117. data/lib/woods/flow_assembler.rb +87 -8
  118. data/lib/woods/flow_precomputer.rb +44 -7
  119. data/lib/woods/gem_mapper.rb +2 -0
  120. data/lib/woods/git_history.rb +116 -0
  121. data/lib/woods/graph_analyzer.rb +195 -63
  122. data/lib/woods/hooks/context_cli.rb +54 -0
  123. data/lib/woods/hooks/context_event.rb +88 -0
  124. data/lib/woods/hooks/context_hint.rb +73 -0
  125. data/lib/woods/hooks/context_impact.rb +77 -0
  126. data/lib/woods/hooks/context_output.rb +47 -0
  127. data/lib/woods/hooks/context_state.rb +102 -0
  128. data/lib/woods/hooks/refresh.rb +79 -0
  129. data/lib/woods/hooks/rule_projection.rb +78 -0
  130. data/lib/woods/input_rules.rb +19 -0
  131. data/lib/woods/mcp/bearer_auth.rb +20 -12
  132. data/lib/woods/mcp/bootstrapper.rb +62 -0
  133. data/lib/woods/mcp/index_reader.rb +323 -160
  134. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  135. data/lib/woods/mcp/origin_guard.rb +17 -9
  136. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  137. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  138. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  139. data/lib/woods/mcp/search_results.rb +74 -0
  140. data/lib/woods/mcp/server.rb +158 -37
  141. data/lib/woods/mcp/tool_contract.rb +2 -0
  142. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  143. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  144. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  145. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  146. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  147. data/lib/woods/notion/exporter.rb +56 -17
  148. data/lib/woods/obsidian/destination_plan.rb +98 -0
  149. data/lib/woods/obsidian/name_mapper.rb +19 -3
  150. data/lib/woods/obsidian/note_builder.rb +19 -10
  151. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  152. data/lib/woods/operator/pipeline_guard.rb +18 -13
  153. data/lib/woods/path_dispatcher.rb +7 -1
  154. data/lib/woods/payload_store.rb +29 -15
  155. data/lib/woods/railtie.rb +3 -3
  156. data/lib/woods/railtie_support.rb +12 -12
  157. data/lib/woods/rake_helpers.rb +392 -0
  158. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  159. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  160. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  161. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  162. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  163. data/lib/woods/resilience/index_validator.rb +112 -23
  164. data/lib/woods/retrieval/context_assembler.rb +50 -15
  165. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  166. data/lib/woods/retrieval/lexical_index.rb +119 -0
  167. data/lib/woods/retrieval/ranker.rb +4 -2
  168. data/lib/woods/retrieval/scope.rb +108 -0
  169. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  170. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  171. data/lib/woods/retrieval/search_executor.rb +86 -27
  172. data/lib/woods/retrieval/source_evidence.rb +200 -0
  173. data/lib/woods/retriever.rb +98 -22
  174. data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
  175. data/lib/woods/session_tracer/middleware.rb +10 -12
  176. data/lib/woods/session_tracer/redis_store.rb +22 -6
  177. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  178. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  179. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  180. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  181. data/lib/woods/source_inputs/handoff.rb +102 -0
  182. data/lib/woods/source_inputs/launcher.rb +157 -0
  183. data/lib/woods/source_inputs/manifest.rb +124 -0
  184. data/lib/woods/source_inputs/private_key.rb +55 -0
  185. data/lib/woods/source_inputs/scanner.rb +171 -0
  186. data/lib/woods/source_inputs/scopes.rb +71 -0
  187. data/lib/woods/source_inputs/session.rb +214 -0
  188. data/lib/woods/source_inputs/status.rb +84 -0
  189. data/lib/woods/source_inputs/verifier.rb +107 -0
  190. data/lib/woods/storage/metadata_store.rb +25 -25
  191. data/lib/woods/storage/pgvector.rb +29 -8
  192. data/lib/woods/storage/qdrant.rb +17 -7
  193. data/lib/woods/storage/vector_store.rb +18 -6
  194. data/lib/woods/tasks.rb +3 -2
  195. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  196. data/lib/woods/unblocked/exporter.rb +59 -70
  197. data/lib/woods/version.rb +1 -1
  198. data/lib/woods/watch/boot_snapshot.rb +52 -0
  199. data/lib/woods/watch/daemon.rb +136 -28
  200. data/lib/woods/watch/listen_watcher.rb +4 -0
  201. data/lib/woods/watch/polling_watcher.rb +5 -1
  202. data/lib/woods/watch/status.rb +20 -15
  203. data/lib/woods/watch/tree_scan.rb +21 -13
  204. data/lib/woods/watch/watcher.rb +4 -1
  205. data/lib/woods.rb +135 -11
  206. data/plugin/.claude-plugin/plugin.json +1 -1
  207. data/plugin/hooks/adapters/normalize.jq +15 -0
  208. data/plugin/hooks/adapters/normalize.rb +63 -0
  209. data/plugin/hooks/hooks.json +20 -0
  210. data/plugin/hooks/woods-context.sh +50 -0
  211. data/plugin/hooks/woods-input-rules.sh +159 -0
  212. data/plugin/hooks/woods-opencode.mjs +65 -0
  213. data/plugin/hooks/woods-post-edit.sh +2 -225
  214. data/plugin/hooks/woods-refresh.sh +260 -0
  215. data/plugin/hooks/woods-session-start.sh +47 -55
  216. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  217. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  218. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  219. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  220. data/plugin/skills/woods-setup/SKILL.md +107 -6
  221. metadata +84 -5
data/lib/woods/builder.rb CHANGED
@@ -110,6 +110,11 @@ module Woods
110
110
  # on disk and passes the populated store here.
111
111
  # @return [Retriever, Cache::CachedRetriever] A fully wired retriever
112
112
  def build_retriever(vector_store: nil, metadata_store: nil, graph_store: nil)
113
+ if @config.retrieval_mode == :lexical
114
+ return build_lexical_retriever(metadata_store: metadata_store,
115
+ graph_store: graph_store)
116
+ end
117
+
113
118
  provider = build_resilient_embedding_provider
114
119
  cache = build_cache_store
115
120
 
@@ -119,12 +124,21 @@ module Woods
119
124
  vector_store: vector_store || build_vector_store(dimensions: vector_dimensions(provider)),
120
125
  metadata_store: metadata_store || build_metadata_store,
121
126
  graph_store: graph_store || build_graph_store,
122
- embedding_provider: provider
127
+ embedding_provider: provider, default_budget: @config.max_context_tokens
123
128
  )
124
129
 
125
130
  cache ? wrap_with_retriever_cache(retriever, cache) : retriever
126
131
  end
127
132
 
133
+ def build_lexical_retriever(metadata_store:, graph_store:)
134
+ retriever = Retriever.new(vector_store: nil, metadata_store: metadata_store || build_metadata_store,
135
+ graph_store: graph_store, embedding_provider: nil, mode: :lexical,
136
+ default_budget: @config.max_context_tokens)
137
+ cache = build_cache_store
138
+ cache ? wrap_with_retriever_cache(retriever, cache) : retriever
139
+ end
140
+ private :build_lexical_retriever
141
+
128
142
  # Instantiate the vector store adapter specified by the configuration.
129
143
  #
130
144
  # The :pgvector branch also ensures the adapter's own schema exists —
@@ -482,19 +496,21 @@ module Woods
482
496
 
483
497
  # Instantiate the metadata store adapter specified by the configuration.
484
498
  #
499
+ # @param output_dir [String, Pathname] Default SQLite database directory; an
500
+ # explicit metadata_store_options[:database] still takes precedence.
485
501
  # @return [Storage::MetadataStore::Interface] Metadata store adapter instance
486
502
  # @raise [ArgumentError] if the configured type is not recognized
487
- def build_metadata_store
503
+ def build_metadata_store(output_dir: @config.output_dir)
488
504
  case @config.metadata_store
489
505
  when :in_memory then Storage::MetadataStore::InMemory.new
490
- when :sqlite then Storage::MetadataStore::SQLite.new(**sqlite_metadata_options)
506
+ when :sqlite then Storage::MetadataStore::SQLite.new(**sqlite_metadata_options(output_dir))
491
507
  else raise ArgumentError, "Unknown metadata_store: #{@config.metadata_store}"
492
508
  end
493
509
  end
494
510
 
495
- def sqlite_metadata_options
511
+ def sqlite_metadata_options(output_dir)
496
512
  opts = (@config.metadata_store_options || {}).transform_keys(&:to_sym)
497
- opts[:database] ||= File.join(@config.output_dir.to_s, 'metadata.sqlite3')
513
+ opts[:database] ||= File.join(output_dir.to_s, 'metadata.sqlite3')
498
514
  opts
499
515
  end
500
516
  private :sqlite_metadata_options
@@ -418,6 +418,8 @@ module Woods
418
418
  def vector_store = @retriever.vector_store
419
419
  def metadata_store = @retriever.metadata_store
420
420
  def graph_store = @retriever.graph_store
421
+ def mode = @retriever.respond_to?(:mode) ? @retriever.mode : :semantic
422
+ def default_budget = @retriever.respond_to?(:default_budget) ? @retriever.default_budget : 8000
421
423
 
422
424
  # Invalidate every cached context result. Called from the MCP +reload+
423
425
  # tool after the retriever's stores have been re-hydrated from a fresh
@@ -449,12 +451,17 @@ module Woods
449
451
  # @param types [Array<String, Symbol>, nil] Include-only filter
450
452
  # @param exclude_types [Array<String, Symbol>, nil] Additional exclusions
451
453
  # @return [Retriever::RetrievalResult]
452
- def retrieve(query, budget: 8000, types: nil, exclude_types: nil)
453
- key = context_key(query, budget, types: types, exclude_types: exclude_types)
454
+ def retrieve(query, budget: default_budget, types: nil, exclude_types: nil, packages: nil, source_paths: nil,
455
+ evidence: 'full') # rubocop:disable Metrics/ParameterLists
456
+ Retrieval::SourceEvidence.validate_mode!(evidence)
457
+ scoped = Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
458
+ scope_options = scoped ? { packages: packages, source_paths: source_paths } : {}
459
+ scope_options[:evidence] = evidence unless evidence == 'full'
460
+ key = context_key(query, budget, types: types, exclude_types: exclude_types, **scope_options)
454
461
  cached = @cache_store.read(key)
455
462
  return rehydrate_cached(cached, budget) if cached
456
463
 
457
- result = @retriever.retrieve(query, budget: budget, types: types, exclude_types: exclude_types)
464
+ result = @retriever.retrieve(query, budget: budget, types: types, exclude_types: exclude_types, **scope_options)
458
465
 
459
466
  begin
460
467
  @cache_store.write(key, serialize_result(result), ttl: @context_ttl)
@@ -478,8 +485,14 @@ module Woods
478
485
  # @param types [Array<String, Symbol>, nil]
479
486
  # @param exclude_types [Array<String, Symbol>, nil]
480
487
  # @return [String]
481
- def context_key(query, budget, types: nil, exclude_types: nil)
482
- Cache.cache_key(:context, query, budget.to_s, fingerprint(types), fingerprint(exclude_types))
488
+ def context_key(query, budget, types: nil, exclude_types: nil, packages: nil, source_paths: nil, evidence: 'full') # rubocop:disable Metrics/ParameterLists
489
+ parts = [query, budget.to_s, fingerprint(types), fingerprint(exclude_types)]
490
+ parts << 'lexical' if mode == :lexical
491
+ parts << JSON.generate(evidence: evidence) unless evidence == 'full'
492
+ if Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
493
+ parts << JSON.generate(packages: packages, source_paths: source_paths)
494
+ end
495
+ Cache.cache_key(:context, *parts)
483
496
  end
484
497
 
485
498
  def fingerprint(types)
@@ -504,7 +517,8 @@ module Woods
504
517
  tokens_used: cached['tokens_used'],
505
518
  budget: budget,
506
519
  trace: nil,
507
- type_rank_context: rehydrate_type_rank_context(cached['type_rank_context'])
520
+ type_rank_context: rehydrate_type_rank_context(cached['type_rank_context']),
521
+ applied_scope: rehydrate_scope(cached['applied_scope'])
508
522
  )
509
523
  end
510
524
 
@@ -514,10 +528,17 @@ module Woods
514
528
  'sources' => result.sources,
515
529
  'strategy' => result.strategy&.to_s,
516
530
  'tokens_used' => result.tokens_used,
517
- 'type_rank_context' => serialize_type_rank_context(result.type_rank_context)
531
+ 'type_rank_context' => serialize_type_rank_context(result.type_rank_context),
532
+ 'applied_scope' => result.applied_scope
518
533
  }
519
534
  end
520
535
 
536
+ def rehydrate_scope(scope)
537
+ return nil unless scope
538
+
539
+ scope.transform_keys(&:to_sym).tap { |value| value[:outcome] = value[:outcome]&.to_sym }
540
+ end
541
+
521
542
  # type_rank_context is a Hash<String => Hash<Symbol, ...>> with
522
543
  # :source carrying a Symbol value. JSON-backed caches (Redis,
523
544
  # SolidCache) collapse both to strings on the round-trip, so we
@@ -25,11 +25,10 @@ module Woods
25
25
  # @param parts [Array<String>] Key components (will be SHA256-hashed if long)
26
26
  # @return [String] Namespaced key
27
27
  def self.cache_key(domain, *parts)
28
- raw = if parts.one?
29
- parts.first.to_s
30
- else
31
- parts.map { |part| "#{part.to_s.bytesize}:#{part}" }.join
32
- end
28
+ raw = parts.map do |part|
29
+ value = part.to_s
30
+ "#{value.bytesize}:#{value}"
31
+ end.join
33
32
  suffix = raw.length > 64 ? Digest::SHA256.hexdigest(raw) : raw
34
33
  "woods:cache:#{domain}:#{suffix}"
35
34
  end
@@ -15,7 +15,8 @@ module Woods
15
15
  #
16
16
  # Paths are held absolute (matching how {DependencyGraph} registers file
17
17
  # paths) and exposed relative on demand (matching how {PathDispatcher}
18
- # rules are written).
18
+ # rules are written). Normalization is lexical, so vanished files remain
19
+ # representable without resolving symlink targets or consulting the filesystem.
19
20
  #
20
21
  # @example
21
22
  # cs = Woods::ChangeSet.new(paths: %w[app/models/user.rb app/models/gone.rb], root: Rails.root)
@@ -29,7 +30,7 @@ module Woods
29
30
  # @param paths [Array<String>] changed paths, absolute or root-relative
30
31
  # @param root [String, Pathname] application root (usually Rails.root)
31
32
  def initialize(paths:, root:)
32
- @root = Pathname.new(root.to_s)
33
+ @root = Pathname.new(root.to_s).expand_path
33
34
  @absolute_paths = Array(paths).filter_map { |p| absolutize(p) }.uniq.freeze
34
35
  end
35
36
 
@@ -72,7 +73,7 @@ module Woods
72
73
  # @param path [String] absolute path
73
74
  # @return [String] root-relative path, or the input if outside {#root}
74
75
  def relativize(path)
75
- prefix = "#{@root}/"
76
+ prefix = File.join(@root.to_s, '')
76
77
  path.start_with?(prefix) ? path.delete_prefix(prefix) : path
77
78
  end
78
79
 
@@ -82,7 +83,7 @@ module Woods
82
83
  str = path.to_s.strip
83
84
  return nil if str.empty?
84
85
 
85
- Pathname.new(str).absolute? ? str : @root.join(str).to_s
86
+ @root.join(str).cleanpath.to_s
86
87
  end
87
88
  end
88
89
  end
@@ -68,6 +68,7 @@ module Woods
68
68
  MISSING_KEY_ERRORS = %w[
69
69
  ActiveSupport::EncryptedConfiguration::MissingKeyError
70
70
  ActiveSupport::EncryptedFile::MissingKeyError
71
+ ActiveSupport::EncryptedFile::MissingContentError
71
72
  ActiveSupport::MessageEncryptor::InvalidMessage
72
73
  ].freeze
73
74
 
@@ -90,13 +91,19 @@ module Woods
90
91
  # @return [CredentialIndex] populated index, or an empty index when
91
92
  # the credentials store can't be opened.
92
93
  def build(rails_app:)
93
- new(secrets: collect_secrets(rails_app))
94
+ refresh(rails_app: rails_app)
94
95
  rescue StandardError => e
95
96
  raise unless missing_key_error?(e)
96
97
 
97
98
  new(secrets: [])
98
99
  end
99
100
 
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
+
100
107
  # Emit a structured log warning when any of the given credentials
101
108
  # files has a modification time newer than `process_start`. This
102
109
  # catches the common "rotated credentials but forgot to restart"
@@ -136,12 +143,23 @@ module Woods
136
143
  private
137
144
 
138
145
  def collect_secrets(rails_app)
139
- config = rails_app.credentials.config
146
+ config = fresh_credentials(rails_app.credentials).config
140
147
  collected = []
141
148
  walk(config, collected)
142
149
  collected
143
150
  end
144
151
 
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
+
145
163
  def walk(node, collected)
146
164
  case node
147
165
  when Hash then node.each_value { |v| walk(v, collected) }
@@ -155,17 +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, @secret_index)
159
159
  [scanned, counts]
160
160
  end
161
161
 
162
162
  private
163
163
 
164
- def walk(value, counts)
164
+ def walk(value, counts, index)
165
165
  case value
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) }
166
+ when String then scan_string(value, counts, index)
167
+ when Hash then walk_hash(value, counts, index)
168
+ when Array then value.map { |item| walk(item, counts, index) }
169
169
  else value
170
170
  end
171
171
  end
@@ -177,19 +177,19 @@ module Woods
177
177
  # credential-shaped string is emitted as a Symbol after redaction
178
178
  # (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
179
179
  # non-Symbol keys (Integer, etc.) pass through unchanged.
180
- def walk_hash(hash, counts)
180
+ def walk_hash(hash, counts, index)
181
181
  hash.each_with_object({}) do |(key, val), out|
182
182
  scanned_key = case key
183
- when String then scan_string(key, counts)
184
- when Symbol then scan_string(key.to_s, counts).to_sym
183
+ when String then scan_string(key, counts, index)
184
+ when Symbol then scan_string(key.to_s, counts, index).to_sym
185
185
  else key
186
186
  end
187
- out[scanned_key] = walk(val, counts)
187
+ out[scanned_key] = walk(val, counts, index)
188
188
  end
189
189
  end
190
190
 
191
- def scan_string(str, counts)
192
- result = redact_indexed_secrets(str, counts)
191
+ def scan_string(str, counts, index)
192
+ result = redact_indexed_secrets(str, counts, index)
193
193
  result = scan_encoded_forms(result, counts)
194
194
  @active_patterns.inject(result) do |acc, (name, pattern)|
195
195
  acc.gsub(pattern) do
@@ -290,10 +290,10 @@ module Woods
290
290
  printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
291
291
  end
292
292
 
293
- def redact_indexed_secrets(str, counts)
294
- return str unless @secret_index&.match?(str)
293
+ def redact_indexed_secrets(str, counts, index)
294
+ return str unless index&.match?(str)
295
295
 
296
- redacted = @secret_index.redact(str)
296
+ redacted = index.redact(str)
297
297
  counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
298
298
  redacted
299
299
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Console
5
+ # Coordinates scanner construction and rotation without retaining servers
6
+ # after their transports release them. Requests keep their own index snapshot.
7
+ class CredentialScannerRegistry
8
+ def initialize
9
+ @scanners = ObjectSpace::WeakMap.new
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def register
14
+ @mutex.synchronize do
15
+ scanner = yield
16
+ # Older Ruby WeakMap implementations validate the value's liveness
17
+ # when enumerating. Keep the scanner in both weak positions so an
18
+ # immortal sentinel cannot make an abandoned scanner look live.
19
+ @scanners[scanner] = scanner
20
+ scanner
21
+ end
22
+ end
23
+
24
+ def rebuild
25
+ @mutex.synchronize do
26
+ scanners = @scanners.values
27
+ return nil if scanners.empty?
28
+
29
+ index = yield
30
+ scanners.each { |scanner| scanner.replace_index!(index) }
31
+ index
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1491,7 +1491,7 @@ module Woods
1491
1491
  # `SqlNoiseStripper` is the same module SqlValidator uses. The
1492
1492
  # combined single-pass strip_noise resolves comments and literals
1493
1493
  # together so a comment marker inside a literal can't hide a keyword.
1494
- stripped = SqlNoiseStripper.strip_noise(template)
1494
+ stripped = SqlNoiseStripper.strip_noise(template, dialect: sql_dialect || :postgres, **mysql_quote_modes)
1495
1495
  if SCOPE_TEMPLATE_FORBIDDEN.match?(stripped)
1496
1496
  raise ValidationError,
1497
1497
  'scope template contains forbidden SQL keywords ' \
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/encrypted_configuration'
4
+
5
+ module Woods
6
+ module Console
7
+ # A private, uncached Rails configuration for one credential-index build.
8
+ # Unlike Rails' permissive configuration reader, a missing encrypted file
9
+ # must raise during an explicit refresh rather than erase the last index.
10
+ class EncryptedCredentialSnapshot < ActiveSupport::EncryptedConfiguration
11
+ def read
12
+ ActiveSupport::EncryptedFile.instance_method(:read).bind(self).call
13
+ end
14
+ end
15
+ end
16
+ end
@@ -81,17 +81,12 @@ module Woods
81
81
  # for compatibility. Passing it fails closed because console_eval is unavailable.
82
82
  # @param unsafe_eval_audit_log_path [String, Pathname, nil] Legacy option retained
83
83
  # for compatibility. Passing it fails closed because console_eval is unavailable.
84
- def initialize(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
85
- unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
86
- stateless: true)
87
- @app = app
88
- @path = path
89
- @embedded_read_tools = embedded_read_tools
90
- @unsafe_eval_confirmation = unsafe_eval_confirmation
91
- @unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
92
- @stateless = stateless
93
- @mutex = Mutex.new
94
- @transport = nil
84
+ # Rails 6.0 forwards middleware options as a positional hash on Ruby 3.
85
+ # Delegate to explicit keywords to preserve required/unknown option checks.
86
+ def initialize(app, options = {}, **keywords)
87
+ raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
88
+
89
+ initialize_options(app, **options, **keywords)
95
90
  end
96
91
 
97
92
  # Rack interface — intercepts requests at the configured path.
@@ -99,7 +94,8 @@ module Woods
99
94
  # The enabled flag is read from Woods.configuration on every request,
100
95
  # never captured at construction: the railtie inserts this middleware
101
96
  # before config/initializers have run (#183). While
102
- # `console_mcp_enabled` is false (the default), requests — even at the
97
+ # `console_mcp_enabled` is false (the default), or HTTP is explicitly
98
+ # disabled with `console_mcp_http_enabled = false`, requests — even at the
103
99
  # mounted path — pass through to the wrapped app unchanged, so a host
104
100
  # that never opted in is completely unaffected. Requests at
105
101
  # non-matching paths always pass through.
@@ -118,11 +114,24 @@ module Woods
118
114
 
119
115
  private
120
116
 
117
+ def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
118
+ unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
119
+ stateless: true)
120
+ @app = app
121
+ @path = path
122
+ @embedded_read_tools = embedded_read_tools
123
+ @unsafe_eval_confirmation = unsafe_eval_confirmation
124
+ @unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
125
+ @stateless = stateless
126
+ @mutex = Mutex.new
127
+ @transport = nil
128
+ end
129
+
121
130
  # Whether the console is enabled, read from the live configuration on
122
131
  # every request. Nil-safe: the railtie mounts this middleware in every
123
132
  # app (#183), including hosts that never called Woods.configure.
124
133
  def enabled?
125
- Woods.configuration&.console_mcp_enabled
134
+ Woods.configuration&.console_mcp_enabled && Woods.configuration.console_mcp_http_enabled
126
135
  end
127
136
 
128
137
  # Thread-safe lazy initialization of the MCP server and transport.
@@ -14,6 +14,7 @@ require_relative 'audit_logger'
14
14
  require_relative 'confirmation'
15
15
  require_relative 'console_response_renderer'
16
16
  require_relative 'credential_scanner'
17
+ require_relative 'credential_scanner_registry'
17
18
  require_relative 'eval_guard'
18
19
  require_relative 'table_gate'
19
20
  require_relative 'redactor'
@@ -44,10 +45,14 @@ module Woods
44
45
  # transport.open
45
46
  #
46
47
  module Server # rubocop:disable Metrics/ModuleLength
48
+ SCANNERS = CredentialScannerRegistry.new
49
+ private_constant :SCANNERS
50
+
47
51
  class << self # rubocop:disable Metrics/ClassLength
48
52
  # Rebuild the boot-time credential index from fresh Rails credentials
49
- # and hot-swap it into the active scanner without restarting the process.
53
+ # and hot-swap it into every live scanner without restarting the process.
50
54
  #
55
+ # Refresh failures propagate without replacing any live index.
51
56
  # Host rotation jobs should call this immediately after `rails credentials:edit`
52
57
  # changes are deployed. The swap is atomic on MRI (GVL) — in-flight scans see
53
58
  # either the old or the new index, never a partial one.
@@ -62,14 +67,11 @@ module Woods
62
67
  # the rebuild was skipped.
63
68
  def rebuild_credential_index(rails_app: nil)
64
69
  return nil unless credential_defense_enabled?
65
- return nil unless @active_scanner
66
70
 
67
71
  target_app = rails_app || default_rails_app
68
72
  return nil unless target_app
69
73
 
70
- new_index = CredentialIndex.build(rails_app: target_app)
71
- @active_scanner.replace_index!(new_index)
72
- new_index
74
+ SCANNERS.rebuild { CredentialIndex.refresh(rails_app: target_app) }
73
75
  end
74
76
 
75
77
  # True when Woods is configured and credential defense is on.
@@ -288,21 +290,21 @@ module Woods
288
290
  model_reflections: model_reflections)
289
291
  end
290
292
 
291
- secret_index = build_credential_index(config)
292
- disabled_patterns = Array(config&.console_disabled_scanner_patterns)
293
- scanner = if disabled_patterns.include?(:all)
294
- nil
295
- else
296
- CredentialScanner.new(
297
- disabled_patterns: disabled_patterns,
298
- secret_index: secret_index
299
- )
300
- end
301
- @active_scanner = scanner
293
+ scanner = build_scanner(config)
302
294
 
303
295
  ResponseContext.build(safe_ctx: safe_ctx, table_gate: table_gate, credential_scanner: scanner)
304
296
  end
305
297
 
298
+ def build_scanner(config)
299
+ disabled_patterns = Array(config&.console_disabled_scanner_patterns)
300
+ return nil if disabled_patterns.include?(:all)
301
+
302
+ SCANNERS.register do
303
+ CredentialScanner.new(disabled_patterns: disabled_patterns,
304
+ secret_index: build_credential_index(config))
305
+ end
306
+ end
307
+
306
308
  # Build the boot-time credential index from Rails.application's encrypted
307
309
  # credentials. Returns nil when credential defense is disabled or when no
308
310
  # Rails application is reachable (specs, non-Rails hosts) — the scanner