rails-hyperdrive 0.9.2 → 0.10.0

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: 57237faa04d72c8dcfd1bc7d2acf3dec207eb641c12c08e8fa63dda0f649bbf7
4
- data.tar.gz: 7101691749e528d36d478497874c367472b475c82873f065cbad66439e5d77b3
3
+ metadata.gz: 5831108b4ac1e545e827ce63f40ce9cb7615f8e51269a2f16914e2ed96f167bb
4
+ data.tar.gz: cd610fcf5441285f1f8357f4cee2796ad41d89f6d882447f90b6f3a430d7f2d6
5
5
  SHA512:
6
- metadata.gz: 3ae38d66200e6f17e66b11560cf029afdf9b7bbaf8dbd6477ef92de26cd440c454849900ee83fbe53fce5eae447ba2b6d40461227581cee3fcea5f874eb78f33
7
- data.tar.gz: 521d4da069f27f9b91020ac08106db9ea19aed075ce07204924da1839328390c72d85d392b060ac6317bc6fcb930225f78f10055869830abc66808a22d5c4b76
6
+ metadata.gz: 0d46038fe1baddd36949795d0e13cfa180dda30d77476420866a1efc433089a2c4398ee440e0162ec07fa3bdea9fd07149b239c696a663ef06984027169c3670
7
+ data.tar.gz: da719e11364876cedddd585b81f812565333d97f4eab1c625d6b60e1a99a0598b695591c3bfac464a0b9b27f4c3f4274d7a14a88f9966d6039fa8a016cf95d81
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-09-09
11
+
12
+ ### Fixed
13
+
14
+ - **`bin/rails hyperdrive:init`/`sync`/`discover` run from a subdirectory of the
15
+ app now write to the app root.** The generators read the lockfile, the config
16
+ file, and `Gemfile.lock` through `Rails.root` but had no destination root, so
17
+ Thor defaulted to the current directory and `../bin/rails hyperdrive:init`
18
+ scattered `.mcp.json`, `.claude/`, and `.hyperdrive/` under wherever it was
19
+ invoked from. Each generator now starts with the app root as its destination.
20
+ - **A `.hyperdrive/lock.yml` that cannot be read or parsed now stops
21
+ `hyperdrive:init`/`sync` with an error naming the file.** Previously a syntax
22
+ error was silently read as an absent lock — the run warned "locally modified"
23
+ for every installed file and then rewrote the lock with every entry and
24
+ `claude_md.state` dropped — while an unreadable file, a `!ruby/object` tag, or
25
+ a YAML alias crashed with a raw backtrace. Unreadable, unparseable, and
26
+ non-map-root locks all halt before any content write, `--dry-run` included; an
27
+ empty file still reads as absent. `bundle install` reports the same line and
28
+ installs nothing. `rake hyperdrive:skills:check`/`render` likewise report
29
+ unparseable rendered frontmatter as a render error for every YAML failure, not
30
+ only syntax errors.
31
+ - **The install pipeline no longer reports a disabled artifact left on disk as
32
+ an orphan**, in any mode — the artifact is still shipped by its gem, so it is
33
+ not stranded, and its lock entry is carried as before. Additive runs (the
34
+ `bundle install` hook) now report no disabled artifacts at all.
35
+ - **`hyperdrive:init --mount-at` now accepts only a plain `/segment[/segment]`
36
+ path** (letters, digits, `_`, `-`) and refuses anything else before writing
37
+ anything, since the value was interpolated unescaped into `config/routes.rb`
38
+ as Ruby source. When `config/routes.rb` has no
39
+ `Rails.application.routes.draw do` block, init warns and tells you the line to
40
+ add by hand, and the summary no longer claims the mount was written.
41
+
42
+ ### Changed
43
+
44
+ - **`run_sql` refuses `PRAGMA name = value` assignments.** The paren form
45
+ (`PRAGMA table_info(users)`) and bare reads stay allowed. The guard remains a
46
+ guardrail rather than a parser, so a mutation keyword inside a string literal
47
+ (`SELECT 'update me'`) is still refused.
48
+ - **`McpServer.rack_app` no longer accepts `allowed_hosts:`.** The method
49
+ memoizes, so the argument was ignored after the first call, and nothing ever
50
+ passed it; the allowlist is the middleware's own constant.
51
+ - **When one companion ships the same artifact name at two paths** — a skill
52
+ under both skill roots, or a flat file in both the convention root and its
53
+ manifest `<kind>_dir:` override — discovery now reports the dropped path as a
54
+ skip instead of choosing silently. The survivor is unchanged (the
55
+ lexicographically greatest path), and the gem is not marked as having skipped
56
+ anything, since the name still installs.
57
+
10
58
  ## [0.9.2] - 2026-09-08
