parse-stack-next 5.7.5 → 5.8.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +856 -0
  3. data/README.md +15 -4
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +190 -14
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +318 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +290 -17
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. metadata +8 -1
@@ -266,6 +266,27 @@ module Parse
266
266
  # * **`$facet`** — recursive: each facet branch is itself a
267
267
  # pipeline; rewrite every branch independently.
268
268
  #
269
+ # Each joined class is also held to its own Class-Level Permissions,
270
+ # the way Parse Server's include sub-query would be:
271
+ #
272
+ # * its `find` CLP is evaluated with Parse Server's branch
273
+ # semantics ({Parse::CLPScope.row_constraint_for!}). A denial
274
+ # refuses the whole join (fail closed). A grant that rests only
275
+ # on `readUserFields` / `pointerFields` adds the "row points at
276
+ # the requesting user" predicate to the join's leading `$match`
277
+ # (or to `restrictSearchWithMatch` for `$graphLookup`);
278
+ # * its protectedFields for the scope are removed by a
279
+ # {Parse::CLPScope.protected_strip_stage} placed right after that
280
+ # `$match`, before any caller stage of the sub-pipeline. Joined
281
+ # rows therefore never carry a protected field into the outer
282
+ # pipeline, whatever later stages reshape them, and a `_User`
283
+ # row keeps its own `email` only when its STORED `_id` is the
284
+ # requesting user.
285
+ #
286
+ # When `class_name:` names the queried class, each top-level `$facet`
287
+ # branch also starts with that class's strip stage: a branch nests the
288
+ # documents one level down, out of reach of the top-level strip.
289
+ #
269
290
  # Returns a NEW Array; the input pipeline is not mutated.
270
291
  # Master and nil-resolution pass through unchanged. Legacy
271
292
  # (non-strict-role) empty-perms resolutions also pass through.
@@ -275,8 +296,12 @@ module Parse
275
296
  #
276
297
  # @param pipeline [Array<Hash>] the aggregation pipeline.
277
298
  # @param resolution [Resolution, nil]
299
+ # @param class_name [String, nil] the queried class, for `$facet`
300
+ # branch stripping.
278
301
  # @return [Array<Hash>] the rewritten pipeline.
279
- def rewrite_pipeline(pipeline, resolution)
302
+ # @raise [Parse::CLPScope::Denied] when a joined class refuses `find`
303
+ # for the scope.
304
+ def rewrite_pipeline(pipeline, resolution, class_name: nil)
280
305
  return pipeline if pipeline.nil? || pipeline.empty?
281
306
  return pipeline if resolution.nil? || resolution.master?
282
307
  perms = resolution.permission_strings
@@ -301,7 +326,25 @@ module Parse
301
326
  # this gate; the rewriter is the shared SDK-level layer so the
302
327
  # mongo-direct path enforces it independent of whether an agent
303
328
  # made the call.
304
- pipeline.map { |stage| rewrite_stage(stage, acl_match, perms) }
329
+ ctx = JoinContext.new(acl_match, perms, resolution, clp_client_for(resolution))
330
+ pipeline.each_with_index.map do |stage, idx|
331
+ rewritten = rewrite_stage(stage, ctx)
332
+ next rewritten if class_name.nil?
333
+ strip_facet_branches(rewritten, class_name.to_s, ctx, pipeline[0...idx])
334
+ end
335
+ end
336
+
337
+ # Whether {.rewrite_pipeline} rewrites join stages for this
338
+ # resolution (and so places the protected-field strip at the head of
339
+ # every join). False for master, nil, and legacy empty-perms scopes.
340
+ #
341
+ # @param resolution [Resolution, nil]
342
+ # @return [Boolean]
343
+ def rewrites_joins?(resolution)
344
+ return false if resolution.nil? || resolution.master?
345
+ perms = resolution.permission_strings
346
+ strict = resolution.respond_to?(:strict_role?) && resolution.strict_role?
347
+ strict || !(perms.nil? || perms.empty?)
305
348
  end
