react_on_rails 17.0.1 → 17.1.0.rc.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/docs/agent/README.md +26 -0
  4. data/docs/agent/doctor-fix-loop.md +25 -0
  5. data/docs/agent/install-and-upgrade.md +162 -0
  6. data/docs/agent/rsc-adoption.md +31 -0
  7. data/docs/agent/streaming-debug.md +57 -0
  8. data/lib/generators/react_on_rails/base_generator.rb +4 -4
  9. data/lib/generators/react_on_rails/generator_helper.rb +12 -1
  10. data/lib/generators/react_on_rails/install_generator.rb +50 -5
  11. data/lib/generators/react_on_rails/js_dependency_manager.rb +1 -1
  12. data/lib/generators/react_on_rails/pro_generator.rb +5 -3
  13. data/lib/generators/react_on_rails/pro_setup.rb +51 -19
  14. data/lib/generators/react_on_rails/rsc_generator.rb +18 -1
  15. data/lib/generators/react_on_rails/rsc_setup/client_references.rb +63 -25
  16. data/lib/generators/react_on_rails/rsc_setup.rb +141 -39
  17. data/lib/generators/react_on_rails/templates/agent_files/AGENTS.md +31 -3
  18. data/lib/generators/react_on_rails/templates/base/base/{Procfile.dev-static-assets → Procfile.dev-static-assets.tt} +1 -1
  19. data/lib/generators/react_on_rails/templates/base/base/{Procfile.dev → Procfile.dev.tt} +1 -1
  20. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +18 -42
  21. data/lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb +215 -0
  22. data/lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_skill.md +71 -0
  23. data/lib/react_on_rails/agent_guardrails.rb +223 -0
  24. data/lib/react_on_rails/dev/service_checker.rb +1 -1
  25. data/lib/react_on_rails/doctor.rb +880 -48
  26. data/lib/react_on_rails/doctor_schema.rb +192 -0
  27. data/lib/react_on_rails/engine.rb +2 -2
  28. data/lib/react_on_rails/helper.rb +17 -10
  29. data/lib/react_on_rails/length_prefixed_parser.rb +22 -14
  30. data/lib/react_on_rails/lenient_json.rb +0 -0
  31. data/lib/react_on_rails/prerender_error.rb +38 -8
  32. data/lib/react_on_rails/react_component/render_options.rb +1 -1
  33. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +168 -15
  34. data/lib/react_on_rails/version.rb +1 -1
  35. data/lib/tasks/agent_guardrails.rake +21 -0
  36. data/rakelib/run_rspec.rake +14 -0
  37. data/sig/react_on_rails/prerender_error.rbs +2 -1
  38. data/skills/doctor-fix-loop/SKILL.md +26 -0
  39. data/skills/install-and-upgrade/SKILL.md +24 -0
  40. data/skills/rsc-adoption/SKILL.md +28 -0
  41. data/skills/streaming-debug/SKILL.md +53 -0
  42. metadata +18 -4
  43. data/lib/generators/react_on_rails/templates/base/base/bin/shakapacker-watch +0 -26
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Claude Code PostToolUse hook — installed by `rake react_on_rails:install_rsc_agent_guardrails`.
5
+ # NON-BLOCKING advisory (always exits 0). Warns when React on Rails Pro RSC endpoints are wired
6
+ # without visible authentication. The RSC payload route renders any registered server component
7
+ # with caller-supplied props and has NO built-in auth — see the `rsc-app-safety` skill.
8
+ # Callback detection is a conservative name/scope heuristic; silence does not prove authorization.
9
+
10
+ require "json"
11
+ require "ripper"
12
+
13
+ # Recognizing a callback as authentication only silences the advisory, so this stays deliberately
14
+ # conservative: an over-broad match turns a false positive into a false negative, which is the more
15
+ # dangerous direction for a security guardrail.
16
+ #
17
+ # A trailing `!`/`?` is the discriminating signal for the open-ended forms. Devise and friends
18
+ # generate bang callbacks per scope (`authenticate_admin_user!`, `authenticate_api_user!`,
19
+ # `authenticate_customer!`), and app-defined auth callbacks follow the same convention, so any
20
+ # `authenticate*`/`authorize*` name that ends in a bang counts. Without a bang the name must come
21
+ # from the narrow allowlist, which keeps unrelated callbacks such as `authenticate_analytics_session`
22
+ # and `authorize_for_metrics` from reading as authentication.
23
+ AUTH_NAME = /
24
+ (?:
25
+ authenticate(?:_\w+)?[!?] |
26
+ authorize(?:_\w+)?[!?] |
27
+ authenticate(?:_(?:user|account|admin|member|session))? |
28
+ authorize(?:_(?:request|access|user))? |
29
+ require_(?:login|user|authentication|authorization) |
30
+ verify_authenticat(?:ed|ion)
31
+ )[!?]?
32
+ /x
33
+ AUTH_CALLBACK_NAME = "(?:prepend_|append_)?before_action"
34
+ COMMENT_EVENTS = %i[on_comment on_embdoc_beg on_embdoc on_embdoc_end].freeze
35
+ # String, heredoc, and regexp bodies are data, not executable code. Ripper reports all of them as
36
+ # :on_tstring_content, so they must be neutralized alongside comments — otherwise a
37
+ # `skip_before_action` written inside a heredoc reads as a real callback and produces a false warning.
38
+ STRING_BODY_EVENTS = %i[on_tstring_content].freeze
39
+ SCOPE_SYNTAX = /(?:%[iw](?:\[[^\]]*\]|\([^)]*\)|\{[^}]*\})|\[[^\]]*\]|:[A-Za-z_]\w*[!?]?|["'][^"']+["'])/
40
+ ONLY_SCOPE = /\A,\s*only:\s*(#{SCOPE_SYNTAX})\z/o
41
+ EXCEPT_SCOPE = /\A,\s*except:\s*(#{SCOPE_SYNTAX})\z/o
42
+
43
+ def logical_statements(lines, callback_name)
44
+ lines.each_with_index.filter_map do |line, index|
45
+ next unless line.match?(/^\s*#{callback_name}\b/)
46
+
47
+ statement = line.dup
48
+ while index + 1 < lines.length &&
49
+ (statement.rstrip.end_with?(",", "\\") ||
50
+ statement.count("(") > statement.count(")") ||
51
+ statement.count("[") > statement.count("]"))
52
+ index += 1
53
+ statement << lines[index]
54
+ end
55
+ statement.gsub(/\s+/, " ").strip
56
+ end
57
+ end
58
+
59
+ def parse_callback(statement, callback_name)
60
+ callback = statement.match(
61
+ /\A#{callback_name}\s*(?<parenthesized>\()?\s*:?(?<auth>#{AUTH_NAME})(?=\s*(?:,|\)|\z))(?<rest>.*)\z/
62
+ )
63
+ return unless callback
64
+
65
+ rest = callback[:rest].strip
66
+ rest = rest.delete_suffix(")").strip if callback[:parenthesized]
67
+ [callback[:auth], rest]
68
+ end
69
+
70
+ def applies_to_payload?(rest, unknown:)
71
+ return true if rest.empty?
72
+
73
+ if (scope = rest.match(ONLY_SCOPE))
74
+ scope[1].match?(/\brsc_payload\b/)
75
+ elsif (scope = rest.match(EXCEPT_SCOPE))
76
+ !scope[1].match?(/\brsc_payload\b/)
77
+ else
78
+ unknown
79
+ end
80
+ end
81
+
82
+ def authenticated_callback_names(lines)
83
+ logical_statements(lines, AUTH_CALLBACK_NAME).filter_map do |statement|
84
+ parsed = parse_callback(statement, AUTH_CALLBACK_NAME)
85
+ parsed&.first if parsed && applies_to_payload?(parsed.last, unknown: false)
86
+ end
87
+ end
88
+
89
+ def remove_skipped_callbacks(lines, authenticated_callbacks)
90
+ logical_statements(lines, "skip_before_action").each do |statement|
91
+ parsed = parse_callback(statement, "skip_before_action")
92
+ next unless parsed && authenticated_callbacks.include?(parsed.first)
93
+
94
+ authenticated_callbacks.delete(parsed.first) if applies_to_payload?(parsed.last, unknown: true)
95
+ end
96
+ end
97
+
98
+ def ruby_tokens(content)
99
+ return [] unless content.valid_encoding?
100
+
101
+ Ripper.lex(content)
102
+ rescue ArgumentError
103
+ []
104
+ end
105
+
106
+ def blank_text(text)
107
+ text.gsub(/[^\n]/, " ")
108
+ end
109
+
110
+ # Blanks only the parts of a string body that begin a source line, because those are the parts that
111
+ # can masquerade as a statement to the line-oriented callback scanner. Content that starts mid-line
112
+ # (`only: "rsc_payload"`) is preserved so callback scope parsing keeps working.
113
+ def blank_line_starting_text(text, column)
114
+ text.split("\n", -1).each_with_index.map do |part, index|
115
+ index.zero? && column.positive? ? part : blank_text(part)
116
+ end.join("\n")
117
+ end
118
+
119
+ def scannable_ruby_lines(tokens)
120
+ heredoc_depth = 0
121
+ tokens.each_with_object(+"") do |(position, event, token, _state), scannable|
122
+ heredoc_depth += 1 if event == :on_heredoc_beg
123
+ heredoc_depth -= 1 if event == :on_heredoc_end
124
+ scannable << if COMMENT_EVENTS.include?(event)
125
+ blank_text(token)
126
+ elsif STRING_BODY_EVENTS.include?(event)
127
+ # Every line of a heredoc body begins a source line; Ripper reports its indentation
128
+ # as a separate token, so the body's column is not a reliable signal there.
129
+ heredoc_depth.positive? ? blank_text(token) : blank_line_starting_text(token, position[1])
130
+ else
131
+ token
132
+ end
133
+ end.lines
134
+ end
135
+
136
+ # Executable lines only. Falls back to a crude comment strip when the file does not lex (so an
137
+ # unparseable routes file still gets its advisory rather than silently passing).
138
+ def code_lines(content)
139
+ tokens = ruby_tokens(content)
140
+ return scannable_ruby_lines(tokens) if tokens.any?
141
+
142
+ content.lines.map { |line| line.sub(/#.*/, "") }
143
+ end
144
+
145
+ def renderer_evidence?(tokens)
146
+ tokens.any? do |_position, event, token, _state|
147
+ (event == :on_const && token == "RSCPayloadRenderer") || (event == :on_ident && token == "rsc_payload")
148
+ end
149
+ end
150
+
151
+ def authentication_evidence?(lines)
152
+ authenticated_callbacks = authenticated_callback_names(lines)
153
+ remove_skipped_callbacks(lines, authenticated_callbacks)
154
+ authenticated_callbacks.any?
155
+ end
156
+
157
+ def input_file
158
+ input = $stdin.tty? ? "" : $stdin.read
159
+ parsed_input = JSON.parse(input)
160
+ parsed_path = parsed_input.is_a?(Hash) ? parsed_input.dig("tool_input", "file_path").to_s : ""
161
+ parsed_path.empty? ? ARGV.first.to_s : parsed_path
162
+ rescue JSON::ParserError
163
+ ARGV.first.to_s
164
+ end
165
+
166
+ def warning_context(relative_path, detail)
167
+ [
168
+ "⚠️ rsc-app-safety: #{relative_path}",
169
+ detail,
170
+ "The React on Rails Pro RSC payload route renders any registered server component with",
171
+ "caller-supplied props and has NO built-in authentication. Confirm this endpoint is behind your",
172
+ "app's auth via ReactOnRailsPro.configure { |config| config.rsc_payload_authorizer = ... } or",
173
+ "an authenticated, app-owned controller's before_action, and",
174
+ "that server components derive identity from the session, not props. Read the rsc-app-safety",
175
+ "skill before shipping."
176
+ ].join("\n")
177
+ end
178
+
179
+ def read_file(path)
180
+ File.read(path)
181
+ rescue SystemCallError
182
+ nil
183
+ end
184
+
185
+ file = input_file.tr("\\", "/")
186
+ exit 0 if file.empty? || !File.file?(file)
187
+
188
+ root = ENV.fetch("CLAUDE_PROJECT_DIR", Dir.pwd).tr("\\", "/").delete_suffix("/")
189
+ relative_path = file.delete_prefix("#{root}/")
190
+ detail = case file
191
+ when %r{\A(?:.*/)?config/routes(?:/[^/]+)?\.rb\z}
192
+ content = read_file(file)
193
+ if content && code_lines(content).any? { |line| line.include?("rsc_payload_route") }
194
+ "This routes file mounts rsc_payload_route (a public RSC endpoint)."
195
+ end
196
+ when %r{\A(?:.*/)?app/controllers/.+\.rb\z}
197
+ content = read_file(file)
198
+ tokens = ruby_tokens(content) if content
199
+ lines = scannable_ruby_lines(tokens) if tokens&.any?
200
+ if lines && renderer_evidence?(tokens) && !authentication_evidence?(lines)
201
+ "This controller wires an RSC payload renderer, but the edited file shows no " \
202
+ "before_action/authentication locally. Inherited callbacks are not inspected."
203
+ end
204
+ end
205
+
206
+ if detail
207
+ puts JSON.generate(
208
+ "hookSpecificOutput" => {
209
+ "hookEventName" => "PostToolUse",
210
+ "additionalContext" => warning_context(relative_path, detail)
211
+ }
212
+ )
213
+ end
214
+
215
+ exit 0
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: rsc-app-safety
3
+ description: >
4
+ Use when building or reviewing a Rails app that uses React on Rails Pro React
5
+ Server Components (RSC) — mounting the RSC payload route, writing server
6
+ components, configuring the Node renderer, or handling props, secrets, or error
7
+ reporting. Flags the RSC API footguns that expose an app to XSS, auth bypass, or
8
+ data leakage.
9
+ ---
10
+
11
+ # RSC app safety (React on Rails Pro)
12
+
13
+ React Server Components render on a trusted server and stream to an untrusted client. The React on
14
+ Rails Pro RSC API is safe when used correctly, but a few usage patterns are footguns. Check these
15
+ whenever you add or change RSC in this app.
16
+
17
+ ## Footguns to avoid
18
+
19
+ 1. **Authenticate the RSC payload route.** `rsc_payload_route` mounts a public endpoint that renders
20
+ **any registered server component** with **caller-supplied props**
21
+ (`GET <path>/<Component>?props=…`). It ships with no built-in authentication. The default
22
+ `ReactOnRailsPro::RscPayloadController` does not inherit from your app's `ApplicationController`,
23
+ so an application-wide `before_action` does not protect it. Configure its supported authorizer in
24
+ `config/initializers/react_on_rails_pro.rb` to check the Rails session and component name before
25
+ props are parsed or rendered:
26
+
27
+ ```ruby
28
+ allowed_rsc_components = %w[AccountPage DashboardPage].freeze
29
+
30
+ ReactOnRailsPro.configure do |config|
31
+ config.rsc_payload_authorizer = lambda do |controller, component_name|
32
+ controller.session[:user_id].present? && allowed_rsc_components.include?(component_name)
33
+ end
34
+ end
35
+ ```
36
+
37
+ Alternatively, explicitly route to an app-owned controller that applies the required
38
+ authentication and authorization callbacks. (Ref: `shakacode/react_on_rails#4595`.)
39
+
40
+ 2. **Treat server-component props as untrusted input.** A server component must derive the current
41
+ user and permissions from the Rails session / `railsContext`, never from its props — props on the
42
+ payload route are attacker-controlled. Don't key data access or authorization off props alone.
43
+
44
+ 3. **Keep the Node renderer private.** It executes server bundles and is not meant to be
45
+ internet-facing. Bind it to a private network, set `RENDERER_PASSWORD` (required in production —
46
+ the renderer refuses to boot without one), and never expose its port publicly.
47
+ (Ref: `shakacode/react_on_rails#4596`.)
48
+
49
+ 4. **Minimize secrets or PII.** Don't embed the renderer password in a `renderer_url` that also gets
50
+ logged. React on Rails redacts SSR props and generated JavaScript from `PrerenderError` messages
51
+ and error-tracker context, but still minimize sensitive props and avoid logging them in application
52
+ code. (Ref: `shakacode/react_on_rails#4597`.)
53
+
54
+ 5. **Don't hand-build inline scripts around RSC output.** Let React on Rails serialize props and RSC
55
+ payloads — it HTML-escapes them (`ERB::Util.json_escape`). Never wrap RSC data in your own
56
+ `<script>` / `raw` / `html_safe` string; that reintroduces the XSS the framework already prevents.
57
+
58
+ ## Quick checklist for an RSC change
59
+
60
+ - [ ] Any `rsc_payload_route` is protected by `config.rsc_payload_authorizer` or explicitly targets
61
+ an authenticated, app-owned controller.
62
+ - [ ] Server components read identity from session / `railsContext`, not from props.
63
+ - [ ] Node renderer is private and `RENDERER_PASSWORD` is set; its port is not publicly exposed.
64
+ - [ ] No secrets in logged URLs; sensitive props minimized and not logged by application code.
65
+ - [ ] No custom `<script>` / `html_safe` / `raw` wrapping of props or RSC payloads.
66
+
67
+ ## More
68
+
69
+ - React on Rails Pro RSC docs: https://www.shakacode.com/react-on-rails-pro
70
+ - Update this skill anytime with `rake react_on_rails:install_rsc_agent_guardrails`.
71
+ - This skill and its hook are managed copies; reinstalling replaces local edits to either file.
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "tempfile"
6
+
7
+ module ReactOnRails
8
+ # Installs (and idempotently updates) the RSC "agent guardrail" assets into a host app:
9
+ # a Claude Code skill and an advisory PostToolUse hook that steer AI agents away from the
10
+ # React Server Components API footguns (unauthenticated payload route, trusting props, exposing
11
+ # the Node renderer, leaking secrets). Invoked by `rake react_on_rails:install_rsc_agent_guardrails`
12
+ # and by the RSC generator.
13
+ module AgentGuardrails
14
+ Error = Class.new(StandardError)
15
+
16
+ TEMPLATES_DIR = File.expand_path("agent_guardrails/templates", __dir__)
17
+
18
+ # source template (under TEMPLATES_DIR) => destination path relative to the app root
19
+ FILES = {
20
+ "rsc_app_safety_skill.md" => ".claude/skills/rsc-app-safety/SKILL.md",
21
+ "rsc_app_safety_check.rb" => ".claude/hooks/rsc-app-safety-check.rb"
22
+ }.freeze
23
+
24
+ HOOK_COMMAND = "ruby"
25
+ HOOK_ARGS = ["${CLAUDE_PROJECT_DIR}/.claude/hooks/rsc-app-safety-check.rb"].freeze
26
+ LEGACY_HOOK_COMMAND = "${CLAUDE_PROJECT_DIR}/.claude/hooks/rsc-app-safety-check.sh"
27
+ LEGACY_HOOK_REL = ".claude/hooks/rsc-app-safety-check.sh"
28
+ HOOK_REL = ".claude/hooks/rsc-app-safety-check.rb"
29
+ HOOK_MATCHER = "Edit|Write"
30
+ SETTINGS_REL = ".claude/settings.json"
31
+
32
+ # Copies the guardrail files and registers the advisory hook. Idempotent: re-running only
33
+ # writes what changed. Returns an array of human-readable action strings.
34
+ def self.install(destination_root, skip_existing: false)
35
+ new_installer(destination_root, skip_existing:).install
36
+ end
37
+
38
+ def self.new_installer(destination_root, skip_existing: false)
39
+ Installer.new(destination_root, skip_existing:)
40
+ end
41
+
42
+ # Where guardrails get installed when no explicit destination is given. Prefers the Rails
43
+ # application root so the task installs into the app's `.claude/` even when rake is invoked
44
+ # from a subdirectory; falls back to the working directory outside a Rails app.
45
+ def self.default_destination_root(explicit = nil)
46
+ explicit = explicit.to_s
47
+ return explicit unless explicit.empty?
48
+
49
+ rails_root = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : nil
50
+ rails_root ? rails_root.to_s : Dir.pwd
51
+ end
52
+
53
+ # Encapsulates a single install run against one app root.
54
+ class Installer
55
+ def initialize(destination_root, skip_existing: false)
56
+ @destination_root = File.expand_path(destination_root.to_s)
57
+ @skip_existing = skip_existing
58
+ end
59
+
60
+ def install
61
+ validate_settings_before_copy
62
+ actions = FILES.map { |source, dest_rel| copy_file(source, dest_rel) }
63
+ actions << register_hook
64
+ actions << remove_legacy_hook
65
+ actions.compact
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :destination_root, :skip_existing
71
+
72
+ def validate_settings_before_copy
73
+ settings_path = File.join(destination_root, SETTINGS_REL)
74
+ return if skip_existing && File.exist?(settings_path)
75
+
76
+ read_settings(settings_path)
77
+ end
78
+
79
+ def copy_file(source, dest_rel)
80
+ source_path = File.join(TEMPLATES_DIR, source)
81
+ dest_path = File.join(destination_root, dest_rel)
82
+ existed = File.exist?(dest_path)
83
+ return "skipped #{dest_rel} (already exists)" if skip_existing && existed
84
+
85
+ new_content = File.read(source_path)
86
+ unchanged = existed && File.read(dest_path) == new_content
87
+
88
+ unless unchanged
89
+ FileUtils.mkdir_p(File.dirname(dest_path))
90
+ File.write(dest_path, new_content)
91
+ end
92
+ File.chmod(0o755, dest_path) if dest_rel == HOOK_REL
93
+
94
+ return "unchanged #{dest_rel}" if unchanged
95
+
96
+ existed ? "updated #{dest_rel}" : "created #{dest_rel}"
97
+ end
98
+
99
+ def register_hook
100
+ settings_path = File.join(destination_root, SETTINGS_REL)
101
+ return "skipped #{SETTINGS_REL} (already exists)" if skip_existing && File.exist?(settings_path)
102
+
103
+ settings = read_settings(settings_path)
104
+ return "unchanged #{SETTINGS_REL} (hook already registered)" if hook_registered?(settings)
105
+
106
+ add_hook(settings)
107
+ FileUtils.mkdir_p(File.dirname(settings_path))
108
+ existed = File.exist?(settings_path)
109
+ atomic_write(settings_path, "#{JSON.pretty_generate(settings)}\n")
110
+ existed ? "updated #{SETTINGS_REL} (registered hook)" : "created #{SETTINGS_REL} (registered hook)"
111
+ end
112
+
113
+ # settings.json is the user's file and may hold unrelated configuration, so it is replaced by
114
+ # rename rather than truncate-and-write: an interrupted or failed write can never leave a
115
+ # half-written or empty settings file behind.
116
+ def atomic_write(path, content)
117
+ directory = File.dirname(path)
118
+ mode = File.exist?(path) ? File.stat(path).mode & 0o7777 : 0o644
119
+ temp = Tempfile.create([".#{File.basename(path)}", ".tmp"], directory)
120
+ begin
121
+ temp.write(content)
122
+ temp.flush
123
+ temp.fsync
124
+ temp.close
125
+ File.chmod(mode, temp.path)
126
+ File.rename(temp.path, path)
127
+ rescue StandardError
128
+ FileUtils.rm_f(temp.path)
129
+ raise
130
+ end
131
+ end
132
+
133
+ def remove_legacy_hook
134
+ legacy_path = File.join(destination_root, LEGACY_HOOK_REL)
135
+ return unless File.exist?(legacy_path)
136
+ return "skipped #{LEGACY_HOOK_REL} (already exists)" if skip_existing
137
+
138
+ FileUtils.rm_f(legacy_path)
139
+ "removed #{LEGACY_HOOK_REL} (replaced by #{HOOK_REL})"
140
+ end
141
+
142
+ def read_settings(path)
143
+ return {} unless File.exist?(path)
144
+
145
+ content = File.read(path).strip
146
+ return {} if content.empty?
147
+
148
+ settings = JSON.parse(content)
149
+ raise Error, invalid_settings_message unless valid_settings_shape?(settings)
150
+
151
+ settings
152
+ rescue JSON::ParserError
153
+ raise Error, invalid_settings_message
154
+ end
155
+
156
+ def valid_settings_shape?(settings)
157
+ return false unless settings.is_a?(Hash)
158
+
159
+ hooks = settings["hooks"]
160
+ return true if hooks.nil?
161
+ return false unless hooks.is_a?(Hash)
162
+
163
+ valid_post_tool_use?(hooks["PostToolUse"])
164
+ end
165
+
166
+ def valid_post_tool_use?(entries)
167
+ return true if entries.nil?
168
+
169
+ entries.is_a?(Array) && entries.all? { |entry| valid_hook_group?(entry) }
170
+ end
171
+
172
+ def valid_hook_group?(entry)
173
+ return false unless entry.is_a?(Hash)
174
+
175
+ hooks = entry["hooks"]
176
+ hooks.nil? || (hooks.is_a?(Array) && hooks.all?(Hash))
177
+ end
178
+
179
+ # Settings are validated before anything is copied, so reaching here means NOTHING was
180
+ # installed — including #{HOOK_REL}. The recovery therefore has to be "fix the settings and
181
+ # re-run", not "register the hook manually": the hook script does not exist yet, so pointing
182
+ # Claude at it would leave the advisory guardrail silently disabled.
183
+ def invalid_settings_message
184
+ "#{SETTINGS_REL} is not valid JSON for Claude settings, so it was left untouched and no " \
185
+ "guardrail files were installed (#{HOOK_REL} does not exist yet). Fix or remove " \
186
+ "#{SETTINGS_REL}, then re-run `rake react_on_rails:install_rsc_agent_guardrails` to " \
187
+ "install the hook and register it as a PostToolUse (#{HOOK_MATCHER}) command hook."
188
+ end
189
+
190
+ def hook_registered?(settings)
191
+ entries = Array(settings.dig("hooks", "PostToolUse"))
192
+ managed_hooks = entries.flat_map { |entry| Array(entry["hooks"]) }.select { |hook| managed_hook?(hook) }
193
+ return false unless managed_hooks.one? && registered_hook?(managed_hooks.first)
194
+
195
+ entries.any? do |entry|
196
+ entry["matcher"] == HOOK_MATCHER && Array(entry["hooks"]).include?(managed_hooks.first)
197
+ end
198
+ end
199
+
200
+ def add_hook(settings)
201
+ hooks = (settings["hooks"] ||= {})
202
+ post_tool_use = (hooks["PostToolUse"] ||= [])
203
+ post_tool_use.each do |candidate|
204
+ Array(candidate["hooks"]).reject! { |hook| managed_hook?(hook) }
205
+ end
206
+ entry = post_tool_use.find { |candidate| candidate["matcher"] == HOOK_MATCHER }
207
+ unless entry
208
+ entry = { "matcher" => HOOK_MATCHER, "hooks" => [] }
209
+ post_tool_use << entry
210
+ end
211
+ (entry["hooks"] ||= []) << { "type" => "command", "command" => HOOK_COMMAND, "args" => HOOK_ARGS }
212
+ end
213
+
214
+ def registered_hook?(hook)
215
+ hook["type"] == "command" && hook["command"] == HOOK_COMMAND && hook["args"] == HOOK_ARGS
216
+ end
217
+
218
+ def managed_hook?(hook)
219
+ registered_hook?(hook) || hook["command"] == LEGACY_HOOK_COMMAND
220
+ end
221
+ end
222
+ end
223
+ end
@@ -90,7 +90,7 @@ module ReactOnRails
90
90
  end
91
91
 
92
92
  def load_config(config_path)
93
- YAML.load_file(config_path)
93
+ YAML.safe_load_file(config_path)
94
94
  rescue StandardError => e
95
95
  puts Rainbow("⚠️ Failed to load #{config_path}: #{e.message}").yellow
96
96
  puts Rainbow(" Continuing without service checks...").yellow