parse-stack-next 5.5.3 → 5.5.5

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -4
  3. data/README.md +26 -13
  4. data/bin/parse-console +9 -1
  5. data/docs/TEST_SERVER.md +115 -238
  6. data/docs/mcp_guide.md +1 -1
  7. data/docs/mongodb_index_optimization_guide.md +3 -2
  8. data/docs/usage_guide.md +1 -1
  9. data/docs/yard-template/default/fulldoc/html/css/common.css +52 -9
  10. data/docs/yard-template/default/fulldoc/html/css/full_list.css +40 -13
  11. data/lib/parse/agent/constraint_translator.rb +18 -18
  12. data/lib/parse/agent/errors.rb +29 -7
  13. data/lib/parse/agent/metadata_dsl.rb +6 -6
  14. data/lib/parse/agent/tools.rb +250 -59
  15. data/lib/parse/agent.rb +42 -30
  16. data/lib/parse/api/aggregate.rb +3 -3
  17. data/lib/parse/api/cloud_functions.rb +19 -10
  18. data/lib/parse/api/objects.rb +8 -8
  19. data/lib/parse/api/users.rb +9 -9
  20. data/lib/parse/atlas_search/session.rb +34 -34
  21. data/lib/parse/atlas_search.rb +243 -110
  22. data/lib/parse/client/body_builder.rb +10 -10
  23. data/lib/parse/client/logging.rb +5 -2
  24. data/lib/parse/client/profiling.rb +5 -2
  25. data/lib/parse/client/protocol.rb +1 -1
  26. data/lib/parse/client/url_redaction.rb +94 -0
  27. data/lib/parse/client.rb +43 -28
  28. data/lib/parse/embeddings/image_fetch.rb +6 -1
  29. data/lib/parse/embeddings/voyage.rb +16 -17
  30. data/lib/parse/live_query/client.rb +7 -7
  31. data/lib/parse/live_query/subscription.rb +1 -1
  32. data/lib/parse/lock.rb +1 -1
  33. data/lib/parse/lock_backend.rb +118 -2
  34. data/lib/parse/model/acl.rb +24 -24
  35. data/lib/parse/model/classes/job_schedule.rb +8 -8
  36. data/lib/parse/model/classes/job_status.rb +9 -9
  37. data/lib/parse/model/classes/role.rb +49 -49
  38. data/lib/parse/model/classes/session.rb +2 -2
  39. data/lib/parse/model/classes/user.rb +66 -66
  40. data/lib/parse/model/core/builder.rb +7 -7
  41. data/lib/parse/model/core/create_lock.rb +1 -1
  42. data/lib/parse/model/core/properties.rb +4 -4
  43. data/lib/parse/model/file.rb +57 -16
  44. data/lib/parse/model/model.rb +19 -19
  45. data/lib/parse/model/object.rb +38 -38
  46. data/lib/parse/model/pointer.rb +4 -4
  47. data/lib/parse/model/push.rb +5 -5
  48. data/lib/parse/mongodb.rb +84 -26
  49. data/lib/parse/pipeline_security.rb +2 -2
  50. data/lib/parse/query/constraints.rb +38 -38
  51. data/lib/parse/query.rb +151 -75
  52. data/lib/parse/retrieval/reranker/cohere.rb +30 -0
  53. data/lib/parse/schema.rb +1 -1
  54. data/lib/parse/stack/version.rb +1 -1
  55. data/lib/parse/stack.rb +23 -10
  56. data/lib/parse/two_factor_auth/user_extension.rb +25 -25
  57. data/lib/parse/webhooks/payload.rb +35 -35
  58. data/lib/parse/webhooks/registration.rb +2 -2
  59. data/lib/parse/webhooks/replay_protection.rb +16 -16
  60. data/lib/parse/webhooks.rb +11 -11
  61. data/parse-stack-next.gemspec +19 -1
  62. metadata +2 -38
  63. data/.bundle/config +0 -5
  64. data/.env.sample +0 -138
  65. data/.env.test +0 -10
  66. data/.github/ISSUE_TEMPLATE/bug_report.yml +0 -105
  67. data/.github/ISSUE_TEMPLATE/feature_request.yml +0 -67
  68. data/.github/dependabot.yml +0 -13
  69. data/.github/workflows/codeql.yml +0 -44
  70. data/.github/workflows/docs.yml +0 -39
  71. data/.github/workflows/release.yml +0 -43
  72. data/.github/workflows/ruby.yml +0 -38
  73. data/.gitignore +0 -56
  74. data/.ruby-version +0 -1
  75. data/.solargraph.yml +0 -22
  76. data/.vscode/settings.json +0 -3
  77. data/.yardopts +0 -19
  78. data/Gemfile +0 -43
  79. data/Gemfile.lock +0 -198
  80. data/Makefile +0 -63
  81. data/Rakefile +0 -825
  82. data/config/parse-config.json +0 -12
  83. data/scripts/debug-ips.js +0 -35
  84. data/scripts/docker/Dockerfile.parse +0 -17
  85. data/scripts/docker/atlas-init.js +0 -284
  86. data/scripts/docker/docker-compose.atlas.yml +0 -80
  87. data/scripts/docker/docker-compose.test.yml +0 -159
  88. data/scripts/docker/docker-compose.verifyemail.yml +0 -4
  89. data/scripts/docker/mongo-init.js +0 -21
  90. data/scripts/docker/preflight.sh +0 -76
  91. data/scripts/eval_mcp_with_lm_studio.rb +0 -274
  92. data/scripts/start-parse.sh +0 -154
  93. data/scripts/start_mcp_server.rb +0 -78
  94. data/scripts/test_server_connection.rb +0 -82
  95. data/scripts/vector_prototype/create_vector_index.js +0 -105
  96. data/scripts/vector_prototype/fetch_embeddings.py +0 -241
  97. data/scripts/vector_prototype/fixture_manifest.json +0 -9
  98. data/scripts/vector_prototype/query_prototype.rb +0 -84
  99. data/scripts/vector_prototype/run.sh +0 -34
