Skip to content

Commit e03de00

Browse files
committed
Agent: security fixes, allowlist & new tools
Fixes hidden-class redaction for Parse-on-Mongo pointer storage forms and corrects agent_fields/enriched_schema case-mismatch by resolving allowlist entries through the class field_map; adds denylist for internal Parse fields. Introduces agent_join_fields and keys-on-include auto-projection with truncated_include_fields metadata to limit included row materialization. Adds pointer compaction for aggregate results (with compact_pointers: flag), high-level aggregation helpers (group_by, group_by_date, distinct) with dry_run support, and a lightweight discovery built-in (list_tools) plus tool categories and registry/category filtering. Adds agent_canonical_filter DSL and opt-out per-call, expands get_schema to include richer method/enum/allowlist metadata, provides structured AccessDenied details, and adds a MetadataAudit utility. Tests and docs updated to cover the new behaviors and features.
1 parent 9b5d0f6 commit e03de00

33 files changed

Lines changed: 5969 additions & 167 deletions

CHANGELOG.md

Lines changed: 85 additions & 0 deletions
Large diffs are not rendered by default.

Gemfile.lock

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
parse-stack (4.2.0)
4+
parse-stack (4.2.1)
55
activemodel (>= 6.1, < 9)
66
activesupport (>= 6.1, < 9)
77
faraday (~> 2.0)
@@ -28,9 +28,9 @@ GEM
2828
securerandom (>= 0.3)
2929
tzinfo (~> 2.0, >= 2.0.5)
3030
uri (>= 0.13.1)
31-
ansi (1.5.0)
31+
ansi (1.6.0)
3232
base64 (0.3.0)
33-
bigdecimal (4.1.0)
33+
bigdecimal (4.1.2)
3434
bson (5.2.0)
3535
builder (3.3.0)
3636
coderay (1.1.3)
@@ -43,7 +43,7 @@ GEM
4343
dotenv (3.2.0)
4444
drb (2.2.3)
4545
erb (6.0.4)
46-
faraday (2.14.1)
46+
faraday (2.14.2)
4747
faraday-net_http (>= 2.0, < 3.5)
4848
json
4949
logger
@@ -60,20 +60,20 @@ GEM
6060
prism (>= 1.3.0)
6161
rdoc (>= 4.0.0)
6262
reline (>= 0.4.2)
63-
json (2.19.3)
63+
json (2.19.5)
6464
logger (1.7.0)
6565
method_source (1.1.0)
66-
minitest (6.0.3)
66+
minitest (6.0.6)
6767
drb (~> 2.0)
6868
prism (~> 1.5)
6969
minitest-mock (5.27.0)
70-
minitest-reporters (1.7.1)
70+
minitest-reporters (1.8.0)
7171
ansi
7272
builder
73-
minitest (>= 5.0)
73+
minitest (>= 5.0, < 7)
7474
ruby-progressbar
7575
moneta (1.6.0)
76-
mongo (2.23.0)
76+
mongo (2.24.0)
7777
base64
7878
bson (>= 4.14.1, < 6.0.0)
7979
mustermann (3.1.1)
@@ -87,9 +87,10 @@ GEM
8787
prettyprint
8888
prettyprint (0.2.0)
8989
prism (1.9.0)
90-
pry (0.14.2)
90+
pry (0.16.0)
9191
coderay (~> 1.1)
9292
method_source (~> 1.0)
93+
reline (>= 0.6.0)
9394
psych (5.3.1)
9495
date
9596
stringio
@@ -105,15 +106,15 @@ GEM
105106
rack (>= 3.0.0)
106107
rack-test (2.2.0)
107108
rack (>= 1.3)
108-
rake (13.3.1)
109+
rake (13.4.2)
109110
rdoc (7.2.0)
110111
erb
111112
psych (>= 4.0.0)
112113
tsort
113114
redcarpet (3.6.1)
114115
redis (5.4.1)
115116
redis-client (>= 0.22.0)
116-
redis-client (0.27.0)
117+
redis-client (0.29.0)
117118
connection_pool
118119
reline (0.6.3)
119120
io-console (~> 0.5)
@@ -134,7 +135,7 @@ GEM
134135
concurrent-ruby (~> 1.0)
135136
uri (1.1.1)
136137
webrick (1.9.2)
137-
yard (0.9.42)
138+
yard (0.9.43)
138139

139140
PLATFORMS
140141
aarch64-linux-gnu

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4256,6 +4256,13 @@ result = agent.execute(:get_all_schemas)
42564256
result = agent.execute(:query_class, class_name: "Song", limit: 10)
42574257
result = agent.execute(:count_objects, class_name: "Song", where: { plays: { "$gte" => 1000 } })
42584258

4259+
# High-level aggregation helpers (v4.2.1) — no pipeline authoring needed
4260+
result = agent.execute(:group_by, class_name: "Song", field: "genre",
4261+
sort: "value_desc", limit: 10)
4262+
result = agent.execute(:group_by_date, class_name: "Song", field: "createdAt",
4263+
interval: "day", timezone: "America/New_York")
4264+
result = agent.execute(:distinct, class_name: "Song", field: "artist")
4265+
42594266
# Ask natural language questions (requires LLM endpoint)
42604267
response = agent.ask("How many songs have more than 1000 plays?")
42614268
puts response[:answer]
@@ -4287,6 +4294,12 @@ class Song < Parse::Object
42874294

