From af9f51e17c2d999abdffbf50f40ef0a5e69e6490 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 27 Jun 2026 20:14:08 -0500 Subject: [PATCH 01/86] refactor: extract between bind collaborator --- lib/code_to_query/compiler.rb | 62 +++++++++--- lib/code_to_query/query.rb | 58 ++++++----- spec/code_to_query/compiler_spec.rb | 112 +++++++++++++++++++++ spec/code_to_query/query_spec.rb | 146 ++++++++++++++++++++++++++++ 4 files changed, 338 insertions(+), 40 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 0c04985..6b0aac0 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -419,12 +419,17 @@ def build_string_comparison_fragment(quoted_column, filter, bind_spec, placehold end def build_string_between_fragment(quoted_column, filter, bind_spec, params_hash, placeholder_index) - append_between_bind_specs(bind_spec, filter, params_hash) - - placeholder1 = placeholder_for_adapter(placeholder_index) - placeholder2 = placeholder_for_adapter(placeholder_index + 1) + between_clause = build_between_bind_clause( + filter, + bind_spec, + params_hash, + placeholder_index: placeholder_index + ) - ["#{quoted_column} BETWEEN #{placeholder1} AND #{placeholder2}", placeholder_index + 2] + [ + "#{quoted_column} BETWEEN #{between_clause[:start_placeholder]} AND #{between_clause[:end_placeholder]}", + between_clause[:next_placeholder_index] + ] end def build_string_in_fragment(quoted_column, filter, bind_spec, params_hash, placeholder_index) @@ -555,10 +560,10 @@ def build_arel_condition(table, filter, bind_spec, params_hash = nil) # Force fallback to string builder for complex correlated subqueries raise StandardError, 'not_exists Arel compilation is not implemented; falling back to string builder' when 'between' - start_key, end_key = append_between_bind_specs(bind_spec, filter, params_hash) + between_clause = build_between_bind_clause(filter, bind_spec, params_hash) - start_param = Arel::Nodes::BindParam.new(start_key) - end_param = Arel::Nodes::BindParam.new(end_key) + start_param = Arel::Nodes::BindParam.new(between_clause[:start_key]) + end_param = Arel::Nodes::BindParam.new(between_clause[:end_key]) column.between(start_param..end_param) when 'in' key = filter_bind_key(filter) @@ -621,22 +626,32 @@ def append_between_bind_specs(bind_spec, filter, params_hash = nil) [start_key, end_key] end - def alias_legacy_between_params!(params_hash, filter, start_key, end_key) - return if filter['param_start'] || filter['param_end'] + def build_between_bind_clause(filter, bind_spec, params_hash = nil, placeholder_index: nil) + start_key, end_key = append_between_bind_specs(bind_spec, filter, params_hash) + + { + start_key: start_key, + end_key: end_key, + start_placeholder: placeholder_index && placeholder_for_adapter(placeholder_index), + end_placeholder: placeholder_index && placeholder_for_adapter(placeholder_index + 1), + next_placeholder_index: placeholder_index && placeholder_index + 2 + } + end - if !params_hash_has_key?(params_hash, start_key) && params_hash_has_key?(params_hash, 'start') - params_hash[start_key] = params_hash['start'] + def alias_legacy_between_params!(params_hash, filter, start_key, end_key) + if !filter['param_start'] && !params_hash_has_key?(params_hash, start_key) && params_hash_has_key?(params_hash, 'start') + params_hash[start_key] = params_hash_value(params_hash, 'start') end - return if params_hash_has_key?(params_hash, end_key) + return if filter['param_end'] || params_hash_has_key?(params_hash, end_key) return unless params_hash_has_key?(params_hash, 'end') - params_hash[end_key] = params_hash['end'] + params_hash[end_key] = params_hash_value(params_hash, 'end') end def default_between_bind_key(filter, bound_side) column = filter['column'].to_s.strip - return bound_side unless column != '' + return bound_side if column.empty? "#{sanitize_bind_key(column)}_#{bound_side}" end @@ -649,6 +664,20 @@ def params_hash_has_key?(params_hash, key) params_hash.key?(key.to_s) || params_hash.key?(key.to_sym) end + def params_hash_value(params_hash, key) + return params_hash[key.to_s] if params_hash.key?(key.to_s) + + params_hash[key.to_sym] + end + + def params_hash_lookup(params_hash, key) + return [false, nil] unless params_hash.respond_to?(:key?) + return [true, params_hash[key.to_s]] if params_hash.key?(key.to_s) + return [true, params_hash[key.to_sym]] if params_hash.key?(key.to_sym) + + [false, nil] + end + def having_bind_key(having_filter) having_filter['param'] || "having_#{having_filter['column']}" end @@ -846,7 +875,8 @@ def normalize_params_with_model(intent) key = f['param'] || col next unless key - raw = params[key.to_s] || params[key.to_sym] + present, raw = params_hash_lookup(params, key) + next unless present next if raw.nil? # Map Rails enum string to integer diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 72d514e..47feb51 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -37,10 +37,10 @@ def binds column_name = bind_info[:column] # Get parameter value (check both string and symbol keys) - value = @params[key.to_s] || @params[key.to_sym] + value = param_value_for_key(@params, key) # Determine the correct ActiveRecord type - type = infer_column_type(connection, @intent['table'], column_name, bind_info[:cast]) + type = infer_column_type(connection, @intent['table'], column_name, bind_info[:cast], key) ActiveRecord::Relation::QueryAttribute.new(column_name.to_s, value, type) end @@ -182,17 +182,20 @@ def normalize_between_filter_params(filters, normalized_params) end def hydrate_between_param_defaults(filter, normalized_params) - return if filter['param_start'] || filter['param_end'] - start_key, end_key = between_filter_keys(filter) return if between_param_key_present?(normalized_params, start_key) && between_param_key_present?(normalized_params, end_key) - legacy_start = normalized_params['start'] || normalized_params[:start] - legacy_end = normalized_params['end'] || normalized_params[:end] + legacy_start = param_value_for_key(normalized_params, 'start') + legacy_end = param_value_for_key(normalized_params, 'end') + + if !filter['param_start'] && between_param_key_present?(normalized_params, 'start') && !between_param_key_present?(normalized_params, start_key) + normalized_params[start_key] = legacy_start + end - normalized_params[start_key] = legacy_start if legacy_start && !between_param_key_present?(normalized_params, start_key) - normalized_params[end_key] = legacy_end if legacy_end && !between_param_key_present?(normalized_params, end_key) + if !filter['param_end'] && between_param_key_present?(normalized_params, 'end') && !between_param_key_present?(normalized_params, end_key) + normalized_params[end_key] = legacy_end + end end def between_filter_keys(filter) @@ -212,6 +215,13 @@ def between_param_key_present?(params, key) params.key?(key) || params.key?(key.to_sym) || params.key?(key.to_s) end + def param_value_for_key(params, key) + return params[key] if params.key?(key) + return params[key.to_s] if params.key?(key.to_s) + + params[key.to_sym] + end + def query_shape [@intent['type'], @intent['table']].compact.join(':') end @@ -297,7 +307,7 @@ def check_policy_compliance policy_in_binds || policy_in_params end - def infer_column_type(connection, table_name, column_name, explicit_cast) + def infer_column_type(connection, table_name, column_name, explicit_cast, param_key = column_name) return explicit_cast if explicit_cast # Try to get column info from ActiveRecord @@ -320,7 +330,7 @@ def infer_column_type(connection, table_name, column_name, explicit_cast) end # Ultimate fallback: infer from parameter value - infer_type_from_value(@params[column_name] || @params[column_name.to_sym]) + infer_type_from_value(param_value_for_key(@params, param_key)) end def infer_type_from_value(value) @@ -368,31 +378,31 @@ def apply_filter_to_scope(scope, filter) case operator when '=' param_key = filter['param'] || column - value = @params[param_key.to_s] || @params[param_key.to_sym] + value = param_value_for_key(@params, param_key) scope.where(column => value) when '!=', '<>' param_key = filter['param'] || column - value = @params[param_key.to_s] || @params[param_key.to_sym] + value = param_value_for_key(@params, param_key) scope.where.not(column => value) when '>', '>=', '<', '<=' param_key = filter['param'] || column - value = @params[param_key.to_s] || @params[param_key.to_sym] + value = param_value_for_key(@params, param_key) scope.where("#{scope.connection.quote_column_name(column)} #{operator} ?", value) when 'between' start_key, end_key = between_filter_keys(filter) start_key, end_key = [start_key, end_key].map(&:to_s) - start_value = @params[start_key] || @params[start_key.to_sym] - end_value = @params[end_key] || @params[end_key.to_sym] - start_value ||= @params['start'] || @params[:start] - end_value ||= @params['end'] || @params[:end] + start_value = param_value_for_key(@params, start_key) + end_value = param_value_for_key(@params, end_key) + start_value = param_value_for_key(@params, 'start') if !filter['param_start'] && !between_param_key_present?(@params, start_key) + end_value = param_value_for_key(@params, 'end') if !filter['param_end'] && !between_param_key_present?(@params, end_key) scope.where(column => (start_value..end_value)) when 'in' param_key = filter['param'] || column - values = @params[param_key.to_s] || @params[param_key.to_sym] + values = param_value_for_key(@params, param_key) scope.where(column => Array(values)) when 'like', 'ilike' param_key = filter['param'] || column - value = @params[param_key.to_s] || @params[param_key.to_sym] + value = param_value_for_key(@params, param_key) scope.where("#{scope.connection.quote_column_name(column)} #{operator.upcase} ?", value) when 'exists', 'not_exists' related_table = filter['related_table'] @@ -415,7 +425,7 @@ def apply_filter_to_scope(scope, filter) rcol = rf['column'] rop = rf['op'] rkey = rf['param'] || rcol - rval = @params[rkey.to_s] || @params[rkey.to_sym] + rval = param_value_for_key(@params, rkey) next if rcol.nil? || rop.nil? case rop @@ -428,10 +438,10 @@ def apply_filter_to_scope(scope, filter) when 'between' start_key, end_key = between_filter_keys(rf) start_key, end_key = [start_key, end_key].map(&:to_s) - start_val = @params[start_key] || @params[start_key.to_sym] - end_val = @params[end_key] || @params[end_key.to_sym] - start_val ||= @params['start'] || @params[:start] - end_val ||= @params['end'] || @params[:end] + start_val = param_value_for_key(@params, start_key) + end_val = param_value_for_key(@params, end_key) + start_val = param_value_for_key(@params, 'start') if !rf['param_start'] && !between_param_key_present?(@params, start_key) + end_val = param_value_for_key(@params, 'end') if !rf['param_end'] && !between_param_key_present?(@params, end_key) subquery = subquery.where("#{related_table}.#{rcol} BETWEEN ? AND ?", start_val, end_val) when 'in' vals = Array(rval) diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 915c233..06bc9a2 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -172,6 +172,50 @@ end end + context 'with related subquery BETWEEN filter' do + let(:intent) do + { + 'type' => 'select', + 'table' => 'questions', + 'columns' => ['*'], + 'filters' => [ + { 'column' => 'archived', 'op' => '=', 'param' => 'archived' }, + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [ + { 'column' => 'created_at', 'op' => 'between' } + ] + } + ], + 'limit' => 100, + 'params' => { + 'archived' => false, + 'created_at_start' => '2023-01-01', + 'created_at_end' => '2023-12-31' + } + } + end + + it 'preserves subquery BETWEEN placeholders and bind order' do + result = compiler.compile(intent) + + expect(result[:sql]).to eq( + 'SELECT * FROM "questions" WHERE "archived" = $1 AND ' \ + 'EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id" ' \ + 'AND "answers"."created_at" BETWEEN $2 AND $3) LIMIT 100' + ) + expect(result[:bind_spec]).to eq([ + { key: 'archived', column: 'archived', cast: nil }, + { key: 'created_at_start', column: 'created_at', cast: nil }, + { key: 'created_at_end', column: 'created_at', cast: nil } + ]) + end + end + context 'with WHERE filters' do let(:intent) do { @@ -295,6 +339,74 @@ hash_including(key: 'created_at_end', column: 'created_at') ) end + + it 'aliases legacy symbol keys to derived bind keys for SQL compilation' do + intent['params'] = { start: '2023-01-01', end: '2023-12-31' } + + result = compiler.compile(intent) + + expect(result[:params]['created_at_start']).to eq('2023-01-01') + expect(result[:params]['created_at_end']).to eq('2023-12-31') + end + + it 'preserves falsey legacy symbol values when aliasing derived bind keys' do + intent['params'] = { start: false, end: true } + + result = compiler.compile(intent) + + expect(result[:params]['created_at_start']).to be(false) + expect(result[:params]['created_at_end']).to be(true) + end + end + + context 'with Arel BETWEEN filters lacking explicit param names' do + let(:arel_table) { Arel::Table.new(:orders) } + let(:filter) do + { + 'column' => 'created_at', + 'op' => 'between' + } + end + + it 'reuses derived bind keys and aliases legacy params for Arel compilation' do + bind_spec = [] + params_hash = { 'start' => '2023-01-01', 'end' => '2023-12-31' } + + condition = compiler.__send__(:build_arel_condition, arel_table, filter, bind_spec, params_hash) + + expect(condition).to be_a(Arel::Nodes::Between) + expect(params_hash['created_at_start']).to eq('2023-01-01') + expect(params_hash['created_at_end']).to eq('2023-12-31') + expect(bind_spec).to eq([ + { key: 'created_at_start', column: 'created_at', cast: nil }, + { key: 'created_at_end', column: 'created_at', cast: nil } + ]) + end + + it 'aliases legacy symbol keys for Arel compilation' do + bind_spec = [] + params_hash = { start: '2023-01-01', end: '2023-12-31' } + + compiler.__send__(:build_arel_condition, arel_table, filter, bind_spec, params_hash) + + expect(params_hash['created_at_start']).to eq('2023-01-01') + expect(params_hash['created_at_end']).to eq('2023-12-31') + end + + it 'aliases only the implicit side when one bound keeps an explicit param name' do + bind_spec = [] + params_hash = { 'end' => '2023-12-31' } + mixed_filter = filter.merge('param_start' => 'from_date') + + compiler.__send__(:build_arel_condition, arel_table, mixed_filter, bind_spec, params_hash) + + expect(params_hash).not_to have_key('created_at_start') + expect(params_hash['created_at_end']).to eq('2023-12-31') + expect(bind_spec).to eq([ + { key: 'from_date', column: 'created_at', cast: nil }, + { key: 'created_at_end', column: 'created_at', cast: nil } + ]) + end end context 'with ORDER BY clause' do diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index e146eca..77b0bc2 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -49,6 +49,152 @@ expect(q.params['created_at_start']).to eq('2023-01-01') expect(q.params['created_at_end']).to eq('2023-12-31') end + + it 'adds between defaults derived from column names for legacy symbol-key params' do + q = described_class.new( + sql: 'SELECT * FROM "orders" WHERE "created_at" BETWEEN $1 AND $2', + params: { start: '2023-01-01', end: '2023-12-31' }, + bind_spec: [{ key: 'created_at_start', column: 'created_at' }, { key: 'created_at_end', column: 'created_at' }], + intent: { + 'table' => 'orders', + 'type' => 'select', + 'filters' => [ + { 'column' => 'created_at', 'op' => 'between' } + ] + }, + allow_tables: ['orders'], + config: config + ) + + expect(q.params['created_at_start']).to eq('2023-01-01') + expect(q.params['created_at_end']).to eq('2023-12-31') + end + + it 'preserves false-valued legacy between params when deriving column-based keys' do + q = described_class.new( + sql: 'SELECT * FROM "orders" WHERE "created_at" BETWEEN $1 AND $2', + params: { 'start' => false, 'end' => true }, + bind_spec: [{ key: 'created_at_start', column: 'created_at' }, { key: 'created_at_end', column: 'created_at' }], + intent: { + 'table' => 'orders', + 'type' => 'select', + 'filters' => [ + { 'column' => 'created_at', 'op' => 'between' } + ] + }, + allow_tables: ['orders'], + config: config + ) + + expect(q.params['created_at_start']).to be(false) + expect(q.params['created_at_end']).to be(true) + end + + it 'hydrates only the implicit side when one bound keeps an explicit param name' do + q = described_class.new( + sql: 'SELECT * FROM "orders" WHERE "created_at" BETWEEN $1 AND $2', + params: { 'end' => '2023-12-31' }, + bind_spec: [{ key: 'from_date', column: 'created_at' }, { key: 'created_at_end', column: 'created_at' }], + intent: { + 'table' => 'orders', + 'type' => 'select', + 'filters' => [ + { 'column' => 'created_at', 'op' => 'between', 'param_start' => 'from_date' } + ] + }, + allow_tables: ['orders'], + config: config + ) + + expect(q.params['created_at_end']).to eq('2023-12-31') + expect(q.params).not_to have_key('created_at_start') + end + end + + describe '#binds' do + it 'preserves false values stored under string keys' do + mock_connection = double('Connection') + ar_base = Class.new do + def self.connection = @mock_connection + + class << self + attr_writer :mock_connection + end + end + ar_base.mock_connection = mock_connection + stub_const('ActiveRecord::Base', ar_base) + stub_const('ActiveRecord::Relation::QueryAttribute', Struct.new(:name, :value, :type)) + + q = described_class.new( + sql: 'SELECT * FROM "users" WHERE "archived" = $1', + params: { 'archived' => false }, + bind_spec: [{ key: 'archived', column: 'archived', cast: nil }], + intent: { 'table' => 'users', 'type' => 'select' }, + allow_tables: ['users'], + config: config + ) + allow(q).to receive(:infer_column_type).and_return(:boolean) + + binds = q.binds + + expect(binds.first.value).to be(false) + end + end + + describe 'false-valued param lookups' do + it 'preserves false values when applying equality filters to a scope' do + scoped_query = described_class.new( + sql: 'SELECT * FROM "users" WHERE "archived" = $1', + params: { 'archived' => false }, + bind_spec: [], + intent: { + 'table' => 'users', + 'type' => 'select', + 'filters' => [{ 'column' => 'archived', 'op' => '=' }] + }, + allow_tables: ['users'], + config: config + ) + scope = double('scope') + + expect(scope).to receive(:where).with('archived' => false) + scoped_query.send(:apply_filter_to_scope, scope, { 'column' => 'archived', 'op' => '=' }) + end + + it 'does not fall back to legacy start when between start uses an explicit param name' do + scoped_query = described_class.new( + sql: 'SELECT * FROM "orders" WHERE "created_at" BETWEEN $1 AND $2', + params: { 'start' => false, 'created_at_end' => '2023-12-31' }, + bind_spec: [], + intent: { + 'table' => 'orders', + 'type' => 'select', + 'filters' => [{ 'column' => 'created_at', 'op' => 'between', 'param_start' => 'from_date' }] + }, + allow_tables: ['orders'], + config: config + ) + scope = double('scope') + + expect(scope).to receive(:where).with('created_at' => (nil..'2023-12-31')) + scoped_query.send(:apply_filter_to_scope, scope, { 'column' => 'created_at', 'op' => 'between', 'param_start' => 'from_date' }) + end + + it 'infers boolean type from false values stored under custom bind keys' do + typed_query = described_class.new( + sql: 'SELECT * FROM "users" WHERE "archived" = $1', + params: { 'is_archived' => false }, + bind_spec: [], + intent: { 'table' => 'users', 'type' => 'select' }, + allow_tables: ['users'], + config: config + ) + + hide_const('ActiveRecord::Base') + + inferred_type = typed_query.send(:infer_column_type, nil, 'archived', nil, 'is_archived') + expect(inferred_type).to be_a(ActiveRecord::Type::Boolean) + end end describe '#safe?' do From 96727ac1e0d19848b6626483f30888fde45c84c2 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 27 Jun 2026 20:19:16 -0500 Subject: [PATCH 02/86] fix: repair between bind CI regressions --- lib/code_to_query/compiler.rb | 2 +- spec/code_to_query/query_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 6b0aac0..11a5840 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -564,7 +564,7 @@ def build_arel_condition(table, filter, bind_spec, params_hash = nil) start_param = Arel::Nodes::BindParam.new(between_clause[:start_key]) end_param = Arel::Nodes::BindParam.new(between_clause[:end_key]) - column.between(start_param..end_param) + Arel::Nodes::Between.new(column, Arel::Nodes::And.new([start_param, end_param])) when 'in' key = filter_bind_key(filter) append_bind_spec(bind_spec, key: key, column: filter['column'], cast: :array) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 77b0bc2..9dbac4e 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -192,7 +192,7 @@ class << self hide_const('ActiveRecord::Base') - inferred_type = typed_query.send(:infer_column_type, nil, 'archived', nil, 'is_archived') + inferred_type = typed_query.send(:infer_column_type, nil, nil, 'archived', nil, 'is_archived') expect(inferred_type).to be_a(ActiveRecord::Type::Boolean) end end From e0ea8bec774c37b1bb6d703495a1ccbacb4a8f8f Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 27 Jun 2026 20:21:47 -0500 Subject: [PATCH 03/86] fix: satisfy code_to_query CI lint gates --- lib/code_to_query/compiler.rb | 2 +- lib/code_to_query/query.rb | 6 +++--- spec/code_to_query/query_spec.rb | 12 +++++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 11a5840..e14eef3 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -634,7 +634,7 @@ def build_between_bind_clause(filter, bind_spec, params_hash = nil, placeholder_ end_key: end_key, start_placeholder: placeholder_index && placeholder_for_adapter(placeholder_index), end_placeholder: placeholder_index && placeholder_for_adapter(placeholder_index + 1), - next_placeholder_index: placeholder_index && placeholder_index + 2 + next_placeholder_index: placeholder_index && (placeholder_index + 2) } end diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 47feb51..eaab619 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -193,9 +193,9 @@ def hydrate_between_param_defaults(filter, normalized_params) normalized_params[start_key] = legacy_start end - if !filter['param_end'] && between_param_key_present?(normalized_params, 'end') && !between_param_key_present?(normalized_params, end_key) - normalized_params[end_key] = legacy_end - end + return unless !filter['param_end'] && between_param_key_present?(normalized_params, 'end') && !between_param_key_present?(normalized_params, end_key) + + normalized_params[end_key] = legacy_end end def between_filter_keys(filter) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 9dbac4e..2542bb9 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -155,10 +155,11 @@ class << self allow_tables: ['users'], config: config ) - scope = double('scope') + scope = instance_spy('scope') - expect(scope).to receive(:where).with('archived' => false) scoped_query.send(:apply_filter_to_scope, scope, { 'column' => 'archived', 'op' => '=' }) + + expect(scope).to have_received(:where).with('archived' => false) end it 'does not fall back to legacy start when between start uses an explicit param name' do @@ -174,10 +175,11 @@ class << self allow_tables: ['orders'], config: config ) - scope = double('scope') + scope = instance_spy('scope') - expect(scope).to receive(:where).with('created_at' => (nil..'2023-12-31')) scoped_query.send(:apply_filter_to_scope, scope, { 'column' => 'created_at', 'op' => 'between', 'param_start' => 'from_date' }) + + expect(scope).to have_received(:where).with('created_at' => (nil..'2023-12-31')) end it 'infers boolean type from false values stored under custom bind keys' do @@ -396,7 +398,7 @@ class << self end end - describe '#binds' do + describe '#binds fallback behavior' do context 'without ActiveRecord' do it 'returns empty array' do hide_const('ActiveRecord') From c4785425fe244e67e5e412fc5c5ec9187f8fedcf Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 27 Jun 2026 20:24:11 -0500 Subject: [PATCH 04/86] fix: satisfy query spec rubocop cops --- spec/code_to_query/query_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 2542bb9..7d47113 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -155,7 +155,7 @@ class << self allow_tables: ['users'], config: config ) - scope = instance_spy('scope') + scope = spy('scope') scoped_query.send(:apply_filter_to_scope, scope, { 'column' => 'archived', 'op' => '=' }) @@ -175,7 +175,7 @@ class << self allow_tables: ['orders'], config: config ) - scope = instance_spy('scope') + scope = spy('scope') scoped_query.send(:apply_filter_to_scope, scope, { 'column' => 'created_at', 'op' => 'between', 'param_start' => 'from_date' }) From 5e0f1265d6534657fe2c5d280dcfe5d49b8445db Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 2 Jul 2026 14:57:44 -0500 Subject: [PATCH 05/86] fix: preserve policy-augmented query intent --- lib/code_to_query.rb | 2 +- lib/code_to_query/compiler.rb | 4 +-- lib/code_to_query/query.rb | 10 ++++++ spec/code_to_query/query_spec.rb | 59 ++++++++++++++++++++++++++++++++ spec/code_to_query_spec.rb | 33 ++++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) diff --git a/lib/code_to_query.rb b/lib/code_to_query.rb index c3b6953..2c6c4eb 100644 --- a/lib/code_to_query.rb +++ b/lib/code_to_query.rb @@ -142,7 +142,7 @@ def self.ask(prompt:, schema: nil, allow_tables: nil, current_user: nil) end Query.new(sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], - intent: validated_intent, allow_tables: allow_tables, config: config) + intent: compiled[:intent] || validated_intent, allow_tables: allow_tables, config: config) end def self.query_shape(intent) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index e14eef3..0c1c40a 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -200,7 +200,7 @@ def compile_with_arel(intent, current_user = nil) sql = visitor.accept(query.ast, Arel::Collectors::SQLString.new).value - { sql: sql, params: params_hash, bind_spec: bind_spec } + { sql: sql, params: params_hash, bind_spec: bind_spec, intent: intent } rescue StandardError => e @config.logger.warn("[code_to_query] Arel compilation failed: #{e.message}") compile_with_string_building(intent, current_user) @@ -238,7 +238,7 @@ def compile_with_string_building(intent, current_user = nil) sql_parts << build_string_limit_clause(limit) end - { sql: sql_parts.join(' '), params: params_hash, bind_spec: bind_spec } + { sql: sql_parts.join(' '), params: params_hash, bind_spec: bind_spec, intent: intent } end def build_arel_select_query(intent, table) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index eaab619..ce8b82d 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -297,6 +297,8 @@ def check_policy_compliance # Verify via bind_spec or params keys rather than scanning SQL text. return true unless @config.policy_adapter + return true unless policy_predicates_expected? + policy_in_binds = Array(@bind_spec).any? do |bind| key = bind[:key] key.to_s.start_with?('policy_') @@ -307,6 +309,14 @@ def check_policy_compliance policy_in_binds || policy_in_params end + def policy_predicates_expected? + Array(@intent['filters']).any? do |filter| + filter['param'].to_s.start_with?('policy_') || + filter['param_start'].to_s.start_with?('policy_') || + filter['param_end'].to_s.start_with?('policy_') + end + end + def infer_column_type(connection, table_name, column_name, explicit_cast, param_key = column_name) return explicit_cast if explicit_cast diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 7d47113..9e72771 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -19,6 +19,45 @@ ) end + def build_scope_backed_user_model(scope) + stub_const('ActiveRecord', Module.new) unless defined?(ActiveRecord) + stub_const('ActiveRecord::Base', Class.new) + + user_model = Class.new(ActiveRecord::Base) do + def self.table_name = 'users' + def self.all = @scope + + class << self + attr_writer :scope + end + end + + user_model.scope = scope + stub_const('User', user_model) + end + + def build_policy_query(config) + described_class.new( + sql: 'SELECT * FROM "users" WHERE "active" = $1 AND "tenant_id" = $2 LIMIT 100', + params: { 'active' => true, 'policy_tenant_id' => 42 }, + bind_spec: [ + { key: 'active', column: 'active', cast: nil }, + { key: 'policy_tenant_id', column: 'tenant_id', cast: nil } + ], + intent: { + 'table' => 'users', + 'type' => 'select', + 'filters' => [ + { 'column' => 'active', 'op' => '=', 'param' => 'active' }, + { 'column' => 'tenant_id', 'op' => '=', 'param' => 'policy_tenant_id' } + ], + 'limit' => 100 + }, + allow_tables: ['users'], + config: config + ) + end + describe '#sql' do it 'returns the SQL string' do expect(query.sql).to eq(sql) @@ -240,6 +279,14 @@ class << self expect(q).to have_received(:perform_safety_checks).once end + + it 'returns true for allowlist-only policy adapters with no injected predicates' do + config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } + + expect(query.safe?).to be true + ensure + config.policy_adapter = nil + end end describe '#explain' do @@ -310,6 +357,18 @@ class << self expect(query.to_relation).to be_nil end end + + it 'applies compiler-injected policy filters from the query intent' do + scope = double('scope') + allow(scope).to receive_messages(where: scope, order: scope, limit: scope) + build_scope_backed_user_model(scope) + + build_policy_query(config).to_relation + + expect(scope).to have_received(:where).with('active' => true).ordered + expect(scope).to have_received(:where).with('tenant_id' => 42).ordered + expect(scope).to have_received(:limit).with(100) + end end describe '#to_active_record' do diff --git a/spec/code_to_query_spec.rb b/spec/code_to_query_spec.rb index f583af9..8c9728e 100644 --- a/spec/code_to_query_spec.rb +++ b/spec/code_to_query_spec.rb @@ -69,6 +69,39 @@ expect(query).to be_a(CodeToQuery::Query) end + it 'preserves compiler-augmented intent on the returned query' do + planner = instance_double(CodeToQuery::Planner) + validator = instance_double(CodeToQuery::Validator) + compiler = instance_double(CodeToQuery::Compiler) + linter = instance_double(CodeToQuery::Guardrails::SqlLinter) + + compiled_intent = sample_intent.merge( + 'filters' => [ + { 'column' => 'tenant_id', 'op' => '=', 'param' => 'policy_tenant_id' } + ], + 'params' => { 'policy_tenant_id' => 42 } + ) + + allow(CodeToQuery::Planner).to receive(:new).and_return(planner) + allow(CodeToQuery::Validator).to receive(:new).and_return(validator) + allow(CodeToQuery::Compiler).to receive(:new).and_return(compiler) + allow(CodeToQuery::Guardrails::SqlLinter).to receive(:new).and_return(linter) + + allow(planner).to receive(:plan).and_return(sample_intent) + allow(validator).to receive(:validate).and_return(sample_intent) + allow(compiler).to receive(:compile).and_return( + sql: 'SELECT users.* FROM users WHERE users.tenant_id = $1', + params: { 'policy_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_tenant_id', column: 'tenant_id', cast: nil }], + intent: compiled_intent + ) + allow(linter).to receive(:check!) + + query = described_class.ask(prompt: 'Get users', allow_tables: ['users']) + + expect(query.intent).to eq(compiled_intent) + end + # rubocop:disable RSpec/ExampleLength it 'marks compile/lint telemetry as policy-applied when policy binds are present' do events = [] From 008b5cc797743cc7db49acd3e03ec530cbbb6637 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 2 Jul 2026 17:55:48 -0500 Subject: [PATCH 06/86] fix: keep subquery policy compliance fail-closed --- lib/code_to_query/compiler.rb | 47 +++++++++++++++++---- lib/code_to_query/query.rb | 28 +++++++------ spec/code_to_query/compiler_spec.rb | 20 +++++++++ spec/code_to_query/query_spec.rb | 64 +++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 21 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 0c1c40a..767fc05 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -16,7 +16,8 @@ def initialize(config) end def compile(intent, current_user: nil) - intent_with_policy = apply_policy_predicates(intent, current_user) + working_intent = deep_dup_value(intent) + intent_with_policy = apply_policy_predicates(working_intent, current_user) if use_arel? compile_with_arel(intent_with_policy, current_user) else @@ -34,8 +35,11 @@ def apply_policy_predicates(intent, current_user) policy_hash = extract_enforced_predicates(policy_info) return intent if policy_hash.empty? + policy_keys = [] filters = Array(intent['filters']) + policy_hash.map do |column, value| if value.is_a?(Range) && value.begin && value.end + policy_keys << "policy_#{column}_start" + policy_keys << "policy_#{column}_end" { 'column' => column.to_s, 'op' => 'between', @@ -43,6 +47,7 @@ def apply_policy_predicates(intent, current_user) 'param_end' => "policy_#{column}_end" } else + policy_keys << "policy_#{column}" { 'column' => column.to_s, 'op' => '=', @@ -63,7 +68,8 @@ def apply_policy_predicates(intent, current_user) intent.merge( 'filters' => filters, - 'params' => params + 'params' => params, + '__policy_expected_keys' => merge_policy_expected_keys(intent, policy_keys) ) rescue PolicyAdapterError raise @@ -217,7 +223,7 @@ def compile_with_string_building(intent, current_user = nil) if (filters = intent['filters']).present? where_fragments = filters.map do |filter| fragment, placeholder_index = build_string_filter_fragment( - filter, table, bind_spec, params_hash, placeholder_index, current_user + filter, table, bind_spec, params_hash, placeholder_index, current_user, intent ) fragment end @@ -393,13 +399,13 @@ def build_string_limit_clause(limit) "LIMIT #{Integer(limit)}" end - def build_string_filter_fragment(filter, table, bind_spec, params_hash, placeholder_index, current_user) + def build_string_filter_fragment(filter, table, bind_spec, params_hash, placeholder_index, current_user, intent) col = quote_ident(filter['column']) case filter['op'] when '=', '>', '<', '>=', '<=', '!=', '<>' build_string_comparison_fragment(col, filter, bind_spec, placeholder_index) when 'exists', 'not_exists' - build_string_subquery_fragment(filter, table, bind_spec, params_hash, placeholder_index, current_user) + build_string_subquery_fragment(filter, table, bind_spec, params_hash, placeholder_index, current_user, intent) when 'between' build_string_between_fragment(col, filter, bind_spec, params_hash, placeholder_index) when 'in' @@ -449,7 +455,7 @@ def build_string_pattern_fragment(quoted_column, filter, bind_spec, placeholder_ ["#{quoted_column} #{filter['op'].upcase} #{placeholder}", placeholder_index + 1] end - def build_string_subquery_fragment(filter, table, bind_spec, params_hash, placeholder_index, current_user) + def build_string_subquery_fragment(filter, table, bind_spec, params_hash, placeholder_index, current_user, intent) related_table = filter['related_table'] fk_column = filter['fk_column'] base_column = filter['base_column'] || 'id' @@ -464,7 +470,7 @@ def build_string_subquery_fragment(filter, table, bind_spec, params_hash, placeh sub_where = ["#{rt}.#{fk_col} = #{quote_ident(table)}.#{base_col}"] sub_where, placeholder_index = apply_policy_in_subquery( - sub_where, bind_spec, params_hash, related_table, placeholder_index, current_user + sub_where, bind_spec, params_hash, related_table, placeholder_index, current_user, intent ) related_filters.each do |related_filter| @@ -494,7 +500,7 @@ def build_string_subquery_filter_fragment(quoted_related_table, filter, bind_spe end end - def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, placeholder_index, current_user) + def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, placeholder_index, current_user, intent) return [sub_where, placeholder_index] unless @config.policy_adapter.respond_to?(:call) info = safely_fetch_policy(table: related_table, current_user: current_user) @@ -507,6 +513,7 @@ def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, p if value.is_a?(Range) && value.begin && value.end start_key = "#{policy_key_prefix}_start" end_key = "#{policy_key_prefix}_end" + merge_policy_expected_keys!(intent, start_key, end_key) params_hash[start_key] = value.begin params_hash[end_key] = value.end p1 = placeholder_for_adapter(placeholder_index) @@ -518,6 +525,7 @@ def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, p sub_where << "#{rcol} BETWEEN #{p1} AND #{p2}" else key = policy_key_prefix + merge_policy_expected_keys!(intent, key) params_hash[key] = value p = placeholder_for_adapter(placeholder_index) append_bind_spec(bind_spec, key: key, column: column) @@ -546,6 +554,29 @@ def policy_key_fragment(value) value.to_s.gsub(/[^a-zA-Z0-9_]/, '_') end + def merge_policy_expected_keys(intent, keys) + (Array(intent['__policy_expected_keys']) + Array(keys)).map(&:to_s).uniq + end + + def merge_policy_expected_keys!(intent, *keys) + intent['__policy_expected_keys'] = merge_policy_expected_keys(intent, keys.flatten) + end + + def deep_dup_value(value) + case value + when Hash + value.each_with_object({}) do |(key, nested_value), copy| + copy[key] = deep_dup_value(nested_value) + end + when Array + value.map { |nested_value| deep_dup_value(nested_value) } + else + value.dup + end + rescue TypeError + value + end + def build_arel_condition(table, filter, bind_spec, params_hash = nil) column = table[filter['column']] operator = filter['op'] diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index ce8b82d..d8994e0 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -297,24 +297,26 @@ def check_policy_compliance # Verify via bind_spec or params keys rather than scanning SQL text. return true unless @config.policy_adapter - return true unless policy_predicates_expected? + expected_keys = expected_policy_keys + return true if expected_keys.empty? - policy_in_binds = Array(@bind_spec).any? do |bind| - key = bind[:key] - key.to_s.start_with?('policy_') - end + present_keys = Array(@bind_spec).filter_map { |bind| bind[:key]&.to_s } + present_keys.concat(@params.keys.map(&:to_s)) - policy_in_params = @params.keys.any? { |k| k.to_s.start_with?('policy_') } - - policy_in_binds || policy_in_params + expected_keys.all? { |key| present_keys.include?(key) } end def policy_predicates_expected? - Array(@intent['filters']).any? do |filter| - filter['param'].to_s.start_with?('policy_') || - filter['param_start'].to_s.start_with?('policy_') || - filter['param_end'].to_s.start_with?('policy_') - end + expected_policy_keys.any? + end + + def expected_policy_keys + explicit_keys = Array(@intent['__policy_expected_keys']).map(&:to_s) + filter_keys = Array(@intent['filters']).flat_map do |filter| + [filter['param'], filter['param_start'], filter['param_end']] + end.compact.map(&:to_s).select { |key| key.start_with?('policy_') } + + (explicit_keys + filter_keys).uniq end def infer_column_type(connection, table_name, column_name, explicit_cast, param_key = column_name) diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 06bc9a2..02dec55 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -944,6 +944,26 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil expect(result[:params]['policy_tenant_id']).to eq(666) expect(result[:params][subquery_key]).to eq(42) expect(result[:bind_spec]).to include(hash_including(key: subquery_key, column: :tenant_id)) + expect(result[:intent]['__policy_expected_keys']).to include(subquery_key) + end + + it 'does not mutate the caller intent while recording subquery policy expectations' do + config.policy_adapter = lambda do |_user, **kwargs| + kwargs[:table] == 'answers' ? { enforced_predicates: { tenant_id: 42 } } : {} + end + + original_intent = { + 'table' => 'questions', + 'columns' => ['*'], + 'filters' => [related_filter('answers')], + 'limit' => 100, + 'params' => {} + } + + compiler.compile(original_intent) + + expect(original_intent).not_to have_key('__policy_expected_keys') + expect(original_intent['filters'].first).not_to have_key('__policy_expected_keys') end it 'keeps main-table and related-table policy binds distinct for the same column name' do diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 9e72771..56ff423 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -287,6 +287,70 @@ class << self ensure config.policy_adapter = nil end + + it 'returns false when expected subquery policy keys are missing from binds and params' do + config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } + + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE NOT EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: {}, + bind_spec: [], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'not_exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + ensure + config.policy_adapter = nil + end + + it 'returns true when expected subquery policy keys are present in binds and params' do + config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } + + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + allow(q).to receive(:perform_safety_checks).and_call_original + + expect(q.safe?).to be true + ensure + config.policy_adapter = nil + end end describe '#explain' do From f3e15528a83621e155839a66bdc2003c2f79a143 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 2 Jul 2026 19:23:23 -0500 Subject: [PATCH 07/86] fix: keep EXISTS policy subqueries out of top-level allowlist --- lib/code_to_query/query.rb | 92 +++++++++++++++++- spec/code_to_query/query_spec.rb | 162 +++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 2 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index d8994e0..4f4c9b9 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -248,7 +248,7 @@ def policy_applied? end def preview_would_run? - Guardrails::SqlLinter.new(@config, allow_tables: @allow_tables).check!(@sql) + lint_sql! true rescue SecurityError false @@ -268,7 +268,7 @@ def extract_metrics_from_intent(intent) def perform_safety_checks # Basic SQL structure checks - Guardrails::SqlLinter.new(@config, allow_tables: @allow_tables).check!(@sql) + lint_sql! # EXPLAIN-based performance checks return false if @config.enable_explain_gate && !Guardrails::ExplainGate.new(@config).allowed?( @@ -319,6 +319,94 @@ def expected_policy_keys (explicit_keys + filter_keys).uniq end + def effective_lint_allow_tables + (Array(@allow_tables) + related_tables_from_filters(@intent['filters'])).compact.map(&:to_s).uniq + end + + def related_tables_from_filters(filters) + Array(filters).flat_map do |filter| + nested_tables = related_tables_from_filters(filter['related_filters']) + direct_tables = %w[exists not_exists].include?(filter['op'].to_s) ? [filter['related_table']] : [] + + direct_tables + nested_tables + end + end + + def lint_sql! + Guardrails::SqlLinter.new(@config, allow_tables: effective_lint_allow_tables).check!(@sql) + check_top_level_table_allowlist! + end + + def check_top_level_table_allowlist! + allowed_tables = Array(@allow_tables).compact.map { |table| table.to_s.downcase } + return if allowed_tables.empty? + + top_level_sql = strip_exists_subqueries(@sql) + + raise SecurityError, 'Top-level common table expressions are not allowed' if top_level_sql.match?(/\A\s*WITH\b/i) + raise SecurityError, 'Top-level derived tables are not allowed' if top_level_sql.match?(/\b(?:FROM|JOIN)\s*\(/i) + + extract_table_names(top_level_sql).each do |table| + next if allowed_tables.include?(table.to_s.downcase) + + raise SecurityError, "Table '#{table}' is not in the allowed list: #{allowed_tables.join(', ')}" + end + end + + def strip_exists_subqueries(sql) + source = sql.to_s + stripped = +'' + index = 0 + + while index < source.length + match = /\b(?:NOT\s+)?EXISTS\s*\(/i.match(source, index) + break unless match + + stripped << source[index...match.begin(0)] + stripped << 'TRUE' + index = skip_parenthesized_sql(source, match.end(0)) + end + + stripped << source[index..] if index < source.length + stripped + end + + def skip_parenthesized_sql(source, index) + depth = 1 + + while index < source.length && depth.positive? + char = source[index] + depth += 1 if char == '(' + depth -= 1 if char == ')' + index += 1 + end + + index + end + + def extract_table_names(sql) + tables = [] + + sql.scan(/\bFROM\s+(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|\bJOIN\b|$)/im) do |match| + match.first.split(',').each do |reference| + table_name = extract_table_reference_name(reference) + tables << table_name if table_name + end + end + + sql.scan(/\b(?:INNER\s+|LEFT\s+|RIGHT\s+|FULL\s+|CROSS\s+)?JOIN\s+(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|\bJOIN\b|$)/im) do |match| + table_name = extract_table_reference_name(match.first) + tables << table_name if table_name + end + + tables.uniq + end + + def extract_table_reference_name(reference) + match = reference.to_s.strip.match(/\A(?:(?:`[^`]+`|"[^"]+"|'[^']+'|[a-zA-Z0-9_]+)\.)*(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?[a-zA-Z_][a-zA-Z0-9_]*)?\z/i) + match&.captures&.compact&.first + end + def infer_column_type(connection, table_name, column_name, explicit_cast, param_key = column_name) return explicit_cast if explicit_cast diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 56ff423..72bc30c 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -351,6 +351,168 @@ class << self ensure config.policy_adapter = nil end + + it 'still blocks related tables when SQL references them outside the declared EXISTS subquery' do + q = described_class.new( + sql: 'SELECT * FROM "questions" JOIN "answers" ON "answers"."question_id" = "questions"."id" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + + it 'still blocks related tables when SQL references them as an extra top-level FROM source' do + q = described_class.new( + sql: 'SELECT * FROM "questions", "answers" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + + it 'still blocks schema-qualified related tables when SQL references them as an extra top-level FROM source' do + q = described_class.new( + sql: 'SELECT * FROM "questions", public."answers" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + + it 'still blocks top-level derived tables that reference related tables outside the declared EXISTS subquery' do + q = described_class.new( + sql: 'SELECT * FROM "questions", (SELECT * FROM "answers") leaked WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + + it 'still blocks top-level common table expressions that reference related tables outside the declared EXISTS subquery' do + q = described_class.new( + sql: 'WITH leaked AS (SELECT * FROM "answers") SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + + it 'still blocks top-level common table expressions with leading whitespace' do + q = described_class.new( + sql: ' WITH leaked AS (SELECT * FROM "answers") SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end end describe '#explain' do From d17c30c434266389691acd25abd97a4e18d14780 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 2 Jul 2026 19:27:17 -0500 Subject: [PATCH 08/86] fix: block comma-style top-level derived tables --- lib/code_to_query/query.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 4f4c9b9..0a1963f 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -344,7 +344,7 @@ def check_top_level_table_allowlist! top_level_sql = strip_exists_subqueries(@sql) raise SecurityError, 'Top-level common table expressions are not allowed' if top_level_sql.match?(/\A\s*WITH\b/i) - raise SecurityError, 'Top-level derived tables are not allowed' if top_level_sql.match?(/\b(?:FROM|JOIN)\s*\(/i) + raise SecurityError, 'Top-level derived tables are not allowed' if top_level_sql.match?(/(?:\bFROM\b|\bJOIN\b|,)\s*\(/i) extract_table_names(top_level_sql).each do |table| next if allowed_tables.include?(table.to_s.downcase) From 20fb244a43a596d3b709163f15e9d5a30e63bf76 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 2 Jul 2026 19:29:35 -0500 Subject: [PATCH 09/86] style: satisfy rubocop safe navigation rule --- lib/code_to_query/query.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 0a1963f..bb48f93 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -404,7 +404,8 @@ def extract_table_names(sql) def extract_table_reference_name(reference) match = reference.to_s.strip.match(/\A(?:(?:`[^`]+`|"[^"]+"|'[^']+'|[a-zA-Z0-9_]+)\.)*(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?[a-zA-Z_][a-zA-Z0-9_]*)?\z/i) - match&.captures&.compact&.first + captures = match&.captures + captures&.compact&.first end def infer_column_type(connection, table_name, column_name, explicit_cast, param_key = column_name) From d0c5d7e0731af9caa56a8bdf39270249d2ee552e Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 2 Jul 2026 20:12:09 -0500 Subject: [PATCH 10/86] fix: align ask linting with query allowlist safety --- lib/code_to_query.rb | 8 ++-- lib/code_to_query/query.rb | 5 +- spec/code_to_query/query_spec.rb | 26 ++++++++++ spec/code_to_query_spec.rb | 82 ++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/lib/code_to_query.rb b/lib/code_to_query.rb index 2c6c4eb..400338b 100644 --- a/lib/code_to_query.rb +++ b/lib/code_to_query.rb @@ -124,9 +124,12 @@ def self.ask(prompt:, schema: nil, allow_tables: nil, current_user: nil) row_limit: validated_intent['limit'], policy_applied: policy_applied } + query = Query.new(sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], + intent: compiled[:intent] || validated_intent, allow_tables: allow_tables, config: config) + begin Instrumentation.instrument(:lint, **lint_payload) do - Guardrails::SqlLinter.new(config, allow_tables: allow_tables).check!(compiled[:sql]) + query.send(:lint_sql!) end rescue SecurityError => e Instrumentation.instrument( @@ -141,8 +144,7 @@ def self.ask(prompt:, schema: nil, allow_tables: nil, current_user: nil) raise end - Query.new(sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], - intent: compiled[:intent] || validated_intent, allow_tables: allow_tables, config: config) + query end def self.query_shape(intent) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index bb48f93..c77d26c 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -320,7 +320,10 @@ def expected_policy_keys end def effective_lint_allow_tables - (Array(@allow_tables) + related_tables_from_filters(@intent['filters'])).compact.map(&:to_s).uniq + explicit_tables = Array(@allow_tables).compact.map(&:to_s).uniq + return @allow_tables if explicit_tables.empty? + + (explicit_tables + related_tables_from_filters(@intent['filters'])).compact.map(&:to_s).uniq end def related_tables_from_filters(filters) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 72bc30c..ba0a3e3 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -352,6 +352,32 @@ class << self config.policy_adapter = nil end + it 'does not enforce a partial related-table allowlist when no explicit allow_tables are provided' do + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', + params: {}, + bind_spec: [], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ] + }, + allow_tables: nil, + config: config + ) + + expect(q.safe?).to be true + end + it 'still blocks related tables when SQL references them outside the declared EXISTS subquery' do q = described_class.new( sql: 'SELECT * FROM "questions" JOIN "answers" ON "answers"."question_id" = "questions"."id" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', diff --git a/spec/code_to_query_spec.rb b/spec/code_to_query_spec.rb index 8c9728e..5a0bb9a 100644 --- a/spec/code_to_query_spec.rb +++ b/spec/code_to_query_spec.rb @@ -102,6 +102,88 @@ expect(query.intent).to eq(compiled_intent) end + it 'allows declared EXISTS related tables during ask linting' do + planner = instance_double(CodeToQuery::Planner) + validator = instance_double(CodeToQuery::Validator) + compiler = instance_double(CodeToQuery::Compiler) + linter = instance_double(CodeToQuery::Guardrails::SqlLinter) + + compiled_intent = { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ] + } + + allow(CodeToQuery::Planner).to receive(:new).and_return(planner) + allow(CodeToQuery::Validator).to receive(:new).and_return(validator) + allow(CodeToQuery::Compiler).to receive(:new).and_return(compiler) + allow(CodeToQuery::Guardrails::SqlLinter).to receive(:new) + .with(CodeToQuery.config, allow_tables: %w[questions answers]) + .and_return(linter) + + allow(planner).to receive(:plan).and_return(compiled_intent) + allow(validator).to receive(:validate).and_return(compiled_intent) + allow(compiler).to receive(:compile).and_return( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', + params: {}, + bind_spec: [], + intent: compiled_intent + ) + allow(linter).to receive(:check!) + + expect { described_class.ask(prompt: 'Get questions', allow_tables: ['questions']) }.not_to raise_error + end + + it 'does not invent a partial allowlist when ask is called without allow_tables' do + planner = instance_double(CodeToQuery::Planner) + validator = instance_double(CodeToQuery::Validator) + compiler = instance_double(CodeToQuery::Compiler) + linter = instance_double(CodeToQuery::Guardrails::SqlLinter) + + compiled_intent = { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ] + } + + allow(CodeToQuery::Planner).to receive(:new).and_return(planner) + allow(CodeToQuery::Validator).to receive(:new).and_return(validator) + allow(CodeToQuery::Compiler).to receive(:new).and_return(compiler) + allow(CodeToQuery::Guardrails::SqlLinter).to receive(:new) + .with(CodeToQuery.config, allow_tables: nil) + .and_return(linter) + + allow(planner).to receive(:plan).and_return(compiled_intent) + allow(validator).to receive(:validate).and_return(compiled_intent) + allow(compiler).to receive(:compile).and_return( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', + params: {}, + bind_spec: [], + intent: compiled_intent + ) + allow(linter).to receive(:check!) + + expect { described_class.ask(prompt: 'Get questions') }.not_to raise_error + end + # rubocop:disable RSpec/ExampleLength it 'marks compile/lint telemetry as policy-applied when policy binds are present' do events = [] From 7d4951ed7428a99de129c4fcc3d71773748d0ee7 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 2 Jul 2026 20:16:59 -0500 Subject: [PATCH 11/86] test: satisfy rubocop for ask allowlist specs --- spec/code_to_query_spec.rb | 105 ++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 60 deletions(-) diff --git a/spec/code_to_query_spec.rb b/spec/code_to_query_spec.rb index 5a0bb9a..c31e404 100644 --- a/spec/code_to_query_spec.rb +++ b/spec/code_to_query_spec.rb @@ -24,6 +24,41 @@ stub_config(stub_llm: true, provider: :local) end + let(:exists_related_filter) do + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + end + + def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:) + planner = instance_double(CodeToQuery::Planner) + validator = instance_double(CodeToQuery::Validator) + compiler = instance_double(CodeToQuery::Compiler) + linter = instance_double(CodeToQuery::Guardrails::SqlLinter) + + allow(CodeToQuery::Planner).to receive(:new).and_return(planner) + allow(CodeToQuery::Validator).to receive(:new).and_return(validator) + allow(CodeToQuery::Compiler).to receive(:new).and_return(compiler) + allow(CodeToQuery::Guardrails::SqlLinter).to receive(:new) + .with(described_class.config, allow_tables: allow_tables) + .and_return(linter) + + allow(planner).to receive(:plan).and_return(compiled_intent) + allow(validator).to receive(:validate).and_return(compiled_intent) + allow(compiler).to receive(:compile).and_return( + sql: sql, + params: {}, + bind_spec: [], + intent: compiled_intent + ) + allow(linter).to receive(:check!) + end + # rubocop:disable RSpec/MultipleExpectations,RSpec/ExampleLength it 'emits non-sensitive pipeline instrumentation' do events = [] @@ -103,83 +138,33 @@ end it 'allows declared EXISTS related tables during ask linting' do - planner = instance_double(CodeToQuery::Planner) - validator = instance_double(CodeToQuery::Validator) - compiler = instance_double(CodeToQuery::Compiler) - linter = instance_double(CodeToQuery::Guardrails::SqlLinter) - compiled_intent = { 'table' => 'questions', 'type' => 'select', - 'filters' => [ - { - 'column' => 'id', - 'op' => 'exists', - 'related_table' => 'answers', - 'fk_column' => 'question_id', - 'base_column' => 'id', - 'related_filters' => [] - } - ] + 'filters' => [exists_related_filter] } - allow(CodeToQuery::Planner).to receive(:new).and_return(planner) - allow(CodeToQuery::Validator).to receive(:new).and_return(validator) - allow(CodeToQuery::Compiler).to receive(:new).and_return(compiler) - allow(CodeToQuery::Guardrails::SqlLinter).to receive(:new) - .with(CodeToQuery.config, allow_tables: %w[questions answers]) - .and_return(linter) - - allow(planner).to receive(:plan).and_return(compiled_intent) - allow(validator).to receive(:validate).and_return(compiled_intent) - allow(compiler).to receive(:compile).and_return( - sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', - params: {}, - bind_spec: [], - intent: compiled_intent + stub_ask_pipeline( + compiled_intent: compiled_intent, + allow_tables: %w[questions answers], + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")' ) - allow(linter).to receive(:check!) expect { described_class.ask(prompt: 'Get questions', allow_tables: ['questions']) }.not_to raise_error end it 'does not invent a partial allowlist when ask is called without allow_tables' do - planner = instance_double(CodeToQuery::Planner) - validator = instance_double(CodeToQuery::Validator) - compiler = instance_double(CodeToQuery::Compiler) - linter = instance_double(CodeToQuery::Guardrails::SqlLinter) - compiled_intent = { 'table' => 'questions', 'type' => 'select', - 'filters' => [ - { - 'column' => 'id', - 'op' => 'exists', - 'related_table' => 'answers', - 'fk_column' => 'question_id', - 'base_column' => 'id', - 'related_filters' => [] - } - ] + 'filters' => [exists_related_filter] } - allow(CodeToQuery::Planner).to receive(:new).and_return(planner) - allow(CodeToQuery::Validator).to receive(:new).and_return(validator) - allow(CodeToQuery::Compiler).to receive(:new).and_return(compiler) - allow(CodeToQuery::Guardrails::SqlLinter).to receive(:new) - .with(CodeToQuery.config, allow_tables: nil) - .and_return(linter) - - allow(planner).to receive(:plan).and_return(compiled_intent) - allow(validator).to receive(:validate).and_return(compiled_intent) - allow(compiler).to receive(:compile).and_return( - sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', - params: {}, - bind_spec: [], - intent: compiled_intent + stub_ask_pipeline( + compiled_intent: compiled_intent, + allow_tables: nil, + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")' ) - allow(linter).to receive(:check!) expect { described_class.ask(prompt: 'Get questions') }.not_to raise_error end From 324eec2e50dd48fa62fb31b5b851b9329199de58 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Fri, 3 Jul 2026 09:19:46 -0500 Subject: [PATCH 12/86] fix: fail closed for relation-only subquery policies --- lib/code_to_query/query.rb | 12 ++++++- spec/code_to_query/query_spec.rb | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index c77d26c..ef3e705 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -87,7 +87,7 @@ def explain def to_relation return nil unless defined?(ActiveRecord::Base) - return nil unless @intent['type'] == 'select' + return nil unless relationable? table_name = @intent['table'] model = infer_model_for_table(table_name) @@ -122,6 +122,7 @@ def to_active_record def relationable? return false unless defined?(ActiveRecord::Base) return false unless @intent['type'] == 'select' + return false if compiler_only_subquery_policy_filters? !!infer_model_for_table(@intent['table']) end @@ -254,6 +255,15 @@ def preview_would_run? false end + def compiler_only_subquery_policy_filters? + Array(@intent['filters']).any? do |filter| + next false unless %w[exists not_exists].include?(filter['op'].to_s) + + related_policy_keys = Array(@intent['__policy_expected_keys']).grep(/\Apolicy_subquery_/) + related_policy_keys.any? + end + end + def extract_metrics_from_intent(intent) data = intent.is_a?(Hash) ? intent['_metrics'] : nil return {} unless data.is_a?(Hash) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index ba0a3e3..2dc95c8 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -36,6 +36,23 @@ class << self stub_const('User', user_model) end + def build_scope_backed_question_model(scope) + stub_const('ActiveRecord', Module.new) unless defined?(ActiveRecord) + stub_const('ActiveRecord::Base', Class.new) unless defined?(ActiveRecord::Base) + + question_model = Class.new(ActiveRecord::Base) do + def self.table_name = 'questions' + def self.all = @scope + + class << self + attr_writer :scope + end + end + + question_model.scope = scope + stub_const('Question', question_model) + end + def build_policy_query(config) described_class.new( sql: 'SELECT * FROM "users" WHERE "active" = $1 AND "tenant_id" = $2 LIMIT 100', @@ -58,6 +75,31 @@ def build_policy_query(config) ) end + def build_subquery_policy_query(config) + described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + end + describe '#sql' do it 'returns the SQL string' do expect(query.sql).to eq(sql) @@ -621,6 +663,14 @@ class << self expect(scope).to have_received(:where).with('tenant_id' => 42).ordered expect(scope).to have_received(:limit).with(100) end + it 'returns nil when relation semantics would drop compiler-only subquery policy predicates' do + scope = double('scope') + allow(scope).to receive_messages(where: scope, order: scope, limit: scope) + build_scope_backed_question_model(scope) + + expect(build_subquery_policy_query(config).to_relation).to be_nil + expect(scope).not_to have_received(:where) + end end describe '#to_active_record' do @@ -635,6 +685,12 @@ class << self q = described_class.new(sql: sql, params: params, bind_spec: bind_spec, intent: { 'type' => 'insert' }, allow_tables: ['users'], config: config) expect(q.relationable?).to be false end + + it 'returns false when relation semantics would drop compiler-only subquery policy predicates' do + build_scope_backed_question_model(double('scope')) + + expect(build_subquery_policy_query(config).relationable?).to be false + end end describe '#to_relation!' do From 129b84224a9309522c5045b2b98ca2e2321bf305 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Fri, 3 Jul 2026 09:22:24 -0500 Subject: [PATCH 13/86] fix: satisfy rubocop spacing in query spec --- spec/code_to_query/query_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 2dc95c8..5b56307 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -663,6 +663,7 @@ class << self expect(scope).to have_received(:where).with('tenant_id' => 42).ordered expect(scope).to have_received(:limit).with(100) end + it 'returns nil when relation semantics would drop compiler-only subquery policy predicates' do scope = double('scope') allow(scope).to receive_messages(where: scope, order: scope, limit: scope) From 0595c66dbb7f83f15dd7794b2d8298d73e140cf9 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Fri, 3 Jul 2026 13:22:42 -0500 Subject: [PATCH 14/86] fix: keep related subqueries inside explicit allowlists --- lib/code_to_query/query.rb | 11 +---------- spec/code_to_query_spec.rb | 7 ++++--- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index ef3e705..8de9431 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -333,16 +333,7 @@ def effective_lint_allow_tables explicit_tables = Array(@allow_tables).compact.map(&:to_s).uniq return @allow_tables if explicit_tables.empty? - (explicit_tables + related_tables_from_filters(@intent['filters'])).compact.map(&:to_s).uniq - end - - def related_tables_from_filters(filters) - Array(filters).flat_map do |filter| - nested_tables = related_tables_from_filters(filter['related_filters']) - direct_tables = %w[exists not_exists].include?(filter['op'].to_s) ? [filter['related_table']] : [] - - direct_tables + nested_tables - end + explicit_tables end def lint_sql! diff --git a/spec/code_to_query_spec.rb b/spec/code_to_query_spec.rb index c31e404..4d40d01 100644 --- a/spec/code_to_query_spec.rb +++ b/spec/code_to_query_spec.rb @@ -137,7 +137,7 @@ def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:) expect(query.intent).to eq(compiled_intent) end - it 'allows declared EXISTS related tables during ask linting' do + it 'rejects EXISTS related tables unless they are explicitly allowed' do compiled_intent = { 'table' => 'questions', 'type' => 'select', @@ -146,11 +146,12 @@ def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:) stub_ask_pipeline( compiled_intent: compiled_intent, - allow_tables: %w[questions answers], + allow_tables: ['questions'], sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")' ) - expect { described_class.ask(prompt: 'Get questions', allow_tables: ['questions']) }.not_to raise_error + expect { described_class.ask(prompt: 'Get questions', allow_tables: ['questions']) } + .to raise_error(SecurityError, /allowed list/i) end it 'does not invent a partial allowlist when ask is called without allow_tables' do From e9be8bcc90d5e265e911d757636c39dff6773f09 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Fri, 3 Jul 2026 13:26:41 -0500 Subject: [PATCH 15/86] test: align allowlist security specs --- spec/code_to_query/query_spec.rb | 2 +- spec/code_to_query_spec.rb | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 5b56307..0133f1c 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -383,7 +383,7 @@ class << self ], '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] }, - allow_tables: ['questions'], + allow_tables: ['questions', 'answers'], config: config ) diff --git a/spec/code_to_query_spec.rb b/spec/code_to_query_spec.rb index 4d40d01..f06762e 100644 --- a/spec/code_to_query_spec.rb +++ b/spec/code_to_query_spec.rb @@ -35,7 +35,7 @@ } end - def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:) + def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:, linter_error: nil) planner = instance_double(CodeToQuery::Planner) validator = instance_double(CodeToQuery::Validator) compiler = instance_double(CodeToQuery::Compiler) @@ -56,7 +56,9 @@ def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:) bind_spec: [], intent: compiled_intent ) - allow(linter).to receive(:check!) + allow(linter).to receive(:check!) do + raise linter_error if linter_error + end end # rubocop:disable RSpec/MultipleExpectations,RSpec/ExampleLength @@ -147,7 +149,8 @@ def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:) stub_ask_pipeline( compiled_intent: compiled_intent, allow_tables: ['questions'], - sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")' + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', + linter_error: SecurityError.new("Table 'answers' is not in the allowed list: questions") ) expect { described_class.ask(prompt: 'Get questions', allow_tables: ['questions']) } From e5f825b0ead187296389be7a7c5e06aaec25d130 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Fri, 3 Jul 2026 13:29:10 -0500 Subject: [PATCH 16/86] style: satisfy word array cop --- spec/code_to_query/query_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 0133f1c..0f2529b 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -383,7 +383,7 @@ class << self ], '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] }, - allow_tables: ['questions', 'answers'], + allow_tables: %w[questions answers], config: config ) From 424963b67d623df5003c67483302f010ffdfca5c Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 4 Jul 2026 07:08:39 -0500 Subject: [PATCH 17/86] fix: block quoted top-level aliases outside allowlist --- lib/code_to_query/query.rb | 2 +- spec/code_to_query/query_spec.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 8de9431..33adb84 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -407,7 +407,7 @@ def extract_table_names(sql) end def extract_table_reference_name(reference) - match = reference.to_s.strip.match(/\A(?:(?:`[^`]+`|"[^"]+"|'[^']+'|[a-zA-Z0-9_]+)\.)*(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?[a-zA-Z_][a-zA-Z0-9_]*)?\z/i) + match = reference.to_s.strip.match(/\A(?:(?:`[^`]+`|"[^"]+"|'[^']+'|[a-zA-Z0-9_]+)\.)*(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?(?:`[^`]+`|"[^"]+"|'[^']+'|[a-zA-Z_][a-zA-Z0-9_]*))?\z/i) captures = match&.captures captures&.compact&.first end diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 0f2529b..1103555 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -447,6 +447,33 @@ class << self expect(q.safe?).to be false end + it 'still blocks related tables when SQL references them outside the declared EXISTS subquery with a quoted alias' do + q = described_class.new( + sql: 'SELECT * FROM "questions" JOIN "answers" AS "a" ON "a"."question_id" = "questions"."id" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + it 'still blocks related tables when SQL references them as an extra top-level FROM source' do q = described_class.new( sql: 'SELECT * FROM "questions", "answers" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', From ac59ded4abee4217c002cb0b7c160745d1a64e15 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 4 Jul 2026 07:10:34 -0500 Subject: [PATCH 18/86] fix: cover quoted aliases in allowlist guard --- lib/code_to_query/query.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 33adb84..d3f3461 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -407,7 +407,11 @@ def extract_table_names(sql) end def extract_table_reference_name(reference) - match = reference.to_s.strip.match(/\A(?:(?:`[^`]+`|"[^"]+"|'[^']+'|[a-zA-Z0-9_]+)\.)*(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?(?:`[^`]+`|"[^"]+"|'[^']+'|[a-zA-Z_][a-zA-Z0-9_]*))?\z/i) + identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z0-9_]+)' + alias_identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z_][a-zA-Z0-9_]*)' + table_reference_pattern = /\A(?:#{identifier}\.)*(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i + + match = reference.to_s.strip.match(table_reference_pattern) captures = match&.captures captures&.compact&.first end From b0926976a9650a608ca862135661d6e86c2b34bd Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 4 Jul 2026 07:17:11 -0500 Subject: [PATCH 19/86] fix: block lateral derived table bypass --- lib/code_to_query/query.rb | 2 +- spec/code_to_query_spec.rb | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index d3f3461..905b7f8 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -348,7 +348,7 @@ def check_top_level_table_allowlist! top_level_sql = strip_exists_subqueries(@sql) raise SecurityError, 'Top-level common table expressions are not allowed' if top_level_sql.match?(/\A\s*WITH\b/i) - raise SecurityError, 'Top-level derived tables are not allowed' if top_level_sql.match?(/(?:\bFROM\b|\bJOIN\b|,)\s*\(/i) + raise SecurityError, 'Top-level derived tables are not allowed' if top_level_sql.match?(/(?:\bFROM\b|\bJOIN\b(?:\s+LATERAL)?|,)\s*\(/i) extract_table_names(top_level_sql).each do |table| next if allowed_tables.include?(table.to_s.downcase) diff --git a/spec/code_to_query_spec.rb b/spec/code_to_query_spec.rb index f06762e..2b90df1 100644 --- a/spec/code_to_query_spec.rb +++ b/spec/code_to_query_spec.rb @@ -157,6 +157,24 @@ def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:, linter_error: nil) .to raise_error(SecurityError, /allowed list/i) end + it 'rejects top-level lateral derived tables that reference unallowed tables' do + compiled_intent = { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [exists_related_filter] + } + + stub_ask_pipeline( + compiled_intent: compiled_intent, + allow_tables: ['questions'], + sql: 'SELECT * FROM "questions" JOIN LATERAL (SELECT * FROM "answers") leaked ON TRUE WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', + linter_error: SecurityError.new('Top-level derived tables are not allowed') + ) + + expect { described_class.ask(prompt: 'Get questions', allow_tables: ['questions']) } + .to raise_error(SecurityError, /derived tables are not allowed/i) + end + it 'does not invent a partial allowlist when ask is called without allow_tables' do compiled_intent = { 'table' => 'questions', From d96bca5ab523db32cb2b35adf2741a57726301f0 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 4 Jul 2026 07:22:02 -0500 Subject: [PATCH 20/86] fix: block lateral derived tables in allowlist guard --- lib/code_to_query/query.rb | 2 +- spec/code_to_query/query_spec.rb | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 905b7f8..7e84aa1 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -348,7 +348,7 @@ def check_top_level_table_allowlist! top_level_sql = strip_exists_subqueries(@sql) raise SecurityError, 'Top-level common table expressions are not allowed' if top_level_sql.match?(/\A\s*WITH\b/i) - raise SecurityError, 'Top-level derived tables are not allowed' if top_level_sql.match?(/(?:\bFROM\b|\bJOIN\b(?:\s+LATERAL)?|,)\s*\(/i) + raise SecurityError, 'Top-level derived tables are not allowed' if top_level_sql.match?(/(?:\bFROM\b|\bJOIN\b|,)\s*(?:LATERAL\s+)?\(/i) extract_table_names(top_level_sql).each do |table| next if allowed_tables.include?(table.to_s.downcase) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 1103555..a8429c1 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -555,6 +555,60 @@ class << self expect(q.safe?).to be false end + it 'still blocks top-level lateral derived tables in FROM sources' do + q = described_class.new( + sql: 'SELECT * FROM LATERAL (SELECT * FROM "answers") leaked, "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + + it 'still blocks comma lateral derived tables that reference related tables outside the declared EXISTS subquery' do + q = described_class.new( + sql: 'SELECT * FROM "questions", LATERAL (SELECT * FROM "answers") leaked WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + it 'still blocks top-level common table expressions that reference related tables outside the declared EXISTS subquery' do q = described_class.new( sql: 'WITH leaked AS (SELECT * FROM "answers") SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', From 1f8efe7d0905c3df70b72110d4d069fa2a0659da Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 4 Jul 2026 23:23:18 -0500 Subject: [PATCH 21/86] fix: preserve subquery policy enforcement context --- lib/code_to_query/compiler.rb | 2 +- lib/code_to_query/query.rb | 14 +++++------- spec/code_to_query/compiler_spec.rb | 18 ++++++++++++++++ spec/code_to_query/query_spec.rb | 33 ++++++++++++++++++++++++++++- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 767fc05..30a1bd8 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -503,7 +503,7 @@ def build_string_subquery_filter_fragment(quoted_related_table, filter, bind_spe def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, placeholder_index, current_user, intent) return [sub_where, placeholder_index] unless @config.policy_adapter.respond_to?(:call) - info = safely_fetch_policy(table: related_table, current_user: current_user) + info = safely_fetch_policy(table: related_table, current_user: current_user, intent: intent) predicates = extract_enforced_predicates(info) return [sub_where, placeholder_index] unless predicates.is_a?(Hash) && predicates.any? diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 7e84aa1..4dfc53c 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -232,16 +232,13 @@ def preview_params end def applied_policy_keys - # Policy predicates compiled by CodeToQuery use policy-prefixed bind keys. - # Surface only those keys so preview callers can audit policy application - # without exposing bind values through telemetry. - keys = Array(@bind_spec).filter_map do |bind| + # Policy predicates compiled by CodeToQuery must surface as policy-prefixed + # bind keys. Surface only those bound keys so preview callers can audit + # policy application without trusting raw params or exposing bind values. + Array(@bind_spec).filter_map do |bind| key = bind[:key] key.to_s if key.to_s.start_with?('policy_') - end - - keys.concat(@params.keys.filter_map { |key| key.to_s if key.to_s.start_with?('policy_') }) - keys.uniq + end.uniq end def policy_applied? @@ -311,7 +308,6 @@ def check_policy_compliance return true if expected_keys.empty? present_keys = Array(@bind_spec).filter_map { |bind| bind[:key]&.to_s } - present_keys.concat(@params.keys.map(&:to_s)) expected_keys.all? { |key| present_keys.include?(key) } end diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 02dec55..371a4f7 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -1037,6 +1037,24 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil expect(result[:sql]).to include('"line_items"."account_id" = $1') expect(result[:params][subquery_key]).to eq(7) end + + it 'passes the parent intent when applying subquery policies' do + observed_intents = [] + config.policy_adapter = lambda do |_user, **kwargs| + observed_intents << kwargs[:intent] if kwargs[:table] == 'line_items' + + kwargs[:table] == 'line_items' ? { enforced_predicates: { account_id: 7 } } : {} + end + + result = compile_with_related_filters( + table: 'orders', + filters: [related_filter('line_items', fk_column: 'order_id')], + current_user: { account_id: 7 } + ) + + expect(observed_intents).to contain_exactly(include('table' => 'orders', 'filters' => [include('related_table' => 'line_items')])) + expect(result[:params]['policy_subquery_1_line_items_account_id']).to eq(7) + end end end end diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index a8429c1..e14b8f7 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -361,7 +361,7 @@ class << self config.policy_adapter = nil end - it 'returns true when expected subquery policy keys are present in binds and params' do + it 'returns true when expected subquery policy keys are present in binds' do config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } q = described_class.new( @@ -394,6 +394,37 @@ class << self config.policy_adapter = nil end + it 'returns false when expected subquery policy keys are present only in params' do + config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } + + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: %w[questions answers], + config: config + ) + + expect(q.safe?).to be false + ensure + config.policy_adapter = nil + end + it 'does not enforce a partial related-table allowlist when no explicit allow_tables are provided' do q = described_class.new( sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', From 4a9731684805db3ef0a2d646c796c8c8a4620cbe Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 5 Jul 2026 06:35:22 -0500 Subject: [PATCH 22/86] fix: enforce policy allowlists for related tables --- lib/code_to_query/query.rb | 9 ++++++--- lib/code_to_query/validator.rb | 3 +++ spec/code_to_query/query_spec.rb | 27 +++++++++++++++++++++++++++ spec/code_to_query/validator_spec.rb | 11 +++++++++++ spec/code_to_query_spec.rb | 28 ++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 4dfc53c..f7c5a51 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -327,9 +327,12 @@ def expected_policy_keys def effective_lint_allow_tables explicit_tables = Array(@allow_tables).compact.map(&:to_s).uniq - return @allow_tables if explicit_tables.empty? + policy_tables = Array(@intent['__policy_allowed_tables']).compact.map(&:to_s).uniq - explicit_tables + return policy_tables if explicit_tables.empty? + return explicit_tables if policy_tables.empty? + + explicit_tables & policy_tables end def lint_sql! @@ -338,7 +341,7 @@ def lint_sql! end def check_top_level_table_allowlist! - allowed_tables = Array(@allow_tables).compact.map { |table| table.to_s.downcase } + allowed_tables = Array(effective_lint_allow_tables).compact.map { |table| table.to_s.downcase } return if allowed_tables.empty? top_level_sql = strip_exists_subqueries(@sql) diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index 2c8341e..46c4235 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -147,6 +147,9 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) allowed_tables = Array(fetch_value(policy_info, :allowed_tables)).map { |t| t.to_s.downcase } if allowed_tables.any? + intent[:__policy_allowed_tables] = allowed_tables + intent['__policy_allowed_tables'] = allowed_tables + table = fetch_value(intent, :table) if (table.to_s.strip != '') && !allowed_tables.include?(table.to_s.downcase) raise ArgumentError, "Invalid intent: table '#{table}' not permitted by policy" diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index e14b8f7..7a7932d 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -451,6 +451,33 @@ class << self expect(q.safe?).to be true end + it 'enforces policy-derived related-table allowlists when no explicit allow_tables are provided' do + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', + params: {}, + bind_spec: [], + intent: { + 'table' => 'questions', + 'type' => 'select', + '__policy_allowed_tables' => ['questions'], + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ] + }, + allow_tables: nil, + config: config + ) + + expect(q.safe?).to be false + end + it 'still blocks related tables when SQL references them outside the declared EXISTS subquery' do q = described_class.new( sql: 'SELECT * FROM "questions" JOIN "answers" ON "answers"."question_id" = "questions"."id" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index ab59372..ed4687f 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -330,6 +330,17 @@ result = validator.validate(intent) expect(result[:columns]).to eq(['*']) end + + it 'records policy-allowed tables for downstream SQL linting' do + intent = { + 'type' => 'select', + 'table' => 'users', + 'columns' => ['*'] + } + + result = validator.validate(intent) + expect(result[:__policy_allowed_tables]).to eq(%w[users orders]) + end end end diff --git a/spec/code_to_query_spec.rb b/spec/code_to_query_spec.rb index 2b90df1..913c564 100644 --- a/spec/code_to_query_spec.rb +++ b/spec/code_to_query_spec.rb @@ -191,6 +191,34 @@ def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:, linter_error: nil) expect { described_class.ask(prompt: 'Get questions') }.not_to raise_error end + it 'rejects EXISTS related tables that fall outside the policy adapter allowlist' do + planner = instance_double(CodeToQuery::Planner) + validator = instance_double(CodeToQuery::Validator) + compiler = instance_double(CodeToQuery::Compiler) + + compiled_intent = { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [exists_related_filter], + '__policy_allowed_tables' => ['questions'] + } + + allow(CodeToQuery::Planner).to receive(:new).and_return(planner) + allow(CodeToQuery::Validator).to receive(:new).and_return(validator) + allow(CodeToQuery::Compiler).to receive(:new).and_return(compiler) + allow(planner).to receive(:plan).and_return(compiled_intent) + allow(validator).to receive(:validate).and_return(compiled_intent) + allow(compiler).to receive(:compile).and_return( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', + params: {}, + bind_spec: [], + intent: compiled_intent + ) + + expect { described_class.ask(prompt: 'Get questions') } + .to raise_error(SecurityError, /allowed list/i) + end + # rubocop:disable RSpec/ExampleLength it 'marks compile/lint telemetry as policy-applied when policy binds are present' do events = [] From f95a693503bb33beba66204a9b148408e44c659c Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 5 Jul 2026 06:38:03 -0500 Subject: [PATCH 23/86] fix: preserve nil allowlists when no policy applies --- lib/code_to_query/query.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index f7c5a51..0e5bd3d 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -329,6 +329,7 @@ def effective_lint_allow_tables explicit_tables = Array(@allow_tables).compact.map(&:to_s).uniq policy_tables = Array(@intent['__policy_allowed_tables']).compact.map(&:to_s).uniq + return @allow_tables if explicit_tables.empty? && policy_tables.empty? return policy_tables if explicit_tables.empty? return explicit_tables if policy_tables.empty? From 439d41d7ff630a83a82ab17506d8de3ecc4d4e20 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 5 Jul 2026 07:17:19 -0500 Subject: [PATCH 24/86] fix: normalize policy table allowlist intersections --- lib/code_to_query/query.rb | 8 +++++--- spec/code_to_query/query_spec.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 0e5bd3d..a36f52a 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -328,12 +328,14 @@ def expected_policy_keys def effective_lint_allow_tables explicit_tables = Array(@allow_tables).compact.map(&:to_s).uniq policy_tables = Array(@intent['__policy_allowed_tables']).compact.map(&:to_s).uniq + normalized_explicit_tables = explicit_tables.map(&:downcase).uniq + normalized_policy_tables = policy_tables.map(&:downcase).uniq return @allow_tables if explicit_tables.empty? && policy_tables.empty? - return policy_tables if explicit_tables.empty? - return explicit_tables if policy_tables.empty? + return normalized_policy_tables if explicit_tables.empty? + return normalized_explicit_tables if policy_tables.empty? - explicit_tables & policy_tables + normalized_explicit_tables & normalized_policy_tables end def lint_sql! diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 7a7932d..63f4f48 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -478,6 +478,33 @@ class << self expect(q.safe?).to be false end + it 'enforces policy-derived allowlists even when explicit allow_tables casing differs' do + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', + params: {}, + bind_spec: [], + intent: { + 'table' => 'questions', + 'type' => 'select', + '__policy_allowed_tables' => ['questions'], + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ] + }, + allow_tables: ['Questions'], + config: config + ) + + expect(q.safe?).to be false + end + it 'still blocks related tables when SQL references them outside the declared EXISTS subquery' do q = described_class.new( sql: 'SELECT * FROM "questions" JOIN "answers" ON "answers"."question_id" = "questions"."id" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', From e9d9f102f6f7da61eed6dfdc9f19961334c73172 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 5 Jul 2026 11:49:49 -0500 Subject: [PATCH 25/86] fix: fail closed on disjoint policy allowlists --- lib/code_to_query/query.rb | 11 +++++++++++ spec/code_to_query/query_spec.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index a36f52a..4684f9b 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -338,7 +338,18 @@ def effective_lint_allow_tables normalized_explicit_tables & normalized_policy_tables end + def allowlist_sources_present? + explicit_tables = Array(@allow_tables).compact + policy_tables = Array(@intent['__policy_allowed_tables']).compact + + explicit_tables.any? || policy_tables.any? + end + def lint_sql! + if allowlist_sources_present? && Array(effective_lint_allow_tables).empty? + raise SecurityError, 'No tables remain after intersecting explicit and policy allowlists' + end + Guardrails::SqlLinter.new(@config, allow_tables: effective_lint_allow_tables).check!(@sql) check_top_level_table_allowlist! end diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 63f4f48..303673a 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -505,6 +505,33 @@ class << self expect(q.safe?).to be false end + it 'fails closed when explicit and policy allowlists intersect to an empty set' do + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', + params: {}, + bind_spec: [], + intent: { + 'table' => 'questions', + 'type' => 'select', + '__policy_allowed_tables' => ['answers'], + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + it 'still blocks related tables when SQL references them outside the declared EXISTS subquery' do q = described_class.new( sql: 'SELECT * FROM "questions" JOIN "answers" ON "answers"."question_id" = "questions"."id" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', From 6411c15536e158165b231b2c967bd0904f041614 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 5 Jul 2026 23:38:44 -0500 Subject: [PATCH 26/86] fix: carry related-table policy allowlists --- lib/code_to_query/compiler.rb | 12 ++++++++++++ spec/code_to_query/compiler_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 30a1bd8..f0e1257 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -504,6 +504,7 @@ def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, p return [sub_where, placeholder_index] unless @config.policy_adapter.respond_to?(:call) info = safely_fetch_policy(table: related_table, current_user: current_user, intent: intent) + merge_policy_allowed_tables!(intent, info) predicates = extract_enforced_predicates(info) return [sub_where, placeholder_index] unless predicates.is_a?(Hash) && predicates.any? @@ -562,6 +563,17 @@ def merge_policy_expected_keys!(intent, *keys) intent['__policy_expected_keys'] = merge_policy_expected_keys(intent, keys.flatten) end + def merge_policy_allowed_tables!(intent, policy_info) + return unless policy_info.is_a?(Hash) + + allowed_tables = Array(policy_info[:allowed_tables] || policy_info['allowed_tables']).map do |table| + table.to_s.downcase + end.reject(&:empty?) + return if allowed_tables.empty? + + intent['__policy_allowed_tables'] = (Array(intent['__policy_allowed_tables']) + allowed_tables).uniq + end + def deep_dup_value(value) case value when Hash diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 371a4f7..87316cc 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -947,6 +947,26 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil expect(result[:intent]['__policy_expected_keys']).to include(subquery_key) end + it 'records related-table allowlists returned by the subquery policy adapter' do + config.policy_adapter = lambda do |_user, **kwargs| + case kwargs[:table] + when 'questions' + { allowed_tables: ['questions'] } + when 'answers' + { + allowed_tables: %w[questions answers], + enforced_predicates: { tenant_id: 42 } + } + else + {} + end + end + + result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) + + expect(result[:intent]['__policy_allowed_tables']).to eq(%w[questions answers]) + end + it 'does not mutate the caller intent while recording subquery policy expectations' do config.policy_adapter = lambda do |_user, **kwargs| kwargs[:table] == 'answers' ? { enforced_predicates: { tenant_id: 42 } } : {} From 864241201d0eec07086c9535a67cbe52cb4f09a2 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 6 Jul 2026 00:18:53 -0500 Subject: [PATCH 27/86] fix: scope related tables to declared exists filters --- lib/code_to_query/query.rb | 24 ++++++++++++++++++++++++ spec/code_to_query/query_spec.rb | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 4684f9b..ce26e9b 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -352,6 +352,7 @@ def lint_sql! Guardrails::SqlLinter.new(@config, allow_tables: effective_lint_allow_tables).check!(@sql) check_top_level_table_allowlist! + check_policy_scoped_related_table_references! end def check_top_level_table_allowlist! @@ -370,6 +371,29 @@ def check_top_level_table_allowlist! end end + def check_policy_scoped_related_table_references! + policy_scoped_related_tables = declared_related_tables_outside_explicit_allowlist + return if policy_scoped_related_tables.empty? + + extract_table_names(strip_exists_subqueries(@sql)).each do |table| + next unless policy_scoped_related_tables.include?(table.to_s.downcase) + + raise SecurityError, + "Table '#{table}' is only allowed inside declared EXISTS/NOT EXISTS filters" + end + end + + def declared_related_tables_outside_explicit_allowlist + explicit_tables = Array(@allow_tables).compact.map { |table| table.to_s.downcase } + + Array(@intent['filters']).filter_map do |filter| + op = filter['op'].to_s.downcase + next unless %w[exists not_exists].include?(op) + + filter['related_table']&.to_s&.downcase + end.reject { |table| table.nil? || explicit_tables.include?(table) }.uniq + end + def strip_exists_subqueries(sql) source = sql.to_s stripped = +'' diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 303673a..4f8801a 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -640,6 +640,34 @@ class << self expect(q.safe?).to be false end + it 'still blocks related tables when SQL reuses them in a non-EXISTS nested subquery' do + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE id IN (SELECT "answers"."question_id" FROM "answers" WHERE "answers"."tenant_id" = $1) AND EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + '__policy_allowed_tables' => %w[questions answers], + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: nil, + config: config + ) + + expect(q.safe?).to be false + end + it 'still blocks top-level derived tables that reference related tables outside the declared EXISTS subquery' do q = described_class.new( sql: 'SELECT * FROM "questions", (SELECT * FROM "answers") leaked WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', From 712be6a10f2263121afbfe9a338e4bb75286ac2e Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 6 Jul 2026 07:49:38 -0500 Subject: [PATCH 28/86] fix: catch outer and natural join allowlist bypasses --- lib/code_to_query/query.rb | 8 +++-- spec/code_to_query/query_spec.rb | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index ce26e9b..9a3f958 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -428,14 +428,14 @@ def skip_parenthesized_sql(source, index) def extract_table_names(sql) tables = [] - sql.scan(/\bFROM\s+(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|\bJOIN\b|$)/im) do |match| + sql.scan(/\bFROM\s+(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| match.first.split(',').each do |reference| table_name = extract_table_reference_name(reference) tables << table_name if table_name end end - sql.scan(/\b(?:INNER\s+|LEFT\s+|RIGHT\s+|FULL\s+|CROSS\s+)?JOIN\s+(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|\bJOIN\b|$)/im) do |match| + sql.scan(/#{join_clause_pattern}\s+(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| table_name = extract_table_reference_name(match.first) tables << table_name if table_name end @@ -443,6 +443,10 @@ def extract_table_names(sql) tables.uniq end + def join_clause_pattern + /\b(?:INNER\s+|(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+|CROSS\s+|NATURAL\s+(?:(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+)?)?JOIN\b/i + end + def extract_table_reference_name(reference) identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z0-9_]+)' alias_identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z_][a-zA-Z0-9_]*)' diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 4f8801a..853f0c3 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -586,6 +586,60 @@ class << self expect(q.safe?).to be false end + it 'still blocks related tables when SQL references them through a LEFT OUTER JOIN' do + q = described_class.new( + sql: 'SELECT * FROM "questions" LEFT OUTER JOIN "answers" ON "answers"."question_id" = "questions"."id" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + + it 'still blocks related tables when SQL references them through a NATURAL JOIN' do + q = described_class.new( + sql: 'SELECT * FROM "questions" NATURAL JOIN "answers" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'answers', + 'fk_column' => 'question_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be false + end + it 'still blocks related tables when SQL references them as an extra top-level FROM source' do q = described_class.new( sql: 'SELECT * FROM "questions", "answers" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', From 9b1448c49c4b7011c2bd15f672bbbee061c30cc8 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 20:29:47 -0500 Subject: [PATCH 29/86] fix: fail closed on incomplete policy enforcement --- lib/code_to_query/compiler.rb | 50 ++++++++++-- lib/code_to_query/query.rb | 122 ++++++++++++++++++++++++++-- lib/code_to_query/validator.rb | 12 ++- spec/code_to_query/compiler_spec.rb | 41 ++++++++++ spec/code_to_query/query_spec.rb | 49 +++++++++++ 5 files changed, 258 insertions(+), 16 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index f0e1257..bb383e1 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -17,12 +17,15 @@ def initialize(config) def compile(intent, current_user: nil) working_intent = deep_dup_value(intent) + strip_untrusted_policy_expectations!(working_intent) intent_with_policy = apply_policy_predicates(working_intent, current_user) - if use_arel? - compile_with_arel(intent_with_policy, current_user) - else - compile_with_string_building(intent_with_policy, current_user) - end + result = if use_arel? + compile_with_arel(intent_with_policy, current_user) + else + compile_with_string_building(intent_with_policy, current_user) + end + verify_compiled_policy_binds!(result) + result end private @@ -86,11 +89,15 @@ def safely_fetch_policy(table:, current_user:, intent: nil) else @config.policy_adapter.call(current_user, table: table) end - rescue ArgumentError + rescue ArgumentError => e + raise unless policy_signature_mismatch?(e) + # Backward compatibility: adapters may accept user plus table or only user. begin @config.policy_adapter.call(current_user, table: table) - rescue ArgumentError + rescue ArgumentError => fallback_error + raise fallback_error unless policy_signature_mismatch?(fallback_error) + begin @config.policy_adapter.call(current_user) rescue StandardError => e @@ -117,11 +124,17 @@ def extract_enforced_predicates(policy_info) predicates = policy_info[:enforced_predicates] || policy_info['enforced_predicates'] || policy_info[:predicates] || policy_info['predicates'] + explicit_predicate_contract = policy_info.keys.any? do |key| + %w[enforced_predicates predicates].include?(key.to_s) + end predicates = policy_info if predicates.nil? && direct_predicate_hash?(policy_info) predicates ||= {} unless predicates.is_a?(Hash) return handle_policy_failure("Policy predicates must be a Hash, got #{predicates.class}") end + if explicit_predicate_contract && predicates.empty? + return handle_policy_failure('Policy adapter returned an empty predicate contract') + end predicates.each do |column, value| return handle_policy_failure('Policy predicate columns must be present') if column.to_s.strip.empty? @@ -141,6 +154,10 @@ def direct_predicate_hash?(policy_info) end end + def policy_signature_mismatch?(error) + error.message.match?(/unknown keyword.*intent|wrong number of arguments|no keywords accepted/) + end + def policy_adapter_fail_open? @config.respond_to?(:policy_adapter_fail_open) && @config.policy_adapter_fail_open end @@ -574,6 +591,25 @@ def merge_policy_allowed_tables!(intent, policy_info) intent['__policy_allowed_tables'] = (Array(intent['__policy_allowed_tables']) + allowed_tables).uniq end + def strip_untrusted_policy_expectations!(intent) + intent.delete('__policy_expected_keys') + intent.delete(:__policy_expected_keys) + end + + # Independent compiler backstop: metadata alone never proves that a policy + # predicate was emitted. Every expected key must have both a bind and value. + def verify_compiled_policy_binds!(result) + expected = Array(result.dig(:intent, '__policy_expected_keys')).map(&:to_s) + return if expected.empty? + + binds = Array(result[:bind_spec]).filter_map { |bind| bind[:key]&.to_s } + params = (result[:params] || {}).keys.map(&:to_s) + missing = expected.reject { |key| binds.include?(key) && params.include?(key) } + return if missing.empty? + + raise PolicyAdapterError, "Compiled policy binds are missing: #{missing.join(', ')}" + end + def deep_dup_value(value) case value when Hash diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 9a3f958..f01af42 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -307,9 +307,10 @@ def check_policy_compliance expected_keys = expected_policy_keys return true if expected_keys.empty? - present_keys = Array(@bind_spec).filter_map { |bind| bind[:key]&.to_s } + bind_keys = Array(@bind_spec).filter_map { |bind| bind[:key]&.to_s } + param_keys = @params.keys.map(&:to_s) - expected_keys.all? { |key| present_keys.include?(key) } + expected_keys.all? { |key| bind_keys.include?(key) && param_keys.include?(key) } end def policy_predicates_expected? @@ -396,11 +397,12 @@ def declared_related_tables_outside_explicit_allowlist def strip_exists_subqueries(sql) source = sql.to_s + searchable = mask_sql_literals_and_comments(source) stripped = +'' index = 0 while index < source.length - match = /\b(?:NOT\s+)?EXISTS\s*\(/i.match(source, index) + match = /\b(?:NOT\s+)?EXISTS\s*\(/i.match(searchable, index) break unless match stripped << source[index...match.begin(0)] @@ -414,30 +416,136 @@ def strip_exists_subqueries(sql) def skip_parenthesized_sql(source, index) depth = 1 + state = :code while index < source.length && depth.positive? char = source[index] - depth += 1 if char == '(' - depth -= 1 if char == ')' + following = source[index + 1] + case state + when :single_quote + if char == "'" && following == "'" + index += 2 + next + end + state = :code if char == "'" + when :double_quote + if char == '"' && following == '"' + index += 2 + next + end + state = :code if char == '"' + when :backtick + state = :code if char == '`' + when :line_comment + state = :code if char == "\n" + when :block_comment + if char == '*' && following == '/' + state = :code + index += 2 + next + end + else + if char == "'" + state = :single_quote + elsif char == '"' + state = :double_quote + elsif char == '`' + state = :backtick + elsif char == '-' && following == '-' + state = :line_comment + index += 2 + next + elsif char == '/' && following == '*' + state = :block_comment + index += 2 + next + elsif char == '(' + depth += 1 + elsif char == ')' + depth -= 1 + end + end index += 1 end + raise SecurityError, 'Unterminated EXISTS subquery' if depth.positive? + index end + # Preserve character offsets while hiding SQL tokens in values and comments. + def mask_sql_literals_and_comments(source) + masked = source.dup + state = :code + index = 0 + while index < source.length + char = source[index] + following = source[index + 1] + case state + when :single_quote + masked[index] = ' ' + if char == "'" && following == "'" + masked[index + 1] = ' ' + index += 1 + elsif char == "'" + state = :code + end + when :double_quote + state = :code if char == '"' + when :backtick + state = :code if char == '`' + when :line_comment + masked[index] = ' ' unless char == "\n" + state = :code if char == "\n" + when :block_comment + masked[index] = ' ' + if char == '*' && following == '/' + masked[index + 1] = ' ' + state = :code + index += 1 + end + else + if char == "'" + masked[index] = ' ' + state = :single_quote + elsif char == '"' + state = :double_quote + elsif char == '`' + state = :backtick + elsif char == '-' && following == '-' + masked[index] = masked[index + 1] = ' ' + state = :line_comment + index += 1 + elsif char == '/' && following == '*' + masked[index] = masked[index + 1] = ' ' + state = :block_comment + index += 1 + end + end + index += 1 + end + raise SecurityError, 'Unterminated SQL literal or comment' unless [:code, :line_comment].include?(state) + + masked + end + def extract_table_names(sql) tables = [] sql.scan(/\bFROM\s+(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| match.first.split(',').each do |reference| table_name = extract_table_reference_name(reference) - tables << table_name if table_name + raise SecurityError, "Unsupported FROM reference: #{reference.to_s.strip}" unless table_name + + tables << table_name end end sql.scan(/#{join_clause_pattern}\s+(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| table_name = extract_table_reference_name(match.first) - tables << table_name if table_name + raise SecurityError, "Unsupported JOIN reference: #{match.first.to_s.strip}" unless table_name + + tables << table_name end tables.uniq diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index 46c4235..4f14e20 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -237,10 +237,14 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) def safe_call_policy_adapter(adapter, current_user, table:, intent:) adapter.call(current_user, table: table, intent: intent) - rescue ArgumentError + rescue ArgumentError => e + raise unless policy_signature_mismatch?(e) + begin adapter.call(current_user, table: table) - rescue ArgumentError + rescue ArgumentError => fallback_error + raise fallback_error unless policy_signature_mismatch?(fallback_error) + begin adapter.call(current_user) rescue StandardError => e @@ -262,6 +266,10 @@ def safe_call_policy_adapter(adapter, current_user, table:, intent:) raise CodeToQuery::PolicyAdapterError, "Policy adapter failed: #{e.message}" end + def policy_signature_mismatch?(error) + error.message.match?(/unknown keyword.*intent|wrong number of arguments|no keywords accepted/) + end + def handle_policy_failure(message) CodeToQuery.config.logger.warn("[code_to_query] #{message}") {} diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 87316cc..637b45a 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -839,6 +839,24 @@ ) end + it 'fails closed when an explicit predicate contract yields no predicates' do + config.policy_adapter = ->(_user, **) { { enforced_predicates: {} } } + + expect { compiler.compile(intent) }.to raise_error( + CodeToQuery::PolicyAdapterError, /empty predicate contract/ + ) + end + + it 'does not swallow errors raised inside an intent-aware strict keyword adapter' do + config.policy_adapter = lambda do |_user, table:, intent:| + raise ArgumentError, "policy rejected #{table}" if intent + end + + expect { compiler.compile(intent) }.to raise_error( + CodeToQuery::PolicyAdapterError, /policy rejected orders/ + ) + end + it 'documents explicit availability mode by allowing fail open only when configured' do config.policy_adapter_fail_open = true config.policy_adapter = ->(_user, **) { raise 'policy service unavailable' } @@ -947,6 +965,29 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil expect(result[:intent]['__policy_expected_keys']).to include(subquery_key) end + it 'enforces related-table policy when Arel compilation is selected' do + config.policy_adapter = lambda do |_user, **kwargs| + kwargs[:table] == 'answers' ? { enforced_predicates: { tenant_id: 42 } } : {} + end + allow(compiler).to receive(:use_arel?).and_return(true) + + result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) + + expect(result[:sql]).to include('EXISTS', '"answers"."tenant_id" = $1') + expect(result[:params]).to include('policy_subquery_1_answers_tenant_id' => 42) + expect(result[:bind_spec]).to include(hash_including(key: 'policy_subquery_1_answers_tenant_id')) + end + + it 'discards caller-supplied policy expectation metadata' do + config.policy_adapter = ->(_user, **) { {} } + result = compiler.compile( + 'table' => 'questions', 'columns' => ['*'], 'filters' => [], 'params' => {}, + '__policy_expected_keys' => ['policy_attacker'] + ) + + expect(result[:intent]).not_to have_key('__policy_expected_keys') + end + it 'records related-table allowlists returned by the subquery policy adapter' do config.policy_adapter = lambda do |_user, **kwargs| case kwargs[:table] diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 853f0c3..4a7d468 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -425,6 +425,55 @@ class << self config.policy_adapter = nil end + it 'returns false for NOT EXISTS when expected policy binds have no parameter value' do + config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE NOT EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: {}, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', 'type' => 'select', + 'filters' => [{ 'column' => 'id', 'op' => 'not_exists', 'related_table' => 'answers', 'fk_column' => 'question_id' }], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: %w[questions answers], config: config + ) + + expect(q.safe?).to be false + ensure + config.policy_adapter = nil + end + + it 'does not let EXISTS-like text in a literal hide a disallowed table' do + q = described_class.new( + sql: %(SELECT 'EXISTS (ignored)' FROM "questions" JOIN "answers" ON TRUE), + params: {}, bind_spec: [], intent: { 'table' => 'questions', 'type' => 'select' }, + allow_tables: ['questions'], config: config + ) + + expect(q.safe?).to be false + end + + it 'ignores parentheses in EXISTS comments while identifying the subquery boundary' do + q = described_class.new( + sql: "SELECT * FROM \"questions\" WHERE EXISTS (SELECT 1 FROM \"answers\" /* ) */ WHERE TRUE)", + params: {}, bind_spec: [], + intent: { 'table' => 'questions', 'type' => 'select', 'filters' => [{ 'op' => 'exists', 'related_table' => 'answers' }] }, + allow_tables: ['questions'], config: config + ) + + expect(q.safe?).to be true + end + + it 'fails closed for an unparseable top-level table reference' do + q = described_class.new( + sql: 'SELECT * FROM ONLY "questions"', params: {}, bind_spec: [], + intent: { 'table' => 'questions', 'type' => 'select' }, allow_tables: ['questions'], config: config + ) + + expect(q.safe?).to be false + end + it 'does not enforce a partial related-table allowlist when no explicit allow_tables are provided' do q = described_class.new( sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', From 1d7a90c0ca1a750f5734469c02a3bd52b3152119 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 20:32:58 -0500 Subject: [PATCH 30/86] test: preserve fail-closed policy regressions --- spec/code_to_query/compiler_spec.rb | 6 ++++-- spec/code_to_query/query_spec.rb | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 637b45a..2e73027 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -980,10 +980,12 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil it 'discards caller-supplied policy expectation metadata' do config.policy_adapter = ->(_user, **) { {} } - result = compiler.compile( + untrusted_intent = { 'table' => 'questions', 'columns' => ['*'], 'filters' => [], 'params' => {}, '__policy_expected_keys' => ['policy_attacker'] - ) + } + + result = compiler.compile(untrusted_intent) expect(result[:intent]).not_to have_key('__policy_expected_keys') end diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 4a7d468..8d9c489 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -455,14 +455,16 @@ class << self end it 'ignores parentheses in EXISTS comments while identifying the subquery boundary' do + sql = "SELECT * FROM \"questions\" WHERE EXISTS (SELECT 1 FROM \"answers\" /* ) */ WHERE TRUE)" q = described_class.new( - sql: "SELECT * FROM \"questions\" WHERE EXISTS (SELECT 1 FROM \"answers\" /* ) */ WHERE TRUE)", + sql: sql, params: {}, bind_spec: [], intent: { 'table' => 'questions', 'type' => 'select', 'filters' => [{ 'op' => 'exists', 'related_table' => 'answers' }] }, allow_tables: ['questions'], config: config ) - expect(q.safe?).to be true + expect(q.send(:strip_exists_subqueries, sql)).to eq('SELECT * FROM "questions" WHERE TRUE') + expect(q.safe?).to be false # SqlLinter intentionally rejects SQL comments. end it 'fails closed for an unparseable top-level table reference' do From 66f8af17bf3cebabc63165621c2a2f51a1d02800 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 20:38:23 -0500 Subject: [PATCH 31/86] Extract SQL scanning from Query --- lib/code_to_query/query.rb | 168 +-------------------- lib/code_to_query/query/sql_scanning.rb | 185 ++++++++++++++++++++++++ spec/code_to_query/query_spec.rb | 2 +- 3 files changed, 192 insertions(+), 163 deletions(-) create mode 100644 lib/code_to_query/query/sql_scanning.rb diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index f01af42..022b6a9 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -5,6 +5,8 @@ rescue LoadError end +require_relative 'query/sql_scanning' + module CodeToQuery class Query attr_reader :sql, :params, :intent, :metrics @@ -396,173 +398,15 @@ def declared_related_tables_outside_explicit_allowlist end def strip_exists_subqueries(sql) - source = sql.to_s - searchable = mask_sql_literals_and_comments(source) - stripped = +'' - index = 0 - - while index < source.length - match = /\b(?:NOT\s+)?EXISTS\s*\(/i.match(searchable, index) - break unless match - - stripped << source[index...match.begin(0)] - stripped << 'TRUE' - index = skip_parenthesized_sql(source, match.end(0)) - end - - stripped << source[index..] if index < source.length - stripped - end - - def skip_parenthesized_sql(source, index) - depth = 1 - state = :code - - while index < source.length && depth.positive? - char = source[index] - following = source[index + 1] - case state - when :single_quote - if char == "'" && following == "'" - index += 2 - next - end - state = :code if char == "'" - when :double_quote - if char == '"' && following == '"' - index += 2 - next - end - state = :code if char == '"' - when :backtick - state = :code if char == '`' - when :line_comment - state = :code if char == "\n" - when :block_comment - if char == '*' && following == '/' - state = :code - index += 2 - next - end - else - if char == "'" - state = :single_quote - elsif char == '"' - state = :double_quote - elsif char == '`' - state = :backtick - elsif char == '-' && following == '-' - state = :line_comment - index += 2 - next - elsif char == '/' && following == '*' - state = :block_comment - index += 2 - next - elsif char == '(' - depth += 1 - elsif char == ')' - depth -= 1 - end - end - index += 1 - end - - raise SecurityError, 'Unterminated EXISTS subquery' if depth.positive? - - index - end - - # Preserve character offsets while hiding SQL tokens in values and comments. - def mask_sql_literals_and_comments(source) - masked = source.dup - state = :code - index = 0 - while index < source.length - char = source[index] - following = source[index + 1] - case state - when :single_quote - masked[index] = ' ' - if char == "'" && following == "'" - masked[index + 1] = ' ' - index += 1 - elsif char == "'" - state = :code - end - when :double_quote - state = :code if char == '"' - when :backtick - state = :code if char == '`' - when :line_comment - masked[index] = ' ' unless char == "\n" - state = :code if char == "\n" - when :block_comment - masked[index] = ' ' - if char == '*' && following == '/' - masked[index + 1] = ' ' - state = :code - index += 1 - end - else - if char == "'" - masked[index] = ' ' - state = :single_quote - elsif char == '"' - state = :double_quote - elsif char == '`' - state = :backtick - elsif char == '-' && following == '-' - masked[index] = masked[index + 1] = ' ' - state = :line_comment - index += 1 - elsif char == '/' && following == '*' - masked[index] = masked[index + 1] = ' ' - state = :block_comment - index += 1 - end - end - index += 1 - end - raise SecurityError, 'Unterminated SQL literal or comment' unless [:code, :line_comment].include?(state) - - masked + sql_scanner.strip_exists_subqueries(sql) end def extract_table_names(sql) - tables = [] - - sql.scan(/\bFROM\s+(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| - match.first.split(',').each do |reference| - table_name = extract_table_reference_name(reference) - raise SecurityError, "Unsupported FROM reference: #{reference.to_s.strip}" unless table_name - - tables << table_name - end - end - - sql.scan(/#{join_clause_pattern}\s+(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| - table_name = extract_table_reference_name(match.first) - raise SecurityError, "Unsupported JOIN reference: #{match.first.to_s.strip}" unless table_name - - tables << table_name - end - - tables.uniq - end - - def join_clause_pattern - /\b(?:INNER\s+|(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+|CROSS\s+|NATURAL\s+(?:(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+)?)?JOIN\b/i + sql_scanner.extract_table_names(sql) end - def extract_table_reference_name(reference) - identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z0-9_]+)' - alias_identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z_][a-zA-Z0-9_]*)' - table_reference_pattern = /\A(?:#{identifier}\.)*(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i - - match = reference.to_s.strip.match(table_reference_pattern) - captures = match&.captures - captures&.compact&.first + def sql_scanner + @sql_scanner ||= SqlScanner.new end def infer_column_type(connection, table_name, column_name, explicit_cast, param_key = column_name) diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb new file mode 100644 index 0000000..05fcc82 --- /dev/null +++ b/lib/code_to_query/query/sql_scanning.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module CodeToQuery + class Query + # Internal SQL scanning helpers used to enforce table allowlists while + # preserving quoted literals, identifiers, and comment boundaries. + class SqlScanner + def strip_exists_subqueries(sql) + source = sql.to_s + searchable = mask_sql_literals_and_comments(source) + stripped = +'' + index = 0 + + while index < source.length + match = /\b(?:NOT\s+)?EXISTS\s*\(/i.match(searchable, index) + break unless match + + stripped << source[index...match.begin(0)] + stripped << 'TRUE' + index = skip_parenthesized_sql(source, match.end(0)) + end + + stripped << source[index..] if index < source.length + stripped + end + + private + + def skip_parenthesized_sql(source, index) + depth = 1 + state = :code + + while index < source.length && depth.positive? + char = source[index] + following = source[index + 1] + case state + when :single_quote + if char == "'" && following == "'" + index += 2 + next + end + state = :code if char == "'" + when :double_quote + if char == '"' && following == '"' + index += 2 + next + end + state = :code if char == '"' + when :backtick + state = :code if char == '`' + when :line_comment + state = :code if char == "\n" + when :block_comment + if char == '*' && following == '/' + state = :code + index += 2 + next + end + else + if char == "'" + state = :single_quote + elsif char == '"' + state = :double_quote + elsif char == '`' + state = :backtick + elsif char == '-' && following == '-' + state = :line_comment + index += 2 + next + elsif char == '/' && following == '*' + state = :block_comment + index += 2 + next + elsif char == '(' + depth += 1 + elsif char == ')' + depth -= 1 + end + end + index += 1 + end + + raise SecurityError, 'Unterminated EXISTS subquery' if depth.positive? + + index + end + + # Preserve character offsets while hiding SQL tokens in values and comments. + def mask_sql_literals_and_comments(source) + masked = source.dup + state = :code + index = 0 + while index < source.length + char = source[index] + following = source[index + 1] + case state + when :single_quote + masked[index] = ' ' + if char == "'" && following == "'" + masked[index + 1] = ' ' + index += 1 + elsif char == "'" + state = :code + end + when :double_quote + state = :code if char == '"' + when :backtick + state = :code if char == '`' + when :line_comment + masked[index] = ' ' unless char == "\n" + state = :code if char == "\n" + when :block_comment + masked[index] = ' ' + if char == '*' && following == '/' + masked[index + 1] = ' ' + state = :code + index += 1 + end + else + if char == "'" + masked[index] = ' ' + state = :single_quote + elsif char == '"' + state = :double_quote + elsif char == '`' + state = :backtick + elsif char == '-' && following == '-' + masked[index] = masked[index + 1] = ' ' + state = :line_comment + index += 1 + elsif char == '/' && following == '*' + masked[index] = masked[index + 1] = ' ' + state = :block_comment + index += 1 + end + end + index += 1 + end + raise SecurityError, 'Unterminated SQL literal or comment' unless %i[code line_comment].include?(state) + + masked + end + + public + + def extract_table_names(sql) + tables = [] + + sql.scan(/\bFROM\s+(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| + match.first.split(',').each do |reference| + table_name = extract_table_reference_name(reference) + raise SecurityError, "Unsupported FROM reference: #{reference.to_s.strip}" unless table_name + + tables << table_name + end + end + + sql.scan(/#{join_clause_pattern}\s+(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| + table_name = extract_table_reference_name(match.first) + raise SecurityError, "Unsupported JOIN reference: #{match.first.to_s.strip}" unless table_name + + tables << table_name + end + + tables.uniq + end + + private + + def join_clause_pattern + /\b(?:INNER\s+|(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+|CROSS\s+|NATURAL\s+(?:(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+)?)?JOIN\b/i + end + + def extract_table_reference_name(reference) + identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z0-9_]+)' + alias_identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z_][a-zA-Z0-9_]*)' + table_reference_pattern = /\A(?:#{identifier}\.)*(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i + + match = reference.to_s.strip.match(table_reference_pattern) + captures = match&.captures + captures&.compact&.first + end + end + end +end diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 8d9c489..830e0bc 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -455,7 +455,7 @@ class << self end it 'ignores parentheses in EXISTS comments while identifying the subquery boundary' do - sql = "SELECT * FROM \"questions\" WHERE EXISTS (SELECT 1 FROM \"answers\" /* ) */ WHERE TRUE)" + sql = 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" /* ) */ WHERE TRUE)' q = described_class.new( sql: sql, params: {}, bind_spec: [], From d592455b0ff50e296ad5ed93df0804ec4be37660 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 20:46:55 -0500 Subject: [PATCH 32/86] Fix multiline SQL table scanning --- lib/code_to_query/query/sql_scanning.rb | 4 ++-- spec/code_to_query/query_spec.rb | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 05fcc82..a0782e1 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -146,7 +146,7 @@ def mask_sql_literals_and_comments(source) def extract_table_names(sql) tables = [] - sql.scan(/\bFROM\s+(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| + sql.scan(/\bFROM\s+(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|\z)/im) do |match| match.first.split(',').each do |reference| table_name = extract_table_reference_name(reference) raise SecurityError, "Unsupported FROM reference: #{reference.to_s.strip}" unless table_name @@ -155,7 +155,7 @@ def extract_table_names(sql) end end - sql.scan(/#{join_clause_pattern}\s+(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|$)/im) do |match| + sql.scan(/#{join_clause_pattern}\s+(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|\z)/im) do |match| table_name = extract_table_reference_name(match.first) raise SecurityError, "Unsupported JOIN reference: #{match.first.to_s.strip}" unless table_name diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 830e0bc..91dee24 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -476,6 +476,15 @@ class << self expect(q.safe?).to be false end + it 'rejects a disallowed FROM table continued on a second line' do + q = described_class.new( + sql: "SELECT * FROM \"questions\",\n\"answers\"", params: {}, bind_spec: [], + intent: { 'table' => 'questions', 'type' => 'select' }, allow_tables: ['questions'], config: config + ) + + expect(q.safe?).to be false + end + it 'does not enforce a partial related-table allowlist when no explicit allow_tables are provided' do q = described_class.new( sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."question_id" = "questions"."id")', From f339455a3b11ffd530ae49a022c9011b079b796a Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 22:06:05 -0500 Subject: [PATCH 33/86] fix: reject undeclared exists references --- lib/code_to_query/query.rb | 22 ++++++++++++++++++++++ lib/code_to_query/query/sql_scanning.rb | 17 +++++++++++++++++ spec/code_to_query/query_spec.rb | 22 ++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 022b6a9..13a124d 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -384,6 +384,28 @@ def check_policy_scoped_related_table_references! raise SecurityError, "Table '#{table}' is only allowed inside declared EXISTS/NOT EXISTS filters" end + + declared_references = Hash.new(0) + Array(@intent['filters']).each do |filter| + operator = filter['op'].to_s.downcase + table = filter['related_table']&.to_s&.downcase + declared_references[[operator, table]] += 1 if %w[exists not_exists].include?(operator) && table + end + + sql_references = Hash.new(0) + sql_scanner.exists_subqueries(@sql).each do |subquery| + extract_table_names(strip_exists_subqueries(subquery[:sql])).each do |table| + normalized_table = table.to_s.downcase + next unless policy_scoped_related_tables.include?(normalized_table) + + reference = [subquery[:operator], normalized_table] + sql_references[reference] += 1 + next if sql_references[reference] <= declared_references[reference] + + raise SecurityError, + "Table '#{table}' has an undeclared #{subquery[:operator].upcase} reference" + end + end end def declared_related_tables_outside_explicit_allowlist diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index a0782e1..a2b4f12 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -24,6 +24,23 @@ def strip_exists_subqueries(sql) stripped end + def exists_subqueries(sql) + source = sql.to_s + searchable = mask_sql_literals_and_comments(source) + subqueries = [] + index = 0 + + while (match = /\b(NOT\s+)?EXISTS\s*\(/i.match(searchable, index)) + boundary = skip_parenthesized_sql(source, match.end(0)) + body = source[match.end(0)...(boundary - 1)] + subqueries << { operator: match[1] ? 'not_exists' : 'exists', sql: body } + subqueries.concat(exists_subqueries(body)) + index = boundary + end + + subqueries + end + private def skip_parenthesized_sql(source, index) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 91dee24..57f318b 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -394,6 +394,28 @@ class << self config.policy_adapter = nil end + it 'rejects additional unscoped OR EXISTS and NOT EXISTS references to a policy-scoped table' do + config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } + + %w[EXISTS NOT\ EXISTS].each do |operator| + q = described_class.new( + sql: %(SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1) OR #{operator} (SELECT 1 FROM "answers")), + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', 'type' => 'select', + 'filters' => [{ 'column' => 'id', 'op' => 'exists', 'related_table' => 'answers', 'fk_column' => 'question_id' }], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: ['questions'], config: config + ) + + expect(q.safe?).to be false + end + ensure + config.policy_adapter = nil + end + it 'returns false when expected subquery policy keys are present only in params' do config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } From c882a1f3c0e981788e2a0f306ed478fb6d651e49 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 22:09:56 -0500 Subject: [PATCH 34/86] fix: validate policy scope per exists occurrence --- lib/code_to_query/query.rb | 30 +++++++++++++++++++++++-- lib/code_to_query/query/sql_scanning.rb | 27 ++++++++++++++++++---- spec/code_to_query/query_spec.rb | 22 ++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 13a124d..12d8343 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -393,14 +393,20 @@ def check_policy_scoped_related_table_references! end sql_references = Hash.new(0) - sql_scanner.exists_subqueries(@sql).each do |subquery| + subqueries = sql_scanner.exists_subqueries(@sql) + subqueries.each do |subquery| extract_table_names(strip_exists_subqueries(subquery[:sql])).each do |table| normalized_table = table.to_s.downcase next unless policy_scoped_related_tables.include?(normalized_table) reference = [subquery[:operator], normalized_table] sql_references[reference] += 1 - next if sql_references[reference] <= declared_references[reference] + if sql_references[reference] <= declared_references[reference] + next if policy_bind_present_in_subquery?(subquery, normalized_table, subqueries) + + raise SecurityError, + "Table '#{table}' has an unscoped #{subquery[:operator].upcase} reference" + end raise SecurityError, "Table '#{table}' has an undeclared #{subquery[:operator].upcase} reference" @@ -408,6 +414,26 @@ def check_policy_scoped_related_table_references! end end + def policy_bind_present_in_subquery?(subquery, table, subqueries) + table_fragment = table.to_s.gsub(/[^a-zA-Z0-9_]/, '_') + policy_bind_numbers = Array(@bind_spec).each_with_index.filter_map do |bind, index| + key = bind[:key]&.to_s + index + 1 if key&.match?(/\Apolicy_subquery_\d+_#{Regexp.escape(table_fragment)}_/) + end + return true if policy_bind_numbers.empty? # This table has no row predicate to enforce. + + nested_ranges = subqueries.filter_map do |candidate| + next if candidate.equal?(subquery) + next unless candidate[:start] >= subquery[:start] && candidate[:finish] <= subquery[:finish] + + candidate[:start]...candidate[:finish] + end + sql_scanner.bind_placeholder_positions(@sql).any? do |position, bind_number| + position >= subquery[:start] && position < subquery[:finish] && + nested_ranges.none? { |range| range.cover?(position) } && policy_bind_numbers.include?(bind_number) + end + end + def declared_related_tables_outside_explicit_allowlist explicit_tables = Array(@allow_tables).compact.map { |table| table.to_s.downcase } diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index a2b4f12..8e31746 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -24,7 +24,7 @@ def strip_exists_subqueries(sql) stripped end - def exists_subqueries(sql) + def exists_subqueries(sql, source_offset: 0) source = sql.to_s searchable = mask_sql_literals_and_comments(source) subqueries = [] @@ -32,15 +32,34 @@ def exists_subqueries(sql) while (match = /\b(NOT\s+)?EXISTS\s*\(/i.match(searchable, index)) boundary = skip_parenthesized_sql(source, match.end(0)) - body = source[match.end(0)...(boundary - 1)] - subqueries << { operator: match[1] ? 'not_exists' : 'exists', sql: body } - subqueries.concat(exists_subqueries(body)) + body_start = match.end(0) + body = source[body_start...(boundary - 1)] + subqueries << { + operator: match[1] ? 'not_exists' : 'exists', sql: body, + start: source_offset + body_start, finish: source_offset + boundary - 1 + } + subqueries.concat(exists_subqueries(body, source_offset: source_offset + body_start)) index = boundary end subqueries end + def bind_placeholder_positions(sql) + searchable = mask_sql_literals_and_comments(sql.to_s) + if searchable.match?(/\$\d+/) + searchable.to_enum(:scan, /\$(\d+)/).map do + [Regexp.last_match.begin(0), Regexp.last_match(1).to_i] + end + else + ordinal = 0 + searchable.to_enum(:scan, /\?/).map do + ordinal += 1 + [Regexp.last_match.begin(0), ordinal] + end + end + end + private def skip_parenthesized_sql(source, index) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 57f318b..68453cd 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -416,6 +416,28 @@ class << self config.policy_adapter = nil end + it 'rejects two declared same-table EXISTS occurrences when only one is policy scoped' do + config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1 AND "answers"."account_id" = $2) OR EXISTS (SELECT 1 FROM "answers")', + params: { 'policy_subquery_1_answers_tenant_id' => 42, 'policy_subquery_2_answers_account_id' => 7 }, + bind_spec: [ + { key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }, + { key: 'policy_subquery_2_answers_account_id', column: 'account_id', cast: nil } + ], + intent: { + 'table' => 'questions', 'type' => 'select', + 'filters' => Array.new(2) { { 'column' => 'id', 'op' => 'exists', 'related_table' => 'answers', 'fk_column' => 'question_id' } }, + '__policy_expected_keys' => %w[policy_subquery_1_answers_tenant_id policy_subquery_2_answers_account_id] + }, + allow_tables: ['questions'], config: config + ) + + expect(q.safe?).to be false + ensure + config.policy_adapter = nil + end + it 'returns false when expected subquery policy keys are present only in params' do config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } From cec1b30dac079381bbf1fa5dd60761bc32ba204d Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 22:13:25 -0500 Subject: [PATCH 35/86] fix: bind policies to subquery occurrences --- lib/code_to_query/query.rb | 48 ++++++++++++++++++++++---------- spec/code_to_query/query_spec.rb | 22 +++++++++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 12d8343..cea1889 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -385,12 +385,13 @@ def check_policy_scoped_related_table_references! "Table '#{table}' is only allowed inside declared EXISTS/NOT EXISTS filters" end - declared_references = Hash.new(0) + declared_references = Hash.new { |hash, key| hash[key] = [] } Array(@intent['filters']).each do |filter| operator = filter['op'].to_s.downcase table = filter['related_table']&.to_s&.downcase - declared_references[[operator, table]] += 1 if %w[exists not_exists].include?(operator) && table + declared_references[[operator, table]] << filter if %w[exists not_exists].include?(operator) && table end + policy_binds_by_declaration = policy_binds_by_related_filter sql_references = Hash.new(0) subqueries = sql_scanner.exists_subqueries(@sql) @@ -401,8 +402,10 @@ def check_policy_scoped_related_table_references! reference = [subquery[:operator], normalized_table] sql_references[reference] += 1 - if sql_references[reference] <= declared_references[reference] - next if policy_bind_present_in_subquery?(subquery, normalized_table, subqueries) + declaration = declared_references[reference][sql_references[reference] - 1] + if declaration + required_bind_numbers = policy_binds_by_declaration.fetch(declaration.object_id, []) + next if policy_bind_present_in_subquery?(subquery, required_bind_numbers, subqueries) raise SecurityError, "Table '#{table}' has an unscoped #{subquery[:operator].upcase} reference" @@ -414,13 +417,8 @@ def check_policy_scoped_related_table_references! end end - def policy_bind_present_in_subquery?(subquery, table, subqueries) - table_fragment = table.to_s.gsub(/[^a-zA-Z0-9_]/, '_') - policy_bind_numbers = Array(@bind_spec).each_with_index.filter_map do |bind, index| - key = bind[:key]&.to_s - index + 1 if key&.match?(/\Apolicy_subquery_\d+_#{Regexp.escape(table_fragment)}_/) - end - return true if policy_bind_numbers.empty? # This table has no row predicate to enforce. + def policy_bind_present_in_subquery?(subquery, policy_bind_numbers, subqueries) + return true if policy_bind_numbers.empty? # This occurrence has no row predicate to enforce. nested_ranges = subqueries.filter_map do |candidate| next if candidate.equal?(subquery) @@ -428,9 +426,31 @@ def policy_bind_present_in_subquery?(subquery, table, subqueries) candidate[:start]...candidate[:finish] end - sql_scanner.bind_placeholder_positions(@sql).any? do |position, bind_number| - position >= subquery[:start] && position < subquery[:finish] && - nested_ranges.none? { |range| range.cover?(position) } && policy_bind_numbers.include?(bind_number) + present_bind_numbers = sql_scanner.bind_placeholder_positions(@sql).filter_map do |position, bind_number| + bind_number if position >= subquery[:start] && position < subquery[:finish] && + nested_ranges.none? { |range| range.cover?(position) } + end + (policy_bind_numbers - present_bind_numbers).empty? + end + + def policy_binds_by_related_filter + declarations_by_table = Array(@intent['filters']).select do |filter| + %w[exists not_exists].include?(filter['op'].to_s.downcase) && filter['related_table'] + end.group_by { |filter| filter['related_table'].to_s.downcase } + + declarations_by_table.each_with_object({}) do |(table, declarations), result| + table_fragment = table.gsub(/[^a-zA-Z0-9_]/, '_') + bind_numbers = Array(@bind_spec).each_with_index.filter_map do |bind, index| + key = bind[:key]&.to_s + index + 1 if key&.match?(/\Apolicy_subquery_\d+_#{Regexp.escape(table_fragment)}_/) + end + quotient, remainder = bind_numbers.length.divmod(declarations.length) + offset = 0 + declarations.each_with_index do |declaration, index| + count = quotient + (index < remainder ? 1 : 0) + result[declaration.object_id] = bind_numbers.slice(offset, count) + offset += count + end end end diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 68453cd..0c40698 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -438,6 +438,28 @@ class << self config.policy_adapter = nil end + it 'rejects reuse of the first occurrence policy bind by a second same-table EXISTS' do + config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1) OR EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42, 'policy_subquery_2_answers_tenant_id' => 42 }, + bind_spec: [ + { key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }, + { key: 'policy_subquery_2_answers_tenant_id', column: 'tenant_id', cast: nil } + ], + intent: { + 'table' => 'questions', 'type' => 'select', + 'filters' => Array.new(2) { { 'column' => 'id', 'op' => 'exists', 'related_table' => 'answers', 'fk_column' => 'question_id' } }, + '__policy_expected_keys' => %w[policy_subquery_1_answers_tenant_id policy_subquery_2_answers_tenant_id] + }, + allow_tables: ['questions'], config: config + ) + + expect(q.safe?).to be false + ensure + config.policy_adapter = nil + end + it 'returns false when expected subquery policy keys are present only in params' do config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } From e4cf3d474443fa649615d906f1ff1131132e3e44 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 22:18:34 -0500 Subject: [PATCH 36/86] fix: mask PostgreSQL dollar-quoted literals --- lib/code_to_query/query/sql_scanning.rb | 40 ++++++++++++++++++- spec/code_to_query/query/sql_scanning_spec.rb | 40 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 spec/code_to_query/query/sql_scanning_spec.rb diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 8e31746..c12de6f 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -65,11 +65,19 @@ def bind_placeholder_positions(sql) def skip_parenthesized_sql(source, index) depth = 1 state = :code + dollar_quote_delimiter = nil while index < source.length && depth.positive? char = source[index] following = source[index + 1] case state + when :dollar_quote + if source[index, dollar_quote_delimiter.length] == dollar_quote_delimiter + index += dollar_quote_delimiter.length + state = :code + dollar_quote_delimiter = nil + next + end when :single_quote if char == "'" && following == "'" index += 2 @@ -93,7 +101,12 @@ def skip_parenthesized_sql(source, index) next end else - if char == "'" + if (delimiter = dollar_quote_delimiter_at(source, index)) + state = :dollar_quote + dollar_quote_delimiter = delimiter + index += delimiter.length + next + elsif char == "'" state = :single_quote elsif char == '"' state = :double_quote @@ -125,11 +138,21 @@ def skip_parenthesized_sql(source, index) def mask_sql_literals_and_comments(source) masked = source.dup state = :code + dollar_quote_delimiter = nil index = 0 while index < source.length char = source[index] following = source[index + 1] case state + when :dollar_quote + if source[index, dollar_quote_delimiter.length] == dollar_quote_delimiter + masked[index, dollar_quote_delimiter.length] = ' ' * dollar_quote_delimiter.length + index += dollar_quote_delimiter.length + state = :code + dollar_quote_delimiter = nil + next + end + masked[index] = ' ' when :single_quote masked[index] = ' ' if char == "'" && following == "'" @@ -153,7 +176,13 @@ def mask_sql_literals_and_comments(source) index += 1 end else - if char == "'" + if (delimiter = dollar_quote_delimiter_at(source, index)) + masked[index, delimiter.length] = ' ' * delimiter.length + state = :dollar_quote + dollar_quote_delimiter = delimiter + index += delimiter.length + next + elsif char == "'" masked[index] = ' ' state = :single_quote elsif char == '"' @@ -177,6 +206,13 @@ def mask_sql_literals_and_comments(source) masked end + # PostgreSQL dollar-quote tags follow unquoted identifier rules (without + # dollar signs); the empty tag is valid too. In particular, $1 is a bind, + # not the start of a dollar-quoted literal. + def dollar_quote_delimiter_at(source, index) + source[index..]&.match(/\A\$(?:[a-zA-Z_][a-zA-Z0-9_]*)?\$/)&.[](0) + end + public def extract_table_names(sql) diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb new file mode 100644 index 0000000..7a6e822 --- /dev/null +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CodeToQuery::Query::SqlScanner do + subject(:scanner) { described_class.new } + + describe '#bind_placeholder_positions' do + it 'ignores placeholders in untagged PostgreSQL dollar-quoted literals' do + sql = 'SELECT $$spoof $1$$, $2' + + expect(scanner.bind_placeholder_positions(sql)).to eq([[sql.index('$2'), 2]]) + end + + it 'ignores placeholders in tagged PostgreSQL dollar-quoted literals' do + sql = 'SELECT $body$spoof $1 and ?$body$, $2' + + expect(scanner.bind_placeholder_positions(sql)).to eq([[sql.index('$2'), 2]]) + end + + it 'preserves placeholder offsets after dollar-quoted literals' do + sql = 'SELECT $tag$($9) and $other$ text$tag$, $1' + + expect(scanner.bind_placeholder_positions(sql)).to eq([[sql.index('$1'), 1]]) + end + + it 'fails closed for unterminated dollar-quoted literals' do + expect { scanner.bind_placeholder_positions('SELECT $tag$spoof $1') } + .to raise_error(SecurityError, 'Unterminated SQL literal or comment') + end + end + + describe '#exists_subqueries' do + it 'ignores parentheses inside dollar-quoted literals when finding the boundary' do + sql = 'SELECT 1 WHERE EXISTS (SELECT $$)$$ FROM "answers")' + + expect(scanner.exists_subqueries(sql).first[:sql]).to eq('SELECT $$)$$ FROM "answers"') + end + end +end From 5c9e718769c9b6ff6cdf430877d4835d7a4bce41 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 22:21:56 -0500 Subject: [PATCH 37/86] fix: satisfy RuboCop identity hash checks --- lib/code_to_query/query.rb | 6 +++--- spec/code_to_query/query_spec.rb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index cea1889..17a2976 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -404,7 +404,7 @@ def check_policy_scoped_related_table_references! sql_references[reference] += 1 declaration = declared_references[reference][sql_references[reference] - 1] if declaration - required_bind_numbers = policy_binds_by_declaration.fetch(declaration.object_id, []) + required_bind_numbers = policy_binds_by_declaration.fetch(declaration, []) next if policy_bind_present_in_subquery?(subquery, required_bind_numbers, subqueries) raise SecurityError, @@ -438,7 +438,7 @@ def policy_binds_by_related_filter %w[exists not_exists].include?(filter['op'].to_s.downcase) && filter['related_table'] end.group_by { |filter| filter['related_table'].to_s.downcase } - declarations_by_table.each_with_object({}) do |(table, declarations), result| + declarations_by_table.each_with_object({}.compare_by_identity) do |(table, declarations), result| table_fragment = table.gsub(/[^a-zA-Z0-9_]/, '_') bind_numbers = Array(@bind_spec).each_with_index.filter_map do |bind, index| key = bind[:key]&.to_s @@ -448,7 +448,7 @@ def policy_binds_by_related_filter offset = 0 declarations.each_with_index do |declaration, index| count = quotient + (index < remainder ? 1 : 0) - result[declaration.object_id] = bind_numbers.slice(offset, count) + result[declaration] = bind_numbers.slice(offset, count) offset += count end end diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 0c40698..c52e412 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -397,7 +397,7 @@ class << self it 'rejects additional unscoped OR EXISTS and NOT EXISTS references to a policy-scoped table' do config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } - %w[EXISTS NOT\ EXISTS].each do |operator| + ['EXISTS', 'NOT EXISTS'].each do |operator| q = described_class.new( sql: %(SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1) OR #{operator} (SELECT 1 FROM "answers")), params: { 'policy_subquery_1_answers_tenant_id' => 42 }, From 085bdc1f2a455f043f1a6eabd02fbfc414c397be Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 23:00:49 -0500 Subject: [PATCH 38/86] fix: enforce allowlisted related table policies --- lib/code_to_query/compiler.rb | 6 ------ lib/code_to_query/query.rb | 8 +++----- spec/code_to_query/compiler_spec.rb | 18 ++++++++++++++---- spec/code_to_query/query_spec.rb | 4 +++- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index bb383e1..b716af0 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -124,17 +124,11 @@ def extract_enforced_predicates(policy_info) predicates = policy_info[:enforced_predicates] || policy_info['enforced_predicates'] || policy_info[:predicates] || policy_info['predicates'] - explicit_predicate_contract = policy_info.keys.any? do |key| - %w[enforced_predicates predicates].include?(key.to_s) - end predicates = policy_info if predicates.nil? && direct_predicate_hash?(policy_info) predicates ||= {} unless predicates.is_a?(Hash) return handle_policy_failure("Policy predicates must be a Hash, got #{predicates.class}") end - if explicit_predicate_contract && predicates.empty? - return handle_policy_failure('Policy adapter returned an empty predicate contract') - end predicates.each do |column, value| return handle_policy_failure('Policy predicate columns must be present') if column.to_s.strip.empty? diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 17a2976..f522242 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -375,7 +375,7 @@ def check_top_level_table_allowlist! end def check_policy_scoped_related_table_references! - policy_scoped_related_tables = declared_related_tables_outside_explicit_allowlist + policy_scoped_related_tables = declared_related_tables return if policy_scoped_related_tables.empty? extract_table_names(strip_exists_subqueries(@sql)).each do |table| @@ -454,15 +454,13 @@ def policy_binds_by_related_filter end end - def declared_related_tables_outside_explicit_allowlist - explicit_tables = Array(@allow_tables).compact.map { |table| table.to_s.downcase } - + def declared_related_tables Array(@intent['filters']).filter_map do |filter| op = filter['op'].to_s.downcase next unless %w[exists not_exists].include?(op) filter['related_table']&.to_s&.downcase - end.reject { |table| table.nil? || explicit_tables.include?(table) }.uniq + end.compact.uniq end def strip_exists_subqueries(sql) diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 2e73027..14addff 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -839,12 +839,22 @@ ) end - it 'fails closed when an explicit predicate contract yields no predicates' do + it 'accepts an empty enforced_predicates contract' do config.policy_adapter = ->(_user, **) { { enforced_predicates: {} } } - expect { compiler.compile(intent) }.to raise_error( - CodeToQuery::PolicyAdapterError, /empty predicate contract/ - ) + result = compiler.compile(intent) + + expect(result[:sql]).to eq('SELECT * FROM "orders" LIMIT 100') + expect(result[:bind_spec]).to eq([]) + end + + it 'accepts an empty predicates contract' do + config.policy_adapter = ->(_user, **) { { predicates: {} } } + + result = compiler.compile(intent) + + expect(result[:sql]).to eq('SELECT * FROM "orders" LIMIT 100') + expect(result[:bind_spec]).to eq([]) end it 'does not swallow errors raised inside an intent-aware strict keyword adapter' do diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index c52e412..045a894 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -407,7 +407,9 @@ class << self 'filters' => [{ 'column' => 'id', 'op' => 'exists', 'related_table' => 'answers', 'fk_column' => 'question_id' }], '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] }, - allow_tables: ['questions'], config: config + # Explicitly allowlisting the related table must not bypass the + # occurrence-level policy scope checks. + allow_tables: %w[questions answers], config: config ) expect(q.safe?).to be false From 9211a57c5783d508abdf5abc2c2b4b9cc5280fe2 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 23:54:27 -0500 Subject: [PATCH 39/86] fix: harden quoted table extraction --- lib/code_to_query/query/sql_scanning.rb | 42 ++++++++++++++++--- spec/code_to_query/query/sql_scanning_spec.rb | 14 +++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index c12de6f..d2d0ce0 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -216,10 +216,17 @@ def dollar_quote_delimiter_at(source, index) public def extract_table_names(sql) + source = sql.to_s + searchable = mask_sql_literals_comments_and_identifier_contents(source) tables = [] - sql.scan(/\bFROM\s+(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|\z)/im) do |match| - match.first.split(',').each do |reference| + searchable.to_enum(:scan, /\bFROM\b\s*(.+?)(?=\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|\z)/im).each do + match = Regexp.last_match + clause = source[match.begin(1)...match.end(1)] + masked_clause = searchable[match.begin(1)...match.end(1)] + commas = masked_clause.to_enum(:scan, /,/).map { Regexp.last_match.begin(0) } + [-1, *commas, clause.length].each_cons(2) do |left, right| + reference = clause[(left + 1)...right] table_name = extract_table_reference_name(reference) raise SecurityError, "Unsupported FROM reference: #{reference.to_s.strip}" unless table_name @@ -227,9 +234,11 @@ def extract_table_names(sql) end end - sql.scan(/#{join_clause_pattern}\s+(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|\z)/im) do |match| - table_name = extract_table_reference_name(match.first) - raise SecurityError, "Unsupported JOIN reference: #{match.first.to_s.strip}" unless table_name + searchable.to_enum(:scan, /#{join_clause_pattern}\s*(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|\z)/im).each do + match = Regexp.last_match + reference = source[match.begin(1)...match.end(1)] + table_name = extract_table_reference_name(reference) + raise SecurityError, "Unsupported JOIN reference: #{reference.to_s.strip}" unless table_name tables << table_name end @@ -239,6 +248,29 @@ def extract_table_names(sql) private + def mask_sql_literals_comments_and_identifier_contents(source) + masked = mask_sql_literals_and_comments(source) + quote = nil + index = 0 + while index < source.length + char = source[index] + if quote + if char == quote && source[index + 1] == quote + masked[index] = masked[index + 1] = ' ' + index += 1 + elsif char == quote + quote = nil + else + masked[index] = ' ' + end + elsif ['"', '`'].include?(char) + quote = char + end + index += 1 + end + masked + end + def join_clause_pattern /\b(?:INNER\s+|(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+|CROSS\s+|NATURAL\s+(?:(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+)?)?JOIN\b/i end diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index 7a6e822..8df6f79 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -37,4 +37,18 @@ expect(scanner.exists_subqueries(sql).first[:sql]).to eq('SELECT $$)$$ FROM "answers"') end end + + describe '#extract_table_names' do + it 'extracts quoted FROM and JOIN identifiers without intervening whitespace' do + sql = 'SELECT * FROM"questions" JOIN"answers" ON "answers"."question_id" = "questions"."id"' + + expect(scanner.extract_table_names(sql)).to eq(%w[questions answers]) + end + + it 'does not treat keyword text inside quoted identifiers as SQL structure' do + sql = 'SELECT * FROM "questions" AS "JOIN decoy" JOIN"answers WHERE decoy" ON TRUE' + + expect(scanner.extract_table_names(sql)).to eq(['questions', 'answers WHERE decoy']) + end + end end From 66e9f33494f9ad1ed2e764da34c87ca97e46820c Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sat, 11 Jul 2026 23:57:39 -0500 Subject: [PATCH 40/86] fix: prevent literal quotes masking table references --- lib/code_to_query/query/sql_scanning.rb | 2 +- spec/code_to_query/query/sql_scanning_spec.rb | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index d2d0ce0..1a68047 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -263,7 +263,7 @@ def mask_sql_literals_comments_and_identifier_contents(source) else masked[index] = ' ' end - elsif ['"', '`'].include?(char) + elsif ['"', '`'].include?(char) && masked[index] == char quote = char end index += 1 diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index 8df6f79..f6f7fd2 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -39,6 +39,17 @@ end describe '#extract_table_names' do + it 'does not let identifier quotes inside string literals hide executable table references' do + expect(scanner.extract_table_names(%q(SELECT '"' FROM"forbidden"))).to eq(['forbidden']) + expect(scanner.extract_table_names("SELECT '`' FROM`forbidden`")).to eq(['forbidden']) + end + + it 'does not let identifier quotes inside dollar-quoted literals hide executable table references' do + sql = 'SELECT $body$`$body$ FROM "allowed" JOIN"forbidden" ON TRUE' + + expect(scanner.extract_table_names(sql)).to eq(%w[allowed forbidden]) + end + it 'extracts quoted FROM and JOIN identifiers without intervening whitespace' do sql = 'SELECT * FROM"questions" JOIN"answers" ON "answers"."question_id" = "questions"."id"' @@ -50,5 +61,9 @@ expect(scanner.extract_table_names(sql)).to eq(['questions', 'answers WHERE decoy']) end + + it 'does not split a FROM reference on a comma inside a quoted identifier' do + expect(scanner.extract_table_names('SELECT * FROM "questions, archived"')).to eq(['questions, archived']) + end end end From b83e086f0de545a6d9cfe1de57e29aaf7d8d87a0 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 00:03:21 -0500 Subject: [PATCH 41/86] test: cover quoted identifier masker edge cases --- spec/code_to_query/query/sql_scanning_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index f6f7fd2..33865ab 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -44,6 +44,14 @@ expect(scanner.extract_table_names("SELECT '`' FROM`forbidden`")).to eq(['forbidden']) end + it 'does not let a string quote inside a quoted identifier hide a later table reference' do + expect(scanner.extract_table_names(%q(SELECT 1 AS "a'b" FROM "forbidden"))).to eq(['forbidden']) + end + + it 'masks doubled delimiters inside a quoted identifier without hiding a later table reference' do + expect(scanner.extract_table_names('SELECT 1 AS "a""b" FROM "forbidden"')).to eq(['forbidden']) + end + it 'does not let identifier quotes inside dollar-quoted literals hide executable table references' do sql = 'SELECT $body$`$body$ FROM "allowed" JOIN"forbidden" ON TRUE' From d0645e6d899aae556078d51a73c961e0a1b0c766 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 03:37:16 -0500 Subject: [PATCH 42/86] fix: harden policy scope enforcement --- lib/code_to_query/compiler.rb | 24 ++++++++++---- lib/code_to_query/query.rb | 23 ++++++++----- lib/code_to_query/query/sql_scanning.rb | 33 +++++++++++++++++-- lib/code_to_query/validator.rb | 9 +++-- spec/code_to_query/compiler_spec.rb | 14 +++++++- spec/code_to_query/query/sql_scanning_spec.rb | 18 ++++++++++ spec/code_to_query/query_spec.rb | 25 ++++++++++++++ spec/code_to_query/validator_spec.rb | 19 +++++++++++ 8 files changed, 146 insertions(+), 19 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index b716af0..8d36bd9 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -35,6 +35,7 @@ def apply_policy_predicates(intent, current_user) table = intent['table'] policy_info = safely_fetch_policy(table: table, current_user: current_user, intent: intent) + merge_policy_allowed_tables!(intent, policy_info) policy_hash = extract_enforced_predicates(policy_info) return intent if policy_hash.empty? @@ -577,17 +578,28 @@ def merge_policy_expected_keys!(intent, *keys) def merge_policy_allowed_tables!(intent, policy_info) return unless policy_info.is_a?(Hash) - allowed_tables = Array(policy_info[:allowed_tables] || policy_info['allowed_tables']).map do |table| - table.to_s.downcase - end.reject(&:empty?) - return if allowed_tables.empty? - - intent['__policy_allowed_tables'] = (Array(intent['__policy_allowed_tables']) + allowed_tables).uniq + present = policy_info.key?(:allowed_tables) || policy_info.key?('allowed_tables') + return unless present + + value = policy_info.key?(:allowed_tables) ? policy_info[:allowed_tables] : policy_info['allowed_tables'] + allowed_tables = Array(value).map { |table| table.to_s.downcase }.reject(&:empty?) + if intent.key?('__policy_allowed_tables') + existing = Array(intent['__policy_allowed_tables']) + intent['__policy_allowed_tables'] = if existing.empty? || allowed_tables.empty? + [] + else + (existing + allowed_tables).uniq + end + else + intent['__policy_allowed_tables'] = allowed_tables + end end def strip_untrusted_policy_expectations!(intent) intent.delete('__policy_expected_keys') intent.delete(:__policy_expected_keys) + intent.delete('__policy_allowed_tables') + intent.delete(:__policy_allowed_tables) end # Independent compiler backstop: metadata alone never proves that a policy diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index f522242..3f23cb4 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -334,18 +334,18 @@ def effective_lint_allow_tables normalized_explicit_tables = explicit_tables.map(&:downcase).uniq normalized_policy_tables = policy_tables.map(&:downcase).uniq - return @allow_tables if explicit_tables.empty? && policy_tables.empty? + return @allow_tables unless policy_allowlist_present? return normalized_policy_tables if explicit_tables.empty? - return normalized_explicit_tables if policy_tables.empty? normalized_explicit_tables & normalized_policy_tables end def allowlist_sources_present? - explicit_tables = Array(@allow_tables).compact - policy_tables = Array(@intent['__policy_allowed_tables']).compact + Array(@allow_tables).compact.any? || policy_allowlist_present? + end - explicit_tables.any? || policy_tables.any? + def policy_allowlist_present? + @intent.key?('__policy_allowed_tables') end def lint_sql! @@ -405,7 +405,7 @@ def check_policy_scoped_related_table_references! declaration = declared_references[reference][sql_references[reference] - 1] if declaration required_bind_numbers = policy_binds_by_declaration.fetch(declaration, []) - next if policy_bind_present_in_subquery?(subquery, required_bind_numbers, subqueries) + next if policy_bind_present_in_subquery?(subquery, required_bind_numbers, subqueries, table) raise SecurityError, "Table '#{table}' has an unscoped #{subquery[:operator].upcase} reference" @@ -417,7 +417,7 @@ def check_policy_scoped_related_table_references! end end - def policy_bind_present_in_subquery?(subquery, policy_bind_numbers, subqueries) + def policy_bind_present_in_subquery?(subquery, policy_bind_numbers, subqueries, table) return true if policy_bind_numbers.empty? # This occurrence has no row predicate to enforce. nested_ranges = subqueries.filter_map do |candidate| @@ -430,7 +430,14 @@ def policy_bind_present_in_subquery?(subquery, policy_bind_numbers, subqueries) bind_number if position >= subquery[:start] && position < subquery[:finish] && nested_ranges.none? { |range| range.cover?(position) } end - (policy_bind_numbers - present_bind_numbers).empty? + return false unless (policy_bind_numbers - present_bind_numbers).empty? + + policy_bind_numbers.all? do |number| + bind = Array(@bind_spec)[number - 1] + sql_scanner.policy_predicate_bind_numbers( + subquery[:sql], table, bind && bind[:column], question_bind_number: number + ).include?(number) + end end def policy_binds_by_related_filter diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 1a68047..f051ee7 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -7,7 +7,7 @@ class Query class SqlScanner def strip_exists_subqueries(sql) source = sql.to_s - searchable = mask_sql_literals_and_comments(source) + searchable = mask_sql_literals_comments_and_identifier_contents(source) stripped = +'' index = 0 @@ -26,7 +26,7 @@ def strip_exists_subqueries(sql) def exists_subqueries(sql, source_offset: 0) source = sql.to_s - searchable = mask_sql_literals_and_comments(source) + searchable = mask_sql_literals_comments_and_identifier_contents(source) subqueries = [] index = 0 @@ -60,6 +60,35 @@ def bind_placeholder_positions(sql) end end + # Identify binds used as values of the expected qualified policy column. + # A placeholder elsewhere in the EXISTS body does not enforce policy. + def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil) + return [] if table.to_s.empty? || column.to_s.empty? + + searchable = mask_sql_literals_and_comments(sql.to_s) + where_match = /\bWHERE\b/i.match(searchable) + return [] unless where_match + + searchable = searchable[where_match.end(0)..] + # Compiler-emitted policy predicates are conjunctive. Refuse OR here: + # `policy_column = $1 OR TRUE` does not enforce the policy. + return [] if searchable.match?(/\bOR\b/i) + + identifier = lambda do |value| + escaped = Regexp.escape(value.to_s) + "(?:\"#{escaped}\"|`#{escaped}`|#{escaped})" + end + qualified = "#{identifier.call(table)}\\s*\\.\\s*#{identifier.call(column)}" + if question_bind_number && searchable.match?(/#{qualified}\s*(?:=\s*\?|BETWEEN\s*\?\s+AND\s*\?)/i) + return [question_bind_number] + end + + pattern = /#{qualified}\s*(?:=\s*\$(\d+)|BETWEEN\s*\$(\d+)\s+AND\s*\$(\d+))/i + searchable.to_enum(:scan, pattern).flat_map do + Regexp.last_match.captures.compact.map(&:to_i) + end.uniq + end + private def skip_parenthesized_sql(source, index) diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index 4f14e20..d8093d6 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -43,6 +43,10 @@ class Validator def validate(intent_hash, current_user: nil, allow_tables: nil) preprocessed = preprocess_exists_filters(intent_hash) + # Policy metadata is an internal, trusted channel. Never permit an + # intent supplied by a caller/provider to influence it. + preprocessed.delete('__policy_allowed_tables') + preprocessed.delete(:__policy_allowed_tables) if fetch_value(preprocessed, :limit).nil? && CodeToQuery.config.default_limit preprocessed = preprocessed.merge('limit' => CodeToQuery.config.default_limit) @@ -145,8 +149,9 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) raise CodeToQuery::PolicyAdapterError, message end - allowed_tables = Array(fetch_value(policy_info, :allowed_tables)).map { |t| t.to_s.downcase } - if allowed_tables.any? + policy_has_allowed_tables = policy_info.key?(:allowed_tables) || policy_info.key?('allowed_tables') + if policy_has_allowed_tables + allowed_tables = Array(fetch_value(policy_info, :allowed_tables)).map { |t| t.to_s.downcase } intent[:__policy_allowed_tables] = allowed_tables intent['__policy_allowed_tables'] = allowed_tables diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 14addff..eaac724 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -992,12 +992,24 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil config.policy_adapter = ->(_user, **) { {} } untrusted_intent = { 'table' => 'questions', 'columns' => ['*'], 'filters' => [], 'params' => {}, - '__policy_expected_keys' => ['policy_attacker'] + '__policy_expected_keys' => ['policy_attacker'], + '__policy_allowed_tables' => ['attacker_secrets'] } result = compiler.compile(untrusted_intent) expect(result[:intent]).not_to have_key('__policy_expected_keys') + expect(result[:intent]).not_to have_key('__policy_allowed_tables') + end + + it 'preserves an explicitly empty related policy allowlist as deny all' do + config.policy_adapter = lambda do |_user, **kwargs| + kwargs[:table] == 'questions' ? { allowed_tables: ['questions'] } : { allowed_tables: [] } + end + + result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) + + expect(result[:intent]['__policy_allowed_tables']).to eq([]) end it 'records related-table allowlists returned by the subquery policy adapter' do diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index 33865ab..89f6bb7 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -36,6 +36,24 @@ expect(scanner.exists_subqueries(sql).first[:sql]).to eq('SELECT $$)$$ FROM "answers"') end + + it 'does not let EXISTS text in a quoted identifier swallow a real subquery' do + sql = 'SELECT 1 AS "x EXISTS (" WHERE EXISTS (SELECT 1 FROM "answers")' + + expect(scanner.exists_subqueries(sql).map { |entry| entry[:sql] }) + .to eq(['SELECT 1 FROM "answers"']) + expect(scanner.strip_exists_subqueries(sql)).to eq('SELECT 1 AS "x EXISTS (" WHERE TRUE') + end + end + + describe '#policy_predicate_bind_numbers' do + it 'accepts binds only in the expected qualified predicate' do + expect(scanner.policy_predicate_bind_numbers('SELECT $1 FROM "answers" WHERE TRUE', 'answers', 'tenant_id')).to eq([]) + expect(scanner.policy_predicate_bind_numbers('SELECT "answers"."tenant_id" = $1 FROM "answers" WHERE TRUE', 'answers', 'tenant_id')).to eq([]) + expect(scanner.policy_predicate_bind_numbers('SELECT 1 FROM "answers" WHERE $1 = $1', 'answers', 'tenant_id')).to eq([]) + expect(scanner.policy_predicate_bind_numbers('SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1 OR TRUE', 'answers', 'tenant_id')).to eq([]) + expect(scanner.policy_predicate_bind_numbers('SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1', 'answers', 'tenant_id')).to eq([1]) + end end describe '#extract_table_names' do diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 045a894..b6b285a 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -394,6 +394,31 @@ class << self config.policy_adapter = nil end + it 'rejects policy binds used only in SELECT or tautological expressions' do + config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } + + [ + 'SELECT $1 FROM "answers" WHERE TRUE', + 'SELECT 1 FROM "answers" WHERE $1 = $1' + ].each do |body| + q = described_class.new( + sql: "SELECT * FROM \"questions\" WHERE EXISTS (#{body})", + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', 'type' => 'select', + 'filters' => [{ 'column' => 'id', 'op' => 'exists', 'related_table' => 'answers', 'fk_column' => 'question_id' }], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: %w[questions answers], config: config + ) + + expect(q.safe?).to be false + end + ensure + config.policy_adapter = nil + end + it 'rejects additional unscoped OR EXISTS and NOT EXISTS references to a policy-scoped table' do config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index ed4687f..33b03bc 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -31,6 +31,25 @@ end end + context 'with policy table restrictions' do + let(:intent) do + { 'type' => 'select', 'table' => 'users', 'columns' => ['*'], + '__policy_allowed_tables' => ['users'] } + end + + it 'strips caller-supplied policy allowlist metadata before consulting policy' do + CodeToQuery.config.policy_adapter = ->(_user, **) { { allowed_tables: ['orders'] } } + + expect { validator.validate(intent) }.to raise_error(ArgumentError, /not permitted by policy/) + end + + it 'treats an explicitly empty policy allowlist as deny all' do + CodeToQuery.config.policy_adapter = ->(_user, **) { { allowed_tables: [] } } + + expect { validator.validate(intent) }.to raise_error(ArgumentError, /not permitted by policy/) + end + end + context 'with missing required fields' do it 'raises ArgumentError when type is missing' do intent = { 'table' => 'users', 'columns' => ['*'] } From 706d18435ac3738001f65bce33fc979e915214c3 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 03:47:42 -0500 Subject: [PATCH 43/86] fix: require enforcing policy predicates --- lib/code_to_query/query/sql_scanning.rb | 57 ++++++++++++++++--- spec/code_to_query/query/sql_scanning_spec.rb | 33 +++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index f051ee7..e416fa6 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -69,28 +69,67 @@ def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil) where_match = /\bWHERE\b/i.match(searchable) return [] unless where_match - searchable = searchable[where_match.end(0)..] - # Compiler-emitted policy predicates are conjunctive. Refuse OR here: - # `policy_column = $1 OR TRUE` does not enforce the policy. - return [] if searchable.match?(/\bOR\b/i) + conjuncts = top_level_conjuncts(searchable[where_match.end(0)..]) + return [] unless conjuncts identifier = lambda do |value| escaped = Regexp.escape(value.to_s) - "(?:\"#{escaped}\"|`#{escaped}`|#{escaped})" + unquoted = "(?(predicate) { /\A\s*\(*\s*#{predicate}\s*\)*\s*\z/i } + question_pattern = wrappers.call("#{qualified}\\s*(?:=\\s*\\?|BETWEEN\\s*\\?\\s+AND\\s*\\?)") + if question_bind_number && conjuncts.any? { |conjunct| conjunct.match?(question_pattern) } return [question_bind_number] end - pattern = /#{qualified}\s*(?:=\s*\$(\d+)|BETWEEN\s*\$(\d+)\s+AND\s*\$(\d+))/i - searchable.to_enum(:scan, pattern).flat_map do - Regexp.last_match.captures.compact.map(&:to_i) + pattern = wrappers.call("#{qualified}\\s*(?:=\\s*\\$(\\d+)|BETWEEN\\s*\\$(\\d+)\\s+AND\\s*\\$(\\d+))") + conjuncts.flat_map do |conjunct| + match = pattern.match(conjunct) + match ? match.captures.compact.map(&:to_i) : [] end.uniq end private + # Return mandatory top-level WHERE conjuncts. Nested expressions stay + # intact, so policy text under NOT, CASE, or a subquery cannot count. + def top_level_conjuncts(searchable) + conjuncts = [] + start = 0 + depth = 0 + between = false + + searchable.to_enum(:scan, /\b(?:AND|OR|BETWEEN)\b|[()]/i).each do + token = Regexp.last_match(0).upcase + case token + when '(' + depth += 1 + when ')' + return nil if depth.zero? + + depth -= 1 + when 'OR' + return nil if depth.zero? + when 'BETWEEN' + between = true if depth.zero? + when 'AND' + next unless depth.zero? + if between + between = false + else + conjuncts << searchable[start...Regexp.last_match.begin(0)] + start = Regexp.last_match.end(0) + end + end + end + return nil unless depth.zero? + + conjuncts << searchable[start..] + conjuncts + end + def skip_parenthesized_sql(source, index) depth = 1 state = :code diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index 89f6bb7..e8f035d 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -54,6 +54,39 @@ expect(scanner.policy_predicate_bind_numbers('SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1 OR TRUE', 'answers', 'tenant_id')).to eq([]) expect(scanner.policy_predicate_bind_numbers('SELECT 1 FROM "answers" WHERE "answers"."tenant_id" = $1', 'answers', 'tenant_id')).to eq([1]) end + + it 'rejects predicates in non-enforcing boolean and expression contexts' do + expect(scanner.policy_predicate_bind_numbers( + 'SELECT 1 FROM "answers" WHERE NOT ("answers"."tenant_id" = $1)', 'answers', 'tenant_id' + )).to eq([]) + expect(scanner.policy_predicate_bind_numbers( + 'SELECT 1 FROM "answers" WHERE CASE WHEN "answers"."tenant_id" = $1 THEN TRUE ELSE TRUE END', + 'answers', 'tenant_id' + )).to eq([]) + end + + it 'requires identifier boundaries for unquoted policy columns' do + expect(scanner.policy_predicate_bind_numbers( + 'SELECT 1 FROM answers WHERE notanswers.tenant_id = $1', 'answers', 'tenant_id' + )).to eq([]) + expect(scanner.policy_predicate_bind_numbers( + 'SELECT 1 FROM answers WHERE answers.tenant_id_suffix = $1', 'answers', 'tenant_id' + )).to eq([]) + end + + it 'does not count a predicate placed in a nested scope' do + sql = 'SELECT 1 FROM "answers" WHERE EXISTS ' \ + '(SELECT 1 FROM "decoys" WHERE "answers"."tenant_id" = $1)' + + expect(scanner.policy_predicate_bind_numbers(sql, 'answers', 'tenant_id')).to eq([]) + end + + it 'accepts compiler-shaped top-level conjuncts including BETWEEN' do + sql = 'SELECT 1 FROM answers WHERE answers.question_id = questions.id ' \ + 'AND answers.created_at BETWEEN $2 AND $3' + + expect(scanner.policy_predicate_bind_numbers(sql, 'answers', 'created_at')).to eq([2, 3]) + end end describe '#extract_table_names' do From 94017009eb84537ecaf4aca9749d71a6dc9047d9 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 03:54:55 -0500 Subject: [PATCH 44/86] fix: enforce Unicode policy identifier boundaries --- lib/code_to_query/query/sql_scanning.rb | 6 +++++- spec/code_to_query/compiler_spec.rb | 14 ++++++++++++++ spec/code_to_query/query/sql_scanning_spec.rb | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index e416fa6..900fdce 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -74,7 +74,11 @@ def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil) identifier = lambda do |value| escaped = Regexp.escape(value.to_s) - unquoted = "(? 'questions', 'columns' => ['*'], 'filters' => [], 'params' => {}, + '__policy_allowed_tables' => ['attacker'] + } + + result = compiler.compile(untrusted_intent) + + expect(result[:intent]['__policy_allowed_tables']).to eq(['questions']) + end + it 'preserves an explicitly empty related policy allowlist as deny all' do config.policy_adapter = lambda do |_user, **kwargs| kwargs[:table] == 'questions' ? { allowed_tables: ['questions'] } : { allowed_tables: [] } diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index e8f035d..f726240 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -74,6 +74,21 @@ )).to eq([]) end + it 'requires Unicode identifier boundaries for unquoted policy identifiers' do + expect(scanner.policy_predicate_bind_numbers( + 'SELECT 1 FROM answers WHERE éanswers.tenant_id = $1', 'answers', 'tenant_id' + )).to eq([]) + expect(scanner.policy_predicate_bind_numbers( + 'SELECT 1 FROM answers WHERE answersé.tenant_id = $1', 'answers', 'tenant_id' + )).to eq([]) + expect(scanner.policy_predicate_bind_numbers( + 'SELECT 1 FROM answers WHERE answers.étenant_id = $1', 'answers', 'tenant_id' + )).to eq([]) + expect(scanner.policy_predicate_bind_numbers( + 'SELECT 1 FROM answers WHERE answers.tenant_idé = $1', 'answers', 'tenant_id' + )).to eq([]) + end + it 'does not count a predicate placed in a nested scope' do sql = 'SELECT 1 FROM "answers" WHERE EXISTS ' \ '(SELECT 1 FROM "decoys" WHERE "answers"."tenant_id" = $1)' From 49c8a1966e7d47ea4a5a981623d4cc4f7dda4c83 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 04:02:07 -0500 Subject: [PATCH 45/86] test: preserve absent policy table allowlist --- .../validator_adversarial_fixture_spec.rb | 8 ++++---- spec/code_to_query/validator_spec.rb | 10 ++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/spec/code_to_query/validator_adversarial_fixture_spec.rb b/spec/code_to_query/validator_adversarial_fixture_spec.rb index 685fe3b..2c0ff8f 100644 --- a/spec/code_to_query/validator_adversarial_fixture_spec.rb +++ b/spec/code_to_query/validator_adversarial_fixture_spec.rb @@ -25,10 +25,10 @@ it 'rejects the intent with the expected reason' do policy = test_case['policy'] policy_adapter = policy && lambda do |_user, **_context| - { - allowed_tables: policy.fetch('allowed_tables', []), - allowed_columns: policy.fetch('allowed_columns', {}) - } + {}.tap do |policy_info| + policy_info[:allowed_tables] = policy['allowed_tables'] if policy.key?('allowed_tables') + policy_info[:allowed_columns] = policy['allowed_columns'] if policy.key?('allowed_columns') + end end stub_config(policy_adapter: policy_adapter, policy_adapter_fail_open: false) diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index 33b03bc..714e3c7 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -48,6 +48,16 @@ expect { validator.validate(intent) }.to raise_error(ArgumentError, /not permitted by policy/) end + + it 'leaves tables unrestricted when the policy table allowlist is absent' do + CodeToQuery.config.policy_adapter = ->(_user, **) { { allowed_columns: {} } } + + result = validator.validate(intent) + + expect(result[:table]).to eq('users') + expect(result).not_to have_key(:__policy_allowed_tables) + expect(result).not_to have_key('__policy_allowed_tables') + end end context 'with missing required fields' do From 0eab1498e4ed746cf3cd7b324785d9bd51b655fa Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 04:04:41 -0500 Subject: [PATCH 46/86] style: satisfy Ruby layout checks --- lib/code_to_query/compiler.rb | 8 ++++---- lib/code_to_query/query/sql_scanning.rb | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 8d36bd9..ac75617 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -586,10 +586,10 @@ def merge_policy_allowed_tables!(intent, policy_info) if intent.key?('__policy_allowed_tables') existing = Array(intent['__policy_allowed_tables']) intent['__policy_allowed_tables'] = if existing.empty? || allowed_tables.empty? - [] - else - (existing + allowed_tables).uniq - end + [] + else + (existing + allowed_tables).uniq + end else intent['__policy_allowed_tables'] = allowed_tables end diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 900fdce..d8edc12 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -120,6 +120,7 @@ def top_level_conjuncts(searchable) between = true if depth.zero? when 'AND' next unless depth.zero? + if between between = false else From a8fe98f67e71302b4d5fa6d2ecb34209f5c46081 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 08:51:25 -0500 Subject: [PATCH 47/86] fix: enforce related table policy allowlists --- lib/code_to_query/compiler.rb | 8 ++++++-- spec/code_to_query/compiler_spec.rb | 20 +++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index ac75617..37319e9 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -516,7 +516,7 @@ def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, p return [sub_where, placeholder_index] unless @config.policy_adapter.respond_to?(:call) info = safely_fetch_policy(table: related_table, current_user: current_user, intent: intent) - merge_policy_allowed_tables!(intent, info) + merge_policy_allowed_tables!(intent, info, required_table: related_table) predicates = extract_enforced_predicates(info) return [sub_where, placeholder_index] unless predicates.is_a?(Hash) && predicates.any? @@ -575,7 +575,7 @@ def merge_policy_expected_keys!(intent, *keys) intent['__policy_expected_keys'] = merge_policy_expected_keys(intent, keys.flatten) end - def merge_policy_allowed_tables!(intent, policy_info) + def merge_policy_allowed_tables!(intent, policy_info, required_table: nil) return unless policy_info.is_a?(Hash) present = policy_info.key?(:allowed_tables) || policy_info.key?('allowed_tables') @@ -583,6 +583,10 @@ def merge_policy_allowed_tables!(intent, policy_info) value = policy_info.key?(:allowed_tables) ? policy_info[:allowed_tables] : policy_info['allowed_tables'] allowed_tables = Array(value).map { |table| table.to_s.downcase }.reject(&:empty?) + if required_table && !allowed_tables.include?(required_table.to_s.downcase) + raise PolicyAdapterError, "Policy does not allow related table: #{required_table}" + end + if intent.key?('__policy_allowed_tables') existing = Array(intent['__policy_allowed_tables']) intent['__policy_allowed_tables'] = if existing.empty? || allowed_tables.empty? diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 01572ed..d9a765a 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -1016,14 +1016,28 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil expect(result[:intent]['__policy_allowed_tables']).to eq(['questions']) end - it 'preserves an explicitly empty related policy allowlist as deny all' do + it 'rejects an explicitly empty related policy allowlist' do config.policy_adapter = lambda do |_user, **kwargs| kwargs[:table] == 'questions' ? { allowed_tables: ['questions'] } : { allowed_tables: [] } end - result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) + expect do + compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) + end.to raise_error(CodeToQuery::PolicyAdapterError, /Policy does not allow related table: answers/) + end + + it 'rejects a related table omitted by its own policy response despite the outer allowlist' do + config.policy_adapter = lambda do |_user, **kwargs| + if kwargs[:table] == 'questions' + { allowed_tables: %w[questions answers] } + else + { allowed_tables: ['questions'] } + end + end - expect(result[:intent]['__policy_allowed_tables']).to eq([]) + expect do + compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) + end.to raise_error(CodeToQuery::PolicyAdapterError, /Policy does not allow related table: answers/) end it 'records related-table allowlists returned by the subquery policy adapter' do From 3ce10aaf683cd05fa316089767f6f4e1f715a624 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 09:36:59 -0500 Subject: [PATCH 48/86] fix: verify question-mark policy bind identity --- lib/code_to_query/query.rb | 3 +- lib/code_to_query/query/sql_scanning.rb | 29 +++++++++++++--- spec/code_to_query/query/sql_scanning_spec.rb | 34 +++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 3f23cb4..3834688 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -435,7 +435,8 @@ def policy_bind_present_in_subquery?(subquery, policy_bind_numbers, subqueries, policy_bind_numbers.all? do |number| bind = Array(@bind_spec)[number - 1] sql_scanner.policy_predicate_bind_numbers( - subquery[:sql], table, bind && bind[:column], question_bind_number: number + subquery[:sql], table, bind && bind[:column], + question_bind_number: number, source_sql: @sql, source_offset: subquery[:start] ).include?(number) end end diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index d8edc12..9e7c776 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -46,7 +46,7 @@ def exists_subqueries(sql, source_offset: 0) end def bind_placeholder_positions(sql) - searchable = mask_sql_literals_and_comments(sql.to_s) + searchable = mask_sql_literals_comments_and_identifier_contents(sql.to_s) if searchable.match?(/\$\d+/) searchable.to_enum(:scan, /\$(\d+)/).map do [Regexp.last_match.begin(0), Regexp.last_match(1).to_i] @@ -62,14 +62,16 @@ def bind_placeholder_positions(sql) # Identify binds used as values of the expected qualified policy column. # A placeholder elsewhere in the EXISTS body does not enforce policy. - def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil) + def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil, + source_sql: sql, source_offset: 0) return [] if table.to_s.empty? || column.to_s.empty? searchable = mask_sql_literals_and_comments(sql.to_s) where_match = /\bWHERE\b/i.match(searchable) return [] unless where_match - conjuncts = top_level_conjuncts(searchable[where_match.end(0)..]) + where_body = searchable[where_match.end(0)..] + conjuncts = top_level_conjuncts(where_body) return [] unless conjuncts identifier = lambda do |value| @@ -84,8 +86,25 @@ def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil) qualified = "#{identifier.call(table)}\\s*\\.\\s*#{identifier.call(column)}" wrappers = ->(predicate) { /\A\s*\(*\s*#{predicate}\s*\)*\s*\z/i } question_pattern = wrappers.call("#{qualified}\\s*(?:=\\s*\\?|BETWEEN\\s*\\?\\s+AND\\s*\\?)") - if question_bind_number && conjuncts.any? { |conjunct| conjunct.match?(question_pattern) } - return [question_bind_number] + placeholder_positions = bind_placeholder_positions(source_sql) + first_placeholder = placeholder_positions.first + question_placeholders = first_placeholder && source_sql.to_s[first_placeholder.first] == '?' + if question_bind_number && question_placeholders + placeholder_ordinals = placeholder_positions.to_h + search_from = 0 + conjuncts.each do |conjunct| + conjunct_offset = where_body.index(conjunct, search_from) + search_from = conjunct_offset + conjunct.length + next unless (match = question_pattern.match(conjunct)) + + match[0].to_enum(:scan, /\?/).each do + predicate_offset = where_match.end(0) + conjunct_offset + match.begin(0) + local_position = predicate_offset + Regexp.last_match.begin(0) + ordinal = placeholder_ordinals[source_offset + local_position] + return [ordinal] if ordinal == question_bind_number + end + end + return [] end pattern = wrappers.call("#{qualified}\\s*(?:=\\s*\\$(\\d+)|BETWEEN\\s*\\$(\\d+)\\s+AND\\s*\\$(\\d+))") diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index f726240..ccdeb01 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -28,6 +28,12 @@ expect { scanner.bind_placeholder_positions('SELECT $tag$spoof $1') } .to raise_error(SecurityError, 'Unterminated SQL literal or comment') end + + it 'ignores question marks in quoted SQLite and MySQL identifiers' do + sql = 'SELECT "sqlite?name", `mysql?name`, ?' + + expect(scanner.bind_placeholder_positions(sql)).to eq([[sql.rindex('?'), 1]]) + end end describe '#exists_subqueries' do @@ -47,6 +53,34 @@ end describe '#policy_predicate_bind_numbers' do + it 'derives SQLite and MySQL question-mark bind identity from the global SQL position' do + full_sql = 'SELECT ? FROM questions WHERE EXISTS ' \ + '(SELECT ? FROM answers WHERE answers.kind = ? AND answers.tenant_id = ?)' + body_start = full_sql.index('SELECT ?', full_sql.index('EXISTS')) + body = full_sql[body_start...full_sql.rindex(')')] + + expect(scanner.policy_predicate_bind_numbers( + body, 'answers', 'tenant_id', + question_bind_number: 3, source_sql: full_sql, source_offset: body_start + )).to eq([]) + expect(scanner.policy_predicate_bind_numbers( + body, 'answers', 'tenant_id', + question_bind_number: 4, source_sql: full_sql, source_offset: body_start + )).to eq([4]) + end + + it 'ignores question marks in literals, comments, and dollar quotes when deriving identity' do + full_sql = "SELECT '?', $$?$$, ? /* ? */ WHERE EXISTS " \ + '(SELECT 1 FROM answers WHERE answers.tenant_id = ?)' + body_start = full_sql.index('SELECT 1') + body = full_sql[body_start...full_sql.rindex(')')] + + expect(scanner.policy_predicate_bind_numbers( + body, 'answers', 'tenant_id', + question_bind_number: 2, source_sql: full_sql, source_offset: body_start + )).to eq([2]) + end + it 'accepts binds only in the expected qualified predicate' do expect(scanner.policy_predicate_bind_numbers('SELECT $1 FROM "answers" WHERE TRUE', 'answers', 'tenant_id')).to eq([]) expect(scanner.policy_predicate_bind_numbers('SELECT "answers"."tenant_id" = $1 FROM "answers" WHERE TRUE', 'answers', 'tenant_id')).to eq([]) From b496e329786ff660aac58a140c0a277d623d77d2 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 12:08:57 -0500 Subject: [PATCH 49/86] docs: clarify empty policy table allowlists --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ea767..1059d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This file tracks the major changes in each release. - Added a security policy and threat model covering safe deployment defaults, cross-tenant data exposure, schema-context filtering, observability data exposure, and vulnerability reporting. ### Changed +- Compatibility note: policy adapters that explicitly return `allowed_tables: []` deny access to all tables, as intended; omit `allowed_tables` only when no policy table allowlist is being supplied. - Added explicit explain-gate profiles and deployment tradeoff guidance for `explain_fail_open` in the README, including strict, availability-first, and local/test configuration patterns. - Breaking default change: EXPLAIN gate errors now fail closed by default; set `explain_fail_open = true` only for availability-first readonly deployments that can tolerate skipped EXPLAIN checks. - Context pack generation now omits sensitive schema metadata by default for password, secret, token, credential, digest, salt, OTP, and API key style columns; customize `sensitive_column_patterns` when applications use additional naming conventions. From 60733eaa2a1539c5a22ce8719d755fb364f497b8 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 22:52:00 -0500 Subject: [PATCH 50/86] fix: close SQL table allowlist bypasses --- lib/code_to_query/query.rb | 13 +++++++++---- lib/code_to_query/query/sql_scanning.rb | 3 +-- spec/code_to_query/query/sql_scanning_spec.rb | 5 +++++ spec/code_to_query/query_spec.rb | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 3834688..88e8ac1 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -368,7 +368,9 @@ def check_top_level_table_allowlist! raise SecurityError, 'Top-level derived tables are not allowed' if top_level_sql.match?(/(?:\bFROM\b|\bJOIN\b|,)\s*(?:LATERAL\s+)?\(/i) extract_table_names(top_level_sql).each do |table| - next if allowed_tables.include?(table.to_s.downcase) + # Preserve quoted identifier case. Downcasing here would make a distinct + # PostgreSQL table such as "USERS" match an allowlisted `users` table. + next if allowed_tables.include?(table.to_s) raise SecurityError, "Table '#{table}' is not in the allowed list: #{allowed_tables.join(', ')}" end @@ -376,7 +378,7 @@ def check_top_level_table_allowlist! def check_policy_scoped_related_table_references! policy_scoped_related_tables = declared_related_tables - return if policy_scoped_related_tables.empty? + return unless allowlist_sources_present? extract_table_names(strip_exists_subqueries(@sql)).each do |table| next unless policy_scoped_related_tables.include?(table.to_s.downcase) @@ -397,8 +399,11 @@ def check_policy_scoped_related_table_references! subqueries = sql_scanner.exists_subqueries(@sql) subqueries.each do |subquery| extract_table_names(strip_exists_subqueries(subquery[:sql])).each do |table| - normalized_table = table.to_s.downcase - next unless policy_scoped_related_tables.include?(normalized_table) + normalized_table = table.to_s + unless policy_scoped_related_tables.include?(normalized_table) + raise SecurityError, + "Table '#{table}' has an undeclared #{subquery[:operator].upcase} reference" + end reference = [subquery[:operator], normalized_table] sql_references[reference] += 1 diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 9e7c776..4fa1531 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -368,9 +368,8 @@ def join_clause_pattern end def extract_table_reference_name(reference) - identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z0-9_]+)' alias_identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z_][a-zA-Z0-9_]*)' - table_reference_pattern = /\A(?:#{identifier}\.)*(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i + table_reference_pattern = /\A(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i match = reference.to_s.strip.match(table_reference_pattern) captures = match&.captures diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index ccdeb01..657eb14 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -139,6 +139,11 @@ end describe '#extract_table_names' do + it 'fails closed for qualified table references instead of discarding the qualifier' do + expect { scanner.extract_table_names('SELECT * FROM evil.users') } + .to raise_error(SecurityError, /Unsupported FROM reference/) + end + it 'does not let identifier quotes inside string literals hide executable table references' do expect(scanner.extract_table_names(%q(SELECT '"' FROM"forbidden"))).to eq(['forbidden']) expect(scanner.extract_table_names("SELECT '`' FROM`forbidden`")).to eq(['forbidden']) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index b6b285a..64475ce 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -330,6 +330,25 @@ class << self config.policy_adapter = nil end + it 'rejects a differently cased quoted PostgreSQL identifier from a lowercase allowlist' do + q = described_class.new( + sql: 'SELECT * FROM "USERS" LIMIT 100', params: {}, bind_spec: [], + intent: { 'table' => 'users', 'type' => 'select' }, allow_tables: ['users'], config: config + ) + + expect(q.safe?).to be false + end + + it 'rejects an undeclared EXISTS table even when the top-level allowlist permits it' do + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers")', + params: {}, bind_spec: [], intent: { 'table' => 'questions', 'type' => 'select', 'filters' => [] }, + allow_tables: %w[questions answers], config: config + ) + + expect(q.safe?).to be false + end + it 'returns false when expected subquery policy keys are missing from binds and params' do config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } From 83ddae812f2709b81d8d5c35e217c7af1133260e Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 22:55:10 -0500 Subject: [PATCH 51/86] test: cover undeclared NOT EXISTS without declarations --- spec/code_to_query/query_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 64475ce..186eb9d 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -349,6 +349,16 @@ class << self expect(q.safe?).to be false end + it 'rejects an undeclared NOT EXISTS table when the intent declares no related tables' do + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE NOT EXISTS (SELECT 1 FROM "answers")', + params: {}, bind_spec: [], intent: { 'table' => 'questions', 'type' => 'select', 'filters' => [] }, + allow_tables: %w[questions answers], config: config + ) + + expect(q.safe?).to be false + end + it 'returns false when expected subquery policy keys are missing from binds and params' do config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } From 5cbc8e836a727819129fde51d2aacc1ee6237ac1 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 23:03:12 -0500 Subject: [PATCH 52/86] fix: honor adapter identifier case semantics --- lib/code_to_query/query.rb | 66 ++++++++++++++++--------- lib/code_to_query/query/sql_scanning.rb | 19 ++++--- spec/code_to_query/query_spec.rb | 47 ++++++++++++++++++ 3 files changed, 102 insertions(+), 30 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 88e8ac1..0ae9e09 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -331,13 +331,12 @@ def expected_policy_keys def effective_lint_allow_tables explicit_tables = Array(@allow_tables).compact.map(&:to_s).uniq policy_tables = Array(@intent['__policy_allowed_tables']).compact.map(&:to_s).uniq - normalized_explicit_tables = explicit_tables.map(&:downcase).uniq - normalized_policy_tables = policy_tables.map(&:downcase).uniq - return @allow_tables unless policy_allowlist_present? - return normalized_policy_tables if explicit_tables.empty? + return policy_tables if explicit_tables.empty? - normalized_explicit_tables & normalized_policy_tables + explicit_tables.select do |table| + policy_tables.any? { |policy_table| allowlist_names_equivalent?(table, policy_table) } + end end def allowlist_sources_present? @@ -359,7 +358,7 @@ def lint_sql! end def check_top_level_table_allowlist! - allowed_tables = Array(effective_lint_allow_tables).compact.map { |table| table.to_s.downcase } + allowed_tables = Array(effective_lint_allow_tables).compact.map(&:to_s) return if allowed_tables.empty? top_level_sql = strip_exists_subqueries(@sql) @@ -367,12 +366,10 @@ def check_top_level_table_allowlist! raise SecurityError, 'Top-level common table expressions are not allowed' if top_level_sql.match?(/\A\s*WITH\b/i) raise SecurityError, 'Top-level derived tables are not allowed' if top_level_sql.match?(/(?:\bFROM\b|\bJOIN\b|,)\s*(?:LATERAL\s+)?\(/i) - extract_table_names(top_level_sql).each do |table| - # Preserve quoted identifier case. Downcasing here would make a distinct - # PostgreSQL table such as "USERS" match an allowlisted `users` table. - next if allowed_tables.include?(table.to_s) + extract_table_identifiers(top_level_sql).each do |table| + next if allowed_tables.any? { |allowed| table_identifier_allowed?(table, allowed) } - raise SecurityError, "Table '#{table}' is not in the allowed list: #{allowed_tables.join(', ')}" + raise SecurityError, "Table '#{table.name}' is not in the allowed list: #{allowed_tables.join(', ')}" end end @@ -380,17 +377,17 @@ def check_policy_scoped_related_table_references! policy_scoped_related_tables = declared_related_tables return unless allowlist_sources_present? - extract_table_names(strip_exists_subqueries(@sql)).each do |table| - next unless policy_scoped_related_tables.include?(table.to_s.downcase) + extract_table_identifiers(strip_exists_subqueries(@sql)).each do |table| + next unless policy_scoped_related_tables.any? { |allowed| table_identifier_allowed?(table, allowed) } raise SecurityError, - "Table '#{table}' is only allowed inside declared EXISTS/NOT EXISTS filters" + "Table '#{table.name}' is only allowed inside declared EXISTS/NOT EXISTS filters" end declared_references = Hash.new { |hash, key| hash[key] = [] } Array(@intent['filters']).each do |filter| operator = filter['op'].to_s.downcase - table = filter['related_table']&.to_s&.downcase + table = filter['related_table']&.to_s declared_references[[operator, table]] << filter if %w[exists not_exists].include?(operator) && table end policy_binds_by_declaration = policy_binds_by_related_filter @@ -398,11 +395,11 @@ def check_policy_scoped_related_table_references! sql_references = Hash.new(0) subqueries = sql_scanner.exists_subqueries(@sql) subqueries.each do |subquery| - extract_table_names(strip_exists_subqueries(subquery[:sql])).each do |table| - normalized_table = table.to_s - unless policy_scoped_related_tables.include?(normalized_table) + extract_table_identifiers(strip_exists_subqueries(subquery[:sql])).each do |table| + normalized_table = policy_scoped_related_tables.find { |allowed| table_identifier_allowed?(table, allowed) } + unless normalized_table raise SecurityError, - "Table '#{table}' has an undeclared #{subquery[:operator].upcase} reference" + "Table '#{table.name}' has an undeclared #{subquery[:operator].upcase} reference" end reference = [subquery[:operator], normalized_table] @@ -410,14 +407,14 @@ def check_policy_scoped_related_table_references! declaration = declared_references[reference][sql_references[reference] - 1] if declaration required_bind_numbers = policy_binds_by_declaration.fetch(declaration, []) - next if policy_bind_present_in_subquery?(subquery, required_bind_numbers, subqueries, table) + next if policy_bind_present_in_subquery?(subquery, required_bind_numbers, subqueries, table.name) raise SecurityError, - "Table '#{table}' has an unscoped #{subquery[:operator].upcase} reference" + "Table '#{table.name}' has an unscoped #{subquery[:operator].upcase} reference" end raise SecurityError, - "Table '#{table}' has an undeclared #{subquery[:operator].upcase} reference" + "Table '#{table.name}' has an undeclared #{subquery[:operator].upcase} reference" end end end @@ -449,7 +446,7 @@ def policy_bind_present_in_subquery?(subquery, policy_bind_numbers, subqueries, def policy_binds_by_related_filter declarations_by_table = Array(@intent['filters']).select do |filter| %w[exists not_exists].include?(filter['op'].to_s.downcase) && filter['related_table'] - end.group_by { |filter| filter['related_table'].to_s.downcase } + end.group_by { |filter| filter['related_table'].to_s } declarations_by_table.each_with_object({}.compare_by_identity) do |(table, declarations), result| table_fragment = table.gsub(/[^a-zA-Z0-9_]/, '_') @@ -472,7 +469,7 @@ def declared_related_tables op = filter['op'].to_s.downcase next unless %w[exists not_exists].include?(op) - filter['related_table']&.to_s&.downcase + filter['related_table']&.to_s end.compact.uniq end @@ -484,6 +481,27 @@ def extract_table_names(sql) sql_scanner.extract_table_names(sql) end + def extract_table_identifiers(sql) + sql_scanner.extract_table_identifiers(sql) + end + + # Allowlist names are catalog identifiers. Lowercase names denote portable + # unquoted identifiers; mixed/uppercase names denote deliberately cased + # identifiers. SQLite is the exception: it resolves identifiers without + # regard to case even when they are quoted. + def table_identifier_allowed?(identifier, allowed) + return identifier.name.casecmp?(allowed) if @config.adapter.to_sym == :sqlite + return identifier.name == allowed if identifier.quoted + + allowed == allowed.downcase && identifier.name.downcase == allowed + end + + def allowlist_names_equivalent?(left, right) + return left.casecmp?(right) if @config.adapter.to_sym == :sqlite + + left == right || (left == left.downcase && right == right.downcase && left.casecmp?(right)) + end + def sql_scanner @sql_scanner ||= SqlScanner.new end diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 4fa1531..3a85057 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -5,6 +5,7 @@ class Query # Internal SQL scanning helpers used to enforce table allowlists while # preserving quoted literals, identifiers, and comment boundaries. class SqlScanner + TableIdentifier = Struct.new(:name, :quoted, keyword_init: true) def strip_exists_subqueries(sql) source = sql.to_s searchable = mask_sql_literals_comments_and_identifier_contents(source) @@ -308,6 +309,10 @@ def dollar_quote_delimiter_at(source, index) public def extract_table_names(sql) + extract_table_identifiers(sql).map(&:name).uniq + end + + def extract_table_identifiers(sql) source = sql.to_s searchable = mask_sql_literals_comments_and_identifier_contents(source) tables = [] @@ -319,7 +324,7 @@ def extract_table_names(sql) commas = masked_clause.to_enum(:scan, /,/).map { Regexp.last_match.begin(0) } [-1, *commas, clause.length].each_cons(2) do |left, right| reference = clause[(left + 1)...right] - table_name = extract_table_reference_name(reference) + table_name = extract_table_reference_identifier(reference) raise SecurityError, "Unsupported FROM reference: #{reference.to_s.strip}" unless table_name tables << table_name @@ -329,13 +334,13 @@ def extract_table_names(sql) searchable.to_enum(:scan, /#{join_clause_pattern}\s*(.+?)(?=\bON\b|\bUSING\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|\bHAVING\b|\bUNION\b|#{join_clause_pattern}|\z)/im).each do match = Regexp.last_match reference = source[match.begin(1)...match.end(1)] - table_name = extract_table_reference_name(reference) + table_name = extract_table_reference_identifier(reference) raise SecurityError, "Unsupported JOIN reference: #{reference.to_s.strip}" unless table_name tables << table_name end - tables.uniq + tables.uniq { |identifier| [identifier.name, identifier.quoted] } end private @@ -367,13 +372,15 @@ def join_clause_pattern /\b(?:INNER\s+|(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+|CROSS\s+|NATURAL\s+(?:(?:LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+)?)?JOIN\b/i end - def extract_table_reference_name(reference) + def extract_table_reference_identifier(reference) alias_identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z_][a-zA-Z0-9_]*)' table_reference_pattern = /\A(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i match = reference.to_s.strip.match(table_reference_pattern) - captures = match&.captures - captures&.compact&.first + return unless match + + quoted_name = match.captures[0..2].compact.first + TableIdentifier.new(name: quoted_name || match[4], quoted: !quoted_name.nil?) end end end diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 186eb9d..39c923f 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -339,6 +339,53 @@ class << self expect(q.safe?).to be false end + it 'accepts uppercase unquoted PostgreSQL and SQLite identifiers from a lowercase allowlist' do + %i[postgres sqlite].each do |adapter| + q = described_class.new( + sql: 'SELECT * FROM USERS LIMIT 100', params: {}, bind_spec: [], + intent: { 'table' => 'users', 'type' => 'select' }, allow_tables: ['users'], + config: stub_config(adapter: adapter) + ) + + expect(q.safe?).to be(true), "expected #{adapter} unquoted identifier folding to be honored" + end + end + + it 'accepts explicitly allowlisted quoted mixed-case identifiers on every adapter' do + { postgres: '"AuditEvents"', sqlite: '"AuditEvents"', mysql: '`AuditEvents`' }.each do |adapter, table| + q = described_class.new( + sql: "SELECT * FROM #{table} LIMIT 100", params: {}, bind_spec: [], + intent: { 'table' => 'AuditEvents', 'type' => 'select' }, allow_tables: ['AuditEvents'], + config: stub_config(adapter: adapter) + ) + + expect(q.safe?).to be(true), "expected quoted #{adapter} identifier case to be preserved" + end + end + + it 'uses SQLite case-insensitive semantics for quoted identifiers' do + q = described_class.new( + sql: 'SELECT * FROM "AUDITEVENTS" LIMIT 100', params: {}, bind_spec: [], + intent: { 'table' => 'AuditEvents', 'type' => 'select' }, allow_tables: ['AuditEvents'], + config: stub_config(adapter: :sqlite) + ) + + expect(q.safe?).to be true + end + + it 'accepts uppercase unquoted related tables in PostgreSQL EXISTS filters' do + q = described_class.new( + sql: 'SELECT * FROM QUESTIONS WHERE EXISTS (SELECT 1 FROM ANSWERS)', params: {}, bind_spec: [], + intent: { + 'table' => 'questions', 'type' => 'select', + 'filters' => [{ 'column' => 'id', 'op' => 'exists', 'related_table' => 'answers' }] + }, + allow_tables: %w[questions answers], config: config + ) + + expect(q.safe?).to be true + end + it 'rejects an undeclared EXISTS table even when the top-level allowlist permits it' do q = described_class.new( sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers")', From 7924d23b4fbbab6a095b62aa0137620b42069e78 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 23:11:24 -0500 Subject: [PATCH 53/86] fix: preserve policy table identifier casing --- lib/code_to_query/compiler.rb | 10 ++- lib/code_to_query/validator.rb | 10 ++- .../policy_metadata_casing_spec.rb | 74 +++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 spec/code_to_query/policy_metadata_casing_spec.rb diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 37319e9..94e0bba 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -582,8 +582,8 @@ def merge_policy_allowed_tables!(intent, policy_info, required_table: nil) return unless present value = policy_info.key?(:allowed_tables) ? policy_info[:allowed_tables] : policy_info['allowed_tables'] - allowed_tables = Array(value).map { |table| table.to_s.downcase }.reject(&:empty?) - if required_table && !allowed_tables.include?(required_table.to_s.downcase) + allowed_tables = Array(value).map(&:to_s).reject(&:empty?) + if required_table && allowed_tables.none? { |allowed| policy_table_allowed?(required_table, allowed) } raise PolicyAdapterError, "Policy does not allow related table: #{required_table}" end @@ -599,6 +599,12 @@ def merge_policy_allowed_tables!(intent, policy_info, required_table: nil) end end + def policy_table_allowed?(table, allowed) + return table.to_s.casecmp?(allowed.to_s) if @config.adapter.to_sym == :sqlite + + table.to_s == allowed.to_s + end + def strip_untrusted_policy_expectations!(intent) intent.delete('__policy_expected_keys') intent.delete(:__policy_expected_keys) diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index d8093d6..c1d419a 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -151,12 +151,12 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) policy_has_allowed_tables = policy_info.key?(:allowed_tables) || policy_info.key?('allowed_tables') if policy_has_allowed_tables - allowed_tables = Array(fetch_value(policy_info, :allowed_tables)).map { |t| t.to_s.downcase } + allowed_tables = Array(fetch_value(policy_info, :allowed_tables)).map(&:to_s) intent[:__policy_allowed_tables] = allowed_tables intent['__policy_allowed_tables'] = allowed_tables table = fetch_value(intent, :table) - if (table.to_s.strip != '') && !allowed_tables.include?(table.to_s.downcase) + if (table.to_s.strip != '') && allowed_tables.none? { |allowed| policy_table_allowed?(table, allowed) } raise ArgumentError, "Invalid intent: table '#{table}' not permitted by policy" end end @@ -275,6 +275,12 @@ def policy_signature_mismatch?(error) error.message.match?(/unknown keyword.*intent|wrong number of arguments|no keywords accepted/) end + def policy_table_allowed?(table, allowed) + return table.to_s.casecmp?(allowed.to_s) if CodeToQuery.config.adapter.to_sym == :sqlite + + table.to_s == allowed.to_s + end + def handle_policy_failure(message) CodeToQuery.config.logger.warn("[code_to_query] #{message}") {} diff --git a/spec/code_to_query/policy_metadata_casing_spec.rb b/spec/code_to_query/policy_metadata_casing_spec.rb new file mode 100644 index 0000000..ca1316e --- /dev/null +++ b/spec/code_to_query/policy_metadata_casing_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'policy table metadata casing pipeline' do + let(:config) { stub_config(adapter: :postgres, default_limit: 100) } + let(:validator) { CodeToQuery::Validator.new } + let(:compiler) { CodeToQuery::Compiler.new(config) } + let(:policy_tables) { %w[AuditEvents auditevents] } + + before do + config.policy_adapter = ->(_user, **) { { allowed_tables: policy_tables } } + end + + after do + config.policy_adapter = nil + end + + def query_from_policy(table) + validated = validator.validate( + { 'type' => 'select', 'table' => table, 'columns' => ['*'] } + ).deep_stringify_keys + compiled = compiler.compile(validated) + query = CodeToQuery::Query.new( + sql: compiled[:sql], + params: compiled[:params], + bind_spec: compiled[:bind_spec], + intent: compiled[:intent], + allow_tables: nil, + config: config + ) + + [validated, compiled, query] + end + + it 'preserves a deliberately cased quoted PostgreSQL table from policy through query safety' do + validated, compiled, query = query_from_policy('AuditEvents') + + expect(validated['__policy_allowed_tables']).to eq(policy_tables) + expect(compiled[:intent]['__policy_allowed_tables']).to eq(policy_tables) + expect(compiled[:sql]).to include('FROM "AuditEvents"') + expect(query.safe?).to be true + end + + it 'keeps a distinct lowercase PostgreSQL table authorized independently' do + validated, compiled, query = query_from_policy('auditevents') + + expect(validated['__policy_allowed_tables']).to eq(policy_tables) + expect(compiled[:intent]['__policy_allowed_tables']).to eq(policy_tables) + expect(compiled[:sql]).to include('FROM "auditevents"') + expect(query.safe?).to be true + end + + it 'does not authorize a lowercase related table from a deliberately cased policy entry' do + config.policy_adapter = lambda do |_user, **kwargs| + if kwargs[:table] == 'accounts' + { allowed_tables: %w[accounts AuditEvents] } + else + { allowed_tables: ['AuditEvents'] } + end + end + intent = { + 'type' => 'select', 'table' => 'accounts', 'columns' => ['*'], + 'filters' => [{ + 'op' => 'exists', 'related_table' => 'auditevents', + 'fk_column' => 'account_id', 'base_column' => 'id' + }] + } + validated = validator.validate(intent).deep_stringify_keys + + expect { compiler.compile(validated) } + .to raise_error(CodeToQuery::PolicyAdapterError, /does not allow related table: auditevents/) + end +end From 19af9322cd73f316e084c08f102f1b7c87ab650a Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 12 Jul 2026 23:19:55 -0500 Subject: [PATCH 54/86] fix: enforce exact MySQL table allowlist casing --- lib/code_to_query/guardrails/sql_linter.rb | 14 +++++++-- lib/code_to_query/query.rb | 3 ++ .../guardrails/sql_linter_spec.rb | 22 ++++++++++++++ spec/code_to_query/query_spec.rb | 30 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index d080414..34cf265 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -5,8 +5,7 @@ module Guardrails class SqlLinter def initialize(config, allow_tables: nil) @config = config - # normalize allowlist to lowercase for case-insensitive comparison - @allow_tables = Array(allow_tables).compact.map { |t| t.to_s.downcase } + @allow_tables = Array(allow_tables).compact.map(&:to_s) end def check!(sql) @@ -209,12 +208,21 @@ def check_table_allowlist!(sql) referenced_tables = extract_table_names(sql) referenced_tables.each do |table| - unless @allow_tables.include?(table.to_s.downcase) + unless table_allowed?(table) raise SecurityError, "Table '#{table}' is not in the allowed list: #{@allow_tables.join(', ')}" end end end + def table_allowed?(table) + # MySQL table-name case semantics vary by lower_case_table_names and + # host filesystem. Fail closed with exact matching when they are not + # explicitly available to this legacy linter. + return @allow_tables.include?(table.to_s) if @config.adapter.to_sym == :mysql + + @allow_tables.any? { |allowed| allowed.casecmp?(table.to_s) } + end + def check_no_literals!(sql) # Block string literals (except in very specific contexts) # Allow some specific cases like LIKE patterns that might be pre-sanitized diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 0ae9e09..4c4fb6e 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -491,6 +491,9 @@ def extract_table_identifiers(sql) # regard to case even when they are quoted. def table_identifier_allowed?(identifier, allowed) return identifier.name.casecmp?(allowed) if @config.adapter.to_sym == :sqlite + # MySQL table-name case semantics depend on lower_case_table_names and + # the host filesystem. Without server metadata, only exact case is safe. + return identifier.name == allowed if @config.adapter.to_sym == :mysql return identifier.name == allowed if identifier.quoted allowed == allowed.downcase && identifier.name.downcase == allowed diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 0c412ce..0a78b26 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -78,6 +78,28 @@ sql = 'SELECT * FROM "users" JOIN "admin_secrets" ON users.id = admin_secrets.user_id LIMIT 100' expect { linter.check!(sql) }.to raise_error(SecurityError, /not in the allowed list/) end + + context 'with MySQL identifiers' do + let(:config) { stub_config(adapter: :mysql, max_limit: 1000, max_joins: 2) } + + it 'rejects an unquoted case mismatch' do + expect { linter.check!('SELECT * FROM USERS LIMIT 100') } + .to raise_error(SecurityError, /not in the allowed list/) + end + + it 'rejects a quoted case mismatch' do + expect { linter.check!('SELECT * FROM `USERS` LIMIT 100') } + .to raise_error(SecurityError, /not in the allowed list/) + end + + it 'allows an exact-case unquoted table' do + expect { linter.check!('SELECT * FROM users LIMIT 100') }.not_to raise_error + end + + it 'allows an exact-case quoted table' do + expect { linter.check!('SELECT * FROM `users` LIMIT 100') }.not_to raise_error + end + end end context 'with JOIN complexity limits' do diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 39c923f..9dfc1e3 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -351,6 +351,36 @@ class << self end end + it 'rejects a differently cased unquoted MySQL identifier from a lowercase allowlist' do + q = described_class.new( + sql: 'SELECT * FROM USERS LIMIT 100', params: {}, bind_spec: [], + intent: { 'table' => 'users', 'type' => 'select' }, allow_tables: ['users'], + config: stub_config(adapter: :mysql) + ) + + expect(q.safe?).to be false + end + + it 'rejects a differently cased quoted MySQL identifier from a lowercase allowlist' do + q = described_class.new( + sql: 'SELECT * FROM `USERS` LIMIT 100', params: {}, bind_spec: [], + intent: { 'table' => 'users', 'type' => 'select' }, allow_tables: ['users'], + config: stub_config(adapter: :mysql) + ) + + expect(q.safe?).to be false + end + + it 'accepts exact-case unquoted MySQL identifiers from the allowlist' do + q = described_class.new( + sql: 'SELECT * FROM AuditEvents LIMIT 100', params: {}, bind_spec: [], + intent: { 'table' => 'AuditEvents', 'type' => 'select' }, allow_tables: ['AuditEvents'], + config: stub_config(adapter: :mysql) + ) + + expect(q.safe?).to be true + end + it 'accepts explicitly allowlisted quoted mixed-case identifiers on every adapter' do { postgres: '"AuditEvents"', sqlite: '"AuditEvents"', mysql: '`AuditEvents`' }.each do |adapter, table| q = described_class.new( From 28034834954801485e1798f93cacab046fec417e Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 06:43:33 -0500 Subject: [PATCH 55/86] fix: scope related policy table authorization --- lib/code_to_query/compiler.rb | 8 ++++++++ lib/code_to_query/query.rb | 9 ++++++++- spec/code_to_query/compiler_spec.rb | 22 ++++++++++++++++++---- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 94e0bba..16b2af2 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -587,6 +587,12 @@ def merge_policy_allowed_tables!(intent, policy_info, required_table: nil) raise PolicyAdapterError, "Policy does not allow related table: #{required_table}" end + if required_table + intent['__policy_related_tables'] = + (Array(intent['__policy_related_tables']) + [required_table.to_s]).uniq + return + end + if intent.key?('__policy_allowed_tables') existing = Array(intent['__policy_allowed_tables']) intent['__policy_allowed_tables'] = if existing.empty? || allowed_tables.empty? @@ -610,6 +616,8 @@ def strip_untrusted_policy_expectations!(intent) intent.delete(:__policy_expected_keys) intent.delete('__policy_allowed_tables') intent.delete(:__policy_allowed_tables) + intent.delete('__policy_related_tables') + intent.delete(:__policy_related_tables) end # Independent compiler backstop: metadata alone never proves that a policy diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 4c4fb6e..84a95da 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -339,6 +339,13 @@ def effective_lint_allow_tables end end + def sql_linter_allow_tables + related_tables = Array(@intent['__policy_related_tables']).compact.map(&:to_s).uniq + return effective_lint_allow_tables if related_tables.empty? + + (Array(effective_lint_allow_tables) + related_tables).compact.map(&:to_s).uniq + end + def allowlist_sources_present? Array(@allow_tables).compact.any? || policy_allowlist_present? end @@ -352,7 +359,7 @@ def lint_sql! raise SecurityError, 'No tables remain after intersecting explicit and policy allowlists' end - Guardrails::SqlLinter.new(@config, allow_tables: effective_lint_allow_tables).check!(@sql) + Guardrails::SqlLinter.new(@config, allow_tables: sql_linter_allow_tables).check!(@sql) check_top_level_table_allowlist! check_policy_scoped_related_table_references! end diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index d9a765a..055fe22 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -993,13 +993,15 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil untrusted_intent = { 'table' => 'questions', 'columns' => ['*'], 'filters' => [], 'params' => {}, '__policy_expected_keys' => ['policy_attacker'], - '__policy_allowed_tables' => ['attacker_secrets'] + '__policy_allowed_tables' => ['attacker_secrets'], + '__policy_related_tables' => ['attacker_secrets'] } result = compiler.compile(untrusted_intent) expect(result[:intent]).not_to have_key('__policy_expected_keys') expect(result[:intent]).not_to have_key('__policy_allowed_tables') + expect(result[:intent]).not_to have_key('__policy_related_tables') end it 'replaces caller-supplied allowlist metadata with the enforced policy allowlist' do @@ -1040,14 +1042,14 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil end.to raise_error(CodeToQuery::PolicyAdapterError, /Policy does not allow related table: answers/) end - it 'records related-table allowlists returned by the subquery policy adapter' do + it 'does not widen top-level authorization with related-table policy metadata' do config.policy_adapter = lambda do |_user, **kwargs| case kwargs[:table] when 'questions' { allowed_tables: ['questions'] } when 'answers' { - allowed_tables: %w[questions answers], + allowed_tables: %w[answers users], enforced_predicates: { tenant_id: 42 } } else @@ -1056,8 +1058,20 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil end result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) + query_options = { + params: result[:params], bind_spec: result[:bind_spec], intent: result[:intent], + allow_tables: nil, config: config + } + correlated_query = CodeToQuery::Query.new(sql: result[:sql], **query_options) + widened_query = CodeToQuery::Query.new( + sql: result[:sql].sub('FROM "questions"', 'FROM "questions" JOIN "users" ON TRUE'), + **query_options + ) - expect(result[:intent]['__policy_allowed_tables']).to eq(%w[questions answers]) + expect(result[:intent]['__policy_allowed_tables']).to eq(['questions']) + expect(result[:intent]['__policy_related_tables']).to eq(['answers']) + expect(correlated_query.safe?).to be true + expect(widened_query.safe?).to be false end it 'does not mutate the caller intent while recording subquery policy expectations' do From 09e65c67311be1092873bd2cbba1842af7536024 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 06:51:41 -0500 Subject: [PATCH 56/86] fix: preserve unrestricted base policy linting --- lib/code_to_query/query.rb | 2 +- spec/code_to_query/compiler_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 84a95da..a1b6f05 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -341,7 +341,7 @@ def effective_lint_allow_tables def sql_linter_allow_tables related_tables = Array(@intent['__policy_related_tables']).compact.map(&:to_s).uniq - return effective_lint_allow_tables if related_tables.empty? + return effective_lint_allow_tables if related_tables.empty? || !allowlist_sources_present? (Array(effective_lint_allow_tables) + related_tables).compact.map(&:to_s).uniq end diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 055fe22..64fdca4 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -1074,6 +1074,26 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil expect(widened_query.safe?).to be false end + it 'preserves an unrestricted base policy when authorizing a related table' do + config.policy_adapter = lambda do |_user, **kwargs| + if kwargs[:table] == 'answers' + { allowed_tables: %w[answers users], enforced_predicates: { tenant_id: 42 } } + else + {} + end + end + + result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) + query = CodeToQuery::Query.new( + sql: result[:sql], params: result[:params], bind_spec: result[:bind_spec], + intent: result[:intent], allow_tables: nil, config: config + ) + + expect(result[:intent]).not_to have_key('__policy_allowed_tables') + expect(result[:intent]['__policy_related_tables']).to eq(['answers']) + expect(query.safe?).to be true + end + it 'does not mutate the caller intent while recording subquery policy expectations' do config.policy_adapter = lambda do |_user, **kwargs| kwargs[:table] == 'answers' ? { enforced_predicates: { tenant_id: 42 } } : {} From 877d1bcace9da515117a340e54fbef1b66ae22df Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 07:04:46 -0500 Subject: [PATCH 57/86] fix: dispatch policy adapters safely --- lib/code_to_query.rb | 1 + lib/code_to_query/compiler.rb | 31 +------ lib/code_to_query/policy_adapter_invoker.rb | 48 ++++++++++ lib/code_to_query/validator.rb | 26 +----- spec/code_to_query/compiler_spec.rb | 25 ++++++ .../policy_adapter_invoker_spec.rb | 88 +++++++++++++++++++ spec/code_to_query/validator_spec.rb | 22 +++++ 7 files changed, 186 insertions(+), 55 deletions(-) create mode 100644 lib/code_to_query/policy_adapter_invoker.rb create mode 100644 spec/code_to_query/policy_adapter_invoker_spec.rb diff --git a/lib/code_to_query.rb b/lib/code_to_query.rb index 400338b..a70e831 100644 --- a/lib/code_to_query.rb +++ b/lib/code_to_query.rb @@ -12,6 +12,7 @@ require_relative 'code_to_query/configuration' require_relative 'code_to_query/errors' require_relative 'code_to_query/instrumentation' +require_relative 'code_to_query/policy_adapter_invoker' require_relative 'code_to_query/providers/base' require_relative 'code_to_query/providers/openai' require_relative 'code_to_query/providers/local' diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 16b2af2..6a53974 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -85,32 +85,7 @@ def apply_policy_predicates(intent, current_user) end def safely_fetch_policy(table:, current_user:, intent: nil) - if intent - @config.policy_adapter.call(current_user, table: table, intent: intent) - else - @config.policy_adapter.call(current_user, table: table) - end - rescue ArgumentError => e - raise unless policy_signature_mismatch?(e) - - # Backward compatibility: adapters may accept user plus table or only user. - begin - @config.policy_adapter.call(current_user, table: table) - rescue ArgumentError => fallback_error - raise fallback_error unless policy_signature_mismatch?(fallback_error) - - begin - @config.policy_adapter.call(current_user) - rescue StandardError => e - return handle_policy_failure("Policy adapter failed: #{e.message}") if policy_adapter_fail_open? - - raise policy_failure("Policy adapter failed: #{e.message}") - end - rescue StandardError => e - return handle_policy_failure("Policy adapter failed: #{e.message}") if policy_adapter_fail_open? - - raise policy_failure("Policy adapter failed: #{e.message}") - end + PolicyAdapterInvoker.call(@config.policy_adapter, current_user, table: table, intent: intent) rescue StandardError => e return handle_policy_failure("Policy adapter failed: #{e.message}") if policy_adapter_fail_open? @@ -149,10 +124,6 @@ def direct_predicate_hash?(policy_info) end end - def policy_signature_mismatch?(error) - error.message.match?(/unknown keyword.*intent|wrong number of arguments|no keywords accepted/) - end - def policy_adapter_fail_open? @config.respond_to?(:policy_adapter_fail_open) && @config.policy_adapter_fail_open end diff --git a/lib/code_to_query/policy_adapter_invoker.rb b/lib/code_to_query/policy_adapter_invoker.rb new file mode 100644 index 0000000..7f141bb --- /dev/null +++ b/lib/code_to_query/policy_adapter_invoker.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module CodeToQuery + # Invokes policy adapters once, selecting a compatible keyword or positional + # context shape before invocation so adapter exceptions can never alter dispatch. + class PolicyAdapterInvoker + class << self + def call(adapter, current_user, table:, intent: nil) + context = available_context(table: table, intent: intent) + parameters = callable_parameters(adapter) + keywords = supported_keywords(parameters, context) + return adapter.call(current_user, **keywords) unless keywords.empty? + return adapter.call(current_user, context) if accepts_positional_context?(parameters) + + adapter.call(current_user) + end + + private + + def available_context(table:, intent:) + { table: table }.tap do |context| + context[:intent] = intent unless intent.nil? + end + end + + def supported_keywords(parameters, context) + return context if parameters.any? { |type, _name| type == :keyrest } + + keyword_names = parameters.filter_map do |type, name| + name if %i[key keyreq].include?(type) + end + context.slice(*keyword_names) + end + + def accepts_positional_context?(parameters) + return true if parameters.any? { |type, _name| type == :rest } + + parameters.count { |type, _name| %i[req opt].include?(type) } >= 2 + end + + def callable_parameters(adapter) + return adapter.parameters if adapter.is_a?(Proc) || adapter.is_a?(Method) + + adapter.method(:call).parameters + end + end + end +end diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index c1d419a..abe6883 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -241,27 +241,7 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) end def safe_call_policy_adapter(adapter, current_user, table:, intent:) - adapter.call(current_user, table: table, intent: intent) - rescue ArgumentError => e - raise unless policy_signature_mismatch?(e) - - begin - adapter.call(current_user, table: table) - rescue ArgumentError => fallback_error - raise fallback_error unless policy_signature_mismatch?(fallback_error) - - begin - adapter.call(current_user) - rescue StandardError => e - return handle_policy_failure("Policy adapter failed: #{e.message}") if policy_adapter_fail_open? - - raise CodeToQuery::PolicyAdapterError, "Policy adapter failed: #{e.message}" - end - rescue StandardError => e - return handle_policy_failure("Policy adapter failed: #{e.message}") if policy_adapter_fail_open? - - raise CodeToQuery::PolicyAdapterError, "Policy adapter failed: #{e.message}" - end + PolicyAdapterInvoker.call(adapter, current_user, table: table, intent: intent) rescue StandardError => e if policy_adapter_fail_open? CodeToQuery.config.logger.warn("[code_to_query] Policy adapter failed: #{e.message}") @@ -271,10 +251,6 @@ def safe_call_policy_adapter(adapter, current_user, table:, intent:) raise CodeToQuery::PolicyAdapterError, "Policy adapter failed: #{e.message}" end - def policy_signature_mismatch?(error) - error.message.match?(/unknown keyword.*intent|wrong number of arguments|no keywords accepted/) - end - def policy_table_allowed?(table, allowed) return table.to_s.casecmp?(allowed.to_s) if CodeToQuery.config.adapter.to_sym == :sqlite diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index 64fdca4..ee99c67 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -803,6 +803,16 @@ expect(result[:bind_spec]).to include(hash_including(key: 'policy_tenant_id', column: 'tenant_id')) end + it 'supports legacy policy adapters that accept only current user' do + config.policy_adapter = ->(user) { { enforced_predicates: { tenant_id: user.fetch(:tenant_id) } } } + allow(config.policy_adapter).to receive(:call).and_call_original + + result = compiler.compile(intent, current_user: { tenant_id: 42 }) + + expect(result[:params]).to include('policy_tenant_id' => 42) + expect(config.policy_adapter).to have_received(:call).once + end + it 'fails closed when the adapter raises' do config.policy_adapter = ->(_user, **) { raise 'policy service unavailable' } @@ -867,6 +877,21 @@ ) end + ['wrong number of arguments', 'unknown keyword: :intent'].each do |message| + it "invokes an intent-aware adapter once and fails closed when it internally raises #{message.inspect}" do + calls = 0 + config.policy_adapter = lambda do |_user, table:, intent:| + calls += 1 + raise ArgumentError, message if table == 'orders' && intent + end + + expect { compiler.compile(intent) }.to raise_error( + CodeToQuery::PolicyAdapterError, /Policy adapter failed: #{Regexp.escape(message)}/ + ) + expect(calls).to eq(1) + end + end + it 'documents explicit availability mode by allowing fail open only when configured' do config.policy_adapter_fail_open = true config.policy_adapter = ->(_user, **) { raise 'policy service unavailable' } diff --git a/spec/code_to_query/policy_adapter_invoker_spec.rb b/spec/code_to_query/policy_adapter_invoker_spec.rb new file mode 100644 index 0000000..f781e03 --- /dev/null +++ b/spec/code_to_query/policy_adapter_invoker_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CodeToQuery::PolicyAdapterInvoker do + let(:current_user) { { id: 7 } } + let(:intent) { { 'table' => 'orders' } } + + def invoke(adapter) + described_class.call(adapter, current_user, table: 'orders', intent: intent) + end + + def adapter_forms(implementation) + proc_adapter = implementation + + method_owner = Object.new + method_owner.define_singleton_method(:call, &implementation) + + callable_class = Class.new do + define_method(:call, &implementation) + end + + { + proc: proc_adapter, + method: method_owner.method(:call), + callable_object: callable_class.new + } + end + + it 'passes table and intent to explicit keyword adapters in every callable form' do + implementation = proc { |user, table:, intent:| [user, table, intent] } + adapter_forms(implementation).each do |form, candidate| + expect(invoke(candidate)).to eq([current_user, 'orders', intent]), form.to_s + end + end + + it 'passes the complete context to keyrest adapters in every callable form' do + implementation = proc { |user, **context| [user, context] } + adapter_forms(implementation).each do |form, candidate| + expect(invoke(candidate)).to eq([current_user, { table: 'orders', intent: intent }]), form.to_s + end + end + + it 'passes context as a positional hash to rest adapters in every callable form' do + implementation = proc { |*args| args } + adapter_forms(implementation).each do |form, candidate| + expect(invoke(candidate)).to eq([current_user, { table: 'orders', intent: intent }]), form.to_s + end + end + + it 'passes context as a positional hash to legacy optional-options adapters in every callable form' do + implementation = proc { |user, options = {}| [user, options] } + adapter_forms(implementation).each do |form, candidate| + expect(invoke(candidate)).to eq([current_user, { table: 'orders', intent: intent }]), form.to_s + end + end + + it 'selects only table for table-only keyword adapters in every callable form' do + implementation = proc { |user, table:| [user, table] } + adapter_forms(implementation).each do |form, candidate| + expect(invoke(candidate)).to eq([current_user, 'orders']), form.to_s + end + end + + it 'passes only current user to user-only adapters in every callable form' do + implementation = proc { |user| user } + adapter_forms(implementation).each do |form, candidate| + expect(invoke(candidate)).to eq(current_user), form.to_s + end + end + + it 'invokes each adapter only once when it raises a signature-like ArgumentError internally' do + calls = Hash.new(0) + current_form = nil + + implementation = proc do |_user, table:, intent:| + calls[current_form] += 1 + raise ArgumentError, 'wrong number of arguments' if table == 'orders' && intent + end + + adapter_forms(implementation).each do |form, candidate| + current_form = form + + expect { invoke(candidate) }.to raise_error(ArgumentError, 'wrong number of arguments') + expect(calls[form]).to eq(1) + end + end +end diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index 714e3c7..8060adb 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -422,8 +422,11 @@ end it 'falls back to simpler call signature' do + allow(adapter).to receive(:call).and_call_original + result = validator.send(:safe_call_policy_adapter, adapter, nil, table: 'orders', intent: {}) expect(result[:allowed_tables]).to eq(['orders']) + expect(adapter).to have_received(:call).once end end @@ -434,6 +437,7 @@ it 'falls back to current-user-only call signature under fail-closed default' do CodeToQuery.config.policy_adapter_fail_open = false + allow(adapter).to receive(:call).and_call_original result = validator.send( :safe_call_policy_adapter, @@ -444,6 +448,7 @@ ) expect(result[:allowed_tables]).to eq(['accounts']) + expect(adapter).to have_received(:call).once end end @@ -470,5 +475,22 @@ expect(result).to eq({}) end end + + context 'when a compatible adapter raises a signature-like ArgumentError internally' do + ['wrong number of arguments', 'unknown keyword: :intent'].each do |message| + it "invokes the adapter once and fails closed for #{message.inspect}" do + calls = 0 + adapter = lambda do |_user, table:, intent:| + calls += 1 + raise ArgumentError, message if table == 'users' && intent + end + + expect do + validator.send(:safe_call_policy_adapter, adapter, nil, table: 'users', intent: {}) + end.to raise_error(CodeToQuery::PolicyAdapterError, /Policy adapter failed: #{Regexp.escape(message)}/) + expect(calls).to eq(1) + end + end + end end end From a558cce1dbee6f928617ef3d8eb2aab434c3ce67 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 09:12:07 -0500 Subject: [PATCH 58/86] fix: close query policy execution gaps --- lib/code_to_query/query.rb | 68 ++++++++++++++++--- lib/code_to_query/validator.rb | 11 ++++ spec/code_to_query/query_spec.rb | 93 ++++++++++++++++++++++++++ spec/code_to_query/validator_spec.rb | 97 ++++++++++++++++++++++++++++ 4 files changed, 260 insertions(+), 9 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index a1b6f05..e7007cf 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -9,18 +9,37 @@ module CodeToQuery class Query - attr_reader :sql, :params, :intent, :metrics - def initialize(sql:, params:, bind_spec:, intent:, allow_tables:, config:) - @sql = sql - @params = normalize_params_with_between_defaults(params || {}, intent['filters']) - @bind_spec = bind_spec || [] - @intent = intent || {} - @allow_tables = allow_tables + copied_intent = deep_copy(intent || {}) + copied_params = deep_copy(params || {}) + + @sql = deep_freeze(deep_copy(sql)) + @params = deep_freeze(normalize_params_with_between_defaults(copied_params, copied_intent['filters'])) + @bind_spec = deep_freeze(deep_copy(bind_spec || [])) + @intent = deep_freeze(copied_intent) + @allow_tables = deep_freeze(deep_copy(allow_tables)) @config = config @safety_checked = false @safety_result = nil - @metrics = extract_metrics_from_intent(@intent) + @metrics = deep_freeze(extract_metrics_from_intent(@intent)) + end + + # Return mutable copies for backwards compatibility without exposing the + # immutable state used by safety checks and execution. + def sql + deep_copy(@sql) + end + + def params + deep_copy(@params) + end + + def intent + deep_copy(@intent) + end + + def metrics + deep_copy(@metrics) end def binds @@ -138,7 +157,7 @@ def to_relation! def preview { - sql: @sql, + sql: deep_copy(@sql), params: preview_params, applied_policies: applied_policy_keys, estimated_cost: nil, @@ -148,12 +167,43 @@ def preview def run CodeToQuery::Instrumentation.instrument(:run, telemetry_payload) do + raise SecurityError, 'Query failed safety checks at execution boundary' unless perform_safety_checks + Runner.new(@config).run(sql: @sql, binds: binds) end end private + def deep_copy(value) + case value + when Hash + value.each_with_object({}) { |(key, item), copy| copy[deep_copy(key)] = deep_copy(item) } + when Array + value.map { |item| deep_copy(item) } + when Symbol, Numeric, true, false, nil + value + else + value.dup + end + rescue TypeError + value + end + + def deep_freeze(value) + case value + when Hash + value.each do |key, item| + deep_freeze(key) + deep_freeze(item) + end + when Array + value.each { |item| deep_freeze(item) } + end + + value.freeze + end + def telemetry_payload { table: @intent['table'], diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index abe6883..08d7cc4 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -213,6 +213,10 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) if %w[exists not_exists].include?(op) related_table = fetch_value(f, :related_table) rel_cols = normalized[related_table.to_s.downcase] + + ensure_policy_column_allowed!(fetch_value(f, :fk_column), related_table, rel_cols) + ensure_policy_column_allowed!(fetch_value(f, :base_column), main_table, normalized[main_table]) + next if rel_cols.nil? || rel_cols.empty? Array(fetch_value(f, :related_filters)).each do |rf| @@ -240,6 +244,13 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) raise ArgumentError, e.message end + def ensure_policy_column_allowed!(column, table, allowed_columns) + return if allowed_columns.nil? || allowed_columns.empty? + return if allowed_columns.include?(column.to_s.downcase) + + raise ArgumentError, "Invalid intent: column '#{column}' not permitted on '#{table}'" + end + def safe_call_policy_adapter(adapter, current_user, table:, intent:) PolicyAdapterInvoker.call(adapter, current_user, table: table, intent: intent) rescue StandardError => e diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 9dfc1e3..2a66d5c 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -104,6 +104,21 @@ def build_subquery_policy_query(config) it 'returns the SQL string' do expect(query.sql).to eq(sql) end + + it 'keeps internal SQL isolated from constructor and accessor mutations' do + mutable_sql = sql.dup + q = described_class.new( + sql: mutable_sql, params: params, bind_spec: bind_spec, intent: intent, + allow_tables: ['users'], config: config + ) + expect(q.safe?).to be true + + mutable_sql.replace('DROP TABLE users') + returned_sql = q.sql + returned_sql.replace('DROP TABLE users') + + expect(q.sql).to eq(sql) + end end describe '#params' do @@ -111,6 +126,20 @@ def build_subquery_policy_query(config) expect(query.params).to eq(params) end + it 'keeps internal nested parameters isolated from constructor and accessor mutations' do + nested_params = { 'filters' => [{ 'value' => 'active'.dup }] } + q = described_class.new( + sql: sql, params: nested_params, bind_spec: [], intent: intent, + allow_tables: ['users'], config: config + ) + + nested_params['filters'].first['value'].replace('tampered') + returned_params = q.params + returned_params['filters'].first['value'].replace('tampered') + + expect(q.params.dig('filters', 0, 'value')).to eq('active') + end + it 'adds between defaults derived from column names for legacy start/end params' do q = described_class.new( sql: 'SELECT * FROM "orders" WHERE "created_at" BETWEEN $1 AND $2', @@ -192,6 +221,26 @@ def build_subquery_policy_query(config) end end + describe '#intent' do + it 'keeps internal nested intent isolated from constructor and accessor mutations' do + mutable_intent = { + 'table' => 'users'.dup, + 'type' => 'select', + 'filters' => [{ 'column' => 'active'.dup, 'op' => '=' }] + } + q = described_class.new( + sql: sql, params: params, bind_spec: bind_spec, intent: mutable_intent, + allow_tables: ['users'], config: config + ) + + mutable_intent['filters'].first['column'].replace('admin') + returned_intent = q.intent + returned_intent['filters'].first['column'].replace('admin') + + expect(q.intent.dig('filters', 0, 'column')).to eq('active') + end + end + describe '#binds' do it 'preserves false values stored under string keys' do mock_connection = double('Connection') @@ -1236,6 +1285,28 @@ class << self end describe '#run' do + it 'executes the protected SQL after constructor and accessor values are mutated' do + mutable_sql = sql.dup + mutable_intent = intent.transform_values { |value| value.is_a?(String) ? value.dup : value } + mutable_allow_tables = ['users'.dup] + q = described_class.new( + sql: mutable_sql, params: params, bind_spec: [], intent: mutable_intent, + allow_tables: mutable_allow_tables, config: config + ) + runner = instance_double(CodeToQuery::Runner, run: :result) + allow(CodeToQuery::Runner).to receive(:new).with(config).and_return(runner) + + expect(q.safe?).to be true + mutable_sql.replace('DROP TABLE users') + mutable_intent['table'].replace('admins') + mutable_allow_tables.first.replace('admins') + q.sql.replace('DROP TABLE users') + q.intent['table'].replace('admins') + + expect(q.run).to eq(:result) + expect(runner).to have_received(:run).with(sql: sql, binds: []) + end + it 'emits run instrumentation and delegates to Runner' do events = [] subscriber = ActiveSupport::Notifications.subscribe('code_to_query.run') do |_name, _started, _finished, _id, payload| @@ -1261,6 +1332,28 @@ class << self ensure ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber end + + it 're-runs safety checks at the execution boundary instead of trusting the cached result' do + allow(query).to receive(:perform_safety_checks).and_return(true, false) + allow(CodeToQuery::Runner).to receive(:new) + + expect(query.safe?).to be true + + expect { query.run }.to raise_error(SecurityError, /failed safety checks/) + expect(query).to have_received(:perform_safety_checks).twice + expect(CodeToQuery::Runner).not_to have_received(:new) + end + + it 'fails closed when an unsafe query is run directly' do + unsafe_query = described_class.new( + sql: 'DROP TABLE users', params: {}, bind_spec: [], intent: intent, + allow_tables: ['users'], config: config + ) + allow(CodeToQuery::Runner).to receive(:new) + + expect { unsafe_query.run }.to raise_error(SecurityError, /failed safety checks/) + expect(CodeToQuery::Runner).not_to have_received(:new) + end end describe '#preview' do diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index 8060adb..03d469d 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -180,6 +180,103 @@ end end + context 'with policy column restrictions on related subqueries' do + before do + config.policy_adapter = lambda do |_user, **_kwargs| + { + allowed_tables: %w[users orders], + allowed_columns: { + 'users' => %w[id email], + 'orders' => %w[user_id status] + } + } + end + end + + def related_subquery_intent(operator, fk_column: 'user_id', base_column: 'id') + { + 'type' => 'select', + 'table' => 'users', + 'columns' => ['email'], + 'filters' => [ + { + 'op' => operator, + 'related_table' => 'orders', + 'fk_column' => fk_column, + 'base_column' => base_column, + 'related_filters' => [ + { 'column' => 'status', 'op' => '=', 'param' => 'status' } + ] + } + ] + } + end + + %w[exists not_exists].each do |op| + it "allows #{op} when both correlated columns are policy-permitted" do + result = validator.validate(related_subquery_intent(op)) + + expect(result[:filters].first).to include(fk_column: 'user_id', base_column: 'id') + end + + it "rejects #{op} when fk_column is not permitted on the related table" do + expect do + validator.validate(related_subquery_intent(op, fk_column: 'secret_user_id')) + end.to raise_error(ArgumentError, /column 'secret_user_id' not permitted on 'orders'/) + end + + it "rejects #{op} when base_column is not permitted on the main table" do + expect do + validator.validate(related_subquery_intent(op, base_column: 'secret_id')) + end.to raise_error(ArgumentError, /column 'secret_id' not permitted on 'users'/) + end + + it "uses and checks the default id base_column for #{op} when it is omitted" do + subquery_intent = related_subquery_intent(op) + subquery_intent['filters'].first.delete('base_column') + + result = validator.validate(subquery_intent) + + expect(result[:filters].first[:base_column]).to eq('id') + end + + it "rejects #{op} when the omitted base_column defaults to a disallowed id" do + config.policy_adapter = lambda do |_user, **_kwargs| + { + allowed_tables: %w[users orders], + allowed_columns: { + 'users' => ['email'], + 'orders' => %w[user_id status] + } + } + end + subquery_intent = related_subquery_intent(op) + subquery_intent['filters'].first.delete('base_column') + + expect do + validator.validate(subquery_intent) + end.to raise_error(ArgumentError, /column 'id' not permitted on 'users'/) + end + + it "does not restrict #{op} correlation columns for nil or empty per-table lists" do + config.policy_adapter = lambda do |_user, **_kwargs| + { + allowed_tables: %w[users orders], + allowed_columns: { 'users' => nil, 'orders' => [] } + } + end + + result = validator.validate( + related_subquery_intent(op, fk_column: 'legacy_user_key', base_column: 'legacy_id') + ) + + expect(result[:filters].first).to include( + fk_column: 'legacy_user_key', base_column: 'legacy_id' + ) + end + end + end + context 'with order clause' do it 'validates order clause' do intent = { From e87a48c0c981ca2a880c7707a9e69ec65077a262 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 09:18:48 -0500 Subject: [PATCH 59/86] style: satisfy query class length gate --- lib/code_to_query/query.rb | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index e7007cf..56240db 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -26,21 +26,13 @@ def initialize(sql:, params:, bind_spec:, intent:, allow_tables:, config:) # Return mutable copies for backwards compatibility without exposing the # immutable state used by safety checks and execution. - def sql - deep_copy(@sql) - end + def sql = deep_copy(@sql) - def params - deep_copy(@params) - end + def params = deep_copy(@params) - def intent - deep_copy(@intent) - end + def intent = deep_copy(@intent) - def metrics - deep_copy(@metrics) - end + def metrics = deep_copy(@metrics) def binds return [] unless defined?(ActiveRecord::Base) From 31e51a309e77ebf6b05e5525f4181ad6dfc85937 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 13:57:09 -0500 Subject: [PATCH 60/86] fix: enforce adapter-aware column casing --- lib/code_to_query/validator.rb | 50 ++++++++++++++-------- spec/code_to_query/validator_spec.rb | 64 ++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index 08d7cc4..30cb224 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -164,19 +164,22 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) allowed_columns = fetch_value(policy_info, :allowed_columns) || {} return if allowed_columns.nil? || allowed_columns.empty? - # Normalize map keys to strings with lowercase table and column names + # Preserve identifier case because PostgreSQL quotes every identifier. + # Quoted PostgreSQL columns are distinct by case, while MySQL and SQLite + # column identifiers remain case-insensitive. normalized = {} allowed_columns.each do |tbl, cols| - normalized[tbl.to_s.downcase] = Array(cols).map { |c| c.to_s.downcase } + normalized[tbl.to_s] = Array(cols).map(&:to_s) end - main_table = fetch_value(intent, :table).to_s.downcase + main_table = fetch_value(intent, :table).to_s + main_columns = policy_columns_for_table(normalized, main_table) # Columns in SELECT Array(fetch_value(intent, :columns)).each do |col| next if col == '*' - next unless normalized[main_table]&.any? - unless normalized[main_table].include?(col.to_s.downcase) + next unless main_columns&.any? + unless policy_column_allowed?(col, main_columns) raise ArgumentError, "Invalid intent: selecting column '#{col}' not permitted on '#{main_table}'" end end @@ -185,24 +188,24 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) Array(fetch_value(intent, :order)).each do |o| col = fetch_value(o, :column) next if col.nil? - next unless normalized[main_table]&.any? - unless normalized[main_table].include?(col.to_s.downcase) + next unless main_columns&.any? + unless policy_column_allowed?(col, main_columns) raise ArgumentError, "Invalid intent: ordering by column '#{col}' not permitted on '#{main_table}'" end end # DISTINCT ON columns Array(fetch_value(intent, :distinct_on)).each do |col| - next unless normalized[main_table]&.any? - unless normalized[main_table].include?(col.to_s.downcase) + next unless main_columns&.any? + unless policy_column_allowed?(col, main_columns) raise ArgumentError, "Invalid intent: distinct_on column '#{col}' not permitted on '#{main_table}'" end end # GROUP BY Array(fetch_value(intent, :group_by)).each do |col| - next unless normalized[main_table]&.any? - unless normalized[main_table].include?(col.to_s.downcase) + next unless main_columns&.any? + unless policy_column_allowed?(col, main_columns) raise ArgumentError, "Invalid intent: group_by column '#{col}' not permitted on '#{main_table}'" end end @@ -212,17 +215,17 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) op = fetch_value(f, :op).to_s if %w[exists not_exists].include?(op) related_table = fetch_value(f, :related_table) - rel_cols = normalized[related_table.to_s.downcase] + rel_cols = policy_columns_for_table(normalized, related_table) ensure_policy_column_allowed!(fetch_value(f, :fk_column), related_table, rel_cols) - ensure_policy_column_allowed!(fetch_value(f, :base_column), main_table, normalized[main_table]) + ensure_policy_column_allowed!(fetch_value(f, :base_column), main_table, main_columns) next if rel_cols.nil? || rel_cols.empty? Array(fetch_value(f, :related_filters)).each do |rf| col = fetch_value(rf, :column) next if col.nil? - unless rel_cols.include?(col.to_s.downcase) + unless policy_column_allowed?(col, rel_cols) raise ArgumentError, "Invalid intent: filter column '#{col}' not permitted on '#{related_table}'" end end @@ -230,9 +233,9 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) col = fetch_value(f, :column) next if col.nil? - cols = normalized[main_table] + cols = main_columns next if cols.nil? || cols.empty? - unless cols.include?(col.to_s.downcase) + unless policy_column_allowed?(col, cols) raise ArgumentError, "Invalid intent: filter column '#{col}' not permitted on '#{main_table}'" end end @@ -246,11 +249,24 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) def ensure_policy_column_allowed!(column, table, allowed_columns) return if allowed_columns.nil? || allowed_columns.empty? - return if allowed_columns.include?(column.to_s.downcase) + return if policy_column_allowed?(column, allowed_columns) raise ArgumentError, "Invalid intent: column '#{column}' not permitted on '#{table}'" end + def policy_columns_for_table(allowed_columns, table) + entry = allowed_columns.find { |allowed_table, _columns| policy_table_allowed?(table, allowed_table) } + entry&.last + end + + def policy_column_allowed?(column, allowed_columns) + if %i[postgres postgresql].include?(CodeToQuery.config.adapter.to_sym) + return allowed_columns.include?(column.to_s) + end + + allowed_columns.any? { |allowed| allowed.casecmp?(column.to_s) } + end + def safe_call_policy_adapter(adapter, current_user, table:, intent:) PolicyAdapterInvoker.call(adapter, current_user, table: table, intent: intent) rescue StandardError => e diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index 03d469d..6ba079b 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -277,6 +277,70 @@ def related_subquery_intent(operator, fk_column: 'user_id', base_column: 'id') end end + context 'with adapter-specific policy column casing' do + let(:column_policy) do + { + allowed_tables: %w[users orders], + allowed_columns: { + 'users' => %w[id UserCode], + 'orders' => %w[user_id User_ID status StatusCode] + } + } + end + let(:related_intent) do + { + 'type' => 'select', 'table' => 'users', 'columns' => ['UserCode'], + 'filters' => [{ + 'op' => 'exists', 'related_table' => 'orders', + 'fk_column' => 'user_id', 'base_column' => 'id', + 'related_filters' => [{ 'column' => 'StatusCode', 'op' => '=', 'param' => 'status' }] + }] + } + end + + before do + config.policy_adapter = ->(_user, **_kwargs) { column_policy } + end + + it 'allows distinct exact-case PostgreSQL columns' do + expect { validator.validate(related_intent) }.not_to raise_error + + mixed_case_fk = related_intent.dup + mixed_case_fk['filters'] = related_intent['filters'].map { |filter| filter.merge('fk_column' => 'User_ID') } + expect { validator.validate(mixed_case_fk) }.not_to raise_error + end + + { + 'selected column' => ->(intent) { intent['columns'] = ['usercode'] }, + 'main-table filter column' => lambda do |intent| + intent['filters'] = [{ 'column' => 'usercode', 'op' => '=', 'param' => 'code' }] + end, + 'related fk_column' => ->(intent) { intent['filters'].first['fk_column'] = 'USER_ID' }, + 'main-table base_column' => ->(intent) { intent['filters'].first['base_column'] = 'ID' }, + 'related filter column' => ->(intent) { intent['filters'].first['related_filters'].first['column'] = 'statuscode' } + }.each do |path, change_case| + it "rejects a case-only PostgreSQL mismatch in the #{path}" do + intent = Marshal.load(Marshal.dump(related_intent)) + change_case.call(intent) + + expect { validator.validate(intent) }.to raise_error(ArgumentError, /not permitted/) + end + end + + %i[mysql sqlite].each do |adapter| + it "retains #{adapter} case-insensitive column semantics" do + config.adapter = adapter + intent = Marshal.load(Marshal.dump(related_intent)) + intent['columns'] = ['usercode'] + intent['filters'].first['fk_column'] = 'USER_ID' + intent['filters'].first['base_column'] = 'ID' + intent['filters'].first['related_filters'].first['column'] = 'statuscode' + + expect { validator.validate(intent) }.not_to raise_error + end + end + end + context 'with order clause' do it 'validates order clause' do intent = { From 3d345c7a63e396f74ebd12a6dad36fa167fadc2f Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 14:04:33 -0500 Subject: [PATCH 61/86] fix: close policy column casing gaps --- lib/code_to_query/validator.rb | 12 +++++++++++- spec/code_to_query/validator_spec.rb | 21 ++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index 30cb224..f06fe81 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -210,6 +210,16 @@ def enforce_allowlists!(intent, current_user:, allow_tables:) end end + # Aggregation columns + Array(fetch_value(intent, :aggregations)).each do |aggregation| + col = fetch_value(aggregation, :column) + next if col.nil? + next unless main_columns&.any? + unless policy_column_allowed?(col, main_columns) + raise ArgumentError, "Invalid intent: aggregation column '#{col}' not permitted on '#{main_table}'" + end + end + # WHERE filters Array(fetch_value(intent, :filters)).each do |f| op = fetch_value(f, :op).to_s @@ -279,7 +289,7 @@ def safe_call_policy_adapter(adapter, current_user, table:, intent:) end def policy_table_allowed?(table, allowed) - return table.to_s.casecmp?(allowed.to_s) if CodeToQuery.config.adapter.to_sym == :sqlite + return table.to_s.casecmp?(allowed.to_s) if %i[mysql sqlite].include?(CodeToQuery.config.adapter.to_sym) table.to_s == allowed.to_s end diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index 6ba079b..ab50353 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -282,7 +282,7 @@ def related_subquery_intent(operator, fk_column: 'user_id', base_column: 'id') { allowed_tables: %w[users orders], allowed_columns: { - 'users' => %w[id UserCode], + 'users' => %w[id UserCode PublicTotal], 'orders' => %w[user_id User_ID status StatusCode] } } @@ -312,6 +312,14 @@ def related_subquery_intent(operator, fk_column: 'user_id', base_column: 'id') { 'selected column' => ->(intent) { intent['columns'] = ['usercode'] }, + 'ORDER BY column' => lambda do |intent| + intent['order'] = [{ 'column' => 'usercode', 'dir' => 'asc' }] + end, + 'DISTINCT ON column' => ->(intent) { intent['distinct_on'] = ['usercode'] }, + 'GROUP BY column' => ->(intent) { intent['group_by'] = ['usercode'] }, + 'aggregation column' => lambda do |intent| + intent['aggregations'] = [{ 'type' => 'sum', 'column' => 'publictotal' }] + end, 'main-table filter column' => lambda do |intent| intent['filters'] = [{ 'column' => 'usercode', 'op' => '=', 'param' => 'code' }] end, @@ -339,6 +347,17 @@ def related_subquery_intent(operator, fk_column: 'user_id', base_column: 'id') expect { validator.validate(intent) }.not_to raise_error end end + + it 'uses case-insensitive MySQL policy table keys when enforcing columns' do + config.adapter = :mysql + config.policy_adapter = lambda do |_user, **_kwargs| + { allowed_columns: { 'Users' => ['id'] } } + end + intent = { 'type' => 'select', 'table' => 'users', 'columns' => ['secret'] } + + expect { validator.validate(intent) } + .to raise_error(ArgumentError, /selecting column 'secret' not permitted on 'users'/) + end end context 'with order clause' do From 8b9edde1fcaed1b0360517d4156e39851bb17d5c Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 14:17:48 -0500 Subject: [PATCH 62/86] fix: fail closed on postgres policy table casing --- lib/code_to_query/validator.rb | 12 ++++- spec/code_to_query/validator_spec.rb | 81 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index f06fe81..296ee8d 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -4,6 +4,8 @@ module CodeToQuery class Validator + DENY_ALL_POLICY_COLUMNS = [Object.new.freeze].freeze + IntentSchema = Dry::Schema.Params do required(:type).filled(:string) required(:table).filled(:string) @@ -266,7 +268,15 @@ def ensure_policy_column_allowed!(column, table, allowed_columns) def policy_columns_for_table(allowed_columns, table) entry = allowed_columns.find { |allowed_table, _columns| policy_table_allowed?(table, allowed_table) } - entry&.last + return entry.last if entry + + return unless %i[postgres postgresql].include?(CodeToQuery.config.adapter.to_sym) + return unless allowed_columns.any? { |allowed_table, _columns| table.to_s.casecmp?(allowed_table) } + + # PostgreSQL quotes identifiers, so a case-only policy key names a + # different table. Treat that near-match as an explicit denial rather + # than as an absent partial-policy entry. + DENY_ALL_POLICY_COLUMNS end def policy_column_allowed?(column, allowed_columns) diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index ab50353..526f9b4 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -358,6 +358,87 @@ def related_subquery_intent(operator, fk_column: 'user_id', base_column: 'id') expect { validator.validate(intent) } .to raise_error(ArgumentError, /selecting column 'secret' not permitted on 'users'/) end + + context 'with a case-only PostgreSQL policy table-key mismatch' do + before do + config.policy_adapter = lambda do |_user, **_kwargs| + { allowed_columns: { 'Users' => ['id'] } } + end + end + + { + 'selected column' => ->(intent) { intent['columns'] = ['id'] }, + 'filter column' => lambda do |intent| + intent['filters'] = [{ 'column' => 'id', 'op' => '=', 'param' => 'id' }] + end, + 'ORDER BY column' => lambda do |intent| + intent['order'] = [{ 'column' => 'id', 'dir' => 'asc' }] + end, + 'DISTINCT ON column' => ->(intent) { intent['distinct_on'] = ['id'] }, + 'GROUP BY column' => ->(intent) { intent['group_by'] = ['id'] }, + 'aggregation column' => lambda do |intent| + intent['aggregations'] = [{ 'type' => 'sum', 'column' => 'id' }] + end + }.each do |path, add_column_reference| + it "fails closed for the main-table #{path}" do + intent = { 'type' => 'select', 'table' => 'users', 'columns' => ['*'] } + add_column_reference.call(intent) + + expect { validator.validate(intent) }.to raise_error(ArgumentError, /not permitted on 'users'/) + end + end + + %w[fk_column related_filters].each do |path| + it "fails closed for a related-table #{path} path" do + config.policy_adapter = lambda do |_user, **_kwargs| + { allowed_columns: { 'users' => ['id'], 'Orders' => %w[user_id status] } } + end + intent = { + 'type' => 'select', 'table' => 'users', 'columns' => ['*'], + 'filters' => [{ + 'op' => 'exists', 'related_table' => 'orders', + 'fk_column' => 'user_id', 'base_column' => 'id', + 'related_filters' => [{ 'column' => 'status', 'op' => '=', 'param' => 'status' }] + }] + } + + expect { validator.validate(intent) }.to raise_error(ArgumentError, /not permitted on 'orders'/) + end + end + + it 'fails closed for a main-table base_column path' do + config.policy_adapter = lambda do |_user, **_kwargs| + { allowed_columns: { 'Users' => ['id'], 'orders' => ['user_id'] } } + end + intent = { + 'type' => 'select', 'table' => 'users', 'columns' => ['*'], + 'filters' => [{ + 'op' => 'exists', 'related_table' => 'orders', + 'fk_column' => 'user_id', 'base_column' => 'id' + }] + } + + expect { validator.validate(intent) }.to raise_error(ArgumentError, /not permitted on 'users'/) + end + + it 'preserves partial policies for truly unrelated absent PostgreSQL table keys' do + config.policy_adapter = lambda do |_user, **_kwargs| + { allowed_columns: { 'accounts' => ['id'] } } + end + intent = { 'type' => 'select', 'table' => 'users', 'columns' => ['secret'] } + + expect { validator.validate(intent) }.not_to raise_error + end + + %i[mysql sqlite].each do |adapter| + it "retains #{adapter} case-insensitive table-key behavior" do + config.adapter = adapter + intent = { 'type' => 'select', 'table' => 'users', 'columns' => ['id'] } + + expect { validator.validate(intent) }.not_to raise_error + end + end + end end context 'with order clause' do From c6eb1f77e90b2ba25af80edfc2d9cd7a34fd3b78 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 14:23:10 -0500 Subject: [PATCH 63/86] fix: reject policy table casing before column checks --- lib/code_to_query/validator.rb | 8 +++---- spec/code_to_query/validator_spec.rb | 32 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/lib/code_to_query/validator.rb b/lib/code_to_query/validator.rb index 296ee8d..8bb0f98 100644 --- a/lib/code_to_query/validator.rb +++ b/lib/code_to_query/validator.rb @@ -4,8 +4,6 @@ module CodeToQuery class Validator - DENY_ALL_POLICY_COLUMNS = [Object.new.freeze].freeze - IntentSchema = Dry::Schema.Params do required(:type).filled(:string) required(:table).filled(:string) @@ -274,9 +272,9 @@ def policy_columns_for_table(allowed_columns, table) return unless allowed_columns.any? { |allowed_table, _columns| table.to_s.casecmp?(allowed_table) } # PostgreSQL quotes identifiers, so a case-only policy key names a - # different table. Treat that near-match as an explicit denial rather - # than as an absent partial-policy entry. - DENY_ALL_POLICY_COLUMNS + # different table. Reject the near-match here rather than waiting for a + # column reference, since wildcard and columnless queries have none. + raise ArgumentError, "Invalid intent: policy table key not permitted on '#{table}' due to identifier casing" end def policy_column_allowed?(column, allowed_columns) diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index 526f9b4..fb9579d 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -366,6 +366,22 @@ def related_subquery_intent(operator, fk_column: 'user_id', base_column: 'id') end end + [ + ['a wildcard SELECT', { 'columns' => ['*'] }], + ['an empty SELECT column list', { 'columns' => [] }], + [ + 'a columnless count aggregation', + { 'columns' => [], 'aggregations' => [{ 'type' => 'count' }] } + ] + ].each do |description, attributes| + it "fails closed for #{description} without relying on a column reference" do + intent = { 'type' => 'select', 'table' => 'users' }.merge(attributes) + + expect { validator.validate(intent) } + .to raise_error(ArgumentError, /policy table key not permitted on 'users'/) + end + end + { 'selected column' => ->(intent) { intent['columns'] = ['id'] }, 'filter column' => lambda do |intent| @@ -406,6 +422,22 @@ def related_subquery_intent(operator, fk_column: 'user_id', base_column: 'id') end end + it 'rejects a related-table key mismatch before checking its columns' do + config.policy_adapter = lambda do |_user, **_kwargs| + { allowed_columns: { 'users' => ['id'], 'Orders' => [] } } + end + intent = { + 'type' => 'select', 'table' => 'users', 'columns' => ['*'], + 'filters' => [{ + 'op' => 'exists', 'related_table' => 'orders', + 'fk_column' => 'legacy_user_id', 'base_column' => 'id' + }] + } + + expect { validator.validate(intent) } + .to raise_error(ArgumentError, /policy table key not permitted on 'orders'/) + end + it 'fails closed for a main-table base_column path' do config.policy_adapter = lambda do |_user, **_kwargs| { allowed_columns: { 'Users' => ['id'], 'orders' => ['user_id'] } } From cbc52be3401d8d35a732e69a8f453d9efacf9aca Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 14:51:24 -0500 Subject: [PATCH 64/86] fix: verify policy predicate placement --- lib/code_to_query/query.rb | 11 ++- lib/code_to_query/query/sql_scanning.rb | 97 ++++++++++++++++++++----- spec/code_to_query/query_spec.rb | 84 +++++++++++++++++++++ 3 files changed, 172 insertions(+), 20 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 56240db..5246821 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -354,7 +354,15 @@ def check_policy_compliance bind_keys = Array(@bind_spec).filter_map { |bind| bind[:key]&.to_s } param_keys = @params.keys.map(&:to_s) - expected_keys.all? { |key| bind_keys.include?(key) && param_keys.include?(key) } + return false unless expected_keys.all? { |key| bind_keys.include?(key) && param_keys.include?(key) } + + Array(@bind_spec).each_with_index.all? do |bind, index| + next true unless expected_keys.include?(bind[:key]&.to_s) && !bind[:key].to_s.start_with?('policy_subquery_') + + sql_scanner.policy_predicate_bind?( + @sql, @intent['table'], bind[:column], index + 1, adapter: @config.adapter + ) + end end def policy_predicates_expected? @@ -487,6 +495,7 @@ def policy_bind_present_in_subquery?(subquery, policy_bind_numbers, subqueries, bind = Array(@bind_spec)[number - 1] sql_scanner.policy_predicate_bind_numbers( subquery[:sql], table, bind && bind[:column], + adapter: @config.adapter, question_bind_number: number, source_sql: @sql, source_offset: subquery[:start] ).include?(number) end diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 3a85057..4b9bd53 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -61,32 +61,31 @@ def bind_placeholder_positions(sql) end end + def policy_predicate_bind?(sql, table, column, bind_number, adapter:) + policy_predicate_bind_numbers( + sql, table, column, adapter: adapter, question_bind_number: bind_number, source_sql: sql + ).include?(bind_number) + end + # Identify binds used as values of the expected qualified policy column. - # A placeholder elsewhere in the EXISTS body does not enforce policy. - def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil, + # A placeholder elsewhere in the query does not enforce policy. + def policy_predicate_bind_numbers(sql, table, column, adapter: :postgres, question_bind_number: nil, source_sql: sql, source_offset: 0) return [] if table.to_s.empty? || column.to_s.empty? searchable = mask_sql_literals_and_comments(sql.to_s) - where_match = /\bWHERE\b/i.match(searchable) - return [] unless where_match + where_body, where_offset = top_level_where_body(searchable) + return [] unless where_body - where_body = searchable[where_match.end(0)..] conjuncts = top_level_conjuncts(where_body) return [] unless conjuncts - identifier = lambda do |value| - escaped = Regexp.escape(value.to_s) - # SQL engines commonly accept Unicode letters in unquoted identifiers. - # Treat Unicode letters, marks, numbers, and connector punctuation as - # identifier characters so a trusted name cannot match a suffix or - # prefix of an attacker-controlled identifier. - unquoted = "(?(predicate) { /\A\s*\(*\s*#{predicate}\s*\)*\s*\z/i } - question_pattern = wrappers.call("#{qualified}\\s*(?:=\\s*\\?|BETWEEN\\s*\\?\\s+AND\\s*\\?)") + qualified = "#{policy_identifier_pattern(table, adapter, table: true)}\\s*\\.\\s*" \ + "#{policy_identifier_pattern(column, adapter, table: false)}" + wrappers = ->(predicate) { /\A\s*\(*\s*#{predicate}\s*\)*\s*\z/ } + question_pattern = wrappers.call( + "#{qualified}\\s*(?:=\\s*\\?|(?i:BETWEEN)\\s*\\?\\s+(?i:AND)\\s*\\?)" + ) placeholder_positions = bind_placeholder_positions(source_sql) first_placeholder = placeholder_positions.first question_placeholders = first_placeholder && source_sql.to_s[first_placeholder.first] == '?' @@ -99,7 +98,7 @@ def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil, next unless (match = question_pattern.match(conjunct)) match[0].to_enum(:scan, /\?/).each do - predicate_offset = where_match.end(0) + conjunct_offset + match.begin(0) + predicate_offset = where_offset + conjunct_offset + match.begin(0) local_position = predicate_offset + Regexp.last_match.begin(0) ordinal = placeholder_ordinals[source_offset + local_position] return [ordinal] if ordinal == question_bind_number @@ -108,7 +107,9 @@ def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil, return [] end - pattern = wrappers.call("#{qualified}\\s*(?:=\\s*\\$(\\d+)|BETWEEN\\s*\\$(\\d+)\\s+AND\\s*\\$(\\d+))") + pattern = wrappers.call( + "#{qualified}\\s*(?:=\\s*\\$(\\d+)|(?i:BETWEEN)\\s*\\$(\\d+)\\s+(?i:AND)\\s*\\$(\\d+))" + ) conjuncts.flat_map do |conjunct| match = pattern.match(conjunct) match ? match.captures.compact.map(&:to_i) : [] @@ -117,6 +118,64 @@ def policy_predicate_bind_numbers(sql, table, column, question_bind_number: nil, private + def top_level_where_body(searchable) + structure = mask_sql_literals_comments_and_identifier_contents(searchable) + depth = 0 + where_end = nil + structure.to_enum(:scan, /\bWHERE\b|[()]/i).each do + token = Regexp.last_match(0) + if token == '(' + depth += 1 + elsif token == ')' + return [nil, nil] if depth.zero? + + depth -= 1 + elsif depth.zero? + where_end = Regexp.last_match.end(0) + break + end + end + return [nil, nil] unless where_end + + finish = searchable.length + suffix = structure[where_end..] + suffix.to_enum(:scan, /\b(?:GROUP\s+BY|HAVING|ORDER\s+BY|LIMIT|OFFSET|FETCH|FOR|UNION|INTERSECT|EXCEPT|RETURNING)\b|[()]/i).each do + token = Regexp.last_match(0) + if token == '(' + depth += 1 + elsif token == ')' + return [nil, nil] if depth.zero? + + depth -= 1 + elsif depth.zero? + finish = where_end + Regexp.last_match.begin(0) + break + end + end + return [nil, nil] unless depth.zero? + + [searchable[where_end...finish], where_end] + end + + def policy_identifier_pattern(value, adapter, table:) + escaped = Regexp.escape(value.to_s) + boundary = '[\\p{L}\\p{M}\\p{N}\\p{Pc}$]' + exact_unquoted = "(?(_user, **) { { allowed_tables: ['questions'] } } + + [ + 'SELECT $1 AS leaked_policy_value FROM "questions" WHERE TRUE LIMIT 10', + 'SELECT * FROM "questions" WHERE "questions"."tenant_id" = $1 OR TRUE LIMIT 10', + 'SELECT * FROM "questions" WHERE "tenant_id" = $1 LIMIT 10', + 'SELECT * FROM "questions" WHERE "other"."tenant_id" = $1 LIMIT 10' + ].each do |adversarial_sql| + q = described_class.new( + sql: adversarial_sql, + params: { 'policy_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', 'type' => 'select', + '__policy_expected_keys' => ['policy_tenant_id'] + }, + allow_tables: ['questions'], config: config + ) + + expect(q.safe?).to be(false), "expected to reject #{adversarial_sql.inspect}" + end + ensure + config.policy_adapter = nil + end + + it 'accepts compiler-shaped main-table policy predicates with trailing clauses' do + config.policy_adapter = ->(_user, **) { { allowed_tables: ['questions'] } } + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE "questions"."tenant_id" = $1 ORDER BY "questions"."id" LIMIT 10', + params: { 'policy_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', 'type' => 'select', + '__policy_expected_keys' => ['policy_tenant_id'] + }, + allow_tables: ['questions'], config: config + ) + + expect(q.safe?).to be true + ensure + config.policy_adapter = nil + end + + it 'uses adapter identifier casing semantics for policy predicates' do + policy_adapter = ->(_user, **) { { allowed_tables: ['questions'] } } + cases = { + postgres: 'QUESTIONS.TENANT_ID', + sqlite: '"QUESTIONS"."TENANT_ID"', + mysql: '`questions`.`TENANT_ID`' + } + + cases.each do |adapter, qualified_column| + q = described_class.new( + sql: "SELECT * FROM questions WHERE #{qualified_column} = $1 LIMIT 10", + params: { 'policy_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_tenant_id', column: 'tenant_id', cast: nil }], + intent: { 'table' => 'questions', 'type' => 'select', '__policy_expected_keys' => ['policy_tenant_id'] }, + allow_tables: ['questions'], config: stub_config(adapter: adapter, policy_adapter: policy_adapter) + ) + + expect(q.safe?).to be(true), "expected #{adapter} casing semantics to be honored" + end + end + + it 'rejects a differently cased quoted PostgreSQL policy column' do + config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers" WHERE "answers"."TENANT_ID" = $1)', + params: { 'policy_subquery_1_answers_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_subquery_1_answers_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'questions', 'type' => 'select', + 'filters' => [{ 'column' => 'id', 'op' => 'exists', 'related_table' => 'answers', 'fk_column' => 'question_id' }], + '__policy_expected_keys' => ['policy_subquery_1_answers_tenant_id'] + }, + allow_tables: %w[questions answers], config: config + ) + + expect(q.safe?).to be false + ensure + config.policy_adapter = nil + end + it 'rejects policy binds used only in SELECT or tautological expressions' do config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } From ef9db1b54a02fe520c06fff41526b62f3a5c4db2 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 15:02:58 -0500 Subject: [PATCH 65/86] fix: limit policy identifier folding to ASCII --- lib/code_to_query.rb | 1 + lib/code_to_query/compiler.rb | 4 ++- lib/code_to_query/guardrails/sql_linter.rb | 2 +- lib/code_to_query/identifier_semantics.rb | 29 +++++++++++++++++++ lib/code_to_query/query.rb | 8 ++--- lib/code_to_query/query/sql_scanning.rb | 9 +++--- lib/code_to_query/validator.rb | 13 ++++++--- spec/code_to_query/query/sql_scanning_spec.rb | 17 +++++++++++ spec/code_to_query/query_spec.rb | 13 +++++++++ spec/code_to_query/validator_spec.rb | 14 +++++++++ 10 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 lib/code_to_query/identifier_semantics.rb diff --git a/lib/code_to_query.rb b/lib/code_to_query.rb index a70e831..721b93c 100644 --- a/lib/code_to_query.rb +++ b/lib/code_to_query.rb @@ -12,6 +12,7 @@ require_relative 'code_to_query/configuration' require_relative 'code_to_query/errors' require_relative 'code_to_query/instrumentation' +require_relative 'code_to_query/identifier_semantics' require_relative 'code_to_query/policy_adapter_invoker' require_relative 'code_to_query/providers/base' require_relative 'code_to_query/providers/openai' diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 6a53974..b43a655 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -577,7 +577,9 @@ def merge_policy_allowed_tables!(intent, policy_info, required_table: nil) end def policy_table_allowed?(table, allowed) - return table.to_s.casecmp?(allowed.to_s) if @config.adapter.to_sym == :sqlite + if @config.adapter.to_sym == :sqlite + return IdentifierSemantics.ascii_case_insensitive?(table, allowed) + end table.to_s == allowed.to_s end diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index 34cf265..40340d4 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -220,7 +220,7 @@ def table_allowed?(table) # explicitly available to this legacy linter. return @allow_tables.include?(table.to_s) if @config.adapter.to_sym == :mysql - @allow_tables.any? { |allowed| allowed.casecmp?(table.to_s) } + @allow_tables.any? { |allowed| IdentifierSemantics.ascii_case_insensitive?(allowed, table) } end def check_no_literals!(sql) diff --git a/lib/code_to_query/identifier_semantics.rb b/lib/code_to_query/identifier_semantics.rb new file mode 100644 index 0000000..11dc4c3 --- /dev/null +++ b/lib/code_to_query/identifier_semantics.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module CodeToQuery + # Database identifier folding is deliberately narrower than Ruby's Unicode + # case folding. The supported adapters fold ASCII identifier letters only; + # non-ASCII codepoints remain distinct catalog characters. + module IdentifierSemantics + module_function + + def ascii_fold(value) + value.to_s.tr('A-Z', 'a-z') + end + + def ascii_case_insensitive?(left, right) + ascii_fold(left) == ascii_fold(right) + end + + def ascii_case_insensitive_pattern(value) + value.to_s.each_char.map do |character| + if character.match?(/\A[A-Za-z]\z/) + lower = character.tr('A-Z', 'a-z') + "[#{lower}#{lower.tr('a-z', 'A-Z')}]" + else + Regexp.escape(character) + end + end.join + end + end +end diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 5246821..9bceb51 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -548,19 +548,19 @@ def extract_table_identifiers(sql) # identifiers. SQLite is the exception: it resolves identifiers without # regard to case even when they are quoted. def table_identifier_allowed?(identifier, allowed) - return identifier.name.casecmp?(allowed) if @config.adapter.to_sym == :sqlite + return IdentifierSemantics.ascii_case_insensitive?(identifier.name, allowed) if @config.adapter.to_sym == :sqlite # MySQL table-name case semantics depend on lower_case_table_names and # the host filesystem. Without server metadata, only exact case is safe. return identifier.name == allowed if @config.adapter.to_sym == :mysql return identifier.name == allowed if identifier.quoted - allowed == allowed.downcase && identifier.name.downcase == allowed + allowed == IdentifierSemantics.ascii_fold(allowed) && IdentifierSemantics.ascii_fold(identifier.name) == allowed end def allowlist_names_equivalent?(left, right) - return left.casecmp?(right) if @config.adapter.to_sym == :sqlite + return IdentifierSemantics.ascii_case_insensitive?(left, right) if @config.adapter.to_sym == :sqlite - left == right || (left == left.downcase && right == right.downcase && left.casecmp?(right)) + left == right || (left == IdentifierSemantics.ascii_fold(left) && right == IdentifierSemantics.ascii_fold(right) && IdentifierSemantics.ascii_case_insensitive?(left, right)) end def sql_scanner diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 4b9bd53..de44c8e 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -159,19 +159,20 @@ def top_level_where_body(searchable) def policy_identifier_pattern(value, adapter, table:) escaped = Regexp.escape(value.to_s) + ascii_folded = IdentifierSemantics.ascii_case_insensitive_pattern(value) boundary = '[\\p{L}\\p{M}\\p{N}\\p{Pc}$]' exact_unquoted = "(?(_user, **) { { allowed_tables: ['kids'] } } + q = described_class.new( + sql: 'SELECT * FROM "kids" WHERE "kids"."Kind" = ? LIMIT 10', + params: { 'policy_kind' => 'student' }, + bind_spec: [{ key: 'policy_kind', column: 'kind', cast: nil }], + intent: { 'table' => 'kids', 'type' => 'select', '__policy_expected_keys' => ['policy_kind'] }, + allow_tables: ['kids'], config: stub_config(adapter: :sqlite, policy_adapter: policy_adapter) + ) + + expect(q.safe?).to be false + end + it 'rejects a differently cased quoted PostgreSQL policy column' do config.policy_adapter = ->(_user, **) { { allowed_tables: %w[questions answers] } } q = described_class.new( diff --git a/spec/code_to_query/validator_spec.rb b/spec/code_to_query/validator_spec.rb index fb9579d..1966e19 100644 --- a/spec/code_to_query/validator_spec.rb +++ b/spec/code_to_query/validator_spec.rb @@ -346,6 +346,20 @@ def related_subquery_intent(operator, fk_column: 'user_id', base_column: 'id') expect { validator.validate(intent) }.not_to raise_error end + + it "does not Unicode-case-fold #{adapter} policy identifiers" do + config.adapter = adapter + config.policy_adapter = lambda do |_user, **_kwargs| + { allowed_tables: ['kids'], allowed_columns: { 'kids' => ['kind'] } } + end + + expect do + validator.validate({ 'type' => 'select', 'table' => 'Kids', 'columns' => ['kind'] }) + end.to raise_error(ArgumentError, /not permitted by policy/) + expect do + validator.validate({ 'type' => 'select', 'table' => 'kids', 'columns' => ['Kind'] }) + end.to raise_error(ArgumentError, /not permitted/) + end end it 'uses case-insensitive MySQL policy table keys when enforcing columns' do From c521d1a0791e6d5ad4a5a13a75041b463be5e074 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 15:20:21 -0500 Subject: [PATCH 66/86] fix: qualify fallback base policy predicates --- lib/code_to_query/compiler.rb | 8 +++++ spec/code_to_query/compiler_spec.rb | 50 +++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index b43a655..1ebc35f 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -384,6 +384,7 @@ def build_string_limit_clause(limit) def build_string_filter_fragment(filter, table, bind_spec, params_hash, placeholder_index, current_user, intent) col = quote_ident(filter['column']) + col = "#{quote_ident(table)}.#{col}" if base_policy_filter?(filter, intent) case filter['op'] when '=', '>', '<', '>=', '<=', '!=', '<>' build_string_comparison_fragment(col, filter, bind_spec, placeholder_index) @@ -685,6 +686,13 @@ def filter_bind_key(filter) filter['param'] || filter['column'] end + def base_policy_filter?(filter, intent) + expected_keys = Array(intent['__policy_expected_keys']).map(&:to_s) + filter_keys = [filter['param'], filter['param_start'], filter['param_end']].compact.map(&:to_s) + + filter_keys.any? { |key| expected_keys.include?(key) && !key.start_with?('policy_subquery_') } + end + def between_bind_keys(filter) [ filter['param_start'] || default_between_bind_key(filter, 'start'), diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index ee99c67..ebd5cb3 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -925,12 +925,32 @@ result = compiler.compile(intent) - expect(result[:sql]).to include('WHERE "status" = $1 AND "tenant_id" = $2') + expect(result[:sql]).to include('WHERE "status" = $1 AND "orders"."tenant_id" = $2') expect(result[:params]).to include('status' => 'paid', 'policy_tenant_id' => 42) expect(result[:bind_spec]).to include(hash_including(key: 'status', column: 'status')) expect(result[:bind_spec]).to include(hash_including(key: 'policy_tenant_id', column: 'tenant_id')) end + it 'produces a safe query with adapter-quoted policy identifiers when string building is forced' do + config.policy_adapter = lambda do |_user, **_kwargs| + { allowed_tables: ['AuditEvents'], enforced_predicates: { 'TenantID' => 42 } } + end + allow(compiler).to receive(:use_arel?).and_return(false) + intent = { + 'type' => 'select', 'table' => 'AuditEvents', 'columns' => ['*'], + 'filters' => [], 'limit' => 100, 'params' => {} + } + + result = compiler.compile(intent) + query = CodeToQuery::Query.new( + sql: result[:sql], params: result[:params], bind_spec: result[:bind_spec], + intent: result[:intent], allow_tables: nil, config: config + ) + + expect(result[:sql]).to include('"AuditEvents"."TenantID" = $1') + expect(query.safe?).to be true + end + it 'does not let prompt-sourced filters override tenant predicates' do intent = { 'table' => 'orders', @@ -943,7 +963,7 @@ result = compiler.compile(intent) - expect(result[:sql]).to include('"tenant_id" != $1 AND "tenant_id" = $2') + expect(result[:sql]).to include('"tenant_id" != $1 AND "orders"."tenant_id" = $2') expect(result[:params]).to include('attacker_tenant_id' => 42, 'policy_tenant_id' => 42) end end @@ -1154,13 +1174,37 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil subquery_key = 'policy_subquery_1_answers_tenant_id' expect(result[:sql]).to include('"answers"."tenant_id" = $1') - expect(result[:sql]).to include('"tenant_id" = $2') + expect(result[:sql]).to include('"questions"."tenant_id" = $2') expect(result[:params]['policy_tenant_id']).to eq(42) expect(result[:params][subquery_key]).to eq(7) expect(result[:bind_spec]).to include(hash_including(key: subquery_key, column: :tenant_id)) expect(result[:bind_spec]).to include(hash_including(key: 'policy_tenant_id', column: 'tenant_id')) end + it 'produces a safe EXISTS query with qualified base and related policy predicates' do + config.policy_adapter = lambda do |_user, **kwargs| + case kwargs[:table] + when 'questions' + { allowed_tables: %w[questions answers], enforced_predicates: { tenant_id: 42 } } + when 'answers' + { allowed_tables: ['answers'], enforced_predicates: { tenant_id: 7 } } + else + {} + end + end + allow(compiler).to receive(:use_arel?).and_return(true) + + result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) + query = CodeToQuery::Query.new( + sql: result[:sql], params: result[:params], bind_spec: result[:bind_spec], + intent: result[:intent], allow_tables: nil, config: config + ) + + expect(result[:sql]).to include('"answers"."tenant_id" = $1') + expect(result[:sql]).to include('"questions"."tenant_id" = $2') + expect(query.safe?).to be true + end + it 'uses separate bind keys for multiple related-table policies on the same column name' do config.policy_adapter = lambda do |_user, **kwargs| case kwargs[:table] From 520c9365eeff796200633d23e0d7539f75c95dae Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 15:26:43 -0500 Subject: [PATCH 67/86] test: isolate singleton configuration per example --- spec/support/test_helpers.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/support/test_helpers.rb b/spec/support/test_helpers.rb index d43ada7..96cbf7c 100644 --- a/spec/support/test_helpers.rb +++ b/spec/support/test_helpers.rb @@ -56,4 +56,23 @@ def sample_intent RSpec.configure do |config| config.include TestHelpers + + # Specs share the production singleton, so restore every setting after each example. + config.around do |example| + singleton_config = CodeToQuery::Configuration.instance + original_state = singleton_config.instance_variables.to_h do |variable| + [variable, singleton_config.instance_variable_get(variable)] + end + + example.run + ensure + if singleton_config && original_state + (singleton_config.instance_variables - original_state.keys).each do |variable| + singleton_config.remove_instance_variable(variable) + end + original_state.each do |variable, value| + singleton_config.instance_variable_set(variable, value) + end + end + end end From ce158ed57fd7605f730d8b8195333698c41e6641 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 17:44:51 -0500 Subject: [PATCH 68/86] fix: close query guardrail bypasses --- lib/code_to_query.rb | 3 +- lib/code_to_query/compiler.rb | 10 ++++++ lib/code_to_query/guardrails/sql_linter.rb | 8 +++++ lib/code_to_query/query.rb | 13 +++++-- .../guardrails/sql_linter_spec.rb | 12 +++++++ .../policy_metadata_casing_spec.rb | 3 +- spec/code_to_query/query_spec.rb | 36 +++++++++++++++++-- 7 files changed, 79 insertions(+), 6 deletions(-) diff --git a/lib/code_to_query.rb b/lib/code_to_query.rb index 721b93c..85c908d 100644 --- a/lib/code_to_query.rb +++ b/lib/code_to_query.rb @@ -127,7 +127,8 @@ def self.ask(prompt:, schema: nil, allow_tables: nil, current_user: nil) policy_applied: policy_applied } query = Query.new(sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], - intent: compiled[:intent] || validated_intent, allow_tables: allow_tables, config: config) + intent: compiled[:intent] || validated_intent, allow_tables: allow_tables, config: config, + policy_contract: compiled[:policy_contract]) begin Instrumentation.instrument(:lint, **lint_payload) do diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 1ebc35f..3a86d64 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -11,6 +11,8 @@ module CodeToQuery # rubocop:disable Metrics/ClassLength class Compiler + PolicyContract = Struct.new(:adapter, :expected_keys, keyword_init: true) + def initialize(config) @config = config end @@ -25,6 +27,7 @@ def compile(intent, current_user: nil) compile_with_string_building(intent_with_policy, current_user) end verify_compiled_policy_binds!(result) + result[:policy_contract] = build_policy_contract(result) if @config.policy_adapter.respond_to?(:call) result end @@ -608,6 +611,13 @@ def verify_compiled_policy_binds!(result) raise PolicyAdapterError, "Compiled policy binds are missing: #{missing.join(', ')}" end + def build_policy_contract(result) + PolicyContract.new( + adapter: @config.policy_adapter, + expected_keys: Array(result.dig(:intent, '__policy_expected_keys')).map(&:to_s).uniq.freeze + ).freeze + end + def deep_dup_value(value) case value when Hash diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index 40340d4..23a477c 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -12,6 +12,7 @@ def check!(sql) normalized = sql.to_s.strip.gsub(/\s+/, ' ') check_statement_type!(normalized) + check_unsupported_table_query_expressions!(normalized) check_dangerous_patterns!(normalized) check_required_limit!(normalized) check_table_allowlist!(normalized) if @allow_tables.any? @@ -40,6 +41,13 @@ def check_statement_type!(sql) end end + def check_unsupported_table_query_expressions!(sql) + return unless %i[postgres postgresql].include?(@config.adapter.to_sym) + return unless sql.match?(/\bTABLE\s+(?:ONLY\s+)?(?:"|[a-zA-Z_])/i) + + raise SecurityError, 'PostgreSQL TABLE query expressions are not supported' + end + def check_dangerous_patterns!(sql) patterns = build_dangerous_patterns patterns.each do |pattern| diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 9bceb51..9b06b7a 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -8,8 +8,9 @@ require_relative 'query/sql_scanning' module CodeToQuery + # rubocop:disable Metrics/ClassLength class Query - def initialize(sql:, params:, bind_spec:, intent:, allow_tables:, config:) + def initialize(sql:, params:, bind_spec:, intent:, allow_tables:, config:, policy_contract: nil) copied_intent = deep_copy(intent || {}) copied_params = deep_copy(params || {}) @@ -19,6 +20,7 @@ def initialize(sql:, params:, bind_spec:, intent:, allow_tables:, config:) @intent = deep_freeze(copied_intent) @allow_tables = deep_freeze(deep_copy(allow_tables)) @config = config + @policy_contract = policy_contract @safety_checked = false @safety_result = nil @metrics = deep_freeze(extract_metrics_from_intent(@intent)) @@ -349,7 +351,7 @@ def check_policy_compliance return true unless @config.policy_adapter expected_keys = expected_policy_keys - return true if expected_keys.empty? + return compiler_policy_contract?(expected_keys) if expected_keys.empty? bind_keys = Array(@bind_spec).filter_map { |bind| bind[:key]&.to_s } param_keys = @params.keys.map(&:to_s) @@ -365,6 +367,12 @@ def check_policy_compliance end end + def compiler_policy_contract?(expected_keys) + @policy_contract.is_a?(Compiler::PolicyContract) && + @policy_contract.adapter.equal?(@config.policy_adapter) && + @policy_contract.expected_keys == expected_keys + end + def policy_predicates_expected? expected_policy_keys.any? end @@ -770,4 +778,5 @@ def format_explain_result(result) end end end + # rubocop:enable Metrics/ClassLength end diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 0a78b26..da1017d 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -79,6 +79,18 @@ expect { linter.check!(sql) }.to raise_error(SecurityError, /not in the allowed list/) end + it 'rejects unsupported PostgreSQL TABLE query expressions inside subqueries' do + sql = 'SELECT * FROM "users" WHERE EXISTS (TABLE "admin_secrets") LIMIT 10' + + expect { linter.check!(sql) }.to raise_error(SecurityError, /TABLE query expressions are not supported/) + end + + it 'rejects TABLE query expressions used as nested set-operation operands' do + sql = 'SELECT * FROM "users" WHERE EXISTS (SELECT * FROM "users" UNION TABLE "admin_secrets") LIMIT 10' + + expect { linter.check!(sql) }.to raise_error(SecurityError, /TABLE query expressions are not supported/) + end + context 'with MySQL identifiers' do let(:config) { stub_config(adapter: :mysql, max_limit: 1000, max_joins: 2) } diff --git a/spec/code_to_query/policy_metadata_casing_spec.rb b/spec/code_to_query/policy_metadata_casing_spec.rb index ca1316e..c00186c 100644 --- a/spec/code_to_query/policy_metadata_casing_spec.rb +++ b/spec/code_to_query/policy_metadata_casing_spec.rb @@ -27,7 +27,8 @@ def query_from_policy(table) bind_spec: compiled[:bind_spec], intent: compiled[:intent], allow_tables: nil, - config: config + config: config, + policy_contract: compiled[:policy_contract] ) [validated, compiled, query] diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 728430c..30be859 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -371,10 +371,42 @@ class << self expect(q).to have_received(:perform_safety_checks).once end - it 'returns true for allowlist-only policy adapters with no injected predicates' do + it 'returns true for a compiler-verified allowlist-only policy with no injected predicates' do config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } + compiled = CodeToQuery::Compiler.new(config).compile( + { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 } + ) + q = described_class.new( + sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], + intent: compiled[:intent], allow_tables: ['users'], config: config, + policy_contract: compiled[:policy_contract] + ) - expect(query.safe?).to be true + expect(q.safe?).to be true + ensure + config.policy_adapter = nil + end + + it 'fails closed when a directly constructed query has no compiler policy contract' do + config.policy_adapter = ->(_user, **) { { enforced_predicates: { tenant_id: 42 } } } + + expect(query.safe?).to be false + ensure + config.policy_adapter = nil + end + + it 'fails closed when policy enforcement is enabled after compilation' do + compiled = CodeToQuery::Compiler.new(config).compile( + { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 } + ) + config.policy_adapter = ->(_user, **) { { enforced_predicates: { tenant_id: 42 } } } + q = described_class.new( + sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], + intent: compiled[:intent], allow_tables: ['users'], config: config, + policy_contract: compiled[:policy_contract] + ) + + expect(q.safe?).to be false ensure config.policy_adapter = nil end From decee593ab76ed76aa53335f68a9560a61cfdcfc Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 17:58:32 -0500 Subject: [PATCH 69/86] fix: bind policy evidence to compiled queries --- lib/code_to_query.rb | 4 +- lib/code_to_query/compiler.rb | 82 +++++++- lib/code_to_query/guardrails/sql_linter.rb | 2 +- lib/code_to_query/query.rb | 13 +- lib/code_to_query/query/sql_scanning.rb | 192 +++++++++++++++++ spec/code_to_query/compiler_spec.rb | 11 +- .../guardrails/advanced_sql_linter_spec.rb | 3 +- .../guardrails/sql_linter_spec.rb | 68 ++++++ spec/code_to_query/query/sql_scanning_spec.rb | 72 +++++++ spec/code_to_query/query_spec.rb | 195 +++++++++++++++--- 10 files changed, 590 insertions(+), 52 deletions(-) diff --git a/lib/code_to_query.rb b/lib/code_to_query.rb index 85c908d..82653cf 100644 --- a/lib/code_to_query.rb +++ b/lib/code_to_query.rb @@ -112,7 +112,9 @@ def self.ask(prompt:, schema: nil, allow_tables: nil, current_user: nil) policy_applied: false } compiled = Instrumentation.instrument(:compile, compile_payload) do - compile_result = Compiler.new(config).compile(validated_intent, current_user: current_user) + compile_result = Compiler.new(config).compile( + validated_intent, current_user: current_user, allow_tables: allow_tables + ) compile_payload[:policy_applied] = policy_applied_from_bind_spec?(compile_result[:bind_spec]) compile_result end diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index 3a86d64..d9c5966 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -11,13 +11,28 @@ module CodeToQuery # rubocop:disable Metrics/ClassLength class Compiler - PolicyContract = Struct.new(:adapter, :expected_keys, keyword_init: true) + # Policy evidence is an opaque capability. Its constructor and immutable + # snapshot are private, so caller-supplied metadata can never stand in for + # a successful policy compilation. The issuing adapter is retained by + # reference so its identity cannot be recycled while the capability lives. + class PolicyContract + def initialize(snapshot, policy_adapter) + @snapshot = snapshot + @policy_adapter = policy_adapter + freeze + end + + attr_reader :snapshot, :policy_adapter + private :snapshot, :policy_adapter + private_class_method :new + end + private_constant :PolicyContract def initialize(config) @config = config end - def compile(intent, current_user: nil) + def compile(intent, current_user: nil, allow_tables: nil) working_intent = deep_dup_value(intent) strip_untrusted_policy_expectations!(working_intent) intent_with_policy = apply_policy_predicates(working_intent, current_user) @@ -27,10 +42,64 @@ def compile(intent, current_user: nil) compile_with_string_building(intent_with_policy, current_user) end verify_compiled_policy_binds!(result) - result[:policy_contract] = build_policy_contract(result) if @config.policy_adapter.respond_to?(:call) + result[:policy_contract] = build_policy_contract(result, allow_tables) if @config.policy_adapter.respond_to?(:call) result end + class << self + private + + def valid_policy_contract?(contract, sql:, params:, bind_spec:, intent:, allow_tables:, config:) + return false unless contract.instance_of?(PolicyContract) + return false unless contract.send(:policy_adapter).equal?(config.policy_adapter) + + contract.send(:snapshot) == policy_contract_snapshot( + sql: sql, params: params, bind_spec: bind_spec, intent: intent, + allow_tables: allow_tables, config: config + ) + end + + def issue_policy_contract(result, allow_tables, config) + policy_adapter = config.policy_adapter + snapshot = policy_contract_snapshot( + sql: result[:sql], params: result[:params], bind_spec: result[:bind_spec], + intent: result[:intent], allow_tables: allow_tables, config: config + ) + PolicyContract.send(:new, snapshot, policy_adapter) + end + + def policy_contract_snapshot(sql:, params:, bind_spec:, intent:, allow_tables:, config:) + deep_freeze_contract_value( + sql: sql, + params: params, + bind_spec: bind_spec, + intent: intent, + allow_tables: allow_tables, + config_identity: config.__id__, + adapter: config.adapter, + policy_adapter_fail_open: config.respond_to?(:policy_adapter_fail_open) && config.policy_adapter_fail_open + ) + end + + def deep_freeze_contract_value(value) + copy = case value + when Hash + value.each_with_object({}) do |(key, item), result| + result[deep_freeze_contract_value(key)] = deep_freeze_contract_value(item) + end + when Array + value.map { |item| deep_freeze_contract_value(item) } + when Symbol, Numeric, true, false, nil + value + else + value.dup + end + copy.freeze + rescue TypeError + value + end + end + private def apply_policy_predicates(intent, current_user) @@ -611,11 +680,8 @@ def verify_compiled_policy_binds!(result) raise PolicyAdapterError, "Compiled policy binds are missing: #{missing.join(', ')}" end - def build_policy_contract(result) - PolicyContract.new( - adapter: @config.policy_adapter, - expected_keys: Array(result.dig(:intent, '__policy_expected_keys')).map(&:to_s).uniq.freeze - ).freeze + def build_policy_contract(result, allow_tables) + self.class.send(:issue_policy_contract, result, allow_tables, @config) end def deep_dup_value(value) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index 23a477c..f56dce3 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -43,7 +43,7 @@ def check_statement_type!(sql) def check_unsupported_table_query_expressions!(sql) return unless %i[postgres postgresql].include?(@config.adapter.to_sym) - return unless sql.match?(/\bTABLE\s+(?:ONLY\s+)?(?:"|[a-zA-Z_])/i) + return unless Query::SqlScanner.new.table_query_expression?(sql) raise SecurityError, 'PostgreSQL TABLE query expressions are not supported' end diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 9b06b7a..2ee0b6c 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -351,7 +351,8 @@ def check_policy_compliance return true unless @config.policy_adapter expected_keys = expected_policy_keys - return compiler_policy_contract?(expected_keys) if expected_keys.empty? + return false unless compiler_policy_contract? + return true if expected_keys.empty? bind_keys = Array(@bind_spec).filter_map { |bind| bind[:key]&.to_s } param_keys = @params.keys.map(&:to_s) @@ -367,10 +368,12 @@ def check_policy_compliance end end - def compiler_policy_contract?(expected_keys) - @policy_contract.is_a?(Compiler::PolicyContract) && - @policy_contract.adapter.equal?(@config.policy_adapter) && - @policy_contract.expected_keys == expected_keys + def compiler_policy_contract? + Compiler.send( + :valid_policy_contract?, @policy_contract, + sql: @sql, params: @params, bind_spec: @bind_spec, intent: @intent, + allow_tables: @allow_tables, config: @config + ) end def policy_predicates_expected? diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index de44c8e..2bc7dcc 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -6,6 +6,23 @@ class Query # preserving quoted literals, identifiers, and comment boundaries. class SqlScanner TableIdentifier = Struct.new(:name, :quoted, keyword_init: true) + + # PostgreSQL keywords that cannot be used as an unquoted relation name. + # Keeping this distinction in the scanner matters because words such as + # IS after `(table` are expression grammar, not the relation operand of a + # TABLE query expression. Quoted words remain valid relation names. + POSTGRES_RESERVED_KEYWORDS = %w[ + ALL ANALYSE ANALYZE AND ANY ARRAY AS ASC ASYMMETRIC BOTH CASE CAST CHECK + COLLATE COLUMN CONSTRAINT CREATE CURRENT_CATALOG CURRENT_DATE CURRENT_ROLE + CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER DEFAULT DEFERRABLE DESC DISTINCT + DO ELSE END EXCEPT FALSE FETCH FOR FOREIGN FREEZE FROM FULL GRANT GROUP + HAVING ILIKE IN INITIALLY INNER INTERSECT INTO IS ISNULL JOIN LATERAL LEADING + LEFT LIKE LIMIT LOCALTIME LOCALTIMESTAMP NATURAL NOT NOTNULL NULL NULLS OFFSET + ON ONLY OR ORDER OUTER OVERLAPS PLACING PRIMARY REFERENCES RETURNING RIGHT + SELECT SESSION_USER SIMILAR SOME SYMMETRIC TABLE THEN TO TRAILING TRUE UNION + UNIQUE USER USING VARIADIC VERBOSE WHEN WHERE WINDOW WITH + ].freeze + def strip_exists_subqueries(sql) source = sql.to_s searchable = mask_sql_literals_comments_and_identifier_contents(source) @@ -61,6 +78,22 @@ def bind_placeholder_positions(sql) end end + # PostgreSQL's TABLE relation query is a SELECT-equivalent and can occur + # wherever a query operand is accepted. Tokenize code (with literals, + # comments, and quoted identifier contents masked) so PostgreSQL's broad + # unquoted identifier syntax is handled without mistaking TABLE inside + # data or identifiers. + def table_query_expression?(sql) + tokens = sql_tokens(sql.to_s) + tokens.each_with_index.any? do |token, index| + next false unless keyword_token?(token, 'TABLE') + next false unless query_operand_boundary?(tokens, index) + + relation_finish = table_relation_operand_finish(tokens, index + 1) + relation_finish && table_query_continuation?(tokens[relation_finish]) + end + end + def policy_predicate_bind?(sql, table, column, bind_number, adapter:) policy_predicate_bind_numbers( sql, table, column, adapter: adapter, question_bind_number: bind_number, source_sql: sql @@ -118,6 +151,165 @@ def policy_predicate_bind_numbers(sql, table, column, adapter: :postgres, questi private + def sql_tokens(source) + searchable = mask_sql_literals_comments_and_identifier_contents(source) + tokens = [] + index = 0 + while index < searchable.length + if searchable[index].match?(/\s/) + index += 1 + elsif postgres_unicode_quoted_identifier_start?(searchable, index) + finish = quoted_identifier_finish(source, index + 2) + tokens << { + type: :identifier, value: source[index...finish], quoted: true, unicode_quoted: true + } + index = finish + elsif searchable[index] == '"' + finish = quoted_identifier_finish(source, index) + tokens << { type: :identifier, value: source[index...finish], quoted: true } + index = finish + elsif postgres_identifier_start?(searchable[index]) + finish = index + 1 + finish += 1 while postgres_identifier_continuation?(searchable[finish]) + tokens << { type: :identifier, value: searchable[index...finish] } + index = finish + else + tokens << { type: :symbol, value: searchable[index] } + index += 1 + end + end + tokens + end + + def quoted_identifier_finish(source, index) + index += 1 + while index < source.length + if source[index] == '"' && source[index + 1] == '"' + index += 2 + elsif source[index] == '"' + return index + 1 + else + index += 1 + end + end + source.length + end + + # PostgreSQL lexes U&"..." (with no whitespace around the ampersand) as + # one Unicode-escaped delimited identifier. Treating U, &, and the quoted + # portion as separate tokens would miss a valid TABLE relation operand. + def postgres_unicode_quoted_identifier_start?(searchable, index) + searchable[index]&.casecmp?('U') && searchable[index + 1] == '&' && searchable[index + 2] == '"' + end + + # PostgreSQL's lexer permits any non-ASCII character in an unquoted + # identifier, including characters Unicode classifies as symbols. Avoid + # a Unicode letter/category allowlist here: it would miss executable + # relation names such as an emoji. + def postgres_identifier_start?(character) + return false unless character + + !character.ascii_only? || character == '_' || character.match?(/[A-Za-z]/) + end + + def postgres_identifier_continuation?(character) + postgres_identifier_start?(character) || character&.match?(/[0-9$]/) + end + + def keyword_token?(token, keyword) + token && token[:type] == :identifier && token[:value].casecmp?(keyword) + end + + def postgres_relation_identifier_token?(token) + return false unless token&.fetch(:type, nil) == :identifier + return true if token[:quoted] + + POSTGRES_RESERVED_KEYWORDS.none? { |keyword| token[:value].casecmp?(keyword) } + end + + def postgres_relation_identifier_finish(tokens, index) + token = tokens[index] + return unless postgres_relation_identifier_token?(token) + + index += 1 + # PostgreSQL permits an optional UESCAPE string after each U& quoted + # identifier. String literal contents are deliberately absent from the + # token stream, so consuming UESCAPE here reaches the next SQL token. + index += 1 if token[:unicode_quoted] && keyword_token?(tokens[index], 'UESCAPE') + index + end + + # Parse TABLE's complete relation operand before deciding whether TABLE + # starts a query expression. At an operand boundary, `(table IS NULL)` + # and `(TABLE accounts)` initially look alike; the token after the + # relation is what distinguishes expression grammar from query grammar. + def table_relation_operand_finish(tokens, index) + index += 1 if keyword_token?(tokens[index], 'ONLY') + parenthesized = tokens[index]&.fetch(:value, nil) == '(' + index += 1 if parenthesized + index = postgres_relation_identifier_finish(tokens, index) + return unless index + + while tokens[index]&.fetch(:value, nil) == '.' + index = postgres_relation_identifier_finish(tokens, index + 1) + return unless index + end + if parenthesized + return unless tokens[index]&.fetch(:value, nil) == ')' + + index += 1 + end + index += 1 if tokens[index]&.fetch(:value, nil) == '*' + index + end + + def table_query_continuation?(token) + return true unless token + return true if token[:value] == ')' + + %w[UNION INTERSECT EXCEPT ORDER LIMIT OFFSET FETCH FOR].any? do |keyword| + keyword_token?(token, keyword) + end + end + + def query_operand_boundary?(tokens, index) + return true if index.zero? + + previous = tokens[index - 1] + return true if previous&.fetch(:value, nil) == '(' + return true if %w[UNION INTERSECT EXCEPT].any? { |keyword| keyword_token?(previous, keyword) } + return true if cte_query_operand_boundary?(tokens, index) + return false unless %w[ALL DISTINCT].any? { |keyword| keyword_token?(previous, keyword) } + + %w[UNION INTERSECT EXCEPT].any? { |keyword| keyword_token?(tokens[index - 2], keyword) } + end + + # At the outer level of a WITH operand, CTE definitions end in a closing + # parenthesis immediately before the main SELECT/TABLE/VALUES expression. + # Walk back over those balanced definitions to distinguish that position + # from an ordinary identifier or alias named `table`. + def cte_query_operand_boundary?(tokens, index) + depth = 0 + cursor = index - 1 + while cursor >= 0 + token = tokens[cursor] + value = token[:value] + if value == ')' + depth += 1 + elsif value == '(' + return false if depth.zero? + + depth -= 1 + elsif depth.zero? + return query_operand_boundary?(tokens, cursor) if keyword_token?(token, 'WITH') + return false if %w[SELECT VALUES TABLE].any? { |keyword| keyword_token?(token, keyword) } + return false if value == ';' + end + cursor -= 1 + end + false + end + def top_level_where_body(searchable) structure = mask_sql_literals_comments_and_identifier_contents(searchable) depth = 0 diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index ebd5cb3..f645efb 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -944,7 +944,8 @@ result = compiler.compile(intent) query = CodeToQuery::Query.new( sql: result[:sql], params: result[:params], bind_spec: result[:bind_spec], - intent: result[:intent], allow_tables: nil, config: config + intent: result[:intent], allow_tables: nil, config: config, + policy_contract: result[:policy_contract] ) expect(result[:sql]).to include('"AuditEvents"."TenantID" = $1') @@ -1105,7 +1106,7 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) query_options = { params: result[:params], bind_spec: result[:bind_spec], intent: result[:intent], - allow_tables: nil, config: config + allow_tables: nil, config: config, policy_contract: result[:policy_contract] } correlated_query = CodeToQuery::Query.new(sql: result[:sql], **query_options) widened_query = CodeToQuery::Query.new( @@ -1131,7 +1132,8 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) query = CodeToQuery::Query.new( sql: result[:sql], params: result[:params], bind_spec: result[:bind_spec], - intent: result[:intent], allow_tables: nil, config: config + intent: result[:intent], allow_tables: nil, config: config, + policy_contract: result[:policy_contract] ) expect(result[:intent]).not_to have_key('__policy_allowed_tables') @@ -1197,7 +1199,8 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil result = compile_with_related_filters(table: 'questions', filters: [related_filter('answers')]) query = CodeToQuery::Query.new( sql: result[:sql], params: result[:params], bind_spec: result[:bind_spec], - intent: result[:intent], allow_tables: nil, config: config + intent: result[:intent], allow_tables: nil, config: config, + policy_contract: result[:policy_contract] ) expect(result[:sql]).to include('"answers"."tenant_id" = $1') diff --git a/spec/code_to_query/guardrails/advanced_sql_linter_spec.rb b/spec/code_to_query/guardrails/advanced_sql_linter_spec.rb index f0f295a..a9034db 100644 --- a/spec/code_to_query/guardrails/advanced_sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/advanced_sql_linter_spec.rb @@ -223,8 +223,7 @@ context 'with malformed SQL' do it 'handles missing closing quotes' do sql = "SELECT * FROM users WHERE name = 'unclosed LIMIT 10" - # This should not crash the linter - expect { linter.check!(sql) }.not_to raise_error + expect { linter.check!(sql) }.to raise_error(SecurityError, /Unterminated SQL literal/) end it 'handles unbalanced parentheses' do diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index da1017d..1b31d08 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -17,6 +17,24 @@ sql = 'SELECT * FROM "users" WHERE "active" = $1 LIMIT 50' expect { linter.check!(sql) }.not_to raise_error end + + it 'passes an ordinary table identifier inside a parenthesized expression' do + sql = 'SELECT (table IS NULL) FROM users LIMIT 1' + + expect { linter.check!(sql) }.not_to raise_error + end + + it 'passes BETWEEN on an ordinary table identifier' do + sql = 'SELECT (table BETWEEN $1 AND $2) FROM users LIMIT 1' + + expect { linter.check!(sql) }.not_to raise_error + end + + it 'passes AT TIME ZONE on an ordinary table identifier' do + sql = 'SELECT (table AT TIME ZONE $1) FROM users LIMIT 1' + + expect { linter.check!(sql) }.not_to raise_error + end end context 'with dangerous queries' do @@ -91,6 +109,56 @@ expect { linter.check!(sql) }.to raise_error(SecurityError, /TABLE query expressions are not supported/) end + it 'rejects TABLE query expressions with Unicode PostgreSQL identifiers' do + sql = 'SELECT * FROM "users" WHERE EXISTS (TABLE évil) LIMIT 10' + + expect { linter.check!(sql) }.to raise_error(SecurityError, /TABLE query expressions are not supported/) + end + + it 'rejects TABLE query expressions with PostgreSQL Unicode-escaped quoted identifiers' do + expressions = [ + 'SELECT EXISTS (TABLE U&"secrets") LIMIT 1', + %q(SELECT EXISTS (TABLE U&"s\0065crets") LIMIT 1), + %q(SELECT EXISTS (TABLE U&"secr""ets") LIMIT 1) + ] + + expressions.each do |sql| + expect { linter.check!(sql) } + .to raise_error(SecurityError, /TABLE query expressions are not supported/), sql + end + end + + it 'rejects nested TABLE ONLY expressions across comments and whitespace' do + sql = "SELECT * FROM \"users\" WHERE EXISTS ((TABLE /* gap */ ONLY\n(évil))) LIMIT 10" + + expect { linter.check!(sql) }.to raise_error(SecurityError, /TABLE query expressions are not supported/) + end + + it 'rejects a TABLE query expression after a CTE definition' do + sql = 'SELECT * FROM "users" WHERE EXISTS (WITH q AS (SELECT 1) /* gap */ TABLE évil) LIMIT 10' + + expect { linter.check!(sql) }.to raise_error(SecurityError, /TABLE query expressions are not supported/) + end + + it 'rejects a TABLE query expression with an emoji PostgreSQL identifier' do + sql = 'SELECT EXISTS (WITH q AS (SELECT 1) TABLE 💣) LIMIT 1' + + expect { linter.check!(sql) }.to raise_error(SecurityError, /TABLE query expressions are not supported/) + end + + it 'does not treat table in identifiers, strings, or comments as a TABLE query expression' do + scanner = CodeToQuery::Query::SqlScanner.new + + expect(scanner.table_query_expression?('SELECT table FROM users')).to be false + expect(scanner.table_query_expression?('SELECT table.id FROM users AS table')).to be false + expect(scanner.table_query_expression?('SELECT users.table FROM users')).to be false + expect(scanner.table_query_expression?('SELECT 1 AS table FROM users')).to be false + expect(scanner.table_query_expression?('WITH q AS (SELECT 1) SELECT table FROM q AS table')).to be false + expect(scanner.table_query_expression?('SELECT "table" FROM users')).to be false + expect(scanner.table_query_expression?("SELECT 'TABLE évil' FROM users")).to be false + expect(scanner.table_query_expression?('SELECT 1 /* TABLE évil */ FROM users')).to be false + end + context 'with MySQL identifiers' do let(:config) { stub_config(adapter: :mysql, max_limit: 1000, max_joins: 2) } diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index a5b4695..d2e61e0 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -52,6 +52,78 @@ end end + describe '#table_query_expression?' do + it 'does not mistake an ordinary table identifier in an expression for a TABLE query' do + expect(scanner.table_query_expression?('SELECT (table IS NULL) FROM users LIMIT 1')).to be false + expect(scanner.table_query_expression?('SELECT (table = $1) FROM users LIMIT 1')).to be false + expect(scanner.table_query_expression?('SELECT (table NOT IN ($1)), table.id FROM users AS table LIMIT 1')).to be false + expect(scanner.table_query_expression?('SELECT table($1) FROM users LIMIT 1')).to be false + end + + it 'uses the relation operand boundary to reject expression operators' do + expressions = [ + 'table BETWEEN $1 AND $2', + 'table AT TIME ZONE $1', + 'table IS DISTINCT FROM $1', + 'table = $1', + 'table <> $1', + 'table IN ($1)', + 'table NOT IN ($1)', + 'table + $1', + 'table::text' + ] + + expressions.each do |expression| + expect(scanner.table_query_expression?("SELECT (#{expression}) FROM users LIMIT 1")).to be(false), expression + end + end + + it 'still recognizes relation identifiers in TABLE query expressions' do + expect(scanner.table_query_expression?('SELECT EXISTS (TABLE "IS")')).to be true + expect(scanner.table_query_expression?('SELECT EXISTS (TABLE /* gap */ ONLY (💣))')).to be true + expect(scanner.table_query_expression?('SELECT EXISTS (WITH q AS (SELECT 1) TABLE évil)')).to be true + expect(scanner.table_query_expression?('SELECT EXISTS (SELECT 1 UNION ALL TABLE schema_name.relation_name)')).to be true + end + + it 'recognizes PostgreSQL Unicode-escaped quoted relation identifiers' do + expressions = [ + 'SELECT EXISTS (TABLE U&"secrets") LIMIT 1', + %q(SELECT EXISTS (TABLE U&"s\0065crets") LIMIT 1), + %q(SELECT EXISTS (TABLE U&"schema".U&"secr""ets") LIMIT 1), + %q(SELECT EXISTS (TABLE U&"s!0065crets" UESCAPE '!') LIMIT 1) + ] + + expressions.each do |expression| + expect(scanner.table_query_expression?(expression)).to be(true), expression + end + end + + it 'does not combine spaced U, ampersand, and quoted identifier tokens' do + expect(scanner.table_query_expression?('SELECT (table U & "secrets") FROM users LIMIT 1')).to be false + expect(scanner.table_query_expression?('SELECT (table U& "secrets") FROM users LIMIT 1')).to be false + end + + it 'recognizes valid TABLE relation completion and query-level continuation' do + expressions = [ + 'TABLE accounts', + 'TABLE ONLY (archive.accounts) *', + '(TABLE accounts)', + 'TABLE accounts UNION SELECT * FROM users', + 'TABLE accounts INTERSECT ALL TABLE active_accounts', + 'TABLE accounts EXCEPT TABLE archived_accounts', + 'TABLE accounts ORDER BY id', + 'TABLE accounts LIMIT 1', + 'TABLE accounts OFFSET 1', + 'TABLE accounts FETCH FIRST 1 ROW ONLY', + 'TABLE accounts FOR UPDATE' + ] + + expressions.each do |expression| + expect(scanner.table_query_expression?(expression)).to be(true), expression + end + end + end + describe '#policy_predicate_bind_numbers' do it 'derives SQLite and MySQL question-mark bind identity from the global SQL position' do full_sql = 'SELECT ? FROM questions WHERE EXISTS ' \ diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index 30be859..dae5995 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require 'weakref' RSpec.describe CodeToQuery::Query do let(:config) { stub_config(adapter: :postgres) } @@ -100,6 +101,19 @@ def build_subquery_policy_query(config) ) end + def build_compiled_policy_query(config, policy) + config.policy_adapter = ->(_user, **) { policy } + compiled = CodeToQuery::Compiler.new(config).compile( + { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 }, + allow_tables: ['users'] + ) + described_class.new( + sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], + intent: compiled[:intent], allow_tables: ['users'], config: config, + policy_contract: compiled[:policy_contract] + ) + end + describe '#sql' do it 'returns the SQL string' do expect(query.sql).to eq(sql) @@ -374,7 +388,8 @@ class << self it 'returns true for a compiler-verified allowlist-only policy with no injected predicates' do config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } compiled = CodeToQuery::Compiler.new(config).compile( - { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 } + { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 }, + allow_tables: ['users'] ) q = described_class.new( sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], @@ -387,6 +402,141 @@ class << self config.policy_adapter = nil end + it 'requires opaque compiler evidence for nonempty policy keys' do + config.policy_adapter = ->(_user, **) { { enforced_predicates: { tenant_id: 42 } } } + q = described_class.new( + sql: 'SELECT * FROM "users" WHERE "users"."tenant_id" = $1 LIMIT 100', + params: { 'policy_tenant_id' => 42 }, + bind_spec: [{ key: 'policy_tenant_id', column: 'tenant_id', cast: nil }], + intent: { + 'table' => 'users', 'type' => 'select', + '__policy_expected_keys' => ['policy_tenant_id'] + }, + allow_tables: ['users'], config: config + ) + + expect(q.safe?).to be false + ensure + config.policy_adapter = nil + end + + it 'accepts a compiler-produced nonempty policy contract' do + config.policy_adapter = ->(_user, **) { { enforced_predicates: { tenant_id: 42 } } } + compiled = CodeToQuery::Compiler.new(config).compile( + { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 }, + allow_tables: ['users'] + ) + q = described_class.new( + sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], + intent: compiled[:intent], allow_tables: ['users'], config: config, + policy_contract: compiled[:policy_contract] + ) + + expect(q.safe?).to be true + ensure + config.policy_adapter = nil + end + + it 'keeps nonempty policy evidence alive while the query is alive across forced GC' do + q = build_compiled_policy_query(config, enforced_predicates: { tenant_id: 42 }) + + GC.start(full_mark: true, immediate_sweep: true) + + expect(q.safe?).to be true + ensure + config.policy_adapter = nil + end + + it 'keeps allowlist-only policy evidence alive while the query is alive across forced GC' do + q = build_compiled_policy_query(config, allowed_tables: ['users'], enforced_predicates: {}) + + GC.start(full_mark: true, immediate_sweep: true) + + expect(q.safe?).to be true + ensure + config.policy_adapter = nil + end + + it 'retains adapter identity for the contract lifetime and rejects a replacement after GC churn' do + issuing_adapter_ref = WeakRef.new( + config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } + ) + compiled = CodeToQuery::Compiler.new(config).compile( + { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 }, + allow_tables: ['users'] + ) + attributes = { + sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], + intent: compiled[:intent], allow_tables: ['users'], config: config, + policy_contract: compiled[:policy_contract] + } + + config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } + 50_000.times { Object.new } + GC.start(full_mark: true, immediate_sweep: true) + + expect(issuing_adapter_ref).to be_weakref_alive + expect(described_class.new(**attributes).safe?).to be false + + config.policy_adapter = issuing_adapter_ref.__getobj__ + expect(described_class.new(**attributes).safe?).to be true + ensure + config.policy_adapter = nil + end + + it 'rejects forged and replayed policy capabilities' do + config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } + compiled = CodeToQuery::Compiler.new(config).compile( + { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 }, + allow_tables: ['users'] + ) + attributes = { + sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], + intent: compiled[:intent], allow_tables: ['users'], config: config + } + forged = described_class.new(**attributes, policy_contract: Object.new) + replayed = described_class.new( + **attributes, sql: "#{compiled[:sql]} ", policy_contract: compiled[:policy_contract] + ) + + expect(forged.safe?).to be false + expect(replayed.safe?).to be false + ensure + config.policy_adapter = nil + end + + it 'binds policy evidence to params, intent, and explicit allowlists' do + config.policy_adapter = ->(_user, **) { { enforced_predicates: { tenant_id: 42 } } } + compiled = CodeToQuery::Compiler.new(config).compile( + { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 }, + allow_tables: ['users'] + ) + attributes = { + sql: compiled[:sql], params: compiled[:params], bind_spec: compiled[:bind_spec], + intent: compiled[:intent], allow_tables: ['users'], config: config, + policy_contract: compiled[:policy_contract] + } + changed_params = described_class.new( + **attributes, params: compiled[:params].merge('policy_tenant_id' => 7) + ) + changed_binds = described_class.new( + **attributes, bind_spec: compiled[:bind_spec].map { |bind| bind.merge(cast: :tampered) } + ) + changed_intent = described_class.new(**attributes, intent: compiled[:intent].merge('limit' => 99)) + changed_allowlist = described_class.new(**attributes, allow_tables: %w[users admins]) + + expect(changed_params.safe?).to be false + expect(changed_binds.safe?).to be false + expect(changed_intent.safe?).to be false + expect(changed_allowlist.safe?).to be false + ensure + config.policy_adapter = nil + end + + it 'does not expose a publicly constructible policy contract class' do + expect { CodeToQuery::Compiler::PolicyContract }.to raise_error(NameError) + end + it 'fails closed when a directly constructed query has no compiler policy contract' do config.policy_adapter = ->(_user, **) { { enforced_predicates: { tenant_id: 42 } } } @@ -397,7 +547,8 @@ class << self it 'fails closed when policy enforcement is enabled after compilation' do compiled = CodeToQuery::Compiler.new(config).compile( - { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 } + { 'table' => 'users', 'type' => 'select', 'columns' => ['*'], 'limit' => 100 }, + allow_tables: ['users'] ) config.policy_adapter = ->(_user, **) { { enforced_predicates: { tenant_id: 42 } } } q = described_class.new( @@ -548,7 +699,7 @@ class << self config.policy_adapter = nil end - it 'returns true when expected subquery policy keys are present in binds' do + it 'rejects synthetic subquery policy metadata even when binds are present' do config.policy_adapter = ->(_user, **) { { allowed_tables: ['users'] } } q = described_class.new( @@ -574,9 +725,7 @@ class << self config: config ) - allow(q).to receive(:perform_safety_checks).and_call_original - - expect(q.safe?).to be true + expect(q.safe?).to be false ensure config.policy_adapter = nil end @@ -607,42 +756,26 @@ class << self config.policy_adapter = nil end - it 'accepts compiler-shaped main-table policy predicates with trailing clauses' do - config.policy_adapter = ->(_user, **) { { allowed_tables: ['questions'] } } - q = described_class.new( - sql: 'SELECT * FROM "questions" WHERE "questions"."tenant_id" = $1 ORDER BY "questions"."id" LIMIT 10', - params: { 'policy_tenant_id' => 42 }, - bind_spec: [{ key: 'policy_tenant_id', column: 'tenant_id', cast: nil }], - intent: { - 'table' => 'questions', 'type' => 'select', - '__policy_expected_keys' => ['policy_tenant_id'] - }, - allow_tables: ['questions'], config: config - ) + it 'recognizes compiler-shaped main-table policy predicates with trailing clauses' do + scanner = CodeToQuery::Query::SqlScanner.new + sql = 'SELECT * FROM "questions" WHERE "questions"."tenant_id" = $1 ORDER BY "questions"."id" LIMIT 10' - expect(q.safe?).to be true - ensure - config.policy_adapter = nil + expect(scanner.policy_predicate_bind?(sql, 'questions', 'tenant_id', 1, adapter: :postgres)).to be true end - it 'uses adapter identifier casing semantics for policy predicates' do - policy_adapter = ->(_user, **) { { allowed_tables: ['questions'] } } + it 'uses adapter identifier casing semantics while scanning policy predicates' do cases = { postgres: 'QUESTIONS.TENANT_ID', sqlite: '"QUESTIONS"."TENANT_ID"', mysql: '`questions`.`TENANT_ID`' } + scanner = CodeToQuery::Query::SqlScanner.new cases.each do |adapter, qualified_column| - q = described_class.new( - sql: "SELECT * FROM questions WHERE #{qualified_column} = $1 LIMIT 10", - params: { 'policy_tenant_id' => 42 }, - bind_spec: [{ key: 'policy_tenant_id', column: 'tenant_id', cast: nil }], - intent: { 'table' => 'questions', 'type' => 'select', '__policy_expected_keys' => ['policy_tenant_id'] }, - allow_tables: ['questions'], config: stub_config(adapter: adapter, policy_adapter: policy_adapter) - ) + sql = "SELECT * FROM questions WHERE #{qualified_column} = $1 LIMIT 10" - expect(q.safe?).to be(true), "expected #{adapter} casing semantics to be honored" + expect(scanner.policy_predicate_bind?(sql, 'questions', 'tenant_id', 1, adapter: adapter)) + .to be(true), "expected #{adapter} casing semantics to be honored" end end From 5161100b5ed734b39b9c3b2b12aed3d202bffa21 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 18:58:59 -0500 Subject: [PATCH 70/86] style: satisfy SQL scanner lint --- spec/code_to_query/guardrails/sql_linter_spec.rb | 2 +- spec/code_to_query/query/sql_scanning_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 1b31d08..436f173 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -119,7 +119,7 @@ expressions = [ 'SELECT EXISTS (TABLE U&"secrets") LIMIT 1', %q(SELECT EXISTS (TABLE U&"s\0065crets") LIMIT 1), - %q(SELECT EXISTS (TABLE U&"secr""ets") LIMIT 1) + 'SELECT EXISTS (TABLE U&"secr""ets") LIMIT 1' ] expressions.each do |sql| diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index d2e61e0..bfaacd8 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -89,7 +89,7 @@ expressions = [ 'SELECT EXISTS (TABLE U&"secrets") LIMIT 1', %q(SELECT EXISTS (TABLE U&"s\0065crets") LIMIT 1), - %q(SELECT EXISTS (TABLE U&"schema".U&"secr""ets") LIMIT 1), + 'SELECT EXISTS (TABLE U&"schema".U&"secr""ets") LIMIT 1', %q(SELECT EXISTS (TABLE U&"s!0065crets" UESCAPE '!') LIMIT 1) ] From ef54d6557fafae89115b5dc9e6d04c43f26d4c34 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Mon, 13 Jul 2026 21:39:06 -0500 Subject: [PATCH 71/86] fix: enforce related policy columns --- lib/code_to_query/compiler.rb | 50 +++++++++++++++++++++++++++++++--- spec/code_to_query_spec.rb | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index d9c5966..c8369b6 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -526,7 +526,7 @@ def build_string_subquery_fragment(filter, table, bind_spec, params_hash, placeh sub_where = ["#{rt}.#{fk_col} = #{quote_ident(table)}.#{base_col}"] sub_where, placeholder_index = apply_policy_in_subquery( - sub_where, bind_spec, params_hash, related_table, placeholder_index, current_user, intent + sub_where, bind_spec, params_hash, placeholder_index, current_user, intent, filter ) related_filters.each do |related_filter| @@ -556,12 +556,14 @@ def build_string_subquery_filter_fragment(quoted_related_table, filter, bind_spe end end - def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, placeholder_index, current_user, intent) + def apply_policy_in_subquery(sub_where, bind_spec, params_hash, placeholder_index, current_user, intent, filter) return [sub_where, placeholder_index] unless @config.policy_adapter.respond_to?(:call) + related_table = filter['related_table'] info = safely_fetch_policy(table: related_table, current_user: current_user, intent: intent) merge_policy_allowed_tables!(intent, info, required_table: related_table) predicates = extract_enforced_predicates(info) + enforce_related_allowed_columns!(info, related_table, filter) return [sub_where, placeholder_index] unless predicates.is_a?(Hash) && predicates.any? predicates.each do |column, value| @@ -592,7 +594,7 @@ def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, p end [sub_where, placeholder_index] - rescue PolicyAdapterError + rescue PolicyAdapterError, ArgumentError raise rescue StandardError => e raise policy_failure("Policy application failed in subquery: #{e.message}") unless policy_adapter_fail_open? @@ -600,6 +602,48 @@ def apply_policy_in_subquery(sub_where, bind_spec, params_hash, related_table, p [sub_where, placeholder_index] end + def enforce_related_allowed_columns!(policy_info, related_table, filter) + allowed_columns = policy_info[:allowed_columns] || policy_info['allowed_columns'] + return unless allowed_columns.is_a?(Hash) && allowed_columns.any? + + entry = allowed_columns.find do |table, _columns| + policy_column_table_allowed?(related_table, table) + end + if entry.nil? && %i[postgres postgresql].include?(@config.adapter.to_sym) && + allowed_columns.any? { |table, _columns| IdentifierSemantics.ascii_case_insensitive?(related_table, table) } + raise ArgumentError, + "Invalid intent: policy table key not permitted on '#{related_table}' due to identifier casing" + end + return unless entry + + columns = Array(entry.last).map(&:to_s) + return if columns.empty? + + fk_column = filter['fk_column'] + unless policy_column_allowed?(fk_column, columns) + raise ArgumentError, "Invalid intent: column '#{fk_column}' not permitted on '#{related_table}'" + end + + Array(filter['related_filters']).each do |related_filter| + column = related_filter['column'] + next if column.nil? || policy_column_allowed?(column, columns) + + raise ArgumentError, "Invalid intent: filter column '#{column}' not permitted on '#{related_table}'" + end + end + + def policy_column_allowed?(column, allowed_columns) + return allowed_columns.include?(column.to_s) if %i[postgres postgresql].include?(@config.adapter.to_sym) + + allowed_columns.any? { |allowed| IdentifierSemantics.ascii_case_insensitive?(allowed, column) } + end + + def policy_column_table_allowed?(table, allowed) + return IdentifierSemantics.ascii_case_insensitive?(table, allowed) if %i[mysql sqlite].include?(@config.adapter.to_sym) + + table.to_s == allowed.to_s + end + def subquery_policy_key_prefix(table, column, placeholder_index) safe_table = policy_key_fragment(table) safe_column = policy_key_fragment(column) diff --git a/spec/code_to_query_spec.rb b/spec/code_to_query_spec.rb index 913c564..a3b4ea8 100644 --- a/spec/code_to_query_spec.rb +++ b/spec/code_to_query_spec.rb @@ -219,6 +219,57 @@ def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:, linter_error: nil) .to raise_error(SecurityError, /allowed list/i) end + context 'with table-scoped policy column metadata for related filters' do + let(:policy_calls) { [] } + + before do + described_class.config.policy_adapter = lambda do |_user, **context| + table = context.fetch(:table) + policy_calls << table + columns = table == 'questions' ? ['id'] : %w[question_id status] + + { allowed_columns: { table => columns } } + end + end + + def ask_with_related_policy_filter(operation:, fk_column: 'question_id', related_columns: ['status']) + intent = { + 'type' => 'select', 'table' => 'questions', 'columns' => ['id'], + 'filters' => [{ + 'op' => operation, 'related_table' => 'answers', + 'fk_column' => fk_column, 'base_column' => 'id', + 'related_filters' => related_columns.map.with_index do |column, index| + { 'column' => column, 'op' => '=', 'param' => "answer_filter_#{index}" } + end + }], + 'params' => related_columns.each_index.to_h { |index| ["answer_filter_#{index}", index] } + } + planner = instance_double(CodeToQuery::Planner, plan: intent) + allow(CodeToQuery::Planner).to receive(:new).and_return(planner) + + described_class.ask( + prompt: 'Get questions by answer attributes', + allow_tables: %w[questions answers] + ) + end + + %w[exists not_exists].each do |operation| + it "rejects a disallowed related fk_column for #{operation}" do + expect do + ask_with_related_policy_filter(operation: operation, fk_column: 'secret_question_id') + end.to raise_error(ArgumentError, /column 'secret_question_id' not permitted on 'answers'/) + expect(policy_calls).to eq(%w[questions questions answers]) + end + + it "checks every related filter column for #{operation}" do + expect do + ask_with_related_policy_filter(operation: operation, related_columns: %w[status secret]) + end.to raise_error(ArgumentError, /filter column 'secret' not permitted on 'answers'/) + expect(policy_calls).to eq(%w[questions questions answers]) + end + end + end + # rubocop:disable RSpec/ExampleLength it 'marks compile/lint telemetry as policy-applied when policy binds are present' do events = [] From a78b609507c65f468740e1a47a13dcbce95fad83 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Tue, 14 Jul 2026 07:19:28 -0500 Subject: [PATCH 72/86] fix: preserve policy enforcement boundaries --- lib/code_to_query/compiler.rb | 15 ++++--- lib/code_to_query/query.rb | 32 ++++++++++++--- spec/code_to_query/compiler_spec.rb | 64 +++++++++++++++++++++++++++++ spec/code_to_query_spec.rb | 21 ++++++++++ 4 files changed, 122 insertions(+), 10 deletions(-) diff --git a/lib/code_to_query/compiler.rb b/lib/code_to_query/compiler.rb index c8369b6..3afcdc7 100644 --- a/lib/code_to_query/compiler.rb +++ b/lib/code_to_query/compiler.rb @@ -566,6 +566,9 @@ def apply_policy_in_subquery(sub_where, bind_spec, params_hash, placeholder_inde enforce_related_allowed_columns!(info, related_table, filter) return [sub_where, placeholder_index] unless predicates.is_a?(Hash) && predicates.any? + # Preserve declaration ownership structurally; policy key fragments are + # intentionally display-safe but are not an injective table identity. + policy_filter_index = Array(intent['filters']).index { |candidate| candidate.equal?(filter) } predicates.each do |column, value| rcol = "#{quote_ident(related_table)}.#{quote_ident(column)}" policy_key_prefix = subquery_policy_key_prefix(related_table, column, placeholder_index) @@ -576,10 +579,10 @@ def apply_policy_in_subquery(sub_where, bind_spec, params_hash, placeholder_inde params_hash[start_key] = value.begin params_hash[end_key] = value.end p1 = placeholder_for_adapter(placeholder_index) - append_bind_spec(bind_spec, key: start_key, column: column) + append_bind_spec(bind_spec, key: start_key, column: column, policy_filter_index: policy_filter_index) placeholder_index += 1 p2 = placeholder_for_adapter(placeholder_index) - append_bind_spec(bind_spec, key: end_key, column: column) + append_bind_spec(bind_spec, key: end_key, column: column, policy_filter_index: policy_filter_index) placeholder_index += 1 sub_where << "#{rcol} BETWEEN #{p1} AND #{p2}" else @@ -587,7 +590,7 @@ def apply_policy_in_subquery(sub_where, bind_spec, params_hash, placeholder_inde merge_policy_expected_keys!(intent, key) params_hash[key] = value p = placeholder_for_adapter(placeholder_index) - append_bind_spec(bind_spec, key: key, column: column) + append_bind_spec(bind_spec, key: key, column: column, policy_filter_index: policy_filter_index) placeholder_index += 1 sub_where << "#{rcol} = #{p}" end @@ -886,8 +889,10 @@ def having_bind_key(having_filter) having_filter['param'] || "having_#{having_filter['column']}" end - def append_bind_spec(bind_spec, key:, column:, cast: nil) - bind_spec << { key: key, column: column, cast: cast } + def append_bind_spec(bind_spec, key:, column:, cast: nil, policy_filter_index: nil) + bind = { key: key, column: column, cast: cast } + bind[:policy_filter_index] = policy_filter_index unless policy_filter_index.nil? + bind_spec << bind end def placeholder_for_adapter(index) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 2ee0b6c..c3cdf7f 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -404,6 +404,13 @@ def sql_linter_allow_tables related_tables = Array(@intent['__policy_related_tables']).compact.map(&:to_s).uniq return effective_lint_allow_tables if related_tables.empty? || !allowlist_sources_present? + explicit_tables = Array(@allow_tables).compact.map(&:to_s).uniq + if explicit_tables.any? + related_tables.select! do |table| + explicit_tables.any? { |explicit| allowlist_names_equivalent?(table, explicit) } + end + end + (Array(effective_lint_allow_tables) + related_tables).compact.map(&:to_s).uniq end @@ -513,19 +520,34 @@ def policy_bind_present_in_subquery?(subquery, policy_bind_numbers, subqueries, end def policy_binds_by_related_filter - declarations_by_table = Array(@intent['filters']).select do |filter| + filters = Array(@intent['filters']) + declarations = filters.select do |filter| %w[exists not_exists].include?(filter['op'].to_s.downcase) && filter['related_table'] - end.group_by { |filter| filter['related_table'].to_s } + end + + # Compiler-issued bind metadata is covered by the opaque policy contract. + # Prefer it over lossy policy-key fragments when it is available. + if Array(@bind_spec).any? { |bind| bind.key?(:policy_filter_index) } + return declarations.each_with_object({}.compare_by_identity) do |declaration, result| + declaration_index = filters.index { |filter| filter.equal?(declaration) } + result[declaration] = Array(@bind_spec).each_with_index.filter_map do |bind, index| + index + 1 if bind[:policy_filter_index] == declaration_index + end + end + end + + # Retain compatibility for direct Query construction with legacy bind specs. + declarations_by_table = declarations.group_by { |filter| filter['related_table'].to_s } - declarations_by_table.each_with_object({}.compare_by_identity) do |(table, declarations), result| + declarations_by_table.each_with_object({}.compare_by_identity) do |(table, table_declarations), result| table_fragment = table.gsub(/[^a-zA-Z0-9_]/, '_') bind_numbers = Array(@bind_spec).each_with_index.filter_map do |bind, index| key = bind[:key]&.to_s index + 1 if key&.match?(/\Apolicy_subquery_\d+_#{Regexp.escape(table_fragment)}_/) end - quotient, remainder = bind_numbers.length.divmod(declarations.length) + quotient, remainder = bind_numbers.length.divmod(table_declarations.length) offset = 0 - declarations.each_with_index do |declaration, index| + table_declarations.each_with_index do |declaration, index| count = quotient + (index < remainder ? 1 : 0) result[declaration] = bind_numbers.slice(offset, count) offset += count diff --git a/spec/code_to_query/compiler_spec.rb b/spec/code_to_query/compiler_spec.rb index f645efb..0995848 100644 --- a/spec/code_to_query/compiler_spec.rb +++ b/spec/code_to_query/compiler_spec.rb @@ -1120,6 +1120,70 @@ def compile_with_related_filters(table:, filters:, params: {}, current_user: nil expect(widened_query.safe?).to be false end + it 'does not widen an explicit caller allowlist with an authorized related table' do + config.policy_adapter = lambda do |_user, **kwargs| + if kwargs[:table] == 'questions' + { allowed_tables: %w[questions answers] } + else + { allowed_tables: ['answers'], enforced_predicates: { tenant_id: 42 } } + end + end + + result = compiler.compile( + { + 'table' => 'questions', 'columns' => ['*'], + 'filters' => [related_filter('answers')], 'limit' => 100, 'params' => {} + }, + allow_tables: ['questions'] + ) + query = CodeToQuery::Query.new( + sql: result[:sql], params: result[:params], bind_spec: result[:bind_spec], + intent: result[:intent], allow_tables: ['questions'], config: config, + policy_contract: result[:policy_contract] + ) + + expect(query.safe?).to be false + end + + # rubocop:disable RSpec/ExampleLength + it 'keeps policy bind ownership distinct for colliding quoted table names' do + config.policy_adapter = lambda do |_user, **kwargs| + case kwargs[:table] + when 'a-b' + { allowed_tables: ['a-b'], enforced_predicates: { tenant_id: 7 } } + when 'a_b' + { allowed_tables: ['a_b'], enforced_predicates: { tenant_id: 9 } } + else + { allowed_tables: %w[questions a-b a_b] } + end + end + + result = compiler.compile( + { + 'table' => 'questions', 'columns' => ['*'], + 'filters' => [ + related_filter('a-b'), + related_filter('a_b', operation: 'not_exists') + ], + 'limit' => 100, 'params' => {} + }, + allow_tables: %w[questions a-b a_b] + ) + query = CodeToQuery::Query.new( + sql: result[:sql], params: result[:params], bind_spec: result[:bind_spec], + intent: result[:intent], allow_tables: %w[questions a-b a_b], config: config, + policy_contract: result[:policy_contract] + ) + + expect(result[:sql]).to include('FROM "a-b"', 'FROM "a_b"') + expect(result[:params]).to include( + 'policy_subquery_1_a_b_tenant_id' => 7, + 'policy_subquery_2_a_b_tenant_id' => 9 + ) + expect(query.safe?).to be true + end + # rubocop:enable RSpec/ExampleLength + it 'preserves an unrestricted base policy when authorizing a related table' do config.policy_adapter = lambda do |_user, **kwargs| if kwargs[:table] == 'answers' diff --git a/spec/code_to_query_spec.rb b/spec/code_to_query_spec.rb index a3b4ea8..b938da7 100644 --- a/spec/code_to_query_spec.rb +++ b/spec/code_to_query_spec.rb @@ -191,6 +191,27 @@ def stub_ask_pipeline(compiled_intent:, allow_tables:, sql:, linter_error: nil) expect { described_class.ask(prompt: 'Get questions') }.not_to raise_error end + it 'does not widen an explicit caller allowlist through the ask pipeline' do + intent = { + 'table' => 'questions', 'type' => 'select', 'columns' => ['*'], + 'filters' => [exists_related_filter], 'limit' => 100, 'params' => {} + } + planner = instance_double(CodeToQuery::Planner, plan: intent) + validator = instance_double(CodeToQuery::Validator, validate: intent) + allow(CodeToQuery::Planner).to receive(:new).and_return(planner) + allow(CodeToQuery::Validator).to receive(:new).and_return(validator) + described_class.config.policy_adapter = lambda do |_user, **context| + if context[:table] == 'questions' + { allowed_tables: %w[questions answers] } + else + { allowed_tables: ['answers'], enforced_predicates: { tenant_id: 42 } } + end + end + + expect { described_class.ask(prompt: 'Get questions', allow_tables: ['questions']) } + .to raise_error(SecurityError, /allowed list/i) + end + it 'rejects EXISTS related tables that fall outside the policy adapter allowlist' do planner = instance_double(CodeToQuery::Planner) validator = instance_double(CodeToQuery::Validator) From 01dc880de890a33351254837081c037ce686dec5 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Tue, 14 Jul 2026 19:37:00 -0500 Subject: [PATCH 73/86] fix: block PostgreSQL dynamic query functions --- lib/code_to_query/guardrails/sql_linter.rb | 33 +++++++++++----- .../guardrails/sql_linter_spec.rb | 38 +++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index f56dce3..c49b73e 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -3,6 +3,19 @@ module CodeToQuery module Guardrails class SqlLinter + DANGEROUS_FUNCTIONS = %w[ + load_file outfile dumpfile + sys_exec sys_eval + benchmark sleep pg_sleep + version user database schema + current_user current_database current_schema + inet_server_addr inet_client_addr + ].freeze + + POSTGRES_DYNAMIC_QUERY_FUNCTIONS = %w[ + query_to_xml query_to_xmlschema query_to_xml_and_xmlschema + ].freeze + def initialize(config, allow_tables: nil) @config = config @allow_tables = Array(allow_tables).compact.map(&:to_s) @@ -250,18 +263,18 @@ def check_no_literals!(sql) end def check_no_dangerous_functions!(sql) - dangerous_functions = %w[ - load_file outfile dumpfile - sys_exec sys_eval - benchmark sleep pg_sleep - version user database schema - current_user current_database current_schema - inet_server_addr inet_client_addr - ] - - dangerous_functions.each do |func| + DANGEROUS_FUNCTIONS.each do |func| raise SecurityError, "Dangerous function '#{func}' is not allowed" if sql.match?(/\b#{func}\s*\(/i) end + + return unless %i[postgres postgresql].include?(@config.adapter.to_sym) + + POSTGRES_DYNAMIC_QUERY_FUNCTIONS.each do |func| + # PostgreSQL accepts quoted built-in function identifiers, including + # schema-qualified calls such as pg_catalog."query_to_xml"(...). + pattern = /(?:\b#{func}|"#{func}")\s*\(/i + raise SecurityError, "Dangerous function '#{func}' is not allowed" if sql.match?(pattern) + end end def check_no_subqueries!(sql) diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 436f173..15cf9f3 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -215,6 +215,44 @@ sql = 'SELECT * FROM information_schema.tables LIMIT 100' expect { linter.check!(sql) }.to raise_error(SecurityError, /information_schema/) end + + context 'with PostgreSQL server-side query execution' do + let(:config) { stub_config(adapter: :postgres, max_limit: 1000, max_joins: 2) } + + it 'blocks query_to_xml from hiding a non-allowlisted table in dollar-quoted SQL' do + sql = 'SELECT query_to_xml($q$TABLE secrets$q$, true, false, $x$$x$) FROM users LIMIT 10' + + expect { linter.check!(sql) }.to raise_error(SecurityError, /query_to_xml/) + end + + it 'blocks query_to_xml schema-generation siblings' do + functions = %w[query_to_xmlschema query_to_xml_and_xmlschema] + + functions.each do |function| + sql = "SELECT #{function}($q$TABLE secrets$q$, true, false, $x$$x$) FROM users LIMIT 10" + + expect { linter.check!(sql) }.to raise_error(SecurityError, /#{function}/), function + end + end + + it 'blocks schema-qualified and quoted query_to_xml calls' do + calls = ['pg_catalog.query_to_xml', 'pg_catalog."query_to_xml"'] + + calls.each do |function_call| + sql = "SELECT #{function_call}($q$TABLE secrets$q$, true, false, $x$$x$) FROM users LIMIT 10" + + expect { linter.check!(sql) }.to raise_error(SecurityError), function_call + end + end + end + + it 'does not apply the PostgreSQL dynamic-query denylist to other adapters' do + mysql_config = stub_config(adapter: :mysql, max_limit: 1000, max_joins: 2) + mysql_linter = described_class.new(mysql_config, allow_tables: %w[users]) + sql = 'SELECT query_to_xml($1, true, false, $2) FROM users LIMIT 10' + + expect { mysql_linter.check!(sql) }.not_to raise_error + end end end end From 3f01eee2adab040e6a900d08e4088b2b79e3dc83 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Tue, 14 Jul 2026 19:49:48 -0500 Subject: [PATCH 74/86] fix: reject Unicode-escaped query functions --- lib/code_to_query/guardrails/sql_linter.rb | 64 +++++++++++++++++++ .../guardrails/sql_linter_spec.rb | 28 +++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index c49b73e..66434b6 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -16,6 +16,12 @@ class SqlLinter query_to_xml query_to_xmlschema query_to_xml_and_xmlschema ].freeze + POSTGRES_UNICODE_FUNCTION_IDENTIFIER = / + (?(?:""|[^"])*)" + (?:\s+UESCAPE\s+'(?[^'])')? + \s*\( + /ix + def initialize(config, allow_tables: nil) @config = config @allow_tables = Array(allow_tables).compact.map(&:to_s) @@ -29,6 +35,7 @@ def check!(sql) check_dangerous_patterns!(normalized) check_required_limit!(normalized) check_table_allowlist!(normalized) if @allow_tables.any? + check_no_postgres_dynamic_query_functions!(normalized) check_no_literals!(normalized) check_no_dangerous_functions!(normalized) check_no_subqueries!(normalized) if @config.block_subqueries @@ -266,7 +273,9 @@ def check_no_dangerous_functions!(sql) DANGEROUS_FUNCTIONS.each do |func| raise SecurityError, "Dangerous function '#{func}' is not allowed" if sql.match?(/\b#{func}\s*\(/i) end + end + def check_no_postgres_dynamic_query_functions!(sql) return unless %i[postgres postgresql].include?(@config.adapter.to_sym) POSTGRES_DYNAMIC_QUERY_FUNCTIONS.each do |func| @@ -275,6 +284,61 @@ def check_no_dangerous_functions!(sql) pattern = /(?:\b#{func}|"#{func}")\s*\(/i raise SecurityError, "Dangerous function '#{func}' is not allowed" if sql.match?(pattern) end + + sql.scan(POSTGRES_UNICODE_FUNCTION_IDENTIFIER) do + match = Regexp.last_match + identifier = decode_postgres_unicode_identifier(match[:identifier], match[:escape] || '\\') + next unless POSTGRES_DYNAMIC_QUERY_FUNCTIONS.include?(identifier) + + raise SecurityError, "Dangerous function '#{identifier}' is not allowed" + end + end + + def decode_postgres_unicode_identifier(identifier, escape) + return if escape.match?(/[0-9A-F+'"\s]/i) + + characters = identifier.gsub('""', '"').chars + decoded = +'' + index = 0 + + while index < characters.length + if characters[index] != escape + decoded << characters[index] + index += 1 + next + end + + codepoint, consumed = postgres_unicode_escape(characters, index, escape) + return unless codepoint + + if codepoint.between?(0xD800, 0xDBFF) + low_surrogate, low_consumed = postgres_unicode_escape(characters, index + consumed, escape) + return unless low_surrogate&.between?(0xDC00, 0xDFFF) + + codepoint = 0x10000 + ((codepoint - 0xD800) << 10) + low_surrogate - 0xDC00 + consumed += low_consumed + end + + decoded << codepoint.chr(Encoding::UTF_8) + index += consumed + end + + decoded + rescue RangeError + nil + end + + def postgres_unicode_escape(characters, index, escape) + return unless characters[index] == escape + return [escape.ord, 2] if characters[index + 1] == escape + + extended = characters[index + 1] == '+' + digits = extended ? 6 : 4 + start = index + (extended ? 2 : 1) + hexadecimal = characters[start, digits]&.join + return unless hexadecimal&.match?(/\A[0-9A-F]{#{digits}}\z/i) + + [hexadecimal.to_i(16), digits + (extended ? 2 : 1)] end def check_no_subqueries!(sql) diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 15cf9f3..1a66ebd 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -244,14 +244,38 @@ expect { linter.check!(sql) }.to raise_error(SecurityError), function_call end end + + it 'blocks Unicode-escaped quoted identifiers for every dynamic-query function' do + calls = { + 'query_to_xml' => 'U&"query_to_!0078ml" UESCAPE \'!\'', + 'query_to_xmlschema' => 'public.U&"query_to_xmlschema" UESCAPE \'!\'', + 'query_to_xml_and_xmlschema' => 'U&"query_to_!0078ml_and_xmlschema" UESCAPE \'!\'' + } + + calls.each do |function, function_call| + sql = "SELECT #{function_call}($q$TABLE secrets$q$, true, false, $x$$x$) FROM users LIMIT 10" + + expect { linter.check!(sql) }.to raise_error(SecurityError, /#{function}/), function_call + end + end + + it 'does not overmatch a longer Unicode quoted function identifier' do + sql = 'SELECT U&"query_to_xml_backup"($1) FROM users LIMIT 10' + + expect { linter.check!(sql) }.not_to raise_error + end end it 'does not apply the PostgreSQL dynamic-query denylist to other adapters' do mysql_config = stub_config(adapter: :mysql, max_limit: 1000, max_joins: 2) mysql_linter = described_class.new(mysql_config, allow_tables: %w[users]) - sql = 'SELECT query_to_xml($1, true, false, $2) FROM users LIMIT 10' + calls = ['query_to_xml', 'U&"query_to_\+000078ml"'] - expect { mysql_linter.check!(sql) }.not_to raise_error + calls.each do |function_call| + sql = "SELECT #{function_call}($1, true, false, $2) FROM users LIMIT 10" + + expect { mysql_linter.check!(sql) }.not_to raise_error + end end end end From 8cf5c1eb59c973259438372f660a8e5ca622b509 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Tue, 14 Jul 2026 19:56:22 -0500 Subject: [PATCH 75/86] fix: honor quoted function identifier boundaries --- lib/code_to_query/guardrails/sql_linter.rb | 34 ++++++++++++++++--- .../guardrails/sql_linter_spec.rb | 12 +++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index 66434b6..b08837f 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -16,8 +16,12 @@ class SqlLinter query_to_xml query_to_xmlschema query_to_xml_and_xmlschema ].freeze + POSTGRES_QUOTED_FUNCTION_IDENTIFIER = / + (?(?:""|[^"])*)"\s*\( + /x + POSTGRES_UNICODE_FUNCTION_IDENTIFIER = / - (?(?:""|[^"])*)" + (?(?:""|[^"])*)" (?:\s+UESCAPE\s+'(?[^'])')? \s*\( /ix @@ -279,10 +283,16 @@ def check_no_postgres_dynamic_query_functions!(sql) return unless %i[postgres postgresql].include?(@config.adapter.to_sym) POSTGRES_DYNAMIC_QUERY_FUNCTIONS.each do |func| - # PostgreSQL accepts quoted built-in function identifiers, including - # schema-qualified calls such as pg_catalog."query_to_xml"(...). - pattern = /(?:\b#{func}|"#{func}")\s*\(/i - raise SecurityError, "Dangerous function '#{func}' is not allowed" if sql.match?(pattern) + next unless postgres_unquoted_function_call?(sql, func) + + raise SecurityError, "Dangerous function '#{func}' is not allowed" + end + + sql.scan(POSTGRES_QUOTED_FUNCTION_IDENTIFIER) do + identifier = Regexp.last_match[:identifier].gsub('""', '"') + next unless POSTGRES_DYNAMIC_QUERY_FUNCTIONS.include?(identifier) + + raise SecurityError, "Dangerous function '#{identifier}' is not allowed" end sql.scan(POSTGRES_UNICODE_FUNCTION_IDENTIFIER) do @@ -294,6 +304,20 @@ def check_no_postgres_dynamic_query_functions!(sql) end end + def postgres_unquoted_function_call?(sql, function) + sql.to_enum(:scan, /#{Regexp.escape(function)}\s*\(/i).any? do + match = Regexp.last_match + previous = match.begin(0).positive? ? sql[match.begin(0) - 1] : nil + following = sql[match.begin(0) + function.length] + + !postgres_identifier_continuation?(previous) && !postgres_identifier_continuation?(following) + end + end + + def postgres_identifier_continuation?(character) + character && (!character.ascii_only? || character.match?(/[A-Z0-9_$]/i)) + end + def decode_postgres_unicode_identifier(identifier, escape) return if escape.match?(/[0-9A-F+'"\s]/i) diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 1a66ebd..2eacbfb 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -259,6 +259,18 @@ end end + it 'does not overmatch denied text after a doubled quote in a quoted identifier' do + sql = 'SELECT "prefix""query_to_xml"($1) FROM users LIMIT 10' + + expect { linter.check!(sql) }.not_to raise_error + end + + it 'does not overmatch denied text after a doubled quote in a Unicode quoted identifier' do + sql = 'SELECT U&"prefix""query_to_xml"($1) FROM users LIMIT 10' + + expect { linter.check!(sql) }.not_to raise_error + end + it 'does not overmatch a longer Unicode quoted function identifier' do sql = 'SELECT U&"query_to_xml_backup"($1) FROM users LIMIT 10' From 9d03560fc49697845aa65b9308428c0a65cf355b Mon Sep 17 00:00:00 2001 From: kholdrex Date: Tue, 14 Jul 2026 20:12:55 -0500 Subject: [PATCH 76/86] fix: close PostgreSQL UESCAPE query bypass --- lib/code_to_query/guardrails/sql_linter.rb | 87 +++++++++++++++++-- lib/code_to_query/query/sql_scanning.rb | 7 ++ .../guardrails/sql_linter_spec.rb | 32 +++++++ 3 files changed, 120 insertions(+), 6 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index b08837f..ddbcf7f 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -22,8 +22,6 @@ class SqlLinter POSTGRES_UNICODE_FUNCTION_IDENTIFIER = / (?(?:""|[^"])*)" - (?:\s+UESCAPE\s+'(?[^'])')? - \s*\( /ix def initialize(config, allow_tables: nil) @@ -282,28 +280,105 @@ def check_no_dangerous_functions!(sql) def check_no_postgres_dynamic_query_functions!(sql) return unless %i[postgres postgresql].include?(@config.adapter.to_sym) + searchable = Query::SqlScanner.new.mask_literals_and_comments(sql) + POSTGRES_DYNAMIC_QUERY_FUNCTIONS.each do |func| - next unless postgres_unquoted_function_call?(sql, func) + next unless postgres_unquoted_function_call?(searchable, func) raise SecurityError, "Dangerous function '#{func}' is not allowed" end - sql.scan(POSTGRES_QUOTED_FUNCTION_IDENTIFIER) do + searchable.scan(POSTGRES_QUOTED_FUNCTION_IDENTIFIER) do identifier = Regexp.last_match[:identifier].gsub('""', '"') next unless POSTGRES_DYNAMIC_QUERY_FUNCTIONS.include?(identifier) raise SecurityError, "Dangerous function '#{identifier}' is not allowed" end - sql.scan(POSTGRES_UNICODE_FUNCTION_IDENTIFIER) do + searchable.scan(POSTGRES_UNICODE_FUNCTION_IDENTIFIER) do match = Regexp.last_match - identifier = decode_postgres_unicode_identifier(match[:identifier], match[:escape] || '\\') + escape, function_call = postgres_unicode_function_escape(sql, match.end(0)) + next unless function_call + + unless escape + raise SecurityError, 'Inconclusive PostgreSQL Unicode function identifier' + end + + identifier = decode_postgres_unicode_identifier(match[:identifier], escape) + unless identifier + raise SecurityError, 'Inconclusive PostgreSQL Unicode function identifier' + end next unless POSTGRES_DYNAMIC_QUERY_FUNCTIONS.include?(identifier) raise SecurityError, "Dangerous function '#{identifier}' is not allowed" end end + def postgres_unicode_function_escape(sql, index) + whitespace_start = index + index += 1 while sql[index]&.match?(/\s/) + escape = '\\' + + if index > whitespace_start && sql[index, 7]&.casecmp?('UESCAPE') && + !postgres_identifier_continuation?(sql[index + 7]) + index += 7 + index += 1 while sql[index]&.match?(/\s/) + escape, index = postgres_uescape_literal(sql, index) + return [nil, true] unless index + + index += 1 while sql[index]&.match?(/\s/) + end + + [escape, sql[index] == '('] + end + + def postgres_uescape_literal(sql, index) + escape_string = sql[index]&.casecmp?('E') && sql[index + 1] == "'" + index += 1 if escape_string + return [nil, nil] unless sql[index] == "'" + + index += 1 + value = +'' + while index < sql.length + if sql[index] == "'" && sql[index + 1] == "'" + value << "'" + index += 2 + elsif sql[index] == "'" + return [value.length == 1 ? value : nil, index + 1] + elsif escape_string && sql[index] == '\\' + character, index = postgres_escape_string_character(sql, index + 1) + return [nil, nil] unless index + + value << character + else + value << sql[index] + index += 1 + end + end + [nil, nil] + end + + def postgres_escape_string_character(sql, index) + return [nil, nil] unless sql[index] + + simple = { 'b' => "\b", 'f' => "\f", 'n' => "\n", 'r' => "\r", 't' => "\t" } + return [simple.fetch(sql[index], sql[index]), index + 1] unless sql[index].match?(/[0-7xXuU]/) + + pattern, base = case sql[index] + when /[0-7]/ then [/[0-7]{1,3}/, 8] + when /[xX]/ then [/[0-9A-F]{1,2}/i, 16] + when 'u' then [/[0-9A-F]{4}/i, 16] + when 'U' then [/[0-9A-F]{8}/i, 16] + end + digit_index = sql[index].match?(/[xXuU]/) ? index + 1 : index + digits = sql[digit_index..]&.match(/\A#{pattern}/)&.[](0) + return [nil, nil] unless digits + + [digits.to_i(base).chr(Encoding::UTF_8), digit_index + digits.length] + rescue RangeError + [nil, nil] + end + def postgres_unquoted_function_call?(sql, function) sql.to_enum(:scan, /#{Regexp.escape(function)}\s*\(/i).any? do match = Regexp.last_match diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 2bc7dcc..cf270d8 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -94,6 +94,13 @@ def table_query_expression?(sql) end end + # Preserve source offsets while hiding literal values and comments. This + # lets callers inspect executable SQL without mistaking function-like text + # in PostgreSQL dollar quotes for code. + def mask_literals_and_comments(sql) + mask_sql_literals_and_comments(sql.to_s) + end + def policy_predicate_bind?(sql, table, column, bind_number, adapter:) policy_predicate_bind_numbers( sql, table, column, adapter: adapter, question_bind_number: bind_number, source_sql: sql diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 2eacbfb..f37382d 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -259,6 +259,38 @@ end end + it 'blocks Unicode identifiers with escape string UESCAPE clauses beside parameterized LIKE operators' do + %w[LIKE ILIKE].each do |operator| + sql = %(SELECT U&"query_to_!0078ml" UESCAPE E'!'($q$TABLE secrets$q$, true, false, $x$$x$) FROM users WHERE name #{operator} $3 LIMIT 10) + + expect { linter.check!(sql) }.to raise_error(SecurityError, /query_to_xml/), operator + end + end + + it 'accepts standard and escape-string UESCAPE forms for benign Unicode function identifiers' do + ["'!'", "E'!'", "e'!'"].each do |uescape| + sql = %(SELECT U&"safe_function" UESCAPE #{uescape}($1) FROM users WHERE name LIKE $2 LIMIT 10) + + expect { linter.check!(sql) }.not_to raise_error, uescape + end + end + + it 'fails closed when a Unicode function identifier UESCAPE is inconclusive' do + sql = %(SELECT U&"safe_function" UESCAPE E'!!'($1) FROM users WHERE name LIKE $2 LIMIT 10) + + expect { linter.check!(sql) }.to raise_error(SecurityError, /Inconclusive PostgreSQL Unicode/) + end + + it 'does not treat denied-looking calls in dollar-quoted literals as functions' do + calls = ['query_to_xml($1)', '"query_to_xml"($1)'] + + calls.each do |call| + sql = "SELECT $q$#{call}$q$ FROM users LIMIT 10" + + expect { linter.check!(sql) }.not_to raise_error, call + end + end + it 'does not overmatch denied text after a doubled quote in a quoted identifier' do sql = 'SELECT "prefix""query_to_xml"($1) FROM users LIMIT 10' From cbb802d251d59f67d5e221f50f3953cfcd397e71 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Tue, 14 Jul 2026 20:23:52 -0500 Subject: [PATCH 77/86] fix: mask quoted identifiers and PostgreSQL E strings --- lib/code_to_query/guardrails/sql_linter.rb | 10 +++-- lib/code_to_query/query/sql_scanning.rb | 41 +++++++++++++++++++ .../guardrails/sql_linter_spec.rb | 12 ++++++ spec/code_to_query/query/sql_scanning_spec.rb | 25 +++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index ddbcf7f..a844eb0 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -280,22 +280,24 @@ def check_no_dangerous_functions!(sql) def check_no_postgres_dynamic_query_functions!(sql) return unless %i[postgres postgresql].include?(@config.adapter.to_sym) - searchable = Query::SqlScanner.new.mask_literals_and_comments(sql) + scanner = Query::SqlScanner.new + quoted_searchable = scanner.mask_literals_and_comments(sql) + executable_searchable = scanner.mask_literals_comments_and_identifier_contents(sql) POSTGRES_DYNAMIC_QUERY_FUNCTIONS.each do |func| - next unless postgres_unquoted_function_call?(searchable, func) + next unless postgres_unquoted_function_call?(executable_searchable, func) raise SecurityError, "Dangerous function '#{func}' is not allowed" end - searchable.scan(POSTGRES_QUOTED_FUNCTION_IDENTIFIER) do + quoted_searchable.scan(POSTGRES_QUOTED_FUNCTION_IDENTIFIER) do identifier = Regexp.last_match[:identifier].gsub('""', '"') next unless POSTGRES_DYNAMIC_QUERY_FUNCTIONS.include?(identifier) raise SecurityError, "Dangerous function '#{identifier}' is not allowed" end - searchable.scan(POSTGRES_UNICODE_FUNCTION_IDENTIFIER) do + quoted_searchable.scan(POSTGRES_UNICODE_FUNCTION_IDENTIFIER) do match = Regexp.last_match escape, function_call = postgres_unicode_function_escape(sql, match.end(0)) next unless function_call diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index cf270d8..0d8426f 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -101,6 +101,13 @@ def mask_literals_and_comments(sql) mask_sql_literals_and_comments(sql.to_s) end + # Also hide quoted identifier bodies when scanning for executable, + # unquoted tokens. Delimiters stay in place so callers can separately + # inspect exact quoted identifiers without losing source offsets. + def mask_literals_comments_and_identifier_contents(sql) + mask_sql_literals_comments_and_identifier_contents(sql.to_s) + end + def policy_predicate_bind?(sql, table, column, bind_number, adapter:) policy_predicate_bind_numbers( sql, table, column, adapter: adapter, question_bind_number: bind_number, source_sql: sql @@ -436,6 +443,16 @@ def skip_parenthesized_sql(source, index) next end state = :code if char == "'" + when :escape_string + if char == '\\' && following + index += 2 + next + end + if char == "'" && following == "'" + index += 2 + next + end + state = :code if char == "'" when :double_quote if char == '"' && following == '"' index += 2 @@ -458,6 +475,10 @@ def skip_parenthesized_sql(source, index) dollar_quote_delimiter = delimiter index += delimiter.length next + elsif postgres_escape_string_start?(source, index) + state = :escape_string + index += 2 + next elsif char == "'" state = :single_quote elsif char == '"' @@ -513,6 +534,17 @@ def mask_sql_literals_and_comments(source) elsif char == "'" state = :code end + when :escape_string + masked[index] = ' ' + if char == '\\' && following + masked[index + 1] = ' ' + index += 1 + elsif char == "'" && following == "'" + masked[index + 1] = ' ' + index += 1 + elsif char == "'" + state = :code + end when :double_quote state = :code if char == '"' when :backtick @@ -534,6 +566,10 @@ def mask_sql_literals_and_comments(source) dollar_quote_delimiter = delimiter index += delimiter.length next + elsif postgres_escape_string_start?(source, index) + masked[index] = masked[index + 1] = ' ' + state = :escape_string + index += 1 elsif char == "'" masked[index] = ' ' state = :single_quote @@ -565,6 +601,11 @@ def dollar_quote_delimiter_at(source, index) source[index..]&.match(/\A\$(?:[a-zA-Z_][a-zA-Z0-9_]*)?\$/)&.[](0) end + def postgres_escape_string_start?(source, index) + source[index]&.casecmp?('E') && source[index + 1] == "'" && + !postgres_identifier_continuation?(index.positive? ? source[index - 1] : nil) + end + public def extract_table_names(sql) diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index f37382d..450515e 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -308,6 +308,18 @@ expect { linter.check!(sql) }.not_to raise_error end + + it 'does not scan denied-looking text inside an ordinary quoted identifier as an unquoted call' do + sql = 'SELECT "prefix query_to_xml ( suffix" FROM users LIMIT 10' + + expect { linter.check!(sql) }.not_to raise_error + end + + it 'does not scan denied-looking text inside a Unicode quoted identifier as an unquoted call' do + sql = 'SELECT U&"prefix query_to_xml ( suffix" FROM users LIMIT 10' + + expect { linter.check!(sql) }.not_to raise_error + end end it 'does not apply the PostgreSQL dynamic-query denylist to other adapters' do diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index bfaacd8..eceb6a7 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -34,6 +34,31 @@ expect(scanner.bind_placeholder_positions(sql)).to eq([[sql.rindex('?'), 1]]) end + + it 'honors backslash-escaped apostrophes in PostgreSQL E strings' do + %w[E e].each do |prefix| + sql = "SELECT #{prefix}'prefix\\' query_to_xml($1) suffix', $2" + + expect(scanner.bind_placeholder_positions(sql)).to eq([[sql.index('$2'), 2]]) + end + end + + it 'distinguishes ordinary strings from PostgreSQL E strings' do + sql = %q(SELECT 'ordinary\' , $1) + + expect(scanner.bind_placeholder_positions(sql)).to eq([[sql.index('$1'), 1]]) + end + end + + describe '#mask_literals_comments_and_identifier_contents' do + it 'masks denied-looking text in literals and quoted identifier bodies' do + sql = "SELECT E'prefix\\' query_to_xml($1)', \"query_to_xml ( suffix\", " \ + 'U&"query_to_xml ( decoy", query_to_xml($2)' + masked = scanner.mask_literals_comments_and_identifier_contents(sql) + + expect(masked.scan('query_to_xml').length).to eq(1) + expect(masked.index('query_to_xml')).to eq(sql.rindex('query_to_xml')) + end end describe '#exists_subqueries' do From 95c46e6e6745e9335e755f1c2c250b624f9d0402 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Tue, 14 Jul 2026 20:36:26 -0500 Subject: [PATCH 78/86] fix: deny PostgreSQL XML export functions --- lib/code_to_query/guardrails/sql_linter.rb | 20 +++-- .../guardrails/sql_linter_spec.rb | 82 ++++++++++++++++++- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index a844eb0..28ab61c 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -12,8 +12,16 @@ class SqlLinter inet_server_addr inet_client_addr ].freeze - POSTGRES_DYNAMIC_QUERY_FUNCTIONS = %w[ + # PostgreSQL's complete built-in server-side XML export family. These + # functions read a relation, query, cursor, schema, or the whole current + # database without putting the exported relations in a FROM/JOIN clause, + # so every member can bypass this linter's table allowlist. + POSTGRES_SERVER_SIDE_XML_EXPORT_FUNCTIONS = %w[ + table_to_xml table_to_xmlschema table_to_xml_and_xmlschema query_to_xml query_to_xmlschema query_to_xml_and_xmlschema + cursor_to_xml cursor_to_xmlschema + schema_to_xml schema_to_xmlschema schema_to_xml_and_xmlschema + database_to_xml database_to_xmlschema database_to_xml_and_xmlschema ].freeze POSTGRES_QUOTED_FUNCTION_IDENTIFIER = / @@ -37,7 +45,7 @@ def check!(sql) check_dangerous_patterns!(normalized) check_required_limit!(normalized) check_table_allowlist!(normalized) if @allow_tables.any? - check_no_postgres_dynamic_query_functions!(normalized) + check_no_postgres_server_side_xml_export_functions!(normalized) check_no_literals!(normalized) check_no_dangerous_functions!(normalized) check_no_subqueries!(normalized) if @config.block_subqueries @@ -277,14 +285,14 @@ def check_no_dangerous_functions!(sql) end end - def check_no_postgres_dynamic_query_functions!(sql) + def check_no_postgres_server_side_xml_export_functions!(sql) return unless %i[postgres postgresql].include?(@config.adapter.to_sym) scanner = Query::SqlScanner.new quoted_searchable = scanner.mask_literals_and_comments(sql) executable_searchable = scanner.mask_literals_comments_and_identifier_contents(sql) - POSTGRES_DYNAMIC_QUERY_FUNCTIONS.each do |func| + POSTGRES_SERVER_SIDE_XML_EXPORT_FUNCTIONS.each do |func| next unless postgres_unquoted_function_call?(executable_searchable, func) raise SecurityError, "Dangerous function '#{func}' is not allowed" @@ -292,7 +300,7 @@ def check_no_postgres_dynamic_query_functions!(sql) quoted_searchable.scan(POSTGRES_QUOTED_FUNCTION_IDENTIFIER) do identifier = Regexp.last_match[:identifier].gsub('""', '"') - next unless POSTGRES_DYNAMIC_QUERY_FUNCTIONS.include?(identifier) + next unless POSTGRES_SERVER_SIDE_XML_EXPORT_FUNCTIONS.include?(identifier) raise SecurityError, "Dangerous function '#{identifier}' is not allowed" end @@ -310,7 +318,7 @@ def check_no_postgres_dynamic_query_functions!(sql) unless identifier raise SecurityError, 'Inconclusive PostgreSQL Unicode function identifier' end - next unless POSTGRES_DYNAMIC_QUERY_FUNCTIONS.include?(identifier) + next unless POSTGRES_SERVER_SIDE_XML_EXPORT_FUNCTIONS.include?(identifier) raise SecurityError, "Dangerous function '#{identifier}' is not allowed" end diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 450515e..502d2e9 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -219,6 +219,16 @@ context 'with PostgreSQL server-side query execution' do let(:config) { stub_config(adapter: :postgres, max_limit: 1000, max_joins: 2) } + let(:xml_export_functions) do + %w[ + table_to_xml table_to_xmlschema table_to_xml_and_xmlschema + query_to_xml query_to_xmlschema query_to_xml_and_xmlschema + cursor_to_xml cursor_to_xmlschema + schema_to_xml schema_to_xmlschema schema_to_xml_and_xmlschema + database_to_xml database_to_xmlschema database_to_xml_and_xmlschema + ] + end + it 'blocks query_to_xml from hiding a non-allowlisted table in dollar-quoted SQL' do sql = 'SELECT query_to_xml($q$TABLE secrets$q$, true, false, $x$$x$) FROM users LIMIT 10' @@ -235,6 +245,59 @@ end end + it 'blocks direct relation, schema, database, and cursor export calls with their real signatures' do + calls = [ + 'table_to_xml(to_regclass($1), true, false, $2)', + 'schema_to_xml($1, true, false, $2)', + 'database_to_xml(true, false, $1)', + 'cursor_to_xml($1, 100, true, false, $2)' + ] + + calls.each do |function_call| + sql = "SELECT #{function_call} FROM users LIMIT 10" + + expect { linter.check!(sql) }.to raise_error(SecurityError), function_call + end + end + + it 'blocks the complete built-in relation, query, cursor, schema, and database XML export family' do + xml_export_functions.each do |function| + sql = "SELECT #{function}($1) FROM users LIMIT 10" + + expect { linter.check!(sql) }.to raise_error(SecurityError, /#{function}/), function + end + end + + it 'blocks every XML export function across quoted and schema-qualified forms' do + xml_export_functions.each do |function| + [ + %("#{function}"), + "public.#{function}", + %(public."#{function}") + ].each do |function_call| + sql = "SELECT #{function_call}($1) FROM users LIMIT 10" + + expect { linter.check!(sql) }.to raise_error(SecurityError, /#{function}/), function_call + end + end + end + + it 'blocks every XML export function as a Unicode-escaped quoted identifier' do + xml_export_functions.each do |function| + encoded = function.sub('x', '!0078') + calls = [ + %(U&"#{function}"), + %(public.U&"#{encoded}" UESCAPE '!') + ] + + calls.each do |function_call| + sql = "SELECT #{function_call}($1) FROM users LIMIT 10" + + expect { linter.check!(sql) }.to raise_error(SecurityError, /#{function}/), function_call + end + end + end + it 'blocks schema-qualified and quoted query_to_xml calls' do calls = ['pg_catalog.query_to_xml', 'pg_catalog."query_to_xml"'] @@ -309,6 +372,23 @@ expect { linter.check!(sql) }.not_to raise_error end + it 'does not overmatch longer identifiers based on any XML export function name' do + xml_export_functions.each do |function| + calls = [ + "#{function}_backup", + %("#{function}_backup"), + "public.#{function}_backup", + %(public.U&"#{function}_backup") + ] + + calls.each do |function_call| + sql = "SELECT #{function_call}($1) FROM users LIMIT 10" + + expect { linter.check!(sql) }.not_to raise_error, function_call + end + end + end + it 'does not scan denied-looking text inside an ordinary quoted identifier as an unquoted call' do sql = 'SELECT "prefix query_to_xml ( suffix" FROM users LIMIT 10' @@ -322,7 +402,7 @@ end end - it 'does not apply the PostgreSQL dynamic-query denylist to other adapters' do + it 'does not apply the PostgreSQL server-side XML export denylist to other adapters' do mysql_config = stub_config(adapter: :mysql, max_limit: 1000, max_joins: 2) mysql_linter = described_class.new(mysql_config, allow_tables: %w[users]) calls = ['query_to_xml', 'U&"query_to_\+000078ml"'] From a9b48a033fe81cd9a2b7935c4c2cc321c72b9d74 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Wed, 15 Jul 2026 10:56:57 -0500 Subject: [PATCH 79/86] fix: parse PostgreSQL ONLY relation modifiers --- lib/code_to_query/guardrails/sql_linter.rb | 8 ++++++-- lib/code_to_query/query/sql_scanning.rb | 5 ++++- spec/code_to_query/guardrails/sql_linter_spec.rb | 11 +++++++++++ spec/code_to_query/query/sql_scanning_spec.rb | 8 ++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index 28ab61c..91f19cf 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -515,16 +515,20 @@ def check_time_delay_patterns!(sql) def extract_table_names(sql) tables = [] + relation_modifier = %i[postgres postgresql].include?(@config.adapter.to_sym) ? '(?:ONLY\s+)?' : '' + table_reference = /(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+)(?:\s+(?:AS\s+)?[a-zA-Z_][a-zA-Z0-9_]*)?)/ # Extract FROM clause tables (improved regex) - from_matches = sql.scan(/\bFROM\s+(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+)(?:\s+(?:AS\s+)?[a-zA-Z_][a-zA-Z0-9_]*)?)/i) + from_matches = sql.scan(/\bFROM\s+#{relation_modifier}#{table_reference}/i) from_matches.each do |match| table_name = match.compact.first tables << table_name if table_name end # Extract JOIN clause tables (improved regex) - join_matches = sql.scan(/\b(?:INNER\s+|LEFT\s+|RIGHT\s+|FULL\s+|CROSS\s+)?JOIN\s+(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+)(?:\s+(?:AS\s+)?[a-zA-Z_][a-zA-Z0-9_]*)?)/i) + join_matches = sql.scan( + /\b(?:INNER\s+|LEFT\s+|RIGHT\s+|FULL\s+|CROSS\s+)?JOIN\s+#{relation_modifier}#{table_reference}/i + ) join_matches.each do |match| table_name = match.compact.first tables << table_name if table_name diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 0d8426f..bfccf52 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -674,7 +674,10 @@ def join_clause_pattern def extract_table_reference_identifier(reference) alias_identifier = '(?:`[^`]+`|"[^"]+"|\'[^\']+\'|[a-zA-Z_][a-zA-Z0-9_]*)' - table_reference_pattern = /\A(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i + # ONLY is a PostgreSQL relation modifier, not the relation itself. If + # it is consumed as an ordinary identifier, the following relation is + # mistaken for an alias and can evade an allowlist containing `only`. + table_reference_pattern = /\A(?:ONLY\s+)?(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i match = reference.to_s.strip.match(table_reference_pattern) return unless match diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 502d2e9..3c3928e 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -92,6 +92,17 @@ expect { linter.check!(sql) }.to raise_error(SecurityError, /not in the allowed list/) end + it 'does not allow PostgreSQL ONLY to hide a non-allowlisted relation' do + only_linter = described_class.new(config, allow_tables: ['only']) + + expect { only_linter.check!('SELECT * FROM ONLY "admin_secrets" LIMIT 100') } + .to raise_error(SecurityError, /admin_secrets.*not in the allowed list/) + end + + it 'allows an allowlisted quoted relation after PostgreSQL ONLY' do + expect { linter.check!('SELECT * FROM ONLY "users" LIMIT 100') }.not_to raise_error + end + it 'validates JOIN table allowlist' do sql = 'SELECT * FROM "users" JOIN "admin_secrets" ON users.id = admin_secrets.user_id LIMIT 100' expect { linter.check!(sql) }.to raise_error(SecurityError, /not in the allowed list/) diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index eceb6a7..a90fbc1 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -253,6 +253,14 @@ end describe '#extract_table_names' do + it 'treats PostgreSQL ONLY as a relation modifier' do + expect(scanner.extract_table_names('SELECT * FROM ONLY admin_secrets')).to eq(['admin_secrets']) + end + + it 'extracts a quoted relation after PostgreSQL ONLY' do + expect(scanner.extract_table_names('SELECT * FROM ONLY "Admin Secrets"')).to eq(['Admin Secrets']) + end + it 'fails closed for qualified table references instead of discarding the qualifier' do expect { scanner.extract_table_names('SELECT * FROM evil.users') } .to raise_error(SecurityError, /Unsupported FROM reference/) From 3ad5e0cb09fcd7b72a66a1ab0fa5dda27f7f7134 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Wed, 15 Jul 2026 11:06:26 -0500 Subject: [PATCH 80/86] fix: scope ONLY parsing to PostgreSQL --- lib/code_to_query/query.rb | 2 +- lib/code_to_query/query/sql_scanning.rb | 7 +++++- .../guardrails/sql_linter_spec.rb | 7 ++++++ spec/code_to_query/query/sql_scanning_spec.rb | 25 +++++++++++++++++-- spec/code_to_query/query_spec.rb | 16 +++++++++--- 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index c3cdf7f..513072f 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -597,7 +597,7 @@ def allowlist_names_equivalent?(left, right) end def sql_scanner - @sql_scanner ||= SqlScanner.new + @sql_scanner ||= SqlScanner.new(adapter: @config.adapter) end def infer_column_type(connection, table_name, column_name, explicit_cast, param_key = column_name) diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index bfccf52..0a5151a 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -23,6 +23,10 @@ class SqlScanner UNIQUE USER USING VARIADIC VERBOSE WHEN WHERE WINDOW WITH ].freeze + def initialize(adapter: nil) + @adapter = adapter&.to_sym + end + def strip_exists_subqueries(sql) source = sql.to_s searchable = mask_sql_literals_comments_and_identifier_contents(source) @@ -677,7 +681,8 @@ def extract_table_reference_identifier(reference) # ONLY is a PostgreSQL relation modifier, not the relation itself. If # it is consumed as an ordinary identifier, the following relation is # mistaken for an alias and can evade an allowlist containing `only`. - table_reference_pattern = /\A(?:ONLY\s+)?(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i + relation_modifier = %i[postgres postgresql].include?(@adapter) ? '(?:ONLY\\s+)?' : '' + table_reference_pattern = /\A#{relation_modifier}(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([a-zA-Z0-9_]+))(?:\s+(?:AS\s+)?#{alias_identifier})?\z/i match = reference.to_s.strip.match(table_reference_pattern) return unless match diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 3c3928e..15b283c 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -103,6 +103,13 @@ expect { linter.check!('SELECT * FROM ONLY "users" LIMIT 100') }.not_to raise_error end + it 'does not allow PostgreSQL JOIN ONLY to hide a non-allowlisted relation' do + only_linter = described_class.new(config, allow_tables: %w[users only]) + + expect { only_linter.check!('SELECT * FROM users JOIN ONLY admin_secrets ON TRUE LIMIT 100') } + .to raise_error(SecurityError, /admin_secrets.*not in the allowed list/) + end + it 'validates JOIN table allowlist' do sql = 'SELECT * FROM "users" JOIN "admin_secrets" ON users.id = admin_secrets.user_id LIMIT 100' expect { linter.check!(sql) }.to raise_error(SecurityError, /not in the allowed list/) diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index a90fbc1..41d644c 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -254,11 +254,32 @@ describe '#extract_table_names' do it 'treats PostgreSQL ONLY as a relation modifier' do - expect(scanner.extract_table_names('SELECT * FROM ONLY admin_secrets')).to eq(['admin_secrets']) + postgres_scanner = described_class.new(adapter: :postgres) + + expect(postgres_scanner.extract_table_names('SELECT * FROM ONLY admin_secrets')).to eq(['admin_secrets']) end it 'extracts a quoted relation after PostgreSQL ONLY' do - expect(scanner.extract_table_names('SELECT * FROM ONLY "Admin Secrets"')).to eq(['Admin Secrets']) + postgres_scanner = described_class.new(adapter: :postgres) + + expect(postgres_scanner.extract_table_names('SELECT * FROM ONLY "Admin Secrets"')).to eq(['Admin Secrets']) + end + + it 'treats PostgreSQL ONLY as a JOIN relation modifier' do + postgres_scanner = described_class.new(adapter: :postgres) + + expect(postgres_scanner.extract_table_names('SELECT * FROM users JOIN ONLY admin_secrets ON TRUE')) + .to eq(%w[users admin_secrets]) + end + + it 'keeps ONLY as the relation name on adapters where it is not a modifier' do + %i[mysql sqlite].each do |adapter| + adapter_scanner = described_class.new(adapter: adapter) + + expect(adapter_scanner.extract_table_names('SELECT * FROM ONLY admin_secrets')).to eq(['ONLY']) + expect(adapter_scanner.extract_table_names('SELECT * FROM users JOIN ONLY admin_secrets ON TRUE')) + .to eq(%w[users ONLY]) + end end it 'fails closed for qualified table references instead of discarding the qualifier' do diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index dae5995..abaf331 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -977,13 +977,23 @@ class << self expect(q.safe?).to be false # SqlLinter intentionally rejects SQL comments. end - it 'fails closed for an unparseable top-level table reference' do + it 'parses PostgreSQL ONLY in the central top-level allowlist check' do q = described_class.new( - sql: 'SELECT * FROM ONLY "questions"', params: {}, bind_spec: [], + sql: 'SELECT * FROM ONLY "questions" LIMIT 1', params: {}, bind_spec: [], intent: { 'table' => 'questions', 'type' => 'select' }, allow_tables: ['questions'], config: config ) - expect(q.safe?).to be false + expect(q.safe?).to be true + end + + it 'does not let allow_tables only hide another relation from the central allowlist check' do + q = described_class.new( + sql: 'SELECT * FROM ONLY admin_secrets LIMIT 1', params: {}, bind_spec: [], + intent: { 'table' => 'only', 'type' => 'select' }, allow_tables: ['only'], config: config + ) + + expect { q.send(:check_top_level_table_allowlist!) } + .to raise_error(SecurityError, /admin_secrets.*not in the allowed list/) end it 'rejects a disallowed FROM table continued on a second line' do From 7ca34f32072170c8374112bb51b1cada65c50734 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Sun, 19 Jul 2026 23:18:22 -0500 Subject: [PATCH 81/86] docs: clarify policy query compatibility --- CHANGELOG.md | 3 +++ docs/pundit-policy-adapter.md | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1059d0a..a264972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ This file tracks the major changes in each release. - Added a security policy and threat model covering safe deployment defaults, cross-tenant data exposure, schema-context filtering, observability data exposure, and vulnerability reporting. ### Changed +- Security compatibility: when a `policy_adapter` is configured, executable `Query` objects must come from `CodeToQuery.ask` (or the compiler path) with the compiler's opaque policy contract. Directly constructed queries have no valid contract and now fail closed. +- `Query#sql`, `#params`, `#intent`, and `#metrics` now return detached deep copies. Mutating a returned value no longer changes query state; repeated reads may allocate new objects. +- Compiled policy queries are bound to the exact policy adapter and configuration identity used at compilation. Replacing the adapter or relevant configuration invalidates existing queries; compile them again before execution. - Compatibility note: policy adapters that explicitly return `allowed_tables: []` deny access to all tables, as intended; omit `allowed_tables` only when no policy table allowlist is being supplied. - Added explicit explain-gate profiles and deployment tradeoff guidance for `explain_fail_open` in the README, including strict, availability-first, and local/test configuration patterns. - Breaking default change: EXPLAIN gate errors now fail closed by default; set `explain_fail_open = true` only for availability-first readonly deployments that can tolerate skipped EXPLAIN checks. diff --git a/docs/pundit-policy-adapter.md b/docs/pundit-policy-adapter.md index 2a34db3..8e078a9 100644 --- a/docs/pundit-policy-adapter.md +++ b/docs/pundit-policy-adapter.md @@ -27,6 +27,12 @@ Keep these boundaries explicit: - **Pundit remains authoritative.** Use Pundit to decide whether the user can access the reporting surface. Do not use natural-language prompts as authorization input. - **Keep logs narrow.** Prefer logging table names, policy key names, and decisions. Avoid logging raw prompts, row data, full bind values, credentials, or full generated schema context in normal application logs. +### Query lifecycle and compatibility + +- With a `policy_adapter` configured, obtain executable queries through `CodeToQuery.ask` or the compiler path. Policy compilation issues an opaque contract that callers cannot construct; a directly instantiated `CodeToQuery::Query` lacks that evidence and fails closed at safety checks and execution. +- A compiled query's policy contract is bound to the exact policy adapter object and configuration instance used to compile it, including the captured database adapter and policy fail-open setting. Replacing the policy adapter or changing that relevant configuration invalidates the query. Recompile instead of retaining policy-bearing queries across configuration changes or application reloads. +- The public `Query#sql`, `#params`, `#intent`, and `#metrics` readers return detached deep copies. Mutating those values does not alter the immutable state used for safety checks or execution, and repeated reads may allocate new objects. + ## Lambda adapter for tenant, account, and user predicates This example keeps table and column exposure small and derives row predicates from trusted application state. It raises on missing users, unknown tables, and denied policies so CodeToQuery fails closed. Define the custom Pundit predicates such as `code_to_query?`, `view_all_tickets?`, and `view_account_users?` on the relevant policies before using this shape. From 04198f50ef64b819feaab11efaa8fba267da5ab0 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 23 Jul 2026 09:06:41 -0500 Subject: [PATCH 82/86] fix: enforce policy contract for relations --- lib/code_to_query/query.rb | 1 + spec/code_to_query/query_spec.rb | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 513072f..46cc248 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -137,6 +137,7 @@ def to_active_record def relationable? return false unless defined?(ActiveRecord::Base) return false unless @intent['type'] == 'select' + return false if @config.policy_adapter && !compiler_policy_contract? return false if compiler_only_subquery_policy_filters? !!infer_model_for_table(@intent['table']) diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index abaf331..a474a07 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -1519,6 +1519,16 @@ class << self expect(scope).to have_received(:limit).with(100) end + it 'returns nil for a directly constructed query when a policy adapter is configured' do + scope = double('scope') + allow(scope).to receive_messages(where: scope, order: scope, limit: scope) + build_scope_backed_user_model(scope) + config.policy_adapter = ->(_context) { { filters: [] } } + + expect(query.to_relation).to be_nil + expect(scope).not_to have_received(:where) + end + it 'returns nil when relation semantics would drop compiler-only subquery policy predicates' do scope = double('scope') allow(scope).to receive_messages(where: scope, order: scope, limit: scope) @@ -1547,6 +1557,13 @@ class << self expect(build_subquery_policy_query(config).relationable?).to be false end + + it 'returns false without a compiler policy contract when a policy adapter is configured' do + build_scope_backed_user_model(double('scope')) + config.policy_adapter = ->(_context) { { filters: [] } } + + expect(query.relationable?).to be false + end end describe '#to_relation!' do From 231adf6ab189a8c84100260f71d6e458ba2a96b4 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Wed, 29 Jul 2026 21:20:18 -0500 Subject: [PATCH 83/86] fix: reject MySQL TABLE query expressions --- lib/code_to_query/guardrails/sql_linter.rb | 8 ++++++-- spec/code_to_query/guardrails/sql_linter_spec.rb | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index 91f19cf..c6d1ba2 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -72,10 +72,14 @@ def check_statement_type!(sql) end def check_unsupported_table_query_expressions!(sql) - return unless %i[postgres postgresql].include?(@config.adapter.to_sym) + database = case @config.adapter.to_sym + when :postgres, :postgresql then 'PostgreSQL' + when :mysql then 'MySQL' + else return + end return unless Query::SqlScanner.new.table_query_expression?(sql) - raise SecurityError, 'PostgreSQL TABLE query expressions are not supported' + raise SecurityError, "#{database} TABLE query expressions are not supported" end def check_dangerous_patterns!(sql) diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 15b283c..8e5a67e 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -197,6 +197,20 @@ it 'allows an exact-case quoted table' do expect { linter.check!('SELECT * FROM `users` LIMIT 100') }.not_to raise_error end + + it 'rejects TABLE query expressions inside IN subqueries' do + sql = 'SELECT * FROM users WHERE id IN (TABLE admin_secrets) LIMIT 100' + + expect { linter.check!(sql) } + .to raise_error(SecurityError, /MySQL TABLE query expressions are not supported/) + end + + it 'rejects TABLE query expressions used as nested set-operation operands' do + sql = 'SELECT * FROM users WHERE EXISTS (SELECT * FROM users UNION TABLE admin_secrets) LIMIT 100' + + expect { linter.check!(sql) } + .to raise_error(SecurityError, /MySQL TABLE query expressions are not supported/) + end end end From c74df835efa6357e54619d96c5d985014b06a01b Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 30 Jul 2026 14:51:23 -0500 Subject: [PATCH 84/86] fix: reject backtick-quoted MySQL table queries --- lib/code_to_query/guardrails/sql_linter.rb | 2 +- lib/code_to_query/query/sql_scanning.rb | 15 ++++++++++----- spec/code_to_query/guardrails/sql_linter_spec.rb | 7 +++++++ spec/code_to_query/query/sql_scanning_spec.rb | 7 +++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/code_to_query/guardrails/sql_linter.rb b/lib/code_to_query/guardrails/sql_linter.rb index c6d1ba2..a82c5b2 100644 --- a/lib/code_to_query/guardrails/sql_linter.rb +++ b/lib/code_to_query/guardrails/sql_linter.rb @@ -77,7 +77,7 @@ def check_unsupported_table_query_expressions!(sql) when :mysql then 'MySQL' else return end - return unless Query::SqlScanner.new.table_query_expression?(sql) + return unless Query::SqlScanner.new(adapter: @config.adapter).table_query_expression?(sql) raise SecurityError, "#{database} TABLE query expressions are not supported" end diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index 0a5151a..ab73912 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -182,8 +182,8 @@ def sql_tokens(source) type: :identifier, value: source[index...finish], quoted: true, unicode_quoted: true } index = finish - elsif searchable[index] == '"' - finish = quoted_identifier_finish(source, index) + elsif (delimiter = quoted_identifier_delimiter(searchable[index])) + finish = quoted_identifier_finish(source, index, delimiter) tokens << { type: :identifier, value: source[index...finish], quoted: true } index = finish elsif postgres_identifier_start?(searchable[index]) @@ -199,12 +199,12 @@ def sql_tokens(source) tokens end - def quoted_identifier_finish(source, index) + def quoted_identifier_finish(source, index, delimiter = '"') index += 1 while index < source.length - if source[index] == '"' && source[index + 1] == '"' + if source[index] == delimiter && source[index + 1] == delimiter index += 2 - elsif source[index] == '"' + elsif source[index] == delimiter return index + 1 else index += 1 @@ -213,6 +213,11 @@ def quoted_identifier_finish(source, index) source.length end + def quoted_identifier_delimiter(character) + return '"' if character == '"' + return '`' if @adapter == :mysql && character == '`' + end + # PostgreSQL lexes U&"..." (with no whitespace around the ampersand) as # one Unicode-escaped delimited identifier. Treating U, &, and the quoted # portion as separate tokens would miss a valid TABLE relation operand. diff --git a/spec/code_to_query/guardrails/sql_linter_spec.rb b/spec/code_to_query/guardrails/sql_linter_spec.rb index 8e5a67e..31f1407 100644 --- a/spec/code_to_query/guardrails/sql_linter_spec.rb +++ b/spec/code_to_query/guardrails/sql_linter_spec.rb @@ -211,6 +211,13 @@ expect { linter.check!(sql) } .to raise_error(SecurityError, /MySQL TABLE query expressions are not supported/) end + + it 'rejects TABLE query expressions with backtick-quoted relations' do + sql = 'SELECT * FROM users WHERE EXISTS (TABLE `admin_secrets`) LIMIT 100' + + expect { linter.check!(sql) } + .to raise_error(SecurityError, /MySQL TABLE query expressions are not supported/) + end end end diff --git a/spec/code_to_query/query/sql_scanning_spec.rb b/spec/code_to_query/query/sql_scanning_spec.rb index 41d644c..d8bd964 100644 --- a/spec/code_to_query/query/sql_scanning_spec.rb +++ b/spec/code_to_query/query/sql_scanning_spec.rb @@ -147,6 +147,13 @@ expect(scanner.table_query_expression?(expression)).to be(true), expression end end + + it 'recognizes MySQL backtick-quoted TABLE relations when configured for MySQL' do + mysql_scanner = described_class.new(adapter: :mysql) + + expect(mysql_scanner.table_query_expression?('SELECT EXISTS (TABLE `admin_secrets`) LIMIT 1')).to be true + expect(mysql_scanner.table_query_expression?('SELECT EXISTS (TABLE `schema`.`admin_secrets`) LIMIT 1')).to be true + end end describe '#policy_predicate_bind_numbers' do From a569459ca418940abf8db6eb57705a036bb6cf62 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Thu, 30 Jul 2026 14:53:47 -0500 Subject: [PATCH 85/86] style: satisfy SQL scanner lint --- lib/code_to_query/query/sql_scanning.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/code_to_query/query/sql_scanning.rb b/lib/code_to_query/query/sql_scanning.rb index ab73912..2bf9d80 100644 --- a/lib/code_to_query/query/sql_scanning.rb +++ b/lib/code_to_query/query/sql_scanning.rb @@ -215,7 +215,8 @@ def quoted_identifier_finish(source, index, delimiter = '"') def quoted_identifier_delimiter(character) return '"' if character == '"' - return '`' if @adapter == :mysql && character == '`' + + '`' if @adapter == :mysql && character == '`' end # PostgreSQL lexes U&"..." (with no whitespace around the ampersand) as From bdda08ef72cbd073c521994666a6588013f8c4c3 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Fri, 31 Jul 2026 17:40:12 -0500 Subject: [PATCH 86/86] fix: allow self-referential policy subqueries --- lib/code_to_query/query.rb | 2 ++ spec/code_to_query/query_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/code_to_query/query.rb b/lib/code_to_query/query.rb index 46cc248..0e7100b 100644 --- a/lib/code_to_query/query.rb +++ b/lib/code_to_query/query.rb @@ -453,8 +453,10 @@ def check_policy_scoped_related_table_references! policy_scoped_related_tables = declared_related_tables return unless allowlist_sources_present? + base_intent_table = @intent['table']&.to_s extract_table_identifiers(strip_exists_subqueries(@sql)).each do |table| next unless policy_scoped_related_tables.any? { |allowed| table_identifier_allowed?(table, allowed) } + next if base_intent_table && table_identifier_allowed?(table, base_intent_table) raise SecurityError, "Table '#{table.name}' is only allowed inside declared EXISTS/NOT EXISTS filters" diff --git a/spec/code_to_query/query_spec.rb b/spec/code_to_query/query_spec.rb index a474a07..0458c33 100644 --- a/spec/code_to_query/query_spec.rb +++ b/spec/code_to_query/query_spec.rb @@ -648,6 +648,32 @@ class << self expect(q.safe?).to be true end + it 'accepts a self-referential EXISTS on the allowlisted base intent table' do + q = described_class.new( + sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "questions" WHERE "questions"."parent_id" = "questions"."id") LIMIT 100', + params: {}, + bind_spec: [], + intent: { + 'table' => 'questions', + 'type' => 'select', + 'filters' => [ + { + 'column' => 'id', + 'op' => 'exists', + 'related_table' => 'questions', + 'fk_column' => 'parent_id', + 'base_column' => 'id', + 'related_filters' => [] + } + ] + }, + allow_tables: ['questions'], + config: config + ) + + expect(q.safe?).to be true + end + it 'rejects an undeclared EXISTS table even when the top-level allowlist permits it' do q = described_class.new( sql: 'SELECT * FROM "questions" WHERE EXISTS (SELECT 1 FROM "answers")',