woods 2.0.0.beta2 → 2.0.0.beta4

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. metadata +87 -5
@@ -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) }
@@ -145,9 +145,9 @@ module Woods
145
145
 
146
146
  # Scan a value (String, Hash, Array, or any other object) for credentials.
147
147
  #
148
- # Strings are gsub'd against every active pattern. Hash values and Array
149
- # elements are walked recursively; keys and non-string scalars
150
- # (Integer, Float, true/false, nil) pass through untouched.
148
+ # Strings and Symbols are scanned against every active pattern. Hash
149
+ # keys/values and Array elements are walked recursively; numeric values,
150
+ # booleans, and nil pass through untouched. Symbols retain their type.
151
151
  #
152
152
  # @param value [Object]
153
153
  # @return [Array(Object, Hash{Symbol=>Integer})] two-tuple of the scanned
@@ -155,17 +155,18 @@ 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 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) }
169
170
  else value
170
171
  end
171
172
  end
@@ -177,19 +178,19 @@ module Woods
177
178
  # credential-shaped string is emitted as a Symbol after redaction
178
179
  # (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
179
180
  # non-Symbol keys (Integer, etc.) pass through unchanged.
180
- def walk_hash(hash, counts)
181
+ def walk_hash(hash, counts, index)
181
182
  hash.each_with_object({}) do |(key, val), out|
182
183
  scanned_key = case key
183
- when String then scan_string(key, counts)
184
- when Symbol then scan_string(key.to_s, counts).to_sym
184
+ when String then scan_string(key, counts, index)
185
+ when Symbol then scan_string(key.to_s, counts, index).to_sym
185
186
  else key
186
187
  end
187
- out[scanned_key] = walk(val, counts)
188
+ out[scanned_key] = walk(val, counts, index)
188
189
  end
189
190
  end
190
191
 
191
- def scan_string(str, counts)
192
- result = redact_indexed_secrets(str, counts)
192
+ def scan_string(str, counts, index)
193
+ result = redact_indexed_secrets(str, counts, index)
193
194
  result = scan_encoded_forms(result, counts)
194
195
  @active_patterns.inject(result) do |acc, (name, pattern)|
195
196
  acc.gsub(pattern) do
@@ -290,10 +291,10 @@ module Woods
290
291
  printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
291
292
  end
292
293
 
293
- def redact_indexed_secrets(str, counts)
294
- return str unless @secret_index&.match?(str)
294
+ def redact_indexed_secrets(str, counts, index)
295
+ return str unless index&.match?(str)
295
296
 
296
- redacted = @secret_index.redact(str)
297
+ redacted = index.redact(str)
297
298
  counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
298
299
  redacted
299
300
  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
@@ -103,7 +103,14 @@ module Woods
103
103
  response = @conn_mgr.send_request(request)
104
104
  return error_from_response(response, request) unless response['ok']
105
105
 
106
+ # Materialize the wire representation before policy checks. Otherwise
107
+ # Symbols and custom serializers can introduce unscanned strings later.
108
+ # Redact first so protected serializers never run; redact again after
109
+ # normalization to cover fields introduced by a custom serializer.
110
+ # Both renderers consume this same JSON-compatible data tree.
106
111
  result = @ctx.redact(response['result'])
112
+ result = JSON.parse(JSON.generate(result))
113
+ result = @ctx.redact(result)
107
114
  result = scan_for_credentials(result, request)
108
115
  text = @renderer ? @renderer.render_default(result) : JSON.pretty_generate(result)
109
116
  success_response(text)
@@ -479,7 +479,7 @@ module Woods
479
479
  def handle_count(params)
480
480
  model = resolve_model(params['model'])
481
481
  scope = apply_scope(model, params['scope'], model_name: params['model'])
482
- { 'count' => scope.count }
482
+ { 'count' => checked_relation(scope).count }
483
483
  end
484
484
 
485
485
  def handle_sample(params)
@@ -488,7 +488,7 @@ module Woods
488
488
  limit = params.fetch('limit', 5)
489
489
  scope = apply_scope(model, params['scope'], model_name: params['model'])
490
490
  scope = apply_columns(scope, params['columns'])
