rails-profiler 0.33.3 → 0.34.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: 533fac499cbab38112f1be4ab7d066761f0f0cea1b22d76e4d355059ef03d9dd
4
- data.tar.gz: b8d37a14501188a671be2ee4876b8ce9678641cca8acbd836e456ea62249ef48
3
+ metadata.gz: b6a0140a91e984bd6d06d7277a149c2a7eeace21ec53c3d3ddc1acb7eb137879
4
+ data.tar.gz: 730abdd8332a638e3d78db3d74378ab10fc1280f9d5f83f11b327409b076c1e3
5
5
  SHA512:
6
- metadata.gz: 7daad08c5c0b6a119933341e7b1a1ba28d746c2801eef525a83dd54388c85a30bfa68a7240d2e99705992e622929df012b98676f0ec396a028fff759690910da
7
- data.tar.gz: d205ba6d72f09ca00d1eb714db57f99052e64297caddb33999c8c6ae87ae380874cf015af986d0065d7b76c949388a813f3f397adae3fe23b7f5a1e6bdbcbc48
6
+ metadata.gz: '09a3cac5a02d3407f8a03dad19154d8f36c5dd57e8c1f5d9b0021aad015e7e459a4305592e68defb796672a9cbd29b40c48b2933cff56b2fe340c3ae370de767'
7
+ data.tar.gz: dda83a3c7086e4679766abe06d0dc5e88befc5e9ebebb19a87ec605a3b268a2ab81fccef0274e8170043bb4c6ab452f68307e5a1546921b55b418289427fc805
data/CHANGELOG.md CHANGED
@@ -19,6 +19,50 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.34.0] - 2026-10-15
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Added
27
+
28
+ - **MCP:** `config.mcp_read_only = true` leaves out the tools that change something:
29
+ `run_tests`, `clear_profiles`, `set_env_var`, `delete_env_var`, `reset_env_var` and
30
+ `reset_all_env_vars`. They are not listed by `tools/list`, and a client that calls one by name
31
+ gets a "tool not found" error; the tools that only read stay. False by default. The gem's
32
+ `profiler-mcp` executable does not boot the application, so it never reads the option: set
33
+ `PROFILER_MCP_READ_ONLY=1` in the environment the MCP client starts it with. The variable works
34
+ with `rake profiler:mcp` and the HTTP transport too, and never turns read-only off.
35
+
36
+ ### Changed
37
+
38
+ - **Env overrides:** An override saved by an earlier version for a name the profiler now refuses,
39
+ such as `DATABASE_URL`, `PRIMARY_DATABASE_URL`, `LD_PRELOAD` or `HTTPS_PROXY`, is no longer
40
+ applied: not at boot, not before a Sidekiq job, not in the test runner. It stays in the file
41
+ until reset, and a warning gives how many were left out, never their names. Set such a variable
42
+ in the shell that starts the application instead.
43
+
44
+ ### Fixed
45
+
46
+ - **Env overrides:** The Env tab, `set_env_var` and `delete_env_var` refuse a name that is not
47
+ made of letters, digits and underscores (`A=B`, `MY-VAR`), and the variables that select the
48
+ database, the secrets or the network of the application, or make a process load other code:
49
+ `DATABASE_URL` and every name ending in `_DATABASE_URL`, every name starting with `PG` or
50
+ `MYSQL_`, `RAILS_MASTER_KEY`, every proxy variable in any case (`http_proxy`, `HTTPS_PROXY`),
51
+ `SSL_CERT_FILE`, `SSL_CERT_DIR`, `OPENSSL_CONF`, `GCONV_PATH`, `IRBRC`, `PRYRC`, `ZDOTDIR`,
52
+ `LD_PRELOAD`, `RUBYOPT` and the others listed in the docs. Before, any of these could be set,
53
+ `DATABASE_URL` included. The refusal comes before anything is written: a 422 from the endpoint,
54
+ an error from the tool. The list cannot be complete: a gem may read a variable of its own.
55
+ - **Env overrides:** A value holding a NUL byte is refused the same way. Before, it was written to
56
+ the file, then the tool failed with an internal error, and every process logged the same error
57
+ again before each Sidekiq job.
58
+ - **Env overrides:** A reset now reaches every process that applied the override, each Sidekiq
59
+ process and Puma worker, not only the first one to read the file: each puts back the value it
60
+ had before. A process that never applied the override is left alone.
61
+ - **Env overrides:** The overrides file is no longer read and parsed again before every Sidekiq
62
+ job while it does not change. A change written within two seconds of the previous one is never
63
+ missed, on ext4 too, where the file system reuses the inode and the modification time can stay
64
+ the same.
65
+
22
66
  ## [0.33.3] - 2026-10-14
