parse-stack-next 5.7.1 → 5.7.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7cb6a2c813f0a1b2d624852680651ed289f7aac282fa24e246b28d0ed9e69383
4
- data.tar.gz: a3984ae30555af28eadfdc19fa936103bb252bad332b0ddb8ead163277ae4bde
3
+ metadata.gz: e4017efda309de14faf52b86e26274cc097ee029d282a975f0144e324ac68250
4
+ data.tar.gz: e46cbf422955acc46ef7ceff8348a564712a9109051511dfec1bd2dce96356f0
5
5
  SHA512:
6
- metadata.gz: d19428000255676b8a5f10ee0087bf3b6d21216c499f56d895d1ee50a646985a4297b725807b2b19878a8576415c8e1551471d34fa236e9bf90170e6e13abed5
7
- data.tar.gz: 5db3159d3da214300df9e21760ae871f070f6a79d961fe9e082b5160dd0bd2c07cba5d64656791b961ab2c347e9ebf5151eb607fcf0a3768587a3c382476000e
6
+ metadata.gz: 1cee46e278c9ad77fab0d4f2869636c623b43e9e1b7d2ea0ea77f92be9ad8f6631bb6e5f92421997f92d42ba73470db659486595fe0993a50de93140eb6051fb
7
+ data.tar.gz: 36322b4f93132dd277476d999fc71511324d5a726979896ae08e4f6aea31d3aa4454fd41910a1c7758d229c9ff1000cb43de12ffaf8f795403e1ab7cf96f71ef
data/CHANGELOG.md CHANGED
@@ -1,5 +1,168 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.7.3
4
+
5
+ #### Stored values can no longer drive the operator's terminal
6
+
7
+ - **NEW**: `Parse::TerminalSafe` is a canonical sanitizer for untrusted text
8
+ that is about to be written to a terminal, a log record, or an IRB `inspect`
9
+ line. `Parse::TerminalSafe.sanitize(str)` escapes ESC, BEL, backspace,
10
+ carriage return, the remaining C0 controls, DEL, the C1 controls (the 8-bit
11
+ CSI/OSC/DCS introducers, which a sanitizer that only looks for `0x1B`
12
+ misses), the zero-width characters, and the Unicode bidirectional overrides
13
+ and isolates. Tabs and newlines are preserved.
14
+ `Parse::TerminalSafe.sanitize_line(str)` escapes newlines and the Unicode
15
+ line and paragraph separators as well, for text interpolated into a single
16
+ log record. Control characters are escaped rather than deleted, so an
17
+ operator can still see that something tried. Non-UTF-8 and invalid-encoding
18
+ input is coerced first, so the sanitizer never raises on a binary response
19
+ body.
20
+ - **FIXED**: Values read back from Parse Server reached the terminal with their
21
+ control bytes intact. A row whose field contained an OSC 52 sequence could
22
+ write an attacker-chosen payload into the operator's system clipboard, and
23
+ CSI and carriage-return sequences could clear the screen or overwrite lines
24
+ the operator had already read, so what was displayed was not what was stored.
25
+ Every such path now renders through `Parse::TerminalSafe`: the conversational
26
+ agent's answer and tool trace (`Parse::Agent::MCPClient::Result#to_s` and
27
+ `#inspect`, which run merely by evaluating `mcp.ask(...)` in IRB), the
28
+ request/response bodies and header values written by
29
+ `Parse::Middleware::Logging` and by the separate `Parse.logging = true`
30
+ printer in `Parse::Middleware::BodyBuilder`, the REST error text in logged
31
+ error summaries and in `Parse::Client`'s warning path, `Parse::Query`'s error
32
+ and explain warnings, the webhook request, payload, response, handler-error,
33
+ and afterSave-callback lines, and the event and handler-error lines emitted
34
+ by `Parse.watch`. Sanitization applies to rendering only: `result.text`,
35
+ `object.title`, and the parsed response body keep their exact bytes, so a
36
+ caller writing to a non-terminal surface is unaffected.
37
+ - **FIXED**: The LLM provider failure paths in `Parse::Agent::MCPClient`
38
+ interpolated the raw provider response body into the exception message, and a
39
+ malformed success body raised a `JSON::ParserError` quoting the offending
40
+ bytes verbatim. IRB prints both raw, so a hostile or compromised LLM endpoint
41
+ could still land control sequences on the terminal through the failure path.
42
+ Both are escaped now, and the quoted body is capped.
43
+ - **FIXED**: Untrusted text interpolated into a log record could contain a raw
44
+ newline and forge a second, attacker-authored log entry. Log records now use
45
+ the newline-escaping form, and the escape is applied before the body-length
46
+ cap so a truncated record stays on one line too.
47
+ - **CHANGED**: `rake mcp:chat` escapes the answer, the tool-call trace, the
48
+ `/history` and `/compact` output, and error messages before printing them.
49
+
50
+ #### `parse-console --url` no longer trusts the document it fetches
51
+
52
+ - **BREAKING**: `parse-console --url` copied every key in the fetched JSON
53
+ document into the process environment, letting whoever served or tampered
54
+ with that document set arbitrary environment variables for the console
55
+ process, including ones the console never reads but Ruby, OpenSSL, or a
56
+ later `require` does. Only `PARSE_SERVER_URL`,
57
+ `PARSE_SERVER_APPLICATION_ID`, `PARSE_APP_ID`, `PARSE_SERVER_REST_API_KEY`,
58
+ `PARSE_API_KEY`, `PARSE_SERVER_MASTER_KEY`, and `PARSE_MASTER_KEY` are
59
+ copied now, and each value must be a string. **Migration:** a remote config
60
+ that carried additional variables must set them in the shell instead.
61
+ - **FIXED**: `parse-console --url` parsed the fetched document with
62
+ `JSON.load`, which honors `json_class` additions and will instantiate
63
+ arbitrary already-loaded classes from the document. It uses `JSON.parse` now.
64
+ - **CHANGED**: `parse-console --url` refuses plaintext HTTP unless the host is
65
+ loopback. The document carries the master key, so over plaintext anyone on
66
+ the path reads it and can substitute a server URL of their choosing. The
67
+ check runs against `URI#hostname`, so an IPv6 loopback literal and an
68
+ uppercase host both resolve correctly.
69
+ - **FIXED**: `parse-console --url` fetches the document with a streaming
70
+ request under a 1 MiB cap, and revalidates the scheme and host on every
71
+ redirect hop (bounded at five). The previous open-uri call buffered the
72
+ entire response before any read limit applied, and followed redirects itself,
73
+ so a permitted loopback URL could bounce to arbitrary plaintext HTTP on the
74
+ public internet without the scheme check running again.
75
+ - **FIXED**: `parse-console` echoed the supplied URL before validating it, and
76
+ printed the (possibly remotely supplied) server URL and application ID
77
+ verbatim after connecting. All three are escaped now, as is the error output
78
+ from the fetch path, which can quote the fetched bytes.
79
+
80
+ ### 5.7.2
81
+
82
+ #### `between` accepts Ruby Range values
83
+
84
+ - **NEW**: The `between` constraint now accepts a Ruby `Range` in addition to
85
+ a 2-element array, so `Person.where(:age.between => 5..25)` and
86
+ `Record.where(:date.between => 5.days.ago...2.days.ago)` work directly. An
87
+ inclusive range (`..`) maps its upper bound to `$lte`, matching the existing
88
+ array form, while an exclusive range (`...`) maps it to `$lt` instead.
89
+ Beginless (`..25`) and endless (`5..`) ranges are also supported and
90
+ constrain only the side that is present, so `Person.where(:age.between =>
91
+ 18..)` compiles to `{"$gte" => 18}` with no upper bound. The array form is
92
+ unchanged, and both forms produce identical output for the same bounds.
93
+
94
+ #### `Query#where_not_between` for the negated form of a range
95
+
96
+ - **NEW**: `Query#where_not_between(field, value)` adds the logical negation
97
+ of `between`: `Person.query.where_not_between(:age, 5..25)` compiles to
98
+ `age < 5 OR age > 25`, accepting the same Range and 2-element Array forms
99
+ as `between` (exclusive ranges flip the upper side to `$gte`, and a
100
+ beginless or endless Range negates to a single one-sided comparison with
101
+ no `$or` needed). It is not available as a `field.not_between => value`
102
+ symbol constraint: a range's negation is inherently an `$or` of two
103
+ comparisons, and only one `$or` group can be safely merged into a
104
+ compiled query, so a symbol constraint that unilaterally emitted one
105
+ could silently collide with an existing `$or` from `or_where`/`|`.
106
+ `where_not_between` instead composes the negation the way
107
+ `Parse::Query.and` already does, so it correctly nests inside a query's
108
+ other `.where` conditions instead of replacing them, and raises
109
+ `ArgumentError` if the query already has an `$or` group rather than
110
+ silently dropping part of it.
111
+
112
+ #### `embed_image` forwards a presigned URL when the source file has one
113
+
114
+ - **FIXED**: `embed_image` always sent the source file's bare `file.url` to
115
+ the embedding provider (or to the SDK's own `:bytes`-mode downloader). On a
116
+ private-bucket file adapter (S3/GCS configured with `presignedUrl: true`),
117
+ `file.url` is the canonical URL with its signature stripped, so the
118
+ provider's fetch (or the SDK's download) got a 403 instead of the image.
119
+ `Parse::File` already captures the signed variant in `file.presigned_url`
120
+ whenever Parse Server returns one, but `embed_image` never read it.
121
+ Recompute now forwards `file.presigned_url` when it is present and not yet
122
+ expired, and falls back to the bare URL otherwise, for both `source: :url`
123
+ and `source: :bytes`. The stored digest is still keyed on the bare
124
+ canonical URL, so a save that only rotates the file's signature does not
125
+ trigger a needless re-embed. The validity check ignores
126
+ `presigned_url_valid?`'s default 60-second safety buffer (meant for a
127
+ browser render, not an immediate server-side fetch), since on a
128
+ private-bucket adapter the fallback URL is not fetchable at all and would
129
+ otherwise 403 for the last minute of every signature's life.
130
+ `Parse::Embeddings::ImageFetch::FetchedImage#url` now stores the
131
+ query-stripped URL rather than the presigned one, since a live signature
132
+ has no reason to survive into that value object's `#inspect` output.
133
+ `source: :url` mode can now forward a presigned URL to the embedding
134
+ provider under the same `Parse::Embeddings.trust_provider_url_fetch`
135
+ consent already required to forward any URL; operators relying on private
136
+ buckets should confirm the provider's egress handling covers
137
+ credential-bearing URLs, not just public ones.
138
+
139
+ #### `Query#get` now resolves aliased `parse_class` names correctly
140
+
141
+ - **FIXED**: `Query#get` looked up the target class with a raw
142
+ `Object.const_get(@table)`, which only worked when the Parse class name
143
+ matched the Ruby constant name exactly. A model that renames its table via
144
+ `parse_class "SomeOtherName"` was never found by this lookup, so `get`
145
+ silently fell back to a generic `Parse::Object`/`Parse::Pointer` instead of
146
+ hydrating the declared model. `Query#get` now passes the table name through
147
+ to `Parse::Object.build` as a string, letting it run its own
148
+ `Parse::Model.find_class` resolution, which already understands
149
+ `parse_class` aliasing.
150
+
151
+ #### `_safe_warn` now writes through a configured logger
152
+
153
+ - **FIXED**: Internal warnings for authentication, timeout, and cloud-code
154
+ errors (`Parse::Client._safe_warn`) always wrote to STDERR, even when an
155
+ app had configured `Parse.logger = Rails.logger` (or any other logger) for
156
+ the rest of its Parse request/response logging. These warnings now route
157
+ through `Parse::Middleware::Logging.logger` when one is configured, so they
158
+ land in the same place as the app's other logs; STDERR remains the fallback
159
+ when no logger is configured, matching prior behavior. Every call site
160
+ raises the corresponding typed `Parse::Error` immediately after this
161
+ warning, so a configured logger that itself raises (a closed handle, a
162
+ full disk, a remote-aggregator client erroring on a socket) now falls back
163
+ to STDERR rather than propagating in place of the real error and masking
164
+ it.
165
+
3
166
  ### 5.7.1
