woods 1.6.2 → 1.6.4
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 +36 -0
- data/CONTRIBUTING.md +19 -11
- data/README.md +1 -1
- data/exe/woods-console +1 -1
- data/exe/woods-mcp-http +17 -4
- data/lib/woods/cache/cache_middleware.rb +11 -11
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/credential_scanner.rb +2 -1
- data/lib/woods/console/dispatch_pipeline.rb +22 -4
- data/lib/woods/console/embedded_executor.rb +139 -31
- data/lib/woods/console/model_validator.rb +11 -1
- data/lib/woods/console/rack_middleware.rb +27 -6
- data/lib/woods/console/redactor.rb +46 -14
- data/lib/woods/console/safe_context.rb +56 -8
- data/lib/woods/console/server.rb +2 -1
- data/lib/woods/console/sql_noise_stripper.rb +165 -25
- data/lib/woods/console/sql_output_policy.rb +535 -0
- data/lib/woods/console/sql_table_scanner.rb +163 -52
- data/lib/woods/console/sql_validator.rb +70 -10
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/mcp/bearer_auth.rb +29 -10
- data/lib/woods/mcp/http_transport_options.rb +2 -11
- data/lib/woods/mcp/origin_guard.rb +25 -51
- data/lib/woods/mcp/origin_policy.rb +113 -0
- data/lib/woods/railtie.rb +11 -4
- data/lib/woods/version.rb +1 -1
- metadata +9 -5
|
@@ -8,9 +8,13 @@ require_relative 'confirmation'
|
|
|
8
8
|
require_relative 'eval_guard'
|
|
9
9
|
require_relative 'model_validator'
|
|
10
10
|
require_relative 'safe_context'
|
|
11
|
+
require_relative 'redactor'
|
|
11
12
|
require_relative 'scope_predicate_parser'
|
|
12
13
|
require_relative 'sql_noise_stripper'
|
|
14
|
+
require_relative 'sql_validator'
|
|
13
15
|
require_relative 'table_gate'
|
|
16
|
+
require_relative 'adapter_family'
|
|
17
|
+
require_relative 'sql_output_policy'
|
|
14
18
|
|
|
15
19
|
module Woods
|
|
16
20
|
module Console
|
|
@@ -28,6 +32,8 @@ module Woods
|
|
|
28
32
|
# # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
|
|
29
33
|
#
|
|
30
34
|
class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
|
|
35
|
+
include SqlOutputPolicy
|
|
36
|
+
|
|
31
37
|
AGGREGATE_FUNCTIONS = %w[sum average minimum maximum count].freeze
|
|
32
38
|
|
|
33
39
|
TIER1_TOOLS = BridgeProtocol::TIER1_TOOLS
|
|
@@ -46,6 +52,7 @@ module Woods
|
|
|
46
52
|
|
|
47
53
|
# @param model_validator [ModelValidator] Validates model/column names
|
|
48
54
|
# @param safe_context [SafeContext] Wraps execution in rolled-back transaction
|
|
55
|
+
# @param redaction_context [SafeContext, nil] Output policy supplied by the server renderer
|
|
49
56
|
# @param connection [Object, nil] Database connection for adapter detection
|
|
50
57
|
# @param read_tools_enabled [Boolean] Enable sql/query tools in embedded mode (default: false)
|
|
51
58
|
# @param table_gate [TableGate, nil] Enforces console_blocked_tables on every
|
|
@@ -68,9 +75,10 @@ module Woods
|
|
|
68
75
|
# refusal as before.
|
|
69
76
|
def initialize(model_validator:, safe_context:, connection: nil, read_tools_enabled: false, # rubocop:disable Metrics/ParameterLists
|
|
70
77
|
table_gate: nil, eval_guard: nil, confirmation: nil, audit_logger: nil,
|
|
71
|
-
unsafe_eval_enabled: false)
|
|
78
|
+
unsafe_eval_enabled: false, redaction_context: nil)
|
|
72
79
|
@model_validator = model_validator
|
|
73
80
|
@safe_context = safe_context
|
|
81
|
+
@redaction_context = redaction_context || safe_context
|
|
74
82
|
@connection = connection
|
|
75
83
|
@read_tools_enabled = read_tools_enabled
|
|
76
84
|
@table_gate = table_gate
|
|
@@ -98,7 +106,7 @@ module Woods
|
|
|
98
106
|
return refusal if refusal
|
|
99
107
|
|
|
100
108
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
101
|
-
result = @safe_context.execute {
|
|
109
|
+
result = @safe_context.execute { dispatch_with_key_redaction(tool, params) }
|
|
102
110
|
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
|
|
103
111
|
|
|
104
112
|
{ 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
|
|
@@ -118,6 +126,60 @@ module Woods
|
|
|
118
126
|
|
|
119
127
|
private
|
|
120
128
|
|
|
129
|
+
def dispatch_with_key_redaction(tool, params)
|
|
130
|
+
refuse_protected_scope!(params['scope'])
|
|
131
|
+
refuse_protected_scope!(params['by']) if tool == 'find'
|
|
132
|
+
output = dispatch(tool, params)
|
|
133
|
+
return output if redaction_key_values.empty?
|
|
134
|
+
|
|
135
|
+
Redactor.apply(output, typed_redaction_context(tool, params))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def typed_redaction_context(tool, params)
|
|
139
|
+
types = Hash.new { |hash, key| hash[key] = [] }
|
|
140
|
+
typed_redaction_models(tool, params).each do |name, model|
|
|
141
|
+
redaction_key_values.each do |pattern|
|
|
142
|
+
key = pattern['key_column']
|
|
143
|
+
types[key] << model.type_for_attribute(key) if @model_validator.columns_for(name).include?(key)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
@redaction_context.with_key_value_types(types, raw: %w[sql query].include?(tool))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def typed_redaction_models(tool, params)
|
|
150
|
+
tables = if tool == 'sql'
|
|
151
|
+
SqlTableScanner.identifiers_in(params['sql'], dialect: sql_dialect, mysql_modes: mysql_quote_modes)
|
|
152
|
+
else
|
|
153
|
+
selected_source_tables(params)
|
|
154
|
+
end
|
|
155
|
+
@model_validator.model_names.filter_map do |name|
|
|
156
|
+
model = resolve_model(name)
|
|
157
|
+
next unless model.respond_to?(:type_for_attribute)
|
|
158
|
+
next unless typed_model_source?(model, name, params, tables)
|
|
159
|
+
|
|
160
|
+
[name, model]
|
|
161
|
+
rescue NameError
|
|
162
|
+
# Injectable registries can describe models without loading Rails.
|
|
163
|
+
next
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def typed_model_source?(model, name, params, tables)
|
|
168
|
+
return true if name == params['model']
|
|
169
|
+
return false unless model.respond_to?(:table_name)
|
|
170
|
+
|
|
171
|
+
# Match the scanner's unquoted final segment, as the identifier gates
|
|
172
|
+
# do. Keep all matching models across schemas: every possible type
|
|
173
|
+
# contributes to masking rather than selecting one ambiguous source.
|
|
174
|
+
tables.any? { |table| table.split('.').last.casecmp?(model.table_name.split('.').last) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def selected_source_tables(params)
|
|
178
|
+
Array(params['select'] || params['columns']).filter_map do |column|
|
|
179
|
+
column.split('.')[0...-1].join('.') if column.include?('.')
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
121
183
|
def sanitize_execution_error(error)
|
|
122
184
|
klass = error.class.name
|
|
123
185
|
# Well-known AR wrappers that contain the adapter error as their cause —
|
|
@@ -425,8 +487,9 @@ module Woods
|
|
|
425
487
|
return unless @table_gate
|
|
426
488
|
|
|
427
489
|
begin
|
|
428
|
-
|
|
429
|
-
|
|
490
|
+
SqliteReadGuard.validate!(sql) if sql_dialect == :sqlite
|
|
491
|
+
@table_gate.check_sql!(sql, dialect: sql_dialect)
|
|
492
|
+
rescue TableGateError, SqlValidationError => e
|
|
430
493
|
raise ValidationError, e.message
|
|
431
494
|
end
|
|
432
495
|
end
|
|
@@ -454,7 +517,7 @@ module Woods
|
|
|
454
517
|
def handle_count(params)
|
|
455
518
|
model = resolve_model(params['model'])
|
|
456
519
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
457
|
-
{ 'count' => scope.count }
|
|
520
|
+
{ 'count' => checked_relation(scope).count }
|
|
458
521
|
end
|
|
459
522
|
|
|
460
523
|
def handle_sample(params)
|
|
@@ -463,28 +526,30 @@ module Woods
|
|
|
463
526
|
limit = [params.fetch('limit', 5).to_i, 25].min
|
|
464
527
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
465
528
|
scope = apply_columns(scope, params['columns'])
|
|
466
|
-
records = scope.order(random_function).limit(limit)
|
|
529
|
+
records = checked_relation(scope.order(random_function).limit(limit))
|
|
467
530
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
468
531
|
end
|
|
469
532
|
|
|
470
533
|
def handle_find(params)
|
|
534
|
+
validate_select_columns!(params)
|
|
471
535
|
model = resolve_model(params['model'])
|
|
536
|
+
scope = checked_relation(model)
|
|
472
537
|
record = if params['id']
|
|
473
|
-
|
|
538
|
+
scope.find_by(id: params['id'])
|
|
474
539
|
elsif params['by']
|
|
475
|
-
|
|
540
|
+
scope.find_by(params['by'])
|
|
476
541
|
end
|
|
477
542
|
{ 'record' => record ? serialize_record(record, params['columns']) : nil }
|
|
478
543
|
end
|
|
479
544
|
|
|
480
545
|
def handle_pluck(params)
|
|
481
546
|
columns = params['columns']
|
|
482
|
-
|
|
547
|
+
validate_select_columns!(params)
|
|
483
548
|
model = resolve_model(params['model'])
|
|
484
549
|
limit = [params.fetch('limit', 100).to_i, 1000].min
|
|
485
550
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
486
551
|
scope = scope.distinct if params['distinct']
|
|
487
|
-
values = scope.limit(limit).pluck(*columns.map(&:to_sym))
|
|
552
|
+
values = checked_relation(scope.limit(limit)).pluck(*columns.map(&:to_sym))
|
|
488
553
|
{ 'columns' => Array(columns), 'values' => values }
|
|
489
554
|
end
|
|
490
555
|
|
|
@@ -492,6 +557,7 @@ module Woods
|
|
|
492
557
|
column = params['column']
|
|
493
558
|
function = params['function']
|
|
494
559
|
@model_validator.validate_column!(params['model'], column) if column
|
|
560
|
+
refuse_redacted_aggregate_expression!(column)
|
|
495
561
|
|
|
496
562
|
unless AGGREGATE_FUNCTIONS.include?(function)
|
|
497
563
|
raise ValidationError, "Invalid aggregate function: #{function}. " \
|
|
@@ -501,6 +567,7 @@ module Woods
|
|
|
501
567
|
model = resolve_model(params['model'])
|
|
502
568
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
503
569
|
|
|
570
|
+
scope = checked_relation(scope)
|
|
504
571
|
value = if function == 'count'
|
|
505
572
|
column ? scope.count(column.to_sym) : scope.count
|
|
506
573
|
else
|
|
@@ -511,7 +578,7 @@ module Woods
|
|
|
511
578
|
|
|
512
579
|
def handle_association_count(params)
|
|
513
580
|
model = resolve_model(params['model'])
|
|
514
|
-
record = model.find(params['id'])
|
|
581
|
+
record = checked_relation(model).find(params['id'])
|
|
515
582
|
association_name = params['association']
|
|
516
583
|
|
|
517
584
|
unless model.reflect_on_association(association_name.to_sym)
|
|
@@ -527,7 +594,22 @@ module Woods
|
|
|
527
594
|
|
|
528
595
|
scope = record.public_send(association_name)
|
|
529
596
|
scope = apply_scope(scope, params['scope'])
|
|
530
|
-
{ 'count' => scope.count }
|
|
597
|
+
{ 'count' => checked_relation(scope).count }
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Resolve the default scope once and execute the same checked relation.
|
|
601
|
+
# @param scope [Class, ActiveRecord::Relation] Pending model read
|
|
602
|
+
# @return [Class, ActiveRecord::Relation] Authorized relation
|
|
603
|
+
def checked_relation(scope)
|
|
604
|
+
return scope unless @table_gate
|
|
605
|
+
|
|
606
|
+
relation = scope.all
|
|
607
|
+
gate_sql!(relation.to_sql)
|
|
608
|
+
relation
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def sql_dialect
|
|
612
|
+
AdapterFamily.for(active_connection)
|
|
531
613
|
end
|
|
532
614
|
|
|
533
615
|
def gate_association!(model_name, association)
|
|
@@ -571,11 +653,12 @@ module Woods
|
|
|
571
653
|
limit = [params.fetch('limit', 10).to_i, 50].min
|
|
572
654
|
|
|
573
655
|
@model_validator.validate_column!(params['model'], order_by)
|
|
656
|
+
refuse_protected_predicate_column!(order_by)
|
|
574
657
|
direction = 'desc' unless %w[asc desc].include?(direction)
|
|
575
658
|
|
|
576
659
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
577
660
|
scope = apply_columns(scope, params['columns'])
|
|
578
|
-
records = scope.order(order_by => direction.to_sym).limit(limit)
|
|
661
|
+
records = checked_relation(scope.order(order_by => direction.to_sym).limit(limit))
|
|
579
662
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
580
663
|
end
|
|
581
664
|
|
|
@@ -598,21 +681,33 @@ module Woods
|
|
|
598
681
|
sql = params['sql']
|
|
599
682
|
raise ValidationError, 'Missing required parameter: sql' unless sql
|
|
600
683
|
|
|
684
|
+
raise ValidationError, 'Rejected: console_sql requires a recognized database adapter family.' unless sql_dialect
|
|
685
|
+
|
|
601
686
|
require_relative 'sql_validator'
|
|
602
|
-
|
|
603
|
-
# Post-validation, pre-execution TableGate — blocks every configured
|
|
604
|
-
# table even if the sql is otherwise well-formed.
|
|
605
|
-
gate_sql!(sql)
|
|
687
|
+
validate_sql_policy!(sql)
|
|
606
688
|
|
|
607
689
|
limit = params['limit'] ? [params['limit'].to_i, MAX_SQL_LIMIT].min : nil
|
|
608
|
-
query_sql = limit ? "SELECT * FROM (#{sql}) AS _limited LIMIT #{limit}" : sql
|
|
690
|
+
query_sql = limit ? "SELECT * FROM (\n#{sql}\n) AS _limited LIMIT #{limit}" : sql
|
|
691
|
+
validate_sql_policy!(query_sql) if limit
|
|
609
692
|
result = active_connection.select_all(query_sql)
|
|
693
|
+
validate_sql_result_types!(result)
|
|
610
694
|
|
|
611
695
|
{ 'columns' => result.columns, 'rows' => result.rows, 'count' => result.rows.size }
|
|
612
696
|
rescue SqlValidationError => e
|
|
613
697
|
raise ValidationError, e.message
|
|
614
698
|
end
|
|
615
699
|
|
|
700
|
+
def validate_sql_policy!(sql)
|
|
701
|
+
# Alias-list identity is its own policy, even when an alias also
|
|
702
|
+
# looks like a function name to the SQL validator.
|
|
703
|
+
sql_security_views(sql).each { |stripped| refuse_sql_column_alias_lists!(stripped) }
|
|
704
|
+
SqlValidator.new(dialect: sql_dialect, mysql_modes: mysql_quote_modes).validate!(sql)
|
|
705
|
+
validate_protected_sql_usage!(sql)
|
|
706
|
+
# Check both submitted and wrapped SQL against blocked tables before
|
|
707
|
+
# the exact final statement reaches the adapter.
|
|
708
|
+
gate_sql!(sql)
|
|
709
|
+
end
|
|
710
|
+
|
|
616
711
|
# Build and execute a structured ActiveRecord query.
|
|
617
712
|
#
|
|
618
713
|
# @param params [Hash] Must contain 'model' and 'select'
|
|
@@ -672,7 +767,9 @@ module Woods
|
|
|
672
767
|
model_name = params['model']
|
|
673
768
|
relation = model.all
|
|
674
769
|
|
|
675
|
-
|
|
770
|
+
# Keep execution and typed source inference on the same validated projection.
|
|
771
|
+
params['select'] = validated_select(params['select'], model_name) if params['select']
|
|
772
|
+
relation = relation.select(*params['select']) if params['select']
|
|
676
773
|
relation = relation.joins(params['joins'].map(&:to_sym)) if params['joins']&.any?
|
|
677
774
|
relation = apply_scope(relation, params['scope'], model_name: model_name)
|
|
678
775
|
relation = relation.group(*validated_columns(params['group_by'], model_name)) if params['group_by']&.any?
|
|
@@ -689,18 +786,18 @@ module Woods
|
|
|
689
786
|
# @param model_name [String]
|
|
690
787
|
# @return [Array<String>]
|
|
691
788
|
def validated_select(select, model_name)
|
|
692
|
-
Array(select).flat_map { |s| s.to_s.split(',') }.map do |expr|
|
|
789
|
+
expressions = Array(select).flat_map { |s| s.to_s.split(',') }.map do |expr|
|
|
693
790
|
validate_select_expression!(expr.strip, model_name)
|
|
694
791
|
end
|
|
792
|
+
refuse_orphan_eav_value_selection!(expressions, model_name)
|
|
793
|
+
expressions
|
|
695
794
|
end
|
|
696
795
|
|
|
697
796
|
def validate_select_expression!(expr, model_name)
|
|
698
797
|
match = SAFE_SELECT_EXPR.match(expr)
|
|
699
798
|
raise ValidationError, "Rejected select expression: #{expr.inspect}" unless match
|
|
700
799
|
|
|
701
|
-
|
|
702
|
-
column = bare_col || fn_arg
|
|
703
|
-
validate_column_reference!(column, model_name) unless column == '*'
|
|
800
|
+
refuse_redacted_select_shapes!(match.captures, model_name)
|
|
704
801
|
expr
|
|
705
802
|
end
|
|
706
803
|
|
|
@@ -713,6 +810,7 @@ module Woods
|
|
|
713
810
|
Array(columns).flat_map { |c| c.to_s.split(',') }.map do |col|
|
|
714
811
|
col = col.strip
|
|
715
812
|
validate_column_reference!(col, model_name)
|
|
813
|
+
refuse_protected_predicate_column!(col)
|
|
716
814
|
col
|
|
717
815
|
end
|
|
718
816
|
end
|
|
@@ -737,12 +835,12 @@ module Woods
|
|
|
737
835
|
/ix
|
|
738
836
|
private_constant :HAVING_AGG_TEMPLATE
|
|
739
837
|
|
|
740
|
-
def validated_having(having, model_name)
|
|
838
|
+
def validated_having(having, model_name)
|
|
741
839
|
case having
|
|
742
840
|
when Hash
|
|
743
841
|
raise ValidationError, 'having: empty hash' if having.empty?
|
|
744
842
|
|
|
745
|
-
having.each_key { |
|
|
843
|
+
having.each_key { |key| validate_predicate_column_reference!(key.to_s, model_name) }
|
|
746
844
|
[having]
|
|
747
845
|
when Array
|
|
748
846
|
raise ValidationError, 'having: array must be [sql_with_placeholders, *binds]' if having.empty?
|
|
@@ -753,8 +851,7 @@ module Woods
|
|
|
753
851
|
|
|
754
852
|
# Validate any referenced columns through ModelValidator so
|
|
755
853
|
# aggregate args can't reach the db without a column check.
|
|
756
|
-
|
|
757
|
-
validate_column_reference!(col, model_name) if col && col != '*'
|
|
854
|
+
validate_having_input!(match, model_name)
|
|
758
855
|
|
|
759
856
|
having
|
|
760
857
|
else
|
|
@@ -762,11 +859,22 @@ module Woods
|
|
|
762
859
|
end
|
|
763
860
|
end
|
|
764
861
|
|
|
862
|
+
def validate_having_input!(match, model_name)
|
|
863
|
+
column = match[:col] || match[:arg]
|
|
864
|
+
validate_predicate_column_reference!(column, model_name) if column && column != '*'
|
|
865
|
+
refuse_redacted_aggregate_expression!(column) if match[:agg]
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def validate_predicate_column_reference!(column, model_name)
|
|
869
|
+
validate_column_reference!(column, model_name)
|
|
870
|
+
refuse_protected_predicate_column!(column)
|
|
871
|
+
end
|
|
872
|
+
|
|
765
873
|
# Validate `order:` — only Hash `{col => :asc|:desc}` or bare column name.
|
|
766
874
|
def validated_order(order, model_name)
|
|
767
875
|
case order
|
|
768
876
|
when Hash
|
|
769
|
-
order.each_key { |
|
|
877
|
+
order.each_key { |key| validate_predicate_column_reference!(key.to_s, model_name) }
|
|
770
878
|
order.transform_values do |dir|
|
|
771
879
|
dir_sym = dir.to_s.downcase.to_sym
|
|
772
880
|
unless %i[asc desc].include?(dir_sym)
|
|
@@ -777,7 +885,7 @@ module Woods
|
|
|
777
885
|
end
|
|
778
886
|
when String, Symbol
|
|
779
887
|
col = order.to_s.strip
|
|
780
|
-
|
|
888
|
+
validate_predicate_column_reference!(col, model_name)
|
|
781
889
|
col
|
|
782
890
|
else
|
|
783
891
|
raise ValidationError, "order: unsupported type #{order.class}"
|
|
@@ -944,6 +1052,7 @@ module Woods
|
|
|
944
1052
|
return unless params['columns']
|
|
945
1053
|
|
|
946
1054
|
@model_validator.validate_columns!(params['model'], params['columns'])
|
|
1055
|
+
refuse_orphan_eav_value_selection!(params['columns'], params['model'])
|
|
947
1056
|
end
|
|
948
1057
|
|
|
949
1058
|
# Apply column selection to a relation.
|
|
@@ -983,8 +1092,7 @@ module Woods
|
|
|
983
1092
|
#
|
|
984
1093
|
# @return [Arel::Nodes::SqlLiteral]
|
|
985
1094
|
def random_function
|
|
986
|
-
|
|
987
|
-
func = adapter.include?('mysql') ? 'RAND' : 'RANDOM'
|
|
1095
|
+
func = AdapterFamily.for(active_connection) == :mysql ? 'RAND' : 'RANDOM'
|
|
988
1096
|
Arel.sql("#{func}()")
|
|
989
1097
|
end
|
|
990
1098
|
|
|
@@ -20,8 +20,10 @@ 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>] Optional table mapping for EAV provenance
|
|
24
|
+
def initialize(registry:, table_names: {})
|
|
24
25
|
@registry = registry
|
|
26
|
+
@model_by_table = table_names.each_with_object({}) { |(model, table), acc| acc[table.to_s] = model }
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
# Validate that a model name is known.
|
|
@@ -59,6 +61,14 @@ module Woods
|
|
|
59
61
|
column_names.each { |col| validate_column!(model_name, col) }
|
|
60
62
|
end
|
|
61
63
|
|
|
64
|
+
# Return known table columns without guessing an unknown table's shape.
|
|
65
|
+
# @param table_name [String]
|
|
66
|
+
# @return [Array<String>, nil] nil when this table is not registered
|
|
67
|
+
def columns_for_table(table_name)
|
|
68
|
+
model = @model_by_table[table_name.to_s]
|
|
69
|
+
@registry[model] if model
|
|
70
|
+
end
|
|
71
|
+
|
|
62
72
|
# List all known model names.
|
|
63
73
|
#
|
|
64
74
|
# @return [Array<String>]
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'woods/mcp/http_transport_options'
|
|
5
5
|
require 'woods/observability/structured_logger'
|
|
6
|
+
require 'woods/mcp/bearer_auth'
|
|
7
|
+
require 'woods/mcp/origin_guard'
|
|
8
|
+
require 'rack/request'
|
|
6
9
|
|
|
7
10
|
module Woods
|
|
8
11
|
module Console
|
|
@@ -66,7 +69,7 @@ module Woods
|
|
|
66
69
|
# If your threat model requires stricter isolation, use the bridge mode instead
|
|
67
70
|
# (docs/CONSOLE_MCP_SETUP.md) which runs the executor in a separate process.
|
|
68
71
|
#
|
|
69
|
-
class RackMiddleware
|
|
72
|
+
class RackMiddleware # rubocop:disable Metrics/ClassLength -- mount-local authentication precedes legacy construction
|
|
70
73
|
# @param app [#call] The next Rack app in the middleware stack
|
|
71
74
|
# @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
|
|
72
75
|
# @param embedded_read_tools [Boolean] Enable sql/query tools in embedded mode (default: false)
|
|
@@ -77,8 +80,14 @@ module Woods
|
|
|
77
80
|
# @param unsafe_eval_audit_log_path [String, Pathname, nil] JSONL audit log
|
|
78
81
|
# path for every `console_eval` run. Required on the opt-in path. Takes
|
|
79
82
|
# precedence over `config.console_unsafe_eval_audit_log_path`.
|
|
80
|
-
def initialize(app,
|
|
81
|
-
|
|
83
|
+
def initialize(app, options = {}, **keywords)
|
|
84
|
+
raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
initialize_options(app, **options, **keywords)
|
|
87
|
+
end
|
|
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)
|
|
82
91
|
@app = app
|
|
83
92
|
@path = path
|
|
84
93
|
@embedded_read_tools = embedded_read_tools
|
|
@@ -86,7 +95,15 @@ module Woods
|
|
|
86
95
|
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
87
96
|
@mutex = Mutex.new
|
|
88
97
|
@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)
|
|
89
105
|
end
|
|
106
|
+
private :initialize_options
|
|
90
107
|
|
|
91
108
|
DISABLED_BODY = JSON.generate(
|
|
92
109
|
error: 'woods_console_disabled',
|
|
@@ -108,11 +125,15 @@ module Woods
|
|
|
108
125
|
return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
|
|
109
126
|
return [410, { 'content-type' => 'application/json' }, [DISABLED_BODY]] unless enabled?
|
|
110
127
|
|
|
111
|
-
|
|
128
|
+
@guarded_request.call(env)
|
|
112
129
|
end
|
|
113
130
|
|
|
114
131
|
private
|
|
115
132
|
|
|
133
|
+
def handle_request(env)
|
|
134
|
+
ensure_transport.handle_request(Rack::Request.new(env))
|
|
135
|
+
end
|
|
136
|
+
|
|
116
137
|
def enabled?
|
|
117
138
|
Woods.configuration.console_mcp_enabled
|
|
118
139
|
end
|
|
@@ -133,7 +154,7 @@ module Woods
|
|
|
133
154
|
|
|
134
155
|
server = build_embedded_server
|
|
135
156
|
@transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
136
|
-
server,
|
|
157
|
+
server, **@origin_policy.transport_options
|
|
137
158
|
)
|
|
138
159
|
server.transport = @transport
|
|
139
160
|
@transport
|
|
@@ -179,7 +200,7 @@ module Woods
|
|
|
179
200
|
config = Woods.configuration
|
|
180
201
|
introspection = build_model_introspection
|
|
181
202
|
Server.build_embedded(
|
|
182
|
-
model_validator: ModelValidator.new(registry: introspection[:registry]),
|
|
203
|
+
model_validator: ModelValidator.new(registry: introspection[:registry], table_names: introspection[:tables]),
|
|
183
204
|
safe_context: SafeContext.new(pool: ActiveRecord::Base.connection_pool),
|
|
184
205
|
redacted_columns: Array(config&.console_redacted_columns),
|
|
185
206
|
redacted_key_values: Array(config&.console_redacted_key_values),
|
|
@@ -65,7 +65,8 @@ 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'
|
|
68
|
+
when 'rows' then redact_positional(value, plan)
|
|
69
|
+
when 'values' then redact_positional(value, plan, single_column: plan[:column_count] == 1)
|
|
69
70
|
when 'associations' then redact_association_map(value, ctx)
|
|
70
71
|
else value
|
|
71
72
|
end
|
|
@@ -93,7 +94,8 @@ module Woods
|
|
|
93
94
|
# `columns` header: the column-name mask plus any EAV key-value rules
|
|
94
95
|
# resolved to column indexes.
|
|
95
96
|
def positional_plan(columns, ctx)
|
|
96
|
-
{
|
|
97
|
+
{ column_count: columns.is_a?(Array) ? columns.length : nil,
|
|
98
|
+
mask: positional_mask(columns, ctx),
|
|
97
99
|
kv_rules: positional_kv_rules(columns, ctx) }
|
|
98
100
|
end
|
|
99
101
|
|
|
@@ -112,35 +114,65 @@ module Woods
|
|
|
112
114
|
# Resolve EAV patterns against a `columns` header into concrete index
|
|
113
115
|
# pairs. A rule only fires when both key_column and value_column are
|
|
114
116
|
# present in the header, and costs nothing per row otherwise.
|
|
117
|
+
#
|
|
118
|
+
# A duplicated key or value header (an `AS` alias can shadow the real
|
|
119
|
+
# column — CON-1) makes index attribution ambiguous: a last-index-wins
|
|
120
|
+
# lookup would let the shadow steal the mask from the secret. The
|
|
121
|
+
# executor refuses those selects up front; here, defense-in-depth
|
|
122
|
+
# fails toward masking — every cell under a value-named header is
|
|
123
|
+
# redacted unconditionally.
|
|
115
124
|
def positional_kv_rules(columns, ctx)
|
|
116
125
|
return [] unless columns.is_a?(Array)
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
ctx.redacted_key_values.filter_map
|
|
120
|
-
|
|
121
|
-
val_idx = index[pattern['value_column']]
|
|
122
|
-
next unless key_idx && val_idx
|
|
127
|
+
names = columns.map(&:to_s)
|
|
128
|
+
ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern, ctx) }
|
|
129
|
+
end
|
|
123
130
|
|
|
124
|
-
|
|
125
|
-
|
|
131
|
+
# One resolved rule for one EAV pattern, or nil when the header lacks
|
|
132
|
+
# either column. Unambiguous headers get the key/value index pair;
|
|
133
|
+
# duplicated headers get the unconditional mask list.
|
|
134
|
+
def positional_kv_rule(names, pattern, ctx)
|
|
135
|
+
key_idxs = names.each_index.select { |i| names[i] == pattern['key_column'] }
|
|
136
|
+
val_idxs = names.each_index.select { |i| names[i] == pattern['value_column'] }
|
|
137
|
+
return nil if key_idxs.empty? || val_idxs.empty?
|
|
138
|
+
return { mask_idxs: val_idxs } unless key_idxs.one? && val_idxs.one?
|
|
139
|
+
|
|
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)
|
|
126
148
|
end
|
|
127
149
|
|
|
128
150
|
# Redact positional row data using a precomputed plan. Handles both
|
|
129
|
-
# nested arrays (multi-column pluck, sql/query rows) and
|
|
130
|
-
#
|
|
131
|
-
|
|
151
|
+
# nested arrays (multi-column pluck, sql/query rows) and single-column
|
|
152
|
+
# pluck values. Rails collapses the row for single-column pluck, so an
|
|
153
|
+
# Array/Hash value is still one cell and must be redacted as a whole.
|
|
154
|
+
def redact_positional(rows, plan, single_column: false)
|
|
132
155
|
return rows unless rows.is_a?(Array)
|
|
133
156
|
return rows if plan[:mask].nil? && plan[:kv_rules].empty?
|
|
134
157
|
|
|
135
158
|
rows.map do |row|
|
|
136
|
-
row.is_a?(Array)
|
|
159
|
+
if !single_column && row.is_a?(Array)
|
|
160
|
+
redact_row(row, plan)
|
|
161
|
+
else
|
|
162
|
+
redact_scalar(row, plan[:mask])
|
|
163
|
+
end
|
|
137
164
|
end
|
|
138
165
|
end
|
|
139
166
|
|
|
140
167
|
def redact_row(row, plan)
|
|
141
168
|
result = apply_mask(row, plan[:mask])
|
|
142
169
|
plan[:kv_rules].each do |rule|
|
|
143
|
-
|
|
170
|
+
if rule[:mask_idxs]
|
|
171
|
+
# Ambiguous (duplicated) headers: mask every value-named cell.
|
|
172
|
+
rule[:mask_idxs].each { |idx| result[idx] = '[REDACTED]' }
|
|
173
|
+
elsif rule[:key_matches].call(row[rule[:key_idx]])
|
|
174
|
+
result[rule[:val_idx]] = '[REDACTED]'
|
|
175
|
+
end
|
|
144
176
|
end
|
|
145
177
|
result
|
|
146
178
|
end
|