23
67
 
24
68
  <!-- stamped -->
@@ -24,14 +24,20 @@ module Profiler
24
24
  return
25
25
  end
26
26
 
27
- if Profiler::EnvOverrideStore.reserved_key?(key)
28
- render json: { error: "#{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged" },
29
- status: :unprocessable_entity
27
+ # Checked before the store is written: a refused key never reaches the file nor ENV.
28
+ if (reason = Profiler::EnvKeyPolicy.rejection(key))
29
+ render json: { error: "#{reason}; #{key} was left unchanged" }, status: :unprocessable_entity
30
30
  return
31
31
  end
32
32
 
33
33
  value = params[:value]
34
34
 
35
+ # A value ENV cannot hold is refused before the store is written, like a refused key.
36
+ if (reason = Profiler::EnvKeyPolicy.value_rejection(key, value))
37
+ render json: { error: "#{reason}; #{key} was left unchanged" }, status: :unprocessable_entity
38
+ return
39
+ end
40
+
35
41
  if Profiler::Redaction.mask?(value)
36
42
  render json: { error: "#{Profiler::Redaction::MASK} is the mask the profiler shows in place of a " \
37
43
  "hidden value, not a value; #{key} was left unchanged" },
data/exe/profiler-mcp CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # The MCP server over stdio, without the application: its initializers do not run here, so
5
+ # config.mcp_read_only set there has no effect. Set PROFILER_MCP_READ_ONLY=1 in the environment the
6
+ # MCP client gives this process to leave out the write tools (Profiler::MCP::Server.read_only?).
7
+ # `bundle exec rake profiler:mcp` boots the application and reads both.
8
+
4
9
  require "bundler/setup"
5
10
  require "profiler"
6
11
  require "profiler/mcp/server"
@@ -40,7 +40,7 @@ module Profiler
40
40
  attr_accessor :storage_options, :collectors,
41
41
  :slow_query_threshold, :max_queries_warning, :sql_backtrace,
42
42
  :track_memory, :allocated_objects_warning_threshold,
43
- :mcp_enabled, :mcp_transport, :mcp_port,
43
+ :mcp_enabled, :mcp_transport, :mcp_port, :mcp_read_only,
44
44
  :authorization_mode, :extension_cors_enabled,
45
45
  :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
46
46
  :track_ajax, :ajax_skip_paths,
@@ -79,6 +79,10 @@ module Profiler
79
79
  @mcp_enabled = false
80
80
  @mcp_transport = :stdio
81
81
  @mcp_port = 3001
82
+ # true leaves out of the MCP server every tool that changes something: run_tests,
83
+ # clear_profiles and the env tools that set, delete or reset a variable
84
+ # (Profiler::MCP::Server::WRITE_TOOLS). Read when the server is built, at the first MCP call.
85
+ @mcp_read_only = false
82
86
  @authorization_mode = :allow_local
83
87
  @authorize_block = nil