4
167
 
5
168
  #### Cache-invalidation webhooks no longer break every application hook for the same trigger
data/bin/parse-console CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  require 'optparse'
4
4
  require 'json'
5
- require 'open-uri'
5
+ require 'net/http'
6
+ require 'uri'
6
7
  require 'active_support'
7
8
  require 'active_support/core_ext'
9
+ require 'parse/terminal_safe'
8
10
 
9
11
  DEFAULT_CONFIG_FILE = 'config.json'
10
12
  DEFAULT_CONFIG_CONTENTS = {
@@ -18,6 +20,88 @@ DEFAULT_CONFIG_CONTENTS = {
18
20
  }]
19
21
  }.freeze
20
22
 
23
+ # Only these keys are copied out of a remote config document and into the
24
+ # process environment. The loader used to copy every key it was handed, which
25
+ # let whoever served (or tampered with) the document set arbitrary env vars for
26
+ # the console process, including ones the console never reads but Ruby,
27
+ # OpenSSL, or a later `require` does.
28
+ REMOTE_CONFIG_ENV_ALLOWLIST = %w[
29
+ PARSE_SERVER_URL
30
+ PARSE_SERVER_APPLICATION_ID PARSE_APP_ID
31
+ PARSE_SERVER_REST_API_KEY PARSE_API_KEY
32
+ PARSE_SERVER_MASTER_KEY PARSE_MASTER_KEY
33
+ ].freeze
34
+
35
+ # A remote config carries the master key. Over plaintext HTTP anyone on the path
36
+ # reads it and can substitute a server URL of their choosing, so require TLS
37
+ # except when pointing at the loopback interface.
38
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
39
+
40
+ REMOTE_CONFIG_MAX_BYTES = 1_048_576
41
+ REMOTE_CONFIG_MAX_REDIRECTS = 5
42
+
43
+ # SEC-20: never hand a user-supplied string to bare Kernel#open, where
44
+ # `open("|command")` executes a subprocess. Parse an explicit URI and require an
45
+ # HTTP(S) scheme instead.
46
+ def validate_config_uri!(uri)
47
+ unless uri.is_a?(URI::HTTP) # URI::HTTPS < URI::HTTP, so this admits both
48
+ raise "Refusing to load config from a non-HTTP(S) URL: #{uri.to_s.inspect}"
49
+ end
50
+ # `hostname` (not `host`) so an IPv6 literal arrives as "::1" rather than
51
+ # "[::1]"; downcased so "LOCALHOST" is recognized too.
52
+ unless uri.is_a?(URI::HTTPS) || LOOPBACK_HOSTS.include?(uri.hostname.to_s.downcase)
53
+ raise "Refusing to fetch credentials over plaintext HTTP: #{uri.to_s.inspect}. " \
54
+ "Use https, or a loopback host for local testing."
55
+ end
56
+ uri
57
+ end
58
+
59
+ # Fetch a remote config document, streaming it under a hard byte cap and
60
+ # revalidating every redirect hop.
61
+ #
62
+ # Both properties are the reason this is hand-rolled rather than an open-uri
63
+ # one-liner. open-uri buffers the whole response before yielding the IO, so a
64
+ # read cap on the returned handle limits only what is read back out of a body
65
+ # that was already downloaded in full; and it follows redirects itself, so a
66
+ # permitted `http://localhost/...` could bounce to arbitrary plaintext HTTP on
67
+ # the public internet without the scheme check ever running again.
68
+ def fetch_remote_config_body(url)
69
+ uri = validate_config_uri!(URI.parse(url))
70
+ redirects = 0
71
+
72
+ loop do
73
+ body = nil
74
+ Net::HTTP.start(uri.hostname, uri.port,
75
+ use_ssl: uri.is_a?(URI::HTTPS),
76
+ open_timeout: 10, read_timeout: 30) do |http|
77
+ http.request(Net::HTTP::Get.new(uri)) do |res|
78
+ case res
79
+ when Net::HTTPRedirection
80
+ location = res['location'].to_s
81
+ raise "Redirect with no Location header." if location.empty?
82
+ redirects += 1
83
+ if redirects > REMOTE_CONFIG_MAX_REDIRECTS
84
+ raise "Too many redirects (limit #{REMOTE_CONFIG_MAX_REDIRECTS})."
85
+ end
86
+ uri = validate_config_uri!(URI.join(uri.to_s, location))
87
+ when Net::HTTPSuccess
88
+ buffer = +''
89
+ res.read_body do |chunk|
90
+ buffer << chunk
91
+ if buffer.bytesize > REMOTE_CONFIG_MAX_BYTES
92
+ raise "Config exceeds #{REMOTE_CONFIG_MAX_BYTES} bytes; refusing to buffer more."
93
+ end
94
+ end
95
+ body = buffer
96
+ else
97
+ raise "Config fetch failed: HTTP #{res.code}."
98
+ end
99
+ end
100
+ end
101
+ return body if body
102
+ end
103
+ end
104
+
21
105
  opts = { verbose: false, pry: false }
