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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +169 -0
- data/README.md +20 -8
- data/exe/woods-console +51 -6
- data/exe/woods-console-mcp +24 -4
- data/exe/woods-mcp +30 -7
- data/exe/woods-mcp-http +47 -6
- data/lib/generators/woods/install_generator.rb +13 -4
- data/lib/generators/woods/templates/woods.rb.tt +155 -0
- data/lib/tasks/woods.rake +15 -50
- data/lib/woods/builder.rb +174 -9
- data/lib/woods/cache/cache_middleware.rb +360 -31
- data/lib/woods/chunking/semantic_chunker.rb +334 -7
- data/lib/woods/console/adapters/job_adapter.rb +10 -4
- data/lib/woods/console/audit_logger.rb +76 -4
- data/lib/woods/console/bridge.rb +48 -15
- data/lib/woods/console/bridge_protocol.rb +44 -0
- data/lib/woods/console/confirmation.rb +3 -4
- data/lib/woods/console/console_response_renderer.rb +56 -18
- data/lib/woods/console/credential_index.rb +201 -0
- data/lib/woods/console/credential_scanner.rb +302 -0
- data/lib/woods/console/dispatch_pipeline.rb +138 -0
- data/lib/woods/console/embedded_executor.rb +682 -35
- data/lib/woods/console/eval_guard.rb +319 -0
- data/lib/woods/console/model_validator.rb +1 -3
- data/lib/woods/console/rack_middleware.rb +185 -29
- data/lib/woods/console/redactor.rb +161 -0
- data/lib/woods/console/response_context.rb +127 -0
- data/lib/woods/console/safe_context.rb +220 -23
- data/lib/woods/console/scope_predicate_parser.rb +131 -0
- data/lib/woods/console/server.rb +417 -486
- data/lib/woods/console/sql_noise_stripper.rb +87 -0
- data/lib/woods/console/sql_table_scanner.rb +213 -0
- data/lib/woods/console/sql_validator.rb +81 -31
- data/lib/woods/console/table_gate.rb +93 -0
- data/lib/woods/console/tool_specs.rb +552 -0
- data/lib/woods/console/tools/tier1.rb +3 -3
- data/lib/woods/console/tools/tier4.rb +7 -1
- data/lib/woods/dependency_graph.rb +66 -7
- data/lib/woods/embedding/indexer.rb +190 -6
- data/lib/woods/embedding/openai.rb +40 -4
- data/lib/woods/embedding/provider.rb +104 -8
- data/lib/woods/embedding/text_preparer.rb +23 -3
- data/lib/woods/embedding/token_counter.rb +133 -0
- data/lib/woods/evaluation/baseline_runner.rb +20 -2
- data/lib/woods/evaluation/metrics.rb +4 -1
- data/lib/woods/extracted_unit.rb +1 -0
- data/lib/woods/extractor.rb +7 -1
- data/lib/woods/extractors/controller_extractor.rb +6 -0
- data/lib/woods/extractors/mailer_extractor.rb +16 -2
- data/lib/woods/extractors/model_extractor.rb +6 -1
- data/lib/woods/extractors/phlex_extractor.rb +13 -4
- data/lib/woods/extractors/rails_source_extractor.rb +2 -0
- data/lib/woods/extractors/route_helper_resolver.rb +130 -0
- data/lib/woods/extractors/shared_dependency_scanner.rb +130 -2
- data/lib/woods/extractors/view_component_extractor.rb +12 -1
- data/lib/woods/extractors/view_engines/base.rb +141 -0
- data/lib/woods/extractors/view_engines/erb.rb +145 -0
- data/lib/woods/extractors/view_template_extractor.rb +92 -133
- data/lib/woods/flow_assembler.rb +23 -15
- data/lib/woods/flow_precomputer.rb +21 -2
- data/lib/woods/graph_analyzer.rb +3 -4
- data/lib/woods/index_artifact.rb +173 -0
- data/lib/woods/mcp/bearer_auth.rb +45 -0
- data/lib/woods/mcp/bootstrap_state.rb +94 -0
- data/lib/woods/mcp/bootstrapper.rb +337 -16
- data/lib/woods/mcp/config_resolver.rb +288 -0
- data/lib/woods/mcp/errors.rb +134 -0
- data/lib/woods/mcp/index_reader.rb +265 -30
- data/lib/woods/mcp/origin_guard.rb +132 -0
- data/lib/woods/mcp/provider_probe.rb +166 -0
- data/lib/woods/mcp/renderers/claude_renderer.rb +6 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +39 -3
- data/lib/woods/mcp/renderers/plain_renderer.rb +16 -2
- data/lib/woods/mcp/server.rb +737 -137
- data/lib/woods/model_name_cache.rb +78 -2
- data/lib/woods/notion/client.rb +25 -2
- data/lib/woods/notion/mappers/model_mapper.rb +36 -2
- data/lib/woods/railtie.rb +55 -15
- data/lib/woods/resilience/circuit_breaker.rb +9 -2
- data/lib/woods/resilience/retryable_provider.rb +40 -3
- data/lib/woods/resolved_config.rb +299 -0
- data/lib/woods/retrieval/context_assembler.rb +112 -5
- data/lib/woods/retrieval/query_classifier.rb +1 -1
- data/lib/woods/retrieval/ranker.rb +55 -6
- data/lib/woods/retrieval/search_executor.rb +42 -13
- data/lib/woods/retriever.rb +330 -24
- data/lib/woods/session_tracer/middleware.rb +35 -1
- data/lib/woods/storage/graph_store.rb +39 -0
- data/lib/woods/storage/inapplicable_backend.rb +14 -0
- data/lib/woods/storage/metadata_store.rb +129 -1
- data/lib/woods/storage/pgvector.rb +70 -8
- data/lib/woods/storage/qdrant.rb +196 -5
- data/lib/woods/storage/snapshotter/metadata.rb +172 -0
- data/lib/woods/storage/snapshotter/vector.rb +238 -0
- data/lib/woods/storage/snapshotter.rb +24 -0
- data/lib/woods/storage/vector_store.rb +184 -35
- data/lib/woods/tasks.rb +85 -0
- data/lib/woods/temporal/snapshot_store.rb +49 -1
- data/lib/woods/token_utils.rb +44 -5
- data/lib/woods/unblocked/client.rb +1 -1
- data/lib/woods/unblocked/document_builder.rb +35 -10
- data/lib/woods/unblocked/exporter.rb +1 -1
- data/lib/woods/util/host_guard.rb +61 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +126 -6
- 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,
|
|
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
|
|
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
|
-
|
|
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
|