parse-stack-next 5.8.0 → 5.8.2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -1
data/lib/parse/agent.rb CHANGED
@@ -3,8 +3,10 @@
3
3
 
4
4
  require "active_support/notifications"
5
5
  require "securerandom"
6
+ require "digest"
6
7
  require "set"
7
8
  require "uri"
9
+ require "monitor"
8
10
  require_relative "mongodb"
9
11
  require_relative "acl_scope"
10
12
  require_relative "model/acl"
@@ -906,12 +908,45 @@ module Parse
906
908
  attr_reader :acl_role_scope
907
909
 
908
910
  # @return [Parse::ACLScope::Resolution, nil] the resolved ACL scope
909
- # for this agent. Frozen at construction. `nil` means master-key
910
- # posture — the agent runs every tool call with the application
911
- # master key, bypassing per-row ACL/CLP enforcement. Non-nil
912
- # carries a `permission_strings` allow-set that built-in tools
913
- # forward to mongo-direct / Atlas Search via {#acl_scope_kwargs}.
914
- attr_reader :acl_scope
911
+ # for this agent. `nil` means master-key posture: the agent runs
912
+ # every tool call with the application master key, bypassing per-row
913
+ # ACL/CLP enforcement. Non-nil carries a `permission_strings`
914
+ # allow-set that built-in tools forward to mongo-direct / Atlas
915
+ # Search via {#acl_scope_kwargs}.
916
+ #
917
+ # A session-token agent is resolved eagerly at construction. When
918
+ # that fails (Parse Server unreachable), or after {#impersonate}, the
919
+ # scope is resolved again here on first use. It never reads as `nil`
920
+ # for a session agent: if the token still cannot be resolved this
921
+ # raises {Parse::Agent::UnresolvedIdentity} rather than letting a
922
+ # caller mistake the agent for master-key posture.
923
+ # @raise [Parse::Agent::UnresolvedIdentity]
924
+ def acl_scope
925
+ # The unresolved check and the read happen under one lock, so a
926
+ # concurrent #impersonate cannot clear the scope between them and
927
+ # hand a session agent a nil (master-looking) scope. Resolution itself
928
+ # runs outside the lock (see #ensure_session_scope!).
929
+ resolved = scope_mutex.synchronize do
930
+ session_scope_unresolved? ? :unresolved : @acl_scope
931
+ end
932
+ resolved == :unresolved ? ensure_session_scope! : resolved
933
+ end
934
+
935
+ # @return [Boolean] `true` when the agent has a session token whose
936
+ # claim set has not been resolved yet (a failed eager resolution at
937
+ # construction, or a fresh {#impersonate}).
938
+ def session_scope_unresolved?
939
+ @acl_scope.nil? && !@session_token.to_s.empty?
940
+ end
941
+
942
+ # The session token and its resolved scope read together under the scope
943
+ # lock, so a sub-agent never pairs one token with another token's scope.
944
+ # @return [Array(String, Parse::ACLScope::Resolution)] `[token, scope]`;
945
+ # either may be nil.
946
+ def scope_snapshot
947
+ scope_mutex.synchronize { [@session_token, @acl_scope] }
948
+ end
949
+ protected :scope_snapshot
915
950
 
916
951
  # @return [Boolean] whether this agent may run Atlas Search tools
917
952
  # in master-key-equivalent mode when no `session_token` is set.
@@ -1035,11 +1070,35 @@ module Parse
1035
1070
  # `progress_callback` / `cancellation_token` are threaded. An
1036
1071
  # embedder on the non-MCP path may assign any object responding to
1037
1072
  # `#review`.
1073
+ #
1074
+ # A sub-agent built with `parent:` and no gate of its own uses its
1075
+ # parent's gate, read at call time: the dispatcher swaps the
1076
+ # parent's gate per request, so a copy taken at construction would
1077
+ # go stale. Without this a sub-agent fell back to {NullGate} and
1078
+ # ran tools in `require_approval_for` tiers with no approval.
1038
1079
  def approval_gate
1039
- @approval_gate ||= Parse::Agent::NullGate.new
1080
+ return @approval_gate if @approval_gate
1081
+ return @approval_parent.approval_gate if @approval_parent
1082
+ @approval_gate_defaulted = true
1083
+ @approval_gate = Parse::Agent::NullGate.new
1040
1084
  end
1041
1085
 
1042
- attr_writer :approval_gate
1086
+ # Assign this agent's own approval gate. Assigning nil removes it, so
1087
+ # a sub-agent goes back to using its parent's gate.
1088
+ def approval_gate=(gate)
1089
+ @approval_gate_defaulted = false
1090
+ @approval_gate = gate
1091
+ end
1092
+
1093
+ # @return [Parse::Agent::ApprovalGate, nil] the gate assigned on this
1094
+ # agent itself, or nil when it uses its parent's (or the default).
1095
+ # The MCP dispatcher snapshots and restores this rather than
1096
+ # {#approval_gate}, so restoring never pins a parent's gate.
1097
+ # @api private
1098
+ def approval_gate_override
1099
+ gate = @approval_gate
1100
+ gate.is_a?(Parse::Agent::NullGate) && @approval_gate_defaulted ? nil : gate
1101
+ end
1043
1102
 