22
106
  opt_parser = OptionParser.new do |o|
23
107
 
@@ -73,26 +157,33 @@ opt_parser = OptionParser.new do |o|
73
157
  end
74
158
 
75
159
  end
76
- o.on('--url URL', 'Load the env config from a url.') do |url|
160
+ o.on('--url URL', 'Load the env config from an https url.') do |url|
77
161
  begin
78
- puts "Loading config: #{url}"
79
- # SEC-20: do NOT pass a user-supplied string to bare Kernel#open
80
- # `open("|command")` executes a subprocess. Parse an explicit URI and
81
- # require an HTTP(S) scheme, then use open-uri's URI::HTTP#open (a real
82
- # network fetch), never the Kernel form.
83
- uri = URI.parse(url)
84
- unless uri.is_a?(URI::HTTP) # URI::HTTPS < URI::HTTP, so this admits both
85
- raise "Refusing to load config from a non-HTTP(S) URL: #{url.inspect}"
86
- end
87
- json = JSON.load(uri.open)
162
+ # Echo the URL only in escaped form. It is operator-supplied but not yet
163
+ # validated at this point, and a pasted URL is exactly the kind of string
164
+ # that carries a control sequence.
165
+ puts "Loading config: #{Parse::TerminalSafe.sanitize_line(url)}"
166
+ # JSON.parse, never JSON.load: `load` honors `json_class` additions and
167
+ # will instantiate arbitrary loaded classes from the document.
168
+ json = JSON.parse(fetch_remote_config_body(url))
88
169
  raise "Contents not a JSON hash." unless json.is_a?(Hash)