84
88
  @max_profiles = 100
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ # Which environment variables the profiler may change: the Env tab, the MCP env tools, the
5
+ # overrides replayed by EnvOverrideStore#apply! and the environment of the test runner all
6
+ # read this one list.
7
+ #
8
+ # It is a deny list, chosen over an allow list on purpose (DECISIONS.md): the Env tab exists for
9
+ # the application's own variables, feature flags and API URLs, whose names nobody can enumerate
10
+ # in the gem. A deny list is by nature incomplete: any gem, library or tool the application uses
11
+ # can read a variable of its own tomorrow, a new database adapter or HTTP client among them, and
12
+ # no list written here can foresee it. The names below are the ones known to redirect the
13
+ # application's connections, hand over its secrets, or make a process load other code; a
14
+ # variable left out here can still be set in the shell that starts the application.
15
+ module EnvKeyPolicy
16
+ # Set by the test runner in the environment of the test process.
17
+ TEST_RUNNER_CHILD_ENV = "PROFILER_TEST_RUNNER_CHILD"
18
+ RESERVED_KEY_ERROR = "#{TEST_RUNNER_CHILD_ENV} is reserved for the test runner"
19
+
20
+ # Select the environment, the database or the secrets of the application, where it connects
21
+ # and which certificates it trusts, or the profiler's own read-only switch.
22
+ PROTECTED_KEYS = %w[
23
+ RAILS_ENV RACK_ENV DATABASE_URL SECRET_KEY_BASE RAILS_MASTER_KEY PROFILER_TEST_RUNNER_CHILD
24
+ PROFILER_MCP_READ_ONLY SSL_CERT_FILE SSL_CERT_DIR
25
+ ].freeze
26
+ # PG: libpq (PGHOST, PGPASSWORD, PGSERVICEFILE...). MYSQL_: the MySQL client (MYSQL_PWD,
27
+ # MYSQL_HOST...). OPENSSL_: OPENSSL_CONF and OPENSSL_MODULES can load a shared library.
28
+ PROTECTED_PREFIXES = %w[PG MYSQL_ OPENSSL_].freeze
29
+ # _DATABASE_URL: ActiveRecord reads PRIMARY_DATABASE_URL (any <name>_DATABASE_URL of a
30
+ # multi-database configuration) before DATABASE_URL. _PROXY: http_proxy, HTTPS_PROXY, no_proxy
31
+ # and the others, read again by Net::HTTP at each connection, whatever their case.
32
+ PROTECTED_SUFFIXES = %w[_DATABASE_URL _PROXY].freeze
33
+
34
+ # Overrides of these variables make a process, or the shell shims (rbenv, asdf) that start
35
+ # it, load or run code other than its own.
36
+ CODE_LOADING_KEYS = %w[
37
+ SPEC_OPTS TESTOPTS TEST PATH HOME XDG_CONFIG_HOME GEMRC NODE_OPTIONS NODE_PATH
38
+ SHELLOPTS BASHOPTS PS4 ENV CDPATH IFS ZDOTDIR GCONV_PATH LOCPATH IRBRC PRYRC PRY_RC
39
+ LESSOPEN LESSCLOSE PAGER EDITOR VISUAL JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
40
+ ].freeze
41
+ CODE_LOADING_PREFIXES = %w[
42
+ RUBY GEM_ BUNDLE_ BUNDLER_ LD_ DYLD_ BASH_ RBENV_ ASDF_ RVM_ CHRUBY GIT_ BOOTSNAP_
43
+ PYTHON PERL5
44
+ ].freeze
45
+
46
+ # Letters, digits and underscores, not starting with a digit. Anything else, "=" or a NUL
47
+ # byte above all, either cannot be written into ENV or is not a name a shell can set.
48
+ ENV_NAME = /\A[A-Z_][A-Z0-9_]*\z/i
49
+
50
+ module_function
51
+
52
+ def valid_name?(key)
53
+ ENV_NAME.match?(key.to_s)
54
+ end
55
+
56
+ def reserved?(key)
57
+ key.to_s.upcase == TEST_RUNNER_CHILD_ENV
58
+ end
59
+
60
+ def protected?(key)
61
+ name = key.to_s.upcase
62
+ PROTECTED_KEYS.include?(name) ||
63
+ PROTECTED_PREFIXES.any? { |prefix| name.start_with?(prefix) } ||
64
+ PROTECTED_SUFFIXES.any? { |suffix| name.end_with?(suffix) }
65
+ end
66
+
67
+ # True for a name that is not valid, too: the test runner leaves those out with the others.
68
+ def code_loading?(key)
69
+ name = key.to_s.upcase
70
+ return true unless valid_name?(name)
71
+
72
+ CODE_LOADING_KEYS.include?(name) || CODE_LOADING_PREFIXES.any? { |prefix| name.start_with?(prefix) }
73
+ end
74
+
75
+ # Why the profiler refuses to set or delete key, or nil when it may.
76
+ def rejection(key)
77
+ name = key.to_s
78
+ return "key cannot be blank" if name.strip.empty?
79
+ return RESERVED_KEY_ERROR if reserved?(name)
80
+ unless valid_name?(name)
81
+ return "#{name.inspect} is not a valid environment variable name " \
82
+ "(letters, digits and underscores, not starting with a digit)"
83
+ end
84
+ if protected?(name) || code_loading?(name)
85
+ return "#{name} is protected: the profiler does not change the variables that select the " \
86
+ "environment, the database, the secrets or the network of the application, nor " \
87
+ "those that make a process load other code"
88
+ end
89
+
90
+ nil
91
+ end
92
+
93
+ # Why the profiler refuses to set key to value, or nil when it may. A NUL byte cannot be held
94
+ # by an environment variable: ENV[]= raises on it.
95
+ def value_rejection(key, value)
96
+ return nil unless value.to_s.include?("\0")
97
+
98
+ "the value of #{key} contains a NUL byte, which an environment variable cannot hold"
99
+ end
100
+
101
+ def allowed?(key)
102
+ rejection(key).nil?
103
+ end
104
+ end
105
+ end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "fileutils"
5
5
  require_relative "storage/private_files"