@@ -76,9 +76,9 @@ module Parse
76
76
  }.freeze
77
77
 
78
78
  # Per-tool clamps for Atlas Search result counts. The hard cap
79
- # (+ATLAS_LIMIT_MAX+) keeps response sizes predictable when an
79
+ # (`ATLAS_LIMIT_MAX`) keeps response sizes predictable when an
80
80
  # LLM agent asks for "everything"; the default keeps token
81
- # spend reasonable when an agent forgets to set +limit:+.
81
+ # spend reasonable when an agent forgets to set `limit:`.
82
82
  ATLAS_LIMIT_DEFAULT = 10
83
83
  ATLAS_LIMIT_MAX = 20
84
84
 
@@ -1071,12 +1071,12 @@ module Parse
1071
1071
  # Timeout.timeout. Must be >= 1 (a non-positive value raises
1072
1072
  # ArgumentError, since Timeout.timeout(0) would disable the bound).
1073
1073
  # @param handler [Proc] lambda(agent, **args) -> Hash (required)
1074
- # @param client_safe [Boolean] when +true+, the tool is dispatchable
1074
+ # @param client_safe [Boolean] when `true`, the tool is dispatchable
1075
1075
  # from a client-mode agent (one whose client has no master_key).
1076
- # Default +false+ — registered tools are master-key-only unless
1076
+ # Default `false` — registered tools are master-key-only unless
1077
1077
  # the author explicitly declares them safe for session-token
1078
1078
  # contexts. The handler is responsible for routing through
1079
- # +agent.client+ with +agent.session_token+ rather than touching
1079
+ # `agent.client` with `agent.session_token` rather than touching
1080
1080
  # the master key directly.
1081
1081
  # @raise [ArgumentError] when required kwargs are missing or permission is invalid
1082
1082
  #
@@ -1248,8 +1248,18 @@ module Parse
1248
1248
 
1249
1249
  if entry
1250
1250
  with_timeout(sym) { entry[:handler].call(agent, **kwargs) }
1251
- else
1251
+ elsif Tools.respond_to?(sym, true)
1252
1252
  Tools.send(sym, agent, **kwargs)
1253
+ else
1254
+ # Recognized tool name (it passed the tier/gate checks in
1255
+ # Parse::Agent#execute to get here) but no handler ships for
1256
+ # it — the built-in write/admin CRUD tools (create_object,
1257
+ # update_object, delete_object, create_class, delete_class)
1258
+ # are declared in the tiers without an implementation. Raise a
1259
+ # clear typed error instead of the bare NoMethodError that
1260
+ # `Tools.send` would otherwise produce (which collapses to an
1261
+ # opaque "internal error" on the wire).
1262
+ raise Parse::Agent::NotImplemented.new(sym)
1253
1263
  end
1254
1264
  end
1255
1265
 
@@ -1316,9 +1326,9 @@ module Parse
1316
1326
  # natively authorizes (ACL + CLP + protectedFields).
1317
1327
  # * Built-in mutation tools listed in CLIENT_SAFE_MUTATION_TOOLS —
1318
1328
  # same REST-native authorization. The caller is responsible for
1319
- # additionally checking the per-agent +allow_mutations+ gate;
1329
+ # additionally checking the per-agent `allow_mutations` gate;
1320
1330
  # this predicate reports REST-safety only.
1321
- # * Custom tools registered with +client_safe: true+ — the
1331
+ # * Custom tools registered with `client_safe: true` — the
1322
1332
  # registering author has declared the handler safe for
