Skip to content

Commit 9b5d0f6

Browse files
committed
Release parse-stack v4.2.0: security & agent
Bump to v4.2.0 with extensive security hardening, new MCP agent features, streaming/cancellation support, and quality-of-life fixes. Highlights: - Mass-assignment hardening: constructor filters protected keys by default and adds a trusted: kwarg to allow internal hydration. - Push/audience safety: strict errors for missing audiences, broadcast opt-in, and new error classes to avoid accidental global pushes. - Pipeline / AtlasSearch / LookupRewriter hardening: disallow forensic $expr operators, denylist internal underscore collections in lookups, validate embedded AtlasSearch operators, and strip internal fields from results. - Query DSL robustness: stricter validation/coercion for order, limit, skip, and first to avoid silent misbehavior. - Agent/MCP improvements: per-instance tools/method filters, parent/recursion depth, strict filtering options, tool output schemas, progress reporting, cooperative cancellation, improved MCP protocol handling, pre-auth rate limiting, and richer audit metadata. - Webhooks: exact Content-Type validation, replay protection (dedup LRU + optional HMAC timestamped signatures), and SSRF-safe webhook URL checks for registrations. - Agent tools hardening: prevent builtin shadowing, enforce field allowlists on lookups and exports, and safer serialization/redaction of returned objects. - MFA and role fixes: safer MFA setup/disable flows with authorization checks and prevention of role self-references; clarified role-hierarchy docs and added integration test. - Misc: header redaction fixes for logging, keyword-arg forwarding fixes under Ruby 3, docs and CLI tweaks, and added tests for many new behaviors. This release focuses on closing several fail-open attack surfaces and adding defensive APIs and runtime guards, while improving MCP streaming and operator auditability.
1 parent 6d25557 commit 9b5d0f6

62 files changed

Lines changed: 7634 additions & 295 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

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

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
parse-stack (4.1.2)
4+
parse-stack (4.2.0)
55
activemodel (>= 6.1, < 9)
66
activesupport (>= 6.1, < 9)
77
faraday (~> 2.0)

Rakefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ namespace :mcp do
364364
# /trace — toggle tool-call tracing on/off
365365
# /cost — show running token + USD cost totals
366366
# /history — print conversation history
367-
# /exit — leave the chat (also: /quit, Ctrl-D, empty line)
367+
# /exit — leave the chat (also: /quit, exit, quit, Ctrl-D, empty line)
368368
# -------------------------------------------------------------------------
369369
desc "Conversational CLI: talk to your Parse data via the MCP agent"
370370
task :chat do
@@ -404,7 +404,7 @@ namespace :mcp do
404404
puts " /trace — toggle per-turn tool-call tracing on/off"
405405
puts " /cost — show running token + USD totals (and last turn)"
406406
puts " /history — print the conversation log"
407-
puts " /exit — leave (also /quit, Ctrl-D, empty line)"
407+
puts " /exit — leave (also /quit, exit, quit, Ctrl-D, empty line)"
408408
end
409409

410410
puts "=" * 70
@@ -421,7 +421,7 @@ namespace :mcp do
421421
next if line.empty?
422422

423423
case line
424-
when "/exit", "/quit"
424+
when "/exit", "/quit", "exit", "quit"
425425
break
426426
when "/help"
427427
slash_help.call

docs/mcp_guide.md

Lines changed: 367 additions & 4 deletions
Large diffs are not rendered by default.

lib/parse/agent.rb