306
349
 
307
350
  # Walk the result documents and redact every embedded sub-document
@@ -336,34 +379,70 @@ module Parse
336
379
 
337
380
  private
338
381
 
382
+ # Per-call state shared by the join rewriters.
383
+ JoinContext = Struct.new(:acl_match, :perms, :resolution, :client)
384
+
385
+ # The client whose schema decides CLP for a resolution. A user-scoped
386
+ # client (from `become` / `session_client`) holds no master key and
387
+ # cannot read `/schemas`, so it falls back to the default client (nil).
388
+ # Mirrors {Parse::MongoDB.clp_client_for}.
389
+ def clp_client_for(resolution)
390
+ c = client_of(resolution)
391
+ return nil if c.nil?
392
+ return nil unless c.respond_to?(:master_key) && !c.master_key.to_s.empty?
393
+ c
394
+ end
395
+
339
396
  # Apply the rewriter to a single pipeline stage. Operator-aware:
340
397
  # only join-style stages are touched. Everything else passes
341
- # through verbatim. `perms` is threaded down so the cross-class
342
- # CLP gate (Wave-3 TRACK-ACL-3) can challenge each joined class.
343
- def rewrite_stage(stage, acl_match, perms)
398
+ # through verbatim. The context is threaded down so the
399
+ # cross-class CLP gate (Wave-3 TRACK-ACL-3) can challenge each
400
+ # joined class.
401
+ def rewrite_stage(stage, ctx)
344
402
  return stage unless stage.is_a?(Hash)
345
403
  op_key, op_val = stage.first
346
404
  case op_key.to_s
347
405
  when "$lookup"
348
- { op_key => rewrite_lookup(op_val, acl_match, perms) }
406
+ { op_key => rewrite_lookup(op_val, ctx) }
349
407
  when "$unionWith"
350
- { op_key => rewrite_union_with(op_val, acl_match, perms) }
408
+ { op_key => rewrite_union_with(op_val, ctx) }
351
409
  when "$graphLookup"
352
- { op_key => rewrite_graph_lookup(op_val, acl_match, perms) }
410
+ { op_key => rewrite_graph_lookup(op_val, ctx) }
353
411
  when "$facet"
354
- { op_key => rewrite_facet(op_val, acl_match, perms) }
412
+ { op_key => rewrite_facet(op_val, ctx) }
355
413
  else
356
414
  stage
357
415
  end
358
416
  end
359
417
 
360
- # Cross-class CLP gate. Raises {Parse::CLPScope::Denied} when
361
- # the current scope cannot `find` rows of `target_class`. Master
362
- # mode is already short-circuited in {.rewrite_pipeline} (it
363
- # never reaches the rewriters), so reaching this helper means
364
- # `perms` is a real claim set. Centralized here to avoid drift
365
- # between the three join-style rewriters.
366
- def assert_join_target_permitted!(target, perms)
418
+ # Cross-class CLP gate for a joined class. Applies the hard
419
+ # internal-collection floor, then evaluates the joined class's `find`
420
+ # CLP with Parse Server's branch semantics. Raises
421
+ # {Parse::CLPScope::Denied} when the scope cannot `find` rows of
422
+ # `target` (including an unresolvable CLP: fail closed). Returns the
423
+ # `$match` predicate the joined rows must satisfy: the ACL predicate,
424
+ # ANDed with the pointer-ownership predicate when the grant rests on
425
+ # `readUserFields` / `pointerFields`.
426
+ #
427
+ # Master mode is short-circuited in {.rewrite_pipeline}, so reaching
428
+ # this helper means `perms` is a real claim set.
429
+ def join_predicate!(target, ctx)
430
+ acl_predicate = ctx.acl_match["$match"]
431
+ return acl_predicate if target.nil? || target.to_s.empty?
432
+ target_str = target.to_s
433
+ assert_join_target_permitted!(target_str, ctx.perms, client: ctx.client)
434
+ pointer_fields = Parse::CLPScope.row_constraint_for!(
435
+ target_str, :find, ctx.resolution, client: ctx.client,
436
+ )
437
+ return acl_predicate if pointer_fields.nil?
438
+ ownership = Parse::CLPScope.pointer_fields_predicate(pointer_fields, ctx.resolution.user_id)
439
+ { "$and" => [acl_predicate, ownership] }
440
+ end
441
+
442
+ # Internal-collection floor plus the `permits?` CLP gate for a joined
443
+ # class. Raises {Parse::PipelineSecurity::Error} for an internal
444
+ # collection and {Parse::CLPScope::Denied} when CLP refuses `find`.
445
+ def assert_join_target_permitted!(target, perms, client: nil)
367
446
  return if target.nil?
