woods 1.6.2 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 135a4869f2fdd230990dc723a96d62f19447e51295768392d25bd0125dd4e25a
4
- data.tar.gz: bdce75a6b653bd5da5f3a7f4e7c9180020235f5a232daa5e58a4d439824e9a34
3
+ metadata.gz: b77f03a9d89a92c666a6ef9b8b20ed8583270fa0cfbb923acea9d6fbc973f9d2
4
+ data.tar.gz: 8336dccba436603daadaccd125888bc40702f16c4f514002bff298c9d8905f9e
5
5
  SHA512:
6
- metadata.gz: 7dbe8d5460b1775c4f785954c88539fb29a76ad2878ee58e5e8ca11616787c87a058b9e9a774867f3cff4afe0c1b8ade137fd657f5249995d594e2ce2d7dfd8e
7
- data.tar.gz: 99db15fe718972172398fcf1574b33e26e94869beeb0d90c95e7adcb6b41b4a53ad3b3c4fe34067ba01df7e485dec61e73cda5b739dda89dab665f9c6bfb3282
6
+ metadata.gz: ff12092063cd2a853c293812705b64f38c83da9a682d89bdcf243665ab810b5d5368e2d086a3a2949d885d677845637a3773b58c4322b62e56b83585f6f7e208
7
+ data.tar.gz: '0329bd31a7ddd6b0d05dedb46b4942a09d5534faf69668e77d45b1b9a8d37f1c3b860cc222446ddef7e64717e716d7388ef1217f3efe80578e481b875a7edba6'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.3] - 2026-09-22
11
+
12
+ ### Build
13
+
14
+ - Add the explicit, no-publish preparation cycle for the 1.6.3 security maintenance release; keep version writes task-owned and publication dependent on a reviewed trusted-main SHA pin.
15
+
16
+ ### Security
17
+
18
+ - Apply blocked-table policy to resolved model relations and SQLite table syntax before reads, retain table boundaries across comments, and reject quoted spellings of existing denied SQL functions.
19
+ - Scan the serialized Console response after protected-field redaction so Symbol values and custom JSON output cannot bypass credential scanning.
20
+
10
21
  ## [1.6.2] - 2026-09-18
11
22
 
12
23
  ### Fixed
data/CONTRIBUTING.md CHANGED
@@ -128,29 +128,34 @@ By contributing, you agree that your contributions will be licensed under the MI
128
128
 
129
129
  ## Maintenance release
130
130
 
131
- This tree has a one-off, no-publish preparation adapter for the reviewed 1.6.2
131
+ This tree has a one-off, no-publish preparation adapter for the reviewed 1.6.3
132
132
  security patch while `main` carries the newer 2.0 prerelease line. It does not
133
133
  establish a permanent stable branch or authorize another maintenance version.
134
- The approved target is `release/1.6.2`, based on immutable `v1.6.1`. Create that
134
+ The approved target is `release/1.6.3`, based on immutable `v1.6.2`. Create that
135
135
  remote target only after the trusted main maintenance policy is reviewed.
136
136
 
137
137
  From a clean checkout, the only supported transitions are:
138
138
 
139
139
  ```sh
140
- bin/rake "release:reopen[1.6.2.alpha]"
140
+ bin/rake "release:reopen[1.6.3.alpha]"
141
141
  # Review and commit the generated development-state diff.
142
- bin/rake "release:prepare[1.6.2]"
142
+ bin/rake "release:prepare[1.6.3]"
143
143
  ```
144
144
 
145
145
  The tasks never commit, tag, push, dispatch or publish. Never edit VERSION or the
146
- README maintenance `release-state` banner manually. The first reopen creates that
147
- banner; prepare updates it and folds classified Unreleased notes and optional
148
- `changelog/<type>_<slug>.md` entries into a dated release heading. Entry files are
146
+ README maintenance `release-state` banner manually. The existing maintenance
147
+ banner must be present; both tasks update it. Prepare also folds classified
148
+ Unreleased notes and optional `changelog/<type>_<slug>.md` entries into a dated
149
+ release heading. Entry files are
149
150
  nonempty Markdown without headings; supported types include `fixed`, `security`,
150
151
  `build`, and `documentation`. Invalid transitions, dirty trees, malformed notes,
151
152
  and missing/duplicate/unknown fences refuse before writes. This legacy profile
152
153
  has no v2 migration guide or v2 surface inventory requirement.
