woods 1.2.0 → 1.3.0

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +169 -0
  3. data/README.md +20 -8
  4. data/exe/woods-console +51 -6
  5. data/exe/woods-console-mcp +24 -4
  6. data/exe/woods-mcp +30 -7
  7. data/exe/woods-mcp-http +47 -6
  8. data/lib/generators/woods/install_generator.rb +13 -4
  9. data/lib/generators/woods/templates/woods.rb.tt +155 -0
  10. data/lib/tasks/woods.rake +15 -50
  11. data/lib/woods/builder.rb +174 -9
  12. data/lib/woods/cache/cache_middleware.rb +360 -31
  13. data/lib/woods/chunking/semantic_chunker.rb +334 -7
  14. data/lib/woods/console/adapters/job_adapter.rb +10 -4
  15. data/lib/woods/console/audit_logger.rb +76 -4
  16. data/lib/woods/console/bridge.rb +48 -15
  17. data/lib/woods/console/bridge_protocol.rb +44 -0
  18. data/lib/woods/console/confirmation.rb +3 -4
  19. data/lib/woods/console/console_response_renderer.rb +56 -18
  20. data/lib/woods/console/credential_index.rb +201 -0
  21. data/lib/woods/console/credential_scanner.rb +302 -0
  22. data/lib/woods/console/dispatch_pipeline.rb +138 -0
  23. data/lib/woods/console/embedded_executor.rb +682 -35
  24. data/lib/woods/console/eval_guard.rb +319 -0
  25. data/lib/woods/console/model_validator.rb +1 -3
  26. data/lib/woods/console/rack_middleware.rb +185 -29
  27. data/lib/woods/console/redactor.rb +161 -0
  28. data/lib/woods/console/response_context.rb +127 -0
  29. data/lib/woods/console/safe_context.rb +220 -23
  30. data/lib/woods/console/scope_predicate_parser.rb +131 -0
  31. data/lib/woods/console/server.rb +417 -486
  32. data/lib/woods/console/sql_noise_stripper.rb +87 -0
  33. data/lib/woods/console/sql_table_scanner.rb +213 -0
  34. data/lib/woods/console/sql_validator.rb +81 -31
  35. data/lib/woods/console/table_gate.rb +93 -0
  36. data/lib/woods/console/tool_specs.rb +552 -0
  37. data/lib/woods/console/tools/tier1.rb +3 -3
  38. data/lib/woods/console/tools/tier4.rb +7 -1
  39. data/lib/woods/dependency_graph.rb +66 -7
  40. data/lib/woods/embedding/indexer.rb +190 -6
  41. data/lib/woods/embedding/openai.rb +40 -4
  42. data/lib/woods/embedding/provider.rb +104 -8
  43. data/lib/woods/embedding/text_preparer.rb +23 -3
  44. data/lib/woods/embedding/token_counter.rb +133 -0
  45. data/lib/woods/evaluation/baseline_runner.rb +20 -2
  46. data/lib/woods/evaluation/metrics.rb +4 -1
  47. data/lib/woods/extracted_unit.rb +1 -0
  48. data/lib/woods/extractor.rb +7 -1
  49. data/lib/woods/extractors/controller_extractor.rb +6 -0
  50. data/lib/woods/extractors/mailer_extractor.rb +16 -2
  51. data/lib/woods/extractors/model_extractor.rb +6 -1
  52. data/lib/woods/extractors/phlex_extractor.rb +13 -4
  53. data/lib/woods/extractors/rails_source_extractor.rb +2 -0
  54. data/lib/woods/extractors/route_helper_resolver.rb +130 -0
  55. data/lib/woods/extractors/shared_dependency_scanner.rb +130 -2
  56. data/lib/woods/extractors/view_component_extractor.rb +12 -1
  57. data/lib/woods/extractors/view_engines/base.rb +141 -0
  58. data/lib/woods/extractors/view_engines/erb.rb +145 -0
  59. data/lib/woods/extractors/view_template_extractor.rb +92 -133
  60. data/lib/woods/flow_assembler.rb +23 -15
  61. data/lib/woods/flow_precomputer.rb +21 -2
  62. data/lib/woods/graph_analyzer.rb +3 -4
  63. data/lib/woods/index_artifact.rb +173 -0
  64. data/lib/woods/mcp/bearer_auth.rb +45 -0
  65. data/lib/woods/mcp/bootstrap_state.rb +94 -0
  66. data/lib/woods/mcp/bootstrapper.rb +337 -16
  67. data/lib/woods/mcp/config_resolver.rb +288 -0
  68. data/lib/woods/mcp/errors.rb +134 -0
  69. data/lib/woods/mcp/index_reader.rb +265 -30
  70. data/lib/woods/mcp/origin_guard.rb +132 -0
  71. data/lib/woods/mcp/provider_probe.rb +166 -0
  72. data/lib/woods/mcp/renderers/claude_renderer.rb +6 -0
  73. data/lib/woods/mcp/renderers/markdown_renderer.rb +39 -3
  74. data/lib/woods/mcp/renderers/plain_renderer.rb +16 -2
  75. data/lib/woods/mcp/server.rb +737 -137
  76. data/lib/woods/model_name_cache.rb +78 -2
  77. data/lib/woods/notion/client.rb +25 -2
  78. data/lib/woods/notion/mappers/model_mapper.rb +36 -2
  79. data/lib/woods/railtie.rb +55 -15
  80. data/lib/woods/resilience/circuit_breaker.rb +9 -2
  81. data/lib/woods/resilience/retryable_provider.rb +40 -3
  82. data/lib/woods/resolved_config.rb +299 -0
  83. data/lib/woods/retrieval/context_assembler.rb +112 -5
  84. data/lib/woods/retrieval/query_classifier.rb +1 -1
  85. data/lib/woods/retrieval/ranker.rb +55 -6
  86. data/lib/woods/retrieval/search_executor.rb +42 -13
  87. data/lib/woods/retriever.rb +330 -24
  88. data/lib/woods/session_tracer/middleware.rb +35 -1
  89. data/lib/woods/storage/graph_store.rb +39 -0
  90. data/lib/woods/storage/inapplicable_backend.rb +14 -0
  91. data/lib/woods/storage/metadata_store.rb +129 -1
  92. data/lib/woods/storage/pgvector.rb +70 -8
  93. data/lib/woods/storage/qdrant.rb +196 -5
  94. data/lib/woods/storage/snapshotter/metadata.rb +172 -0
  95. data/lib/woods/storage/snapshotter/vector.rb +238 -0
  96. data/lib/woods/storage/snapshotter.rb +24 -0
  97. data/lib/woods/storage/vector_store.rb +184 -35
  98. data/lib/woods/tasks.rb +85 -0
  99. data/lib/woods/temporal/snapshot_store.rb +49 -1
  100. data/lib/woods/token_utils.rb +44 -5
  101. data/lib/woods/unblocked/client.rb +1 -1
  102. data/lib/woods/unblocked/document_builder.rb +35 -10
  103. data/lib/woods/unblocked/exporter.rb +1 -1
  104. data/lib/woods/util/host_guard.rb +61 -0
  105. data/lib/woods/version.rb +1 -1
  106. data/lib/woods.rb +126 -6
  107. metadata +69 -4