89
- json.each { |k,v| ENV[k.upcase] = v }
170
+ json.each do |k, v|
171
+ key = k.to_s.upcase
172
+ next unless REMOTE_CONFIG_ENV_ALLOWLIST.include?(key)
173
+ unless v.is_a?(String)
174
+ raise "Config key #{key} must be a string, got #{v.class}."
175
+ end
176
+ ENV[key] = v
177
+ end
90
178
  opts[:server_url] ||= ENV['PARSE_SERVER_URL']
91
179
  opts[:app_id] ||= ENV['PARSE_SERVER_APPLICATION_ID'] || ENV['PARSE_APP_ID']
92
180
  opts[:api_key] ||= ENV['PARSE_SERVER_REST_API_KEY'] || ENV['PARSE_API_KEY']
93
181
  opts[:master_key] ||= ENV['PARSE_SERVER_MASTER_KEY'] || ENV['PARSE_MASTER_KEY']
94
182
  rescue Exception => e
95
- $stderr.puts "Error: Invalid JSON format for #{url} (#{e})"
183
+ # The message can quote the fetched document, so escape it: this is the
184
+ # one place where remote bytes reach the operator's terminal.
185
+ $stderr.puts "Error: Invalid JSON format for #{Parse::TerminalSafe.sanitize_line(url)} " \
186
+ "(#{Parse::TerminalSafe.sanitize_line(e.message)})"
96
187
  exit 1