6
+ require_relative "env_key_policy"
6
7
 
7
8
  module Profiler
8
9
  class EnvOverrideStore
@@ -26,17 +27,37 @@ module Profiler
26
27
  # File format: { "KEY" => { "value" => "...", "original" => "..." } }
27
28
  # Sentinels for "value":
28
29
  # DELETED_SENTINEL → ENV.delete(key)
29
- # RESTORE_SENTINEL → restore ENV[key] to "original" (cleaned up after apply!)
30
+ # RESTORE_SENTINEL → every process that applied the key puts back the value it had before
31
+ # (apply!). The entry carries "reset_at" and stays RESTORE_TTL seconds,
32
+ # so that each Sidekiq process and Puma worker sees it, not only the first.
33
+
34
+ # How long a RESTORE entry stays in the file. A process that runs no job for longer keeps the
35
+ # override until it restarts.
36
+ RESTORE_TTL = 7 * 24 * 60 * 60
37
+
38
+ # How old the overrides file must be before apply! keeps its content between calls
39
+ # (cached_overrides).
40
+ CACHE_SETTLE_SECONDS = 2
30
41
 
31
42
  def initialize
32
43
  # What ENV held in this process before set, delete or apply! changed a key. Where the
33
44
  # overrides are blocked, a reset restores these and never the file's "original" values,
34
- # which come from whatever machine wrote the file.
45
+ # which come from whatever machine wrote the file. apply! restores these too, so it knows
46
+ # which keys this process changed and has to put back.
35
47
  @process_originals = {}
36
48
  @process_originals_lock = Mutex.new
49
+ # [signature of the file, parsed overrides] as apply! last read them.
50
+ @cached_overrides = nil
51
+ @skipped_keys_logged = nil
37
52
  end
38
53
 
39
54
  def set(key, value)
55
+ # Before the file is written: an entry ENV cannot hold would fail in every process that
56
+ # applies it.
57
+ if (reason = EnvKeyPolicy.value_rejection(key, value))
58
+ raise Error, "could not set #{key}: #{reason}"
59
+ end
60
+
40
61
  surfacing("set #{key}") do
41
62
  remember_process_original(key)
42
63
  update_overrides do |overrides|
@@ -61,7 +82,7 @@ module Profiler
61
82
  update_overrides do |overrides|
62
83
  entry = overrides[key]
63
84
  # Keep a RESTORE entry so Sidekiq workers pick it up on next job
64
- overrides[key] = { "value" => RESTORE_SENTINEL, "original" => entry["original"] } if entry
85
+ overrides[key] = restore_entry(entry["original"]) if entry
65
86
  end
66
87
  end
67
88
 
@@ -74,9 +95,12 @@ module Profiler
74
95
  surfacing("reset the overrides") do
75
96
  update_overrides do |overrides|
76
97
  overrides.each do |key, entry|
98
+ # Already reset: its reset_at stays, so that it still expires.
99
+ next if restore_entry?(entry)
100
+
77
101
  originals[key] = entry.is_a?(Hash) ? entry["original"] : nil
78
102
  # Leave a RESTORE sentinel for Sidekiq workers to pick up
79
- overrides[key] = { "value" => RESTORE_SENTINEL, "original" => originals[key] }
103
+ overrides[key] = restore_entry(originals[key])
80
104
  end
81
105
  end
82
106
  end
@@ -116,47 +140,34 @@ module Profiler
116
140
 
117
141
  # Set by the test runner in the environment of the test process, to which it already gave
118
142
  # the admitted overrides only (TestRunner::Runner.build_env).