11
59
 
12
60
  ### Added
@@ -849,7 +897,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
849
897
  - `hyperdrive:init` generator that installs architecture skills and auto-discovers
850
898
  per-gem skills.
851
899
 
852
- [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.9.2...HEAD
900
+ [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.10.0...HEAD
901
+ [0.10.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.10.0
853
902
  [0.9.2]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.9.2
854
903
  [0.9.1]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.9.1
855
904
  [0.9.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.9.0
data/README.md CHANGED
@@ -71,7 +71,7 @@ $ bin/dev
71
71
  # → agent has 8 tools, the eager guidelines (via CLAUDE.md), and the lazy skills
72
72
  ```
73
73
 
74
- The engine mounts at `/_hyperdrive` by default; `--mount-at /some/path` moves both the route and the URL written to `.mcp.json`. The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp`, so if your dev server runs on another port, edit the URL there.
74
+ The engine mounts at `/_hyperdrive` by default; `--mount-at /some/path` moves both the route and the URL written to `.mcp.json`. That value is interpolated into `config/routes.rb` as Ruby source, so it must be a plain path of one or more `/`-separated segments of letters, digits, `_` and `-`; anything else stops the run before it writes. If `config/routes.rb` has no `Rails.application.routes.draw do` block to anchor to, nothing is mounted and the run warns with the line to add by hand. The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp`, so if your dev server runs on another port, edit the URL there.
75
75
 
76
76
  ---
77
77
 
@@ -82,7 +82,7 @@ The engine mounts at `/_hyperdrive` by default; `--mount-at /some/path` moves bo
82
82
  | # | Tool | Purpose |
83
83
  |---|------|---------|
84
84
  | 1 | `run_ruby` | Eval Ruby in the booted Rails process, with timeout + output capture |
85
- | 2 | `run_sql` | Read-only SQL via the AR connection (`SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`PRAGMA` only; 100 rows max) |
85
+ | 2 | `run_sql` | Read-only SQL via the AR connection (`SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`PRAGMA` only, and no `PRAGMA` assignments; 100 rows max) |
86
86
  | 3 | `tail_logs` | Tail the last N lines of a log under `log/` (defaults to `log/<env>.log`) |
87
87
  | 4 | `list_models` | List Active Record model classes with columns/validations/associations |
88
88
  | 5 | `locate_source` | Resolve `Const` / `Const#method` / `Const.method` / `dep:<gem>` to a file:line |
@@ -184,7 +184,7 @@ CLAUDE.md # user-owned; ONE injected line: @.claude
184
184
  .hyperdrive/lock.yml # git-tracked manifest (source gem, version, content hash)
185
185
  ```
186
186
 
187
- A `git diff` is where you review what a companion gem added. The install summary names each artifact's source gem and version, and every installed file is hashed and attributed to its source in the git-tracked `.hyperdrive/lock.yml`. The files themselves land byte-identical to what the gem ships, with nothing injected. `hyperdrive:init` and `hyperdrive:sync` warn if your app gitignores these paths, since that empties the diff without changing what reaches the agent. The `hyperdrive:discover` cache is the one file rails-hyperdrive adds to `.gitignore`. The lockfile carries a schema version, and a rails-hyperdrive older than the one that wrote it stops with an upgrade message instead of rewriting state it cannot read.
187
+ A `git diff` is where you review what a companion gem added. The install summary names each artifact's source gem and version, and every installed file is hashed and attributed to its source in the git-tracked `.hyperdrive/lock.yml`. The files themselves land byte-identical to what the gem ships, with nothing injected. `hyperdrive:init` and `hyperdrive:sync` warn if your app gitignores these paths, since that empties the diff without changing what reaches the agent. The `hyperdrive:discover` cache is the one file rails-hyperdrive adds to `.gitignore`. The lockfile carries a schema version, and a rails-hyperdrive older than the one that wrote it stops with an upgrade message instead of rewriting state it cannot read. A lockfile that cannot be opened, does not parse, or has something other than a map at its root stops the run the same way, naming the file so you can fix it or restore it from git.
188
188
 
189
189
  `CLAUDE.md` and `index.md` are the **eager chain**: they exist only because a companion gem ships a guideline, and both go when the last one leaves the bundle (the guideline file itself is left on disk and reported as an orphan). A `CLAUDE.md` you had before `hyperdrive:init` gets the one line appended, and tear-down strips just that line; a `CLAUDE.md` the installer created is deleted only while it is still byte-identical to what was written.
190
190
 
@@ -2,7 +2,11 @@ Description:
2
2
  Bootstraps Rails Hyperdrive in this app:
3
3
  - adds the rails-hyperdrive server to .mcp.json (Claude Code config),
4
4
  merging into any servers already configured there
5
- - mounts Rails::Hyperdrive::Engine at /_hyperdrive in config/routes.rb (idempotent)
5
+ - mounts Rails::Hyperdrive::Engine at /_hyperdrive in config/routes.rb
6
+ (idempotent; --mount-at takes one or more /segments of letters,
7
+ digits, _ and -, and is validated before anything is written). With
8
+ no `Rails.application.routes.draw do` block to anchor to, nothing is
9
+ mounted and the warning names the line to add by hand
6
10
  - registers the bundler-hyperdrive plugin in the Gemfile, so newly
7
11
  bundled companion gems install their artifacts on bundle install
8
12
  - adds a .gitignore rule for the hyperdrive:discover cache
@@ -15,7 +15,9 @@ module Rails
15
15
  include GitignoreSupport
16
16
 
17
17
  ENGINE_MOUNT_TOKEN = "Rails::Hyperdrive::Engine"
18
+ ROUTES_DRAW_ANCHOR = /Rails\.application\.routes\.draw do\s*\n/
18
19
  DEFAULT_MOUNT_AT = "/_hyperdrive".freeze
20
+ MOUNT_PATH_FORMAT = %r{\A/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*\z}
19
21
 
20
22
  MCP_JSON_PATH = ".mcp.json".freeze
21
23
  MCP_SERVER_KEY = "rails-hyperdrive".freeze
@@ -65,7 +67,7 @@ module Rails
65
67
  # sibling USAGE file for `--help`.
66
68
  source_root File.expand_path("templates", __dir__)
67
69
 
68
- class_option :mount_at, type: :string, default: DEFAULT_MOUNT_AT, desc: "Engine mount path."
70
+ class_option :mount_at, type: :string, default: DEFAULT_MOUNT_AT, desc: "Engine mount path: one or more /segments of letters, digits, _ and -. Ignored with --skip-mcp."
69
71
  class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, the config file, and the lockfile; leave the .mcp.json, .gitignore, Gemfile, and mount steps."
70
72
  class_option :skip_mcp, type: :boolean, default: false, desc: "Skip MCP setup entirely; write no .mcp.json entry and no engine mount."
71
73
  class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
@@ -74,6 +76,16 @@ module Rails
74
76
  runner.verify_environment!
75
77
  end
76
78
 
79
+ # The value is interpolated into config/routes.rb as Ruby source, so it
80
+ # is validated before anything is written, a dry run included.
81
+ def verify_options
82
+ return if options[:skip_mcp]
83
+ return if mount_path.match?(MOUNT_PATH_FORMAT)
84
+
85
+ raise Thor::Error, "hyperdrive: --mount-at must be a plain path of one or more " \
86
+ "/segments (letters, digits, _ and -), got #{options[:mount_at].to_s.inspect}"
87
+ end
88
+
77
89
  def discover_artifacts
78
90
  runner.discover_artifacts(skip: options[:skip_content])
79
91
  end
@@ -132,12 +144,24 @@ module Rails
132
144
 
133
145
  contents = File.read(::Rails.root.join(routes_file))
134
146
  if contents.include?(ENGINE_MOUNT_TOKEN)
147
+ @mounted = true
135
148
  say_status :identical, "#{routes_file} (engine already mounted)", :blue
136
149
  return
137
150
  end
138
151
 
139
152
  snippet = " mount Rails::Hyperdrive::Engine => \"#{mount_path}\" if Rails.env.development?\n"
140
- inject_into_file routes_file, snippet, after: /Rails\.application\.routes\.draw do\s*\n/
153
+ # inject_into_file reports a non-match as an ordinary unchanged line
154
+ # and returns, so the anchor is checked here or the summary would
155
+ # claim a mount that never landed.
156
+ unless contents.match?(ROUTES_DRAW_ANCHOR)
157
+ say_status :warn, "#{routes_file} has no `Rails.application.routes.draw do` block; add " \
158
+ "`mount Rails::Hyperdrive::Engine => \"#{mount_path}\" if Rails.env.development?` " \
159
+ "to your routes by hand", :yellow
160
+ return
161
+ end
162
+
163
+ inject_into_file routes_file, snippet, after: ROUTES_DRAW_ANCHOR
164
+ @mounted = true
141
165
  end
142
166
 
143
167
  # The settings are the user's, so an existing file is never rewritten or
@@ -167,7 +191,7 @@ module Rails
167
191
  if options[:skip_mcp]
168
192
  say " MCP: skipped (--skip-mcp)"
169
193
  else
170
- say " Mount: #{mount_path} (in config/routes.rb)"
194
+ say " Mount: #{mount_path} #{@mounted ? "(in config/routes.rb)" : "(not written to config/routes.rb; see warning above)"}"
171
195
  say " Server: #{::Rails::Hyperdrive::McpServer::TOOLS.size} MCP tools at http://localhost:3000#{mount_path}/mcp"
172
196
  end
173
197
  runner.summary_lines.each { |line| say line } unless options[:skip_content]
@@ -111,9 +111,20 @@ module Rails
111
111
  # Raised from install, so it stops the run before any content write —
112
112
  # a dry run included.
113
113
  def verify_lock_schema!
114
- return unless lock.schema_ahead?
114
+ return unless readable_lock.schema_ahead?
115
115
 
116
- message = lock.schema_ahead_message(::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
116
+ halt_lock(lock.schema_ahead_message(::Rails::Hyperdrive::InstallLayout::LOCK_PATH))
117
+ end
118
+
119
+ def readable_lock
120
+ lock
121
+ rescue ::Rails::Hyperdrive::LockFile::UnreadableError => e
122
+ halt_lock(::Rails::Hyperdrive::LockFile.unreadable_message(
123
+ ::Rails::Hyperdrive::InstallLayout::LOCK_PATH, e.reason
124
+ ))
125
+ end
126
+
127
+ def halt_lock(message)
117
128
  @shell.say_status :error, message, :red
118
129
  raise Thor::Error, "hyperdrive: #{message}"
119
130
  end
@@ -49,9 +49,13 @@ module Rails
49
49
  end
50
50
 
51
51
  no_commands do
52
+ # Every generator read goes through ::Rails.root, so the destination
53
+ # must too — Thor would otherwise default it to the cwd `bin/rails` was
54
+ # invoked from and split reads and writes across two directories.
52
55
  def start_generator(name, generator)
53
56
  require_application!
54
- ::Rails::Generators::Hyperdrive.const_get(generator).start(@argv)
57
+ ::Rails::Generators::Hyperdrive.const_get(generator)
58
+ .start(@argv, destination_root: ::Rails.root.to_s)
55
59
  end
56
60
  end
57
61
  end
@@ -55,7 +55,11 @@ module Rails
55
55
  return skip(:not_development) unless development?(env)
56
56
  return skip(:not_initialized) unless File.exist?(File.join(root, InstallLayout::LOCK_PATH))
57
57
 
58
- lock = LockFile.load(File.join(root, InstallLayout::LOCK_PATH))
58
+ begin
59
+ lock = LockFile.load(File.join(root, InstallLayout::LOCK_PATH))
60
+ rescue LockFile::UnreadableError => e
61
+ return halted(LockFile.unreadable_message(InstallLayout::LOCK_PATH, e.reason))
62
+ end
59
63
  return halted(lock.schema_ahead_message(InstallLayout::LOCK_PATH)) if lock.schema_ahead?
60
64
 
61
65
  config = ConfigFile.load(File.join(root, InstallLayout::CONFIG_PATH))
@@ -101,11 +101,16 @@ module Rails
101
101
  warn_unknown_manifest_keys(manifest, spec, seen, report)
102
102
  end
103
103
 
104
- # Collapse same-name variants within one source gem (highest
105
- # spec_version, path as tiebreak); never across sources — composite
106
- # identity is (name, source_gem).
104
+ # Collapse same-name candidates within one source gem (the same name
105
+ # shipped under two roots) to the greatest path; never across sources —
106
+ # composite identity is (name, source_gem, artifact_type).
107
107
  candidates.group_by { |a| [a.name, a.source_gem, a.artifact_type] }.map do |_key, group|
108
- group.max_by { |a| [Gem::Version.new(a.spec_version), a.path] }
108
+ winner = group.max_by(&:path)
109
+ group.each do |loser|
110
+ next if loser.equal?(winner)
111
+ report.skip "skip #{loser.path}: #{winner.path} takes precedence"
112
+ end
113
+ winner
109
114
  end
110
115
  end
111
116
 
@@ -124,7 +124,7 @@ module Rails
124
124
  meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol]) || {}
125
125
  return if meta["name"] && meta["description"]
126
126
  raise Error, "#{template}: rendered frontmatter lacks name: or description:"
127
- rescue Psych::SyntaxError
127
+ rescue Psych::Exception
128
128
  raise Error, "#{template}: rendered frontmatter is not parseable YAML"
129
129
  end
130
130
 
@@ -48,7 +48,11 @@ module Rails
48
48
  end
49
49
 
50
50
  def call
51
- return halt_schema_ahead if old_lock.schema_ahead?
51
+ begin
52
+ return halt_schema_ahead if old_lock.schema_ahead?
53
+ rescue LockFile::UnreadableError => e
54
+ return halt_unreadable_lock(e)
55
+ end
52
56
 
53
57
  report_settings_warnings
54
58
  @new_lock = LockFile.new(abs(InstallLayout::LOCK_PATH)).carry_document(old_lock)
@@ -89,6 +93,12 @@ module Rails
89
93
  @result
90
94
  end
91
95
 
96
+ def halt_unreadable_lock(error)
97
+ @shell.say_status :warn,
98
+ LockFile.unreadable_message(InstallLayout::LOCK_PATH, error.reason), :yellow
99
+ @result
100
+ end
101
+
92
102
  def build_result
93
103
  @build_result ||= InstallPlan.build(@artifacts, config: config)
94
104
  end
@@ -139,6 +149,8 @@ module Rails
139
149
  end
140
150
 
141
151
  def report_disabled
152
+ return if additive?
153
+
142
154
  build_result.disabled.each do |entry|
143
155
  @shell.say_status :disabled, "#{entry.type} '#{entry.final_name}' (listed in #{InstallLayout::CONFIG_PATH})", :blue
144
156
  end
@@ -598,12 +610,25 @@ module Rails
598
610
  next if already_removed?(entry.path)
599
611
  next unless DriftVerdict.verdict(file: abs(entry.path), lock_entry: entry, gem_sha: nil) == :orphaned
600
612
 
613
+ # A disabled artifact left on disk is still shipped by its gem, so it
614
+ # is not stranded and draws no orphan report.
615
+ if disabled_entry?(entry)
616
+ @new_lock.carry(entry)
617
+ next
618
+ end
619
+
601
620
  @result.orphaned << entry.path
602
621
  @shell.say_status :orphan, "#{entry.path} (#{orphan_reason(entry)}; left in place)", :yellow
603
622
  @new_lock.carry(entry)
604
623
  end
605
624
  end
606
625
 
626
+ def disabled_entry?(entry)
627
+ type = InstallLayout::ARTIFACT_TYPES[entry.kind]
628
+ return false unless type
629
+ InstallPlan.disabled_dest?(config, type, entry.path, source_gem: entry.source_gem)
630
+ end
631
+
607
632
  def orphan_reason(entry)
608
633
  LockFile.orphan_reason(
609
634
  source_label: entry.source_label,
@@ -9,6 +9,17 @@ module Rails
9
9
  STATE_PRESENT = "present".freeze
10
10
  STATE_REMOVED = "removed-by-user".freeze
11
11
 
12
+ # A lock that exists but cannot be understood is never treated as absent:
13
+ # rewriting it would drop every entry and the user's claude_md state.
14
+ class UnreadableError < StandardError
15
+ attr_reader :reason
16
+
17
+ def initialize(reason)
18
+ @reason = reason
19
+ super(reason)
20
+ end
21
+ end
22
+
12
23
  # In-memory form of one files: entry. On disk, source_gem and
13
24
  # source_version are a single "gem@version" string; the split/join lives
14
25
  # in this file only.
@@ -60,8 +71,16 @@ module Rails
60
71
  def read
61
72
  return self unless File.exist?(@path)
62
73
 
63
- data = YAML.safe_load(File.read(@path))
64
- return self unless data.is_a?(Hash)
74
+ begin
75
+ raw = File.read(@path)
76
+ data = YAML.safe_load(raw)
77
+ rescue Psych::Exception => e
78
+ raise UnreadableError, "not valid YAML: #{e.message.to_s.lines.first.to_s.strip}"
79
+ rescue SystemCallError => e
80
+ raise UnreadableError, e.message
81
+ end
82
+ return self if data.nil?
83
+ raise UnreadableError, "root is not a map" unless data.is_a?(Hash)
65
84
 
66
85
  @document = data
67
86
  @schema_version = data["version"]
@@ -74,8 +93,10 @@ module Rails
74
93
  @files[entry.path] = entry if entry.path
75
94
  end
76
95
  self
77
- rescue Psych::SyntaxError
78
- self
96
+ end
97
+
98
+ def self.unreadable_message(display_path, reason)
99
+ "#{display_path} could not be read (#{reason}); fix it or restore it from git, then re-run"
79
100
  end
80
101
 
81
102
  # A lock written by a newer installer holds state this one cannot read,
@@ -32,15 +32,14 @@ module Rails
32
32
  @server ||= build_server
33
33
  end
34
34
 
35
- def rack_app(allowed_hosts: Safety::RackMiddleware::DEFAULT_ALLOWED_HOSTS)
35
+ def rack_app
36
36
  # The transport's own DNS-rebinding check requires same-origin (host:port);
37
37
  # it is disabled because Safety::RackMiddleware enforces the localhost
38
38
  # Origin allowlist, which accepts any port.
39
39
  @rack_app ||= Safety::RackMiddleware.new(
40
40
  ::MCP::Server::Transports::StreamableHTTPTransport.new(
41
41
  server, stateless: true, enable_json_response: true, dns_rebinding_protection: false
42
- ),
43
- allowed_hosts: allowed_hosts
42
+ )
44
43
  )
45
44
  end
46
45
 
@@ -6,6 +6,7 @@ module Rails
6
6
  module SqlSafety
7
7
  ALLOWED_LEADERS = /\A\s*(WITH\b.*?\bSELECT\b|SELECT\b|EXPLAIN\b|SHOW\b|PRAGMA\b)/im
8
8
  FORBIDDEN_TOKEN = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|REPLACE|MERGE|RENAME|VACUUM|ATTACH|DETACH)\b/i
9
+ PRAGMA_LEADER = /\A\s*PRAGMA\b/i
9
10
 
10
11
  class Error < StandardError; end
11
12
 
@@ -16,6 +17,11 @@ module Rails
16
17
  unless sql =~ ALLOWED_LEADERS
17
18
  raise Error, "only SELECT / WITH...SELECT / EXPLAIN / SHOW / PRAGMA are allowed"
18
19
  end
20
+ # `PRAGMA x = y` writes; the paren form (`PRAGMA table_info(users)`)
21
+ # cannot be told apart from a read and stays allowed.
22
+ if sql =~ PRAGMA_LEADER && sql.include?("=")
23
+ raise Error, "PRAGMA assignments are not allowed"
24
+ end
19
25
  # Second pass catches a mutation smuggled inside a CTE body
20
26
  # (e.g. `WITH x AS (DELETE ...) SELECT ...`).
21
27
  if sql =~ FORBIDDEN_TOKEN
@@ -6,13 +6,13 @@ module Rails
6
6
  module Tools
7
7
  class RunSql < Base
8
8
  tool_name "run_sql"
9
- description "Read-only SQL via ActiveRecord::Base.connection. Rejects INSERT/UPDATE/DELETE/DROP/etc. at the parser level. Caps results at 100 rows."
9
+ description "Read-only SQL via ActiveRecord::Base.connection. Rejects INSERT/UPDATE/DELETE/DROP/etc. at the parser level, and refuses PRAGMA assignments (`PRAGMA foreign_keys = ON`). Caps results at 100 rows."
10
10
 
11
11
  ROW_CAP = 100
12
12
 
13
13
  input_schema(
14
14
  properties: {
15
- sql: { type: "string", description: "A SELECT / WITH / EXPLAIN / SHOW / PRAGMA statement." }
15
+ sql: { type: "string", description: "A SELECT / WITH / EXPLAIN / SHOW / PRAGMA statement. PRAGMA reads only: `PRAGMA table_info(users)` is allowed, `PRAGMA name = value` is refused." }
16
16
  },
17
17
  required: ["sql"]
18
18
  )
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Hyperdrive
3
- VERSION = "0.9.2"
3
+ VERSION = "0.10.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-hyperdrive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bakaface