491
- records = scope.order(random_function).limit(limit)
491
+ records = checked_relation(scope.order(random_function).limit(limit))
492
492
  { 'records' => serialize_records(records, params['columns']) }
493
493
  end
494
494
 
@@ -501,7 +501,8 @@ module Woods
501
501
  end
502
502
  validate_select_columns!(params)
503
503
  model = resolve_model(params['model'])
504
- record = params['id'] ? model.find_by(id: params['id']) : model.find_by(params['by'])
504
+ scope = checked_relation(model)
505
+ record = params['id'] ? scope.find_by(id: params['id']) : scope.find_by(params['by'])
505
506
  { 'record' => record ? serialize_record(record, params['columns']) : nil }
506
507
  end
507
508
 
@@ -534,7 +535,7 @@ module Woods
534
535
  limit = params.fetch('limit', 100)
535
536
  scope = apply_scope(model, params['scope'], model_name: params['model'])
536
537
  scope = scope.distinct if params['distinct']
537
- values = scope.limit(limit).pluck(*columns.map(&:to_sym))
538
+ values = checked_relation(scope.limit(limit)).pluck(*columns.map(&:to_sym))
538
539
  { 'columns' => Array(columns), 'values' => values }
539
540
  end
540
541
 
@@ -554,6 +555,7 @@ module Woods
554
555
 
555
556
  model = resolve_model(params['model'])
556
557
  scope = apply_scope(model, params['scope'], model_name: params['model'])
558
+ scope = checked_relation(scope)
557
559
 
558
560
  value = if function == 'count'
559
561
  column ? scope.count(column.to_sym) : scope.count
@@ -566,6 +568,7 @@ module Woods
566
568
  def handle_association_count(params)
567
569
  model = resolve_model(params['model'])
568
570
  association_name = params['association']
571
+ requested_scope = params['scope']
569
572
  reflection = model.reflect_on_association(association_name.to_sym)
570
573
 
571
574
  raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}" unless reflection
@@ -581,15 +584,33 @@ module Woods
581
584
  # association's own model before any database I/O runs (not just
582
585
  # before the association is read): `model.find` below is itself a
583
586
  # query, and a request with a bad scope should never reach it.
584
- validate_scope_columns!(params['scope'], reflection.klass.name) if params['scope']
587
+ validate_scope_columns!(requested_scope, reflection.klass.name) if requested_scope
585
588
 
586
- record = model.find(params['id'])
589
+ record = checked_relation(model).find(params['id'])
587
590
  scope = record.public_send(association_name)
588
- scope = apply_scope(scope, params['scope'], model_name: reflection.klass.name) if params['scope']
591
+ scope = apply_scope(scope, requested_scope, model_name: reflection.klass.name) if requested_scope
589
592
  gate_association_sql!(scope)
590
593
  { 'count' => scope.count }
591
594
  end
592
595
 
596
+ # Materialize a model's default scope once, inspect the resolved SQL,
597
+ # and return that same relation for execution. Checking model.table_name
598
+ # alone misses default scopes that change FROM or introduce joins. Do
599
+ # not check model.all then issue a fresh query through the model: a
600
+ # dynamic default scope could differ between the check and execution.
601
+ # With no TableGate collaborator, retain the ungated executor contract.
602
+ #
603
+ # @param scope [Class, ActiveRecord::Relation] Pending model read
604
+ # @return [Class, ActiveRecord::Relation] Relation authorized for reading
605
+ # @raise [ValidationError] if its SQL references a blocked table
606
+ def checked_relation(scope)
607
+ return scope unless @table_gate
608
+
609
+ relation = scope.all
610
+ gate_sql!(relation.to_sql)
611
+ relation
612
+ end
613
+
593
614
  # Defense-in-depth: gate_association! (called by
594
615
  # {#handle_association_count} before this) only proves the
595
616
  # association's OWN target table isn't blocked. A through-association,
@@ -738,7 +759,7 @@ module Woods
738
759
 
739
760
  scope = apply_scope(model, params['scope'], model_name: params['model'])
740
761
  scope = apply_columns(scope, params['columns'])
741
- records = scope.order(order_by => direction.to_sym).limit(limit)
762
+ records = checked_relation(scope.order(order_by => direction.to_sym).limit(limit))
742
763
  { 'records' => serialize_records(records, params['columns']) }