1323
1333
  # client-mode dispatch.
1324
1334
  #
@@ -1393,6 +1403,17 @@ module Parse
1393
1403
 
1394
1404
  defs = allowed_tools.filter_map do |tool_name|
1395
1405
  sym = tool_name.to_sym
1406
+ # Invariant: never advertise a tool we can't actually dispatch.
1407
+ # The declared-but-unimplemented write/admin CRUD tools
1408
+ # (create_object/update_object/delete_object/create_class/
1409
+ # delete_class) are members of the write/admin permission tiers
1410
+ # but ship no handler — advertising one in tools/list only to
1411
+ # have it raise NotImplemented on call is worse than omitting it.
1412
+ # A tool is dispatchable iff it's a registered handler or a
1413
+ # builtin Tools method. (Today these also lack a TOOL_DEFINITIONS
1414
+ # entry, so they'd drop out anyway; this makes the guarantee
1415
+ # explicit and future-proof.)
1416
+ next unless registered_defs.key?(sym) || Tools.respond_to?(sym, true)
1396
1417
  registered_defs[sym] || TOOL_DEFINITIONS[sym]
1397
1418
  end
1398
1419
 
@@ -1504,12 +1525,12 @@ module Parse
1504
1525
  # agent_method names. Same shape as Ruby method names with a length cap.
1505
1526
  METHOD_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]{0,127}[!?=]?\z/.freeze
1506
1527
 
1507
- # Keys that are NEVER permitted as +arguments+ to +call_method+,
1508
- # regardless of the method's declared +permitted_keys+. These
1528
+ # Keys that are NEVER permitted as `arguments` to `call_method`,
1529
+ # regardless of the method's declared `permitted_keys`. These
1509
1530
  # reference Parse-internal columns (auth/credential state, ACL,
1510
1531
  # identifiers managed by Parse Server) and have no legitimate
1511
1532
  # use case being set by an LLM through a wrapper method that
1512
- # splats its arguments with +**+.
1533
+ # splats its arguments with `**`.
1513
1534
  CALL_METHOD_DENIED_KEYS = %i[
1514
1535
  _hashed_password _password_history
1515
1536
  authData auth_data _auth_data
@@ -1521,16 +1542,16 @@ module Parse
1521
1542
  className __type
1522
1543
  ].freeze
1523
1544
 
1524
- # Framework-reserved keys that are always permitted in +arguments+
1525
- # regardless of the method's +permitted_keys+ list. +dry_run+ is
1526
- # gated separately by +supports_dry_run+ on the method definition.
1545
+ # Framework-reserved keys that are always permitted in `arguments`
1546
+ # regardless of the method's `permitted_keys` list. `dry_run` is
1547
+ # gated separately by `supports_dry_run` on the method definition.
1527
1548
  # Reserved kwargs the agent dispatcher may inject into an
1528
- # +agent_method+ body. +dry_run+ is forwarded when the method
1529
- # declared +supports_dry_run: true+. +agent+ is injected
1549
+ # `agent_method` body. `dry_run` is forwarded when the method
1550
+ # declared `supports_dry_run: true`. `agent` is injected
1530
1551
  # whenever the method's signature declares the keyword (so the
1531
- # author can read +agent.acl_scope_kwargs+ / +agent.acl_scope+
1552
+ # author can read `agent.acl_scope_kwargs` / `agent.acl_scope`
1532
1553
  # and apply the agent's identity to internal queries the method
1533
- # runs). Neither key counts against +permitted_keys:+.
1554
+ # runs). Neither key counts against `permitted_keys:`.
1534
1555
  AGENT_METHOD_RESERVED_ARG_KEYS = %i[dry_run agent].freeze
1535
1556
 
1536
1557
  # Refuse access to a class marked `agent_hidden`, AND validate that the
@@ -1732,6 +1753,79 @@ module Parse
1732
1753
  end
1733
1754
  module_function :apply_tenant_scope_to_pipeline
1734
1755
 
