woods 1.6.4 → 2.0.0.beta1
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 +1879 -37
- data/CONTRIBUTING.md +195 -137
- data/README.md +162 -520
- 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 +620 -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 +415 -0
- data/docs/INTERNALS.md +415 -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 +197 -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 +39 -3
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +78 -24
- 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 +40 -1
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +18 -13
- 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/credential_index.rb +5 -53
- data/lib/woods/console/credential_scanner.rb +15 -16
- data/lib/woods/console/dispatch_pipeline.rb +46 -34
- data/lib/woods/console/embedded_executor.rb +806 -257
- 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 +24 -6
- data/lib/woods/console/rack_middleware.rb +62 -63
- data/lib/woods/console/redactor.rb +10 -24
- data/lib/woods/console/safe_context.rb +45 -45
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +136 -267
- data/lib/woods/console/sql_noise_stripper.rb +20 -51
- data/lib/woods/console/sql_table_scanner.rb +39 -90
- data/lib/woods/console/sql_validator.rb +455 -85
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +462 -88
- data/lib/woods/console/tools/tier1.rb +0 -3
- data/lib/woods/console/tools/tier4.rb +17 -7
- 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 +2503 -192
- 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 +63 -21
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +138 -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 +408 -34
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +92 -22
- 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 +108 -23
- 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 +907 -154
- 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/mcp/version_aware_tool_dispatch.rb +3 -9
- 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 +223 -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 +70 -38
- 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 +25 -6
- 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.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
- data/lib/woods/session_tracer/session_flow_document.rb +52 -6
- 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 +53 -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 +102 -30
- data/lib/woods/console/adapter_family.rb +0 -39
- 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/console/credential_scanner_registry.rb +0 -36
- data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
- data/lib/woods/console/sql_output_policy.rb +0 -535
- data/lib/woods/console/sqlite_read_guard.rb +0 -46
- 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/mcp/http_transport_options.rb +0 -15
- data/lib/woods/mcp/origin_policy.rb +0 -113
- 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,7 +20,11 @@ module Woods
|
|
|
20
20
|
#
|
|
21
21
|
class ModelValidator
|
|
22
22
|
# @param registry [Hash<String, Array<String>>] Model name => column names mapping
|
|
23
|
-
# @param table_names [Hash<String, String>]
|
|
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).
|
|
24
28
|
def initialize(registry:, table_names: {})
|
|
25
29
|
@registry = registry
|
|
26
30
|
@model_by_table = table_names.each_with_object({}) { |(model, table), acc| acc[table.to_s] = model }
|
|
@@ -61,12 +65,26 @@ module Woods
|
|
|
61
65
|
column_names.each { |col| validate_column!(model_name, col) }
|
|
62
66
|
end
|
|
63
67
|
|
|
64
|
-
#
|
|
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
|
+
#
|
|
65
75
|
# @param table_name [String]
|
|
66
|
-
# @
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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)
|
|
70
88
|
end
|
|
71
89
|
|
|
72
90
|
# List all known model names.
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
-
require 'woods/mcp/http_transport_options'
|
|
5
4
|
require 'woods/observability/structured_logger'
|
|
6
|
-
require 'woods/mcp/bearer_auth'
|
|
7
|
-
require 'woods/mcp/origin_guard'
|
|
8
|
-
require 'rack/request'
|
|
9
5
|
|
|
10
6
|
module Woods
|
|
11
7
|
module Console
|
|
@@ -21,9 +17,8 @@ module Woods
|
|
|
21
17
|
#
|
|
22
18
|
# config.middleware.use Woods::Console::RackMiddleware, path: '/mcp/console'
|
|
23
19
|
#
|
|
24
|
-
# This mounts
|
|
25
|
-
#
|
|
26
|
-
# 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.
|
|
27
22
|
#
|
|
28
23
|
# == Enabling the feature
|
|
29
24
|
#
|
|
@@ -35,10 +30,12 @@ module Woods
|
|
|
35
30
|
# config.console_redacted_columns = %w[api_token password_digest]
|
|
36
31
|
# end
|
|
37
32
|
#
|
|
38
|
-
# With the flag off, requests
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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).
|
|
42
39
|
#
|
|
43
40
|
# == Enabling read tools (console_sql + console_query)
|
|
44
41
|
#
|
|
@@ -52,9 +49,11 @@ module Woods
|
|
|
52
49
|
#
|
|
53
50
|
# Security posture with embedded_read_tools: true:
|
|
54
51
|
#
|
|
55
|
-
# 1. SqlValidator
|
|
56
|
-
#
|
|
57
|
-
# 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.
|
|
58
57
|
#
|
|
59
58
|
# 2. SafeContext rollback — every request (including console_query) runs inside
|
|
60
59
|
# a database transaction that is always rolled back on completion. Even if a
|
|
@@ -65,77 +64,65 @@ module Woods
|
|
|
65
64
|
# ActiveRecord::Base's pool and returns it after the response. No shared
|
|
66
65
|
# mutable state leaks between requests.
|
|
67
66
|
#
|
|
68
|
-
# These three layers
|
|
69
|
-
#
|
|
70
|
-
# (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.
|
|
71
69
|
#
|
|
72
|
-
class RackMiddleware # rubocop:disable Metrics/ClassLength
|
|
70
|
+
class RackMiddleware # rubocop:disable Metrics/ClassLength
|
|
73
71
|
# @param app [#call] The next Rack app in the middleware stack
|
|
74
72
|
# @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
|
|
75
|
-
# @param embedded_read_tools [Boolean] Enable sql/query tools in
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, origin_policy: nil, # rubocop:disable Metrics/ParameterLists -- preserves legacy eval options alongside the shared HTTP policy
|
|
90
|
-
unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil)
|
|
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)
|
|
91
87
|
@app = app
|
|
92
88
|
@path = path
|
|
93
89
|
@embedded_read_tools = embedded_read_tools
|
|
94
90
|
@unsafe_eval_confirmation = unsafe_eval_confirmation
|
|
95
91
|
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
92
|
+
@stateless = stateless
|
|
96
93
|
@mutex = Mutex.new
|
|
97
94
|
@transport = nil
|
|
98
|
-
@origin_policy = origin_policy || Woods::MCP::OriginPolicy.new(
|
|
99
|
-
allowed_origins: Woods.configuration&.console_mcp_allowed_origins
|
|
100
|
-
)
|
|
101
|
-
authenticated = Woods::MCP::BearerAuth.new(
|
|
102
|
-
method(:handle_request), token: -> { Woods.configuration.console_mcp_token }
|
|
103
|
-
)
|
|
104
|
-
@guarded_request = Woods::MCP::OriginGuard.new(authenticated, policy: @origin_policy)
|
|
105
95
|
end
|
|
106
|
-
private :initialize_options
|
|
107
|
-
|
|
108
|
-
DISABLED_BODY = JSON.generate(
|
|
109
|
-
error: 'woods_console_disabled',
|
|
110
|
-
message: 'Woods Console MCP is disabled. Set ' \
|
|
111
|
-
'Woods.configuration.console_mcp_enabled = true to enable. ' \
|
|
112
|
-
'See docs/CONSOLE_MCP_SETUP.md for the full security posture.'
|
|
113
|
-
).freeze
|
|
114
96
|
|
|
115
97
|
# Rack interface — intercepts requests at the configured path.
|
|
116
98
|
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
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.
|
|
121
106
|
#
|
|
122
107
|
# @param env [Hash] Rack environment
|
|
123
108
|
# @return [Array] Rack response triple
|
|
124
109
|
def call(env)
|
|
125
|
-
return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
|
|
126
|
-
return
|
|
110
|
+
return @app.call(env) unless env['PATH_INFO'].to_s.start_with?(@path)
|
|
111
|
+
return @app.call(env) unless enabled?
|
|
127
112
|
|
|
128
|
-
|
|
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))
|
|
129
117
|
end
|
|
130
118
|
|
|
131
119
|
private
|
|
132
120
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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.
|
|
137
124
|
def enabled?
|
|
138
|
-
Woods.configuration
|
|
125
|
+
Woods.configuration&.console_mcp_enabled
|
|
139
126
|
end
|
|
140
127
|
|
|
141
128
|
# Thread-safe lazy initialization of the MCP server and transport.
|
|
@@ -153,8 +140,9 @@ module Woods
|
|
|
153
140
|
Rails.application.eager_load!
|
|
154
141
|
|
|
155
142
|
server = build_embedded_server
|
|
143
|
+
@stateless_mode = resolve_deferred(@stateless)
|
|
156
144
|
@transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
157
|
-
server,
|
|
145
|
+
server, stateless: @stateless_mode
|
|
158
146
|
)
|
|
159
147
|
server.transport = @transport
|
|
160
148
|
@transport
|
|
@@ -204,7 +192,7 @@ module Woods
|
|
|
204
192
|
safe_context: SafeContext.new(pool: ActiveRecord::Base.connection_pool),
|
|
205
193
|
redacted_columns: Array(config&.console_redacted_columns),
|
|
206
194
|
redacted_key_values: Array(config&.console_redacted_key_values),
|
|
207
|
-
read_tools_enabled: @embedded_read_tools,
|
|
195
|
+
read_tools_enabled: resolve_deferred(@embedded_read_tools),
|
|
208
196
|
model_tables: introspection[:tables],
|
|
209
197
|
model_reflections: introspection[:reflections],
|
|
210
198
|
unsafe_eval_confirmation: @unsafe_eval_confirmation,
|
|
@@ -259,6 +247,17 @@ module Woods
|
|
|
259
247
|
end
|
|
260
248
|
end
|
|
261
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
|
+
|
|
262
261
|
def structured_logger
|
|
263
262
|
@structured_logger ||= Woods::Observability::StructuredLogger.new
|
|
264
263
|
end
|
|
@@ -65,8 +65,7 @@ module Woods
|
|
|
65
65
|
case key
|
|
66
66
|
when 'record' then value.is_a?(Hash) ? ctx.redact(value) : value
|
|
67
67
|
when 'records' then redact_hash_array(value, ctx)
|
|
68
|
-
when 'rows'
|
|
69
|
-
when 'values' then redact_positional(value, plan, single_column: plan[:column_count] == 1)
|
|
68
|
+
when 'rows', 'values' then redact_positional(value, plan)
|
|
70
69
|
when 'associations' then redact_association_map(value, ctx)
|
|
71
70
|
else value
|
|
72
71
|
end
|
|
@@ -94,8 +93,7 @@ module Woods
|
|
|
94
93
|
# `columns` header: the column-name mask plus any EAV key-value rules
|
|
95
94
|
# resolved to column indexes.
|
|
96
95
|
def positional_plan(columns, ctx)
|
|
97
|
-
{
|
|
98
|
-
mask: positional_mask(columns, ctx),
|
|
96
|
+
{ mask: positional_mask(columns, ctx),
|
|
99
97
|
kv_rules: positional_kv_rules(columns, ctx) }
|
|
100
98
|
end
|
|
101
99
|
|
|
@@ -125,42 +123,30 @@ module Woods
|
|
|
125
123
|
return [] unless columns.is_a?(Array)
|
|
126
124
|
|
|
127
125
|
names = columns.map(&:to_s)
|
|
128
|
-
ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern
|
|
126
|
+
ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern) }
|
|
129
127
|
end
|
|
130
128
|
|
|
131
129
|
# One resolved rule for one EAV pattern, or nil when the header lacks
|
|
132
130
|
# either column. Unambiguous headers get the key/value index pair;
|
|
133
131
|
# duplicated headers get the unconditional mask list.
|
|
134
|
-
def positional_kv_rule(names, pattern
|
|
132
|
+
def positional_kv_rule(names, pattern)
|
|
135
133
|
key_idxs = names.each_index.select { |i| names[i] == pattern['key_column'] }
|
|
136
134
|
val_idxs = names.each_index.select { |i| names[i] == pattern['value_column'] }
|
|
137
135
|
return nil if key_idxs.empty? || val_idxs.empty?
|
|
138
136
|
return { mask_idxs: val_idxs } unless key_idxs.one? && val_idxs.one?
|
|
139
137
|
|
|
140
|
-
{ key_idx: key_idxs.first, val_idx: val_idxs.first,
|
|
141
|
-
key_matches: ->(value) { sensitive_key?(ctx, value, pattern) } }
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def sensitive_key?(ctx, value, pattern)
|
|
145
|
-
return ctx.sensitive_key?(value, pattern) if ctx.respond_to?(:sensitive_key?)
|
|
146
|
-
|
|
147
|
-
pattern['sensitive_keys'].include?(value.to_s)
|
|
138
|
+
{ key_idx: key_idxs.first, val_idx: val_idxs.first, sensitive: pattern['sensitive_keys'] }
|
|
148
139
|
end
|
|
149
140
|
|
|
150
141
|
# Redact positional row data using a precomputed plan. Handles both
|
|
151
|
-
# nested arrays (multi-column pluck, sql/query rows) and
|
|
152
|
-
# pluck
|
|
153
|
-
|
|
154
|
-
def redact_positional(rows, plan, single_column: false)
|
|
142
|
+
# nested arrays (multi-column pluck, sql/query rows) and flat scalar
|
|
143
|
+
# arrays (pluck with a single column — Rails collapses the result).
|
|
144
|
+
def redact_positional(rows, plan)
|
|
155
145
|
return rows unless rows.is_a?(Array)
|
|
156
146
|
return rows if plan[:mask].nil? && plan[:kv_rules].empty?
|
|
157
147
|
|
|
158
148
|
rows.map do |row|
|
|
159
|
-
|
|
160
|
-
redact_row(row, plan)
|
|
161
|
-
else
|
|
162
|
-
redact_scalar(row, plan[:mask])
|
|
163
|
-
end
|
|
149
|
+
row.is_a?(Array) ? redact_row(row, plan) : redact_scalar(row, plan[:mask])
|
|
164
150
|
end
|
|
165
151
|
end
|
|
166
152
|
|
|
@@ -170,7 +156,7 @@ module Woods
|
|
|
170
156
|
if rule[:mask_idxs]
|
|
171
157
|
# Ambiguous (duplicated) headers: mask every value-named cell.
|
|
172
158
|
rule[:mask_idxs].each { |idx| result[idx] = '[REDACTED]' }
|
|
173
|
-
elsif rule[:
|
|
159
|
+
elsif rule[:sensitive].include?(row[rule[:key_idx]].to_s)
|
|
174
160
|
result[rule[:val_idx]] = '[REDACTED]'
|
|
175
161
|
end
|
|
176
162
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative 'adapter_family'
|
|
4
|
-
|
|
5
3
|
# Stub for environments that don't load ActiveRecord
|
|
6
4
|
unless defined?(ActiveRecord::Rollback)
|
|
7
5
|
module ActiveRecord
|
|
@@ -15,8 +13,10 @@ module Woods
|
|
|
15
13
|
#
|
|
16
14
|
# Safety layers:
|
|
17
15
|
# - Every query runs inside a transaction that is always rolled back
|
|
18
|
-
# - Statement timeout
|
|
19
|
-
#
|
|
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
|
|
20
20
|
# - Column redaction replaces sensitive values with "[REDACTED]"
|
|
21
21
|
#
|
|
22
22
|
# == What SafeContext does NOT cover
|
|
@@ -185,34 +185,22 @@ module Woods
|
|
|
185
185
|
apply_key_value_redaction(redacted)
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
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
|
+
#
|
|
192
198
|
# @return [SafeContext]
|
|
193
|
-
def
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# Match the database spelling and its application representation. If a
|
|
201
|
-
# type cannot safely transform a key, protect the value rather than
|
|
202
|
-
# returning a value whose sensitivity could not be established.
|
|
203
|
-
# @param value [Object] raw or cast EAV key
|
|
204
|
-
# @param pattern [Hash] normalized EAV policy
|
|
205
|
-
# @return [Boolean]
|
|
206
|
-
def sensitive_key?(value, pattern)
|
|
207
|
-
sensitive = pattern['sensitive_keys']
|
|
208
|
-
return true if sensitive.include?(value.to_s)
|
|
209
|
-
|
|
210
|
-
Array(@key_value_types&.fetch(pattern['key_column'], nil)).any? do |type|
|
|
211
|
-
alternate = @raw_key_values ? type.deserialize(value) : type.serialize(value)
|
|
212
|
-
sensitive.include?(alternate.to_s)
|
|
213
|
-
rescue StandardError
|
|
214
|
-
true
|
|
215
|
-
end
|
|
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)
|
|
216
204
|
end
|
|
217
205
|
|
|
218
206
|
private
|
|
@@ -222,7 +210,7 @@ module Woods
|
|
|
222
210
|
# clears the thread-local in ensure so a raise mid-block cannot leak
|
|
223
211
|
# a stale connection reference into the next request on this thread.
|
|
224
212
|
def run_with_timeout(connection)
|
|
225
|
-
|
|
213
|
+
previous_lease = Thread.current[LEASED_CONNECTION_KEY]
|
|
226
214
|
Thread.current[LEASED_CONNECTION_KEY] = connection
|
|
227
215
|
result = nil
|
|
228
216
|
connection.transaction do
|
|
@@ -236,7 +224,7 @@ module Woods
|
|
|
236
224
|
end
|
|
237
225
|
result
|
|
238
226
|
ensure
|
|
239
|
-
Thread.current[LEASED_CONNECTION_KEY] =
|
|
227
|
+
Thread.current[LEASED_CONNECTION_KEY] = previous_lease
|
|
240
228
|
end
|
|
241
229
|
|
|
242
230
|
def normalize_key_value_patterns(patterns)
|
|
@@ -261,7 +249,7 @@ module Woods
|
|
|
261
249
|
key_col = pattern['key_column']
|
|
262
250
|
val_col = pattern['value_column']
|
|
263
251
|
next unless hash.key?(key_col) && hash.key?(val_col)
|
|
264
|
-
next unless
|
|
252
|
+
next unless pattern['sensitive_keys'].include?(hash[key_col].to_s)
|
|
265
253
|
|
|
266
254
|
hash[val_col] = '[REDACTED]'
|
|
267
255
|
end
|
|
@@ -277,12 +265,21 @@ module Woods
|
|
|
277
265
|
# request, background job, etc.). Safe here because every #execute
|
|
278
266
|
# is wrapped in a transaction.
|
|
279
267
|
#
|
|
280
|
-
# MySQL
|
|
281
|
-
#
|
|
282
|
-
#
|
|
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).
|
|
283
280
|
def set_timeout(connection, timeout_ms = @timeout_ms)
|
|
284
281
|
adapter = connection.adapter_name.downcase
|
|
285
|
-
if
|
|
282
|
+
if adapter.include?('mysql')
|
|
286
283
|
set_mysql_timeout(connection, timeout_ms)
|
|
287
284
|
else
|
|
288
285
|
connection.execute("SET LOCAL statement_timeout = '#{timeout_ms.to_i}ms'")
|
|
@@ -297,14 +294,17 @@ module Woods
|
|
|
297
294
|
nil
|
|
298
295
|
end
|
|
299
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
|
|
300
304
|
def set_mysql_timeout(connection, timeout_ms)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
previous_value = Float(connection.select_value("SELECT @@SESSION.#{variable}"))
|
|
305
|
-
connection.execute("SET #{variable} = #{timeout}")
|
|
306
|
-
previous_value = previous_value.to_i unless maria
|
|
307
|
-
-> { connection.execute("SET #{variable} = #{previous_value}") }
|
|
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
308
|
end
|
|
309
309
|
|
|
310
310
|
def warn_timeout_unsupported(adapter, error)
|