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.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- 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
|