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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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>#{
|
|
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
|
-
\
|
|
195
|
+
#{CALL_RECEIVER}\(\s*(?:#{SECRET_ARG})\s*\)\s*\{\s*(?<literal>#{STRING_LITERAL})
|
|
146
196
|
|
|
|
147
|
-
|
|
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
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
#
|
|
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?(
|
|
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!(
|
|
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
|
-
|
|
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?(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?(
|
|
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.
|
|
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?(
|
|
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?(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
148
|
-
# was reading them and hand back a short list
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|