parse-stack-next 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,420 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+
6
+ module Parse
7
+ class Agent
8
+ # Developer-facing introspection mixin. Mixed into {Parse::Agent} via
9
+ # `include Describe` so `agent.describe`, `agent.describe_for(class_name)`,
10
+ # and `agent.would_permit?(...)` are instance methods on every agent.
11
+ #
12
+ # SECURITY POSTURE — this is operator-side observability, NOT data exposed
13
+ # to the LLM. The operator wrote every rule the helper echoes back; showing
14
+ # them their own configuration is just transparency. The output is NOT
15
+ # included in any tool response, MCP `tools/list`, or `parse.agent.tool_call`
16
+ # notification payload by default. If a deployment chooses to surface the
17
+ # output (e.g. via a debug HTTP endpoint), it should be auth-gated on the
18
+ # same boundary that authenticates the operator console.
19
+ #
20
+ # The `session_token` value is NEVER returned verbatim. {#auth_descriptor}
21
+ # emits a stable SHA256-truncated fingerprint so two `describe` calls on
22
+ # the same session correlate, but the raw bearer token never leaves the
23
+ # method. Master-key mode is identified by the `:master_key` symbol only.
24
+ module Describe
25
+ # Full introspection Hash for the agent. Lists every layer that gates
26
+ # what the agent can see and do, plus per-class metadata for the
27
+ # classes the agent explicitly references.
28
+ #
29
+ # @param pretty [Boolean] when true, returns a multi-line String
30
+ # formatted for `puts` debugging instead of the structured Hash.
31
+ # The String is generated from the same data the Hash exposes.
32
+ # @return [Hash, String]
33
+ def describe(pretty: false)
34
+ data = describe_hash
35
+ pretty ? describe_pretty(data) : data
36
+ end
37
+
38
+ # Per-class breakdown for a single Parse class. Includes the agent's
39
+ # effective reach for the class (visible? class-filter permitted?
40
+ # canonical filter? per-agent filter? tenant-scoped?) plus the
41
+ # class-level metadata declared via `agent_fields` / `agent_methods` /
42
+ # `agent_large_fields`. Useful when an agent has 30 visible classes
43
+ # and a developer is debugging one specific refusal.
44
+ #
45
+ # @param class_name [String, Symbol, Class] the Parse class to look up
46
+ # @return [Hash] per-class introspection envelope
47
+ def describe_for(class_name)
48
+ cn = if class_name.is_a?(Class) && class_name.respond_to?(:parse_class)
49
+ class_name.parse_class
50
+ else
51
+ class_name.to_s
52
+ end
53
+ {
54
+ class_name: cn,
55
+ accessible: describe_class_accessibility(cn),
56
+ agent_fields: class_field_allowlist(cn),
57
+ agent_canonical_filter: Parse::Agent::MetadataRegistry.canonical_filter(cn),
58
+ per_agent_filter: respond_to?(:filter_for) ? filter_for(cn) : nil,
59
+ tenant_scope: class_tenant_scope(cn),
60
+ large_fields: class_large_fields(cn),
61
+ agent_methods: class_agent_method_names(cn),
62
+ }
63
+ end
64
+
65
+ # Dispatch-gate simulator. Runs every accessibility check that the
66
+ # tool dispatcher would run, without actually invoking the tool.
67
+ # Lets a developer answer "why is this agent refusing this call?"
68
+ # in one line, without parsing the audit payload or tracing through
69
+ # the tool implementation.
70
+ #
71
+ # TRACK-AGENT-8: mirrors the REAL dispatch gates in
72
+ # {Parse::Agent#execute} and {Parse::Agent::Tools.assert_class_accessible!}.
73
+ # The simulator now checks:
74
+ #
75
+ # * tool filter (`tools:` kwarg / `tool_filter_*` sets) and
76
+ # permission-tier membership
77
+ # * env-gate (`PARSE_AGENT_ALLOW_WRITE_TOOLS` /
78
+ # `PARSE_AGENT_ALLOW_RAW_CRUD` for write tools;
79
+ # `PARSE_AGENT_ALLOW_SCHEMA_OPS` /
80
+ # `PARSE_AGENT_ALLOW_RAW_SCHEMA` for schema tools)
81
+ # * `class_name` accessibility, including hidden-class +
82
+ # master-key-except, per-agent class allowlist, AND the
83
+ # CLP `op:` gate (forwarded when an `op:` is supplied)
84
+ # * `master_atlas?` opt-in gate for `atlas_faceted_search`
85
+ # * `method_filtered?` for `call_method` when a
86
+ # `method_name:` is supplied
87
+ #
88
+ # @param tool_name [Symbol] the tool being checked
89
+ # @param class_name [String, Symbol, Class, nil] optional class scope
90
+ # for tools that take a `class_name:` argument
91
+ # @param op [Symbol, nil] optional CLP op (`:find`, `:get`,
92
+ # `:count`, `:create`, `:update`, `:delete`, `:addField`) for
93
+ # class-level CLP checks. When omitted, only the
94
+ # class-visibility gate runs; CLP is not consulted.
95
+ # @param method_name [Symbol, String, nil] optional `agent_method`
96
+ # target for `call_method` simulation
97
+ # @return [Hash] `{allowed: Boolean, reason: Symbol?, denied_at: Symbol?}`
98
+ # `reason` and `denied_at` are populated only when `allowed: false`.
99
+ def would_permit?(tool_name, class_name: nil, op: nil, method_name: nil, **_kwargs)
100
+ tool_sym = tool_name.to_sym
101
+
102
+ # Tool filter — present at the per-instance layer. Preserve
103
+ # the historical `:tool_filtered` reason regardless of whether
104
+ # the denial came from tier or instance filter, since the
105
+ # describe consumer reads it as "this tool will be refused"
106
+ # rather than as the dispatcher's split error_code.
107
+ unless allowed_tools.include?(tool_sym)
108
+ return { allowed: false, reason: :tool_filtered, denied_at: :allowed_tools }
109
+ end
110
+
111
+ # Env-gate for raw CRUD / schema-mutating tools. Mirrors the
112
+ # gate in Parse::Agent#execute at line 1639-1662.
113
+ if Parse::Agent::WRITE_GATED_TOOLS.include?(tool_sym) &&
114
+ !(Parse::Agent.write_tools_enabled? && Parse::Agent.raw_crud_enabled?)
115
+ return { allowed: false, reason: :write_env_gate_disabled,
116
+ denied_at: :write_env_gate }
117
+ end
118
+ if Parse::Agent::SCHEMA_GATED_TOOLS.include?(tool_sym) &&
119
+ !(Parse::Agent.schema_ops_enabled? && Parse::Agent.raw_schema_enabled?)
120
+ return { allowed: false, reason: :schema_env_gate_disabled,
121
+ denied_at: :schema_env_gate }
122
+ end
123
+
124
+ # atlas_faceted_search opt-in (master_atlas: true required —
125
+ # see tools.rb:atlas_faceted_search). Mirrors the explicit
126
+ # opt-in inside the tool body so the simulator doesn't
127
+ # over-report :permitted for a session-bound agent.
128
+ if tool_sym == :atlas_faceted_search &&
129
+ !(respond_to?(:master_atlas?) && master_atlas?)
130
+ return { allowed: false, reason: :master_atlas_required,
131
+ denied_at: :master_atlas_gate }
132
+ end
133
+
134
+ # Class access gate — when the tool takes a class_name argument.
135
+ # Includes CLP `op:` check when the caller supplied one,
136
+ # mirroring assert_class_accessible!'s signature.
137
+ if class_name
138
+ cn = class_name.is_a?(Class) && class_name.respond_to?(:parse_class) ?
139
+ class_name.parse_class : class_name.to_s
140
+ begin
141
+ Parse::Agent::Tools.assert_class_accessible!(cn, agent: self, op: op)
142
+ rescue Parse::Agent::AccessDenied => e
143
+ kind = e.respond_to?(:kind) && e.kind ? e.kind : :access_denied
144
+ return { allowed: false, reason: kind, denied_at: :assert_class_accessible! }
145
+ rescue Parse::Agent::ValidationError
146
+ return { allowed: false, reason: :invalid_argument, denied_at: :assert_class_accessible! }
147
+ end
148
+ end
149
+
150
+ # method_filtered? — mirror the call_method gate at tools.rb:3948.
151
+ # Only fires when the caller supplied a method_name AND the
152
+ # tool is call_method (the method-filter only narrows that tool).
153
+ if tool_sym == :call_method && method_name && class_name
154
+ cn = class_name.is_a?(Class) && class_name.respond_to?(:parse_class) ?
155
+ class_name.parse_class : class_name.to_s
156
+ if respond_to?(:method_filtered?) &&
157
+ method_filtered?(method_name.to_sym, class_name: cn)
158
+ return { allowed: false, reason: :method_filtered,
159
+ denied_at: :method_filtered }
160
+ end
161
+ end
162
+
163
+ { allowed: true }
164
+ end
165
+
166
+ private
167
+
168
+ # The Hash form of describe. Extracted so both describe(:pretty true/false)
169
+ # paths share the same data source.
170
+ def describe_hash
171
+ {
172
+ agent_id: agent_id,
173
+ agent_depth: agent_depth,
174
+ permissions: @permissions,
175
+ auth: auth_descriptor,
176
+ tenant_id: tenant_id,
177
+ classes: filter_descriptor(@class_filter_only, @class_filter_except),
178
+ tools: tools_descriptor,
179
+ methods: filter_descriptor(@method_filter_only, @method_filter_except, transform: ->(s) { s.to_s }),
180
+ filters: per_agent_filters_summary,
181
+ hidden_classes: Parse::Agent::MetadataRegistry.hidden_class_names,
182
+ per_class: per_class_descriptor,
183
+ strict_modes: {
184
+ tool_filter: strict_tool_filter?,
185
+ class_filter: strict_class_filter?,
186
+ },
187
+ correlation_id: @correlation_id,
188
+ }
189
+ end
190
+
191
+ # Auth-context descriptor. Mirrors the agent's #auth_context type
192
+ # so an acl_user / acl_role agent is NOT mis-reported as
193
+ # `:master_key` just because it has an empty session_token.
194
+ # TRACK-AGENT-8: previously this method keyed solely on
195
+ # `@session_token` emptiness, so a scoped (acl_user/acl_role)
196
+ # agent's describe output erroneously claimed master-key
197
+ # posture. Session-token mode emits an 8-character SHA256-
198
+ # truncated fingerprint so two `describe` calls on the same
199
+ # session correlate to the same value without leaking the raw
200
+ # bearer token. Other scoped modes return their type symbol
201
+ # plus an :identity surfaced from auth_context.
202
+ def auth_descriptor
203
+ ctx = auth_context
204
+ case ctx[:type]
205
+ when :session_token
206
+ { mode: :session_token,
207
+ fingerprint: Digest::SHA256.hexdigest(@session_token.to_s)[0, 8] }
208
+ when :acl_user
209
+ { mode: :acl_user, identity: ctx[:identity] }
210
+ when :acl_role
211
+ { mode: :acl_role, identity: ctx[:identity] }
212
+ else
213
+ { mode: :master_key }
214
+ end
215
+ end
216
+
217
+ # Normalize an only/except filter pair into a `{only:, except:}` Hash.
218
+ # `transform:` is applied to each element when emitting — used to coerce
219
+ # the methods filter's mixed Symbol/String entries to a uniform shape.
220
+ def filter_descriptor(only_set, except_set, transform: nil)
221
+ emit = ->(s) {
222
+ return nil unless s
223
+ arr = s.to_a
224
+ arr = arr.map(&transform) if transform
225
+ arr.sort
226
+ }
227
+ { only: emit.call(only_set), except: emit.call(except_set) }
228
+ end
229
+
230
+ def tools_descriptor
231
+ {
232
+ only: @tool_filter_only && @tool_filter_only.to_a.sort,
233
+ except: @tool_filter_except && @tool_filter_except.to_a.sort,
234
+ effective: allowed_tools.sort,
235
+ }
236
+ end
237
+
238
+ def per_agent_filters_summary
239
+ return nil if @filters.nil?
240
+ @filters.each_with_object({}) do |(key, constraint), h|
241
+ h[key.to_s] = constraint.keys.map(&:to_s).sort
242
+ end
243
+ end
244
+
245
+ # Per-class descriptor — emitted only for classes the agent explicitly
246
+ # references (in `classes:`, in `filters:`, or via a tenant-scoped
247
+ # class with a tenant_id binding). Keeps `describe` output bounded;
248
+ # `describe_for(class_name)` is the unbounded lookup for any single
249
+ # class.
250
+ def per_class_descriptor
251
+ names = Set.new
252
+ names.merge(@class_filter_only.to_a) if @class_filter_only
253
+ names.merge(@class_filter_except.to_a) if @class_filter_except
254
+ if @filters
255
+ names.merge(@filters.keys.reject { |k| k == :default }.map(&:to_s))
256
+ end
257
+ return {} if names.empty?
258
+
259
+ names.sort.each_with_object({}) do |cn, h|
260
+ h[cn] = describe_for(cn).reject { |k, _| k == :class_name }
261
+ end
262
+ end
263
+
264
+ # Resolve the agent's accessibility for a single class.
265
+ # Returns one of `:permitted`, `:hidden`, `:class_filter_excluded`,
266
+ # `:hidden_master_key_only`. The values mirror the `denial_kind`
267
+ # discriminators emitted in the audit payload so a developer reading
268
+ # `describe_for` and a SOC consumer reading audit logs see the same
269
+ # vocabulary.
270
+ #
271
+ # TRACK-AGENT-3 / TRACK-AGENT-8 (Bug 1): the master-key exception
272
+ # gate keys on `auth_context[:using_master_key] == true`, NOT on
273
+ # `@session_token` emptiness. An `acl_user` / `acl_role` agent
274
+ # ALSO has an empty session_token but is NOT a master-key agent,
275
+ # so the prior `@session_token.to_s.empty?` heuristic
276
+ # over-reported `:permitted` for scoped agents against an
277
+ # `agent_hidden(except: :master_key)` class — diverging from the
278
+ # real gate at `tools.rb:1063`.
279
+ def describe_class_accessibility(class_name)
280
+ if Parse::Agent::MetadataRegistry.hidden?(class_name)
281
+ except = Parse::Agent::MetadataRegistry.respond_to?(:hidden_exception_for) ?
282
+ Parse::Agent::MetadataRegistry.hidden_exception_for(class_name) : nil
283
+ if except == :master_key && auth_context[:using_master_key] == true
284
+ # Hidden from session-bound / acl_user / acl_role agents but
285
+ # reachable by this master-key agent.
286
+ else
287
+ return :hidden
288
+ end
289
+ end
290
+ if respond_to?(:class_filter_permits?) && !class_filter_permits?(class_name)
291
+ return :class_filter_excluded
292
+ end
293
+ :permitted
294
+ end
295
+
296
+ # Per-class agent_fields allowlist, or nil when none declared. Returns
297
+ # the wire-format field name Array so the output reads identically to
298
+ # the schema-enriched `get_schema` echo.
299
+ def class_field_allowlist(class_name)
300
+ list = Parse::Agent::MetadataRegistry.field_allowlist(class_name)
301
+ list && list.any? ? list.dup : nil
302
+ end
303
+
304
+ # Tenant-scope rule for the class plus the agent's tenant_id binding.
305
+ # Returns `{field:, value:}` when both are set, nil otherwise. This is
306
+ # the actual scope that would apply on a query against this class.
307
+ def class_tenant_scope(class_name)
308
+ return nil if tenant_id.nil?
309
+ rule = Parse::Agent::MetadataRegistry.respond_to?(:tenant_scope_rule) ?
310
+ Parse::Agent::MetadataRegistry.tenant_scope_rule(class_name) : nil
311
+ return nil unless rule
312
+ { field: rule[:field], value: tenant_id }
313
+ end
314
+
315
+ # @agent_large_fields declared at the class level, surfaced via
316
+ # `get_schema`'s `large_field: true` flag. Returns nil when the class
317
+ # has no Ruby model or no declaration.
318
+ def class_large_fields(class_name)
319
+ klass = begin
320
+ Parse::Model.find_class(class_name)
321
+ rescue StandardError
322
+ nil
323
+ end
324
+ return nil unless klass.respond_to?(:agent_large_fields_set)
325
+ list = klass.agent_large_fields_set
326
+ list && list.any? ? list.to_a.sort : nil
327
+ end
328
+
329
+ # Names of `agent_method` declarations on the class, narrowed to the
330
+ # tier the agent can actually call (so describe doesn't mislead by
331
+ # listing :admin methods on a :readonly agent's report).
332
+ def class_agent_method_names(class_name)
333
+ klass = begin
334
+ Parse::Model.find_class(class_name)
335
+ rescue StandardError
336
+ nil
337
+ end
338
+ return nil unless klass.respond_to?(:agent_methods)
339
+ methods = klass.agent_methods
340
+ return nil if methods.nil? || methods.empty?
341
+ callable = methods.select { |_name, meta| agent_can_call_method?(meta) }
342
+ callable.keys.map(&:to_s).sort
343
+ end
344
+
345
+ # Internal — whether the agent's permission tier permits an agent_method
346
+ # whose declared permission tier is `meta[:permission]`. Falls open when
347
+ # the meta hash is missing a permission key (matches the existing
348
+ # `call_method` dispatch default).
349
+ def agent_can_call_method?(meta)
350
+ return true unless meta.is_a?(Hash)
351
+ declared = meta[:permission] || meta["permission"]
352
+ return true if declared.nil?
353
+ PERMISSION_HIERARCHY[@permissions].to_i >= PERMISSION_HIERARCHY[declared.to_sym].to_i
354
+ end
355
+
356
+ # Render the Hash describe-output as a multi-line String for
357
+ # `puts agent.describe(pretty: true)` debugging. Format is read-once,
358
+ # not parseable — Hash + JSON is the structured surface.
359
+ def describe_pretty(data)
360
+ lines = []
361
+ auth = data[:auth]
362
+ auth_line = auth[:mode] == :session_token ?
363
+ "#{auth[:mode]} (fingerprint=#{auth[:fingerprint]})" :
364
+ auth[:mode].to_s
365
+ lines << "Parse::Agent #{data[:agent_id]} (depth=#{data[:agent_depth]}, correlation=#{data[:correlation_id] || "—"})"
366
+ lines << " auth: #{auth_line}"
367
+ lines << " permissions: #{data[:permissions]}"
368
+ lines << " tenant_id: #{data[:tenant_id] || "—"}"
369
+
370
+ if data[:classes][:only] || data[:classes][:except]
371
+ lines << " classes:"
372
+ lines << " only: #{data[:classes][:only].inspect}" if data[:classes][:only]
373
+ lines << " except: #{data[:classes][:except].inspect}" if data[:classes][:except]
374
+ else
375
+ lines << " classes: (no filter — every visible class reachable)"
376
+ end
377
+
378
+ lines << " tools:"
379
+ lines << " only: #{data[:tools][:only].inspect}" if data[:tools][:only]
380
+ lines << " except: #{data[:tools][:except].inspect}" if data[:tools][:except]
381
+ lines << " effective: #{data[:tools][:effective].inspect}"
382
+
383
+ if data[:methods][:only] || data[:methods][:except]
384
+ lines << " methods:"
385
+ lines << " only: #{data[:methods][:only].inspect}" if data[:methods][:only]
386
+ lines << " except: #{data[:methods][:except].inspect}" if data[:methods][:except]
387
+ end
388
+
389
+ if data[:filters]
390
+ lines << " filters:"
391
+ data[:filters].each do |k, fields|
392
+ lines << " #{k}: [#{fields.join(", ")}]"
393
+ end
394
+ end
395
+
396
+ if data[:hidden_classes].any?
397
+ lines << " hidden_classes: #{data[:hidden_classes].inspect}"
398
+ end
399
+
400
+ if data[:per_class].any?
401
+ lines << " per_class:"
402
+ data[:per_class].each do |cn, info|
403
+ lines << " #{cn}:"
404
+ lines << " accessible: #{info[:accessible]}"
405
+ [:agent_fields, :agent_canonical_filter, :per_agent_filter,
406
+ :tenant_scope, :large_fields, :agent_methods].each do |k|
407
+ v = info[k]
408
+ next if v.nil?
409
+ lines << " #{k}: #{v.inspect}"
410
+ end
411
+ end
412
+ end
413
+
414
+ sm = data[:strict_modes]
415
+ lines << " strict_modes: tool_filter=#{sm[:tool_filter]} class_filter=#{sm[:class_filter]}"
416
+ lines.join("\n")
417
+ end
418
+ end
419
+ end
420
+ end
@@ -0,0 +1,133 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # Error hierarchy for agent operations.
7
+ #
8
+ # Defined in a standalone file so the MCP transport layer
9
+ # (Parse::Agent::MCPRackApp, Parse::Agent::MCPDispatcher) can rescue
10
+ # these classes without transitively loading the full Parse::Agent
11
+ # implementation. A downstream Rack mount only needs to know that
12
+ # `raise Parse::Agent::Unauthorized` works.
13
+
14
+ # Base error class for all agent errors
15
+ class AgentError < StandardError; end
16
+
17
+ # Security-related errors (blocked operations, injection attempts).
18
+ # These should NEVER be swallowed - always re-raise.
19
+ class SecurityError < AgentError; end
20
+
21
+ # Validation errors for invalid input
22
+ class ValidationError < AgentError; end
23
+
24
+ # Timeout errors for long-running operations
25
+ class ToolTimeoutError < AgentError
26
+ attr_reader :tool_name, :timeout
27
+
28
+ def initialize(tool_name, timeout)
29
+ @tool_name = tool_name
30
+ @timeout = timeout
31
+ super("Tool '#{tool_name}' timed out after #{timeout} seconds")
32
+ end
33
+ end
34
+
35
+ # Raised by agent tools when a request targets a Parse class that has
36
+ # been marked `agent_hidden` (see Parse::Agent::MetadataDSL). The
37
+ # rescue path in Parse::Agent#execute translates this to a
38
+ # `:access_denied` error_response without leaking the class name to
39
+ # the wire beyond the sanitized message the caller used.
40
+ class AccessDenied < AgentError
41
+ attr_reader :class_name, :kind, :denied_field, :allowed_fields, :suggested_rewrite
42
+
43
+ # @param class_name [String, nil] the Parse class being refused. May be
44
+ # nil when the denial is not class-scoped (e.g., an env-gate refusal
45
+ # triggered by a `call_method` invocation of a :write method).
46
+ # @param message [String, nil] optional override for the message. When
47
+ # not provided, a default "Class 'X' is not accessible to this agent"
48
+ # message is used.
49
+ # @param kind [Symbol, nil] a finer-grained denial subcode. Lets MCP
50
+ # consumers branch on the specific refusal reason without parsing
51
+ # prose. Known values:
52
+ # :hidden_class — target class is `agent_hidden`
53
+ # :field_denied — projection/sort/match/expr field is
54
+ # outside the class's `agent_fields`
55
+ # allowlist
56
+ # :storage_form_field_ref — same as :field_denied but the
57
+ # offending name is the Parse-on-Mongo
58
+ # storage column (`_p_*`); the rewrite
59
+ # hint points at the bare pointer name
60
+ # @param denied_field [String, nil] the offending column / field name
61
+ # when the refusal is field-scoped. Nil for class-scoped denials.
62
+ # @param allowed_fields [Array<String>, nil] the class's effective
63
+ # `agent_fields` allowlist (capped for wire compactness). Nil when
64
+ # the refusal is not field-scoped.
65
+ # @param suggested_rewrite [String, nil] a one-shot rewrite suggestion
66
+ # the caller can apply to fix the request. Currently emitted for
67
+ # storage-form references (e.g., "use `$author` instead of `$_p_author`").
68
+ def initialize(class_name = nil, message = nil,
69
+ kind: nil, denied_field: nil, allowed_fields: nil,
70
+ suggested_rewrite: nil)
71
+ @class_name = class_name.to_s
72
+ @kind = kind
73
+ @denied_field = denied_field
74
+ @allowed_fields = allowed_fields&.map(&:to_s)
75
+ @suggested_rewrite = suggested_rewrite
76
+ super(message || "Class '#{@class_name}' is not accessible to this agent")
77
+ end
78
+
79
+ # Structured details for the error_response payload. Returns a Hash
80
+ # with only the populated keys so the wire envelope doesn't carry
81
+ # unused nil fields.
82
+ def to_details
83
+ {
84
+ kind: kind,
85
+ denied_field: denied_field,
86
+ allowed_fields: allowed_fields,
87
+ suggested_rewrite: suggested_rewrite,
88
+ }.compact
89
+ end
90
+ end
91
+
92
+ # Authentication failure for MCP transport adapters. Custom auth blocks
93
+ # passed to Parse::Agent::MCPRackApp should raise this (or a subclass) to
94
+ # signal an unauthenticated/unauthorized request; the transport layer
95
+ # catches it and renders a sanitized 401 response.
96
+ class Unauthorized < AgentError
97
+ attr_reader :reason
98
+
99
+ def initialize(message = "Unauthorized", reason: nil)
100
+ @reason = reason
101
+ super(message)
102
+ end
103
+ end
104
+
105
+ # Raised at construction when an agent built with `parent:` would
106
+ # exceed the inherited recursion depth budget. Defends against
107
+ # delegate_to_subagent (or any tool that constructs a Parse::Agent
108
+ # inside its handler) recursing without bound.
109
+ #
110
+ # The budget is decremented on every inherited construction; the
111
+ # zero-floor agent can still execute its own tools, but constructing
112
+ # another sub-agent with `parent: zero_floor_agent` raises this error.
113
+ class RecursionLimitExceeded < AgentError
114
+ attr_reader :depth
115
+
116
+ def initialize(message = nil, depth: nil)
117
+ @depth = depth
118
+ super(message || "Parse::Agent recursion depth exhausted (depth=#{depth.inspect}). " \
119
+ "A sub-agent attempted to construct another sub-agent past the " \
120
+ "configured recursion_depth: cap.")
121
+ end
122
+ end
123
+
124
+ # Raised inside the +call_method+ tool when the resolved
125
+ # +ClassName.method_name+ is excluded by the agent instance's
126
+ # +methods:+ filter. The execute() rescue maps this to a
127
+ # +:tool_filtered+ error_code so consumers can distinguish "the
128
+ # filter excluded this method" from "this method isn't declared
129
+ # agent-callable" (a Parse::Error) or "the tier doesn't allow it"
130
+ # (a +:permission_denied+).
131
+ class MethodFiltered < AgentError; end
132
+ end
133
+ end