parse-stack-next 4.5.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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,80 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # Cooperative cancellation token used by Parse::Agent::MCPDispatcher
7
+ # and Parse::Agent::MCPRackApp to signal in-flight tool calls that the
8
+ # client wants to stop work.
9
+ #
10
+ # The token is cooperative — tools must poll `cancelled?` at safe
11
+ # checkpoints (tool entry, after each Parse/Mongo roundtrip,
12
+ # between chunks). A tool that is blocked inside a synchronous I/O
13
+ # call will not observe the cancellation until the I/O returns.
14
+ # The Ruby-level `Timeout.timeout` already wrapping every tool call
15
+ # remains the hard upper bound on wasted work.
16
+ #
17
+ # Cancellation is triggered from two paths:
18
+ #
19
+ # 1. **SSE client disconnect.** `MCPRackApp::SSEBody#close` invokes
20
+ # `cancel!(reason: :client_disconnect)` on the token before
21
+ # killing the worker thread.
22
+ # 2. **`notifications/cancelled` JSON-RPC notification.** A separate
23
+ # POST whose `params.requestId` matches an in-flight request
24
+ # trips the token associated with that request (after a session
25
+ # identity check — see MCPRackApp for details).
26
+ #
27
+ # @example Polling at a checkpoint
28
+ # def my_tool(agent, **)
29
+ # return cancelled_result if agent.cancelled?
30
+ # data = expensive_io_call
31
+ # return cancelled_result if agent.cancelled?
32
+ # transform_and_return(data)
33
+ # end
34
+ #
35
+ # @example Operator-facing cancel
36
+ # token = Parse::Agent::CancellationToken.new
37
+ # agent.cancellation_token = token
38
+ # # later, from another thread:
39
+ # token.cancel!(reason: :user_requested)
40
+ class CancellationToken
41
+ # @return [Symbol, String, nil] reason supplied to {#cancel!}, or nil
42
+ # if the token has not been cancelled.
43
+ attr_reader :reason
44
+
45
+ def initialize
46
+ @cancelled = false
47
+ @reason = nil
48
+ # Mutex protects the read-modify-write in {#cancel!} so a
49
+ # concurrent cancel from notifications/cancelled and client
50
+ # disconnect cannot lose a reason or partially update state.
51
+ # The hot poll path (#cancelled?) reads the boolean ivar
52
+ # directly — atomic on MRI and on each major Ruby
53
+ # implementation we ship against.
54
+ @mutex = Mutex.new
55
+ end
56
+
57
+ # @return [Boolean] true once {#cancel!} has been called at least once.
58
+ def cancelled?
59
+ @cancelled
60
+ end
61
+
62
+ # Trip the token. Idempotent — subsequent calls are no-ops and do
63
+ # not overwrite the original reason.
64
+ #
65
+ # @param reason [Symbol, String, nil] short tag identifying the
66
+ # trigger (e.g. `:client_disconnect`, `:notifications_cancelled`,
67
+ # `:user_requested`).
68
+ # @return [Boolean] true if this call actually flipped the state,
69
+ # false if the token was already cancelled.
70
+ def cancel!(reason: nil)
71
+ @mutex.synchronize do
72
+ return false if @cancelled
73
+ @cancelled = true
74
+ @reason = reason
75
+ true
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,480 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # The ConstraintTranslator converts JSON-style query constraints
7
+ # (like those from LLM function calls) into Parse REST API format.
8
+ #
9
+ # It enforces strict security validation:
10
+ # - Blocks dangerous operators that allow code execution ($where, $function, etc.)
11
+ # - Rejects unknown operators (whitelist-based approach)
12
+ # - Limits query depth to prevent DoS attacks
13
+ #
14
+ # @example Basic translation
15
+ # ConstraintTranslator.translate({
16
+ # "plays" => { "$gte" => 1000 },
17
+ # "artist" => "Beatles"
18
+ # })
19
+ # # => {"plays" => {"$gte" => 1000}, "artist" => "Beatles"}
20
+ #
21
+ # @example Blocked operator raises SecurityError
22
+ # ConstraintTranslator.translate({ "$where" => "this.a > 1" })
23
+ # # => raises ConstraintSecurityError
24
+ #
25
+ module ConstraintTranslator
26
+ extend self
27
+
28
+ # Security error for blocked operators that allow code execution
29
+ class ConstraintSecurityError < SecurityError
30
+ attr_reader :operator, :reason
31
+
32
+ def initialize(message, operator: nil, reason: nil)
33
+ @operator = operator
34
+ @reason = reason
35
+ super(message)
36
+ end
37
+ end
38
+
39
+ # Validation error for unknown/invalid operators
40
+ class InvalidOperatorError < StandardError
41
+ attr_reader :operator
42
+
43
+ def initialize(message, operator: nil)
44
+ @operator = operator
45
+ super(message)
46
+ end
47
+ end
48
+
49
+ # Operators that are BLOCKED - they allow arbitrary code execution
50
+ # These are blocked regardless of permission level
51
+ BLOCKED_OPERATORS = %w[
52
+ $where
53
+ $function
54
+ $accumulator
55
+ $expr
56
+ ].freeze
57
+
58
+ # Whitelist of allowed Parse query operators
59
+ ALLOWED_OPERATORS = %w[
60
+ $lt $lte $gt $gte $ne $eq
61
+ $in $nin $all $exists
62
+ $regex $options
63
+ $text $search
64
+ $near $nearSphere $geoWithin $geoIntersects
65
+ $centerSphere $box $polygon $geometry
66
+ $maxDistance $maxDistanceInMiles
67
+ $maxDistanceInKilometers $maxDistanceInRadians
68
+ $relatedTo $inQuery $notInQuery
69
+ $containedIn $containsAll
70
+ $select $dontSelect
71
+ $or $and $nor
72
+ ].freeze
73
+
74
+ # Operators whose value carries an inner sub-query of the shape
75
+ # +{className:, where:, key:}+. Each must be validated through
76
+ # {Tools.assert_class_accessible!} so the LLM cannot reach into a
77
+ # hidden class via the sub-query, and the inner +where+ must be
78
+ # recursively re-translated so blocked operators inside it are
79
+ # also caught.
80
+ CROSS_CLASS_OPERATORS = %w[
81
+ $inQuery $notInQuery $select $dontSelect
82
+ ].freeze
83
+
84
+ # Field-name keys (non-operator) that are never permitted in a
85
+ # caller-supplied where: constraint, regardless of class or permission
86
+ # level. These are internal Parse Server columns whose presence in a
87
+ # $match filter creates a 1-bit-per-query oracle that can exfiltrate
88
+ # bcrypt hashes, session tokens, or reset tokens character-by-character
89
+ # via count deltas. The list covers:
90
+ #
91
+ # - Exact names (lowercased storage form and camelCase API form)
92
+ # - A prefix that catches per-provider columns stored as
93
+ # `_auth_data_facebook`, `_auth_data_google`, etc.
94
+ #
95
+ # Mirrored in Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST so
96
+ # the aggregate pipeline path is covered independently (the two
97
+ # modules can be loaded in any order; duplication is intentional).
98
+ DENIED_WHERE_KEYS = %w[
99
+ _hashed_password _password_history
100
+ _session_token _sessionToken
101
+ _email_verify_token _perishable_token
102
+ _failed_login_count _account_lockout_expires_at
103
+ _rperm _wperm
104
+ _auth_data
105
+ ].freeze
106
+
107
+ # Prefix-based check (catches _auth_data_facebook, _auth_data_google, …).
108
+ DENIED_WHERE_KEY_PREFIXES = %w[_auth_data_].freeze
109
+
110
+ # Maximum query depth to prevent DoS via deeply nested structures
111
+ MAX_QUERY_DEPTH = 8
112
+
113
+ # NEW-TOOLS-7: cap $regex pattern length. Patterns larger than this
114
+ # are rejected before reaching MongoDB. 256 is generous for the
115
+ # legitimate analyst-facing patterns the agent surface is designed
116
+ # for (prefix anchors, simple character classes) while keeping the
117
+ # worst-case backtracking cost on any one pattern bounded.
118
+ MAX_REGEX_PATTERN_LENGTH = 256
119
+
120
+ # Allowed $options flag characters. MongoDB accepts i (case
121
+ # insensitive), m (multi-line), x (extended/whitespace-ignored),
122
+ # s (dot-all). The dot-all `s` flag is intentionally omitted: it
123
+ # makes `.` cross newlines, which extends the search frontier on
124
+ # multi-line text fields and amplifies catastrophic-backtracking
125
+ # cost for the worst patterns. `imx` covers every real use case
126
+ # the agent surface needs.
127
+ ALLOWED_REGEX_OPTIONS = "imx"
128
+
129
+ # Heuristic for nested-quantifier ReDoS patterns (catastrophic
130
+ # backtracking). Matches a quantifier (`+` or `*`) INSIDE a
131
+ # parenthesized group that is itself followed by a quantifier
132
+ # (`+`, `*`, or `?`) — the structural shape that drives
133
+ # exponential time on adversarial inputs (`(a+)+`, `(a*)*`,
134
+ # `(x|y)+?` are all reachable). Stricter than the audit's
135
+ # suggested heuristic, which would false-positive on innocuous
136
+ # patterns like `^foo.*bar.*$`. Anchored prefixes without
137
+ # nested-quantifier-groups (`^bar(a+)+` is still refused; plain
138
+ # `^foo.*` is not).
139
+ REDOS_NESTED_QUANTIFIER_RE = /\([^)]*[+*][^)]*\)[+*?]/.freeze
140
+
141
+ # Translate JSON constraints to Parse query format.
142
+ # Validates all operators against the security whitelist.
143
+ #
144
+ # @param constraints [Hash] the query constraints from LLM
145
+ # @raise [ConstraintSecurityError] if blocked operators are used
146
+ # @raise [InvalidOperatorError] if unknown operators are used
147
+ # @return [Hash] translated constraints for Parse REST API
148
+ # @param constraints [Hash] the query constraints from LLM
149
+ # @param agent [Parse::Agent, nil] optional agent context for per-agent
150
+ # class-filter enforcement on embedded cross-class operators
151
+ # (`$inQuery` / `$select`). Passed positionally (not keyword) so a
152
+ # bracket-less Hash literal at the call site — `translate("key" => val)`
153
+ # — continues to parse as a single positional Hash under Ruby 3+
154
+ # kwargs separation. Adding a kwarg would have turned the same call
155
+ # into "empty kwargs + missing positional arg."
156
+ def translate(constraints, agent = nil)
157
+ return {} if constraints.nil? || constraints.empty?
158
+
159
+ raise InvalidOperatorError.new(
160
+ "Constraints must be a Hash, got #{constraints.class}",
161
+ operator: nil,
162
+ ) unless constraints.is_a?(Hash)
163
+
164
+ constraints.transform_keys(&:to_s).each_with_object({}) do |(key, value), result|
165
+ # Check for blocked operators at the root level
166
+ if key.start_with?("$")
167
+ validate_operator!(key)
168
+ end
169
+ # H1 / M1: reject keys that reference internal Parse Server columns.
170
+ # These enable bcrypt-hash and session-token oracle attacks via
171
+ # count deltas even when operators are otherwise clean.
172
+ assert_where_key_permitted!(key)
173
+ result[columnize(key)] = translate_value(value, depth: 0, agent: agent)
174
+ end
175
+ end
176
+
177
+ # Check if constraints are valid without raising.
178
+ #
179
+ # @param constraints [Hash] the query constraints
180
+ # @return [Boolean] true if valid, false otherwise
181
+ def valid?(constraints)
182
+ translate(constraints)
183
+ true
184
+ rescue ConstraintSecurityError, InvalidOperatorError
185
+ false
186
+ end
187
+
188
+ private
189
+
190
+ # Translate a single value, handling nested operators
191
+ #
192
+ # @param value [Object] the value to translate
193
+ # @param depth [Integer] current nesting depth
194
+ # @return [Object] the translated value
195
+ def translate_value(value, depth:, agent: nil)
196
+ raise InvalidOperatorError.new(
197
+ "Query exceeds maximum depth of #{MAX_QUERY_DEPTH}",
198
+ operator: nil,
199
+ ) if depth > MAX_QUERY_DEPTH
200
+
201
+ case value
202
+ when Hash
203
+ translate_hash_value(value, depth: depth, agent: agent)
204
+ when Array
205
+ value.map { |v| translate_value(v, depth: depth + 1, agent: agent) }
206
+ else
207
+ value
208
+ end
209
+ end
210
+
211
+ # Translate a hash value (could be operators or a pointer/object)
212
+ def translate_hash_value(hash, depth:, agent: nil)
213
+ # Check if it's a Parse type (Pointer, Date, File, GeoPoint)
214
+ return hash if parse_type?(hash)
215
+
216
+ # Check if all keys are operators
217
+ if hash.keys.all? { |k| k.to_s.start_with?("$") }
218
+ hash.transform_keys(&:to_s).each_with_object({}) do |(op, val), result|
219
+ validate_operator!(op)
220
+ # NEW-TOOLS-7: validate $regex / $options operands before
221
+ # forwarding to MongoDB.
222
+ assert_regex_operand_safe!(op, val) if op == "$regex" || op == "$options"
223
+ result[op] = if CROSS_CLASS_OPERATORS.include?(op)
224
+ translate_cross_class_value(op, val, depth: depth + 1, agent: agent)
225
+ else
226
+ translate_value(val, depth: depth + 1, agent: agent)
227
+ end
228
+ end
229
+ else
230
+ # Regular nested object - translate keys to columnized format.
231
+ # Apply the internal-field key denylist at every nesting level so
232
+ # a key nested inside $and/$or/$nor cannot bypass the top-level check.
233
+ hash.transform_keys(&:to_s).each_with_object({}) do |(k, v), result|
234
+ assert_where_key_permitted!(k)
235
+ result[columnize(k)] = translate_value(v, depth: depth + 1, agent: agent)
236
+ end
237
+ end
238
+ end
239
+
240
+ # Translate the value of a cross-class operator
241
+ # (+$inQuery+/+$notInQuery+/+$select+/+$dontSelect+). The value
242
+ # carries an embedded +className+ that must be validated against
243
+ # the active accessibility policy, and an embedded +where+ that
244
+ # must be recursively translated so blocked operators (e.g.
245
+ # +$where+ nested inside) cannot smuggle through.
246
+ def translate_cross_class_value(op, val, depth:, agent: nil)
247
+ return val unless val.is_a?(Hash)
248
+ val = val.transform_keys(&:to_s)
249
+ embedded_class_name = nil
250
+ embedded_where = nil
251
+
252
+ if op == "$select" || op == "$dontSelect"
253
+ # Shape: { "query" => { "className" => "X", "where" => {...} }, "key" => "field" }
254
+ query_part = val["query"]
255
+ if query_part.is_a?(Hash)
256
+ query_part = query_part.transform_keys(&:to_s)
257
+ embedded_class_name = query_part["className"]
258
+ embedded_where = query_part["where"]
259
+ end
260
+ else
261
+ # $inQuery / $notInQuery shape: { "className" => "X", "where" => {...} }
262
+ embedded_class_name = val["className"]
263
+ embedded_where = val["where"]
264
+ end
265
+
266
+ if embedded_class_name
267
+ assert_embedded_class_accessible!(op, embedded_class_name, agent: agent)
268
+ end
269
+
270
+ # Recursively translate the inner where clause so denied
271
+ # operators inside it surface immediately.
272
+ #
273
+ # NOTE: `translate`'s second parameter is POSITIONAL (see the
274
+ # signature comment at line 149-153 for the Ruby-3 kwargs
275
+ # rationale). Passing `agent: agent` here would bundle the
276
+ # agent into a Hash literal `{agent: <Parse::Agent>}` and pass
277
+ # that Hash as the positional `agent` argument, so the inner
278
+ # `assert_embedded_class_accessible!` would call
279
+ # `Tools.assert_class_accessible!(class_name, agent: <Hash>)`
280
+ # — the per-agent class-filter check then crashes on
281
+ # `Hash#class_filter_permits?` and the `rescue StandardError`
282
+ # wraps the NoMethodError as ConstraintSecurityError, silently
283
+ # disabling the per-agent class filter on every nested
284
+ # cross-class hop. Keep this call POSITIONAL.
285
+ if embedded_where.is_a?(Hash)
286
+ translated_where = translate(embedded_where, agent)
287
+ new_val = val.dup
288
+ if op == "$select" || op == "$dontSelect"
289
+ query_part = new_val["query"].transform_keys(&:to_s)
290
+ query_part["where"] = translated_where
291
+ new_val["query"] = query_part
292
+ else
293
+ new_val["where"] = translated_where
294
+ end
295
+ val = new_val
296
+ end
297
+
298
+ # Then recursively walk the rest for depth/operator enforcement.
299
+ translate_value(val, depth: depth, agent: agent)
300
+ end
301
+
302
+ # Hook into the agent-side accessibility check when the agent
303
+ # module is loaded; in pure-unit contexts where +Parse::Agent::Tools+
304
+ # has not been loaded, default to a no-op rather than raising —
305
+ # the strict check is enforced wherever the agent dispatches.
306
+ def assert_embedded_class_accessible!(op, class_name, agent: nil)
307
+ if defined?(Parse::Agent::Tools) && Parse::Agent::Tools.respond_to?(:assert_class_accessible!)
308
+ begin
309
+ Parse::Agent::Tools.assert_class_accessible!(class_name, agent: agent)
310
+ rescue Parse::Agent::AccessDenied
311
+ # Preserve the original AccessDenied so the upstream rescue in
312
+ # Parse::Agent#execute maps it to `error_code: :access_denied` with
313
+ # the correct `denial_kind:` (`:hidden_class`, `:class_filter`, etc.)
314
+ # in the audit payload. Wrapping it as ConstraintSecurityError would
315
+ # collapse it to the generic `:security_blocked` code and erase the
316
+ # SOC-relevant subcode.
317
+ raise
318
+ rescue StandardError => e
319
+ raise ConstraintSecurityError.new(
320
+ "SECURITY: operator '#{op}' references inaccessible className " \
321
+ "'#{class_name}': #{e.message}",
322
+ operator: op,
323
+ reason: :cross_class_denied,
324
+ )
325
+ end
326
+ end
327
+ end
328
+
329
+ # Check if hash represents a Parse type
330
+ def parse_type?(hash)
331
+ return false unless hash.is_a?(Hash)
332
+ type = hash["__type"] || hash[:__type]
333
+ %w[Pointer Date File GeoPoint Bytes Polygon Relation].include?(type)
334
+ end
335
+
336
+ # NEW-TOOLS-7: validate $regex / $options operands.
337
+ #
338
+ # MongoDB's regex engine is PCRE (not RE2), so adversarial patterns
339
+ # with nested quantifiers (`(a+)+`, `(a*)*`, `(.|.)+`) cause
340
+ # catastrophic backtracking — quadratic-to-exponential matching
341
+ # cost per document. The agent surface lacks a per-pattern
342
+ # complexity gate at the Mongo level, so refuse the worst shapes
343
+ # at the SDK boundary. Three checks:
344
+ #
345
+ # 1. $regex must be a String. No Hash/Array/Numeric values.
346
+ # 2. Pattern length ≤ MAX_REGEX_PATTERN_LENGTH (256 chars).
347
+ # 3. Pattern must not match the nested-quantifier heuristic
348
+ # (REDOS_NESTED_QUANTIFIER_RE).
349
+ #
350
+ # For $options:
351
+ #
352
+ # 1. Must be a String.
353
+ # 2. Length ≤ 8 (defensive — real-world usage is 0-3 chars).
354
+ # 3. Every character must appear in ALLOWED_REGEX_OPTIONS (imx).
355
+ # The `s` (dot-all) flag is intentionally rejected.
356
+ #
357
+ # @raise [ConstraintSecurityError] on any rule violation.
358
+ def assert_regex_operand_safe!(op, val)
359
+ if op == "$regex"
360
+ unless val.is_a?(String)
361
+ raise ConstraintSecurityError.new(
362
+ "$regex value must be a String (got #{val.class})",
363
+ operator: op,
364
+ reason: :invalid_regex,
365
+ )
366
+ end
367
+ if val.length > MAX_REGEX_PATTERN_LENGTH
368
+ raise ConstraintSecurityError.new(
369
+ "$regex pattern length #{val.length} exceeds " \
370
+ "#{MAX_REGEX_PATTERN_LENGTH} character cap. " \
371
+ "Narrow the pattern (e.g. anchored prefix `^xyz`) or filter " \
372
+ "via a non-regex constraint.",
373
+ operator: op,
374
+ reason: :regex_too_long,
375
+ )
376
+ end
377
+ if REDOS_NESTED_QUANTIFIER_RE.match?(val)
378
+ raise ConstraintSecurityError.new(
379
+ "$regex pattern #{val.inspect} contains a nested quantifier " \
380
+ "(`(...x+...)+` shape) that can trigger catastrophic " \
381
+ "backtracking on MongoDB's PCRE engine. Rewrite the pattern " \
382
+ "without nested quantifier groups.",
383
+ operator: op,
384
+ reason: :regex_redos,
385
+ )
386
+ end
387
+ elsif op == "$options"
388
+ unless val.is_a?(String)
389
+ raise ConstraintSecurityError.new(
390
+ "$options value must be a String (got #{val.class})",
391
+ operator: op,
392
+ reason: :invalid_regex,
393
+ )
394
+ end
395
+ if val.length > 8
396
+ raise ConstraintSecurityError.new(
397
+ "$options string is suspiciously long (#{val.length} chars).",
398
+ operator: op,
399
+ reason: :invalid_regex,
400
+ )
401
+ end
402
+ unrecognized = val.chars.reject { |c| ALLOWED_REGEX_OPTIONS.include?(c) }
403
+ unless unrecognized.empty?
404
+ raise ConstraintSecurityError.new(
405
+ "$options contains disallowed flag(s) " \
406
+ "#{unrecognized.uniq.inspect}. Allowed flags: " \
407
+ "#{ALLOWED_REGEX_OPTIONS.chars.inspect}. The dot-all " \
408
+ "`s` flag is intentionally rejected.",
409
+ operator: op,
410
+ reason: :invalid_regex,
411
+ )
412
+ end
413
+ end
414
+ end
415
+
416
+ # Refuse field-name keys that reference internal Parse Server columns.
417
+ # Applies to every top-level key in a where: constraint hash. Operators
418
+ # ($xxx) bypass this check — they are validated separately by
419
+ # validate_operator!.
420
+ #
421
+ # @param key [String] a non-operator constraint key.
422
+ # @raise [ConstraintSecurityError] when the key is in DENIED_WHERE_KEYS
423
+ # or starts with a DENIED_WHERE_KEY_PREFIXES entry.
424
+ def assert_where_key_permitted!(key)
425
+ return if key.start_with?("$") # operators handled separately
426
+
427
+ k = key.to_s
428
+ if DENIED_WHERE_KEYS.include?(k) ||
429
+ DENIED_WHERE_KEY_PREFIXES.any? { |prefix| k.start_with?(prefix) }
430
+ raise ConstraintSecurityError.new(
431
+ "SECURITY: Field key '#{k}' is an internal Parse Server column and " \
432
+ "must not appear in a where: constraint. Querying against this field " \
433
+ "creates an oracle that can exfiltrate credential or token data via " \
434
+ "count deltas.",
435
+ operator: k,
436
+ reason: :denied_internal_field,
437
+ )
438
+ end
439
+ end
440
+
441
+ # Validate an operator is allowed (strict whitelist enforcement).
442
+ #
443
+ # @param op [String] the operator to validate
444
+ # @raise [ConstraintSecurityError] if operator is blocked
445
+ # @raise [InvalidOperatorError] if operator is unknown
446
+ def validate_operator!(op)
447
+ op_str = op.to_s
448
+
449
+ # Check blocklist FIRST - these are security violations
450
+ if BLOCKED_OPERATORS.include?(op_str)
451
+ raise ConstraintSecurityError.new(
452
+ "SECURITY: Operator '#{op_str}' is blocked - it allows arbitrary code execution. " \
453
+ "This operator is not allowed regardless of permission level.",
454
+ operator: op_str,
455
+ reason: :code_execution,
456
+ )
457
+ end
458
+
459
+ # Strict whitelist validation - reject anything unknown
460
+ unless ALLOWED_OPERATORS.include?(op_str)
461
+ raise InvalidOperatorError.new(
462
+ "Unknown query operator '#{op_str}' is not allowed. " \
463
+ "Allowed operators: #{ALLOWED_OPERATORS.join(", ")}",
464
+ operator: op_str,
465
+ )
466
+ end
467
+ end
468
+
469
+ # Convert field name to Parse column format (camelCase with lowercase first letter)
470
+ # Matches Parse::Query.field_formatter behavior
471
+ def columnize(field)
472
+ return field if field.start_with?("_") # Preserve special fields like _User
473
+
474
+ # Convert snake_case to camelCase
475
+ field.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
476
+ .sub(/^([A-Z])/) { ::Regexp.last_match(1).downcase }
477
+ end
478
+ end
479
+ end
480
+ end