368
447
  target_str = target.to_s
369
448
  return if target_str.empty?
@@ -378,22 +457,53 @@ module Parse
378
457
  # admitting the SDK data classes (`_User`/`_Role`/`_Installation`/
379
458
  # `_Session`), which then face the per-scope CLP `find` gate.
380
459
  Parse::PipelineSecurity.assert_collection_allowed!(target_str)
381
- return if Parse::CLPScope.permits?(target_str, :find, perms)
460
+ return if Parse::CLPScope.permits?(target_str, :find, perms, client: client)
382
461
  raise Parse::CLPScope::Denied.new(
383
462
  target_str, :find,
384
463
  "Joined class '#{target_str}' refuses :find for current scope.",
385
464
  )
386
465
  end
387
466
 
388
- def rewrite_lookup(spec, acl_match, perms)
467
+ # The stages that lead a join's sub-pipeline: the row filter, then
468
+ # the joined class's protected-field strip (when it has any).
469
+ def join_head_stages!(target, ctx)
470
+ head = [{ "$match" => join_predicate!(target, ctx) }]
471
+ strip = strip_stage_for(target, ctx)
472
+ head << strip if strip
473
+ head
474
+ end
475
+
476
+ def strip_stage_for(target, ctx, user_id: ctx.resolution.user_id)
477
+ return nil if target.nil? || target.to_s.empty?
478
+ set = Parse::CLPScope.protected_fields_for(target.to_s, ctx.perms, client: ctx.client)
479
+ Parse::CLPScope.protected_strip_stage(set, class_name: target.to_s, user_id: user_id)
480
+ end
481
+
482
+ # Prepend the queried class's strip stage to every branch of a
483
+ # top-level `$facet`. The `_User` self exemption is kept only when no
484
+ # earlier stage can have rewritten `_id`.
485
+ def strip_facet_branches(stage, class_name, ctx, preceding)
486
+ return stage unless stage.is_a?(Hash)
487
+ op_key, op_val = stage.first
488
+ return stage unless op_key.to_s == "$facet" && op_val.is_a?(Hash)
489
+ stable = Parse::PipelineSecurity.identity_preserving?(preceding)
490
+ strip = strip_stage_for(class_name, ctx, user_id: stable ? ctx.resolution.user_id : nil)
491
+ return stage if strip.nil?
492
+ branches = op_val.each_with_object({}) do |(name, branch), out|
493
+ out[name] = branch.is_a?(Array) ? [strip] + branch : branch
494
+ end
495
+ { op_key => branches }
496
+ end
497
+
498
+ def rewrite_lookup(spec, ctx)
389
499
  # String shorthand `{$lookup: "Collection"}` is not a real
390
500
  # Mongo form; defensively leave it alone.
391
501
  return spec unless spec.is_a?(Hash)
392
502
  # Gate FIRST so a CLP-denied join is refused before the
393
503
  # rewriter spends work rebuilding the sub-pipeline. `from`
394
- # accepts string or symbol — normalize via the gate.
504
+ # accepts string or symbol; the gate normalizes it.
395
505
  target = spec["from"] || spec[:from]