1044
1103
  # @return [Boolean] true if the active cancellation token has been
1045
1104
  # tripped; false otherwise. Returns false when no token is
@@ -1145,7 +1204,11 @@ module Parse
1145
1204
  #
1146
1205
  # @return [Array<String>, nil]
1147
1206
  def acl_permission_strings
1148
- @acl_scope&.permission_strings
1207
+ scope = acl_scope
1208
+ # A session agent never reads as master-key posture, even if a scope
1209
+ # was cleared by something other than #impersonate.
1210
+ raise unresolved_identity_error if scope.nil? && !@session_token.to_s.empty?
1211
+ scope&.permission_strings
1149
1212
  end
1150
1213
 
1151
1214
  # A ready-to-prepend `$match` stage filtering an aggregation
@@ -1177,7 +1240,8 @@ module Parse
1177
1240
  end
1178
1241
 
1179
1242
  # `true` when the agent carries any non-master-key scope
1180
- # (session_token, acl_user, or acl_role). Use this when deciding
1243
+ # (session_token, acl_user, or acl_role), resolved or not. Use this
1244
+ # when deciding
1181
1245
  # whether a Parse Server endpoint that DOES NOT enforce ACL
1182
1246
  # (notably the REST `aggregate` endpoint) is safe to route through:
1183
1247
  # any `true` here means the REST path would silently bypass the
@@ -1186,7 +1250,7 @@ module Parse
1186
1250
  #
1187
1251
  # @return [Boolean]
1188
1252
  def acl_scope?
1189
- !@acl_scope.nil?
1253
+ !@acl_scope.nil? || !@session_token.to_s.empty?
1190
1254
  end
1191
1255
 
1192
1256
  # `true` when the agent's ACL scope cannot be honored by Parse
@@ -1237,16 +1301,25 @@ module Parse
1237
1301
  #
1238
1302
  # @return [Parse::ACLScope::Resolution, nil]
1239
1303
  def refresh_scope!
1240
- return @acl_scope if @session_token
1241
- return nil if @acl_user_scope.nil? && @acl_role_scope.nil?
1242
- resolved = if @acl_user_scope
1243
- Parse::ACLScope.resolve_for_user(@acl_user_scope, client: @client)
1304
+ # A session agent resolves (or raises) rather than returning nil,
1305
+ # which callers read as master-key posture.
1306
+ return acl_scope if @session_token
1307
+ user_scope, role_scope = scope_mutex.synchronize { [@acl_user_scope, @acl_role_scope] }
1308
+ return nil if user_scope.nil? && role_scope.nil?
1309
+ resolved = if user_scope
1310
+ Parse::ACLScope.resolve_for_user(user_scope, client: @client)
1244
1311
  else
1245
- Parse::ACLScope.resolve_for_role(@acl_role_scope, client: @client)
1312
+ Parse::ACLScope.resolve_for_role(role_scope, client: @client)
1313
+ end
1314
+ scope_mutex.synchronize do
1315
+ # Bind only if the identity did not change while resolving (a
1316
+ # concurrent #impersonate replaces it).
1317
+ if @session_token.nil? && @acl_user_scope.equal?(user_scope) && @acl_role_scope.equal?(role_scope)
1318
+ @acl_scope = resolved&.freeze
1319
+ @auth_context = nil # user_id may have changed
1246
1320
  end
1247
- @acl_scope = resolved&.freeze
1248
- @auth_context = nil # invalidate memoized auth_context — user_id may have changed
1249
- @acl_scope
1321
+ @acl_scope
1322
+ end
1250
1323
  end
1251
1324
 
1252
1325
  # @return [String, nil] free-form audit label attached to an
@@ -1269,30 +1342,42 @@ module Parse
1269
1342
  # @return [self]
1270
1343
  def impersonate(user, mint: false, label: nil)
1271
1344
  token = resolve_impersonation_token!(user, mint: mint)
1272
- @session_token = token
1273
- @acl_user_scope = nil
1274
- @acl_role_scope = nil
1275
- @impersonation_label = sanitize_impersonation_label(label) if label
1276
- # Drop memoized scope/auth so the next call resolves under the new
1277
- # token (session-token validity is checked per-call by Parse Server).
1278
- @acl_scope = nil
1279
- @auth_context = nil
1280
- no_master_key = @client.respond_to?(:master_key) && @client.master_key.nil?
1281
- @client_mode = no_master_key && !@session_token.to_s.empty?
1345
+ scope_mutex.synchronize do
1346
+ @session_token = token
1347
+ @acl_user_scope = nil
1348
+ @acl_role_scope = nil
1349
+ @impersonation_label = sanitize_impersonation_label(label) if label
1350
+ # Drop memoized scope/auth so the next call resolves under the new
1351
+ # token (session-token validity is checked per-call by Parse Server).
1352
+ @acl_scope = nil
1353
+ @auth_context = nil
1354
+ @scope_failed_at = nil
1355
+ @scope_failed_token = nil
1356
+ no_master_key = @client.respond_to?(:master_key) && @client.master_key.nil?
1357
+ @client_mode = no_master_key && !@session_token.to_s.empty?
1358
+ end
1282
1359
  self