153
154
 
155
+ CI also runs on pushes to the exact `release/1.6.3` branch, so a private-advisory
156
+ merge receives upstream validation before the trusted-main SHA pin. Publication
157
+ still requires a separate green tag-push run.
158
+
154
159
  Validate the full suite, lint, booted extraction, real Console credential rotation,
155
160
  and installed maintenance package tests. CI builds one gem plus its SHA-256
156
161
  sidecar into `woods-release-<commit SHA>` and tests that artifact on Ruby 3.0/Rails
@@ -165,12 +170,12 @@ ruby -rrubygems -e 'load Gem.bin_path("rspec-core", "rspec")' -- \
165
170
  --options /dev/null spec/integration/maintenance_packaged_gem_spec.rb
166
171
  ```
167
172
 
168
- Preparation is not publication; check RubyGems before describing 1.6.2 as released.
173
+ Preparation is not publication; check RubyGems before describing 1.6.3 as released.
169
174
  The legacy automatic tag publisher is disabled, and Bundler's `release`,
170
175
  `release:rubygem_push`, and `release:source_control_push` tasks abort. Only a
171
176
  maintainer may later tag the reviewed merge commit and dispatch the trusted
172
177
  **main** workflow. That workflow must explicitly allow the exact tag, protected
173
- maintenance branch, immutable 1.6.1 base, reviewed final candidate SHA, required
178
+ maintenance branch, immutable 1.6.2 base, reviewed final candidate SHA, required
174
179
  CI jobs, and immutable artifact. An unpinned candidate remains blocked. First merge a reviewed trusted-main profile update pinning `approved_sha` to that
175
180
  exact maintenance merge SHA; only then may the maintainer tag it. The
176
181
  workflow publishes the already tested gem bytes; never rebuild or publish locally.
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  # Woods
6
6
 
7
7
  <!-- release-state:maintenance-banner -->
8
- > **Woods 1.6.2: prepared maintenance candidate.** This tree remains on the 1.6 maintenance line.
8
+ > **Woods 1.6.3: prepared maintenance candidate.** This tree remains on the 1.6 maintenance line.
9
9
  > Preparation does not establish publication. Check [RubyGems versions](https://rubygems.org/gems/woods/versions)
10
10
  > before selecting an install version. See [maintenance release policy](CONTRIBUTING.md#maintenance-release).
11
11
  <!-- release-state:end -->
@@ -145,7 +145,7 @@ module Woods
145
145
 
146
146
  # Scan a value (String, Hash, Array, or any other object) for credentials.
147
147
  #
148
- # Strings are gsub'd against every active pattern. Hash values and Array
148
+ # Strings and Symbols are scanned against every active pattern. Hash values and Array
149
149
  # elements are walked recursively; keys and non-string scalars
150
150
  # (Integer, Float, true/false, nil) pass through untouched.
151
151
  #
@@ -164,6 +164,7 @@ module Woods
164
164
  def walk(value, counts, index)
165
165
  case value
166
166
  when String then scan_string(value, counts, index)
167
+ when Symbol then scan_string(value.to_s, counts, index).to_sym
167
168
  when Hash then walk_hash(value, counts, index)
168
169
  when Array then value.map { |item| walk(item, counts, index) }
169
170
  else value
@@ -77,15 +77,33 @@ module Woods
77
77
  response = @conn_mgr.send_request(request)
78
78
  return error_from_response(response, request) unless response['ok']
79
79
 
80
- result = @ctx.redact(response['result'])
81
- result = scan_for_credentials(result, request)
82
- text = @renderer ? @renderer.render_default(result) : JSON.pretty_generate(result)
83
- success_response(text)
80
+ render_response(response['result'], request)
84
81
  rescue ConnectionError => e
85
82
  scanned = scan_for_credentials("Connection error: #{e.message}", request)
86
83
  error_response(scanned)
87
84
  end
88
85
 
86
+ def render_response(result, request)
87
+ # Remove protected values before custom serializers run, then scan
88
+ # the materialized representation consumed by both response renderers.
89
+ result = @ctx.redact(result)
90
+ result = JSON.parse(JSON.generate(result))
91
+ result = @ctx.redact(result)
92
+ result = scan_for_credentials(result, request)
93
+ text = @renderer ? @renderer.render_default(result) : JSON.pretty_generate(result)
94
+ success_response(text)
95
+ rescue StandardError
96
+ # SDK 0.x includes uncaught exception messages in its tool errors.
97
+ # Serializer/parser/renderer messages may contain unscanned values.
98
+ rendering_error_response(request)
99
+ end
100
+
101
+ def rendering_error_response(request)
102
+ error_response(scan_for_credentials('Console response could not be serialized safely.', request))
103
+ rescue StandardError
104
+ error_response('[REDACTION_FAILED]')
105
+ end
106
+
89
107
  def error_from_response(response, request)
90
108
  error_text = "#{response['error_type']}: #{response['error']}"
91
109
  error_text = scan_for_credentials(error_text, request)
@@ -10,6 +10,7 @@ require_relative 'model_validator'
10
10
  require_relative 'safe_context'
11
11
  require_relative 'scope_predicate_parser'
12
12
  require_relative 'sql_noise_stripper'
13
+ require_relative 'sql_validator'
13
14
  require_relative 'table_gate'
14
15
 
15
16
  module Woods
@@ -425,8 +426,9 @@ module Woods
425
426
  return unless @table_gate
426
427
 
427
428
  begin
428
- @table_gate.check_sql!(sql)
429
- rescue TableGateError => e
429
+ SqliteReadGuard.validate!(sql) if sql_dialect == :sqlite
430
+ @table_gate.check_sql!(sql, dialect: sql_dialect == :sqlite ? :sqlite : nil)
431
+ rescue TableGateError, SqlValidationError => e
430
432
  raise ValidationError, e.message
431
433
  end
432
434
  end
@@ -454,7 +456,7 @@ module Woods
454
456
  def handle_count(params)
455
457
  model = resolve_model(params['model'])
456
458
  scope = apply_scope(model, params['scope'], model_name: params['model'])
457
- { 'count' => scope.count }
459
+ { 'count' => checked_relation(scope).count }
458
460
  end
459
461
 
460
462
  def handle_sample(params)
@@ -463,16 +465,17 @@ module Woods
463
465
  limit = [params.fetch('limit', 5).to_i, 25].min
464
466
  scope = apply_scope(model, params['scope'], model_name: params['model'])
465
467
  scope = apply_columns(scope, params['columns'])
466
- records = scope.order(random_function).limit(limit)
468
+ records = checked_relation(scope.order(random_function).limit(limit))
467
469
  { 'records' => serialize_records(records, params['columns']) }
468
470
  end
469
471
 
470
472
  def handle_find(params)
471
473
  model = resolve_model(params['model'])
474
+ scope = checked_relation(model)
472
475
  record = if params['id']
473
- model.find_by(id: params['id'])
476
+ scope.find_by(id: params['id'])
474
477
  elsif params['by']
475
- model.find_by(params['by'])
478
+ scope.find_by(params['by'])
476
479
  end
477
480
  { 'record' => record ? serialize_record(record, params['columns']) : nil }
478
481
  end
@@ -484,7 +487,7 @@ module Woods
484
487
  limit = [params.fetch('limit', 100).to_i, 1000].min
485
488
  scope = apply_scope(model, params['scope'], model_name: params['model'])
486
489
  scope = scope.distinct if params['distinct']
487
- values = scope.limit(limit).pluck(*columns.map(&:to_sym))
490
+ values = checked_relation(scope.limit(limit)).pluck(*columns.map(&:to_sym))
488
491
  { 'columns' => Array(columns), 'values' => values }
489
492
  end
490
493
 
@@ -501,6 +504,7 @@ module Woods
501
504
  model = resolve_model(params['model'])
502
505
  scope = apply_scope(model, params['scope'], model_name: params['model'])
503
506
 
507
+ scope = checked_relation(scope)
504
508
  value = if function == 'count'
505
509
  column ? scope.count(column.to_sym) : scope.count
506
510
  else
@@ -511,7 +515,7 @@ module Woods
511
515
 
512
516
  def handle_association_count(params)
513
517
  model = resolve_model(params['model'])
514
- record = model.find(params['id'])
518
+ record = checked_relation(model).find(params['id'])
515
519
  association_name = params['association']
516
520
 
517
521
  unless model.reflect_on_association(association_name.to_sym)
@@ -527,7 +531,26 @@ module Woods
527
531
 
528
532
  scope = record.public_send(association_name)
529
533
  scope = apply_scope(scope, params['scope'])
530
- { 'count' => scope.count }
534
+ { 'count' => checked_relation(scope).count }
535
+ end
536
+
537
+ # Resolve the default scope once and execute the same checked relation.
538
+ # @param scope [Class, ActiveRecord::Relation] Pending model read
539
+ # @return [Class, ActiveRecord::Relation] Authorized relation
540
+ def checked_relation(scope)
541
+ return scope unless @table_gate
542
+
543
+ relation = scope.all
544
+ gate_sql!(relation.to_sql)
545
+ relation
546
+ end
547
+
548
+ def sql_dialect
549
+ name = active_connection.adapter_name.to_s.downcase
550
+ return :sqlite if name.include?('sqlite')
551
+ return :mysql if name.include?('mysql')
552
+
553
+ :postgres
531
554
  end
532
555
 
533
556
  def gate_association!(model_name, association)
@@ -575,7 +598,7 @@ module Woods
575
598
 
576
599
  scope = apply_scope(model, params['scope'], model_name: params['model'])
577
600
  scope = apply_columns(scope, params['columns'])
578
- records = scope.order(order_by => direction.to_sym).limit(limit)
601
+ records = checked_relation(scope.order(order_by => direction.to_sym).limit(limit))
579
602
  { 'records' => serialize_records(records, params['columns']) }
580
603
  end
581
604
 
@@ -599,7 +622,7 @@ module Woods
599
622
  raise ValidationError, 'Missing required parameter: sql' unless sql
600
623
 
601
624
  require_relative 'sql_validator'
602
- SqlValidator.new.validate!(sql)
625
+ SqlValidator.new(dialect: sql_dialect).validate!(sql)
603
626
  # Post-validation, pre-execution TableGate — blocks every configured
604
627
  # table even if the sql is otherwise well-formed.
605
628
  gate_sql!(sql)
@@ -24,7 +24,7 @@ module Woods
24
24
  # SqlNoiseStripper.strip_literals("SELECT 'it\\'s ok' FROM t", dialect: :mysql)
25
25
  # # => "SELECT '' FROM t"
26
26
  #
27
- module SqlNoiseStripper
27
+ module SqlNoiseStripper # rubocop:disable Metrics/ModuleLength
28
28
  # Strips SQL line comments (`-- ...`) and block comments (`/* ... */`).
29
29
  # Line comments are stripped to (but not including) the newline so that
30
30
  # newline-separated statement structure is preserved for callers that
@@ -40,7 +40,7 @@ module Woods
40
40
 
41
41
  def self.strip_comments(sql)
42
42
  out = sql.gsub(LINE_COMMENT, '')
43
- out.gsub(BLOCK_COMMENT, '')
43
+ out.gsub(BLOCK_COMMENT, ' ')
44
44
  end
45
45
 
46
46
  # Strips single-quoted string literals and (for the `:postgres` dialect)
@@ -53,7 +53,7 @@ module Woods
53
53
  # single-quote scanner.
54
54
  #
55
55
  # @param sql [String] the SQL string to process
56
- # @param dialect [Symbol] `:postgres` (default) or `:mysql`.
56
+ # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite`.
57
57
  # - `:postgres` — single-quoted strings support `''` as an apostrophe
