Top Level Namespace

Defined Under Namespace

Modules: Enumerable, GeneratorHelper, GeneratorMessages, ReactOnRails, ReactOnRailsHelper Classes: Array, HelloWorldController, HomeController

Constant Summary collapse

AUTH_NAME =

Recognizing a callback as authentication only silences the advisory, so this stays deliberately conservative: an over-broad match turns a false positive into a false negative, which is the more dangerous direction for a security guardrail.

A trailing !/? is the discriminating signal for the open-ended forms. Devise and friends generate bang callbacks per scope (authenticate_admin_user!, authenticate_api_user!, authenticate_customer!), and app-defined auth callbacks follow the same convention, so any authenticate*/authorize* name that ends in a bang counts. Without a bang the name must come from the narrow allowlist, which keeps unrelated callbacks such as authenticate_analytics_session and authorize_for_metrics from reading as authentication.

/
  (?:
    authenticate(?:_\w+)?[!?] |
    authorize(?:_\w+)?[!?] |
    authenticate(?:_(?:user|account|admin|member|session))? |
    authorize(?:_(?:request|access|user))? |
    require_(?:login|user|authentication|authorization) |
    verify_authenticat(?:ed|ion)
  )[!?]?
/x
AUTH_CALLBACK_NAME =
"(?:prepend_|append_)?before_action"
COMMENT_EVENTS =
%i[on_comment on_embdoc_beg on_embdoc on_embdoc_end].freeze
STRING_BODY_EVENTS =

String, heredoc, and regexp bodies are data, not executable code. Ripper reports all of them as :on_tstring_content, so they must be neutralized alongside comments — otherwise a skip_before_action written inside a heredoc reads as a real callback and produces a false warning.