119
- TEST_RUNNER_CHILD_ENV = "PROFILER_TEST_RUNNER_CHILD"
120
- RESERVED_KEY_ERROR = "#{TEST_RUNNER_CHILD_ENV} is reserved for the test runner"
143
+ TEST_RUNNER_CHILD_ENV = EnvKeyPolicy::TEST_RUNNER_CHILD_ENV
144
+ RESERVED_KEY_ERROR = EnvKeyPolicy::RESERVED_KEY_ERROR
121
145
 
122
146
  # The env tools may not set or delete the test runner marker, whatever its case.
123
147
  def self.reserved_key?(key)
124
- key.to_s.upcase == TEST_RUNNER_CHILD_ENV
148
+ EnvKeyPolicy.reserved?(key)
125
149
  end
126
150
 
151
+ # Runs before every Sidekiq job and console line: the file is parsed again only when it was
152
+ # replaced or changed since the last call (cached_overrides). Each key is applied on its own,
153
+ # so that a key that cannot be written leaves the others applied. A key EnvKeyPolicy refuses,
154
+ # or a value ENV cannot hold, written by a version that did not check them, is left out.
127
155
  def apply!
128
156
  return if ENV[TEST_RUNNER_CHILD_ENV] == "1"
129
157
  return if blocked_reason
130
158
 
131
- overrides = load_overrides
132
- restore_keys = []
133
-
134
- overrides.each do |key, entry|
135
- value = entry.is_a?(Hash) ? entry["value"] : entry
136
- original = entry.is_a?(Hash) ? entry["original"] : nil
137
-
138
- remember_process_original(key) unless value == RESTORE_SENTINEL
139
-
140
- case value
141
- when DELETED_SENTINEL
142
- ENV.delete(key)
143
- when RESTORE_SENTINEL
144
- original.nil? ? ENV.delete(key) : ENV[key] = original
145
- restore_keys << key
146
- else
147
- ENV[key] = value
159
+ skipped = []
160
+ cached_overrides.each do |key, entry|
161
+ unless applicable?(key, entry)
162
+ skipped << key
163
+ next
148
164
  end
149
- end
150
165
 
151
- if restore_keys.any?
152
- # Read again under the lock: another writer may have changed the file since.
153
- update_overrides do |current|
154
- restore_keys.each do |k|
155
- entry = current[k]
156
- current.delete(k) if entry.is_a?(Hash) && entry["value"] == RESTORE_SENTINEL
157
- end
158
- end
166
+ apply_entry(key, entry)
167
+ rescue StandardError => e
168
+ Profiler.log_error("EnvOverrideStore: failed to apply an override", e)
159
169
  end
170
+ log_skipped_keys(skipped)
160
171
  rescue => e
161
172
  Profiler.log_error("EnvOverrideStore: failed to apply overrides", e)
162
173
  end
@@ -191,6 +202,72 @@ module Profiler
191
202
  raise Error, "could not #{action} in env_overrides.json under tmp_path: #{reason}"
192
203
  end
193
204
 