396
- assert_join_target_permitted!(target, perms)
506
+ head = join_head_stages!(target, ctx)
397
507
  spec = spec.dup
398
508
  existing_pipeline = spec["pipeline"] || spec[:pipeline] || []
399
509
  # Walk the sub-pipeline recursively so nested $lookup /
@@ -402,69 +512,77 @@ module Parse
402
512
  # `perms` set. (Mongo evaluates the sub-pipeline in the
403
513
  # joined collection's context, but the requesting session is
404
514
  # unchanged; permissions don't elevate by traversing a join.)
405
- rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, acl_match, perms) }
406
- new_pipeline = [acl_match] + rewritten_inner
407
- spec["pipeline"] = new_pipeline
515
+ rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, ctx) }
516
+ spec["pipeline"] = head + rewritten_inner
408
517
  spec.delete(:pipeline) # symbol form was promoted to string form
409
518
  spec
410
519
  end
411
520
 
412
- def rewrite_union_with(spec, acl_match, perms)
521
+ def rewrite_union_with(spec, ctx)
413
522
  # `$unionWith` accepts either a String (collection name only)
414
- # or a Hash `{coll:, pipeline:}`. Post the target from
415
- # either shape so the CLP gate fires before the String→Hash
416
- # upgrade — denying access to the joined class BEFORE we go
523
+ # or a Hash `{coll:, pipeline:}`. Resolve the target from
524
+ # either shape so the CLP gate fires before the String to Hash
525
+ # upgrade, denying access to the joined class BEFORE we go
417
526
  # to the trouble of building out an upgraded sub-pipeline.
418
527
  target = if spec.is_a?(String)
419
528
  spec
420
529
  elsif spec.is_a?(Hash)
421
530
  spec["coll"] || spec[:coll]
422
531
  end
423
- assert_join_target_permitted!(target, perms)
532
+ return spec unless spec.is_a?(String) || spec.is_a?(Hash)
533
+ head = join_head_stages!(target, ctx)
424
534
 
425
535
  if spec.is_a?(String)
426
- return { "coll" => spec, "pipeline" => [acl_match] }
536
+ return { "coll" => spec, "pipeline" => head }
427
537
  end
428
- return spec unless spec.is_a?(Hash)
429
538
  spec = spec.dup
430
539
  existing_pipeline = spec["pipeline"] || spec[:pipeline] || []
431
- rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, acl_match, perms) }
432
- spec["pipeline"] = [acl_match] + rewritten_inner
540
+ rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, ctx) }
541
+ spec["pipeline"] = head + rewritten_inner
433
542
  spec.delete(:pipeline)
434
543
  spec
435
544
  end
436
545
 
437
- def rewrite_graph_lookup(spec, acl_match, perms)
546
+ def rewrite_graph_lookup(spec, ctx)
438
547
  return spec unless spec.is_a?(Hash)
439
- # Same CLP gate, same reasoning — $graphLookup reads from a
548
+ # Same CLP gate, same reasoning: $graphLookup reads from a
440
549
  # different collection in the same session's authority.
441
550
  target = spec["from"] || spec[:from]
442
- assert_join_target_permitted!(target, perms)
551
+ predicate = join_predicate!(target, ctx)
552
+ # `$graphLookup` returns whole joined documents and has no
553
+ # sub-pipeline to strip protected fields in. Refuse it when the
554
+ # joined class has any for this scope.
555
+ if strip_stage_for(target, ctx)
556
+ raise Parse::CLPScope::Denied.new(
557
+ target.to_s, :find,
558
+ "$graphLookup into '#{target}' would return protected fields for the " \
559
+ "current scope; use $lookup, which strips them.",
560
+ )
561
+ end
443
562
  spec = spec.dup
444
563
  # `$graphLookup` doesn't accept a sub-pipeline. Its filter hook
445
564
  # is `restrictSearchWithMatch`, which is a $match-predicate (no