58
58
  # escape. Backslash is treated literally and does not escape quotes.
59
59
  # Dollar-quoted strings (`$$...$$`, `$tag$...$tag$`) are also stripped.
@@ -61,13 +61,14 @@ module Woods
61
61
  # and `''` (doubled-quote) as apostrophe escapes. Dollar-quoted strings
62
62
  # are also stripped (MySQL does not use them, but stripping them is
63
63
  # harmless and keeps the two dialects consistent).
64
+ # - `:sqlite` — doubled apostrophes escape strings; dollar signs stay literal.
64
65
  # @return [String] a new string with all string literals replaced by `''`
65
66
  # @raise [ArgumentError] if an unsupported dialect is provided
66
67
  DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
67
68
  SINGLE_QUOTED_POSTGRES = /'(?:''|[^'])*'/m
68
69
  SINGLE_QUOTED_MYSQL = /'(?:\\.|''|[^'])*'/m
69
70
 
70
- SUPPORTED_DIALECTS = %i[postgres mysql].freeze
71
+ SUPPORTED_DIALECTS = %i[postgres mysql sqlite].freeze
71
72
  private_constant :SUPPORTED_DIALECTS
72
73
 
73
74
  def self.strip_literals(sql, dialect: :postgres)
@@ -77,7 +78,7 @@ module Woods
77
78
 
