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 +4 -4
- data/CHANGELOG.md +163 -0
- data/bin/parse-console +108 -16
- data/examples/rag_chatbot.rb +5 -2
- data/lib/parse/agent/mcp_client.rb +46 -12
- data/lib/parse/agent/mcp_dispatcher.rb +2 -2
- data/lib/parse/agent.rb +0 -1
- data/lib/parse/client/body_builder.rb +12 -5
- data/lib/parse/client/logging.rb +23 -8
- data/lib/parse/client/request.rb +16 -4
- data/lib/parse/client.rb +34 -4
- data/lib/parse/console.rb +9 -3
- data/lib/parse/embeddings/image_fetch.rb +9 -1
- data/lib/parse/embeddings.rb +17 -4
- data/lib/parse/model/core/embed_managed.rb +41 -4
- data/lib/parse/model/core/querying.rb +0 -4
- data/lib/parse/model/object.rb +0 -1
- data/lib/parse/query/constraints.rb +61 -5
- data/lib/parse/query.rb +83 -8
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +1 -0
- data/lib/parse/terminal_safe.rb +138 -0
- data/lib/parse/webhooks.rb +29 -13
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4017efda309de14faf52b86e26274cc097ee029d282a975f0144e324ac68250
|
|
4
|
+
data.tar.gz: e46cbf422955acc46ef7ceff8348a564712a9109051511dfec1bd2dce96356f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 '
|
|
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
|
|
160
|
+
o.on('--url URL', 'Load the env config from an https url.') do |url|
|
|
77
161
|
begin
|
|
78
|
-
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
puts "
|
|
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?
|
data/examples/rag_chatbot.rb
CHANGED
|
@@ -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
|
-
|
|
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]}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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}]
|
|
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} :
|
|
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]
|
|
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
|
|
data/lib/parse/client/logging.rb
CHANGED
|
@@ -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}:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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)
|
data/lib/parse/client/request.rb
CHANGED
|
@@ -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]
|
|
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
|