Skip to content

Commit 5f6e66d

Browse files
justin808claude
andauthored
Add react_on_rails:install_rsc_agent_guardrails: ship RSC agent-safety skill + hook into apps (#4606)
## What Ships an installable **RSC "agent safety" guardrail** for apps that use React on Rails Pro React Server Components. A new rake task (and the RSC generator) drops a Claude Code skill + an advisory hook into the host app's `.claude/`, so AI coding agents working in that app are steered away from the RSC API footguns surfaced in the recent RSC security audit. This is the downstream half of a two-PR effort. The framework-internals half (a repo-local `rsc-guardrails` skill + payload-escaping regression test) is #4599. ## Why RSC spans a trusted-server → untrusted-client boundary. The audit (issues #4595, #4596, #4597) found the framework solid but identified **usage** footguns an app can hit: mounting `rsc_payload_route` without authentication, trusting server-component props, exposing the Node renderer, or leaking secrets/PII. Those live in app code, not the gem — so the guardrail has to ship *into the app*. ## Changes - **`react_on_rails:install_rsc_agent_guardrails` rake task** — idempotently installs/updates the guardrail assets into the current app's `.claude/`. Safe to re-run after upgrades. - **`ReactOnRails::AgentGuardrails` installer** — copies the skill + hook and registers the hook in `.claude/settings.json` via a **safe JSON merge** (preserves existing hooks; refuses to touch malformed JSON and tells you to add the one line manually). - **`rsc-app-safety` skill template** — the RSC usage footguns + a per-change checklist, installed as `.claude/skills/rsc-app-safety/SKILL.md`. - **App advisory hook template** — a non-blocking `PostToolUse` warning when `config/routes.rb` mounts `rsc_payload_route`, or an RSC controller lacks a visible `before_action`. Installed as `.claude/hooks/rsc-app-safety-check.sh`. - **RSC generator wiring** — `rails g react_on_rails:rsc` now runs the installer, so new RSC apps get the guardrails automatically. ## Verification - New rspec `agent_guardrails_spec.rb`: **4/4 pass** — fresh install, idempotency, safe merge into an existing `settings.json`, and raise-not-clobber on malformed JSON. - `rsc_generator_spec.rb`: passes with the generator wiring. - Rake task exercised end-to-end into a temp app (create → idempotent re-run). - App hook dry-run: warns on unauthenticated `rsc_payload_route` / RSC controller; silent otherwise. `shellcheck` clean; matching anchored on the absolute path (robust to symlinked/`/var` checkouts). - `rubocop` clean; `prettier --check` clean. ## Deliberately deferred (fast-follow) A dedicated docs page — the `rsc-app-safety` skill and the rake task `desc` are self-documenting for now; a docs/ page would pull in the llms-full regeneration gate. Happy to add it in a follow-up if you want it in the docs site. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added RSC agent guardrails that install a Claude Code “rsc-app-safety” skill and a safety advisory hook. * Added `react_on_rails:install_rsc_agent_guardrails` to install/update the guardrails into the app’s `.claude/` directory. * RSC generation now automatically installs these guardrails when Pro is enabled, including payload auth guidance based on routes/controllers. * **Bug Fixes** * Guardrails installation is idempotent, preserves unrelated settings, supports “skip existing”, and removes any legacy hook script. * Install failures degrade gracefully during generation, while the rake task reports permission issues cleanly and exits with status `1`. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a043473 commit 5f6e66d

8 files changed

Lines changed: 1497 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,15 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
213213

214214
#### Added
215215

216+
- **[Pro]** **RSC agent guardrails installer**: New
217+
`rake react_on_rails:install_rsc_agent_guardrails` task (also run automatically by the RSC
218+
generator) installs a Claude Code `rsc-app-safety` skill and an advisory hook into a host app's
219+
`.claude/`, steering AI coding agents away from React Server Components API footguns (mounting the
220+
RSC payload route without authentication, trusting server-component props, exposing the Node
221+
renderer, leaking secrets). Idempotent and safe to re-run after upgrades.
222+
[PR 4606](https://github.com/shakacode/react_on_rails/pull/4606) by
223+
[justin808](https://github.com/justin808).
224+
216225
- **[Pro] React 18 support for non-RSC streaming SSR**: `stream_react_component` with synchronous
217226
props is now explicitly supported on React 18 as well as React 19. Permanent packed-artifact
218227
coverage verifies a production Webpack build and progressive Suspense output on React 18 without

react_on_rails/lib/generators/react_on_rails/rsc_generator.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "rails/generators"
4+
require "react_on_rails/agent_guardrails"
45
require_relative "generator_helper"
56
require_relative "generator_messages"
67
require_relative "js_dependency_manager"
@@ -53,6 +54,7 @@ def run_generator
5354
warn_about_react_version_for_rsc(force: true)
5455
setup_rsc
5556
add_rsc_npm_dependencies
57+
install_agent_guardrails
5658
print_success_message unless options[:invoked_by_install]
5759
else
5860
GeneratorMessages.add_error(<<~MSG.strip)
@@ -120,6 +122,21 @@ def add_rsc_npm_dependencies
120122
say "✅ RSC npm dependencies added", :green
121123
end
122124

125+
def install_agent_guardrails
126+
if options[:pretend]
127+
say_status :pretend, ".claude/ (RSC agent guardrails)", :yellow
128+
return
129+
end
130+
131+
say "🛡️ Installing RSC agent guardrails (rsc-app-safety skill + advisory hook)...", :yellow
132+
ReactOnRails::AgentGuardrails.install(destination_root, skip_existing: options[:skip]).each do |action|
133+
say " #{action}"
134+
end
135+
say "✅ RSC agent guardrails installed", :green
136+
rescue ReactOnRails::AgentGuardrails::Error, SystemCallError => e
137+
say "⚠️ RSC agent guardrail installation incomplete: #{e.message}", :yellow
138+
end
139+
123140
def print_success_message
124141
GeneratorMessages.add_info(<<~MSG)
125142
Next steps:
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)