78
79
  # Strip dollar-quoted strings first so stray apostrophes inside them
79
80
  # do not interfere with the single-quote scanner.
80
- out = sql.gsub(DOLLAR_QUOTED, "''")
81
+ out = dialect == :sqlite ? sql : sql.gsub(DOLLAR_QUOTED, "''")
81
82
 
82
83
  pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
83
84
  out.gsub(pattern, "''")
@@ -98,7 +99,7 @@ module Woods
98
99
  # character rather than swallowing the rest of the statement.
99
100
  #
100
101
  # @param sql [String] the SQL string to process
101
- # @param dialect [Symbol] `:postgres` (default) or `:mysql` — controls
102
+ # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
102
103
  # single-quote escape rules (see {.strip_literals}).
103
104
  # @return [String] a new string with comments removed and every string
104
105
  # literal replaced by `''`
@@ -126,7 +127,16 @@ module Woods
126
127
  out << ch
127
128
  i += 1
128
129
  end
129
- elsif ch == '$' && (tag = dollar_tag_at(sql, i))
130
+ elsif ['"', '`'].include?(ch)
131
+ close = quoted_identifier_end(sql, i, ch)
132
+ if close
133
+ out << sql[i...close]
134
+ i = close
135
+ else
136
+ out << ch
137
+ i += 1
138
+ end
139
+ elsif dialect != :sqlite && ch == '$' && (tag = dollar_tag_at(sql, i))
130
140
  close = sql.index(tag, i + tag.length)