@@ -0,0 +1,552 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is a pure data table — 31 ToolSpec entries across 4 tiers
4
+ # (9 read-only / 9 domain-aware / 10 analytics / 3 guarded).
5
+ # Metrics/ModuleLength is disabled here because the module body is almost
6
+ # entirely declarative data, not imperative logic. Decomposition would just
7
+ # scatter the tool catalogue across many files with no readability gain.
8
+ # rubocop:disable Metrics/ModuleLength
9
+ module Woods
10
+ module Console
11
+ module Server
12
+ TIER1_TOOLS = %w[count sample find pluck aggregate association_count schema recent status].freeze
13
+ TIER2_TOOLS = %w[diagnose_model data_snapshot validate_record check_setting update_setting
14
+ check_policy validate_with check_eligibility decorate].freeze
15
+ TIER3_TOOLS = %w[slow_endpoints error_rates throughput job_queues job_failures job_find
16
+ job_schedule redis_info cache_stats channel_status].freeze
17
+ TIER4_TOOLS = %w[eval sql query].freeze
18
+
19
+ # Value object that holds a single MCP tool's declarative specification.
20
+ #
21
+ # @!attribute [r] name
22
+ # @return [String] MCP tool name (e.g. "console_count")
23
+ # @!attribute [r] description
24
+ # @return [String] Human-readable description shown to the LLM
25
+ # @!attribute [r] properties
26
+ # @return [Hash] JSON Schema property definitions (name => {type:, description:})
27
+ # @!attribute [r] required
28
+ # @return [Array<String>, nil] Required property names, or nil
29
+ # @!attribute [r] tier
30
+ # @return [Integer] Tier number (1-4)
31
+ # @!attribute [r] handler
32
+ # @return [Proc] Lambda called with symbolised args hash; returns the
33
+ # request Hash forwarded to the bridge/executor. Any tier-specific
34
+ # objects (validators, guards) are captured in the lambda's closure.
35
+ ToolSpec = Struct.new(:name, :description, :properties, :required, :tier, :handler, keyword_init: true)
36
+
37
+ # All 31 console tool specifications, grouped by tier:
38
+ # Tier 1 (read-only, 9 tools) — no guard required, bridge-level
39
+ # table_gate already constrains reach.
40
+ # Tier 2 (domain-aware, 9 tools) — no guard required, validators run
41
+ # inside the app under SafeContext.
42
+ # Tier 3 (analytics, 10 tools) — no guard required, adapters wrap
43
+ # external services (Redis, job queues, cache).
44
+ # Tier 4 (guarded, 3 tools) — `eval`, `sql`, `query`. Guards ARE
45
+ # MANDATORY for these. The handler lambda for each Tier-4 tool
46
+ # captures the relevant validator/guard closure; the Server's
47
+ # {DispatchPipeline} and {EmbeddedExecutor} refuse to execute a
48
+ # Tier-4 tool whose `guard` is missing or nil. Never call `eval`,
49
+ # `sql`, or `query` without wiring EvalGuard / SqlValidator first.
50
+ # Each spec is a ToolSpec; the handler lambda captures any objects that
51
+ # must be built once at spec-definition time (validators, guards).
52
+ TOOL_SPECS = [
53
+ # ── Tier 1: read-only ─────────────────────────────────────────────────
54
+ ToolSpec.new(
55
+ name: 'console_count',
56
+ description: 'Count records matching scope conditions.',
57
+ properties: {
58
+ model: { type: 'string', description: 'Model name' },
59
+ scope: { type: 'object', description: 'Filter: {status: "paid", total_refund_gt: 0, ' \
60
+ 'transaction_id_not_null: true}. ' \
61
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
62
+ 'Complex queries: use console_query.' }
63
+ },
64
+ required: ['model'],
65
+ tier: 1,
66
+ handler: ->(args) { Tools::Tier1.console_count(model: args[:model], scope: args[:scope]) }
67
+ ),
68
+ ToolSpec.new(
69
+ name: 'console_sample',
70
+ description: 'Random sample of records.',
71
+ properties: {
72
+ model: { type: 'string', description: 'Model name' },
73
+ limit: { type: 'integer', description: 'Max records (default 5, max 25)' },
74
+ columns: { type: 'array', items: { type: 'string' }, description: 'Columns to include' },
75
+ scope: { type: 'object', description: 'Filter: {status: "paid", amount_gt: 100}. ' \
76
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
77
+ 'Complex queries: use console_query.' }
78
+ },
79
+ required: ['model'],
80
+ tier: 1,
81
+ handler: lambda { |args|
82
+ Tools::Tier1.console_sample(
83
+ model: args[:model], scope: args[:scope], limit: args[:limit] || 5, columns: args[:columns]
84
+ )
85
+ }
86
+ ),
87
+ ToolSpec.new(
88
+ name: 'console_find',
89
+ description: 'Find a single record by primary key or unique column',
90
+ properties: {
91
+ model: { type: 'string', description: 'Model name' },
92
+ id: { type: 'integer', description: 'Primary key value' },
93
+ by: { type: 'object', description: 'Unique column lookup' },
94
+ columns: { type: 'array', items: { type: 'string' }, description: 'Columns to include' }
95
+ },
96
+ required: ['model'],
97
+ tier: 1,
98
+ handler: lambda { |args|
99
+ Tools::Tier1.console_find(
100
+ model: args[:model], id: args[:id], by: args[:by], columns: args[:columns]
101
+ )
102
+ }
103
+ ),
104
+ ToolSpec.new(
105
+ name: 'console_pluck',
106
+ description: 'Extract column values from records.',
107
+ properties: {
108
+ model: { type: 'string', description: 'Model name' },
109
+ columns: { type: 'array', items: { type: 'string' }, description: 'Column names to pluck' },
110
+ scope: { type: 'object', description: 'Filter: {status_in: ["paid","refunded"], amount_gt: 0}. ' \
111
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
112
+ 'Complex queries: use console_query.' },
113
+ limit: { type: 'integer', description: 'Max records (default 100, max 1000)' },
114
+ distinct: { type: 'boolean', description: 'Return unique values only' }
115
+ },
116
+ required: %w[model columns],
117
+ tier: 1,
118
+ handler: lambda { |args|
119
+ Tools::Tier1.console_pluck(
120
+ model: args[:model], columns: args[:columns], scope: args[:scope],
121
+ limit: args[:limit] || 100, distinct: args[:distinct] || false
122
+ )
123
+ }
124
+ ),
125
+ ToolSpec.new(
126
+ name: 'console_aggregate',
127
+ description: 'Run aggregate function on a column. ' \
128
+ 'count omits column to count all rows. ' \
129
+ 'Supports scope predicates: {status: "paid", total_gt: 0}. ' \
130
+ 'For complex queries use console_query.',
131
+ properties: {
132
+ model: { type: 'string', description: 'Model name' },
133
+ function: { type: 'string', description: 'Aggregate function: sum, average, minimum, maximum, count' },
134
+ column: { type: 'string', description: 'Column to aggregate (optional for count)' },
135
+ scope: { type: 'object', description: 'Filter conditions: {col: val} or predicate suffixes ' \
136
+ '(_gt, _lt, _in, _null, etc.)' }
137
+ },
138
+ required: %w[model function],
139
+ tier: 1,
140
+ handler: lambda { |args|
141
+ Tools::Tier1.console_aggregate(
142
+ model: args[:model], function: args[:function], column: args[:column], scope: args[:scope]
143
+ )
144
+ }
145
+ ),
146
+ ToolSpec.new(
147
+ name: 'console_association_count',
148
+ description: 'Count associated records for a specific record.',
149
+ properties: {
150
+ model: { type: 'string', description: 'Model name' },
151
+ id: { type: 'integer', description: 'Record primary key' },
152
+ association: { type: 'string', description: 'Association name' },
153
+ scope: { type: 'object', description: 'Filter on association: {status: "paid", amount_gt: 0}. ' \
154
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
155
+ 'Complex queries: use console_query.' }
156
+ },
157
+ required: %w[model id association],
158
+ tier: 1,
159
+ handler: lambda { |args|
160
+ Tools::Tier1.console_association_count(
161
+ model: args[:model], id: args[:id], association: args[:association], scope: args[:scope]
162
+ )
163
+ }
164
+ ),
165
+ ToolSpec.new(
166
+ name: 'console_schema',
167
+ description: 'Get database schema for a model',
168
+ properties: {
169
+ model: { type: 'string', description: 'Model name' },
170
+ include_indexes: { type: 'boolean', description: 'Include index information' }
171
+ },
172
+ required: ['model'],
173
+ tier: 1,
174
+ handler: lambda { |args|
175
+ Tools::Tier1.console_schema(model: args[:model], include_indexes: args[:include_indexes] || false)
176
+ }
177
+ ),
178
+ ToolSpec.new(
179
+ name: 'console_recent',
180
+ description: 'Recently created/updated records.',
181
+ properties: {
182
+ model: { type: 'string', description: 'Model name' },
183
+ order_by: { type: 'string', description: 'Column to sort by (default: created_at)' },
184
+ direction: { type: 'string', description: 'Sort direction: asc or desc (default: desc)' },
185
+ limit: { type: 'integer', description: 'Max records (default 10, max 50)' },
186
+ scope: { type: 'object', description: 'Filter: {status: "paid", total_gt: 0}. ' \
187
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
188
+ 'Complex queries: use console_query.' },
189
+ columns: { type: 'array', items: { type: 'string' }, description: 'Columns to include' }
190
+ },
191
+ required: ['model'],
192
+ tier: 1,
193
+ handler: lambda { |args|
194
+ Tools::Tier1.console_recent(
195
+ model: args[:model], order_by: args[:order_by] || 'created_at',
196
+ direction: args[:direction] || 'desc', limit: args[:limit] || 10,
197
+ scope: args[:scope], columns: args[:columns]
198
+ )
199
+ }
200
+ ),
201
+ ToolSpec.new(
202
+ name: 'console_status',
203
+ description: 'System health check - list models and connection status',
204
+ properties: {},
205
+ required: nil,
206
+ tier: 1,
207
+ handler: ->(_args) { Tools::Tier1.console_status }
208
+ ),
209
+
210
+ # ── Tier 2: domain-aware ──────────────────────────────────────────────
211
+ ToolSpec.new(
212
+ name: 'console_diagnose_model',
213
+ description: 'Diagnose a model: count, recent records, aggregates',
214
+ properties: {
215
+ model: { type: 'string', description: 'Model name' },
216
+ scope: { type: 'object', description: 'Filter conditions' },
217
+ sample_size: { type: 'integer', description: 'Sample records (default 5, max 25)' }
218
+ },
219
+ required: ['model'],
220
+ tier: 2,
221
+ handler: lambda { |args|
222
+ Tools::Tier2.console_diagnose_model(
223
+ model: args[:model], scope: args[:scope], sample_size: args[:sample_size] || 5
224
+ )
225
+ }
226
+ ),
227
+ ToolSpec.new(
228
+ name: 'console_data_snapshot',
229
+ description: 'Snapshot a record with associations for debugging',
230
+ properties: {
231
+ model: { type: 'string', description: 'Model name' },
232
+ id: { type: 'integer', description: 'Record primary key' },
233
+ associations: { type: 'array', items: { type: 'string' }, description: 'Association names to include' },
234
+ depth: { type: 'integer', description: 'Association depth (default 1, max 3)' }
235
+ },
236
+ required: %w[model id],
237
+ tier: 2,
238
+ handler: lambda { |args|
239
+ Tools::Tier2.console_data_snapshot(
240
+ model: args[:model], id: args[:id],
241
+ associations: args[:associations], depth: args[:depth] || 1
242
+ )
243
+ }
244
+ ),
245
+ ToolSpec.new(
246
+ name: 'console_validate_record',
247
+ description: 'Run validations on an existing record',
248
+ properties: {
249
+ model: { type: 'string', description: 'Model name' },
250
+ id: { type: 'integer', description: 'Record primary key' },
251
+ attributes: { type: 'object', description: 'Attributes to set before validating' }
252
+ },
253
+ required: %w[model id],
254
+ tier: 2,
255
+ handler: lambda { |args|
256
+ Tools::Tier2.console_validate_record(
257
+ model: args[:model], id: args[:id], attributes: args[:attributes]
258
+ )
259
+ }
260
+ ),
261
+ ToolSpec.new(
262
+ name: 'console_check_setting',
263
+ description: 'Check a configuration setting value',
264
+ properties: {
265
+ key: { type: 'string', description: 'Setting key' },
266
+ namespace: { type: 'string', description: 'Setting namespace' }
267
+ },
268
+ required: ['key'],
269
+ tier: 2,
270
+ handler: ->(args) { Tools::Tier2.console_check_setting(key: args[:key], namespace: args[:namespace]) }
271
+ ),
272
+ ToolSpec.new(
273
+ name: 'console_update_setting',
274
+ description: 'Update a configuration setting (requires confirmation)',
275
+ properties: {
276
+ key: { type: 'string', description: 'Setting key' },
277
+ value: { type: 'string', description: 'New value' },
278
+ namespace: { type: 'string', description: 'Setting namespace' }
279
+ },
280
+ required: %w[key value],
281
+ tier: 2,
282
+ handler: lambda { |args|
283
+ Tools::Tier2.console_update_setting(
284
+ key: args[:key], value: args[:value], namespace: args[:namespace]
285
+ )
286
+ }
287
+ ),
288
+ ToolSpec.new(
289
+ name: 'console_check_policy',
290
+ description: 'Check authorization policy for a record and user',
291
+ properties: {
292
+ model: { type: 'string', description: 'Model name' },
293
+ id: { type: 'integer', description: 'Record primary key' },
294
+ user_id: { type: 'integer', description: 'User to check' },
295
+ action: { type: 'string', description: 'Policy action' }
296
+ },
297
+ required: %w[model id user_id action],
298
+ tier: 2,
299
+ handler: lambda { |args|
300
+ Tools::Tier2.console_check_policy(
301
+ model: args[:model], id: args[:id], user_id: args[:user_id], action: args[:action]
302
+ )
303
+ }
304
+ ),
305
+ ToolSpec.new(
306
+ name: 'console_validate_with',
307
+ description: 'Validate attributes against a model without persisting',
308
+ properties: {
309
+ model: { type: 'string', description: 'Model name' },
310
+ attributes: { type: 'object', description: 'Attributes to validate' },
311
+ context: { type: 'string', description: 'Validation context' }
312
+ },
313
+ required: %w[model attributes],
314
+ tier: 2,
315
+ handler: lambda { |args|
316
+ Tools::Tier2.console_validate_with(
317
+ model: args[:model], attributes: args[:attributes], context: args[:context]
318
+ )
319
+ }
320
+ ),
321
+ ToolSpec.new(
322
+ name: 'console_check_eligibility',
323
+ description: 'Check feature eligibility for a record',
324
+ properties: {
325
+ model: { type: 'string', description: 'Model name' },
326
+ id: { type: 'integer', description: 'Record primary key' },
327
+ feature: { type: 'string', description: 'Feature name' }
328
+ },
329
+ required: %w[model id feature],
330
+ tier: 2,
331
+ handler: lambda { |args|
332
+ Tools::Tier2.console_check_eligibility(
333
+ model: args[:model], id: args[:id], feature: args[:feature]
334
+ )
335
+ }
336
+ ),
337
+ ToolSpec.new(
338
+ name: 'console_decorate',
339
+ description: 'Invoke a decorator on a record and return computed attributes',
340
+ properties: {
341
+ model: { type: 'string', description: 'Model name' },
342
+ id: { type: 'integer', description: 'Record primary key' },
343
+ methods: { type: 'array', items: { type: 'string' }, description: 'Decorator methods to call' }
344
+ },
345
+ required: %w[model id],
346
+ tier: 2,
347
+ handler: lambda { |args|
348
+ Tools::Tier2.console_decorate(model: args[:model], id: args[:id], methods: args[:methods])
349
+ }
350
+ ),
351
+
352
+ # ── Tier 3: analytics ─────────────────────────────────────────────────
353
+ ToolSpec.new(
354
+ name: 'console_slow_endpoints',
355
+ description: 'List slowest endpoints by response time',
356
+ properties: {
357
+ limit: { type: 'integer', description: 'Max endpoints (default 10, max 100)' },
358
+ period: { type: 'string', description: 'Time period (default: 1h)' }
359
+ },
360
+ required: nil,
361
+ tier: 3,
362
+ handler: lambda { |args|
363
+ Tools::Tier3.console_slow_endpoints(limit: args[:limit] || 10, period: args[:period] || '1h')
364
+ }
365
+ ),
366
+ ToolSpec.new(
367
+ name: 'console_error_rates',
368
+ description: 'Get error rates by controller or overall',
369
+ properties: {
370
+ period: { type: 'string', description: 'Time period (default: 1h)' },
371
+ controller: { type: 'string', description: 'Filter by controller' }
372
+ },
373
+ required: nil,
374
+ tier: 3,
375
+ handler: lambda { |args|
376
+ Tools::Tier3.console_error_rates(period: args[:period] || '1h', controller: args[:controller])
377
+ }
378
+ ),
379
+ ToolSpec.new(
380
+ name: 'console_throughput',
381
+ description: 'Get request throughput over time',
382
+ properties: {
383
+ period: { type: 'string', description: 'Time period (default: 1h)' },
384
+ interval: { type: 'string', description: 'Aggregation interval (default: 5m)' }
385
+ },
386
+ required: nil,
387
+ tier: 3,
388
+ handler: lambda { |args|
389
+ Tools::Tier3.console_throughput(
390
+ period: args[:period] || '1h', interval: args[:interval] || '5m'
391
+ )
392
+ }
393
+ ),
394
+ ToolSpec.new(
395
+ name: 'console_job_queues',
396
+ description: 'Get job queue statistics',
397
+ properties: {
398
+ queue: { type: 'string', description: 'Filter by queue name' }
399
+ },
400
+ required: nil,
401
+ tier: 3,
402
+ handler: ->(args) { Tools::Tier3.console_job_queues(queue: args[:queue]) }
403
+ ),
404
+ ToolSpec.new(
405
+ name: 'console_job_failures',
406
+ description: 'List recent job failures',
407
+ properties: {
408
+ limit: { type: 'integer', description: 'Max failures (default 10, max 100)' },
409
+ queue: { type: 'string', description: 'Filter by queue name' }
410
+ },
411
+ required: nil,
412
+ tier: 3,
413
+ handler: lambda { |args|
414
+ Tools::Tier3.console_job_failures(limit: args[:limit] || 10, queue: args[:queue])
415
+ }
416
+ ),
417
+ ToolSpec.new(
418
+ name: 'console_job_find',
419
+ description: 'Find a job by ID, optionally retry it (requires confirmation)',
420
+ properties: {
421
+ job_id: { type: 'string', description: 'Job identifier' },
422
+ retry: { type: 'boolean', description: 'Retry the job (requires confirmation)' }
423
+ },
424
+ required: ['job_id'],
425
+ tier: 3,
426
+ handler: ->(args) { Tools::Tier3.console_job_find(job_id: args[:job_id], retry_job: args[:retry]) }
427
+ ),
428
+ ToolSpec.new(
429
+ name: 'console_job_schedule',
430
+ description: 'List scheduled/upcoming jobs',
431
+ properties: {
432
+ limit: { type: 'integer', description: 'Max jobs (default 20, max 100)' }
433
+ },
434
+ required: nil,
435
+ tier: 3,
436
+ handler: ->(args) { Tools::Tier3.console_job_schedule(limit: args[:limit] || 20) }
437
+ ),
438
+ ToolSpec.new(
439
+ name: 'console_redis_info',
440
+ description: 'Get Redis server information',
441
+ properties: {
442
+ section: { type: 'string', description: 'INFO section (e.g., memory, stats)' }
443
+ },
444
+ required: nil,
445
+ tier: 3,
446
+ handler: ->(args) { Tools::Tier3.console_redis_info(section: args[:section]) }
447
+ ),
448
+ ToolSpec.new(
449
+ name: 'console_cache_stats',
450
+ description: 'Get cache store statistics',
451
+ properties: {
452
+ namespace: { type: 'string', description: 'Cache namespace filter' }
453
+ },
454
+ required: nil,
455
+ tier: 3,
456
+ handler: ->(args) { Tools::Tier3.console_cache_stats(namespace: args[:namespace]) }
457
+ ),
458
+ ToolSpec.new(
459
+ name: 'console_channel_status',
460
+ description: 'Get ActionCable channel status',
461
+ properties: {
462
+ channel: { type: 'string', description: 'Filter by channel name' }
463
+ },
464
+ required: nil,
465
+ tier: 3,
466
+ handler: ->(args) { Tools::Tier3.console_channel_status(channel: args[:channel]) }
467
+ ),
468
+
469
+ # ── Tier 4: guarded ───────────────────────────────────────────────────
470
+ ToolSpec.new(
471
+ name: 'console_eval',
472
+ description: [
473
+ 'Propose arbitrary Ruby for execution against the live Rails runtime.',
474
+ 'CURRENTLY DISABLED in embedded mode — the call will always return an instructional refusal.',
475
+ 'Before invoking this tool, SHOW the user your proposed Ruby snippet and let them run it ' \
476
+ 'manually. Do not retry on failure, and do not hide the snippet behind the tool call.',
477
+ 'For most cases use console_query (model + select + joins/group_by/having/order) or ' \
478
+ 'console_sql instead — both already support aggregates and scoped filters.'
479
+ ].join(' '),
480
+ properties: {
481
+ code: { type: 'string',
482
+ description: 'Ruby code you propose to run (will be surfaced to the user first)' },
483
+ timeout: { type: 'integer', description: 'Timeout in seconds (default 10, max 30)' }
484
+ },
485
+ required: ['code'],
486
+ tier: 4,
487
+ handler: begin
488
+ config = Woods.configuration if Woods.respond_to?(:configuration)
489
+ guard = EvalGuard.new if config&.console_credential_defense_enabled
490
+ ->(args) { Tools::Tier4.console_eval(code: args[:code], timeout: args[:timeout] || 10, guard: guard) }
491
+ end
492
+ ),
493
+ ToolSpec.new(
494
+ name: 'console_sql',
495
+ description: [
496
+ 'Execute read-only SQL against the live database (SELECT/WITH...SELECT only).',
497
+ 'SqlValidator blocks all DML/DDL. Every query runs inside a rolled-back transaction — no writes persist.',
498
+ 'Requires embedded_read_tools: true in the rack middleware (see docs/CONSOLE_MCP_SETUP.md).',
499
+ 'Use console_query instead when you want ActiveRecord query builder rather than raw SQL.'
500
+ ].join(' '),
501
+ properties: {
502
+ sql: { type: 'string', description: 'SQL query (SELECT or WITH...SELECT only)' },
503
+ limit: { type: 'integer', description: 'Max rows returned (default unlimited, max 10000)' }
504
+ },
505
+ required: ['sql'],
506
+ tier: 4,
507
+ handler: begin
508
+ validator = SqlValidator.new
509
+ ->(args) { Tools::Tier4.console_sql(sql: args[:sql], validator: validator, limit: args[:limit]) }
510
+ end
511
+ ),
512
+ ToolSpec.new(
513
+ name: 'console_query',
514
+ description: [
515
+ 'Build and run a structured ActiveRecord query with optional joins, grouping, and ordering.',
516
+ 'Example: {model: "Order", select: ["status", "COUNT(*) AS n"], group_by: ["status"]}.',
517
+ 'Use console_count or console_aggregate for simple aggregates without a custom SELECT.',
518
+ 'Use console_sql when you need raw SQL that the query builder cannot express.',
519
+ 'Requires embedded_read_tools: true in the rack middleware (see docs/CONSOLE_MCP_SETUP.md).',
520
+ 'Max 10,000 rows returned. Returns columns + rows arrays like a SQL result set.'
521
+ ].join(' '),
522
+ properties: {
523
+ model: { type: 'string', description: 'ActiveRecord model name (e.g. "Order")' },
524
+ select: { type: 'array', items: { type: 'string' },
525
+ description: 'Columns or expressions to select (e.g. ["status", "COUNT(*) AS n"])' },
526
+ joins: { type: 'array', items: { type: 'string' },
527
+ description: 'Association names to JOIN (e.g. ["line_items", "user"])' },
528
+ group_by: { type: 'array', items: { type: 'string' },
529
+ description: 'Columns to GROUP BY (e.g. ["status", "user_id"])' },
530
+ having: { type: 'string',
531
+ description: 'HAVING filter applied after GROUP BY (e.g. "COUNT(*) > 5")' },
532
+ order: { type: 'object',
533
+ description: 'Order specification as {column => direction} (e.g. {"created_at" => "desc"})' },
534
+ scope: { type: 'object',
535
+ description: 'WHERE conditions as {column => value} or [sql, bind] array' },
536
+ limit: { type: 'integer', description: 'Maximum rows to return (default 10000, hard max 10000)' }
537
+ },
538
+ required: %w[model select],
539
+ tier: 4,
540
+ handler: lambda { |args|
541
+ Tools::Tier4.console_query(
542
+ model: args[:model], select: args[:select], joins: args[:joins],
543
+ group_by: args[:group_by], having: args[:having],
544
+ order: args[:order], scope: args[:scope], limit: args[:limit]
545
+ )
546
+ }
547
+ )
548
+ ].freeze
549
+ end
550
+ end
551
+ end
552
+ # rubocop:enable Metrics/ModuleLength
@@ -60,11 +60,11 @@ module Woods
60
60
  # Run aggregate function on a column.