42884295
property :title, :string, _description: "The song title"
42894296
property :plays, :integer, _description: "Total play count"
4297+
property :is_removed, :boolean
4298+
4299+
# Per-class "valid state" predicate applied by default on every read tool
4300+
# (query_class, count_objects, aggregate). Opt out per-call with
4301+
# `apply_canonical_filter: false`.
4302+
agent_canonical_filter "isRemoved" => { "$ne" => true }
42904303

42914304
# Expose methods with permission levels
42924305
agent_readonly :find_popular, "Find songs with high play counts"

docs/mcp_guide.md

Lines changed: 826 additions & 9 deletions
Large diffs are not rendered by default.

lib/parse/agent.rb

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require_relative "agent/errors"
99
require_relative "agent/metadata_dsl"
1010
require_relative "agent/metadata_registry"
11+
require_relative "agent/metadata_audit"
1112
require_relative "agent/relation_graph"
1213
require_relative "agent/tools"
1314
require_relative "agent/constraint_translator"
@@ -267,6 +268,10 @@ def rack_app(**kwargs, &block)
267268
explain_query
268269
call_method
269270
export_data
271+
group_by
272+
group_by_date
273+
distinct
274+
list_tools
270275
].freeze,
271276
write: %i[
272277
create_object
@@ -1179,7 +1184,8 @@ def execute(tool_name, **kwargs)
11791184
payload[:success] = false
11801185
payload[:error_class] = e.class.name
11811186
payload[:error_code] = :access_denied
1182-
response = error_response(e.message, error_code: :access_denied)
1187+
details = e.respond_to?(:to_details) ? e.to_details : {}
1188+
response = error_response(e.message, error_code: :access_denied, details: details.any? ? details : nil)
11831189

11841190
# Validation errors (e.g. from registered tool handlers or get_objects)
11851191
rescue Parse::Agent::ValidationError => e
@@ -1280,9 +1286,11 @@ def execute(tool_name, **kwargs)
12801286
# Get tool definitions in MCP/OpenAI function calling format
12811287
#
12821288
# @param format [Symbol] the output format (:mcp or :openai)
1289+
# @param category [String, Symbol, nil] optional category filter applied
1290+
# on top of the permission-based allowlist. nil = no filter.
12831291
# @return [Array<Hash>] array of tool definitions
1284-
def tool_definitions(format: :openai)
1285-
Parse::Agent::Tools.definitions(allowed_tools, format: format)
1292+
def tool_definitions(format: :openai, category: nil)
1293+
Parse::Agent::Tools.definitions(allowed_tools, format: format, category: category)
12861294
end
12871295

12881296
# Request options hash for Parse API calls
@@ -2128,7 +2136,7 @@ def append_log(entry)
21282136
@operation_log.shift if @operation_log.size > @max_log_size
21292137
end
21302138

2131-
def error_response(message, error_code: nil, retry_after: nil)
2139+
def error_response(message, error_code: nil, retry_after: nil, details: nil)
21322140
entry = {
21332141
error: message,
21342142
error_code: error_code,
@@ -2138,8 +2146,9 @@ def error_response(message, error_code: nil, retry_after: nil)
21382146
append_log(entry)
21392147

21402148
response = { success: false, error: message }
2141-
response[:error_code] = error_code if error_code
2149+
response[:error_code] = error_code if error_code
21422150
response[:retry_after] = retry_after if retry_after
2151+
response[:details] = details if details.is_a?(Hash) && details.any?
21432152
response
21442153
end
21452154

lib/parse/agent/errors.rb

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,55 @@ def initialize(tool_name, timeout)
3838
# `:access_denied` error_response without leaking the class name to
3939
# the wire beyond the sanitized message the caller used.
4040
class AccessDenied < AgentError
41-
attr_reader :class_name
41+
attr_reader :class_name, :kind, :denied_field, :allowed_fields, :suggested_rewrite
4242

4343
# @param class_name [String, nil] the Parse class being refused. May be
4444
# nil when the denial is not class-scoped (e.g., an env-gate refusal
4545
# triggered by a `call_method` invocation of a :write method).
4646
# @param message [String, nil] optional override for the message. When
4747
# not provided, a default "Class 'X' is not accessible to this agent"
4848
# message is used.
49-
def initialize(class_name = nil, message = nil)
50-
@class_name = class_name.to_s
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
5176
super(message || "Class '#{@class_name}' is not accessible to this agent")
5277
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
5390
end
5491

5592
# Authentication failure for MCP transport adapters. Custom auth blocks

lib/parse/agent/mcp_dispatcher.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,10 +329,20 @@ def self.handle_initialize(params)
329329

330330
# Handle `tools/list`.
331331
#
332+
# Accepts an optional non-standard `category` param (Parse Stack
333+
# extension). Vanilla MCP clients omit it and receive the full
334+
# allowed-tools list unchanged. Clients that know about the
335+
# extension can pass a category string ("schema", "query",
336+
# "aggregate", "mutation", "export", or any custom value) to
337+
# filter the response server-side. Tool descriptors always carry
338+
# `_meta.category` for client-side filtering as well.
339+
#
340+
# @param params [Hash] JSON-RPC params (optional `category`).
332341
# @param agent [Parse::Agent] used to retrieve allowed tool definitions.
333342
# @return [Hash] `{ "tools" => [...] }`
334-
def self.handle_tools_list(_params, agent)
335-
{ "tools" => agent.tool_definitions(format: :mcp) }
343+
def self.handle_tools_list(params, agent)
344+
category = params.is_a?(Hash) ? params["category"] : nil
345+
{ "tools" => agent.tool_definitions(format: :mcp, category: category) }
336346
end
337347
private_class_method :handle_tools_list
338348

0 commit comments

Comments
 (0)