Lines changed: 650 additions & 30 deletions
Large diffs are not rendered by default.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# encoding: UTF-8
2+
# frozen_string_literal: true
3+
4+
module Parse
5+
class Agent
6+
# Cooperative cancellation token used by Parse::Agent::MCPDispatcher
7+
# and Parse::Agent::MCPRackApp to signal in-flight tool calls that the
8+
# client wants to stop work.
9+
#
10+
# The token is cooperative — tools must poll `cancelled?` at safe
11+
# checkpoints (tool entry, after each Parse/Mongo roundtrip,
12+
# between chunks). A tool that is blocked inside a synchronous I/O
13+
# call will not observe the cancellation until the I/O returns.
14+
# The Ruby-level `Timeout.timeout` already wrapping every tool call
15+
# remains the hard upper bound on wasted work.
16+
#
17+
# Cancellation is triggered from two paths:
18+
#
19+
# 1. **SSE client disconnect.** `MCPRackApp::SSEBody#close` invokes
20+
# `cancel!(reason: :client_disconnect)` on the token before
21+
# killing the worker thread.
22+
# 2. **`notifications/cancelled` JSON-RPC notification.** A separate
23+
# POST whose `params.requestId` matches an in-flight request
24+
# trips the token associated with that request (after a session
25+
# identity check — see MCPRackApp for details).
26+
#
27+
# @example Polling at a checkpoint
28+
# def my_tool(agent, **)
29+
# return cancelled_result if agent.cancelled?
30+
# data = expensive_io_call
31+
# return cancelled_result if agent.cancelled?
32+
# transform_and_return(data)
33+
# end
34+
#
35+
# @example Operator-facing cancel
36+
# token = Parse::Agent::CancellationToken.new
37+
# agent.cancellation_token = token
38+
# # later, from another thread:
39+
# token.cancel!(reason: :user_requested)
40+
class CancellationToken
41+
# @return [Symbol, String, nil] reason supplied to {#cancel!}, or nil
42+
# if the token has not been cancelled.
43+
attr_reader :reason
44+
45+
def initialize
46+
@cancelled = false
47+
@reason = nil
48+
# Mutex protects the read-modify-write in {#cancel!} so a
49+
# concurrent cancel from notifications/cancelled and client
50+
# disconnect cannot lose a reason or partially update state.
51+
# The hot poll path (#cancelled?) reads the boolean ivar
52+
# directly — atomic on MRI and on each major Ruby
53+
# implementation we ship against.
54+
@mutex = Mutex.new
55+
end
56+
57+
# @return [Boolean] true once {#cancel!} has been called at least once.
58+
def cancelled?
59+
@cancelled
60+
end
61+
62+
# Trip the token. Idempotent — subsequent calls are no-ops and do
63+
# not overwrite the original reason.
64+
#
65+
# @param reason [Symbol, String, nil] short tag identifying the
66+
# trigger (e.g. `:client_disconnect`, `:notifications_cancelled`,
67+
# `:user_requested`).
68+
# @return [Boolean] true if this call actually flipped the state,
69+
# false if the token was already cancelled.
70+
def cancel!(reason: nil)
71+
@mutex.synchronize do
72+
return false if @cancelled
73+
@cancelled = true
74+
@reason = reason
75+
true
76+
end
77+
end
78+
end
79+
end
80+
end

lib/parse/agent/constraint_translator.rb

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,34 @@ def initialize(message, operator: nil)
8282
# Maximum query depth to prevent DoS via deeply nested structures
8383
MAX_QUERY_DEPTH = 8
8484

85+
# NEW-TOOLS-7: cap $regex pattern length. Patterns larger than this
86+
# are rejected before reaching MongoDB. 256 is generous for the
87+
# legitimate analyst-facing patterns the agent surface is designed
88+
# for (prefix anchors, simple character classes) while keeping the
89+
# worst-case backtracking cost on any one pattern bounded.
90+
MAX_REGEX_PATTERN_LENGTH = 256
91+
92+
# Allowed $options flag characters. MongoDB accepts i (case
93+
# insensitive), m (multi-line), x (extended/whitespace-ignored),
94+
# s (dot-all). The dot-all `s` flag is intentionally omitted: it
95+
# makes `.` cross newlines, which extends the search frontier on
96+
# multi-line text fields and amplifies catastrophic-backtracking
97+
# cost for the worst patterns. `imx` covers every real use case
98+
# the agent surface needs.
99+
ALLOWED_REGEX_OPTIONS = "imx"
100+
101+
# Heuristic for nested-quantifier ReDoS patterns (catastrophic
102+
# backtracking). Matches a quantifier (`+` or `*`) INSIDE a
103+
# parenthesized group that is itself followed by a quantifier
104+
# (`+`, `*`, or `?`) — the structural shape that drives
105+
# exponential time on adversarial inputs (`(a+)+`, `(a*)*`,
106+
# `(x|y)+?` are all reachable). Stricter than the audit's
107+
# suggested heuristic, which would false-positive on innocuous
108+
# patterns like `^foo.*bar.*$`. Anchored prefixes without
109+
# nested-quantifier-groups (`^bar(a+)+` is still refused; plain
110+
# `^foo.*` is not).
111+
REDOS_NESTED_QUANTIFIER_RE = /\([^)]*[+*][^)]*\)[+*?]/.freeze
112+
85113
# Translate JSON constraints to Parse query format.
86114
# Validates all operators against the security whitelist.
87115
#
@@ -149,6 +177,9 @@ def translate_hash_value(hash, depth:)
149177
if hash.keys.all? { |k| k.to_s.start_with?("$") }
150178
hash.transform_keys(&:to_s).each_with_object({}) do |(op, val), result|
151179
validate_operator!(op)
180+
# NEW-TOOLS-7: validate $regex / $options operands before
181+
# forwarding to MongoDB.
182+
assert_regex_operand_safe!(op, val) if op == "$regex" || op == "$options"
152183
result[op] = if CROSS_CLASS_OPERATORS.include?(op)
153184
translate_cross_class_value(op, val, depth: depth + 1)
154185
else
@@ -237,6 +268,86 @@ def parse_type?(hash)
237268
%w[Pointer Date File GeoPoint Bytes Polygon Relation].include?(type)
238269
end
239270