61
61
  #
62
62
  # @param model [String] Model name
63
- # @param function [String] One of: sum, avg, minimum, maximum
64
- # @param column [String] Column to aggregate
63
+ # @param function [String] One of: sum, average, minimum, maximum, count
64
+ # @param column [String, nil] Column to aggregate (optional for count)
65
65
  # @param scope [Hash, nil] Filter conditions
66
66
  # @return [Hash] Bridge request
67
- def console_aggregate(model:, function:, column:, scope: nil)
67
+ def console_aggregate(model:, function:, column: nil, scope: nil)
68
68
  { tool: 'aggregate', params: { model: model, function: function, column: column, scope: scope }.compact }
69
69
  end
70
70
 
@@ -25,8 +25,14 @@ module Woods
25
25
  #
26
26
  # @param code [String] Ruby code to execute
27
27
  # @param timeout [Integer] Execution timeout in seconds (default 10, max 30)
28
+ # @param guard [#check!, nil] Optional EvalGuard instance. When present,
29
+ # the payload is parsed and refused before the bridge request is
30
+ # built — surfacing credential/reflection escapes as a clean MCP
31
+ # error instead of relying on the bridge's own enforcement.
28
32
  # @return [Hash] Bridge request
29
- def console_eval(code:, timeout: DEFAULT_EVAL_TIMEOUT)
33
+ # @raise [Woods::Console::ForbiddenExpressionError] if guard rejects
34
+ def console_eval(code:, timeout: DEFAULT_EVAL_TIMEOUT, guard: nil)
35
+ guard&.check!(code)
30
36
  timeout = timeout.clamp(MIN_EVAL_TIMEOUT, MAX_EVAL_TIMEOUT)
31
37
  { tool: 'eval', params: { code: code, timeout: timeout } }
32
38
  end