woods 1.6.1 → 2.0.0.beta2
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 +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -14,13 +14,15 @@ module Woods
|
|
|
14
14
|
#
|
|
15
15
|
# ## Reachability (v0.2)
|
|
16
16
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
17
|
+
# `EmbeddedExecutor#send_request` already wraps every dispatched tool —
|
|
18
|
+
# `console_eval` included — inside `SafeContext#execute`'s rolled-back
|
|
19
|
+
# transaction before `handle_eval` runs at all. Within that,
|
|
20
|
+
# `handle_eval` calls `EvalGuard#check!` before anything else it
|
|
21
|
+
# controls: ahead of the Confirmation prompt, the timeout, and the audit
|
|
22
|
+
# log. When the opt-in is off (the default), `refusal_for('eval')` still
|
|
23
|
+
# short-circuits with the `eval_disabled` payload and this guard is not
|
|
24
|
+
# reached. See docs/CONSOLE_MCP_SETUP.md "console_eval opt-in" and
|
|
25
|
+
# backlog B-053.
|
|
24
26
|
#
|
|
25
27
|
# Bridge-process mode (in development) will call the same guard before
|
|
26
28
|
# shipping the payload to the remote Rails worker.
|
|
@@ -98,24 +100,29 @@ module Woods
|
|
|
98
100
|
# `remove_method` / `method_defined?` / `prepend` / `include_module`
|
|
99
101
|
# reflection family.
|
|
100
102
|
# - State mutation: `instance_variable_set` / `instance_variable_get`,
|
|
101
|
-
# `class_variable_set` / `class_variable_get
|
|
103
|
+
# `class_variable_set` / `class_variable_get`.
|
|
102
104
|
# - Object-space escapes: `_id2ref`, `each_object`, `const_source_location`.
|
|
103
105
|
# - System / process: `system`, `exec`, `spawn`, `fork`, `popen`, `%x{}`
|
|
104
106
|
# (AST method name `backtick` / xstr) so they can't be invoked
|
|
105
107
|
# implicitly.
|
|
106
|
-
# -
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
# escapes slip through).
|
|
110
|
-
# - Network: `URI.open` (when called as `open` on URI, the AST method
|
|
111
|
-
# name is `open` so the string match above catches it). HTTP / Socket
|
|
112
|
-
# constants are denied separately via DENIED_CONSTANTS.
|
|
113
|
-
# - Loader: `load`, `require`, `require_relative`, `autoload`.
|
|
108
|
+
# - Loader: `require`, `require_relative`, `autoload`. Bare `load` is
|
|
109
|
+
# NOT in this list (Marshal.load/YAML.load call sites are the ones
|
|
110
|
+
# worth denying, and those go through the constant gate below).
|
|
114
111
|
# - Unsafe deserialization: `unsafe_load` / `_load` (Marshal.load and
|
|
115
|
-
# YAML.load are denied via DENIED_CONSTANTS
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
112
|
+
# YAML.load are denied via DENIED_CONSTANTS on the `Marshal`/`YAML`
|
|
113
|
+
# receiver, not through this list).
|
|
114
|
+
# - Legacy taint-mode escapes: `taint` / `untaint`.
|
|
115
|
+
#
|
|
116
|
+
# Bare `open` (Kernel#open, e.g. `open("|shell-command")`) is NOT in
|
|
117
|
+
# this list and is NOT denied — a receiver-less call has no receiver
|
|
118
|
+
# for {#refuse_credential_file_read!} to match either. `File.open` /
|
|
119
|
+
# `IO.open` / `Pathname.new(...).open` are caught only when the
|
|
120
|
+
# argument looks like a credential path, via CREDENTIAL_FILE_READERS
|
|
121
|
+
# below. `URI.open` is denied, but via `URI` being a denied receiver
|
|
122
|
+
# in DENIED_CONSTANTS ({#refuse_denied_constant_receiver!}), not via an
|
|
123
|
+
# `open` entry here. Threading escapes from SafeContext's rollback
|
|
124
|
+
# (`Thread.new`, `Fiber.new`, `Process.fork`, ...) are denied the same
|
|
125
|
+
# way, via their receiver constants.
|
|
119
126
|
DENIED_REFLECTION = %w[
|
|
120
127
|
eval instance_eval class_eval module_eval binding
|
|
121
128
|
instance_exec class_exec module_exec
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Console
|
|
5
|
+
# Normalizes integer inputs and enforces the bounds declared by a ToolSpec.
|
|
6
|
+
module InputContract
|
|
7
|
+
class ValidationError < StandardError; end
|
|
8
|
+
|
|
9
|
+
DECIMAL_INTEGER = /\A-?(?:0|[1-9]\d*)\z/
|
|
10
|
+
private_constant :DECIMAL_INTEGER
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def normalize!(arguments, properties)
|
|
15
|
+
properties.each do |property_name, definition|
|
|
16
|
+
next unless definition[:type] == 'integer'
|
|
17
|
+
|
|
18
|
+
key = argument_key(arguments, property_name)
|
|
19
|
+
next unless key
|
|
20
|
+
|
|
21
|
+
value = parse_integer(arguments[key], property_name)
|
|
22
|
+
enforce_bounds!(value, property_name, definition)
|
|
23
|
+
arguments[key] = value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
arguments
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Reject a String value for any integer-typed property, without
|
|
30
|
+
# attempting to parse it. Used ahead of {normalize!} on paths that skip
|
|
31
|
+
# full JSON-Schema validation (e.g. an unregistered tool spec): without
|
|
32
|
+
# this, a well-formed decimal string like `"15"` sails past
|
|
33
|
+
# {parse_integer} untouched (it's valid input to that method) even
|
|
34
|
+
# though the declared schema's `type: integer` would reject any String
|
|
35
|
+
# outright — silently coercing input the public contract disallows.
|
|
36
|
+
#
|
|
37
|
+
# @param arguments [Hash] Mutated arguments hash (string or symbol keys)
|
|
38
|
+
# @param properties [Hash] ToolSpec JSON Schema property definitions
|
|
39
|
+
# @raise [ValidationError] when an integer-typed property holds a String
|
|
40
|
+
def reject_string_typed_integers!(arguments, properties)
|
|
41
|
+
properties.each do |property_name, definition|
|
|
42
|
+
next unless definition[:type] == 'integer'
|
|
43
|
+
|
|
44
|
+
key = argument_key(arguments, property_name)
|
|
45
|
+
next unless key
|
|
46
|
+
next unless arguments[key].is_a?(String)
|
|
47
|
+
|
|
48
|
+
raise ValidationError, "#{property_name} must be an integer"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def argument_key(arguments, property_name)
|
|
53
|
+
return property_name if arguments.key?(property_name)
|
|
54
|
+
|
|
55
|
+
string_name = property_name.to_s
|
|
56
|
+
string_name if arguments.key?(string_name)
|
|
57
|
+
end
|
|
58
|
+
private_class_method :argument_key
|
|
59
|
+
|
|
60
|
+
def parse_integer(value, property_name)
|
|
61
|
+
return value if value.is_a?(Integer)
|
|
62
|
+
return Integer(value, 10) if value.is_a?(String) && DECIMAL_INTEGER.match?(value)
|
|
63
|
+
|
|
64
|
+
raise ValidationError, "#{property_name} must be an integer"
|
|
65
|
+
end
|
|
66
|
+
private_class_method :parse_integer
|
|
67
|
+
|
|
68
|
+
def enforce_bounds!(value, property_name, definition)
|
|
69
|
+
minimum = definition.fetch(:minimum)
|
|
70
|
+
maximum = definition.fetch(:maximum)
|
|
71
|
+
return if value.between?(minimum, maximum)
|
|
72
|
+
|
|
73
|
+
raise ValidationError, "#{property_name} must be between #{minimum} and #{maximum}"
|
|
74
|
+
end
|
|
75
|
+
private_class_method :enforce_bounds!
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -20,8 +20,14 @@ module Woods
|
|
|
20
20
|
#
|
|
21
21
|
class ModelValidator
|
|
22
22
|
# @param registry [Hash<String, Array<String>>] Model name => column names mapping
|
|
23
|
-
|
|
23
|
+
# @param table_names [Hash<String, String>] Model name => table name mapping, used to
|
|
24
|
+
# resolve a qualified `table.column` reference back to the model that owns it. Optional
|
|
25
|
+
# and defaults to empty — callers that don't supply it get every qualified column
|
|
26
|
+
# reference rejected by {#validate_table_column!} (fail closed: an unmapped table can't
|
|
27
|
+
# be proven safe).
|
|
28
|
+
def initialize(registry:, table_names: {})
|
|
24
29
|
@registry = registry
|
|
30
|
+
@model_by_table = table_names.each_with_object({}) { |(model, table), acc| acc[table.to_s] = model }
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
# Validate that a model name is known.
|
|
@@ -59,6 +65,28 @@ module Woods
|
|
|
59
65
|
column_names.each { |col| validate_column!(model_name, col) }
|
|
60
66
|
end
|
|
61
67
|
|
|
68
|
+
# Validate a qualified `table.column` reference against the real
|
|
69
|
+
# columns of the model that owns `table_name`. Table gating (TableGate)
|
|
70
|
+
# only checks whether a table is *blocked*; it never confirms the
|
|
71
|
+
# column exists on that table at all, so a syntactically valid but
|
|
72
|
+
# nonexistent qualified column previously reached SQL execution and
|
|
73
|
+
# failed as a generic adapter error instead of a typed one.
|
|
74
|
+
#
|
|
75
|
+
# @param table_name [String]
|
|
76
|
+
# @param column_name [String]
|
|
77
|
+
# @return [true]
|
|
78
|
+
# @raise [ValidationError] if the table is unmapped or the column is unknown on it
|
|
79
|
+
def validate_table_column!(table_name, column_name)
|
|
80
|
+
model_name = @model_by_table[table_name.to_s]
|
|
81
|
+
unless model_name
|
|
82
|
+
raise ValidationError,
|
|
83
|
+
"Unknown table '#{table_name}'. Cannot validate qualified column " \
|
|
84
|
+
"'#{table_name}.#{column_name}'."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
validate_column!(model_name, column_name)
|
|
88
|
+
end
|
|
89
|
+
|
|
62
90
|
# List all known model names.
|
|
63
91
|
#
|
|
64
92
|
# @return [Array<String>]
|
|
@@ -17,9 +17,8 @@ module Woods
|
|
|
17
17
|
#
|
|
18
18
|
# config.middleware.use Woods::Console::RackMiddleware, path: '/mcp/console'
|
|
19
19
|
#
|
|
20
|
-
# This mounts
|
|
21
|
-
#
|
|
22
|
-
# pointing users to enable the flag.
|
|
20
|
+
# This mounts the 9 executable Tier 1 tools at /mcp/console. Explicit
|
|
21
|
+
# read-tool mode registers console_sql and console_query as well.
|
|
23
22
|
#
|
|
24
23
|
# == Enabling the feature
|
|
25
24
|
#
|
|
@@ -31,10 +30,12 @@ module Woods
|
|
|
31
30
|
# config.console_redacted_columns = %w[api_token password_digest]
|
|
32
31
|
# end
|
|
33
32
|
#
|
|
34
|
-
# With the flag off, requests
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
33
|
+
# With the flag off, requests — including those at the mounted path —
|
|
34
|
+
# pass through to the app untouched. The Woods railtie mounts this
|
|
35
|
+
# middleware unconditionally (#183), so a host that has not opted in
|
|
36
|
+
# must be completely unaffected. See docs/CONSOLE_MCP_SETUP.md for the
|
|
37
|
+
# full security posture (blocked tables, credential scanner, column/EAV
|
|
38
|
+
# redaction, SafeContext rollback).
|
|
38
39
|
#
|
|
39
40
|
# == Enabling read tools (console_sql + console_query)
|
|
40
41
|
#
|
|
@@ -48,9 +49,11 @@ module Woods
|
|
|
48
49
|
#
|
|
49
50
|
# Security posture with embedded_read_tools: true:
|
|
50
51
|
#
|
|
51
|
-
# 1. SqlValidator
|
|
52
|
-
#
|
|
53
|
-
# interaction.
|
|
52
|
+
# 1. SqlValidator — console_sql rejects INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER/CREATE
|
|
53
|
+
# and similar DML/DDL keywords via a denylist at the string level before any database
|
|
54
|
+
# interaction. REPLACE is rejected too, but by the separate allowed-prefix check
|
|
55
|
+
# (only SELECT, WITH, and EXPLAIN may lead a statement), not the denylist — it isn't
|
|
56
|
+
# in SqlValidator::FORBIDDEN_KEYWORDS.
|
|
54
57
|
#
|
|
55
58
|
# 2. SafeContext rollback — every request (including console_query) runs inside
|
|
56
59
|
# a database transaction that is always rolled back on completion. Even if a
|
|
@@ -61,59 +64,65 @@ module Woods
|
|
|
61
64
|
# ActiveRecord::Base's pool and returns it after the response. No shared
|
|
62
65
|
# mutable state leaks between requests.
|
|
63
66
|
#
|
|
64
|
-
# These three layers
|
|
65
|
-
#
|
|
66
|
-
# (docs/CONSOLE_MCP_SETUP.md) which runs the executor in a separate process.
|
|
67
|
+
# These three layers define the supported read-tool posture. Keep read tools
|
|
68
|
+
# disabled when the host requires a narrower database capability.
|
|
67
69
|
#
|
|
68
|
-
class RackMiddleware
|
|
70
|
+
class RackMiddleware # rubocop:disable Metrics/ClassLength
|
|
69
71
|
# @param app [#call] The next Rack app in the middleware stack
|
|
70
72
|
# @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
|
|
71
|
-
# @param embedded_read_tools [Boolean] Enable sql/query tools in
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
# @param embedded_read_tools [Boolean, #call] Enable sql/query tools in
|
|
74
|
+
# embedded mode (default: false). May be a callable resolved when the
|
|
75
|
+
# server is first built — the railtie passes one because middleware
|
|
76
|
+
# arguments are captured before config/initializers run (#183).
|
|
77
|
+
# @param stateless [Boolean, #call] Use modern stateless Streamable HTTP
|
|
78
|
+
# semantics (default: true). Set false only for compatibility with a
|
|
79
|
+
# client that still requires MCP session IDs.
|
|
80
|
+
# @param unsafe_eval_confirmation [Confirmation, nil] Legacy option retained
|
|
81
|
+
# for compatibility. Passing it fails closed because console_eval is unavailable.
|
|
82
|
+
# @param unsafe_eval_audit_log_path [String, Pathname, nil] Legacy option retained
|
|
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)
|
|
81
87
|
@app = app
|
|
82
88
|
@path = path
|
|
83
89
|
@embedded_read_tools = embedded_read_tools
|
|
84
90
|
@unsafe_eval_confirmation = unsafe_eval_confirmation
|
|
85
91
|
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
92
|
+
@stateless = stateless
|
|
86
93
|
@mutex = Mutex.new
|
|
87
94
|
@transport = nil
|
|
88
95
|
end
|
|
89
96
|
|
|
90
|
-
DISABLED_BODY = JSON.generate(
|
|
91
|
-
error: 'woods_console_disabled',
|
|
92
|
-
message: 'Woods Console MCP is disabled. Set ' \
|
|
93
|
-
'Woods.configuration.console_mcp_enabled = true to enable. ' \
|
|
94
|
-
'See docs/CONSOLE_MCP_SETUP.md for the full security posture.'
|
|
95
|
-
).freeze
|
|
96
|
-
|
|
97
97
|
# Rack interface — intercepts requests at the configured path.
|
|
98
98
|
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
99
|
+
# The enabled flag is read from Woods.configuration on every request,
|
|
100
|
+
# never captured at construction: the railtie inserts this middleware
|
|
101
|
+
# before config/initializers have run (#183). While
|
|
102
|
+
# `console_mcp_enabled` is false (the default), requests — even at the
|
|
103
|
+
# mounted path — pass through to the wrapped app unchanged, so a host
|
|
104
|
+
# that never opted in is completely unaffected. Requests at
|
|
105
|
+
# non-matching paths always pass through.
|
|
103
106
|
#
|
|
104
107
|
# @param env [Hash] Rack environment
|
|
105
108
|
# @return [Array] Rack response triple
|
|
106
109
|
def call(env)
|
|
107
|
-
return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
|
|
108
|
-
return
|
|
110
|
+
return @app.call(env) unless env['PATH_INFO'].to_s.start_with?(@path)
|
|
111
|
+
return @app.call(env) unless enabled?
|
|
109
112
|
|
|
110
|
-
ensure_transport
|
|
113
|
+
transport = ensure_transport
|
|
114
|
+
request_env = env.dup
|
|
115
|
+
request_env.delete('HTTP_MCP_SESSION_ID') if @stateless_mode
|
|
116
|
+
transport.handle_request(Rack::Request.new(request_env))
|
|
111
117
|
end
|
|
112
118
|
|
|
113
119
|
private
|
|
114
120
|
|
|
121
|
+
# Whether the console is enabled, read from the live configuration on
|
|
122
|
+
# every request. Nil-safe: the railtie mounts this middleware in every
|
|
123
|
+
# app (#183), including hosts that never called Woods.configure.
|
|
115
124
|
def enabled?
|
|
116
|
-
Woods.configuration
|
|
125
|
+
Woods.configuration&.console_mcp_enabled
|
|
117
126
|
end
|
|
118
127
|
|
|
119
128
|
# Thread-safe lazy initialization of the MCP server and transport.
|
|
@@ -131,7 +140,10 @@ module Woods
|
|
|
131
140
|
Rails.application.eager_load!
|
|
132
141
|
|
|
133
142
|
server = build_embedded_server
|
|
134
|
-
@
|
|
143
|
+
@stateless_mode = resolve_deferred(@stateless)
|
|
144
|
+
@transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
145
|
+
server, stateless: @stateless_mode
|
|
146
|
+
)
|
|
135
147
|
server.transport = @transport
|
|
136
148
|
@transport
|
|
137
149
|
end
|
|
@@ -176,11 +188,11 @@ module Woods
|
|
|
176
188
|
config = Woods.configuration
|
|
177
189
|
introspection = build_model_introspection
|
|
178
190
|
Server.build_embedded(
|
|
179
|
-
model_validator: ModelValidator.new(registry: introspection[:registry]),
|
|
191
|
+
model_validator: ModelValidator.new(registry: introspection[:registry], table_names: introspection[:tables]),
|
|
180
192
|
safe_context: SafeContext.new(pool: ActiveRecord::Base.connection_pool),
|
|
181
193
|
redacted_columns: Array(config&.console_redacted_columns),
|
|
182
194
|
redacted_key_values: Array(config&.console_redacted_key_values),
|
|
183
|
-
read_tools_enabled: @embedded_read_tools,
|
|
195
|
+
read_tools_enabled: resolve_deferred(@embedded_read_tools),
|
|
184
196
|
model_tables: introspection[:tables],
|
|
185
197
|
model_reflections: introspection[:reflections],
|
|
186
198
|
unsafe_eval_confirmation: @unsafe_eval_confirmation,
|
|
@@ -235,6 +247,17 @@ module Woods
|
|
|
235
247
|
end
|
|
236
248
|
end
|
|
237
249
|
|
|
250
|
+
# Resolve a constructor option that may have been passed as a callable.
|
|
251
|
+
# The railtie defers option values because middleware arguments are
|
|
252
|
+
# captured at railtie-initializer time — before config/initializers
|
|
253
|
+
# have run (#183).
|
|
254
|
+
#
|
|
255
|
+
# @param value [Object, #call]
|
|
256
|
+
# @return [Object]
|
|
257
|
+
def resolve_deferred(value)
|
|
258
|
+
value.respond_to?(:call) ? value.call : value
|
|
259
|
+
end
|
|
260
|
+
|
|
238
261
|
def structured_logger
|
|
239
262
|
@structured_logger ||= Woods::Observability::StructuredLogger.new
|
|
240
263
|
end
|
|
@@ -112,17 +112,30 @@ module Woods
|
|
|
112
112
|
# Resolve EAV patterns against a `columns` header into concrete index
|
|
113
113
|
# pairs. A rule only fires when both key_column and value_column are
|
|
114
114
|
# present in the header, and costs nothing per row otherwise.
|
|
115
|
+
#
|
|
116
|
+
# A duplicated key or value header (an `AS` alias can shadow the real
|
|
117
|
+
# column — CON-1) makes index attribution ambiguous: a last-index-wins
|
|
118
|
+
# lookup would let the shadow steal the mask from the secret. The
|
|
119
|
+
# executor refuses those selects up front; here, defense-in-depth
|
|
120
|
+
# fails toward masking — every cell under a value-named header is
|
|
121
|
+
# redacted unconditionally.
|
|
115
122
|
def positional_kv_rules(columns, ctx)
|
|
116
123
|
return [] unless columns.is_a?(Array)
|
|
117
124
|
|
|
118
|
-
|
|
119
|
-
ctx.redacted_key_values.filter_map
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
names = columns.map(&:to_s)
|
|
126
|
+
ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# One resolved rule for one EAV pattern, or nil when the header lacks
|
|
130
|
+
# either column. Unambiguous headers get the key/value index pair;
|
|
131
|
+
# duplicated headers get the unconditional mask list.
|
|
132
|
+
def positional_kv_rule(names, pattern)
|
|
133
|
+
key_idxs = names.each_index.select { |i| names[i] == pattern['key_column'] }
|
|
134
|
+
val_idxs = names.each_index.select { |i| names[i] == pattern['value_column'] }
|
|
135
|
+
return nil if key_idxs.empty? || val_idxs.empty?
|
|
136
|
+
return { mask_idxs: val_idxs } unless key_idxs.one? && val_idxs.one?
|
|
123
137
|
|
|
124
|
-
|
|
125
|
-
end
|
|
138
|
+
{ key_idx: key_idxs.first, val_idx: val_idxs.first, sensitive: pattern['sensitive_keys'] }
|
|
126
139
|
end
|
|
127
140
|
|
|
128
141
|
# Redact positional row data using a precomputed plan. Handles both
|
|
@@ -140,7 +153,12 @@ module Woods
|
|
|
140
153
|
def redact_row(row, plan)
|
|
141
154
|
result = apply_mask(row, plan[:mask])
|
|
142
155
|
plan[:kv_rules].each do |rule|
|
|
143
|
-
|
|
156
|
+
if rule[:mask_idxs]
|
|
157
|
+
# Ambiguous (duplicated) headers: mask every value-named cell.
|
|
158
|
+
rule[:mask_idxs].each { |idx| result[idx] = '[REDACTED]' }
|
|
159
|
+
elsif rule[:sensitive].include?(row[rule[:key_idx]].to_s)
|
|
160
|
+
result[rule[:val_idx]] = '[REDACTED]'
|
|
161
|
+
end
|
|
144
162
|
end
|
|
145
163
|
result
|
|
146
164
|
end
|
|
@@ -13,8 +13,10 @@ module Woods
|
|
|
13
13
|
#
|
|
14
14
|
# Safety layers:
|
|
15
15
|
# - Every query runs inside a transaction that is always rolled back
|
|
16
|
-
# - Statement timeout
|
|
17
|
-
#
|
|
16
|
+
# - Statement timeout cannot leak to the next pool consumer: PostgreSQL
|
|
17
|
+
# uses `SET LOCAL`, which self-discards at transaction end; MySQL has
|
|
18
|
+
# no LOCAL equivalent, so its session-scoped `max_execution_time` is
|
|
19
|
+
# read before the override and restored in an `ensure` instead
|
|
18
20
|
# - Column redaction replaces sensitive values with "[REDACTED]"
|
|
19
21
|
#
|
|
20
22
|
# == What SafeContext does NOT cover
|
|
@@ -86,7 +88,7 @@ module Woods
|
|
|
86
88
|
# ]
|
|
87
89
|
# )
|
|
88
90
|
#
|
|
89
|
-
class SafeContext
|
|
91
|
+
class SafeContext # rubocop:disable Metrics/ClassLength
|
|
90
92
|
# Thread-local key that exposes the connection currently leased for
|
|
91
93
|
# the in-flight #execute block. Handlers should prefer this over
|
|
92
94
|
# acquiring their own connection so every request stays on a single
|
|
@@ -183,6 +185,24 @@ module Woods
|
|
|
183
185
|
apply_key_value_redaction(redacted)
|
|
184
186
|
end
|
|
185
187
|
|
|
188
|
+
# Return a SafeContext that keeps THIS context's execution behavior —
|
|
189
|
+
# the same pool (or wrapped connection), statement timeout, and
|
|
190
|
+
# rolled-back transaction — with the given redaction policy applied,
|
|
191
|
+
# so the executor and the renderer can share one policy-complete
|
|
192
|
+
# context. A non-empty kwarg defines that list (the documented
|
|
193
|
+
# configuration surface); an empty kwarg preserves the list this
|
|
194
|
+
# context already carries, so spec-style wiring that configures
|
|
195
|
+
# lists on the context keeps working. See {initialize} for the
|
|
196
|
+
# kwarg shapes.
|
|
197
|
+
#
|
|
198
|
+
# @return [SafeContext]
|
|
199
|
+
def with_redaction_policy(redacted_columns: [], redacted_key_values: [])
|
|
200
|
+
policy_columns = redacted_columns.empty? ? @redacted_columns : redacted_columns
|
|
201
|
+
policy_key_values = redacted_key_values.empty? ? @redacted_key_values : redacted_key_values
|
|
202
|
+
SafeContext.new(pool: @pool, timeout_ms: @timeout_ms,
|
|
203
|
+
redacted_columns: policy_columns, redacted_key_values: policy_key_values)
|
|
204
|
+
end
|
|
205
|
+
|
|
186
206
|
private
|
|
187
207
|
|
|
188
208
|
# Wrap one connection in a rolled-back transaction with timeout, and
|
|
@@ -190,17 +210,21 @@ module Woods
|
|
|
190
210
|
# clears the thread-local in ensure so a raise mid-block cannot leak
|
|
191
211
|
# a stale connection reference into the next request on this thread.
|
|
192
212
|
def run_with_timeout(connection)
|
|
193
|
-
|
|
213
|
+
previous_lease = Thread.current[LEASED_CONNECTION_KEY]
|
|
194
214
|
Thread.current[LEASED_CONNECTION_KEY] = connection
|
|
195
215
|
result = nil
|
|
196
216
|
connection.transaction do
|
|
197
|
-
set_timeout(connection)
|
|
198
|
-
|
|
217
|
+
restore_timeout = set_timeout(connection)
|
|
218
|
+
begin
|
|
219
|
+
result = yield(connection)
|
|
220
|
+
ensure
|
|
221
|
+
restore_timeout&.call
|
|
222
|
+
end
|
|
199
223
|
raise ActiveRecord::Rollback
|
|
200
224
|
end
|
|
201
225
|
result
|
|
202
226
|
ensure
|
|
203
|
-
Thread.current[LEASED_CONNECTION_KEY] =
|
|
227
|
+
Thread.current[LEASED_CONNECTION_KEY] = previous_lease
|
|
204
228
|
end
|
|
205
229
|
|
|
206
230
|
def normalize_key_value_patterns(patterns)
|
|
@@ -241,14 +265,25 @@ module Woods
|
|
|
241
265
|
# request, background job, etc.). Safe here because every #execute
|
|
242
266
|
# is wrapped in a transaction.
|
|
243
267
|
#
|
|
244
|
-
# MySQL
|
|
245
|
-
#
|
|
268
|
+
# MySQL has no per-statement equivalent of `SET LOCAL`: `SET
|
|
269
|
+
# max_execution_time` (applies to SELECT only — DDL and DML statements
|
|
270
|
+
# cannot be time-limited via this variable) is SESSION scope and
|
|
271
|
+
# survives ROLLBACK, so left alone it would leak onto the next request
|
|
272
|
+
# served from the same pooled connection. {#set_mysql_timeout} reads
|
|
273
|
+
# the session's current value first and returns a Proc that restores
|
|
274
|
+
# it; the caller runs that Proc in an +ensure+ around the yielded
|
|
275
|
+
# block.
|
|
276
|
+
#
|
|
277
|
+
# @return [Proc, nil] a restore callback for MySQL, or nil when no
|
|
278
|
+
# restoration is needed (PostgreSQL's LOCAL scope self-discards; an
|
|
279
|
+
# unsupported adapter sets nothing to restore).
|
|
246
280
|
def set_timeout(connection, timeout_ms = @timeout_ms)
|
|
247
281
|
adapter = connection.adapter_name.downcase
|
|
248
282
|
if adapter.include?('mysql')
|
|
249
|
-
connection
|
|
283
|
+
set_mysql_timeout(connection, timeout_ms)
|
|
250
284
|
else
|
|
251
285
|
connection.execute("SET LOCAL statement_timeout = '#{timeout_ms.to_i}ms'")
|
|
286
|
+
nil
|
|
252
287
|
end
|
|
253
288
|
rescue StandardError => e
|
|
254
289
|
# Unsupported adapter (SQLite, Trilogy on unsupported version, Oracle) —
|
|
@@ -259,6 +294,19 @@ module Woods
|
|
|
259
294
|
nil
|
|
260
295
|
end
|
|
261
296
|
|
|
297
|
+
# Read MySQL's current session-scoped `max_execution_time`, override
|
|
298
|
+
# it, and return a Proc that restores the value read here. There is no
|
|
299
|
+
# per-statement `SET LOCAL` on MySQL, so the override otherwise
|
|
300
|
+
# outlives this transaction's rollback and bleeds onto whatever the
|
|
301
|
+
# pooled connection serves next.
|
|
302
|
+
#
|
|
303
|
+
# @return [Proc] restores the previous session value
|
|
304
|
+
def set_mysql_timeout(connection, timeout_ms)
|
|
305
|
+
previous_value = connection.select_value('SELECT @@SESSION.max_execution_time').to_i
|
|
306
|
+
connection.execute("SET max_execution_time = #{timeout_ms.to_i}")
|
|
307
|
+
-> { connection.execute("SET max_execution_time = #{previous_value}") }
|
|
308
|
+
end
|
|
309
|
+
|
|
262
310
|
def warn_timeout_unsupported(adapter, error)
|
|
263
311
|
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
264
312
|
|
|
@@ -39,6 +39,15 @@ module Woods
|
|
|
39
39
|
# Suffix pattern — longest suffix match wins because we scan the full list.
|
|
40
40
|
SUFFIX_PATTERN = /(_eq|_not_eq|_gteq|_lteq|_gt|_lt|_not_in|_not_null|_in|_null|_present|_blank|_matches)\z/
|
|
41
41
|
|
|
42
|
+
# Suffix classes, each with its own required value type. Ruby truthiness
|
|
43
|
+
# treats any non-nil, non-false value as true — so without a strict type
|
|
44
|
+
# check, `{status_present: "false"}` (a JSON string) inverts the caller's
|
|
45
|
+
# intent instead of raising. `_eq`/`_not_eq`/`_matches` accept any scalar
|
|
46
|
+
# and are intentionally not classified here.
|
|
47
|
+
EXISTENCE_SUFFIXES = %w[_null _not_null _present _blank].freeze
|
|
48
|
+
COMPARISON_SUFFIXES = %w[_gt _gteq _lt _lteq].freeze
|
|
49
|
+
SET_SUFFIXES = %w[_in _not_in].freeze
|
|
50
|
+
|
|
42
51
|
SUFFIX_HINT = "Supported suffixes: #{SUPPORTED_SUFFIXES.join(', ')}.".freeze
|
|
43
52
|
|
|
44
53
|
# @param model_name [String] ActiveRecord model name (e.g. 'Order')
|
|
@@ -70,8 +79,10 @@ module Woods
|
|
|
70
79
|
suffix = match[1]
|
|
71
80
|
column = key.delete_suffix(suffix)
|
|
72
81
|
@model_validator.validate_column!(@model_name, column)
|
|
82
|
+
validate_suffix_value_type!(suffix, value)
|
|
73
83
|
arel_nodes << build_node(relation, column, suffix, value)
|
|
74
84
|
else
|
|
85
|
+
@model_validator.validate_column!(@model_name, key)
|
|
75
86
|
equality[raw_key] = value
|
|
76
87
|
end
|
|
77
88
|
end
|
|
@@ -83,6 +94,36 @@ module Woods
|
|
|
83
94
|
|
|
84
95
|
private
|
|
85
96
|
|
|
97
|
+
# Enforce the suffix-dependent value type before any Arel node is
|
|
98
|
+
# built. `true`/`false` are checked by identity (`==`), not truthiness,
|
|
99
|
+
# so a JSON string like `"false"` is rejected rather than silently
|
|
100
|
+
# treated as truthy.
|
|
101
|
+
#
|
|
102
|
+
# @param suffix [String] One of SUPPORTED_SUFFIXES
|
|
103
|
+
# @param value [Object] The predicate value
|
|
104
|
+
# @raise [ValidationError] when the value's type doesn't match the suffix class
|
|
105
|
+
def validate_suffix_value_type!(suffix, value)
|
|
106
|
+
case suffix
|
|
107
|
+
when *EXISTENCE_SUFFIXES
|
|
108
|
+
return if [true, false].include?(value)
|
|
109
|
+
|
|
110
|
+
raise ValidationError,
|
|
111
|
+
"Predicate suffix '#{suffix}' requires a strict boolean value " \
|
|
112
|
+
"(got #{value.class}: #{value.inspect}). #{SUFFIX_HINT}"
|
|
113
|
+
when *COMPARISON_SUFFIXES
|
|
114
|
+
return if value.is_a?(String) || value.is_a?(Numeric)
|
|
115
|
+
|
|
116
|
+
raise ValidationError,
|
|
117
|
+
"Predicate suffix '#{suffix}' requires a scalar (String or Numeric) value " \
|
|
118
|
+
"(got #{value.class}: #{value.inspect}). #{SUFFIX_HINT}"
|
|
119
|
+
end
|
|
120
|
+
# _in/_not_in are intentionally not type-checked here: build_node
|
|
121
|
+
# already wraps a scalar in a single-element Array via `Array(value)`,
|
|
122
|
+
# a pre-existing, separately-tested convenience for callers building
|
|
123
|
+
# scope hashes programmatically. The public JSON Schema still
|
|
124
|
+
# requires a real array for _in/_not_in at the MCP boundary.
|
|
125
|
+
end
|
|
126
|
+
|
|
86
127
|
# Build an Arel predicate node for a validated column + suffix.
|
|
87
128
|
#
|
|
88
129
|
# @param relation [ActiveRecord::Relation, Class] Used to get the arel_table
|