205
+ def applicable?(key, entry)
206
+ return false unless EnvKeyPolicy.allowed?(key)
207
+
208
+ value = entry.is_a?(Hash) ? entry["value"] : entry
209
+ EnvKeyPolicy.value_rejection(key, value).nil?
210
+ end
211
+
212
+ def apply_entry(key, entry)
213
+ value = entry.is_a?(Hash) ? entry["value"] : entry
214
+
215
+ if value == RESTORE_SENTINEL
216
+ restore_applied(key)
217
+ return
218
+ end
219
+
220
+ remember_process_original(key)
221
+ value == DELETED_SENTINEL ? ENV.delete(key) : ENV[key] = value
222
+ end
223
+
224
+ # A RESTORE entry stays in the file for every process: this one puts the key back only if it
225
+ # changed it, once, to the value it had before. A process that never applied the override has
226
+ # nothing to restore.
227
+ def restore_applied(key)
228
+ changed_here, original = @process_originals_lock.synchronize do
229
+ [@process_originals.key?(key), @process_originals.delete(key)]
230
+ end
231
+ return unless changed_here
232
+
233
+ original.nil? ? ENV.delete(key) : ENV[key] = original
234
+ end
235
+
236
+ # Says once per set of keys how many were left out, never which: the log may be shared.
237
+ def log_skipped_keys(skipped)
238
+ return if skipped.empty? || @skipped_keys_logged == skipped
239
+
240
+ @skipped_keys_logged = skipped
241
+ noun = skipped.size == 1 ? "override was" : "overrides were"
242
+ Profiler.log_warn("#{skipped.size} persisted environment #{noun} not applied: the name is not " \
243
+ "a valid environment variable name, or one the profiler does not change, " \
244
+ "or the value holds a NUL byte.")
245
+ end
246
+
247
+ def restore_entry(original)
248
+ { "value" => RESTORE_SENTINEL, "original" => original, "reset_at" => Time.now.to_i }
249
+ end
250
+
251
+ def restore_entry?(entry)
252
+ entry.is_a?(Hash) && entry["value"] == RESTORE_SENTINEL
253
+ end
254
+
255
+ # RESTORE entries older than RESTORE_TTL go when the file is next written. One written by an
256
+ # earlier version, with no reset_at, starts its delay now.
257
+ def expire_restore_entries(overrides)
258
+ now = Time.now.to_i
259
+ overrides.delete_if do |_key, entry|
260
+ next false unless restore_entry?(entry)
261
+
262
+ reset_at = entry["reset_at"]
263
+ unless reset_at.is_a?(Integer)
264
+ entry["reset_at"] = now
265
+ next false
266
+ end
267
+ now - reset_at > RESTORE_TTL
268
+ end
269
+ end
270
+
194
271
  def remember_process_original(key)
195
272
  @process_originals_lock.synchronize do
196
273
  @process_originals[key] = ENV[key] unless @process_originals.key?(key)
@@ -208,6 +285,11 @@ module Profiler
208
285
  return false if from_process && !changed_here
209
286
 
210
287
  original = from_process ? process_original : file_entry["original"]
288
+ # A name ENV cannot hold ("A=B"), written by a version that did not check the names: its
289
+ # entry is reset in the file, and there is nothing to put back here.
290
+ return false unless EnvKeyPolicy.valid_name?(key)
291
+ return false if EnvKeyPolicy.value_rejection(key, original)
292
+
211
293
  original.nil? ? ENV.delete(key) : ENV[key] = original
212
294
  true
213
295
  end
@@ -224,11 +306,46 @@ module Profiler
224
306
  path = override_file_path
225
307
  return {} unless File.exist?(path)
226
308
 
227
- JSON.parse(File.read(path))
309
+ parse_overrides(File.read(path))
310
+ end
311
+
312
+ def parse_overrides(content)
313
+ parsed = JSON.parse(content)
314
+ parsed.is_a?(Hash) ? parsed : {}
228
315
  rescue JSON::ParserError
229
316
  {}
230
317
  end
231
318
 
319
+ # load_overrides for apply!, without reading the file again while it is the same file.
320
+ #
321
+ # The signature (inode, device, mtime, size) alone can miss a write: ext4 hands the inode the
322
+ # rename freed to the next temporary file, and two writes of the same size within one tick of
323
+ # the mtime clock then look identical, the stale value kept until the file is written again.
324
+ # So the content is cached only once the file has settled: its mtime at least
325
+ # CACHE_SETTLE_SECONDS in the past. Any later write is then at least that much younger, and
326
+ # gets another mtime on any file system whose mtime tick is shorter (2 seconds on FAT, the
327
+ # coarsest). A file written less than that ago is read at each call, which costs a read only
328
+ # during the seconds after a change. A write between the stat and the read caches the new
329
+ # content under the old signature: the next call sees another mtime and reads again.
330
+ def cached_overrides
331
+ path = override_file_path
332
+ stat = File.stat(path)
333
+ signature = [path.to_s, stat.ino, stat.dev, stat.mtime, stat.size]
334
+ cached = @cached_overrides
335
+ return cached[1] if cached && cached[0] == signature
336
+
337
+ overrides = parse_overrides(File.read(path)).freeze
338
+ @cached_overrides = settled?(stat) ? [signature, overrides] : nil
339
+ overrides
340
+ rescue Errno::ENOENT
341
+ @cached_overrides = nil
342
+ {}
343
+ end
344
+
345
+ def settled?(stat)
346
+ Time.now - stat.mtime >= CACHE_SETTLE_SECONDS
347
+ end
348
+
232
349
  # Reads, changes and writes the file under the lock, so that the web process, its threads and
233
350
  # the Sidekiq processes never save over each other's changes. The file is removed when the