97
188
  end
98
189
  end
@@ -120,8 +211,9 @@ Parse.setup server_url: opts[:server_url],
120
211
  api_key: opts[:api_key],
121
212
  master_key: opts[:master_key]
122
213
  Parse.logging = true if opts[:verbose]
123
- puts "Server : #{Parse.client.server_url}"
124
- puts "App Id : #{Parse.client.app_id}"
214
+ # Both of these can have come from a remote config document, so escape them.
215
+ puts "Server : #{Parse::TerminalSafe.sanitize_line(Parse.client.server_url)}"
216
+ puts "App Id : #{Parse::TerminalSafe.sanitize_line(Parse.client.app_id)}"
125
217
  puts "Master : #{Parse.client.master_key.present?}"
126
218
 
127
219
  if Parse.client.master_key.present?
@@ -208,9 +208,12 @@ def chat_loop(backend: :anthropic)
208
208
  chunks = retrieve(agent, question)
209
209
  answer = ChatAnswerer.public_send(backend, question, chunks)
210
210
 
211
- puts "\n#{answer}\n"
211
+ # The answer is model output grounded in retrieved rows, and the object ids
212
+ # come from the database. Both are untrusted for terminal purposes: escape
213
+ # control sequences before writing them to a TTY.
214
+ puts "\n#{Parse::TerminalSafe.sanitize(answer)}\n"
212
215
  sources = chunks.map { |c| c.dig(:metadata, :object_id) }.uniq.join(", ")
213
- puts " (sources: #{sources})\n\n"
216
+ puts " (sources: #{Parse::TerminalSafe.sanitize_line(sources)})\n\n"
214
217
  end
215
218
  end
216
219
 
@@ -6,6 +6,7 @@ require "uri"
6
6
  require "json"
7
7
  require "securerandom"
8
8
  require_relative "mcp_dispatcher"