743
764
  end
744
765
 
@@ -798,13 +819,14 @@ module Woods
798
819
  # and PostgreSQL quote/comment grammars differ (`\'` escapes, `#`
799
820
  # comments); validating with the matching dialect accepts dialect-valid
800
821
  # literals and still rejects every known bypass form. Unknown adapters
801
- # return nil and get the conservative both-dialect union.
822
+ # return nil and get the conservative union of supported dialects.
802
823
  #
803
824
  # @return [Symbol, nil]
804
825
  def sql_dialect
805
826
  adapter = active_connection.adapter_name.to_s.downcase
806
827
  return :mysql if adapter.include?('mysql')
807
828
  return :postgres if adapter.include?('postgre')
829
+ return :sqlite if adapter.include?('sqlite')
808
830
 
809
831
  nil
810
832
  end
@@ -1491,7 +1513,7 @@ module Woods
1491
1513
  # `SqlNoiseStripper` is the same module SqlValidator uses. The
1492
1514
  # combined single-pass strip_noise resolves comments and literals
1493
1515
  # together so a comment marker inside a literal can't hide a keyword.
1494
- stripped = SqlNoiseStripper.strip_noise(template)
1516
+ stripped = SqlNoiseStripper.strip_noise(template, dialect: sql_dialect || :postgres, **mysql_quote_modes)
1495
1517
  if SCOPE_TEMPLATE_FORBIDDEN.match?(stripped)
1496
1518
  raise ValidationError,
1497
1519
  '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
@@ -53,20 +53,21 @@ module Woods
53
53
  # single-quote scanner.
54
54
  #
55
55
  # @param sql [String] the SQL string to process
56
- # @param dialect [Symbol] `:postgres` (default) or `:mysql`.
56
+ # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite`.
57
57
  # - `:postgres` — single-quoted strings support `''` as an apostrophe
58
58
  # escape. Backslash is treated literally and does not escape quotes.
59
59
  # Dollar-quoted strings (`$$...$$`, `$tag$...$tag$`) are also stripped.
60
60
  # - `:mysql` — single-quoted strings support both `\'` (backslash-escape)
61
61
  # and `''` (doubled-quote) as apostrophe escapes. Dollar-quoted strings
62
62
  # are not recognized by the combined MySQL security scanner.
63
+ # - `:sqlite` — doubled apostrophes escape strings; backslashes and dollar signs are literal.
63
64
  # @return [String] a new string with all string literals replaced by `''`
64
65
  # @raise [ArgumentError] if an unsupported dialect is provided
65
66
  DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
66
67
  SINGLE_QUOTED_POSTGRES = /'(?:''|[^'])*'/m
67
68
  SINGLE_QUOTED_MYSQL = /'(?:\\.|''|[^'])*'/m
68
69
 
69
- SUPPORTED_DIALECTS = %i[postgres mysql].freeze
70
+ SUPPORTED_DIALECTS = %i[postgres mysql sqlite].freeze
70
71
  private_constant :SUPPORTED_DIALECTS
71
72
 
72
73
  def self.strip_literals(sql, dialect: :postgres)
@@ -76,7 +77,7 @@ module Woods
76
77
 
77
78
  # Strip dollar-quoted strings first so stray apostrophes inside them
78
79
  # do not interfere with the single-quote scanner.
79
- out = sql.gsub(DOLLAR_QUOTED, "''")
80
+ out = dialect == :sqlite ? sql : sql.gsub(DOLLAR_QUOTED, "''")
80
81
 
81
82
  pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
82
83
  out.gsub(pattern, "''")
@@ -112,7 +113,7 @@ module Woods
112
113
  # still reads as following a boundary once comments are gone.
113
114
  #
114
115
  # @param sql [String] the SQL string to process
115
- # @param dialect [Symbol] `:postgres` (default) or `:mysql` — controls
116
+ # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
116
117
  # single-quote escape rules (see {.strip_literals}) and whether `#`
117
118
  # opens a line comment. MySQL quote flags reflect session sql_mode.
118
119
  # @return [String] a new string with comments removed and every string
@@ -134,7 +135,8 @@ module Woods
134
135
  if ch == "'"