1283
1360
  end
1284
1361
 
1285
- # Clear an impersonation binding established via {#impersonate},
1286
- # returning the agent to master-key posture. Does not revoke the
1287
- # underlying _Session row (the token may be shared/minted elsewhere).
1362
+ # Clear an impersonation binding established via {#impersonate} (or the
1363
+ # `impersonate_user:` constructor option), returning the agent to
1364
+ # master-key posture. Does not revoke the underlying _Session row (the
1365
+ # token may be shared/minted elsewhere). A no-op on an agent that is not
1366
+ # impersonating: an agent built with its own `session_token:` keeps it,
1367
+ # rather than silently gaining master-key posture.
1288
1368
  # @return [self]
1289
1369
  def stop_impersonating!
1290
- @session_token = nil
1291
- @impersonated_user_id = nil
1292
- @impersonation_label = nil
1293
- @acl_scope = nil
1294
- @auth_context = nil
1295
- @client_mode = false
1370
+ scope_mutex.synchronize do
1371
+ return self if @impersonated_user_id.nil?
1372
+ @session_token = nil
1373
+ @impersonated_user_id = nil
1374
+ @impersonation_label = nil
1375
+ @acl_scope = nil
1376
+ @auth_context = nil
1377
+ @scope_failed_at = nil
1378
+ @scope_failed_token = nil
1379
+ @client_mode = false
1380
+ end
1296
1381
  self
1297
1382
  end
1298
1383
 
@@ -1736,8 +1821,10 @@ module Parse
1736
1821
  #
1737
1822
  # * nil — inherit from parent (the common case; the
1738
1823
  # child wants whatever the parent had).
1739
- # * true — explicit opt-in (caller wants faceted_search
1740
- # authority regardless of parent).
1824
+ # * true: keep faceted_search authority. Allowed only when
1825
+ # the parent has it: a child cannot turn on
1826
+ # master-key faceting under a scoped parent, which
1827
+ # would return every row's buckets and results.
1741
1828
  # * false — explicit opt-OUT: the sub-agent should DROP
1742
1829
  # faceted_search authority even if the parent
1743
1830
  # had it. Previously `false` was the default
@@ -1753,6 +1840,13 @@ module Parse
1753
1840
  # per-row ACL via Parse::ACLScope's `_rperm` match and do
1754
1841
  # NOT consult master_atlas.
1755
1842
  master_atlas = parent.master_atlas if master_atlas.nil?
1843
+ if master_atlas == true && !parent.master_atlas
1844
+ raise ArgumentError,
1845
+ "sub-agent master_atlas: true exceeds parent's master_atlas: false. " \
1846
+ "A sub-agent cannot turn on master-key Atlas faceting its parent " \
1847
+ "was not given. Omit master_atlas to inherit the parent's setting, " \
1848
+ "or pass master_atlas: false."
1849
+ end
1756
1850
 
1757
1851
  # Inherit cooperative cancellation surface. Without this, a
1758
1852
  # delegating tool that constructs a sub-agent and drives it
@@ -1764,6 +1858,9 @@ module Parse
1764
1858
  @cancellation_token = parent.cancellation_token
1765
1859
  @progress_callback = parent.progress_callback
1766
1860
  @log_callback = parent.log_callback
1861
+ # Approval gate: delegate to the parent's current gate (see
1862
+ # {#approval_gate}).
1863
+ @approval_parent = parent
1767
1864
 
1768
1865
  # Clamp the sub-agent's permission tier at the parent's. The
1769
1866
  # default :readonly is always ≤ any parent tier, so this fires
@@ -1809,6 +1906,13 @@ module Parse
1809
1906
  @session_token = session_token
1810
1907
  @acl_user_scope = acl_user
1811
1908
  @acl_role_scope = acl_role
1909
+ # Guards the scope, the token, and the failed-resolution record, so a
1910
+ # concurrent #impersonate cannot leave one token's scope bound to
1911
+ # another, and concurrent first uses resolve only once.
1912
+ @scope_mutex = Monitor.new
1913
+ @scope_failed_at = nil
1914
+ @scope_failed_token = nil
1915
+ @scope_failure = nil
1812
1916
  @tenant_id = tenant_id
1813
1917
  @master_atlas = master_atlas == true
1814
1918
 
@@ -1902,18 +2006,24 @@ module Parse
1902
2006
  @acl_scope = if @session_token
1903
2007
  # Best-effort eager resolution. If Parse Server's /users/me is
1904
2008
  # unreachable at construction time (network blip, test env, MCP