131
141
  if close
132
142
  out << "''"
@@ -141,6 +151,7 @@ module Woods
141
151
  elsif ch == '/' && sql[i + 1] == '*'
142
152
  close = sql.index('*/', i + 2)
143
153
  if close
154
+ out << ' '
144
155
  i = close + 2
145
156
  else
146
157
  # Unterminated block comment: never under-detect. Leave it in
@@ -158,6 +169,23 @@ module Woods
158
169
  out
159
170
  end
160
171
 
172
+ # Preserve quoted identifiers while shielding their comment markers.
173
+ # @api private
174
+ def self.quoted_identifier_end(sql, start, quote)
175
+ i = start + 1
176
+ while i < sql.length
177
+ if sql[i] == quote
178
+ return i + 1 unless sql[i + 1] == quote
179
+
180
+ i += 2
181
+ else
182
+ i += 1
183
+ end
184
+ end
185
+ nil
186
+ end
187
+ private_class_method :quoted_identifier_end
188
+
161
189
  # Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
162
190
  # `$tag$`) at the start of the given slice.
163
191
  DOLLAR_TAG = /\A\$\w*\$/
@@ -120,11 +120,11 @@ module Woods
120
120
  #
121
121
  # @param sql [String, nil] the SQL string to scan
122
122
  # @return [Array<String>] identifiers in first-encounter order, deduplicated
123
- def self.identifiers_in(sql)
123
+ def self.identifiers_in(sql, dialect: nil)
124
124
  return [] if sql.nil? || sql.empty?
125
125
 
126
126
  results = []
127
- %i[postgres mysql].each do |dialect|
127
+ (dialect ? [dialect] : %i[postgres mysql]).each do |dialect|
128
128
  stripped = strip_noise(sql, dialect: dialect)
129
129
  collect_join_identifiers(stripped, results)
130
130
  collect_from_identifiers(stripped, results)
@@ -132,6 +132,49 @@ module Woods
132
132
  results.uniq
133
133
  end
134
134
 
