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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wardstone
4
+ VERSION = "0.1.0"
5
+ end
data/lib/wardstone.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wardstone/version"
4
+ require_relative "wardstone/errors"
5
+ require_relative "wardstone/response_object"
6
+ require_relative "wardstone/configuration"
7
+ require_relative "wardstone/client"
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: []