271+
# NEW-TOOLS-7: validate $regex / $options operands.
272+
#
273+
# MongoDB's regex engine is PCRE (not RE2), so adversarial patterns
274+
# with nested quantifiers (`(a+)+`, `(a*)*`, `(.|.)+`) cause
275+
# catastrophic backtracking — quadratic-to-exponential matching
276+
# cost per document. The agent surface lacks a per-pattern
277+
# complexity gate at the Mongo level, so refuse the worst shapes
278+
# at the SDK boundary. Three checks:
279+
#
280+
# 1. $regex must be a String. No Hash/Array/Numeric values.
281+
# 2. Pattern length ≤ MAX_REGEX_PATTERN_LENGTH (256 chars).
282+
# 3. Pattern must not match the nested-quantifier heuristic
283+
# (REDOS_NESTED_QUANTIFIER_RE).
284+
#
285+
# For $options:
286+
#
287+
# 1. Must be a String.
288+
# 2. Length ≤ 8 (defensive — real-world usage is 0-3 chars).
289+
# 3. Every character must appear in ALLOWED_REGEX_OPTIONS (imx).
290+
# The `s` (dot-all) flag is intentionally rejected.
291+
#
292+
# @raise [ConstraintSecurityError] on any rule violation.
293+
def assert_regex_operand_safe!(op, val)
294+
if op == "$regex"
295+
unless val.is_a?(String)
296+
raise ConstraintSecurityError.new(
297+
"$regex value must be a String (got #{val.class})",
298+
operator: op,
299+
reason: :invalid_regex,
300+
)
301+
end
302+
if val.length > MAX_REGEX_PATTERN_LENGTH
303+
raise ConstraintSecurityError.new(
304+
"$regex pattern length #{val.length} exceeds " \
305+
"#{MAX_REGEX_PATTERN_LENGTH} character cap. " \
306+
"Narrow the pattern (e.g. anchored prefix `^xyz`) or filter " \
307+
"via a non-regex constraint.",
308+
operator: op,
309+
reason: :regex_too_long,
310+
)
311+
end
312+
if REDOS_NESTED_QUANTIFIER_RE.match?(val)
313+
raise ConstraintSecurityError.new(
314+
"$regex pattern #{val.inspect} contains a nested quantifier " \
315+
"(`(...x+...)+` shape) that can trigger catastrophic " \
316+
"backtracking on MongoDB's PCRE engine. Rewrite the pattern " \
317+
"without nested quantifier groups.",
318+
operator: op,
319+
reason: :regex_redos,
320+
)
321+
end
322+
elsif op == "$options"
323+
unless val.is_a?(String)
324+
raise ConstraintSecurityError.new(
325+
"$options value must be a String (got #{val.class})",
326+
operator: op,
327+
reason: :invalid_regex,
328+
)
329+
end
330+
if val.length > 8
331+
raise ConstraintSecurityError.new(
332+
"$options string is suspiciously long (#{val.length} chars).",
333+
operator: op,
334+
reason: :invalid_regex,
335+
)
336+
end
337+
unrecognized = val.chars.reject { |c| ALLOWED_REGEX_OPTIONS.include?(c) }
338+
unless unrecognized.empty?
339+
raise ConstraintSecurityError.new(
340+
"$options contains disallowed flag(s) " \
341+
"#{unrecognized.uniq.inspect}. Allowed flags: " \
342+
"#{ALLOWED_REGEX_OPTIONS.chars.inspect}. The dot-all " \
343+
"`s` flag is intentionally rejected.",
344+
operator: op,
345+
reason: :invalid_regex,
346+
)
347+
end
348+
end
349+
end
350+
240351
# Validate an operator is allowed (strict whitelist enforcement).
241352
#
242353
# @param op [String] the operator to validate