1756
+ # Default-deny joins under an active tenant scope. A field-based
1757
+ # tenant scope only constrains the OUTER collection: the outer
1758
+ # `$match` prepended by {#apply_tenant_scope_to_pipeline} does not
1759
+ # reach into a `$lookup` / `$graphLookup` / `$unionWith`
1760
+ # sub-pipeline, which Mongo evaluates in the JOINED collection's
1761
+ # context with NO tenant predicate injected.
1762
+ #
1763
+ # An earlier version allowed a join when the target class declared
1764
+ # the SAME `agent_tenant_scope` field, on the theory that a
1765
+ # tenant-aware target was safe. It is not: nothing propagates the
1766
+ # tenant *value* into the sub-pipeline, so an (even empty) join into
1767
+ # a "compatible" class still returns rows for every tenant. Until
1768
+ # value propagation into join sub-pipelines lands, refuse ANY join
1769
+ # while a tenant scope is active — the only fail-closed contract.
1770
+ #
1771
+ # @param pipeline [Array<Hash>] the caller pipeline (pre-scope-prepend)
1772
+ # @param scope [Hash, nil] { field:, value: } from {#resolve_tenant_scope!}
1773
+ # @raise [Parse::Agent::AccessDenied] on any join under an active scope
1774
+ def assert_joins_tenant_safe!(pipeline, scope)
1775
+ return unless scope
1776
+ return unless pipeline.is_a?(Array)
1777
+ scope_field = scope[:field].to_s
1778
+ each_join_target(pipeline) do |target|
1779
+ raise Parse::Agent::AccessDenied.new(
1780
+ target,
1781
+ "Join refused under an active tenant scope ('#{scope_field}'): the " \
1782
+ "tenant predicate is not propagated into a $lookup/$graphLookup/" \
1783
+ "$unionWith sub-pipeline, so the join could surface rows from other " \
1784
+ "tenants. Run the join without an agent tenant scope, or constrain " \
1785
+ "the joined class ('#{target}') explicitly.",
1786
+ kind: :tenant_join_denied,
1787
+ )
1788
+ end
1789
+ end
1790
+ module_function :assert_joins_tenant_safe!
1791
+
1792
+ # Yield each join-target collection name found in `$lookup` /
1793
+ # `$graphLookup` / `$unionWith` stages, recursing into `$facet`
1794
+ # branches and `$lookup`/`$unionWith` sub-pipelines. Handles BOTH
1795
+ # the Hash form (`{ from:/coll: ..., pipeline: [...] }`) and the
1796
+ # `$unionWith` String shorthand (`{ "$unionWith" => "Collection" }`)
1797
+ # — the latter previously slipped past every join gate because the
1798
+ # walkers only inspected Hash values.
1799
+ #
1800
+ # @param pipeline [Array<Hash>]
1801
+ # @yieldparam target [String] a join-target collection name
1802
+ def each_join_target(pipeline, &block)
1803
+ return unless pipeline.is_a?(Array)
1804
+ pipeline.each do |stage|
1805
+ next unless stage.is_a?(Hash)
1806
+ stage.each do |op, value|
1807
+ case op.to_s
1808
+ when "$lookup", "$graphLookup", "$unionWith"
1809
+ if value.is_a?(Hash)
1810
+ target = value["from"] || value[:from] || value["coll"] || value[:coll]
1811
+ yield target.to_s if target
1812
+ sub = value["pipeline"] || value[:pipeline]
1813
+ each_join_target(sub, &block) if sub.is_a?(Array)
1814
+ elsif value.is_a?(String)
1815
+ # String shorthand — only `$unionWith` accepts a bare
1816
+ # collection name; harmless to accept the shape for all
1817
+ # three since $lookup/$graphLookup never produce it.
1818
+ yield value
1819
+ end
1820
+ when "$facet"
1821
+ next unless value.is_a?(Hash)
1822
+ value.each_value { |branch| each_join_target(branch, &block) if branch.is_a?(Array) }
1823
+ end
1824
+ end
1825
+ end
1826
+ end
1827
+ module_function :each_join_target
1828
+
1735
1829
  # === Per-agent filter and canonical filter helpers ===
1736
1830
  #
1737
1831
  # Historically a SINGLE helper merged BOTH the class-level
@@ -2181,8 +2275,16 @@ module Parse
2181
2275
  stage.each do |op, value|
2182
2276
  case op.to_s
2183
2277
  when "$lookup", "$graphLookup", "$unionWith"
2184
- target = value.is_a?(Hash) ?
2185
- (value["from"] || value[:from] || value["coll"] || value[:coll]) : nil
2278
+ # `$unionWith` also accepts the String shorthand
2279
+ # `{ "$unionWith" => "Collection" }`. Extract that target too,
2280
+ # otherwise the underscore denylist, hidden?, class-filter
2281
+ # allowlist, and CLP-find gates below silently skip it.
2282
+ target =
2283
+ if value.is_a?(Hash)
2284
+ value["from"] || value[:from] || value["coll"] || value[:coll]
2285
+ elsif value.is_a?(String)
2286
+ value
2287
+ end
2186
2288
  target_str = nil
2187
2289
  if target
2188
2290
  target_str = target.to_s
@@ -2323,8 +2425,8 @@ module Parse
2323
2425
  when "$match"
2324
2426
  # $match expressions can reference any field. Without the
2325
2427
  # field-name check, an attacker can run a boolean oracle on
2326
- # +_hashed_password+ or any other +agent_hidden+ /
2327
- # non-allowlisted column by bisecting via +$regex+ and
2428
+ # `_hashed_password` or any other `agent_hidden` /
2429
+ # non-allowlisted column by bisecting via `$regex` and
2328
2430
  # reading the row count delta. Apply the same field-name