9
+ require_relative "../terminal_safe"
9
10
 
10
11
  module Parse
11
12
  class Agent
@@ -65,17 +66,25 @@ module Parse
65
66
  end
66
67
 
67
68
  # Pretty-print for IRB: tool trace, answer, then per-call usage line.
69
+ #
70
+ # Every interpolated part is attacker-influenced. The answer is LLM
71
+ # output that was itself conditioned on tenant rows, and tool arguments
72
+ # can echo stored values. Merely evaluating `mcp.ask(...)` in IRB writes
73
+ # this string to the terminal, so control sequences are escaped here.
74
+ # `text` itself is untouched: callers that render into a non-terminal
75
+ # surface still get the exact bytes.
68
76
  def to_s
69
77
  parts = []
70
78
  if tool_calls.any?
71
79
  parts << "─── tool calls (#{tool_calls.size}) ───"
72
80
  tool_calls.each_with_index do |tc, i|
73
81
  args_str = tc[:arguments].is_a?(Hash) ? tc[:arguments].inspect : tc[:arguments].to_s
74
- parts << " #{i + 1}. #{tc[:name]}(#{args_str})"
82
+ parts << " #{i + 1}. #{Parse::TerminalSafe.sanitize_line(tc[:name])}" \
83
+ "(#{Parse::TerminalSafe.sanitize_line(args_str)})"
75
84
  end
76
85
  end
77
86
  parts << "─── answer ───"
78
- parts << text.to_s
87
+ parts << Parse::TerminalSafe.sanitize(text)
79
88
  parts << "─── usage ───" << " #{usage}" if usage && usage.total_tokens.positive?
80
89
  parts.join("\n")
81
90
  end
@@ -311,6 +320,39 @@ module Parse
311
320
 
312
321
  private
313
322
 
323
+ # Maximum bytes of a provider response body quoted back in an exception.
324
+ LLM_ERROR_BODY_CAP = 2_000
325
+
326
+ # Check the HTTP status and parse the body, raising with a terminal-safe
327
+ # message on either failure.
328
+ #
329
+ # The provider's response body is untrusted output on both paths. An
330
+ # error body is echoed into the exception message, and a malformed
331
+ # success body produces a `JSON::ParserError` whose message quotes the
332
+ # offending bytes verbatim. Either exception is printed raw by IRB and by
333
+ # most logging setups, so an LLM endpoint (or a model repeating what a
334
+ # tenant row told it to say) could otherwise still land control sequences
335
+ # on the operator's terminal through the failure path.
336
+ #
337
+ # @param res [Net::HTTPResponse]
338
+ # @param label [String] provider name used in the message.
339
+ # @return [Hash] the parsed body.
340
+ def parse_llm_response!(res, label)
341
+ body = res.body.to_s
342
+ unless res.code.to_i.between?(200, 299)
343
+ quoted = Parse::TerminalSafe.sanitize_line(body[0, LLM_ERROR_BODY_CAP])
344
+ raise "#{label} failed: HTTP #{res.code} #{quoted}"
345
+ end
346
+
347
+ begin
348
+ JSON.parse(body)
349
+ rescue JSON::ParserError => e
350
+ raise JSON::ParserError,
351
+ "#{label} returned an unparseable body: " \
352
+ "#{Parse::TerminalSafe.sanitize_line(e.message)[0, LLM_ERROR_BODY_CAP]}"
353
+ end
354
+ end
355
+
314
356
  # Fetch the agent's MCP tool catalog and translate it into the LLM's
315
357
  # native function-calling schema. Cached per call (could be memoized
316
358
  # if tool lists grow large, but they're usually small).
@@ -447,11 +489,7 @@ module Parse
447
489
  res = Net::HTTP.start(uri.hostname, uri.port,
448
490
  use_ssl: uri.scheme == "https",
449
491
  read_timeout: @timeout) { |h| h.request(req) }
450
- unless res.code.to_i.between?(200, 299)
451
- raise "LLM call failed: HTTP #{res.code} #{res.body}"
452
- end
453
-
454
- parsed = JSON.parse(res.body)
492
+ parsed = parse_llm_response!(res, "LLM call")
455
493
  msg = parsed.dig("choices", 0, "message") || {}
456
494
  calls = Array(msg["tool_calls"]).map do |tc|
457
495
  args = tc.dig("function", "arguments")