234
351
  # block leaves it empty.
@@ -236,6 +353,7 @@ module Profiler
236
353
  with_lock do
237
354
  overrides = load_overrides
238
355
  yield overrides
356
+ expire_restore_entries(overrides)
239
357
  overrides.empty? ? FileUtils.rm_f(override_file_path) : save_overrides(overrides)
240
358
  end
241
359
  end
@@ -6,11 +6,32 @@ require_relative "http_guard"
6
6
  module Profiler
7
7
  module MCP
8
8
  class Server
9
+ # The tools that change the profiler's data, ENV or run code: left out when
10
+ # config.mcp_read_only is true or PROFILER_MCP_READ_ONLY is set (Server.read_only?).
11
+ WRITE_TOOLS = %w[
12
+ run_tests clear_profiles set_env_var delete_env_var reset_env_var reset_all_env_vars
13
+ ].freeze
14
+
15
+ # Makes the server read-only like config.mcp_read_only, for exe/profiler-mcp above all: that
16
+ # executable does not boot the application, so the application's initializers, and the
17
+ # option they set, never run there. Any value but an empty one, 0, false, no or off turns
18
+ # it on; it never turns off a config.mcp_read_only set to true.
19
+ READ_ONLY_ENV = "PROFILER_MCP_READ_ONLY"
20
+ READ_ONLY_ENV_OFF = %w[0 false no off].freeze
21
+
9
22
  class << self
10
23
  def instance
11
24
  @instance ||= new
12
25
  end
13
26
 
27
+ # Whether the server leaves out WRITE_TOOLS: config.mcp_read_only, or READ_ONLY_ENV.
28
+ def read_only?
29
+ return true if Profiler.configuration.mcp_read_only
30
+
31
+ value = ENV[READ_ONLY_ENV].to_s.strip.downcase
32
+ !value.empty? && !READ_ONLY_ENV_OFF.include?(value)
33
+ end
34
+
14
35
  def rack_app
15
36
  # Memoized at class level — survives route reloads in development
16
37
  @rack_app ||= lambda do |env|
@@ -399,10 +420,13 @@ module Profiler
399
420
  input_schema: { properties: {} },
400
421
  handler: Tools::ListSlaves
401
422
  )
402
- ]
423
+ ].compact
403
424
  end
404
425
 
426
+ # nil for a tool a read-only server leaves out (Server.read_only?); build_tools drops it.
405
427
  def define_tool(name:, description:, input_schema:, handler:)
428
+ return nil if WRITE_TOOLS.include?(name) && self.class.read_only?
429
+
406
430
  ::MCP::Tool.define(name: name, description: description, input_schema: input_schema) do |server_context: nil, **args|
407
431
  result = handler.call(args.transform_keys(&:to_s))
408
432
  result.is_a?(::MCP::Tool::Response) ? result : ::MCP::Tool::Response.new(result)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "mcp"
4
4
  require_relative "../slave_support"
5
+ require_relative "../../env_key_policy"
5
6
 
6
7
  module Profiler
7
8
  module MCP
@@ -11,9 +12,11 @@ module Profiler
11
12
  key = params["key"].to_s.strip
12
13
 
13
14
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
14
- if Profiler::EnvOverrideStore.reserved_key?(key)
15
+ # Checked before the store is written, and before a slave is asked: a refused key never
16
+ # reaches the file nor ENV.
17
+ if (reason = Profiler::EnvKeyPolicy.rejection(key))
15
18
  return ::MCP::Tool::Response.new(
16
- [{ type: "text", text: "Error: #{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged." }],
19
+ [{ type: "text", text: "Error: #{reason}; #{key} was left unchanged." }],
17
20
  error: true
18
21
  )
19
22
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "mcp"
4
4
  require_relative "../slave_support"
5
+ require_relative "../../env_key_policy"
5
6
  require_relative "../../redaction"
6
7
 
7
8
  module Profiler
@@ -13,9 +14,11 @@ module Profiler
13
14
  value = params["value"].to_s
14
15
 
15
16
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
16
- if Profiler::EnvOverrideStore.reserved_key?(key)
17
+ # Checked before the store is written, and before a slave is asked: a refused key or value
18
+ # never reaches the file nor ENV.
19
+ if (reason = Profiler::EnvKeyPolicy.rejection(key) || Profiler::EnvKeyPolicy.value_rejection(key, value))
17
20
  return ::MCP::Tool::Response.new(
18
- [{ type: "text", text: "Error: #{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged." }],
21
+ [{ type: "text", text: "Error: #{reason}; #{key} was left unchanged." }],
19
22
  error: true
20
23
  )