2329
2431
  # restriction as projection stages.
2330
2432
  next unless permitted_fields && value.is_a?(Hash)
@@ -2377,9 +2479,9 @@ module Parse
2377
2479
  # @api private
2378
2480
  # Walk a $match hash refusing field keys outside the allowlist.
2379
2481
  # Logical operators ($and/$or/$nor/$not) recurse. $expr expressions
2380
- # use the field-reference walker (which understands +$fieldName+
2381
- # strings) and are also blocked at the +PipelineSecurity+ layer
2382
- # for forensic operators inside +$expr+.
2482
+ # use the field-reference walker (which understands `$fieldName`
2483
+ # strings) and are also blocked at the `PipelineSecurity` layer
2484
+ # for forensic operators inside `$expr`.
2383
2485
  def check_match_keys_for_restricted_fields!(node, permitted_fields)
2384
2486
  return unless node.is_a?(Hash)
2385
2487
  node.each do |k, v|
@@ -3029,7 +3131,7 @@ module Parse
3029
3131
  # @param include [Array<String>] pointer fields to include
3030
3132
  # @return [Hash] query results, or a refusal hash if COLLSCAN detected
3031
3133
  # @raise [ConstraintTranslator::ConstraintSecurityError] if blocked operators are used
3032
- # Valid formats for the +format:+ kwarg on query_class. "json" is
3134
+ # Valid formats for the `format:` kwarg on query_class. "json" is
3033
3135
  # the default and returns the structured row envelope; the others
3034
3136
  # delegate to the export_data formatters so the conversational
3035
3137
  # query path can produce a CSV/Markdown/text-table dump without
@@ -3415,8 +3517,8 @@ module Parse
3415
3517
  end
3416
3518
 
3417
3519
  # Validate each id format using the shared OBJECT_ID_RE so
3418
- # +get_objects+ accepts the same set of identifiers as
3419
- # +get_object+ — apps with custom-id schemes that use hyphens or
3520
+ # `get_objects` accepts the same set of identifiers as
3521
+ # `get_object` — apps with custom-id schemes that use hyphens or
3420
3522
  # underscores should not see one entry point accept the id and
3421
3523
  # another reject it.
3422
3524
  unique_ids.each do |id|
@@ -3639,6 +3741,10 @@ module Parse
3639
3741
  # Done after pipeline validation so the injected stage doesn't
3640
3742
  # interfere with the validator's denylist walk.
3641
3743
  scope = resolve_tenant_scope!(agent, class_name)
3744
+ # Default-deny joins into tenant-incompatible classes BEFORE the
3745
+ # outer $match is prepended (the outer scope can't reach a join
3746
+ # sub-pipeline).
3747
+ assert_joins_tenant_safe!(pipeline, scope)
3642
3748
  scoped_pipeline = apply_tenant_scope_to_pipeline(pipeline, scope)
3643
3749
 
3644
3750
  # Per-agent filter (declared via Parse::Agent.new(filters: ...)) is
@@ -4319,6 +4425,7 @@ module Parse
4319
4425
  def run_aggregation_for_group_tool!(agent, class_name:, pipeline:, tool:,
4320
4426
  apply_canonical_filter: true)
4321
4427
  scope = resolve_tenant_scope!(agent, class_name)
4428
+ assert_joins_tenant_safe!(pipeline, scope)
4322
4429
  scoped = apply_tenant_scope_to_pipeline(pipeline, scope)
4323
4430
 
4324
4431
  # TRACK-AGENT-7 split: per-agent filter is UNCONDITIONAL; canonical
@@ -4388,7 +4495,7 @@ module Parse
4388
4495
 
4389
4496
  # @api private
4390
4497
  # H2: After extract_pointer_class! resolves [cls, pairs], check whether
4391
- # +cls+ is an agent_hidden class. If it is, replace every key (objectId)
4498
+ # `cls` is an agent_hidden class. If it is, replace every key (objectId)
4392
4499
  # with nil so the hidden class's row identifiers are not surfaced, and
4393
4500
  # return nil as the pointer_class so the class name is also suppressed
4394
4501
  # from the envelope.
@@ -4682,6 +4789,7 @@ module Parse
4682
4789
  def export_via_aggregate(agent, class_name:, pipeline:, scope: nil)
4683
4790
  PipelineValidator.validate!(pipeline)
4684
4791
  enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
4792
+ assert_joins_tenant_safe!(pipeline, scope)
4685
4793
  # Prepend tenant scope $match before per-agent + canonical filter and auto-limit.
4686
4794
  scoped_pipeline = apply_tenant_scope_to_pipeline(pipeline, scope)
4687
4795
  # TRACK-AGENT-7 split: per-agent filter is UNCONDITIONAL.