135
136
  close = single_quote_end(
136
137
  sql, i,
137
- backslash_escapes: (mysql && !no_backslash_escapes) || (!mysql && postgres_escape_string?(sql, i))
138
+ backslash_escapes: (mysql && !no_backslash_escapes) ||
139
+ (dialect == :postgres && postgres_escape_string?(sql, i))
138
140
  )
139
141
  if close
140
142
  out << "''"
@@ -158,7 +160,7 @@ module Woods
158
160
  out << ch
159
161
  i += 1
160
162
  end
161
- elsif mysql && ch == '`'
163
+ elsif (mysql || dialect == :sqlite) && ch == '`'
162
164
  close = quoted_span_end(sql, i, quote: '`', backslash_escapes: false)
163
165
  if close
164
166
  # Backticks delimit identifiers. Preserve the token for table
@@ -170,7 +172,7 @@ module Woods
170
172
  out << ch
171
173
  i += 1
172
174
  end
173
- elsif !mysql && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
175
+ elsif dialect == :postgres && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
174
176
  close = sql.index(tag, i + tag.length)
175
177
  if close
176
178
  out << "''"
@@ -167,6 +167,49 @@ module Woods
167
167
  results.uniq
168
168
  end
169
169
 
170
+ # Table-factor prefixes, retaining commas after balanced subqueries and
171
+ # JOIN predicates. Each nested FROM/JOIN is also scanned independently.
172
+ # @param sql [String] noise-stripped SQL
173
+ # @return [Array<String>]
174
+ def self.relation_factors(sql)
175
+ sql.to_enum(:scan, /\b(?:FROM|JOIN)\s+/i).flat_map do
176
+ suffix = sql[Regexp.last_match.end(0)..]
177
+ split_top_level_commas(relation_clause(suffix))
178
+ end
179
+ end
180
+
181
+ # Quoted tokens shield punctuation and clause words; a closing parenthesis
182
+ # at depth zero ends this query's clause, not a nested table expression.
183
+ def self.relation_clause(suffix)
184
+ depth = 0
185
+ tokens = /
186
+ "(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]|
187
+ \b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|WINDOW)\b
188
+ /ix
189
+ suffix.to_enum(:scan, tokens).each do
190
+ token = Regexp.last_match[0]
191
+ start = Regexp.last_match.begin(0)
192
+ finish = Regexp.last_match.end(0)
193
+ boundary = token == ')' || relation_keyword_boundary?(suffix[0...start], suffix[finish..], token)
194
+ return suffix[0...start] if depth.zero? && boundary
195
+
196
+ depth += 1 if token == '('
197
+ depth -= 1 if token == ')'
198
+ end
199
+ suffix
200
+ end
201
+ private_class_method :relation_clause
202
+
203
+ def self.relation_keyword_boundary?(prefix, rest, token)
204
+ return false unless token.match?(/\A[A-Za-z]/)
205
+ return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
206
+ return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
207
+ return rest.match?(/\A\s+\w+\s+AS\b/i) if token.casecmp?('WINDOW')
208
+
209
+ true
210
+ end
211
+ private_class_method :relation_keyword_boundary?
212
+
170
213
  # @api private
171
214
  # Every view of the stripped SQL the FROM/JOIN scans must consider for
172
215
  # MySQL executable comments (`/*! ... */`). {SqlNoiseStripper} leaves
@@ -203,12 +246,9 @@ module Woods
203
246
 
204
247
  # @api private
205
248
  def self.collect_from_identifiers(sql, results)
206
- sql.scan(FROM_CLAUSE) do
207
- clause = Regexp.last_match[:clause]
208
- split_top_level_commas(clause).each do |chunk|
209
- ident = lead_identifier(chunk)
210
- results << ident if ident
211
- end
249
+ relation_factors(sql).each do |chunk|
250
+ ident = lead_identifier(chunk)
251
+ results << ident if ident
212
252
  end
213
253
  end
214
254
  private_class_method :collect_from_identifiers
@@ -229,7 +269,7 @@ module Woods
229
269
  depth = 0
230
270
  buf = +''
231
271
  parts = []
232
- clause.each_char do |ch|
272
+ clause.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|./m).each do |ch|
233
273
  case ch
234
274
  when '('
235
275
  depth += 1