21
24
  end
@@ -3,6 +3,7 @@
3
3
  require "set"
4
4
  require_relative "../boot_env"
5
5
  require_relative "../env_override_store"
6
+ require_relative "../env_key_policy"
6
7
  require_relative "run_store"
7
8
  require_relative "discovery"
8
9
 
@@ -176,26 +177,14 @@ module Profiler
176
177
  end
177
178
  end
178
179
 
179
- BLOCKED_ENV_KEYS = %w[RAILS_ENV RACK_ENV DATABASE_URL SECRET_KEY_BASE PROFILER_TEST_RUNNER_CHILD].freeze
180
-
181
- # Overrides of these variables make the test process, or the shell shims (rbenv, asdf) that
182
- # start it, load or run code other than the selected tests. It is a deny list, so it cannot
183
- # be complete; a variable needed by the tests can be set in the shell that starts Rails.
184
- CODE_LOADING_ENV_KEYS = %w[
185
- SPEC_OPTS TESTOPTS TEST PATH HOME XDG_CONFIG_HOME GEMRC NODE_OPTIONS NODE_PATH
186
- SHELLOPTS BASHOPTS PS4 ENV CDPATH IFS
187
- ].freeze
188
- CODE_LOADING_ENV_PREFIXES = %w[
189
- RUBY GEM_ BUNDLE_ BUNDLER_ LD_ DYLD_ BASH_ RBENV_ ASDF_ RVM_ CHRUBY GIT_ BOOTSNAP_
190
- PYTHON PERL5
191
- ].freeze
192
- ENV_NAME = /\A[A-Z_][A-Z0-9_]*\z/i
180
+ # The lists live in Profiler::EnvKeyPolicy, shared with the Env tab and the MCP env tools.
181
+ BLOCKED_ENV_KEYS = EnvKeyPolicy::PROTECTED_KEYS
182
+ CODE_LOADING_ENV_KEYS = EnvKeyPolicy::CODE_LOADING_KEYS
183
+ CODE_LOADING_ENV_PREFIXES = EnvKeyPolicy::CODE_LOADING_PREFIXES
184
+ ENV_NAME = EnvKeyPolicy::ENV_NAME
193
185
 
194
186
  def self.code_loading_env_key?(key)
195
- name = key.to_s.upcase
196
- return true unless ENV_NAME.match?(name)
197
-
198
- CODE_LOADING_ENV_KEYS.include?(name) || CODE_LOADING_ENV_PREFIXES.any? { |prefix| name.start_with?(prefix) }
187
+ EnvKeyPolicy.code_loading?(key)
199
188
  end
200
189
 
201
190
  # Starts from the copy of ENV taken when the gem was loaded, not from ENV: the overrides are
@@ -208,12 +197,15 @@ module Profiler
208
197
 
209
198
  overrides = Profiler.env_override_store.all_overrides
210
199
  overrides.each do |key, entry|
211
- blocked = BLOCKED_ENV_KEYS.include?(key.upcase)
200
+ blocked = EnvKeyPolicy.protected?(key)
212
201
  if blocked || code_loading_env_key?(key)
213
202
  left_out << key unless blocked
214
203
  next
215
204
  end
216
205
  value = entry.is_a?(Hash) ? entry["value"] : entry
206
+ # A value the process environment cannot hold, saved by a version that did not check it.
207
+ next if EnvKeyPolicy.value_rejection(key, value)
208
+
217
209
  if value == EnvOverrideStore::DELETED_SENTINEL
218
210
  base.delete(key)
219
211
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.33.3"
4
+ VERSION = "0.34.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.3
4
+ version: 0.34.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-14 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -169,6 +169,7 @@ files:
169
169
  - lib/profiler/console_profiler.rb
170
170
  - lib/profiler/current_context.rb
171
171
  - lib/profiler/engine.rb
172
+ - lib/profiler/env_key_policy.rb
172
173
  - lib/profiler/env_override_store.rb
173
174
  - lib/profiler/explain_runner.rb
174
175
  - lib/profiler/instrumentation/active_job_instrumentation.rb