@@ -5056,16 +5164,39 @@ module Parse
5056
5164
  )
5057
5165
  end
5058
5166
  end
5167
+ # SEC-01: an instance write/admin method under an acl_user:/acl_role:
5168
+ # scope cannot be enforced. The receiver is fetched with READ scope
5169
+ # (below), but the method body's save/destroy run on the object's
5170
+ # default (master) client, and there is NO session token to bind as
5171
+ # ambient — so an acl_user agent with mere read access to a row could
5172
+ # mutate it with master authority (read -> write escalation). Session-
5173
+ # scoped agents bind the token during invocation; master agents are
5174
+ # admin-by-design. Only the tokenless acl_user/acl_role write case is
5175
+ # unenforceable, so refuse it.
5176
+ if method_info[:type] == :instance &&
5177
+ (required_perm == :write || required_perm == :admin) &&
5178
+ agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
5179
+ raise Parse::Agent::AccessDenied.new(
5180
+ class_name,
5181
+ "Instance #{required_perm} method '#{class_name}.#{method_name}' cannot be " \
5182
+ "invoked under an acl_user:/acl_role: scope: there is no session token to " \
5183
+ "bind, so the method's writes could not be enforced against the caller's " \
5184
+ "row ACL. Use a session-token-scoped (or master) agent for instance " \
5185
+ "write/admin methods.",
5186
+ kind: :unenforceable_scope,
5187
+ )
5188
+ end
5189
+
5059
5190
  args = arguments || {}
5060
5191
  args = args.transform_keys(&:to_sym) if args.is_a?(Hash)
5061
5192
 
5062
5193
  # Apply mass-assignment guards. Always-denied keys come first:
5063
- # +_hashed_password+, +authData+, +_session_token+, +sessionToken+,
5064
- # +ACL+, +objectId+, +id+, +username+, +_rperm+, +_wperm+,
5065
- # +_perishable_token+, +_email_verify_token+, +createdAt+,
5066
- # +updatedAt+, +className+, +__type+. These can never flow
5067
- # through +call_method+ regardless of +permitted_keys+. Then if
5068
- # the method declared +permitted_keys+, the remaining args are
5194
+ # `_hashed_password`, `authData`, `_session_token`, `sessionToken`,
5195
+ # `ACL`, `objectId`, `id`, `username`, `_rperm`, `_wperm`,
5196
+ # `_perishable_token`, `_email_verify_token`, `createdAt`,
5197
+ # `updatedAt`, `className`, `__type`. These can never flow
5198
+ # through `call_method` regardless of `permitted_keys`. Then if
5199
+ # the method declared `permitted_keys`, the remaining args are
5069
5200
  # intersected with that allowlist.
5070
5201
  if args.is_a?(Hash) && !args.empty?
5071
5202
  violated = args.each_key.select { |k| CALL_METHOD_DENIED_KEYS.include?(k) }
@@ -5091,8 +5222,8 @@ module Parse
5091
5222
 
5092
5223
  # Dry-run handling. Two paths:
5093
5224
  #
5094
- # 1. The method declared +supports_dry_run: true+ — keep the
5095
- # +dry_run+ flag in +args+ so the method body can branch on
5225
+ # 1. The method declared `supports_dry_run: true` — keep the
5226
+ # `dry_run` flag in `args` so the method body can branch on
5096
5227
  # it and produce its own preview (e.g., a structural diff,
5097
5228
  # pre-flight counters). The method controls the contract.
5098
5229
  #
@@ -5104,9 +5235,9 @@ module Parse
5104
5235
  # layer can ALWAYS safely report what the call WOULD do
5105
5236
  # (permission tier resolved, args validated, object resolved
5106
5237
  # when needed) without invoking the method itself. We now
5107
- # return a structural preview envelope and the +dry_run+ arg
5238
+ # return a structural preview envelope and the `dry_run` arg
5108
5239
  # is stripped before any execution path. The preview is
5109
- # explicitly flagged +supports_real_dry_run: false+ so a
5240
+ # explicitly flagged `supports_real_dry_run: false` so a
5110
5241
  # consumer knows the method body wasn't consulted and a real
5111
5242
  # dry-run (with method-author logic) isn't available.
5112
5243
  dry_run_requested = args.key?(:dry_run) && args[:dry_run]
@@ -5117,7 +5248,10 @@ module Parse
5117
5248
  # since the same lookup will fail at execution time.
5118
5249
  if method_info[:type] == :instance
5119
5250
  raise "object_id required for instance method '#{method_name}'" unless object_id
5120
- obj_exists = !klass.find(object_id).nil?
5251
+ # SEC-01: resolve existence through the AGENT'S scope, not the
5252
+ # master default client — otherwise the dry-run is an existence
5253
+ # oracle for rows outside the caller's ACL/tenant scope.
5254
+ obj_exists = !fetch_call_method_receiver(agent, klass, class_name, object_id).nil?
5121
5255
  unless obj_exists
