wardstone 0.1.0
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 +7 -0
- data/README.md +182 -0
- data/lib/wardstone/client.rb +336 -0
- data/lib/wardstone/configuration.rb +128 -0
- data/lib/wardstone/errors.rb +69 -0
- data/lib/wardstone/response_object.rb +91 -0
- data/lib/wardstone/version.rb +5 -0
- data/lib/wardstone.rb +7 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 54cfa8194bbad41a36b30d2aff49ddf5c0c61bf3019684170588645978ffb51a
|
|
4
|
+
data.tar.gz: 583f3ed71cde45c6e7fb65ed227966ce563ffc49fe9785376b78a44a3a4784d7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b0f4ab0679dd4cd277b9f23fca6cfde2d7885428c8e4b873ff979129bd4b6532f7aeee0fa89d0d7c434d61ca3782cf2bdd1ff4edbfd0a2555318b9aaa0641296
|
|
7
|
+
data.tar.gz: b2b74e079a56a84b73d6a836cf7e91d00cb7d6ae46f0bbba0de9bc090168ac85b73e9afa63133175ba19f93bc1d09854f092efe05a148b54795347ae63e5093f
|
data/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Wardstone Ruby SDK
|
|
2
|
+
|
|
3
|
+
Ruby SDK for the [Wardstone](https://wardstone.ai) LLM security API. Detect prompt injection, content violations, data leakage, and unknown links in LLM inputs and outputs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "wardstone"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install directly:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install wardstone
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "wardstone"
|
|
23
|
+
|
|
24
|
+
client = Wardstone::Client.new(api_key: "YOUR_API_KEY")
|
|
25
|
+
result = client.detect(text: user_input)
|
|
26
|
+
|
|
27
|
+
if result.risk_bands.prompt_attack.level != "Low Risk"
|
|
28
|
+
puts "Prompt attack detected"
|
|
29
|
+
puts "Risk: #{result.risk_bands.prompt_attack.level}"
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
client = Wardstone::Client.new(
|
|
37
|
+
api_key: "YOUR_API_KEY", # or set WARDSTONE_API_KEY env var
|
|
38
|
+
base_url: "https://wardstone.ai", # default
|
|
39
|
+
timeout: 30, # seconds, default: 30
|
|
40
|
+
max_retries: 2 # default: 2, max: 10
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Environment Variable
|
|
45
|
+
|
|
46
|
+
The API key can be set via the `WARDSTONE_API_KEY` environment variable:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# Will use WARDSTONE_API_KEY from environment
|
|
50
|
+
client = Wardstone::Client.new
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
### Basic Detection
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
result = client.detect(text: "Ignore all previous instructions")
|
|
59
|
+
|
|
60
|
+
result.flagged # true
|
|
61
|
+
result.primary_category # "prompt_attack"
|
|
62
|
+
result.risk_bands.prompt_attack.level # "Severe Risk"
|
|
63
|
+
result.risk_bands.content_violation.level # "Low Risk"
|
|
64
|
+
result.risk_bands.data_leakage.level # "Low Risk"
|
|
65
|
+
result.risk_bands.unknown_links.level # "Low Risk"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Scan Strategies
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# Full scan (check all categories)
|
|
72
|
+
result = client.detect(text: input, scan_strategy: "full-scan")
|
|
73
|
+
|
|
74
|
+
# Early exit (stop at first threat)
|
|
75
|
+
result = client.detect(text: input, scan_strategy: "early-exit")
|
|
76
|
+
|
|
77
|
+
# Smart sample (optimized for long texts)
|
|
78
|
+
result = client.detect(text: input, scan_strategy: "smart-sample")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Raw Scores
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
result = client.detect(text: input, include_raw_scores: true)
|
|
85
|
+
if result.raw_scores
|
|
86
|
+
result.raw_scores.categories.prompt_attack # 0.95
|
|
87
|
+
result.raw_scores.categories.content_violation # 0.01
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Rate Limit Info
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
result = client.detect(text: input)
|
|
95
|
+
result.rate_limit.limit # 1000
|
|
96
|
+
result.rate_limit.remaining # 999
|
|
97
|
+
result.rate_limit.reset # 1700000000
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Response
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"flagged": true,
|
|
105
|
+
"risk_bands": {
|
|
106
|
+
"content_violation": { "level": "Low Risk" },
|
|
107
|
+
"prompt_attack": { "level": "Severe Risk" },
|
|
108
|
+
"data_leakage": { "level": "Low Risk" },
|
|
109
|
+
"unknown_links": { "level": "Low Risk" }
|
|
110
|
+
},
|
|
111
|
+
"primary_category": "prompt_attack",
|
|
112
|
+
"subcategories": {
|
|
113
|
+
"content_violation": { "triggered": [] },
|
|
114
|
+
"data_leakage": { "triggered": [] }
|
|
115
|
+
},
|
|
116
|
+
"unknown_links": {
|
|
117
|
+
"flagged": false,
|
|
118
|
+
"unknown_count": 0,
|
|
119
|
+
"known_count": 0,
|
|
120
|
+
"total_urls": 0,
|
|
121
|
+
"unknown_domains": []
|
|
122
|
+
},
|
|
123
|
+
"processing": {
|
|
124
|
+
"inference_ms": 28,
|
|
125
|
+
"input_length": 62,
|
|
126
|
+
"scan_strategy": "early-exit"
|
|
127
|
+
},
|
|
128
|
+
"rate_limit": {
|
|
129
|
+
"limit": 100000,
|
|
130
|
+
"remaining": 99999,
|
|
131
|
+
"reset": 2592000
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Error Handling
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
begin
|
|
140
|
+
result = client.detect(text: input)
|
|
141
|
+
rescue Wardstone::AuthenticationError => e
|
|
142
|
+
# Invalid or missing API key (401)
|
|
143
|
+
rescue Wardstone::BadRequestError => e
|
|
144
|
+
# Invalid request (400)
|
|
145
|
+
e.max_length # available for text_too_long errors
|
|
146
|
+
rescue Wardstone::PermissionError => e
|
|
147
|
+
# Feature not available on plan (403)
|
|
148
|
+
rescue Wardstone::RateLimitError => e
|
|
149
|
+
# Quota exceeded (429)
|
|
150
|
+
e.retry_after # seconds to wait
|
|
151
|
+
rescue Wardstone::InternalServerError => e
|
|
152
|
+
# Server error (500)
|
|
153
|
+
rescue Wardstone::TimeoutError => e
|
|
154
|
+
# Request timed out
|
|
155
|
+
rescue Wardstone::ConnectionError => e
|
|
156
|
+
# Network failure
|
|
157
|
+
rescue Wardstone::Error => e
|
|
158
|
+
# Catch-all for any Wardstone error
|
|
159
|
+
e.status # HTTP status code (nil for network errors)
|
|
160
|
+
e.code # Machine-readable error code
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Risk Levels
|
|
165
|
+
|
|
166
|
+
Each category returns one of four risk levels:
|
|
167
|
+
|
|
168
|
+
- `"Low Risk"` - No threat detected
|
|
169
|
+
- `"Some Risk"` - Minor concern
|
|
170
|
+
- `"High Risk"` - Significant threat
|
|
171
|
+
- `"Severe Risk"` - Critical threat, action recommended
|
|
172
|
+
|
|
173
|
+
## Requirements
|
|
174
|
+
|
|
175
|
+
- Ruby >= 3.0
|
|
176
|
+
- Zero runtime dependencies (stdlib only)
|
|
177
|
+
|
|
178
|
+
## Links
|
|
179
|
+
|
|
180
|
+
- [Documentation](https://wardstone.ai/docs)
|
|
181
|
+
- [API Reference](https://wardstone.ai/docs)
|
|
182
|
+
- [Support](mailto:jack@wardstone.ai)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require_relative "configuration"
|
|
8
|
+
require_relative "response_object"
|
|
9
|
+
|
|
10
|
+
module Wardstone
|
|
11
|
+
REQUIRED_RESPONSE_KEYS = %w[flagged risk_bands subcategories unknown_links processing].freeze
|
|
12
|
+
REQUIRED_RISK_BAND_KEYS = %w[content_violation prompt_attack data_leakage unknown_links].freeze
|
|
13
|
+
|
|
14
|
+
class Client
|
|
15
|
+
def initialize(api_key: nil, base_url: nil, timeout: nil, max_retries: nil)
|
|
16
|
+
@api_key = Wardstone.resolve_api_key(api_key)
|
|
17
|
+
@base_url = Wardstone.validate_base_url(base_url || DEFAULT_BASE_URL)
|
|
18
|
+
@timeout = timeout || DEFAULT_TIMEOUT
|
|
19
|
+
|
|
20
|
+
unless @timeout.is_a?(Numeric) && @timeout.positive? && @timeout.finite?
|
|
21
|
+
raise Error, "timeout must be a finite positive number."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@max_retries = max_retries || DEFAULT_MAX_RETRIES
|
|
25
|
+
unless @max_retries.is_a?(Integer) && @max_retries >= 0 && @max_retries <= MAX_MAX_RETRIES
|
|
26
|
+
raise Error, "max_retries must be an integer between 0 and #{MAX_MAX_RETRIES}."
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Analyze text for security threats including prompt injection,
|
|
31
|
+
# content violations, data leakage, and unknown links.
|
|
32
|
+
def detect(text:, scan_strategy: nil, include_raw_scores: nil)
|
|
33
|
+
Wardstone.validate_text(text)
|
|
34
|
+
Wardstone.validate_scan_strategy(scan_strategy)
|
|
35
|
+
Wardstone.validate_include_raw_scores(include_raw_scores)
|
|
36
|
+
|
|
37
|
+
body = { "text" => text }
|
|
38
|
+
body["scan_strategy"] = scan_strategy unless scan_strategy.nil?
|
|
39
|
+
body["include_raw_scores"] = include_raw_scores unless include_raw_scores.nil?
|
|
40
|
+
|
|
41
|
+
data, headers = request("/api/detect", body)
|
|
42
|
+
validate_detect_response(data)
|
|
43
|
+
result_hash = data.merge("rate_limit" => parse_rate_limit(headers))
|
|
44
|
+
ResponseObject.new(result_hash)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<Wardstone::Client base_url=#{@base_url.inspect}>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
alias_method :to_s, :inspect
|
|
52
|
+
|
|
53
|
+
# Prevent accidental serialization that could expose the API key.
|
|
54
|
+
def marshal_dump
|
|
55
|
+
raise TypeError, "Wardstone::Client cannot be marshaled"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def marshal_load(_data)
|
|
59
|
+
raise TypeError, "Wardstone::Client cannot be marshaled"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Hide @api_key from introspection.
|
|
63
|
+
def instance_variables
|
|
64
|
+
super - [:@api_key]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def request(path, body)
|
|
70
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
71
|
+
(0..@max_retries).each do |attempt|
|
|
72
|
+
begin
|
|
73
|
+
data, headers, status = execute_request(uri, body)
|
|
74
|
+
|
|
75
|
+
if status >= 200 && status < 300
|
|
76
|
+
return parse_success(data, headers)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
retryable = status == 429 || status >= 500
|
|
80
|
+
|
|
81
|
+
if retryable && attempt < @max_retries
|
|
82
|
+
delay = retry_delay(attempt, headers)
|
|
83
|
+
sleep(delay)
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
raise_for_status(status, data, headers)
|
|
88
|
+
rescue Error
|
|
89
|
+
raise
|
|
90
|
+
rescue ::Net::OpenTimeout, ::Net::ReadTimeout, ::Net::WriteTimeout
|
|
91
|
+
raise TimeoutError, "Request timed out after #{@timeout}s"
|
|
92
|
+
rescue ::Errno::ECONNREFUSED
|
|
93
|
+
raise ConnectionError, "Connection refused"
|
|
94
|
+
rescue ::Errno::ECONNRESET
|
|
95
|
+
raise ConnectionError, "Connection reset"
|
|
96
|
+
rescue ::Errno::EHOSTUNREACH
|
|
97
|
+
raise ConnectionError, "Host unreachable"
|
|
98
|
+
rescue ::SocketError => e
|
|
99
|
+
msg = e.message.match?(/getaddrinfo|nodename|servname/i) ? "DNS lookup failed" : "Socket error"
|
|
100
|
+
raise ConnectionError, msg
|
|
101
|
+
rescue ::OpenSSL::SSL::SSLError => e
|
|
102
|
+
msg = e.message.match?(/certificate/i) ? "TLS certificate error" : "TLS connection error"
|
|
103
|
+
raise ConnectionError, msg
|
|
104
|
+
rescue ::IOError
|
|
105
|
+
raise ConnectionError, "Connection failed"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
raise Error, "Unexpected retry exhaustion"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def execute_request(uri, body)
|
|
113
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
114
|
+
http.use_ssl = (uri.scheme == "https")
|
|
115
|
+
if http.use_ssl?
|
|
116
|
+
http.min_version = :TLS1_2
|
|
117
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
118
|
+
end
|
|
119
|
+
http.open_timeout = @timeout
|
|
120
|
+
http.read_timeout = @timeout
|
|
121
|
+
http.write_timeout = @timeout
|
|
122
|
+
http.max_retries = 0
|
|
123
|
+
|
|
124
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
125
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
126
|
+
req["Content-Type"] = "application/json"
|
|
127
|
+
req["User-Agent"] = USER_AGENT
|
|
128
|
+
|
|
129
|
+
req.body = JSON.generate(body)
|
|
130
|
+
|
|
131
|
+
status = nil
|
|
132
|
+
response_body = nil
|
|
133
|
+
response_obj = nil
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
http.request(req) do |response|
|
|
137
|
+
response_obj = response
|
|
138
|
+
status = response.code.to_i
|
|
139
|
+
|
|
140
|
+
# Block redirects (any 3xx)
|
|
141
|
+
if response.is_a?(Net::HTTPRedirection) || (status >= 300 && status < 400)
|
|
142
|
+
raise ConnectionError,
|
|
143
|
+
"Request was redirected. Automatic redirects are disabled for security."
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Check Content-Length before reading body
|
|
147
|
+
is_success = status >= 200 && status < 300
|
|
148
|
+
max_bytes = is_success ? MAX_RESPONSE_BYTES : MAX_ERROR_BODY_BYTES
|
|
149
|
+
content_length = response["Content-Length"]
|
|
150
|
+
if content_length
|
|
151
|
+
size = Integer(content_length) rescue nil
|
|
152
|
+
if size && size > max_bytes
|
|
153
|
+
if is_success
|
|
154
|
+
raise Error,
|
|
155
|
+
"Response body too large (#{size} bytes). Maximum: #{MAX_RESPONSE_BYTES} bytes."
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Read body incrementally with size cap
|
|
161
|
+
response_body = read_body_with_limit(response, max_bytes, is_success)
|
|
162
|
+
end
|
|
163
|
+
ensure
|
|
164
|
+
http.finish if http.started?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Validate Content-Type for success responses
|
|
168
|
+
if status >= 200 && status < 300
|
|
169
|
+
content_type = response_obj["Content-Type"]
|
|
170
|
+
unless content_type && content_type.include?("application/json")
|
|
171
|
+
ct_display = content_type ? content_type[0, 200] : nil
|
|
172
|
+
raise Error, "Unexpected Content-Type: #{ct_display.inspect}. Expected application/json."
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
data = begin
|
|
177
|
+
JSON.parse(response_body)
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
if status >= 200 && status < 300
|
|
180
|
+
raise Error, "Invalid API response: expected JSON."
|
|
181
|
+
end
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
[data, response_obj, status]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def read_body_with_limit(response, max_bytes, is_success)
|
|
189
|
+
chunks = []
|
|
190
|
+
total = 0
|
|
191
|
+
|
|
192
|
+
response.read_body do |chunk|
|
|
193
|
+
total += chunk.bytesize
|
|
194
|
+
if total > max_bytes
|
|
195
|
+
if is_success
|
|
196
|
+
raise Error,
|
|
197
|
+
"Response body too large (>#{MAX_RESPONSE_BYTES} bytes). Maximum: #{MAX_RESPONSE_BYTES} bytes."
|
|
198
|
+
end
|
|
199
|
+
# For error bodies, truncate silently (force binary to avoid multibyte split)
|
|
200
|
+
remaining = max_bytes - (total - chunk.bytesize)
|
|
201
|
+
chunks << chunk.b.byteslice(0, remaining) if remaining > 0
|
|
202
|
+
break
|
|
203
|
+
end
|
|
204
|
+
chunks << chunk
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
chunks.join
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def validate_detect_response(data)
|
|
211
|
+
REQUIRED_RESPONSE_KEYS.each do |key|
|
|
212
|
+
unless data.key?(key)
|
|
213
|
+
raise Error, "Invalid API response: missing '#{key}' field."
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
unless data["flagged"] == true || data["flagged"] == false
|
|
218
|
+
raise Error, "Invalid API response: missing or invalid 'flagged' field."
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
risk_bands = data["risk_bands"]
|
|
222
|
+
unless risk_bands.is_a?(Hash)
|
|
223
|
+
raise Error, "Invalid API response: missing or invalid 'risk_bands' field."
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
REQUIRED_RISK_BAND_KEYS.each do |key|
|
|
227
|
+
band = risk_bands[key]
|
|
228
|
+
unless band.is_a?(Hash) && band.key?("level") && band["level"].is_a?(String)
|
|
229
|
+
raise Error, "Invalid API response: missing risk_bands.#{key}.level."
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Validate subcategories structure
|
|
234
|
+
subcategories = data["subcategories"]
|
|
235
|
+
unless subcategories.is_a?(Hash)
|
|
236
|
+
raise Error, "Invalid API response: missing or invalid 'subcategories' field."
|
|
237
|
+
end
|
|
238
|
+
%w[content_violation data_leakage].each do |key|
|
|
239
|
+
sub = subcategories[key]
|
|
240
|
+
unless sub.is_a?(Hash)
|
|
241
|
+
raise Error, "Invalid API response: missing subcategories.#{key}."
|
|
242
|
+
end
|
|
243
|
+
unless sub.key?("triggered") && sub["triggered"].is_a?(Array)
|
|
244
|
+
raise Error, "Invalid API response: subcategories.#{key}.triggered must be an array."
|
|
245
|
+
end
|
|
246
|
+
unless sub["triggered"].all? { |t| t.is_a?(String) }
|
|
247
|
+
raise Error, "Invalid API response: subcategories.#{key}.triggered must contain only strings."
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Validate unknown_links structure
|
|
252
|
+
unknown_links = data["unknown_links"]
|
|
253
|
+
unless unknown_links.is_a?(Hash) && (unknown_links["flagged"] == true || unknown_links["flagged"] == false)
|
|
254
|
+
raise Error, "Invalid API response: missing unknown_links.flagged."
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Validate processing structure
|
|
258
|
+
processing = data["processing"]
|
|
259
|
+
unless processing.is_a?(Hash) && processing.key?("inference_ms") && processing["inference_ms"].is_a?(Numeric)
|
|
260
|
+
raise Error, "Invalid API response: missing or invalid processing.inference_ms."
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def parse_success(data, headers)
|
|
265
|
+
unless data.is_a?(Hash)
|
|
266
|
+
raise Error, "Invalid API response: expected a JSON object."
|
|
267
|
+
end
|
|
268
|
+
[data, headers]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def raise_for_status(status, data, headers)
|
|
272
|
+
raw_message = (data.is_a?(Hash) && data["message"]) || "Request failed"
|
|
273
|
+
message = Wardstone.sanitize_message(raw_message)
|
|
274
|
+
raw_code = data.is_a?(Hash) ? data["error"] : nil
|
|
275
|
+
error_code = raw_code.is_a?(String) ? Wardstone.sanitize_message(raw_code) : nil
|
|
276
|
+
|
|
277
|
+
case status
|
|
278
|
+
when 400
|
|
279
|
+
raise BadRequestError.new(
|
|
280
|
+
message,
|
|
281
|
+
code: error_code || "bad_request",
|
|
282
|
+
max_length: data.is_a?(Hash) ? data["maxLength"] : nil
|
|
283
|
+
)
|
|
284
|
+
when 401
|
|
285
|
+
raise AuthenticationError, message
|
|
286
|
+
when 403
|
|
287
|
+
raise PermissionError, message
|
|
288
|
+
when 429
|
|
289
|
+
retry_after = headers["Retry-After"]
|
|
290
|
+
retry_val = parse_retry_after(retry_after)
|
|
291
|
+
raise RateLimitError.new(message, retry_after: retry_val)
|
|
292
|
+
else
|
|
293
|
+
if status >= 500
|
|
294
|
+
raise InternalServerError, message
|
|
295
|
+
end
|
|
296
|
+
raise Error.new(message, status: status, code: error_code)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def retry_delay(attempt, headers)
|
|
301
|
+
if headers
|
|
302
|
+
retry_after = headers["Retry-After"]
|
|
303
|
+
if retry_after
|
|
304
|
+
val = parse_retry_after(retry_after)
|
|
305
|
+
if val && val > 0
|
|
306
|
+
return [val, MAX_RETRY_DELAY].min
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
base = [0.5 * (2**attempt), 8.0].min
|
|
311
|
+
base * (0.5 + rand * 0.5)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def parse_retry_after(value)
|
|
315
|
+
return nil if value.nil?
|
|
316
|
+
Float(value)
|
|
317
|
+
rescue ArgumentError, TypeError
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def parse_rate_limit(headers)
|
|
322
|
+
{
|
|
323
|
+
"limit" => safe_int(headers["X-RateLimit-Limit"]),
|
|
324
|
+
"remaining" => safe_int(headers["X-RateLimit-Remaining"]),
|
|
325
|
+
"reset" => safe_int(headers["X-RateLimit-Reset"])
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def safe_int(val)
|
|
330
|
+
return 0 if val.nil?
|
|
331
|
+
Integer(val)
|
|
332
|
+
rescue ArgumentError, TypeError
|
|
333
|
+
0
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Wardstone
|
|
8
|
+
DEFAULT_BASE_URL = "https://wardstone.ai"
|
|
9
|
+
DEFAULT_TIMEOUT = 30
|
|
10
|
+
DEFAULT_MAX_RETRIES = 2
|
|
11
|
+
MAX_MAX_RETRIES = 10
|
|
12
|
+
MAX_RETRY_DELAY = 60.0
|
|
13
|
+
MAX_RESPONSE_BYTES = 10_485_760 # 10 MB
|
|
14
|
+
MAX_ERROR_BODY_BYTES = 65_536 # 64 KB
|
|
15
|
+
MAX_TEXT_LENGTH = 8_000_000
|
|
16
|
+
MAX_ERROR_MESSAGE_LENGTH = 1000
|
|
17
|
+
MIN_API_KEY_LENGTH = 8
|
|
18
|
+
|
|
19
|
+
VALID_SCAN_STRATEGIES = %w[early-exit full-scan smart-sample].freeze
|
|
20
|
+
LOCALHOST_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
|
|
21
|
+
|
|
22
|
+
USER_AGENT = "wardstone-ruby/#{VERSION}"
|
|
23
|
+
|
|
24
|
+
# All control characters including \t \n \r (matches Node/Python SDKs)
|
|
25
|
+
CONTROL_CHARS_RE = /[\x00-\x1F\x7F]/
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def resolve_api_key(api_key)
|
|
30
|
+
raw = api_key || ENV["WARDSTONE_API_KEY"]
|
|
31
|
+
if raw.nil? || raw.strip.empty?
|
|
32
|
+
raise AuthenticationError,
|
|
33
|
+
"API key is required. Pass it via the api_key option or set the WARDSTONE_API_KEY environment variable."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
key = raw.strip
|
|
37
|
+
if key.length < MIN_API_KEY_LENGTH
|
|
38
|
+
raise AuthenticationError,
|
|
39
|
+
"API key is too short (minimum #{MIN_API_KEY_LENGTH} characters). " \
|
|
40
|
+
"Check that you are using a valid Wardstone API key."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
key
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_base_url(url)
|
|
47
|
+
trimmed = url.sub(/\/+$/, "")
|
|
48
|
+
uri = URI.parse(trimmed)
|
|
49
|
+
|
|
50
|
+
safe_url = url.length > 200 ? url[0, 200] + "..." : url
|
|
51
|
+
|
|
52
|
+
unless %w[https http].include?(uri.scheme)
|
|
53
|
+
raise Error, "Invalid base_url scheme \"#{uri.scheme}\". Only https and http are supported."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless uri.host && !uri.host.empty?
|
|
57
|
+
raise Error, "Invalid base_url: \"#{safe_url}\" has no hostname."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if uri.host.match?(CONTROL_CHARS_RE) || uri.host.include?("\x00")
|
|
61
|
+
raise Error, "Invalid base_url: hostname contains invalid characters."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if uri.userinfo
|
|
65
|
+
raise Error, "base_url must not contain credentials (user:pass@host)."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if uri.query
|
|
69
|
+
raise Error, "base_url must not contain a query string."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if uri.fragment
|
|
73
|
+
raise Error, "base_url must not contain a fragment."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if uri.scheme == "http" && !LOCALHOST_HOSTS.include?(uri.host)
|
|
77
|
+
raise Error,
|
|
78
|
+
"Insecure base_url: HTTP is only allowed for localhost. Use HTTPS for remote hosts."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
trimmed
|
|
82
|
+
rescue URI::InvalidURIError
|
|
83
|
+
safe_url_fallback = url.length > 200 ? url[0, 200] + "..." : url
|
|
84
|
+
raise Error, "Invalid base_url: \"#{safe_url_fallback}\" is not a valid URL."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_text(text)
|
|
88
|
+
if !text.is_a?(String) || text.empty?
|
|
89
|
+
raise BadRequestError.new("text must be a non-empty string.", code: "invalid_input")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if text.length > MAX_TEXT_LENGTH
|
|
93
|
+
raise BadRequestError.new(
|
|
94
|
+
"text exceeds maximum length of #{MAX_TEXT_LENGTH} characters.",
|
|
95
|
+
code: "text_too_long",
|
|
96
|
+
max_length: MAX_TEXT_LENGTH
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_scan_strategy(strategy)
|
|
102
|
+
return if strategy.nil?
|
|
103
|
+
|
|
104
|
+
unless VALID_SCAN_STRATEGIES.include?(strategy)
|
|
105
|
+
raise BadRequestError.new(
|
|
106
|
+
"Invalid scan_strategy. Must be one of: #{VALID_SCAN_STRATEGIES.join(", ")}.",
|
|
107
|
+
code: "invalid_input"
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validate_include_raw_scores(value)
|
|
113
|
+
return if value.nil?
|
|
114
|
+
|
|
115
|
+
unless value == true || value == false
|
|
116
|
+
raise BadRequestError.new("include_raw_scores must be a boolean.", code: "invalid_input")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def sanitize_message(msg)
|
|
121
|
+
truncated = if msg.length > MAX_ERROR_MESSAGE_LENGTH
|
|
122
|
+
msg[0, MAX_ERROR_MESSAGE_LENGTH] + "..."
|
|
123
|
+
else
|
|
124
|
+
msg
|
|
125
|
+
end
|
|
126
|
+
truncated.gsub(CONTROL_CHARS_RE, "")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wardstone
|
|
4
|
+
# Base exception for all Wardstone SDK errors.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :status, :code
|
|
7
|
+
|
|
8
|
+
def initialize(message = "An error occurred", status: nil, code: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
@status = status
|
|
11
|
+
@code = code
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raised when the API key is missing or invalid (401).
|
|
16
|
+
class AuthenticationError < Error
|
|
17
|
+
def initialize(message = "Invalid or missing API key")
|
|
18
|
+
super(message, status: 401, code: "authentication_error")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Raised on 400 responses (invalid JSON, missing text, text too long).
|
|
23
|
+
class BadRequestError < Error
|
|
24
|
+
attr_reader :max_length
|
|
25
|
+
|
|
26
|
+
def initialize(message = "Bad request", code: "bad_request", max_length: nil)
|
|
27
|
+
super(message, status: 400, code: code)
|
|
28
|
+
@max_length = max_length
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Raised when a feature is not available on the current plan (403).
|
|
33
|
+
class PermissionError < Error
|
|
34
|
+
def initialize(message = "Permission denied")
|
|
35
|
+
super(message, status: 403, code: "permission_error")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Raised when the monthly quota is exceeded (429).
|
|
40
|
+
class RateLimitError < Error
|
|
41
|
+
attr_reader :retry_after
|
|
42
|
+
|
|
43
|
+
def initialize(message = "Rate limit exceeded", retry_after: nil)
|
|
44
|
+
super(message, status: 429, code: "rate_limit_error")
|
|
45
|
+
@retry_after = retry_after
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Raised on 5xx server errors.
|
|
50
|
+
class InternalServerError < Error
|
|
51
|
+
def initialize(message = "Internal server error")
|
|
52
|
+
super(message, status: 500, code: "internal_server_error")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Raised when the HTTP connection fails.
|
|
57
|
+
class ConnectionError < Error
|
|
58
|
+
def initialize(message = "Connection failed")
|
|
59
|
+
super(message, status: nil, code: "connection_error")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Raised when a request exceeds the configured timeout.
|
|
64
|
+
class TimeoutError < Error
|
|
65
|
+
def initialize(message = "Request timed out")
|
|
66
|
+
super(message, status: nil, code: "timeout_error")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Wardstone
|
|
6
|
+
# Immutable object providing dot-notation access to API response data.
|
|
7
|
+
#
|
|
8
|
+
# Unlike OpenStruct (deprecated in Ruby 3.0+), this class raises NoMethodError
|
|
9
|
+
# on unknown keys instead of silently returning nil.
|
|
10
|
+
class ResponseObject
|
|
11
|
+
def initialize(data)
|
|
12
|
+
@data = deep_convert(data)
|
|
13
|
+
@data.freeze
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def method_missing(name, *args)
|
|
18
|
+
key = name.to_s
|
|
19
|
+
if @data.key?(key)
|
|
20
|
+
raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" unless args.empty?
|
|
21
|
+
@data[key]
|
|
22
|
+
else
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def respond_to_missing?(name, include_private = false)
|
|
28
|
+
@data.key?(name.to_s) || super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
deep_unconvert(@data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_json(*args)
|
|
36
|
+
to_h.to_json(*args)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def inspect
|
|
40
|
+
"#<Wardstone::ResponseObject #{to_h.inspect}>"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ==(other)
|
|
44
|
+
return to_h == other.to_h if other.is_a?(ResponseObject)
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def [](key)
|
|
49
|
+
@data[key.to_s]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def deep_convert(obj)
|
|
55
|
+
case obj
|
|
56
|
+
when Hash
|
|
57
|
+
obj.each_with_object({}) do |(k, v), hash|
|
|
58
|
+
hash[k.to_s] = wrap(v)
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
obj
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def wrap(obj)
|
|
66
|
+
case obj
|
|
67
|
+
when Hash
|
|
68
|
+
ResponseObject.new(obj)
|
|
69
|
+
when Array
|
|
70
|
+
obj.map { |v| wrap(v) }.freeze
|
|
71
|
+
else
|
|
72
|
+
obj.frozen? ? obj : obj.dup.freeze
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def deep_unconvert(obj)
|
|
77
|
+
case obj
|
|
78
|
+
when ResponseObject
|
|
79
|
+
obj.to_h
|
|
80
|
+
when Hash
|
|
81
|
+
obj.each_with_object({}) do |(k, v), hash|
|
|
82
|
+
hash[k] = deep_unconvert(v)
|
|
83
|
+
end
|
|
84
|
+
when Array
|
|
85
|
+
obj.map { |v| deep_unconvert(v) }
|
|
86
|
+
else
|
|
87
|
+
obj
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/wardstone.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: wardstone
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Wardstone
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: webmock
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.0'
|
|
54
|
+
description: Detect prompt injection, content violations, data leakage, and unknown
|
|
55
|
+
links in LLM inputs and outputs.
|
|
56
|
+
email:
|
|
57
|
+
- jack@wardstone.ai
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- README.md
|
|
63
|
+
- lib/wardstone.rb
|
|
64
|
+
- lib/wardstone/client.rb
|
|
65
|
+
- lib/wardstone/configuration.rb
|
|
66
|
+
- lib/wardstone/errors.rb
|
|
67
|
+
- lib/wardstone/response_object.rb
|
|
68
|
+
- lib/wardstone/version.rb
|
|
69
|
+
homepage: https://wardstone.ai
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
metadata:
|
|
73
|
+
homepage_uri: https://wardstone.ai
|
|
74
|
+
source_code_uri: https://github.com/Wardstone-AI/wardstone-ruby
|
|
75
|
+
documentation_uri: https://wardstone.ai/docs
|
|
76
|
+
rubygems_mfa_required: 'true'
|
|
77
|
+
rdoc_options: []
|
|
78
|
+
require_paths:
|
|
79
|
+
- lib
|
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: 3.0.0
|
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
requirements: []
|
|
91
|
+
rubygems_version: 3.6.9
|
|
92
|
+
specification_version: 4
|
|
93
|
+
summary: Ruby SDK for the Wardstone LLM security API
|
|
94
|
+
test_files: []
|