Module: Parse::Agent::ConstraintTranslator

Extended by:
ConstraintTranslator
Included in:
ConstraintTranslator
Defined in:
lib/parse/agent/constraint_translator.rb

Overview

The ConstraintTranslator converts JSON-style query constraints (like those from LLM function calls) into Parse REST API format.

It enforces strict security validation:

  • Blocks dangerous operators that allow code execution ($where, $function, etc.)

  • Rejects unknown operators (whitelist-based approach)

  • Limits query depth to prevent DoS attacks

Examples:

Basic translation

ConstraintTranslator.translate({
  "plays" => { "$gte" => 1000 },
  "artist" => "Beatles"
})
# => {"plays" => {"$gte" => 1000}, "artist" => "Beatles"}

Blocked operator raises SecurityError

ConstraintTranslator.translate({ "$where" => "this.a > 1" })
# => raises ConstraintSecurityError

Defined Under Namespace

Classes: ConstraintSecurityError, InvalidOperatorError

Constant Summary collapse

BLOCKED_OPERATORS =

Operators that are BLOCKED - they allow arbitrary code execution These are blocked regardless of permission level

%w[
  $where
  $function
  $accumulator
  $expr
].freeze
ALLOWED_OPERATORS =

Whitelist of allowed Parse query operators

%w[
  $lt $lte $gt $gte $ne $eq
  $in $nin $all $exists
  $regex $options
  $text $search
  $near $nearSphere $geoWithin $geoIntersects
  $centerSphere $box $polygon $geometry
  $maxDistance $maxDistanceInMiles
  $maxDistanceInKilometers $maxDistanceInRadians
  $relatedTo $inQuery $notInQuery
  $containedIn $containsAll
  $select $dontSelect
  $or $and $nor
].freeze
CROSS_CLASS_OPERATORS =

Operators whose value carries an inner sub-query of the shape {className:, where:, key:}. Each must be validated through Tools.assert_class_accessible! so the LLM cannot reach into a hidden class via the sub-query, and the inner where must be recursively re-translated so blocked operators inside it are also caught.

%w[
  $inQuery $notInQuery $select $dontSelect
].freeze
DENIED_WHERE_KEYS =

Field-name keys (non-operator) that are never permitted in a caller-supplied where: constraint, regardless of class or permission level. These are internal Parse Server columns whose presence in a $match filter creates a 1-bit-per-query oracle that can exfiltrate bcrypt hashes, session tokens, or reset tokens character-by-character via count deltas. The list covers:

  • Exact names (lowercased storage form and camelCase API form)

  • A prefix that catches per-provider columns stored as ‘_auth_data_facebook`, `_auth_data_google`, etc.

Mirrored in Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST so the aggregate pipeline path is covered independently (the two modules can be loaded in any order; duplication is intentional).

%w[
  _hashed_password _password_history
  _session_token _sessionToken
  _email_verify_token _perishable_token
  _failed_login_count _account_lockout_expires_at
  _rperm _wperm
  _auth_data
].freeze
DENIED_WHERE_KEY_PREFIXES =

Prefix-based check (catches _auth_data_facebook, _auth_data_google, …).

%w[_auth_data_].freeze
MAX_QUERY_DEPTH =

Maximum query depth to prevent DoS via deeply nested structures

8
MAX_REGEX_PATTERN_LENGTH =

NEW-TOOLS-7: cap $regex pattern length. Patterns larger than this are rejected before reaching MongoDB. 256 is generous for the legitimate analyst-facing patterns the agent surface is designed for (prefix anchors, simple character classes) while keeping the worst-case backtracking cost on any one pattern bounded.

256
ALLOWED_REGEX_OPTIONS =

Allowed $options flag characters. MongoDB accepts i (case insensitive), m (multi-line), x (extended/whitespace-ignored), s (dot-all). The dot-all ‘s` flag is intentionally omitted: it makes `.` cross newlines, which extends the search frontier on multi-line text fields and amplifies catastrophic-backtracking cost for the worst patterns. `imx` covers every real use case the agent surface needs.

"imx"
REDOS_NESTED_QUANTIFIER_RE =

Heuristic for nested-quantifier ReDoS patterns (catastrophic backtracking). Matches a quantifier (‘+` or `*`) INSIDE a parenthesized group that is itself followed by a quantifier (`+`, `*`, or `?`) — the structural shape that drives exponential time on adversarial inputs (`(a+)+`, `(a*)*`, `(x|y)+?` are all reachable). Stricter than the audit’s suggested heuristic, which would false-positive on innocuous patterns like ‘^foo.bar.$`. Anchored prefixes without nested-quantifier-groups (`^bar(a+)+` is still refused; plain `^foo.*` is not).

/\([^)]*[+*][^)]*\)[+*?]/.freeze

Instance Method Summary collapse

Instance Method Details

#translate(constraints, agent = nil) ⇒ Hash

Translate JSON constraints to Parse query format. Validates all operators against the security whitelist.

Parameters:

  • constraints (Hash)

    the query constraints from LLM

  • constraints (Hash)

    the query constraints from LLM

  • agent (Parse::Agent, nil) (defaults to: nil)

    optional agent context for per-agent class-filter enforcement on embedded cross-class operators (‘$inQuery` / `$select`). Passed positionally (not keyword) so a bracket-less Hash literal at the call site — `translate(“key” => val)` — continues to parse as a single positional Hash under Ruby 3+ kwargs separation. Adding a kwarg would have turned the same call into “empty kwargs + missing positional arg.”

Returns:

  • (Hash)

    translated constraints for Parse REST API

Raises:



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/parse/agent/constraint_translator.rb', line 156

def translate(constraints, agent = nil)
  return {} if constraints.nil? || constraints.empty?

  raise InvalidOperatorError.new(
    "Constraints must be a Hash, got #{constraints.class}",
    operator: nil,
  ) unless constraints.is_a?(Hash)

  constraints.transform_keys(&:to_s).each_with_object({}) do |(key, value), result|
    # Check for blocked operators at the root level
    if key.start_with?("$")
      validate_operator!(key)
    end
    # H1 / M1: reject keys that reference internal Parse Server columns.
    # These enable bcrypt-hash and session-token oracle attacks via
    # count deltas even when operators are otherwise clean.
    assert_where_key_permitted!(key)
    result[columnize(key)] = translate_value(value, depth: 0, agent: agent)
  end
end

#valid?(constraints) ⇒ Boolean

Check if constraints are valid without raising.

Parameters:

  • constraints (Hash)

    the query constraints

Returns:

  • (Boolean)

    true if valid, false otherwise



181
182
183
184
185
186
# File 'lib/parse/agent/constraint_translator.rb', line 181

def valid?(constraints)
  translate(constraints)
  true
rescue ConstraintSecurityError, InvalidOperatorError
  false
end