5122
5256
  raise "Object not found: #{class_name}##{object_id}"
5123
5257
  end
@@ -5142,9 +5276,9 @@ module Parse
5142
5276
  end
5143
5277
 
5144
5278
  # If the method didn't declare dry-run support and the caller
5145
- # passed a falsy +dry_run+ (or sent the key without it being
5279
+ # passed a falsy `dry_run` (or sent the key without it being
5146
5280
  # truthy), strip it before forwarding — the method body has no
5147
- # idea about +dry_run+ and would raise ArgumentError on the
5281
+ # idea about `dry_run` and would raise ArgumentError on the
5148
5282
  # unexpected kwarg.
5149
5283
  if args.key?(:dry_run) && !method_info[:supports_dry_run]
5150
5284
  args = args.reject { |k, _| k == :dry_run }
@@ -5152,14 +5286,34 @@ module Parse
5152
5286
 
5153
5287
  # Execute with timeout - user methods could be slow
5154
5288
  with_timeout(:call_method) do
5155
- result = if method_info[:type] == :instance
5289
+ invoke = lambda do
5290
+ if method_info[:type] == :instance
5156
5291
  raise "object_id required for instance method '#{method_name}'" unless object_id
5157
- obj = klass.find(object_id)
5292
+ # SEC-01: fetch the receiver through the agent's scope (ACL/CLP/
5293
+ # tenant enforced), NOT the master default client. Fails closed —
5294
+ # a row the scope can't read surfaces as "not found".
5295
+ obj = fetch_call_method_receiver(agent, klass, class_name, object_id)
5158
5296
  raise "Object not found: #{class_name}##{object_id}" unless obj
5159
5297
  call_with_args(obj, method_sym, args, agent: agent)
5160
5298
  else
5161
5299
  call_with_args(klass, method_sym, args, agent: agent)
5162
5300
  end
5301
+ end
5302
+
5303
+ # SEC-01: bind the agent's session token as the ambient session for
5304
+ # the duration of the method body, so any save/destroy the method
5305
+ # performs on the receiver (or an internally-loaded object) inherits
5306
+ # the caller's principal and is enforced against ACL/CLP server-side,
5307
+ # instead of silently running on the object's master default client.
5308
+ # Master agents (no token) run unbound by design; acl_user/acl_role
5309
+ # write/admin instance methods were already refused above.
5310
+ token = agent.respond_to?(:session_token) ? agent.session_token : nil
5311
+ result =
5312
+ if token.is_a?(String) && !token.strip.empty?
5313
+ Parse.with_session(token) { invoke.call }
5314
+ else
5315
+ invoke.call
5316
+ end
5163
5317
 