lib/parse/agent/errors.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,33 @@ def initialize(message = "Unauthorized", reason: nil)
6464
super(message)
6565
end
6666
end
67+
68+
# Raised at construction when an agent built with `parent:` would
69+
# exceed the inherited recursion depth budget. Defends against
70+
# delegate_to_subagent (or any tool that constructs a Parse::Agent
71+
# inside its handler) recursing without bound.
72+
#
73+
# The budget is decremented on every inherited construction; the
74+
# zero-floor agent can still execute its own tools, but constructing
75+
# another sub-agent with `parent: zero_floor_agent` raises this error.
76+
class RecursionLimitExceeded < AgentError
77+
attr_reader :depth
78+
79+
def initialize(message = nil, depth: nil)
80+
@depth = depth
81+
super(message || "Parse::Agent recursion depth exhausted (depth=#{depth.inspect}). " \
82+
"A sub-agent attempted to construct another sub-agent past the " \
83+
"configured recursion_depth: cap.")
84+
end
85+
end
86+
87+
# Raised inside the +call_method+ tool when the resolved
88+
# +ClassName.method_name+ is excluded by the agent instance's
89+
# +methods:+ filter. The execute() rescue maps this to a
90+
# +:tool_filtered+ error_code so consumers can distinguish "the
91+
# filter excluded this method" from "this method isn't declared
92+
# agent-callable" (a Parse::Error) or "the tier doesn't allow it"
93+
# (a +:permission_denied+).
94+
class MethodFiltered < AgentError; end
6795
end
6896
end

lib/parse/agent/mcp_client.rb

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,54 @@ def reset!
244244
@history = []
245245
end
246246

247-
# The conversation message log. Read-only; use `ask` / `reset!` to mutate.
247+
# Replace the conversation history with a previously-saved one. Pairs
248+
# with the `history` reader to persist a session across process
249+
# restarts: stash `client.history` between turns, then call
250+
# `restore_history!(saved)` on a freshly constructed client to resume
251+
# exactly where the previous one left off — without re-billing the
252+
# provider for the original turns.
253+
#
254+
# Accepts the shape `history` produces: an Array of Hashes with
255+
# `:role` and `:content` (Symbol- or String-keyed; normalized to
256+
# Symbol-keyed Strings on entry). Permitted roles are `"user"`,
257+
# `"assistant"`, and `"system"` — the only roles `@history` ever
258+
# carries internally; tool calls live in `Result#transcript`, not in
259+
# the in-memory history. Empty Arrays are allowed (equivalent to
260+
# `reset!`).
261+
#
262+
# @param history [Array<Hash>] the conversation log to install.
263+
# @return [Array<Hash>] the installed history.
264+
# @raise [ArgumentError] when history is not an Array, an entry is
265+
# not a Hash, an entry has no role/content, or a role is outside
266+
# the supported set.
267+
def restore_history!(history)
268+
unless history.is_a?(Array)
269+
raise ArgumentError, "restore_history! expects an Array, got #{history.class}"
270+
end
271+
272+
normalized = history.each_with_index.map do |entry, i|
273+
unless entry.is_a?(Hash)
274+
raise ArgumentError, "restore_history!: entry #{i} is not a Hash (got #{entry.class})"
275+
end
276+
role = entry[:role] || entry["role"]
277+
content = entry[:content] || entry["content"]
278+
if role.to_s.empty?
279+
raise ArgumentError, "restore_history!: entry #{i} is missing :role"
280+
end
281+
unless %w[user assistant system].include?(role.to_s)
282+
raise ArgumentError, "restore_history!: entry #{i} has unsupported role #{role.inspect} (expected user/assistant/system)"
283+
end
284+
if content.nil?
285+
raise ArgumentError, "restore_history!: entry #{i} is missing :content"
286+
end
287+
{ role: role.to_s, content: content.to_s }
288+
end
289+
290+
@history = normalized
291+
end
292+
293+
# The conversation message log. Read-only; use `ask`, `reset!`, or
294+
# `restore_history!` to mutate.
248295
# @return [Array<Hash>]
249296
def history
250297
@history.dup

0 commit comments

Comments
 (0)