1905
- # bootstrap-before-server-ready), leave @acl_scope nil and let
1906
- # Parse Server validate the token per-call via REST. The banner
1907
- # check below keys on identity inputs, NOT on resolution success,
1908
- # so an unresolved-but-supplied session_token does not trip the
1909
- # master-key banner. Failure is silent — Parse Server's
1910
- # per-call validation will surface auth errors at the
1911
- # actual usage site where the operator can act on them.
1912
- begin
1913
- opts = { session_token: @session_token, client: @client }.compact
1914
- Parse::ACLScope.resolve!(opts, method_name: :agent_init)
1915
- rescue StandardError
1916
- nil
2009
+ # bootstrap-before-server-ready), leave the scope unresolved:
2010
+ # {#acl_scope} retries on first use and raises
2011
+ # {Parse::Agent::UnresolvedIdentity} if it still fails, so an
2012
+ # unresolved session agent is never read as master-key posture.
2013
+ # Parse Server also validates the token on every REST call. The
2014
+ # banner check below keys on identity inputs, NOT on resolution
2015
+ # success, so an unresolved session_token does not trip the
2016
+ # master-key banner.
2017
+ #
2018
+ # A sub-agent holding its parent's own token on the parent's
2019
+ # client is the same identity: it reuses the parent's resolved
2020
+ # scope instead of a second /users/me call, and stays unresolved
2021
+ # (lazy path) when the parent has not resolved yet.
2022
+ parent_token, parent_scope = parent && @client.equal?(parent.client) ? parent.scope_snapshot : nil
2023
+ if parent_token && parent_token == @session_token
2024
+ parent_scope
2025
+ else
2026
+ eager_session_scope
1917
2027
  end
1918
2028
  elsif @acl_user_scope
1919
2029
  Parse::ACLScope.resolve_for_user(@acl_user_scope, client: @client)
@@ -1939,10 +2049,26 @@ module Parse
1939
2049
  # Same rationale.
1940
2050
  # * Parent has explicit permission_strings → child MUST have a
1941
2051
  # scope and child's permission_strings ⊆ parent's.
1942
- if parent && parent.acl_scope
1943
- parent_perms = parent.acl_scope.permission_strings
2052
+ # A parent whose session token has not resolved yet has an unknown
2053
+ # claim set. A child that inherits that same token is the same
2054
+ # identity and needs no check; any other child identity cannot be
2055
+ # proven a subset, so the parent's scope is resolved now (raising
2056
+ # Parse::Agent::UnresolvedIdentity if it still cannot be).
2057
+ parent_scope = if parent.nil?
2058
+ nil
2059
+ elsif parent.session_scope_unresolved? && @session_token == parent.session_token
2060
+ nil
2061
+ else
2062
+ parent.acl_scope
2063
+ end
2064
+ if parent_scope
2065
+ parent_perms = parent_scope.permission_strings
1944
2066
  if parent_perms && !parent_perms.empty?
1945
2067
  child_perms = @acl_scope&.permission_strings
2068
+ # A session token that did not resolve is not master-key posture:
2069
+ # its claim set is unknown, so the subset check cannot run. Report
2070
+ # it as unresolved rather than as an attempted widening.
2071
+ raise unresolved_identity_error if child_perms.nil? && session_scope_unresolved?
1946
2072
  if child_perms.nil?
1947
2073
  # SECURITY: emit the full diff on a dedicated audit
1948
2074
  # channel; redact identifiers from the user-visible
@@ -2054,8 +2180,63 @@ module Parse
2054
2180
  Parse::AggregationResult.normalize_field_names!(field_names)
2055
2181
  end
2056
2182
 
