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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/docs/agent/README.md +26 -0
- data/docs/agent/doctor-fix-loop.md +25 -0
- data/docs/agent/install-and-upgrade.md +162 -0
- data/docs/agent/rsc-adoption.md +31 -0
- data/docs/agent/streaming-debug.md +57 -0
- data/lib/generators/react_on_rails/base_generator.rb +4 -4
- data/lib/generators/react_on_rails/generator_helper.rb +12 -1
- data/lib/generators/react_on_rails/install_generator.rb +50 -5
- data/lib/generators/react_on_rails/js_dependency_manager.rb +1 -1
- data/lib/generators/react_on_rails/pro_generator.rb +5 -3
- data/lib/generators/react_on_rails/pro_setup.rb +51 -19
- data/lib/generators/react_on_rails/rsc_generator.rb +18 -1
- data/lib/generators/react_on_rails/rsc_setup/client_references.rb +63 -25
- data/lib/generators/react_on_rails/rsc_setup.rb +141 -39
- data/lib/generators/react_on_rails/templates/agent_files/AGENTS.md +31 -3
- data/lib/generators/react_on_rails/templates/base/base/{Procfile.dev-static-assets → Procfile.dev-static-assets.tt} +1 -1
- data/lib/generators/react_on_rails/templates/base/base/{Procfile.dev → Procfile.dev.tt} +1 -1
- data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +18 -42
- data/lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb +215 -0
- data/lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_skill.md +71 -0
- data/lib/react_on_rails/agent_guardrails.rb +223 -0
- data/lib/react_on_rails/dev/service_checker.rb +1 -1
- data/lib/react_on_rails/doctor.rb +880 -48
- data/lib/react_on_rails/doctor_schema.rb +192 -0
- data/lib/react_on_rails/engine.rb +2 -2
- data/lib/react_on_rails/helper.rb +17 -10
- data/lib/react_on_rails/length_prefixed_parser.rb +22 -14
- data/lib/react_on_rails/lenient_json.rb +0 -0
- data/lib/react_on_rails/prerender_error.rb +38 -8
- data/lib/react_on_rails/react_component/render_options.rb +1 -1
- data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +168 -15
- data/lib/react_on_rails/version.rb +1 -1
- data/lib/tasks/agent_guardrails.rake +21 -0
- data/rakelib/run_rspec.rake +14 -0
- data/sig/react_on_rails/prerender_error.rbs +2 -1
- data/skills/doctor-fix-loop/SKILL.md +26 -0
- data/skills/install-and-upgrade/SKILL.md +24 -0
- data/skills/rsc-adoption/SKILL.md +28 -0
- data/skills/streaming-debug/SKILL.md +53 -0
- metadata +18 -4
- 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.
|
|
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
|