@@ -497,11 +535,7 @@ module Parse
497
535
  res = Net::HTTP.start(uri.hostname, uri.port,
498
536
  use_ssl: uri.scheme == "https",
499
537
  read_timeout: @timeout) { |h| h.request(req) }
500
- unless res.code.to_i.between?(200, 299)
501
- raise "Anthropic call failed: HTTP #{res.code} #{res.body}"
502
- end
503
-
504
- parsed = JSON.parse(res.body)
538
+ parsed = parse_llm_response!(res, "Anthropic call")
505
539
  blocks = Array(parsed["content"])
506
540
  text = blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("\n")
507
541
  calls = blocks.select { |b| b["type"] == "tool_use" }.map do |b|
@@ -191,7 +191,7 @@ module Parse
191
191
 
192
192
  result_hash = dispatch(method, params, agent, id, logger, subscription_manager)
193
193
  { status: result_hash[:status], body: result_hash[:body] }
194
- rescue Parse::Agent::Unauthorized => e
194
+ rescue Parse::Agent::Unauthorized
195
195
  { status: 401, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32001, "Unauthorized") }
196
196
  rescue StandardError => e
197
197
  # Do not leak the exception class name (gem fingerprinting). Server-
@@ -295,7 +295,7 @@ module Parse
295
295
  else
296
296
  { status: 200, body: jsonrpc_envelope(id, result: result) }
297
297
  end
298
- rescue Parse::Agent::Unauthorized => e
298
+ rescue Parse::Agent::Unauthorized
299
299
  { status: 401, body: jsonrpc_error(id, -32001, "Unauthorized") }
300
300
  rescue Parse::Agent::AccessDenied
301
301
  # Class-authorization denial (agent_hidden / classes: allowlist), e.g.
data/lib/parse/agent.rb CHANGED
@@ -2475,7 +2475,6 @@ module Parse
2475
2475
  end
2476
2476
 
2477
2477
  ActiveSupport::Notifications.instrument("parse.agent.tool_call", payload) do
2478
- response = nil
2479
2478
  # Install a fresh embedding accumulator for this tool span. The
2480
2479
  # process-wide "parse.embeddings.embed" subscriber records each
2481
2480
  # embed into it; the ensure below reads + restores it so the
@@ -9,6 +9,7 @@ require "active_support/core_ext"
9
9
  require "active_model/serializers/json"
10
10
  require "json"
11
11
  require "set"
12
+ require_relative "../terminal_safe"
12
13
 
13
14
  module Parse
14
15
 
@@ -317,17 +318,23 @@ module Parse
317
318
  env[:body] = env[:body].to_json
318
319
  end
319
320
 
321
+ # `Parse.logging = true` routes here, so this legacy printer sees the
322
+ # same tenant-controlled bytes the Faraday logging middleware does and
323
+ # needs the same terminal-escape handling.
320
324
  if self.class.logging
321
- puts "[Request #{env.method.upcase}] #{self.class.redact(env[:url].to_s)}"
325
+ puts "[Request #{env.method.upcase}] " \
326
+ "#{Parse::TerminalSafe.sanitize_line(self.class.redact(env[:url].to_s))}"
322
327
  env[:request_headers].each do |k, v|
323
328
  if REDACTED_HEADERS.include?(k.to_s.downcase)
324
- puts "[Header] #{k} : [FILTERED]"
329
+ puts "[Header] #{Parse::TerminalSafe.sanitize_line(k)} : [FILTERED]"
325
330
  else
326
- puts "[Header] #{k} : #{v}"
331
+ puts "[Header] #{Parse::TerminalSafe.sanitize_line(k)} : " \
332
+ "#{Parse::TerminalSafe.sanitize_line(v)}"
327
333
  end
328
334
  end
329
335
 
330
- puts "[Request Body] #{self.class.redact(env[:body].to_s)}"
336
+ puts "[Request Body] " \
337
+ "#{Parse::TerminalSafe.sanitize_line(self.class.redact(env[:body].to_s))}"
331
338
  end
332
339
  @app.call(env).on_complete do |response_env|
333
340
  # on a response, create a new Parse::Response and replace the :body
@@ -335,7 +342,7 @@ module Parse
335
342
  # @todo CHECK FOR HTTP STATUS CODES
336
343
  if self.class.logging
337
344
  puts "[[Response #{response_env[:status]}]] ----------------------------------"
338
- puts self.class.redact(response_env.body.to_s)
345
+ puts Parse::TerminalSafe.sanitize(self.class.redact(response_env.body.to_s))
339
346
  puts "[[Response]] --------------------------------------\n"