2057
- # Sub-agent class-filter inheritance. Unlike `tools:` (which overrides
2058
- # outright), `classes:` clamps to the parent's effective set so a
2183
+ # Sub-agent `tools:` inheritance: narrow only, same rule as `classes:`
2184
+ # below. Intersect onlies, union excepts. A child `only:` that leaves
2185
+ # nothing once the parent's allowlist and excepts apply raises; an
2186
+ # explicitly empty `only: []` is the strictest narrowing and is kept.
2187
+ if parent
2188
+ parent_tools_only = parent.instance_variable_get(:@tool_filter_only)
2189
+ parent_tools_except = parent.instance_variable_get(:@tool_filter_except)
2190
+ requested_tools = @tool_filter_only
2191
+ if parent_tools_only && @tool_filter_only
2192
+ @tool_filter_only = (@tool_filter_only & parent_tools_only).freeze
2193
+ elsif parent_tools_only
2194
+ @tool_filter_only = parent_tools_only
2195
+ end
2196
+ if parent_tools_except
2197
+ @tool_filter_except = (@tool_filter_except ? (@tool_filter_except | parent_tools_except) : parent_tools_except).freeze
2198
+ end
2199
+ if requested_tools && !requested_tools.empty? &&
2200
+ (parent_tools_only || parent_tools_except)
2201
+ reachable = @tool_filter_only - (@tool_filter_except || Set.new)
2202
+ if reachable.empty?
2203
+ raise ArgumentError,
2204
+ "sub-agent tools: { only: } would have no overlap with the parent's " \
2205
+ "tools: filter (parent only: #{parent_tools_only&.to_a&.sort.inspect}, " \
2206
+ "except: #{parent_tools_except&.to_a&.sort.inspect}; the child requested " \
2207
+ "#{requested_tools.to_a.sort.inspect}). A sub-agent cannot enable tools its " \
2208
+ "parent was not given. Pass a subset of the parent's tools, or omit tools: " \
2209
+ "to inherit the parent's filter."
2210
+ end
2211
+ end
2212
+ end
2213
+
2214
+ # Sub-agent `methods:` inheritance. Entries can be bare (`:archive`) or
2215
+ # qualified (`"Post.archive"`), so plain set intersection would get the
2216
+ # matching wrong. Instead the parent's filters are kept as layers and
2217
+ # {#method_filtered?} refuses a call any layer refuses. A child
2218
+ # `only:` that no parent layer could permit raises; an explicitly
2219
+ # empty `only: []` is kept.
2220
+ @method_filter_layers = (parent ? parent.method_filter_layers : []).dup
2221
+ if parent && @method_filter_only && !@method_filter_only.empty? && !@method_filter_layers.empty?
2222
+ reachable = @method_filter_only.any? do |entry|
2223
+ @method_filter_layers.all? { |layer_only, layer_except| method_entry_reachable?(entry, layer_only, layer_except) }
2224
+ end
2225
+ unless reachable
2226
+ raise ArgumentError,
2227
+ "sub-agent methods: { only: } would have no overlap with the parent's " \
2228
+ "methods: filter (child requested #{@method_filter_only.to_a.map(&:to_s).sort.inspect}). " \
2229
+ "A sub-agent cannot call agent methods its parent was not given. Pass a " \
2230
+ "subset of the parent's methods, or omit methods: to inherit them."
2231
+ end
2232
+ end
2233
+ if @method_filter_only || @method_filter_except
2234
+ @method_filter_layers << [@method_filter_only, @method_filter_except].freeze
2235
+ end
2236
+ @method_filter_layers.freeze
2237
+
2238
+ # Sub-agent class-filter inheritance. Like `tools:` above,
2239
+ # `classes:` clamps to the parent's effective set so a
2059
2240
  # sub-agent can NEVER widen its parent's data-reach. Intersect onlies,
2060
2241
  # union excepts. A child `only:` that would have no overlap with the
2061
2242
  # parent's effective set raises at construction — empty-onlyset means
@@ -2253,29 +2434,77 @@ module Parse
2253
2434
  #
2254
2435
  # An entry matches the invocation if it equals either the bare
2255
2436
  # method name (`:archive`) or the qualified form (`"Class.archive"`).
2437
+ # The class part is compared by its Parse class name on both sides, so
2438
+ # `"User.reset"` and `"_User.reset"` name the same method whichever
2439
+ # spelling the entry or the caller used.
2256
2440
  #
2257
2441
  # @param method_name [Symbol, String]
2258
2442
  # @param class_name [String]
2259
2443
  # @return [Boolean] true if filtered (refuse), false if permitted
2260
2444
  def method_filtered?(method_name, class_name:)
2261
- return false if @method_filter_only.nil? && @method_filter_except.nil?
2262
-
2263
2445
  method_sym = method_name.to_sym
2264
- qualified = "#{class_name}.#{method_name}"
2446
+ parse_class = Parse::Agent.canonical_method_class(class_name)
2265
2447
 
2266
- if @method_filter_only
2267
- permitted = @method_filter_only.include?(method_sym) ||
2268
- @method_filter_only.include?(qualified)
2269
- return true unless permitted
2448
+ # A sub-agent carries its parent's filters as earlier layers; its own
2449
+ # filter is the last layer. Any layer can refuse.
2450
+ method_filter_layers.any? do |only, except|
2451
+ (only && !method_entry_matches?(only, method_sym, parse_class)) ||
2452
+ (except && method_entry_matches?(except, method_sym, parse_class))
2270
2453
  end
2454
+ end
2271
2455
 
2272
- if @method_filter_except
2273
- excluded = @method_filter_except.include?(method_sym) ||
2274
- @method_filter_except.include?(qualified)
2275
- return true if excluded
2276
- end
2456
+ # The Parse class name for the class part of a qualified `methods:`
2457
+ # entry or a `call_method` class argument: `"User"` and `"_User"`
2458
+ # both give `"_User"`. A name that resolves to no model is returned
2459
+ # unchanged.
2460
+ # @api private
2461
+ # @param name [String, Class]
2462
+ # @return [String]
2463
+ def self.canonical_method_class(name)
2464
+ return name.parse_class if name.is_a?(Class) && name.respond_to?(:parse_class)
2465
+ str = name.to_s
2466
+ klass = resolve_method_filter_class(str)
2467
+ klass ? klass.parse_class : str
2468
+ end
2277
2469
 