%i[on_tstring_content].freeze
SCOPE_SYNTAX =
/(?:%[iw](?:\[[^\]]*\]|\([^)]*\)|\{[^}]*\})|\[[^\]]*\]|:[A-Za-z_]\w*[!?]?|["'][^"']+["'])/
ONLY_SCOPE =
/\A,\s*only:\s*(#{SCOPE_SYNTAX})\z/o
EXCEPT_SCOPE =
/\A,\s*except:\s*(#{SCOPE_SYNTAX})\z/o

Instance Method Summary collapse

Instance Method Details

#applies_to_payload?(rest, unknown:) ⇒ Boolean

Returns:

  • (Boolean)


70
71
72
73
74
75
76
77
78
79
80
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 70

def applies_to_payload?(rest, unknown:)
  return true if rest.empty?

  if (scope = rest.match(ONLY_SCOPE))
    scope[1].match?(/\brsc_payload\b/)
  elsif (scope = rest.match(EXCEPT_SCOPE))
    !scope[1].match?(/\brsc_payload\b/)
  else
    unknown
  end
end

#authenticated_callback_names(lines) ⇒ Object



82
83
84
85
86
87
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 82

def authenticated_callback_names(lines)
  logical_statements(lines, AUTH_CALLBACK_NAME).filter_map do |statement|
    parsed = parse_callback(statement, AUTH_CALLBACK_NAME)
    parsed&.first if parsed && applies_to_payload?(parsed.last, unknown: false)
  end
end

#authentication_evidence?(lines) ⇒ Boolean

Returns:

  • (Boolean)


151
152
153
154
155
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 151

def authentication_evidence?(lines)
  authenticated_callbacks = authenticated_callback_names(lines)
  remove_skipped_callbacks(lines, authenticated_callbacks)
  authenticated_callbacks.any?
end

#blank_line_starting_text(text, column) ⇒ Object

Blanks only the parts of a string body that begin a source line, because those are the parts that can masquerade as a statement to the line-oriented callback scanner. Content that starts mid-line (only: "rsc_payload") is preserved so callback scope parsing keeps working.



113
114
115
116
117
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 113

def blank_line_starting_text(text, column)
  text.split("\n", -1).each_with_index.map do |part, index|
    index.zero? && column.positive? ? part : blank_text(part)
  end.join("\n")
end

#blank_text(text) ⇒ Object



106
107
108
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 106

def blank_text(text)
  text.gsub(/[^\n]/, " ")
end

#code_lines(content) ⇒ Object

Executable lines only. Falls back to a crude comment strip when the file does not lex (so an unparseable routes file still gets its advisory rather than silently passing).



138
139
140
141
142
143
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 138

def code_lines(content)
  tokens = ruby_tokens(content)
  return scannable_ruby_lines(tokens) if tokens.any?

  content.lines.map { |line| line.sub(/#.*/, "") }
end

#headingObject



23
24
25
# File 'lib/generators/react_on_rails/templates/dev_tests/spec/system/hello_world_spec.rb', line 23

def heading
  page.first(:css, "h1")
end

#input_fileObject



157
158
159
160
161
162
163
164
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 157

def input_file
  input = $stdin.tty? ? "" : $stdin.read
  parsed_input = JSON.parse(input)
  parsed_path = parsed_input.is_a?(Hash) ? parsed_input.dig("tool_input", "file_path").to_s : ""
  parsed_path.empty? ? ARGV.first.to_s : parsed_path
rescue JSON::ParserError
  ARGV.first.to_s
end

#logical_statements(lines, callback_name) ⇒ Object



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 43

def logical_statements(lines, callback_name)
  lines.each_with_index.filter_map do |line, index|
    next unless line.match?(/^\s*#{callback_name}\b/)

    statement = line.dup
    while index + 1 < lines.length &&
          (statement.rstrip.end_with?(",", "\\") ||
           statement.count("(") > statement.count(")") ||
           statement.count("[") > statement.count("]"))
      index += 1
      statement << lines[index]
    end
    statement.gsub(/\s+/, " ").strip
  end
end

#messageObject



19
20
21
# File 'lib/generators/react_on_rails/templates/dev_tests/spec/system/hello_world_spec.rb', line 19

def message
  page.first(:css, "h3")
end

#name_inputObject



15
16
17
# File 'lib/generators/react_on_rails/templates/dev_tests/spec/system/hello_world_spec.rb', line 15

def name_input
  page.first("input")
end

#parse_callback(statement, callback_name) ⇒ Object



59
60
61
62
63
64
65
66
67
68
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 59

def parse_callback(statement, callback_name)
  callback = statement.match(
    /\A#{callback_name}\s*(?<parenthesized>\()?\s*:?(?<auth>#{AUTH_NAME})(?=\s*(?:,|\)|\z))(?<rest>.*)\z/
  )
  return unless callback

  rest = callback[:rest].strip
  rest = rest.delete_suffix(")").strip if callback[:parenthesized]
  [callback[:auth], rest]
end

#read_file(path) ⇒ Object



179
180
181
182
183
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 179

def read_file(path)
  File.read(path)
rescue SystemCallError
  nil
end

#remove_skipped_callbacks(lines, authenticated_callbacks) ⇒ Object



89
90
91
92
93
94
95
96
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 89

def remove_skipped_callbacks(lines, authenticated_callbacks)
  logical_statements(lines, "skip_before_action").each do |statement|
    parsed = parse_callback(statement, "skip_before_action")
    next unless parsed && authenticated_callbacks.include?(parsed.first)

    authenticated_callbacks.delete(parsed.first) if applies_to_payload?(parsed.last, unknown: true)
  end
end

#renderer_evidence?(tokens) ⇒ Boolean

Returns:

  • (Boolean)


145
146
147
148
149
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 145

def renderer_evidence?(tokens)
  tokens.any? do |_position, event, token, _state|
    (event == :on_const && token == "RSCPayloadRenderer") || (event == :on_ident && token == "rsc_payload")
  end
end

#ruby_tokens(content) ⇒ Object



98
99
100
101
102
103
104
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 98

def ruby_tokens(content)
  return [] unless content.valid_encoding?

  Ripper.lex(content)
rescue ArgumentError
  []
end

#scannable_ruby_lines(tokens) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 119

def scannable_ruby_lines(tokens)
  heredoc_depth = 0
  tokens.each_with_object(+"") do |(position, event, token, _state), scannable|
    heredoc_depth += 1 if event == :on_heredoc_beg
    heredoc_depth -= 1 if event == :on_heredoc_end
    scannable << if COMMENT_EVENTS.include?(event)
                   blank_text(token)
                 elsif STRING_BODY_EVENTS.include?(event)
                   # Every line of a heredoc body begins a source line; Ripper reports its indentation
                   # as a separate token, so the body's column is not a reliable signal there.
                   heredoc_depth.positive? ? blank_text(token) : blank_line_starting_text(token, position[1])
                 else
                   token
                 end
  end.lines
end

#warning_context(relative_path, detail) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb', line 166

def warning_context(relative_path, detail)
  [
    "⚠️  rsc-app-safety: #{relative_path}",
    detail,
    "The React on Rails Pro RSC payload route renders any registered server component with",
    "caller-supplied props and has NO built-in authentication. Confirm this endpoint is behind your",
    "app's auth via ReactOnRailsPro.configure { |config| config.rsc_payload_authorizer = ... } or",
    "an authenticated, app-owned controller's before_action, and",
    "that server components derive identity from the session, not props. Read the rsc-app-safety",
    "skill before shipping."
  ].join("\n")
end