340
347
  end
341
348
 
@@ -4,6 +4,7 @@
4
4
  require "faraday"
5
5
  require "logger"
6
6
  require_relative "url_redaction"
7
+ require_relative "../terminal_safe"
7
8
 
8
9
  module Parse
9
10
  module Middleware
@@ -167,7 +168,8 @@ module Parse
167
168
  if Parse::Middleware::BodyBuilder::REDACTED_HEADERS.include?(key.to_s.downcase)
168
169
  logger.debug " [#{prefix} Header] #{key}: [FILTERED]"
169
170
  else
170
- logger.debug " [#{prefix} Header] #{key}: #{value}"
171
+ logger.debug " [#{prefix} Header] #{Parse::TerminalSafe.sanitize_line(key)}: " \
172
+ "#{Parse::TerminalSafe.sanitize_line(value)}"
171
173
  end
172
174
  end
173
175
  end
@@ -196,6 +198,13 @@ module Parse
196
198
  # so truncation can't split a token across the boundary and slip past.
197
199
  content = Parse::Middleware::BodyBuilder.redact(content)
198
200
 
201
+ # Request and response bodies carry tenant-stored values verbatim. A
202
+ # stored ESC sequence would execute against the operator's terminal the
203
+ # moment they tail the log, so escape control characters and newlines
204
+ # here. Done BEFORE the length cap so the record is one line whether or
205
+ # not it was truncated.
206
+ content = Parse::TerminalSafe.sanitize_line(content)
207
+
199
208
  if content.length > max_length
200
209
  logger.debug " [#{prefix} Body] #{content[0...max_length]}... (truncated, #{content.length} total)"
201
210
  elsif content.length > 0
@@ -216,15 +225,21 @@ module Parse
216
225
  end
217
226
  end
218
227
 
228
+ # The error text is whatever the server (or a stored value echoed back by
229
+ # the server) says, so it is untrusted. Escape terminal control sequences
230
+ # AND newlines: this is interpolated into a one-line log record, and a
231
+ # raw LF would let the text forge a second, attacker-authored entry.
219
232
  def error_summary(response_env)
220
233
  body = response_env[:body]
221
- if body.is_a?(Parse::Response) && body.error?
222
- "#{body.code}: #{body.error}"
223
- elsif body.is_a?(Hash)
224
- body["error"] || body[:error] || "Unknown error"
225
- else
226
- "HTTP #{response_env[:status]}"
227
- end
234
+ summary =
235
+ if body.is_a?(Parse::Response) && body.error?
236
+ "#{body.code}: #{body.error}"
237
+ elsif body.is_a?(Hash)
238
+ body["error"] || body[:error] || "Unknown error"
239
+ else
240
+ "HTTP #{response_env[:status]}"
241
+ end
242
+ Parse::TerminalSafe.sanitize_line(summary)
228
243
  end
229
244
 
230
245
  def sanitize_url(url)
@@ -17,12 +17,24 @@ module Parse
17
17
  # @!attribute [rw] body
18
18
  # @return [Hash] the body of this request.
19
19
 
20
- # TODO: Document opts and cache options.
21
-
22
20
  # @!attribute [rw] opts
23
- # @return [Hash] a set of options for this request.
21
+ # @return [Hash] per-request options consumed by {Parse::Client#request}
22
+ # when it builds the HTTP headers for this request. Recognized keys:
23
+ # * `:cache` — `false` sends `Cache-Control: no-cache`; `:write_only`
24
+ # skips the cache read but still writes the response; a `Numeric`
25
+ # overrides the cache expiration (seconds) for this request only.
26
+ # * `:use_master_key` — `false` forces the master key off for this
27
+ # request even if the client has one configured.
28
+ # * `:session_token` — a session token to authenticate this request as
29
+ # a specific user, bypassing the client's default auth context.
30
+ # * `:idempotent` — explicitly enables/disables idempotency-header
31
+ # generation for this request, overriding the class-level defaults.
32
+ # * `:request_id` — a caller-supplied idempotency key; see
33
+ # {.enable_idempotency!}.
24
34
  # @!attribute [rw] cache
25
- # @return [Boolean]
35
+ # @return [Boolean] unused by {Parse::Request} itself; retained as a
36
+ # plain accessor for callers that stash a cache handle or flag
37
+ # directly on the request object rather than through `opts[:cache]`.
26
38
  attr_accessor :method, :path, :body, :headers, :opts, :cache
27
39
 
28
40
  # @!visibility private