2278
- false
2470
+ # Ruby constant names a `methods:` entry may use for a model whose Parse
2471
+ # class name differs (`class Artist < Parse::Object; parse_class
2472
+ # "Musician"; end`).
2473
+ RUBY_CONSTANT_NAME_RE = /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
2474
+
2475
+ # @!visibility private
2476
+ # The Parse::Object subclass a `methods:` class name refers to, by
2477
+ # Parse class name first and then by Ruby constant name, or nil.
2478
+ def self.resolve_method_filter_class(str)
2479
+ klass = begin
2480
+ Parse::Model.find_class(str)
2481
+ rescue StandardError
2482
+ nil
2483
+ end
2484
+ return klass if klass.is_a?(Class) && klass < Parse::Object
2485
+ return nil unless str.match?(RUBY_CONSTANT_NAME_RE)
2486
+ klass = begin
2487
+ Object.const_get(str)
2488
+ rescue StandardError, LoadError
2489
+ nil
2490
+ end
2491
+ klass.is_a?(Class) && klass < Parse::Object ? klass : nil
2492
+ end
2493
+
2494
+ # @return [Array<Array(Set, Set)>] the `methods:` filters in effect as
2495
+ # `[only, except]` pairs, own filter last; a sub-agent carries its
2496
+ # parent's layers first.
2497
+ def method_filter_layers
2498
+ @method_filter_layers || []
2499
+ end
2500
+
2501
+ # @return [Array<Hash>] {#method_filter_layers} as `{only:, except:}`
2502
+ # hashes of sorted name strings (nil when a layer has no such list),
2503
+ # parent layers first. Used by the audit payload and {#describe}.
2504
+ def method_filter_layers_descriptor
2505
+ method_filter_layers.map do |only, except|
2506
+ { only: only && only.to_a.map(&:to_s).sort, except: except && except.to_a.map(&:to_s).sort }
2507
+ end
2279
2508
  end
2280
2509
 
2281
2510
  # @return [Boolean] whether unknown names in tools: raise vs. warn at
@@ -2507,6 +2736,15 @@ module Parse
2507
2736
  payload[:tools_except] = @tool_filter_except.to_a.sort if @tool_filter_except
2508
2737
  payload[:methods_only] = @method_filter_only.to_a.map(&:to_s).sort if @method_filter_only
2509
2738
  payload[:methods_except] = @method_filter_except.to_a.map(&:to_s).sort if @method_filter_except
2739
+ # `methods_only` / `methods_except` are this agent's own filter. A
2740
+ # sub-agent is also bound by its parent's filters, so the full set in
2741
+ # force is emitted as layers (parent first) whenever any layer was
2742
+ # inherited, including a single parent layer on a child with no
2743
+ # filter of its own.
2744
+ own_method_layers = (@method_filter_only || @method_filter_except) ? 1 : 0
2745
+ if method_filter_layers.size > own_method_layers
2746
+ payload[:methods_layers] = method_filter_layers_descriptor
2747
+ end
2510
2748
  # Per-agent per-class filters — emit class-name → field-name list,
2511
2749
  # NOT the constraint values. Filter values can contain user-identifying
2512
2750
  # data (`{ user_id: "abc123" }`, `{ org_id: tenant_uuid }`) that
@@ -3393,7 +3631,44 @@ module Parse
3393
3631
  # strings (qualified-class.method match).
3394
3632
  def normalize_method_filter_entry(value)
3395
3633
  str = value.to_s
3396
- str.include?(".") ? str : str.to_sym
3634
+ return str.to_sym unless str.include?(".")
3635
+ class_part, method_part = str.split(".", 2)
3636
+ if Parse::Agent.resolve_method_filter_class(class_part).nil?
3637
+ warn "[Parse::Agent] methods: entry #{str.inspect} names a class that does not " \
3638
+ "resolve to a loaded Parse::Object model (by Parse class name or Ruby " \
3639
+ "constant). It is matched by its class part as written; check the spelling."
3640
+ end
3641
+ "#{Parse::Agent.canonical_method_class(class_part)}.#{method_part}"
3642
+ end
3643
+
3644
+ # Whether a `methods:` filter set names this invocation, either bare or
3645
+ # qualified. Qualified entries are compared by Parse class name, which
3646
+ # also covers an entry whose class was not loaded at construction.
3647
+ def method_entry_matches?(set, method_sym, parse_class)
3648
+ return true if set.include?(method_sym) || set.include?("#{parse_class}.#{method_sym}")
3649
+ set.any? do |entry|
3650
+ next false unless entry.is_a?(String)
3651
+ class_part, method_part = entry.split(".", 2)
3652
+ method_part == method_sym.to_s && Parse::Agent.canonical_method_class(class_part) == parse_class
3653
+ end
3654
+ end
3655
+
3656
+ # Whether a child `methods:` only-entry could pass one parent layer.
3657
+ # A qualified entry ("Post.archive") passes when the layer names it or
3658
+ # its bare method. A bare entry (:archive) passes when the layer names
3659
+ # it bare or qualified on any class.
3660
+ def method_entry_reachable?(entry, layer_only, layer_except)
3661
+ if entry.is_a?(String)
3662
+ bare = entry.split(".", 2).last.to_sym
3663
+ return false if layer_except && (layer_except.include?(entry) || layer_except.include?(bare))
3664
+ return true unless layer_only
3665
+ layer_only.include?(entry) || layer_only.include?(bare)
3666
+ else
3667
+ return false if layer_except&.include?(entry)
3668
+ return true unless layer_only
3669
+ layer_only.include?(entry) ||
3670
+ layer_only.any? { |e| e.is_a?(String) && e.split(".", 2).last == entry.to_s }
3671
+ end
3397
3672
  end