446
565
  # `$match` wrapper). Combine with any existing restriction via
447
566
  # `$and`.
448
- acl_predicate = acl_match["$match"]
449
567
  existing = spec["restrictSearchWithMatch"] || spec[:restrictSearchWithMatch]
450
568
  combined = if existing.nil? || (existing.respond_to?(:empty?) && existing.empty?)
451
- acl_predicate
569
+ predicate
452
570
  else
453
- { "$and" => [existing, acl_predicate] }
571
+ { "$and" => [existing, predicate] }
454
572
  end
455
573
  spec["restrictSearchWithMatch"] = combined
456
574
  spec.delete(:restrictSearchWithMatch)
457
575
  spec
458
576
  end
459
577
 
460
- def rewrite_facet(spec, acl_match, perms)
578
+ def rewrite_facet(spec, ctx)
461
579
  return spec unless spec.is_a?(Hash)
462
580
  spec.each_with_object({}) do |(branch_name, branch_pipeline), out|
463
581
  out[branch_name] = if branch_pipeline.is_a?(Array)
464
- # Recurse with the same perms — facet branches are
582
+ # Recurse with the same perms: facet branches are
465
583
  # evaluated in the requesting session's authority, not
466
584
  # elevated.
467
- branch_pipeline.map { |s| rewrite_stage(s, acl_match, perms) }
585
+ branch_pipeline.map { |s| rewrite_stage(s, ctx) }
468
586
  else
469
587
  branch_pipeline
470
588
  end
Binary file
@@ -138,6 +138,10 @@ module Parse
138
138
  # `^foo.*` is not).
139
139
  REDOS_NESTED_QUANTIFIER_RE = /\([^)]*[+*][^)]*\)[+*?]/.freeze
140
140
 
141
+ # Fiber-storage key holding the class whose field names are being
142
+ # resolved; see {#translate}.
143
+ CLASS_SCOPE_KEY = :parse_agent_constraint_class
144
+
141
145
  # Translate JSON constraints to Parse query format.
142
146
  # Validates all operators against the security whitelist.
143
147
  #
@@ -153,9 +157,38 @@ module Parse
153
157
  # — continues to parse as a single positional Hash under Ruby 3+
154
158
  # kwargs separation. Adding a kwarg would have turned the same call
155
159
  # into "empty kwargs + missing positional arg."
156
- def translate(constraints, agent = nil)
160
+ #
161
+ # @param class_name [String, nil] the class the constraints apply to.
162
+ # When given, field keys are resolved the way the field-policy check
163
+ # resolves them ({Parse::Agent::MetadataRegistry.wire_field_names}):
164
+ # a declared `field:` name such as `PublicText` is kept exactly, so the
165
+ # column that was checked is the column that is queried. Embedded
166
+ # `$inQuery`/`$select` clauses resolve against their own className.
167
+ def translate(constraints, agent = nil, class_name = nil)
157
168
  return {} if constraints.nil? || constraints.empty?
169
+ previous = Fiber[CLASS_SCOPE_KEY]
170
+ Fiber[CLASS_SCOPE_KEY] = class_name&.to_s
171
+ begin
172
+ translate_in_scope(constraints, agent)
173
+ ensure
174
+ Fiber[CLASS_SCOPE_KEY] = previous
175
+ end
176
+ end
177
+
178
+ # Check if constraints are valid without raising.
179
+ #
180
+ # @param constraints [Hash] the query constraints
181
+ # @return [Boolean] true if valid, false otherwise
182
+ def valid?(constraints)
183
+ translate(constraints)
184
+ true
185
+ rescue ConstraintSecurityError, InvalidOperatorError
186
+ false
187
+ end
188
+
189
+ private
158
190
 
