rails-ai-context 5.32.2 → 5.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.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -17,6 +17,8 @@ module RailsAiContext
17
17
  PLACEHOLDER_NAMES = SafePath::PLACEHOLDER_SUFFIXES.map { |suffix| Regexp.escape(suffix.delete_prefix(".")) }.join("|")
18
18
  EXAMPLE_FILE = /\.(?:#{PLACEHOLDER_NAMES})\b/
19
19
  YAML_FILE = /\.ya?ml(?:\.(?:erb|#{PLACEHOLDER_NAMES}))*\z/
20
+ # A log, rotated or kept ones included: Logger's development.log.0, logrotate's .log-20261001, .log-2026-10-01 and .log.old, and a .log.bak.
21
+ LOG_FILE = /\.log(?:\.\d+|-\d{4}-?\d{2}-?\d{2}(?:-?\d{2})?|\.old|\.bak)?\z/
20
22
 
21
23
  # Regex by design: these scrub vocabulary out of values already read from
22
24
  # the AST or a log file rather than parsing structure. A credential can
@@ -32,7 +34,10 @@ module RailsAiContext
32
34
 
33
35
  # The secret word has to be a whole underscore-delimited part of the name,
34
36
  # so `secret_key` and `api_key` match while `passwordless_login` does not.
35
- SECRET_NAME = /(?<!\w)(?:\w+_)?(?:#{SECRET_WORD})(?:_\w+)?(?!\w)/i
37
+ # A name is a whole word, so it is taken atomically: when what follows it
38
+ # fails, trying the word split another way cannot help, and doing so cost
39
+ # the square of a long word with the secret word in many of its parts.
40
+ SECRET_NAME = /(?<!\w)(?>(?:\w+_)?(?:#{SECRET_WORD})(?:_\w+)?(?!\w))/i
36
41
 
37
42
  # A name ending in `_KEY`/`Key`/`key` is as often a cache or i18n key as a credential,
38
43
  # so under it only a credential-looking value is filtered (see key_credential?).
@@ -66,24 +71,18 @@ module RailsAiContext
66
71
  CREDENTIAL_SHAPE = /[a-f0-9]{16,}|sk_|pk_/i
67
72
 
68
73
  ANSI_ESCAPE = /\e\[[0-9;]*[mGKHF]/
69
- EMAIL_PATTERN = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i
74
+ # An address in any script (jürgen@example.com, anna@münchen.de). It starts
75
+ # where a run of name characters starts and takes the name whole, with
76
+ # nothing to give back, so a 1 MB line of near-addresses costs a pass,
77
+ # not a pass per character.
78
+ EMAIL_PATTERN = /(?<![\p{L}\p{M}\p{N}._%+-])[\p{L}\p{M}\p{N}._%+-]++@(?:[\p{L}\p{M}\p{N}-]++\.)+[\p{L}\p{M}]{2,}(?![\p{L}\p{M}\p{N}-])/
79
+ # The same for a line in another encoding, which a Unicode pattern cannot read.
80
+ ASCII_EMAIL_PATTERN = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i
70
81
  DOTENV_LINE = /\[dotenv\]\s+Set\s+.*/i
71
82
  # A log value: quoted whole, or bare up to a space, a quote or a separator,
72
83
  # so the quotes and the comma around it survive its redaction.
73
84
  LOG_VALUE = /(?!["']?\[FILTERED\])(?:"[^"]*"|'[^']*'|[^\s"',;)\]}]+)/
74
85
 
75
- # A log line spells an assignment four ways: `key=value`, `key: value`,
76
- # `"key"=>"value"` and `"key":"value"`. One pattern over SECRET_NAME
77
- # covers every secret-ish name in all four; a per-name lookbehind list
78
- # covered four names in two shapes and shipped the rest verbatim.
79
- # Lookbehind is unavailable here because SECRET_NAME is variable width,
80
- # so the key and the assignment are captured and written back.
81
- LOG_SECRET_ASSIGNMENT = /
82
- (["']?)(#{SECRET_NAME}|#{KEYISH_NAME})\1
83
- (\s*(?:=>|:|=(?!>))\s*)
84
- (?!["']?\[FILTERED\])(?:"([^"]*)"|'([^']*)'|([^\s,;&)\]}]+))
85
- /xi
86
-
87
86
  # Formats that are a credential wherever they appear: nothing else in
88
87
  # code or config is written this way. A JWT's signature may be empty.
89
88
  CREDENTIAL_TOKENS = [
@@ -112,12 +111,28 @@ module RailsAiContext
112
111
  # Each entry is [pattern, replacement]. The replacement is spelled beside
113
112
  # the pattern rather than worked out later by grepping the pattern's own
114
113
  # source for a distinguishing substring.
114
+ #
115
+ # The names are matched, not looked behind for. Under /i Unicode case
116
+ # folding makes `s` two bytes long (`ſ` is one), so a look-behind holding
117
+ # one has no fixed length, and Onigmo before Ruby 3.4 raises on it as
118
+ # soon as the line carries any non-ASCII character - which every
119
+ # development log's `↳` query-source line does. read_logs then failed
120
+ # outright, and diagnose lost its log section.
115
121
  LOG_PATTERNS = ([
116
- [ /(?<=authorization:\s)(?:Bearer\s)?[^\s"',;)\]}]+/i, FILTERED ],
117
- # A `value` group is filtered in place, keeping the name and the quotes.
118
- [ /(?<=cookie:\s)(?<value>#{LOG_VALUE})/i, :value ],
119
- [ /(?<=session_id=)(?<value>#{LOG_VALUE})/i, :value ],
120
- [ /(?<=_session=)(?<value>#{LOG_VALUE})/i, :value ],
122
+ # A `value` group, which ends the match, is filtered in place, keeping
123
+ # the name and the quotes. The Authorization header goes whole, its
124
+ # scheme with it, however the line spells it: `Authorization: Basic ...`,
125
+ # a quoted value, a hash or JSON key, Rack's `HTTP_AUTHORIZATION`.
126
+ # Unquoted, the name needs a header's space after its colon (so
127
+ # `Authorization::UnauthorizedError` is a class), or an env dump's `=` or
128
+ # a debug line's `=>`; after `name=` an `&` starts the next query parameter.
129
+ [ /(?:(?<quote>["'])(?:HTTP_(?:PROXY_)?|Proxy-|X-)?authorization\k<quote>\s*(?:=>|:|=(?!>))\s*|authorization(?::\s+|\s*=>\s*|\s*(?<query>=)(?![=~>])\s*))
130
+ (?<value>(?!["']?\[FILTERED\]|(?:true|false|nil|null)(?=[\s,;)\]}]|\z|(?(<query>)&|(?!))))(?:"(?:[^"\\]|\\.)*+"|'(?:[^'\\]|\\.)*+'
131
+ |(?:Token|Digest)\s+[\w-]+=(?:"[^"]*"|\[FILTERED\]|[^\s",]+)(?:,\s*[\w-]+=(?:"[^"]*"|\[FILTERED\]|[^\s",]+))*
132
+ |(?:(?:Bearer|Basic|Token)\s+)?(?:\[FILTERED\]|="[^"]*"|(?(<query>)[^\s"',;&)\]}]|[^\s"',;)\]}]))+))/xi, :value ],
133
+ [ /cookie:\s(?<value>#{LOG_VALUE})/i, :value ],
134
+ [ /session_id=(?<value>#{LOG_VALUE})/i, :value ],
135
+ [ /_session=(?<value>#{LOG_VALUE})/i, :value ],
121
136
  [ PEM_BEGIN, FILTERED ]
122
137
  ] + CREDENTIAL_TOKENS.map { |pattern| [ pattern, FILTERED ] }).freeze
123
138
 
@@ -126,11 +141,42 @@ module RailsAiContext
126
141
 
127
142
  # A key is a name, bare or quoted whole (`'api/verify_token'` is a route path). Password
128
143
  # words hold short literals (a PIN), so they stay out of SECRET_WORD, which logs share.
129
- PASSWORD_WORD = /passphrase|password|passwd|pass|pin/i
130
- PASSWORD_NAME = /(?<!\w)(?:\w+_)?(?:#{PASSWORD_WORD})(?:_\w+)?(?!\w)/i
131
- KEY_NAME = /#{SECRET_NAME}|#{PASSWORD_NAME}|#{KEYISH_NAME}/
144
+ # `requirepass` and `masterauth` are Redis's two password settings, and `MYSQL_PWD` and
145
+ # `REDISCLI_AUTH` the variables the mysql and redis-cli clients read theirs from.
146
+ PASSWORD_WORD = /passphrase|password|passwd|pass|pin|requirepass|masterauth|mysql_pwd|rediscli_auth/i
147
+ # A long password word that ends a name may have another joined on before it: Postgres's
148
+ # `PGPASSWORD`, `dbpassword`, a camel-cased `userPassword`. A short word may not (`bypass`,
149
+ # `spin`), nor may one with more after it (`ONEPASSWORD_VAULT` names 1Password's vault).
150
+ PASSWORD_NAME = /(?<!\w)(?>(?:\w+_)?(?:(?:#{PASSWORD_WORD})(?:_\w+)?|[a-z\d]+(?:passphrase|password|passwd))(?!\w))/i
151
+ # JSON and JavaScript write a key's words in camel case: one of them is the secret's
152
+ # (`accessToken`, `clientSecret`, `secretAccessKey`, `jwtSecretKey`), as one between
153
+ # underscores is in SECRET_NAME. `tokenizer` and `secretary` have none.
154
+ CAMEL_SECRET_NAME = /(?<!\w)(?>(?:secret|token|[a-z][a-z\d]*(?:[A-Z][a-z\d]*)*?(?:Secret|Token))(?:[A-Z][a-z\d]*)*(?!\w))/
155
+ # Each of these takes a whole word, and when two of them can, they take the same one.
156
+ KEY_NAME = /(?>#{SECRET_NAME}|#{PASSWORD_NAME}|#{KEYISH_NAME}|#{CAMEL_SECRET_NAME})/
157
+ # A header or an attribute joins its words with dashes (`"X-Api-Key"`, `"x-auth-token"`). Quoted,
158
+ # one with a part a key name reads is taken whole and read with its dashes as underscores (see
159
+ # key_name); one with none (`"Content-Type"`) is not matched, so its literal stays in reach of
160
+ # every pattern. The run is taken in one pass, never given back, so a long one costs no more.
161
+ DASHED_KEY_NAME = /(?=[\w-]*?(?:#{KEY_NAME}))[\w-]++/
162
+
163
+ # A log line spells an assignment four ways: `key=value`, `key: value`,
164
+ # `"key"=>"value"` and `"key":"value"`. One pattern over KEY_NAME covers
165
+ # every secret-ish name in all four, a camel-cased or joined one included
166
+ # (`{"accessToken"=>...}`, `PGPASSWORD: ...`); a per-name lookbehind list
167
+ # covered four names in two shapes and shipped the rest verbatim.
168
+ # Lookbehind is unavailable here because KEY_NAME is variable width,
169
+ # so the key and the assignment are captured and written back. A header
170
+ # name comes whole (`"X-Api-Key"=>`), its dashes read as underscores (see
171
+ # key_name): a run of words and dashes with a secret-ish part, taken from
172
+ # its start in one pass, as DASHED_KEY_NAME takes one in source.
173
+ LOG_SECRET_ASSIGNMENT = /
174
+ (["']?)((?<![\w-])(?=[\w-]*?(?:#{KEY_NAME}))[\w-]++)\1
175
+ (\s*(?:=>|:|=(?!>))\s*)
176
+ (?!["']?\[FILTERED\])(?:"([^"]*)"|'([^']*)'|([^\s,;&)\]}]+))
177
+ /xi
132
178
  # A quoted key may close an index (`ENV["API_TOKEN"] =`); the assignment may be `||=` or another op-assign.
133
- SECRET_KEY = /"(?<name>#{KEY_NAME})"\]?|'(?<name>#{KEY_NAME})'\]?|(?<![\w"'\/\\-])(?<name>#{KEY_NAME})/
179
+ SECRET_KEY = /"(?<name>#{DASHED_KEY_NAME})"\]?|'(?<name>#{DASHED_KEY_NAME})'\]?|(?<![\w"'\/\\-])(?<name>#{KEY_NAME})/
134
180
  KEYED_LITERAL = /(?:#{SECRET_KEY})(?<assign>\s*(?::|=>|(?:\|\||&&|[-+*\/|&])?=(?![=~>]))\s*)(?<literal>#{STRING_LITERAL})/
135
181
 
136
182
  # A call or block whose first argument names the secret holds its value: a setter or a
@@ -138,27 +184,61 @@ module RailsAiContext
138
184
  # a `default:` (`option :client_secret, :string, default: "..."`), a `credentials.fetch` default.
139
185
  # Any other call's second argument is just another argument (`include("password", "email")`).
140
186
  SECRET_ARG = /:(?<name>#{KEY_NAME})|"(?<name>#{KEY_NAME})"|'(?<name>#{KEY_NAME})'/
187
+ # A call's receiver and name are read from where their run of characters starts, and taken
188
+ # whole: tried from each character, a long word or hex blob cost the square of its length,
189
+ # a minute for 80 KB on Ruby 3.1, and on 3.2 and later passed the second Rails 8 allows a match.
190
+ CALL_RECEIVER = /(?<![\w.!?])[.!?]*+\w[\w.!?]*+/
141
191
  CALL_KEYED = /
142
192
  \b(?:fetch|store|stub_const|update_column|update_attribute|write_attribute|set|put|append|setenv)
143
193
  \(\s*(?:#{SECRET_ARG})\s*,\s*(?<literal>#{STRING_LITERAL})
144
194
  |
145
- \w[\w.!?]*\(\s*(?:#{SECRET_ARG})\s*\)\s*\{\s*(?<literal>#{STRING_LITERAL})
195
+ #{CALL_RECEIVER}\(\s*(?:#{SECRET_ARG})\s*\)\s*\{\s*(?<literal>#{STRING_LITERAL})
146
196
  |
147
- \w[\w.!?]*[(\ ]\s*:(?<name>#{KEY_NAME})(?:\s*,\s*:\w+)*\s*,\s*default:\s*(?<literal>#{STRING_LITERAL})
197
+ #{CALL_RECEIVER}[(\ ]\s*:(?<name>#{KEY_NAME})(?:\s*,\s*:\w+)*\s*,\s*default:\s*(?<literal>#{STRING_LITERAL})
148
198
  |
149
199
  (?<name>credentials)\.(?:fetch|dig)\([^()]*?,\s*(?<literal>#{STRING_LITERAL})
150
200
  /x
151
201
 
152
202
  # Shell, Dockerfile, compose, env files and log lines: `NAME=value`, no space
153
203
  # around the `=` (Ruby never writes that), the value unquoted and not `$VAR`/ERB.
154
- # The same shape sits in a URL query or a quoted env entry, so the value
155
- # stops where a quote, bracket or separator starts; a `:name=` setter
156
- # symbol and a `(?<=name=` lookbehind are not assignments.
157
- ENV_ASSIGNMENT = /(?<![\w.:=!-])(?<name>#{KEY_NAME})=(?!\#\{)(?<value>[^\s"'`$<>\\()\[\]{},;&]+)/
204
+ # The same shape sits in a URL query, a quoted env entry or a flag
205
+ # (`--password=...`, `x-api-key=...`), so the value stops where a quote,
206
+ # bracket or separator starts; a `:name=` setter symbol, a `(?<=name=`
207
+ # lookbehind and a regex's wildcard (`/--password=.+/`) are not assignments.
208
+ ENV_ASSIGNMENT = /(?<![\w.:=!])(?<name>#{KEY_NAME})=(?!\#\{|\.[*+?])(?<value>[^\s"'`$<>\\()\[\]{},;&]+)/
209
+
210
+ # A flag may take its value as the next word, bare or quoted (`redis-server --requirepass ...`,
211
+ # `docker login --password "..."`), its name read as a dashed name is (key_name). Prose names a
212
+ # flag that way too (`the --password option`), so there only a value with a digit is a secret
213
+ # (flag_secret?). A `--no-...` switch takes no value; a value that is another flag, a variable,
214
+ # ERB, a placeholder or a path (`~/`, `./`, `/`) is no literal.
215
+ FLAG_ARGUMENT = /
216
+ (?<![\w-])--(?!no-)(?<name>[A-Za-z][\w-]*+)[ \t]++
217
+ (?:(?<quote>["'])(?<value>[^\s"'`$<>\\#%{~.\/][^"'`$\\]*?)\k<quote>
218
+ |(?<value>[^\s"'`$<>\\()\[\]{},;&|#%=~.\/-][^\s"'`$<>\\()\[\]{},;&|]*+))
219
+ /x
220
+
221
+ # An HTTP auth scheme says the word after it is a credential (`Bearer <token>`, `Basic <base64>`,
222
+ # `Token <token>`), under an Authorization key, in a curl line or a cassette alike. "Authorization"
223
+ # is no secret word and the value has a space, so no key rule reads it; the credential alone goes,
224
+ # wherever it sits, when it is shaped like one (scheme_credential?). `Bearer #{token}`,
225
+ # `Basic realm=...` and `Bearer YOUR_TOKEN` hold none.
226
+ AUTH_CREDENTIALS = %r{\b(?:Bearer|Basic|Token)[ \t]++(?<credentials>[\w.~+/-]++=*+)(?![\w.~+/=-])}i
158
227
 
159
228
  # YAML writes a string unquoted, so there a secret-named key's plain
160
229
  # scalar is a literal too.
161
- YAML_SCALAR = /\A(?<head>\s*["']?(?<name>#{KEY_NAME})["']?:[ \t]+)(?<value>[^\s#][^#]*?)(?<tail>\s+#.*)?\z/
230
+ # A Ruby library's YAML may write its keys as symbols (`:password: ...` in config/sidekiq.yml).
231
+ YAML_SCALAR = /\A(?<head>\s*:?["']?(?<name>#{KEY_NAME})["']?:[ \t]+)(?<value>[^\s#][^#]*?)(?<tail>\s+#.*)?\z/
232
+
233
+ # A mapping a secret's name names holds the secret in its `value`. Heroku's app.json writes
234
+ # `"STRIPE_SECRET_KEY": { "value": "..." }` on one line or across several, a YAML file
235
+ # `STRIPE_SECRET_KEY:` over `value: ...`, and render.yaml and a Kubernetes manifest name a
236
+ # list entry by a sibling (`- key: CLIENT_SECRET`, `- name: API_TOKEN`) over its `value:`.
237
+ NAMED_OBJECT = /"(?<name>#{DASHED_KEY_NAME})"[ \t]*:[ \t]*\{[^{}]*\}/
238
+ NAMED_VALUE_PAIR = /(?<head>"value"[ \t]*:[ \t]*")(?<value>[^"]*)"/
239
+ NAMING_LINE = /\A(?<indent>[ \t]*)(?:-[ \t]+(?:key|name)[ \t]*:[ \t]+["']?(?<name>#{KEY_NAME})["']?|["']?(?<name>#{KEY_NAME})["']?[ \t]*:[ \t]*\{?)[ \t]*\z/
240
+ VALUE_LINE = /\A(?<head>[ \t]*["']?value["']?[ \t]*:[ \t]*)(?:(?<quote>["'])(?<value>[^"']*)\k<quote>|(?<value>[^\s"'#,{\[][^#,]*?))(?<tail>[ \t]*,?[ \t]*(?:#.*)?)\z/
241
+ STRUCTURED_FILE = /#{YAML_FILE}|\.json(?:\.(?:#{PLACEHOLDER_NAMES}))*\z/
162
242
 
163
243
  class << self
164
244
  # Source slices and config values.
@@ -196,16 +276,35 @@ module RailsAiContext
196
276
 
197
277
  "#{match[:name]}=#{FILTERED}"
198
278
  end
279
+ result = result.gsub(FLAG_ARGUMENT) { |m| filter_flag(m, Regexp.last_match, example) }
280
+ result = result.gsub(NAMED_OBJECT) { |m| filter_named_object(m, Regexp.last_match, example) }
199
281
  end
200
282
  result = result.gsub(STRING_LITERAL) { |lit| credential?(lit[1..-2]) ? "#{lit[0]}#{FILTERED}#{lit[0]}" : lit }
201
283
  result = CREDENTIAL_TOKENS.reduce(result) { |text, pattern| text.gsub(pattern, FILTERED) }
284
+ result = scheme_credentials(result)
285
+ # A log shown as a file (a tracked log's diff) gets what rails_read_logs filters too.
286
+ return redact_log_line(result) if path.to_s.match?(LOG_FILE)
287
+
202
288
  path.to_s.match?(YAML_FILE) && !locale ? yaml_scalar(result, example) : result
203
289
  end
204
290
 
205
- # Consecutive lines, so a PEM key across them is filtered whole; the line
206
- # count is kept, since callers number the lines.
291
+ # Consecutive lines, so a PEM key across them is filtered whole, and in a
292
+ # YAML or JSON file a `value` under a mapping a secret's name names; the
293
+ # line count is kept, since callers number the lines.
207
294
  def redact_source_lines(lines, path: nil)
208
- outside_key_bodies(lines) { |line| redact_source_line(line, path: path) }
295
+ redacted = outside_key_bodies(lines) { |line| redact_source_line(line, path: path) }
296
+ return redacted unless path.to_s.match?(STRUCTURED_FILE) && !path.to_s.match?(%r{(?:\A|/)locales?/})
297
+
298
+ named_values(redacted, path.to_s.match?(EXAMPLE_FILE))
299
+ end
300
+
301
+ # A slice of the app's source - a method body, a template - as a tool
302
+ # prints it: the same lines, each secret filtered.
303
+ def redact_source(text, path: nil)
304
+ return text unless text.is_a?(String)
305
+
306
+ trailing = text.end_with?("\n") ? "\n" : ""
307
+ redact_source_lines(text.lines.map(&:chomp), path: path).join("\n") + trailing
209
308
  end
210
309
 
211
310
  # A config value arrives without its context, so the setting's name is
@@ -241,7 +340,7 @@ module RailsAiContext
241
340
  return true if path.match?(SECRET_PATH)
242
341
 
243
342
  leaf = path.split(".").last.to_s
244
- leaf.match?(/\A#{SECRET_NAME}\z/) && !leaf.match?(DESCRIPTOR_SUFFIX)
343
+ leaf.match?(WHOLE_SECRET_NAME) && !leaf.match?(DESCRIPTOR_SUFFIX)
245
344
  end
246
345
 
247
346
  # Redaction and shortening are one operation because their order is the
@@ -267,21 +366,24 @@ module RailsAiContext
267
366
  name = Regexp.last_match(:name)
268
367
  log_secret?(name, Regexp.last_match(:value)) ? "#{name}=#{FILTERED}" : m
269
368
  end
270
- result.gsub!(EMAIL_PATTERN, EMAIL)
369
+ result.gsub!(FLAG_ARGUMENT) { |m| filter_flag(m, Regexp.last_match, false) }
370
+ result.gsub!(result.encoding == Encoding::UTF_8 ? EMAIL_PATTERN : ASCII_EMAIL_PATTERN, EMAIL)
271
371
 
272
372
  result.gsub!(LOG_SECRET_ASSIGNMENT) { filter_assignment(Regexp.last_match) }
273
373
 
274
374
  LOG_PATTERNS.each do |pattern, replacement|
275
375
  next result.gsub!(pattern, replacement) unless replacement == :value
276
376
 
377
+ # A value pattern ends with its value. A group's offset is counted
378
+ # from the line's start, so reading one per match made a line of
379
+ # many matches cost the square of its length.
277
380
  result.gsub!(pattern) do
278
381
  match = Regexp.last_match
279
- from = match.begin(:value) - match.begin(0)
280
- match[0][0, from] + filtered_like(match[:value]) + match[0][(from + match[:value].length)..]
382
+ match[0].delete_suffix(match[:value]) + filtered_like(match[:value])
281
383
  end
282
384
  end
283
385
 
284
- result
386
+ scheme_credentials(result)
285
387
  end
286
388
 
287
389
  # The one place a name and value pair leaves the process. Normally the
@@ -298,7 +400,7 @@ module RailsAiContext
298
400
  return FILTERED if credential?(stripped)
299
401
 
300
402
  name = name.to_s
301
- return FILTERED if name.match?(/\A(?:#{KEY_NAME})\z/) && keyed_secret?(name, stripped, placeholder_ok)
403
+ return FILTERED if name.match?(WHOLE_KEY_NAME) && keyed_secret?(name, stripped, placeholder_ok)
302
404
 
303
405
  stripped.gsub(URI_USERINFO) { |m| placeholder?(m) ? m : "#{Regexp.last_match(1)}#{FILTERED}@" }
304
406
  end
@@ -319,12 +421,103 @@ module RailsAiContext
319
421
 
320
422
  # A matched name and literal, with the literal filtered when it holds a secret.
321
423
  def filter_literal(text, match, example)
322
- return text unless keyed_secret?(match[:name], match[:literal][1..-2], example)
424
+ name = key_name(match[:name])
425
+ return text unless name && keyed_secret?(name, match[:literal][1..-2], example)
323
426
 
324
427
  quote = match[:literal][0]
325
428
  text.delete_suffix(match[:literal]) + "#{quote}#{FILTERED}#{quote}"
326
429
  end
327
430
 
431
+ # Compiled once. A pattern built inside a method is compiled again on every call, 23
432
+ # microseconds each, which was most of what a line of many keys or flags cost to filter.
433
+ WHOLE_KEY_NAME = /\A(?:#{KEY_NAME})\z/
434
+ WHOLE_SECRET_NAME = /\A#{SECRET_NAME}\z/
435
+ WHOLE_PASSWORD_NAME = /\A#{PASSWORD_NAME}\z/
436
+ WHOLE_KEYISH_NAME = /\A(?:#{KEYISH_NAME})\z/
437
+ # A name with a secret word in it, in any spelling: a `_KEY` name with one is no mere key.
438
+ WHOLE_SECRET_OR_PASSWORD_NAME = /\A(?:#{SECRET_NAME}|#{PASSWORD_NAME}|#{CAMEL_SECRET_NAME})\z/
439
+ WHOLE_SECRET_WORD = /\A(?:#{SECRET_WORD})\z/
440
+
441
+ # The name a matched key reads as. A dashed one reads with underscores, so `X-Api-Key` is
442
+ # `X_Api_Key`, an API key's name; one that then spells no key name (`X-Key-Id`) is none.
443
+ def key_name(name)
444
+ spelled = name.tr("-", "_")
445
+ spelled if spelled.match?(WHOLE_KEY_NAME)
446
+ end
447
+
448
+ # A one-line object a secret's name names, with its `"value"` filtered when it is that secret.
449
+ def filter_named_object(text, match, example)
450
+ name = key_name(match[:name])
451
+ return text unless name
452
+
453
+ text.sub(NAMED_VALUE_PAIR) do |pair|
454
+ value = Regexp.last_match(:value)
455
+ keyed_secret?(name, value, example) ? %(#{Regexp.last_match(:head)}#{FILTERED}") : pair
456
+ end
457
+ end
458
+
459
+ # The `value` lines of each mapping a secret's name names (NAMING_LINE), up to the line
460
+ # that leaves it: one no deeper than the name. A boolean or null is no secret.
461
+ def named_values(lines, example)
462
+ named = nil
463
+ lines.map do |line|
464
+ if (naming = line.match(NAMING_LINE))
465
+ named = { name: naming[:name], indent: naming[:indent].size }
466
+ next line
467
+ end
468
+ next line if line.strip.empty?
469
+
470
+ named = nil if named && line[/\A[ \t]*/].size <= named[:indent]
471
+ value = named && line.match(VALUE_LINE)
472
+ next line unless value
473
+
474
+ scalar = value[:value].to_s
475
+ next line if scalar.empty? || scalar.start_with?(FILTERED) || scalar.match?(/\A(?:true|false|yes|no|null|~)\z/i)
476
+ next line unless keyed_secret?(named[:name], scalar, example)
477
+
478
+ "#{value[:head]}#{value[:quote]}#{FILTERED}#{value[:quote]}#{value[:tail]}"
479
+ end
480
+ end
481
+
482
+ # A matched flag and the word after it, with the word filtered when it is the flag's secret.
483
+ def filter_flag(text, match, example)
484
+ name = key_name(match[:name])
485
+ value = match[:value]
486
+ return text unless name && flag_secret?(name, value, example)
487
+
488
+ quote = match[:quote].to_s
489
+ text.delete_suffix("#{quote}#{value}#{quote}") + "#{quote}#{FILTERED}#{quote}"
490
+ end
491
+
492
+ # A word after a flag is as often prose as the flag's value, so it needs a digit as well as
493
+ # what keyed_secret? asks of a value: a word is no secret, and a generated one has digits.
494
+ # A `name=value` list (BuildKit's `--secret id=...,env=...`) and a relative path
495
+ # (`keys/deploy.pem`) name where a secret is, not what it is.
496
+ def flag_secret?(name, value, example)
497
+ return false if value.match?(/\A[A-Za-z][\w-]*=[^=]/) || object_path?(value)
498
+
499
+ value.match?(/\d/) && keyed_secret?(name, value, example)
500
+ end
501
+
502
+ # The credential after an auth scheme goes; the scheme stays, so the line still reads.
503
+ def scheme_credentials(text)
504
+ text.gsub(AUTH_CREDENTIALS) do |m|
505
+ credentials = Regexp.last_match(:credentials)
506
+ scheme_credential?(credentials) ? m.delete_suffix(credentials) + FILTERED : m
507
+ end
508
+ end
509
+
510
+ # After a scheme, key_credential?'s 16+ letters and digits, both, read past the separators a
511
+ # credential is written with: a UUID's dashes, base64's `+`, `/` and `=`. A word or a
512
+ # placeholder has no digits to count.
513
+ def scheme_credential?(value)
514
+ return true if CREDENTIAL_TOKENS.any? { |pattern| value.match?(pattern) }
515
+ return false if placeholder?(value)
516
+
517
+ alnum = value.delete("^A-Za-z0-9")
518
+ alnum.length >= 16 && alnum.match?(/[A-Za-z]/) && alnum.match?(/\d/)
519
+ end
520
+
328
521
  # The one verdict on a name and value in a log line, whichever pattern found the pair.
329
522
  def log_secret?(name, value)
330
523
  !descriptor_name?(name) && !(keyish_only?(name) && !key_credential?(value))
@@ -371,18 +564,26 @@ module RailsAiContext
371
564
  end
372
565
 
373
566
  # A secret-named key whose value describes the secret: `TOKEN_USE`,
374
- # `DEAD_TOKEN_ERROR`, `token_url`, besides the config descriptors.
375
- SOURCE_DESCRIPTOR = /_(?:use|scope|kind|label|prefix|suffix|errors?|message|url|uri|endpoint|path|header)\z/i
376
-
567
+ # `DEAD_TOKEN_ERROR`, `token_url`, besides the config descriptors. A
568
+ # Stimulus target, action, controller, outlet or class names an element
569
+ # or a method (`"password-visibility-target": "input"`); a value may be a token.
570
+ # A name for a file, a directory or an identifier says where a secret is or which one
571
+ # (`token_file`, `secret_id`, AWS's `--secret-id`), not what it is.
572
+ SOURCE_DESCRIPTOR = /_(?:use|scope|kind|label|prefix|suffix|errors?|message|url|uri|endpoint|path|header|
573
+ target|action|controller|outlet|class(?:es)?|files?|filename|dir|directory|ids?)\z/xi
574
+
575
+ # A camel-cased name is read with its words joined by underscores (`tokenType` is `token_type`).
377
576
  def descriptor_name?(name)
378
- name.match?(DESCRIPTOR_SUFFIX) || name.match?(SOURCE_DESCRIPTOR)
577
+ snake = name.gsub(/([a-z\d])([A-Z])/, '\1_\2')
578
+ snake.match?(DESCRIPTOR_SUFFIX) || snake.match?(SOURCE_DESCRIPTOR)
379
579
  end
380
580
 
381
581
  # Under a secret-named key a value is a secret unless it is clearly not
382
582
  # one (docs/TOOLS.md lists the shapes): a leak costs more than a hidden line.
383
583
  NOT_A_SECRET = [
384
- # A path has two slashes and no base64 padding or `+`.
385
- %r{\A(?:[a-z][a-z0-9+.-]*://|/[^+=]*/[^+=]*\z)}i,
584
+ # A path has two slashes and no base64 padding or `+`. Its first segment is taken whole, so
585
+ # a slash-filled value ending in `=` is one pass, not a retry from every slash.
586
+ %r{\A(?:[a-z][a-z0-9+.-]*://|/[^+=/]*+/[^+=]*+\z)}i,
386
587
  /\A[?&][\w-]+=/,
387
588
  /\A(?:[vV]\d+(?:[.-]\d+)*|\d+(?:\.\d+){1,3})\z/,
388
589
  /\A\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?)?\z/,
@@ -395,10 +596,15 @@ module RailsAiContext
395
596
  # `HTTP_` or naming a secret word (`_DISCOURSE_API`, `HTTP_API_KEY`).
396
597
  ENV_NAME = /\A_?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\z/
397
598
 
599
+ # A switch's value (`showPassword=false`), not a credential, under any name.
600
+ NOT_A_SECRET_WORD = /\A(?:true|false|yes|no|on|off|null|nil|none|undefined)\z/i
601
+
398
602
  def secret_literal?(value, key)
399
- password = key.match?(/\A#{PASSWORD_NAME}\z/)
603
+ password = key.match?(WHOLE_PASSWORD_NAME)
400
604
  return false if value.length < (password ? 4 : 8) || !value.ascii_only? || placeholder?(value)
401
- return false if value.downcase.tr("-", "_") == key.downcase.tr("-", "_")
605
+ return false if value.match?(NOT_A_SECRET_WORD)
606
+ # The key's own name, in any case (`currentPassword: "current_password"`, an enum's `password: 'password'`).
607
+ return false if snake_name(value) == snake_name(key)
402
608
  # Whitespace makes a message, except in a passphrase.
403
609
  return false if value.match?(/\s/) && !key.match?(/passphrase/i)
404
610
  # Under a password key a word-joined value is the password itself.
@@ -409,6 +615,10 @@ module RailsAiContext
409
615
  (password ? NOT_A_SECRET - [ HEADER_NAME, ADDRESS, HOSTNAME ] : NOT_A_SECRET).none? { |shape| value.match?(shape) }
410
616
  end
411
617
 
618
+ def snake_name(name)
619
+ name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.tr("-", "_")
620
+ end
621
+
412
622
  # Letter-only words, one a secret word, spell a name (`user_api_key`).
413
623
  # ponytail: `super-secret-password` reads as a name; score entropy per word if that leaks.
414
624
  def names_a_secret?(value)
@@ -420,7 +630,7 @@ module RailsAiContext
420
630
  def secret_words?(value)
421
631
  words = value.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2').gsub(/([a-z])([A-Z])/, '\\1_\\2')
422
632
  .downcase.split(/[_.-]+/).reject(&:empty?)
423
- (words + words.each_cons(2).map { |pair| pair.join("_") }).any? { |word| word.match?(/\A(?:#{SECRET_WORD})\z/) }
633
+ (words + words.each_cons(2).map { |pair| pair.join("_") }).any? { |word| word.match?(WHOLE_SECRET_WORD) }
424
634
  end
425
635
 
426
636
  def yaml_scalar(line, example)
@@ -441,7 +651,7 @@ module RailsAiContext
441
651
 
442
652
  # Named only by `_KEY`, and not by a secret word.
443
653
  def keyish_only?(name)
444
- name.match?(/\A(?:#{KEYISH_NAME})\z/) && !name.match?(/\A(?:#{SECRET_NAME}|#{PASSWORD_NAME})\z/)
654
+ name.match?(WHOLE_KEYISH_NAME) && !name.match?(WHOLE_SECRET_OR_PASSWORD_NAME)
445
655
  end
446
656
 
447
657
  # A 16+ character run mixing letters and digits, or a known token format; a cache or i18n
@@ -523,7 +733,8 @@ module RailsAiContext
523
733
  # `password_length: 8` is policy, not a credential, so it survives.
524
734
  def filter_assignment(match)
525
735
  quote, key, assign = match[1], match[2], match[3]
526
- return match[0] unless log_secret?(key, match[4] || match[5] || match[6])
736
+ name = key_name(key)
737
+ return match[0] unless name && log_secret?(name, match[4] || match[5] || match[6])
527
738
 
528
739
  value_quote = match[4] ? '"' : (match[5] ? "'" : "")
529
740
  "#{quote}#{key}#{quote}#{assign}#{value_quote}#{FILTERED}#{value_quote}"
@@ -84,11 +84,13 @@ module RailsAiContext
84
84
  mime_type: "application/json"
85
85
  ).freeze
86
86
 
87
+ # No mime_type: the spec has a template carry one only when every
88
+ # resource it matches shares it, and a views directory holds ERB, HAML,
89
+ # jbuilder and more. Each read names its own.
87
90
  VIEW_TEMPLATE = MCP::ResourceTemplate.new(
88
91
  uri_template: "rails-ai-context://views/{path}",
89
92
  name: "View Template",
90
- description: "View template content for a specific path relative to app/views",
91
- mime_type: "text/html"
93
+ description: "View template source for a specific path relative to app/views"
92
94
  ).freeze
93
95
 
94
96
  ROUTES_TEMPLATE = MCP::ResourceTemplate.new(
@@ -98,6 +100,8 @@ module RailsAiContext
98
100
  mime_type: "application/json"
99
101
  ).freeze
100
102
 
103
+ INVALID_PARAMS = -32602
104
+
101
105
  class << self
102
106
  def static_resources
103
107
  STATIC_RESOURCES.map do |uri, meta|
@@ -114,6 +118,11 @@ module RailsAiContext
114
118
  [ MODEL_TEMPLATE, CONTROLLER_TEMPLATE, CONTROLLER_ACTION_TEMPLATE, VIEW_TEMPLATE, ROUTES_TEMPLATE ]
115
119
  end
116
120
 
121
+ # What a read can name: each static resource, then each template.
122
+ def served_uris
123
+ STATIC_RESOURCES.keys + resource_templates.map { |template| template.to_h[:uriTemplate] }
124
+ end
125
+
117
126
  def register(server)
118
127
  require "json"
119
128
 
@@ -122,30 +131,58 @@ module RailsAiContext
122
131
  server.resources_read_handler do |params|
123
132
  handle_read(params)
124
133
  rescue RailsAiContext::Error => e
125
- # handle_read / VFS raise RailsAiContext::Error for unknown URIs and
126
- # blocked paths (traversal, sensitive files). Left unhandled, the MCP
127
- # SDK collapses them into a generic "-32603 Internal error" that hides
128
- # the URI. On mcp >= 0.20 re-raise as the SDK's ResourceNotFoundError
129
- # so the client gets a proper "-32602 Resource not found: <uri>" with
130
- # the URI in error data (the uniform message also avoids leaking why a
131
- # blocked path was rejected). That class doesn't exist on older but
132
- # still-supported mcp (gemspec allows >= 0.13), so fall back to the
133
- # original error there - same behavior as before this wrapper.
134
- raise e unless defined?(MCP::Server::ResourceNotFoundError)
135
-
136
- raise MCP::Server::ResourceNotFoundError.new(params[:uri])
134
+ raise read_error(params, e)
137
135
  end
138
136
  end
139
137
 
140
138
  private
141
139
 
140
+ # handle_read / VFS raise RailsAiContext::Error for unknown URIs and
141
+ # blocked paths (traversal, sensitive files). Left unhandled, the MCP
142
+ # SDK collapses them into a generic "-32603 Internal error" that hides
143
+ # the URI. On mcp >= 0.20 they become the SDK's ResourceNotFoundError,
144
+ # so the client gets a proper "-32602 Resource not found: <uri>" with
145
+ # the URI in error data (the uniform message also avoids leaking why a
146
+ # blocked path was rejected). That class doesn't exist on older but
147
+ # still-supported mcp (gemspec allows >= 0.13), so the original error
148
+ # goes through there - same behavior as before this wrapper.
149
+ #
150
+ # A name the app does not have, or a file over the cap, is no secret: it
151
+ # fails with its own reason and the names the client can use instead,
152
+ # where it used to succeed with an `{"error": ...}` body a client could
153
+ # not tell from data.
154
+ # The URI is echoed shortened, as a tool echoes an argument: a 100 KB
155
+ # one came back whole, in the message and again in the data.
156
+ def read_error(params, error)
157
+ uri = Tools::BaseTool.echo_input(params[:uri])
158
+ if error.is_a?(RailsAiContext::ResourceUnavailable) && handler_error_data?
159
+ return MCP::Server::RequestHandlerError.new(
160
+ error.message, params,
161
+ error_type: :invalid_params, error_code: INVALID_PARAMS, error_data: { uri: uri }.merge(error.data)
162
+ )
163
+ end
164
+ return error unless defined?(MCP::Server::ResourceNotFoundError)
165
+
166
+ MCP::Server::ResourceNotFoundError.new(uri)
167
+ end
168
+
169
+ # error_code and error_data arrived together with ResourceNotFoundError,
170
+ # which is built on them.
171
+ def handler_error_data?
172
+ MCP::Server::RequestHandlerError.instance_method(:initialize).parameters.any? { |_, name| name == :error_data }
173
+ end
174
+
142
175
  # Content hashes use MCP-spec camelCase keys (mimeType): the SDK places
143
176
  # the handler's return value into the JSON-RPC response without renaming
144
177
  # keys, so snake_case would leak to clients as-is.
145
178
  def handle_read(params)
179
+ # A read introspects afresh, but the booted tier reads reflection,
180
+ # which only a code reload brings up to date after an edit.
181
+ Tools::BaseTool.refresh_if_files_changed!
146
182
  # Resource reads introspect just like tool calls do, and they bypassed
147
- # SafeCall entirely - so a live reload could unload constants while one
148
- # was reading them and hand back a short list with nothing raised.
183
+ # SafeCall entirely - so a reload on another thread could unload
184
+ # constants while one was reading them and hand back a short list
185
+ # with nothing raised.
149
186
  RailsAiContext::CodeReloader.with_app_code { read_resource(params) }
150
187
  end
151
188
 
@@ -169,11 +206,9 @@ module RailsAiContext
169
206
  context = RailsAiContext.introspect
170
207
 
171
208
  if STATIC_RESOURCES.key?(uri)
172
- key = STATIC_RESOURCES[uri][:key]
173
- content = JsonBudget.for_resource(context[key] || {})
174
- [ { uri: uri, mimeType: "application/json", text: content } ]
209
+ [ VFS.json_content(uri, context[STATIC_RESOURCES[uri][:key]] || {}) ]
175
210
  else
176
- raise RailsAiContext::Error, "Unknown resource: #{uri}"
211
+ raise ResourceUnavailable.new("Resource not found: #{Tools::BaseTool.echo_input(uri)}", available: served_uris)
177
212
  end
178
213
  end
179
214
  end
@@ -39,6 +39,14 @@ module RailsAiContext
39
39
  CountPhrase.call(app_controllers(routes).size, "routed controller")
40
40
  end
41
41
 
42
+ # The count as every generated file states it, so CLAUDE.md and the rule
43
+ # files beside it cannot give one app two numbers: "12 app routes
44
+ # across 3 routed controllers (39 total incl. framework)".
45
+ def summary(routes)
46
+ "#{CountPhrase.call(app_route_count(routes), 'app route')} across #{controller_phrase(routes)} " \
47
+ "(#{routes[:total_routes]} total incl. framework#{suffix(routes)})"
48
+ end
49
+
42
50
  def framework_route_count(routes)
43
51
  by_controller(routes)
44
52
  .select { |name, _| framework_controller?(name) }
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "securerandom"
4
+
5
+ # securerandom is a gem an app's bundle pins, and this file loads before the
6
+ # app boots: required here, our copy loaded first and the app's then loaded
7
+ # over it ("already initialized constant"). It loads when first named.
8
+ autoload :SecureRandom, "securerandom"
5
9
 
6
10
  module RailsAiContext
7
11
  # Safe file reading with size limits and error handling.
@@ -23,6 +27,15 @@ module RailsAiContext
23
27
  raise
24
28
  end
25
29
 
30
+ # A file the gem rewrites in part, as UTF-8 whatever the locale says. One
31
+ # that is not valid UTF-8 stays as bytes: what is looked for in it is
32
+ # ASCII, and what is not replaced is written back as it came.
33
+ def self.read_text(path)
34
+ content = File.binread(path)
35
+ utf8 = content.dup.force_encoding(Encoding::UTF_8)
36
+ utf8.valid_encoding? ? utf8 : content
37
+ end
38
+
26
39
  def self.read(path, max_size: nil)
27
40
  return nil unless path
28
41