3398
3673
 
3399
3674
  # Normalize the constructor's `classes:` kwarg into a [only_set,
@@ -3954,6 +4229,13 @@ module Parse
3954
4229
  # acl_role, nil for master_key) so the AUDIT log can attribute
3955
4230
  # tool calls accurately.
3956
4231
  def auth_context
4232
+ # While the session is unresolved the identity is the token's
4233
+ # fingerprint (never the token), and the context is not memoized so
4234
+ # it picks up the user id once resolution succeeds.
4235
+ if session_scope_unresolved?
4236
+ return { type: :session_token, using_master_key: false,
4237
+ identity: "session:#{Digest::SHA256.hexdigest(@session_token.to_s)[0, 8]}" }
4238
+ end
3957
4239
  @auth_context ||= if @session_token && !@session_token.to_s.empty?
3958
4240
  { type: :session_token, using_master_key: false,
3959
4241
  identity: @acl_scope&.user_id }
@@ -3974,6 +4256,175 @@ module Parse
3974
4256
 
3975
4257
  private
3976
4258
 
4259
+ public
4260
+
4261
+ # A redacted one-line summary. Never includes the session token, the
4262
+ # client (whose inspect would print its keys), the conversation, or the
4263
+ # last request and response: an agent is often logged or reported by an
4264
+ # error tracker, and the default Ruby inspect dumped all of them.
4265
+ # @return [String]
4266
+ def inspect
4267
+ parts = ["id=#{agent_id}", "permissions=#{@permissions}", "auth=#{auth_context[:type]}"]
4268
+ parts << "scope=unresolved" if session_scope_unresolved?
4269
+ parts << "parent=#{parent_agent_id}" if parent_agent_id
4270
+ parts << "depth=#{agent_depth}" if agent_depth.to_i.positive?
4271
+ parts << "tools_only=#{@tool_filter_only.size}" if @tool_filter_only
4272
+ parts << "tools_except=#{@tool_filter_except.size}" if @tool_filter_except
4273
+ parts << "classes_only=#{@class_filter_only.size}" if @class_filter_only
4274
+ parts << "classes_except=#{@class_filter_except.size}" if @class_filter_except
4275
+ parts << "method_layers=#{method_filter_layers.size}" unless method_filter_layers.empty?
4276
+ parts << "client_mode" if @client_mode
4277
+ "#<Parse::Agent #{parts.join(" ")}>"
4278
+ end
4279
+ alias_method :to_s, :inspect
4280
+
4281
+ # `pp` walks instance variables by default; print the redacted summary.
4282
+ def pretty_print(q)
4283
+ q.text(inspect)
4284
+ end
4285
+
4286
+ # ActiveSupport's default `as_json` serializes every instance variable,
4287
+ # including the session token and the client (with its keys). JSON log
4288
+ # formatters and error trackers call it, so return the same redacted
4289
+ # fields as {#inspect}.
4290
+ # @return [Hash]
4291
+ def as_json(*)
4292
+ {
4293
+ "agent_id" => agent_id,
4294
+ "permissions" => @permissions.to_s,
4295
+ "auth" => auth_context[:type].to_s,
4296
+ "scope_unresolved" => session_scope_unresolved?,
4297
+ "parent_agent_id" => parent_agent_id,
4298
+ "depth" => agent_depth,
4299
+ "client_mode" => @client_mode ? true : false,
4300
+ }.compact
4301
+ end
4302
+
4303
+ # @return [String] the redacted {#as_json} summary as JSON.
4304
+ def to_json(*args)
4305
+ as_json.to_json(*args)
4306
+ end
4307
+
4308
+ # YAML (Psych) serializes instance variables too; emit the redacted
4309
+ # summary instead.
4310
+ def encode_with(coder)
4311
+ as_json.each { |k, v| coder[k] = v }
4312
+ end
4313
+
4314
+ # An agent holds a session token and a client with its keys, so it is
4315
+ # never marshaled. Build a new agent from configuration instead.
4316
+ # @raise [TypeError]
4317
+ def marshal_dump
4318
+ raise TypeError, "Parse::Agent cannot be marshaled"
4319
+ end
4320
+
4321
+ private
4322
+
4323
+ # Seconds a failed lazy resolution is remembered before the next tool
4324
+ # call tries again. Inside the window calls are refused at once instead
4325
+ # of each waiting out a request to an unreachable Parse Server.
4326
+ SCOPE_RETRY_BACKOFF = 5
4327
+
4328
+ # Resolve a session token into a frozen ACL scope. Does network I/O and
4329
+ # touches no agent state, so it is safe to call without the scope lock.
4330
+ # @return [Array(Parse::ACLScope::Resolution, Symbol)] `[scope, nil]` on
4331
+ # success, `[nil, :invalid_session]` when Parse Server rejected the
4332
+ # token, `[nil, :unreachable]` otherwise.
4333
+ def resolve_session_scope(token)
4334
+ opts = { session_token: token, client: @client }.compact
4335
+ [Parse::ACLScope.resolve!(opts, method_name: :agent_init)&.freeze, nil]
4336
+ rescue Parse::Authorization::InvalidSession => e
4337
+ [nil, e.message.to_s.include?("lookup failed") ? :unreachable : :invalid_session]
4338
+ rescue StandardError
4339
+ [nil, :unreachable]
4340
+ end
4341
+
4342
+ # Best-effort resolution at construction. A failure is recorded like a
4343
+ # lazy one, so the first use inside the backoff window is refused at once
4344
+ # instead of paying a second lookup.
4345
+ def eager_session_scope
4346
+ token = @session_token
4347
+ scope, failure = resolve_session_scope(token)
4348
+ record_scope_failure(token, failure) if scope.nil?
4349
+ scope
4350
+ end
4351
+
4352
+ # Resolve a session token whose eager resolution failed. Raises instead
4353
+ # of leaving the scope nil, which every caller reads as master.
4354
+ #
4355
+ # The token is read under the scope lock, the lookup runs without it (so
4356
+ # other threads using the agent are not blocked for a request timeout),
4357
+ # and the result is bound under the lock only if the token is unchanged,
4358
+ # so a concurrent #impersonate never ends up with this token's scope.
4359
+ def ensure_session_scope!
4360
+ # A concurrent #impersonate can change the token while a lookup runs.
4361
+ # The stale result is dropped and the new identity resolved; a few
4362
+ # rounds bound this if impersonate keeps racing.
4363
+ 3.times do
4364
+ token = scope_mutex.synchronize do
4365
+ return @acl_scope unless session_scope_unresolved?
4366
+ raise unresolved_identity_error if scope_failure_blocks?(@session_token)
4367
+ @session_token
4368
+ end
4369
+ resolved, failure = resolve_session_scope(token)
4370
+ bound = scope_mutex.synchronize do
4371
+ if @session_token != token
4372
+ :identity_changed
4373
+ elsif !@acl_scope.nil?
4374
+ # Another thread bound a scope for this token in the meantime.
4375
+ @acl_scope
4376
+ elsif resolved.nil?
4377
+ record_scope_failure(token, failure)
4378
+ raise unresolved_identity_error
4379
+ else
4380
+ @acl_scope = resolved
4381
+ @auth_context = nil
4382
+ @scope_failed_at = nil
4383
+ @scope_failed_token = nil
4384
+ @scope_failure = nil
4385
+ resolved
4386
+ end
4387
+ end
4388
+ return bound unless bound == :identity_changed
4389
+ end
4390
+ raise unresolved_identity_error
4391
+ end
4392
+
4393
+ def record_scope_failure(token, failure)
4394
+ scope_mutex.synchronize do
4395
+ @scope_failed_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
4396
+ @scope_failed_token = token
4397
+ @scope_failure = failure
4398
+ end
4399
+ end
4400
+
4401
+ # Whether a recorded failure for this token refuses the call without a
4402
+ # new lookup: always for a token Parse Server rejected (it will not
4403
+ # become valid), and within the backoff window for an unreachable server.
4404
+ def scope_failure_blocks?(token)
4405
+ return false if @scope_failed_at.nil? || @scope_failed_token != token
4406
+ return true if @scope_failure == :invalid_session
4407
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @scope_failed_at < SCOPE_RETRY_BACKOFF
4408
+ end
4409
+
4410
+ def unresolved_identity_error
4411
+ if @scope_failure == :invalid_session
4412
+ Parse::Agent::UnresolvedIdentity.new(
4413
+ "This agent's session token is invalid or expired, so its permissions " \
4414
+ "cannot be checked. The request was refused; rebuild the agent with a " \
4415
+ "valid session token.",
4416
+ )
4417
+ else
4418
+ Parse::Agent::UnresolvedIdentity.new
4419
+ end
4420
+ end
4421
+
4422
+ # Reentrant: #acl_scope holds it while #ensure_session_scope! takes it
4423
+ # again.
4424
+ def scope_mutex
4425
+ @scope_mutex ||= Monitor.new
4426
+ end
4427
+
3977
4428
  # Keys that should never be logged for security reasons.
3978
4429
  # Includes query-body keys (where, pipeline), credential keys (session_token,
3979
4430
  # password, secret, token, auth_data, authData, recovery_codes, api_key,