135
+ # Table-factor prefixes, retaining commas after balanced subqueries and
136
+ # JOIN predicates. Each nested FROM/JOIN is also scanned independently.
137
+ # @param sql [String] noise-stripped SQL
138
+ # @return [Array<String>]
139
+ def self.relation_factors(sql)
140
+ sql.to_enum(:scan, /\b(?:FROM|JOIN)\s+/i).flat_map do
141
+ suffix = sql[Regexp.last_match.end(0)..]
142
+ split_top_level_commas(relation_clause(suffix))
143
+ end
144
+ end
145
+
146
+ # Quoted tokens shield punctuation and clause words; a closing parenthesis
147
+ # at depth zero ends this query's clause, not a nested table expression.
148
+ def self.relation_clause(suffix)
149
+ depth = 0
150
+ tokens = /
151
+ "(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]|
152
+ \b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|WINDOW)\b
153
+ /ix
154
+ suffix.to_enum(:scan, tokens).each do
155
+ token = Regexp.last_match[0]
156
+ start = Regexp.last_match.begin(0)
157
+ finish = Regexp.last_match.end(0)
158
+ boundary = token == ')' || relation_keyword_boundary?(suffix[0...start], suffix[finish..], token)
159
+ return suffix[0...start] if depth.zero? && boundary
160
+
161
+ depth += 1 if token == '('
162
+ depth -= 1 if token == ')'
163
+ end
164
+ suffix
165
+ end
166
+ private_class_method :relation_clause
167
+
168
+ def self.relation_keyword_boundary?(prefix, rest, token)
169
+ return false unless token.match?(/\A[A-Za-z]/)
170
+ return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
171
+ return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
172
+ return rest.match?(/\A\s+\w+\s+AS\b/i) if token.casecmp?('WINDOW')
173
+
174
+ true
175
+ end
176
+ private_class_method :relation_keyword_boundary?
177
+
135
178
  # @api private
136
179
  # Comments and literals must be stripped in a single combined pass —
137
180
  # stripping them separately lets a comment marker inside a literal
@@ -153,12 +196,9 @@ module Woods
153
196
 
154
197
  # @api private
155
198
  def self.collect_from_identifiers(sql, results)
156
- sql.scan(FROM_CLAUSE) do
157
- clause = Regexp.last_match[:clause]
158
- split_top_level_commas(clause).each do |chunk|
159
- ident = lead_identifier(chunk)
160
- results << ident if ident
161
- end
199
+ relation_factors(sql).each do |chunk|
200
+ ident = lead_identifier(chunk)
201
+ results << ident if ident
162
202
  end
163
203
  end
164
204
  private_class_method :collect_from_identifiers
@@ -169,7 +209,7 @@ module Woods
169
209
  depth = 0
170
210
  buf = +''
171
211
  parts = []
172
- clause.each_char do |ch|
212
+ clause.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|./m).each do |ch|
173
213
  case ch
174
214
  when '('
175
215
  depth += 1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'woods/console/sql_noise_stripper'
4
+ require 'woods/console/sqlite_read_guard'
4
5
 
5
6
  # @see Woods
6
7
  module Woods