5164
5318
  {
5165
5319
  class_name: class_name,
@@ -5545,20 +5699,20 @@ module Parse
5545
5699
  # atlas_autocomplete, atlas_faceted_search — wrap
5546
5700
  # {Parse::AtlasSearch}. Common gating:
5547
5701
  #
5548
- # * +assert_class_accessible!+ enforces the global
5549
- # +agent_hidden+ + per-agent +classes:+ allowlist before any
5702
+ # * `assert_class_accessible!` enforces the global
5703
+ # `agent_hidden` + per-agent `classes:` allowlist before any
5550
5704
  # search is issued.
5551
- # * +atlas_auth_options!+ refuses unless the agent carries a
5552
- # +session_token+ OR was constructed with +master_atlas: true+.
5705
+ # * `atlas_auth_options!` refuses unless the agent carries a
5706
+ # `session_token` OR was constructed with `master_atlas: true`.
5553
5707
  # Atlas Search bypasses Parse Server entirely, so the agent
5554
5708
  # must declare its ACL posture; reusing the implicit
5555
5709
  # master-key signal from a session-less agent would
5556
5710
  # silently grant Atlas-bypass authority.
5557
- # * +agent_fields+ allowlist is intersected with the caller's
5558
- # +fields:+ / +highlight_field:+ / facet paths at the
5711
+ # * `agent_fields` allowlist is intersected with the caller's
5712
+ # `fields:` / `highlight_field:` / facet paths at the
5559
5713
  # boundary, and applied again to the returned documents and
5560
5714
  # highlight payloads so a field indexed for search but
5561
- # redacted by +agent_fields+ never reaches the wire.
5715
+ # redacted by `agent_fields` never reaches the wire.
5562
5716
 
5563
5717
  # The Tools module declares `private` at the top of its helper
5564
5718
  # section (above), so module-level methods defined past that
@@ -5819,6 +5973,43 @@ module Parse
5819
5973
  end
5820
5974
  module_function :execute_find_via_direct
5821
5975
 
5976
+ # @api private
5977
+ # SEC-01: fetch a `call_method` instance receiver through the AGENT'S
5978
+ # scope so ACL / CLP / tenant enforcement applies, instead of the
5979
+ # master-backed default client a bare `klass.find(object_id)` uses.
5980
+ # Returns a persisted Parse::Object instance, or nil when the row does
5981
+ # not exist or the agent's scope may not read it (fail closed — the
5982
+ # caller maps nil to "Object not found").
5983
+ #
5984
+ # * session-token scope -> REST fetch_object carrying the token;
5985
+ # Parse Server enforces ACL/CLP server-side.
5986
+ # * acl_user:/acl_role: scope -> mongo-direct find (there is no REST
5987
+ # "act as user/role" affordance); the ACL simulation gates the row.
5988
+ # * master posture -> unscoped fetch (admin by design).
5989
+ #
5990
+ # A cross-tenant id is refused post-fetch (AccessDenied, not "not
5991
+ # found") via {#assert_record_in_tenant_scope!}, matching get_object.
5992
+ def fetch_call_method_receiver(agent, klass, class_name, object_id)
5993
+ scope = resolve_tenant_scope!(agent, class_name)
5994
+ result =
5995
+ if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
5996
+ where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent)
5997
+ rows = execute_find_via_direct(agent, class_name, where: where_id, limit: 1)
5998
+ rows && rows.first
5999
+ else
6000
+ response = agent.client.fetch_object(class_name, object_id, **agent.request_opts)
6001
+ unless response.success?
6002
+ return nil if response.object_not_found?
6003
+ raise Parse::Error, "Fetch failed: #{response.error}"
6004
+ end
6005
+ response.result
6006
+ end
6007
+ return nil if result.nil? || (result.respond_to?(:empty?) && result.empty?)
6008
+ assert_record_in_tenant_scope!(result, scope, class_name)
6009
+ (klass || Parse::Object).build(result, class_name)
6010
+ end
6011
+ module_function :fetch_call_method_receiver
6012
+
5822
6013
  # @api private
5823
6014
  # Count variant of {#execute_find_via_direct}. Routes through
5824
6015
  # Parse::MongoDB.aggregate with a terminal $count stage. Returns
@@ -5853,8 +6044,8 @@ module Parse
5853
6044
  end
5854
6045
 
5855
6046
  # @api private
5856
- # Coerce +fields+ to an array of strings and intersect with the
5857
- # class's +agent_fields+ allowlist. Raises {AccessDenied} when
6047
+ # Coerce `fields` to an array of strings and intersect with the
6048
+ # class's `agent_fields` allowlist. Raises {AccessDenied} when
5858
6049
  # the caller named a field outside the allowlist — refuse, not
5859
6050
  # silently strip, so the LLM gets a clear error rather than an
5860
6051
  # empty result it might retry forever.
@@ -5928,9 +6119,9 @@ module Parse
5928
6119
  end
5929
6120
 
5930
6121
  # @api private
5931
- # Validate that every facet's +path:+ entry is in the class's
5932
- # +agent_fields+ allowlist. Mutates +facets+ in place by
5933
- # ensuring each value is a Hash with a string +path+ (the form
6122
+ # Validate that every facet's `path:` entry is in the class's
6123
+ # `agent_fields` allowlist. Mutates `facets` in place by
6124
+ # ensuring each value is a Hash with a string `path` (the form
5934
6125
  # Parse::AtlasSearch expects).
5935
6126
  def normalize_atlas_facet_paths!(class_name, facets)
5936
6127
  allowlist = Parse::Agent::MetadataRegistry.field_allowlist(class_name)
@@ -6055,7 +6246,7 @@ module Parse
6055
6246
 
6056
6247
  # @api private
6057
6248
  # Serialize a Parse::Object (or raw Hash) into the wire-format
6058
- # hash used by other tool envelopes. Falls through to +as_json+
6249
+ # hash used by other tool envelopes. Falls through to `as_json`
6059
6250
  # for Parse objects and returns the input hash for raw-mode
6060
6251
  # results.
6061
6252
  def serialize_atlas_object(obj)
@@ -6070,8 +6261,8 @@ module Parse
6070
6261
  end
6071
6262
 
6072
6263
  # @api private
6073
- # Drop highlight entries whose +path+ is not in the
6074
- # +agent_fields+ allowlist. Highlight snippets carry verbatim
6264
+ # Drop highlight entries whose `path` is not in the
6265
+ # `agent_fields` allowlist. Highlight snippets carry verbatim
6075
6266
  # field content; without this filter, a field redacted from
6076
6267
  # the row would still leak through its highlight passage.
6077
6268
  def filter_atlas_highlights(highlights, permitted_fields)