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.
@@ -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 { dispatch(tool, params) }
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
- @table_gate.check_sql!(sql)
429
- rescue TableGateError => e
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
- model.find_by(id: params['id'])
538
+ scope.find_by(id: params['id'])
474
539
  elsif params['by']
475
- model.find_by(params['by'])
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
- @model_validator.validate_columns!(params['model'], columns) if columns
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
- SqlValidator.new.validate!(sql)
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
- relation = relation.select(*validated_select(params['select'], model_name)) if params['select']
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
- _fn, fn_arg, bare_col, _alias = match.captures
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) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
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 { |k| validate_column_reference!(k.to_s, model_name) }
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
- col = match[:col] || match[:arg]
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 { |k| validate_column_reference!(k.to_s, model_name) }
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
- validate_column_reference!(col, model_name)
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
- adapter = active_connection.adapter_name.downcase
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
- def initialize(registry:)
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, path: '/mcp/console', embedded_read_tools: false,
81
- unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil)
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
- ensure_transport.handle_request(Rack::Request.new(env))
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, **Woods::MCP::HttpTransportOptions.for(Woods.configuration.console_mcp_allowed_origins)
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', 'values' then redact_positional(value, plan)
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
- { mask: positional_mask(columns, ctx),
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
- index = columns.each_with_index.to_h { |name, idx| [name.to_s, idx] }
119
- ctx.redacted_key_values.filter_map do |pattern|
120
- key_idx = index[pattern['key_column']]
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
- { key_idx: key_idx, val_idx: val_idx, sensitive: pattern['sensitive_keys'] }
125
- end
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 flat scalar
130
- # arrays (pluck with a single column — Rails collapses the result).
131
- def redact_positional(rows, plan)
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) ? redact_row(row, plan) : redact_scalar(row, plan[:mask])
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
- result[rule[:val_idx]] = '[REDACTED]' if rule[:sensitive].include?(row[rule[:key_idx]].to_s)
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