@@ -95,17 +96,17 @@ module Woods
95
96
  [kw, /\b#{kw}\b/i]
96
97
  end.freeze
97
98
 
98
- # Frozen map of dangerous function name => regex matching a call to that function.
99
- # Used by {#check_dangerous_functions!}.
100
- DANGEROUS_FUNCTION_REGEXES = DANGEROUS_FUNCTIONS.to_h do |func|
101
- [func, /\b#{func}\s*\(/i]
102
- end.freeze
99
+ # @param dialect [Symbol, nil] Known connection dialect, when available
100
+ def initialize(dialect: nil)
101
+ @dialect = dialect
102
+ end
103
103
 
104
104
  # @raise [SqlValidationError] if the SQL is not a safe read-only statement
105
105
  def validate!(sql)
106
106
  raise SqlValidationError, 'SQL is empty' if sql.nil? || sql.strip.empty?
107
107
 
108
108
  normalized = sql.strip
109
+ SqliteReadGuard.validate!(normalized) if @dialect == :sqlite
109
110
 
110
111
  # Reject multiple statements (semicolons not inside string literals)
111
112
  if contains_multiple_statements?(normalized)
@@ -152,7 +153,7 @@ module Woods
152
153
  # @param sql [String]
153
154
  # @return [Boolean]
154
155
  def contains_multiple_statements?(sql)
155
- stripped = SqlNoiseStripper.strip_noise(sql)
156
+ stripped = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
156
157
  stripped.include?(';')
157
158
  end
158
159
 
@@ -191,8 +192,12 @@ module Woods
191
192
  # @param sql [String]
192
193
  # @raise [SqlValidationError] if a dangerous function is found
193
194
  def check_dangerous_functions!(sql)
194
- DANGEROUS_FUNCTION_REGEXES.each do |func, pattern|
195
- raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed" if sql.match?(pattern)
195
+ view = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
196
+ view.scan(/(?:"([^"\n]+)"|`([^`]+)`|\b([a-z_][a-z0-9_]*))\s*\(/i) do |quoted, backtick, bare|
197
+ func = (quoted || backtick || bare).downcase
198
+ next unless DANGEROUS_FUNCTIONS.include?(func)
199
+
200
+ raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed"
196
201
  end
197
202
  end
198
203
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods/console/sql_noise_stripper'
4
+ require 'woods/console/sql_table_scanner'
5
+
6
+ module Woods
7
+ module Console
8
+ # Refuse SQLite-specific syntax outside the scanner's supported grammar.
9
+ # This is a conservative read boundary, not a general-purpose SQL parser.
10
+ module SqliteReadGuard
11
+ SIMPLE_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
12
+ UNSUPPORTED_SYNTAX = /\b(?:FROM|JOIN)(?=['"`(])/i
13
+ QUOTED_IDENTIFIER = /"(?:[^"]|"")*"|`(?:[^`]|``)*`/
14
+
15
+ # @param sql [String] SQL to execute on SQLite
16
+ # @raise [SqlValidationError] when an identifier or table factor cannot be checked
17
+ # @return [void]
18
+ def self.validate!(sql)
19
+ view = SqlNoiseStripper.strip_noise(sql, dialect: :sqlite)
20
+ refuse! if view.match?(UNSUPPORTED_SYNTAX)
21
+ SqlTableScanner.relation_factors(view).each do |factor|
22
+ factor.scan(QUOTED_IDENTIFIER) { |quoted| refuse! unless quoted[1...-1].match?(SIMPLE_IDENTIFIER) }
23
+ refuse! unless supported_factor?(factor.strip)
24
+ end
25
+ end
26
+
27
+ # A SELECT/WITH subquery has its own independently scanned table factors.
28
+ # Parenthesized table groups and string-quoted names are refused rather
29
+ # than silently omitted from the blocked-table scan.
30
+ def self.supported_factor?(factor)
31
+ return true if factor.match?(/\A\(\s*(?:SELECT|WITH)\b/i)
32
+
33
+ match = SqlTableScanner::LEAD_IDENT.match(factor)
34
+ match && factor[match.end(0)..].match?(/\A(?:\s|,|\)|\z)/)
35
+ end
36
+ private_class_method :supported_factor?
37
+
38
+ def self.refuse!
39
+ raise SqlValidationError,
40
+ 'Rejected: unsupported SQLite identifier or table-reference syntax. ' \
41
+ 'Use simple bare or double-quoted identifiers and SELECT subqueries.'
42
+ end
43
+ private_class_method :refuse!
44
+ end
45
+ end
46
+ end
@@ -32,10 +32,10 @@ module Woods
32
32
 
33
33
  def active? = !(@blocked_bare.empty? && @blocked_qualified.empty?)
34
34
 
35
- def check_sql!(sql)
35
+ def check_sql!(sql, dialect: nil)
36
36
  return unless active? && sql&.length&.positive?
37
37
 
38
- SqlTableScanner.identifiers_in(sql).each do |raw|
38
+ SqlTableScanner.identifiers_in(sql, dialect: dialect).each do |raw|
39
39
  raise TableGateError, reject_message(raw) if blocked?(raw)
40
40
  end
41
41
  end
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '1.6.2'
4
+ VERSION = '1.6.3'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: woods
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.2
4
+ version: 1.6.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leah Armstrong
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-18 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mcp
@@ -180,6 +180,7 @@ files:
180
180
  - lib/woods/console/sql_noise_stripper.rb
181
181
  - lib/woods/console/sql_table_scanner.rb
182
182
  - lib/woods/console/sql_validator.rb
183
+ - lib/woods/console/sqlite_read_guard.rb
183
184
  - lib/woods/console/table_gate.rb
184
185
  - lib/woods/console/tool_specs.rb
185
186
  - lib/woods/console/tools/tier1.rb
@@ -359,10 +360,10 @@ licenses:
359
360
  - MIT
360
361
  metadata:
361
362
  homepage_uri: https://github.com/lost-in-the/woods
362
- source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.2
363
- changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.2/CHANGELOG.md
363
+ source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.3
364
+ changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.3/CHANGELOG.md
364
365
  bug_tracker_uri: https://github.com/lost-in-the/woods/issues
365
- documentation_uri: https://github.com/lost-in-the/woods/tree/v1.6.2/docs
366
+ documentation_uri: https://github.com/lost-in-the/woods/tree/v1.6.3/docs
366
367
  rubygems_mfa_required: 'true'
367
368
  post_install_message:
368
369
  rdoc_options: []