191
+ def translate_in_scope(constraints, agent)
159
192
  raise InvalidOperatorError.new(
160
193
  "Constraints must be a Hash, got #{constraints.class}",
161
194
  operator: nil,
@@ -178,19 +211,6 @@ module Parse
178
211
  end
179
212
  end
180
213
 
181
- # Check if constraints are valid without raising.
182
- #
183
- # @param constraints [Hash] the query constraints
184
- # @return [Boolean] true if valid, false otherwise
185
- def valid?(constraints)
186
- translate(constraints)
187
- true
188
- rescue ConstraintSecurityError, InvalidOperatorError
189
- false
190
- end
191
-
192
- private
193
-
194
214
  # Translate a single value, handling nested operators
195
215
  #
196
216
  # @param value [Object] the value to translate
@@ -305,7 +325,7 @@ module Parse
305
325
  # disabling the per-agent class filter on every nested
306
326
  # cross-class hop. Keep this call POSITIONAL.
307
327
  if embedded_where.is_a?(Hash)
308
- translated_where = translate(embedded_where, agent)
328
+ translated_where = translate(embedded_where, agent, embedded_class_name)
309
329
  new_val = val.dup
310
330
  if op == "$select" || op == "$dontSelect"
311
331
  query_part = new_val["query"].transform_keys(&:to_s)
@@ -542,6 +562,13 @@ module Parse
542
562
  def columnize(field)
543
563
  return field if field.start_with?("_") # Preserve special fields like _User
544
564
 
565
+ class_name = Fiber[CLASS_SCOPE_KEY]
566
+ if class_name && !class_name.empty? && defined?(Parse::Agent::MetadataRegistry)
567
+ root, rest = field.to_s.split(".", 2)
568
+ wire = Parse::Agent::MetadataRegistry.wire_field_names(class_name, [root]).first || root
569
+ return rest ? "#{wire}.#{rest}" : wire
570
+ end
571
+
545
572
  # Convert snake_case to camelCase
546
573
  field.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
547
574
  .sub(/^([A-Z])/) { ::Regexp.last_match(1).downcase }
@@ -298,7 +298,9 @@ module Parse
298
298
  # the wire-format field name Array so the output reads identically to
299
299
  # the schema-enriched `get_schema` echo.
300
300
  def class_field_allowlist(class_name)
301
- list = Parse::Agent::MetadataRegistry.field_allowlist(class_name)
301
+ # Effective allowlist for THIS agent: the class ceiling narrowed by
302
+ # its `fields:` policy.
303
+ list = Parse::Agent::FieldPolicy.with(self) { Parse::Agent::MetadataRegistry.field_allowlist(class_name) }
302
304
  list && list.any? ? list.dup : nil
303
305
  end
304
306
 
@@ -0,0 +1,53 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # Per-agent data-field naming mode for tool output.
7
+ #
8
+ # `Parse::Agent.new(field_names: :server)` asks for data fields in the
9
+ # exact names Parse returns or the model declares through its
10
+ # `field_map` (`createdAt`, `totalPlays`, `ExternalID`), with no
11
+ # snake_case conversion anywhere a tool would otherwise apply one.
12
+ # `:default` (or omitting the option) keeps every tool's existing output.
13
+ #
14
+ # Only data-field keys are affected. MCP protocol keys and SDK envelope
15
+ # keys (`chunks`, `documents`, `object_id`, `next_call`, ...) keep their
16
+ # contracts, and "server names" never means raw MongoDB storage columns
17
+ # (`_p_author`, `_rperm`, `_session_token`). Naming is presentation only:
18
+ # every access check (ACL/CLP, protectedFields, class and per-agent field
19
+ # policies) resolves against canonical field identities before output.
20
+ #
21
+ # Like {FieldPolicy}, the mode is scoped fiber-locally around each tool
22
+ # call by {Parse::Agent::Tools.invoke}, so concurrent agents with
23
+ # different modes never see each other's setting and nothing global is
24
+ # mutated.
25
+ module FieldNames
26
+ SCOPE_KEY = :parse_agent_field_names_scope
27
+
28
+ module_function
29
+
30
+ # Run the block with `agent`'s naming mode in effect.
31
+ def with(agent)
32
+ previous = Fiber[SCOPE_KEY]
33
+ Fiber[SCOPE_KEY] = agent
34
+ yield
35
+ ensure
36
+ Fiber[SCOPE_KEY] = previous
37
+ end
38
+
39
+ # @return [Symbol] `:server` or `:default` for the agent whose tool is
40
+ # executing; `:default` outside a tool call.
41
+ def current_mode
42
+ agent = Fiber[SCOPE_KEY]
43
+ mode = agent.respond_to?(:field_names_mode) ? agent.field_names_mode : nil
44
+ mode || :default
45
+ end
46
+
47
+ # @return [Boolean] true when the executing agent asked for server names.
48
+ def server?
49
+ current_mode == :server
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,74 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # Per-agent field narrowing.
7
+ #
8
+ # A class's `agent_fields` declaration is the CEILING: the most any agent
9
+ # may read. A `Parse::Agent.new(fields: { "Post" => [:title, :status] })`
10
+ # policy NARROWS that ceiling for one agent (one MCP deployment), so a
11
+ # user-facing assistant and an analytics endpoint in the same process can
12
+ # expose different subsets of the same model. A policy can never widen
13
+ # past the ceiling, and a sub-agent's policy intersects its parent's.
14
+ #
15
+ # The effective allowlist is resolved by
16
+ # {Parse::Agent::MetadataRegistry.field_allowlist}, which every
17
+ # enforcement point already calls (projection, `where:`/`keys:` checks,
18
+ # aggregation pipelines, Atlas Search fields, schema output, exports,
19
+ # `semantic_search` chunk text). This module supplies the narrowing for
20
+ # the agent whose tool is currently executing: {Parse::Agent::Tools.invoke}
21
+ # wraps every tool call in {.with}, so the policy applies without
22
+ # threading the agent through each helper.
23
+ #
24
+ # The scope lives in fiber storage (`Fiber[]`), which child fibers and
25
+ # threads inherit when they are created. Concurrent requests on
26
+ # different threads never see each other's policy, and work a custom
27
+ # tool hands to a thread or fiber it starts stays narrowed.
28
+ module FieldPolicy
29
+ SCOPE_KEY = :parse_agent_field_policy_scope
30
+
31
+ module_function
32
+
33
+ # Run the block with `agent`'s field narrowing in effect.
34
+ #
35
+ # @param agent [Parse::Agent, nil]
36
+ # @return the block's value
37
+ #
38
+ # Scopes nest: a tool that builds another agent and invokes it (even one
39
+ # constructed without `parent:`) runs under BOTH policies, so the inner
40
+ # call can only narrow further, never escape the outer agent's policy.
41
+ def with(agent)
42
+ previous = Fiber[SCOPE_KEY]
43
+ Fiber[SCOPE_KEY] = (previous || []) + [agent]
44
+ yield
45
+ ensure
46
+ Fiber[SCOPE_KEY] = previous
47
+ end
48
+
49
+ # @return [Parse::Agent, nil] the innermost agent whose tool is executing.
50
+ def current_agent
51
+ Fiber[SCOPE_KEY]&.last
52
+ end
53
+
54
+ # Wire-format field names the current agent narrows `class_name` to, or
55
+ # nil when the agent places no narrowing on that class (or no agent is
56
+ # in scope).
57
+ #
58
+ # @param class_name [String]
59
+ # @return [Array<String>, nil]
60
+ def narrowing_for(class_name)
61
+ stack = Fiber[SCOPE_KEY]
62
+ return nil if stack.nil? || stack.empty?
63
+ result = nil
64
+ stack.uniq.each do |agent|
65
+ next unless agent.respond_to?(:field_narrowing_for)
66
+ names = agent.field_narrowing_for(class_name)
67
+ next if names.nil?
68
+ result = result ? (result & names) : names
69
+ end
70
+ result
71
+ end
72
+ end
73
+ end
74
+ end