knoxcall 0.0.1 → 1.0.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +201 -0
  3. data/README.md +439 -2
  4. data/exe/knoxcall +8 -0
  5. data/lib/knoxcall/bootstrap.rb +70 -0
  6. data/lib/knoxcall/bound_route.rb +47 -0
  7. data/lib/knoxcall/cli/ai.rb +79 -0
  8. data/lib/knoxcall/cli/ai_control.rb +275 -0
  9. data/lib/knoxcall/cli/common.rb +96 -0
  10. data/lib/knoxcall/cli/init.rb +94 -0
  11. data/lib/knoxcall/cli/login.rb +306 -0
  12. data/lib/knoxcall/cli/logout.rb +41 -0
  13. data/lib/knoxcall/cli/whoami.rb +29 -0
  14. data/lib/knoxcall/cli.rb +377 -0
  15. data/lib/knoxcall/client.rb +1025 -0
  16. data/lib/knoxcall/credentials_file.rb +442 -0
  17. data/lib/knoxcall/dpop.rb +79 -0
  18. data/lib/knoxcall/egress_observations.rb +372 -0
  19. data/lib/knoxcall/errors.rb +304 -0
  20. data/lib/knoxcall/intercept_patch.rb +181 -0
  21. data/lib/knoxcall/intercept_pipeline.rb +455 -0
  22. data/lib/knoxcall/intercept_resolver.rb +140 -0
  23. data/lib/knoxcall/intercept_store.rb +203 -0
  24. data/lib/knoxcall/login.rb +144 -0
  25. data/lib/knoxcall/resources/account.rb +12 -0
  26. data/lib/knoxcall/resources/agents.rb +25 -0
  27. data/lib/knoxcall/resources/ai_gateway.rb +417 -0
  28. data/lib/knoxcall/resources/api_keys.rb +35 -0
  29. data/lib/knoxcall/resources/audit_logs.rb +45 -0
  30. data/lib/knoxcall/resources/clients.rb +39 -0
  31. data/lib/knoxcall/resources/crypto.rb +122 -0
  32. data/lib/knoxcall/resources/dynamic_db.rb +68 -0
  33. data/lib/knoxcall/resources/environments.rb +16 -0
  34. data/lib/knoxcall/resources/logs.rb +51 -0
  35. data/lib/knoxcall/resources/oauth_clients.rb +34 -0
  36. data/lib/knoxcall/resources/opportunities.rb +61 -0
  37. data/lib/knoxcall/resources/pki.rb +41 -0
  38. data/lib/knoxcall/resources/roles.rb +27 -0
  39. data/lib/knoxcall/resources/routes.rb +53 -0
  40. data/lib/knoxcall/resources/secrets.rb +98 -0
  41. data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
  42. data/lib/knoxcall/resources/vaults.rb +77 -0
  43. data/lib/knoxcall/resources/webhooks.rb +48 -0
  44. data/lib/knoxcall/resources/workflows.rb +86 -0
  45. data/lib/knoxcall/resources/wrap.rb +352 -0
  46. data/lib/knoxcall/route_refusal.rb +67 -0
  47. data/lib/knoxcall/signup.rb +122 -0
  48. data/lib/knoxcall/token_exchange.rb +169 -0
  49. data/lib/knoxcall/ulid.rb +19 -0
  50. data/lib/knoxcall/warnings.rb +63 -0
  51. data/lib/knoxcall/workload_provider.rb +192 -0
  52. data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
  53. data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
  54. data/lib/knoxcall/wrap_transport.rb +139 -0
  55. data/lib/knoxcall.rb +45 -1
  56. metadata +70 -9
@@ -0,0 +1,417 @@
1
+ module KnoxCall
2
+ module Resources
3
+ # AI Gateway control plane (server: src/client-api/ai-gateway.ts).
4
+ #
5
+ # A gateway holds one or more agents; each agent mints capability tokens
6
+ # used by the AI egress data plane. Following this SDK's convention for a
7
+ # resource with sub-collections (see routes/vaults), the sub-collections
8
+ # are FLAT methods on one resource, grouped by prefix:
9
+ # +*_gateway+, +*_agent+, +*_token+, plus {#usage}.
10
+ #
11
+ # client.ai_gateway.list_gateways
12
+ # gw = client.ai_gateway.create_gateway(name: "Prod", slug: "prod")
13
+ # ag = client.ai_gateway.create_agent(gw["id"], name: "Support", slug: "support")
14
+ # tok = client.ai_gateway.mint_token(ag["id"], kind: "agent")
15
+ # tok["token"] # plaintext — shown ONCE, store it now
16
+ #
17
+ # The sandbox/test client (KnoxCall::Client.new(sandbox: true)) mints
18
+ # test-env tokens server-side; no env parameter is threaded through these
19
+ # methods.
20
+ class AiGateway
21
+ include UnwrapsEnvelope
22
+
23
+ def initialize(client) = @client = client
24
+
25
+ # -- Gateways ----------------------------------------------------------
26
+
27
+ # Paginated. Params: page (default 1), per_page (default 20, max 100).
28
+ # Returns the {data, meta} envelope.
29
+ def list_gateways(**params) = @client.request("GET", "/v1/ai-gateway/gateways", query: params.empty? ? nil : params)
30
+
31
+ # Yield every gateway, walking pages transparently. Returns a lazy
32
+ # Enumerator when no block is given.
33
+ def each_gateway(**params, &block)
34
+ enum = paginate(params) { |p| list_gateways(**p) }
35
+ block ? enum.each(&block) : enum
36
+ end
37
+
38
+ # body: name:, slug:, description?, budget_daily_usd?, budget_monthly_usd?,
39
+ # budget_overage_action? ('block' | 'warn', AIGW-150 — what happens
40
+ # when a cap is spent; refuses on BOTH data planes under 'block').
41
+ def create_gateway(**input) = unwrap(@client.request("POST", "/v1/ai-gateway/gateways", body: input))
42
+ def get_gateway(gateway_id) = unwrap(@client.request("GET", "/v1/ai-gateway/gateways/#{encode(gateway_id)}"))
43
+ # patch: name?, description?, budget_daily_usd?, budget_monthly_usd?,
44
+ # budget_overage_action? ('block' | 'warn', AIGW-150).
45
+ def update_gateway(gateway_id, **patch) = unwrap(@client.request("PATCH", "/v1/ai-gateway/gateways/#{encode(gateway_id)}", body: patch))
46
+ # Returns {"id", "status"}.
47
+ def delete_gateway(gateway_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/gateways/#{encode(gateway_id)}"))
48
+
49
+ # -- Agents ------------------------------------------------------------
50
+
51
+ # Paginated. Params: page (default 1), per_page (default 20, max 100).
52
+ # Returns the {data, meta} envelope.
53
+ def list_agents(gateway_id, **params) = @client.request("GET", "/v1/ai-gateway/gateways/#{encode(gateway_id)}/agents", query: params.empty? ? nil : params)
54
+
55
+ # Yield every agent under a gateway, walking pages transparently. Returns
56
+ # a lazy Enumerator when no block is given.
57
+ def each_agent(gateway_id, **params, &block)
58
+ enum = paginate(params) { |p| list_agents(gateway_id, **p) }
59
+ block ? enum.each(&block) : enum
60
+ end
61
+
62
+ # body: name:, slug:, description?, primary_route_id?, provider?,
63
+ # upstream_secret_id?, upstream?, default_model?, model_allowlist?,
64
+ # model_denylist?, budget_daily_usd?, budget_monthly_usd?,
65
+ # streaming_enabled?, firewall_policy_id?, pii_redact_policy_id?,
66
+ # pii_request_mode? (off|tokenize), pii_response_mode? (redact|detokenize).
67
+ #
68
+ # AIGW-100: pii_request_mode: decides what happens to the PROMPT before it
69
+ # leaves KnoxCall (default 'tokenize'); pii_response_mode: decides what
70
+ # happens to the answer (default 'detokenize'). 'off' on the request side
71
+ # is the only configuration on which the provider receives the real
72
+ # value.
73
+ #
74
+ # provider: composes the upstream route for you, INSTEAD of primary_route_id:.
75
+ # It is a plain string and the catalog is SERVER-side (fourteen ids at the
76
+ # time of writing, from anthropic and openai through groq, bedrock and
77
+ # openai-compatible); a bad value returns a 400 naming the valid set, so do
78
+ # not mirror the list here. KnoxCall creates
79
+ # an ai-gateway-<slug> route pointing at the provider, injecting
80
+ # upstream_secret_id: through the envelope store, and sets default_model
81
+ # from its pricebook default.
82
+ #
83
+ # upstream: is required for the four providers whose endpoint is yours
84
+ # rather than the vendor's: azure-openai, ollama, bedrock and
85
+ # openai-compatible. It is not defaulted: a bedrock or openai-compatible
86
+ # agent created without upstream: is refused with a 400 at create time.
87
+ #
88
+ # Supply provider: or primary_route_id:, NEVER BOTH (400). Supplying
89
+ # neither creates an agent with no upstream and no credential template,
90
+ # whose first data-plane call 502s.
91
+ #
92
+ # Every agent projection -- create, get, update and the list rows -- carries
93
+ # "agent_url": the data-plane base URL, https://{tenant}.knoxcall.com/v1/ai/{slug}.
94
+ # Point an AI SDK's base_url there with a capability token as the API key. It
95
+ # is server-computed rather than stored, so it MOVES when the slug changes,
96
+ # and is "" when the tenant slug cannot be resolved -- treat empty as "not
97
+ # available", never as a URL.
98
+ def create_agent(gateway_id, **input) = unwrap(@client.request("POST", "/v1/ai-gateway/gateways/#{encode(gateway_id)}/agents", body: input))
99
+ # Agents are addressed by their own id once created (not nested under the gateway).
100
+ def get_agent(agent_id) = unwrap(@client.request("GET", "/v1/ai-gateway/agents/#{encode(agent_id)}"))
101
+ def update_agent(agent_id, **patch) = unwrap(@client.request("PATCH", "/v1/ai-gateway/agents/#{encode(agent_id)}", body: patch))
102
+ # Returns {"id", "status"}.
103
+ def delete_agent(agent_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/agents/#{encode(agent_id)}"))
104
+
105
+ # -- MCP servers -------------------------------------------------------
106
+
107
+ # Paginated. Params: page (default 1), per_page (default 20, max 100).
108
+ # Returns the {data, meta} envelope.
109
+ # AIGW-151/150: an MCP server also carries `pii_redact_policy_id` (the
110
+ # tenant policy whose recognizers apply to its tool arguments and results)
111
+ # and the five `guardrail_webhook_*` fields (the tenant's own scanner,
112
+ # offered BOTH directions — there is no streaming carve-out on this plane).
113
+ # A policy or secret id this tenant does not own is refused 422.
114
+ #
115
+ # AIGW-152: an MCP server is a Live or a Test object. The row carries the
116
+ # mode of the API key that created it, every read and write below is
117
+ # confined to that key's own space (a Live key does not see a Test server
118
+ # at all), and `sandbox` on the response is READ-ONLY — passing it to
119
+ # create/update is ignored, never honoured.
120
+ def list_mcp_servers(gateway_id, **params) = @client.request("GET", "/v1/ai-gateway/gateways/#{encode(gateway_id)}/mcp-servers", query: params.empty? ? nil : params)
121
+
122
+ # Yield every MCP server under a gateway, walking pages transparently.
123
+ # Returns a lazy Enumerator when no block is given.
124
+ def each_mcp_server(gateway_id, **params, &block)
125
+ enum = paginate(params) { |p| list_mcp_servers(gateway_id, **p) }
126
+ block ? enum.each(&block) : enum
127
+ end
128
+
129
+ # Register an upstream MCP server. KnoxCall proxies it at the returned
130
+ # "connect_url" and governs every tool call before it reaches the upstream.
131
+ #
132
+ # body: name:, slug:, upstream_url:, description?, transport?,
133
+ # allowed_tools?, pii_inspection?, auth?.
134
+ #
135
+ # upstream_url must be a public https:// address — private, loopback,
136
+ # link-local and cloud-metadata destinations are refused, because the
137
+ # request carries your decrypted upstream credential. Every value in
138
+ # auth[:headers] must reference a KnoxCall secret, e.g.
139
+ # {Authorization: "Bearer {{secret_id:<uuid>}}"}; a literal credential is
140
+ # refused with 422. allowed_tools EMPTY means the server advertises
141
+ # nothing. server_type "collection" is not accepted — the data plane does
142
+ # not serve it yet.
143
+ #
144
+ # The result carries BOTH "connect_url" (where an MCP client points) and
145
+ # "resource" (the RFC 8707 value a token for it must be bound to).
146
+ def create_mcp_server(gateway_id, **input) = unwrap(@client.request("POST", "/v1/ai-gateway/gateways/#{encode(gateway_id)}/mcp-servers", body: input))
147
+ # MCP servers are addressed by their own id once created.
148
+ def get_mcp_server(server_id) = unwrap(@client.request("GET", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}"))
149
+ # patch: name?, description?, upstream_url?, transport?, allowed_tools?,
150
+ # pii_inspection?, auth?, status? ("active"|"paused"; DELETE archives).
151
+ def update_mcp_server(server_id, **patch) = unwrap(@client.request("PATCH", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}", body: patch))
152
+ # Returns {"id", "status"}.
153
+ def delete_mcp_server(server_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}"))
154
+
155
+ # -- MCP tools ---------------------------------------------------------
156
+
157
+ # Paginated tool metadata rows. What a client can actually call is the
158
+ # intersection of the server's allowed_tools, the upstream's real tools
159
+ # and the token's own tool scope.
160
+ def list_mcp_tools(server_id, **params) = @client.request("GET", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}/tools", query: params.empty? ? nil : params)
161
+
162
+ # Yield every tool row for a server. Lazy Enumerator when no block given.
163
+ def each_mcp_tool(server_id, **params, &block)
164
+ enum = paginate(params) { |p| list_mcp_tools(server_id, **p) }
165
+ block ? enum.each(&block) : enum
166
+ end
167
+
168
+ # Insert or update a tool row by tool_name.
169
+ # body: tool_name:, description?, input_schema?, enabled?.
170
+ def upsert_mcp_tool(server_id, **input) = unwrap(@client.request("POST", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}/tools", body: input))
171
+ # patch: description?, input_schema?, enabled?.
172
+ def update_mcp_tool(server_id, tool_id, **patch) = unwrap(@client.request("PATCH", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}/tools/#{encode(tool_id)}", body: patch))
173
+ # Returns {"id", "deleted"}.
174
+ def delete_mcp_tool(server_id, tool_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}/tools/#{encode(tool_id)}"))
175
+
176
+ # -- Delegated-OAuth connections (AIGW-190) ------------------------------
177
+ #
178
+ # A connection holds ONE person's upstream refresh token, envelope-encrypted
179
+ # under the tenant key. Nothing here returns it, redacted or otherwise.
180
+ #
181
+ # There is deliberately no #connect_mcp_server: consent has to be given by
182
+ # the person whose credential it is, so the flow starts from a signed-in
183
+ # KnoxCall session in the admin console. An API key is not a person.
184
+
185
+ # One page of the people who have connected their upstream account to this
186
+ # MCP server. Params: page, per_page. Returns the {data, meta} envelope.
187
+ def list_mcp_grants(server_id, **params) = @client.request("GET", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}/grants", query: params.empty? ? nil : params)
188
+
189
+ # Yield every connection on this server, walking pages transparently.
190
+ # Returns a lazy Enumerator when no block is given.
191
+ def each_mcp_grant(server_id, **params, &block)
192
+ enum = paginate(params) { |p| list_mcp_grants(server_id, **p) }
193
+ block ? enum.each(&block) : enum
194
+ end
195
+
196
+ # Revoke ONE person's connection. The stored tokens are destroyed.
197
+ def revoke_mcp_grant(server_id, grant_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}/grants/#{encode(grant_id)}"))
198
+
199
+ # Revoke EVERY connection on this server (offboarding in one call).
200
+ def revoke_all_mcp_grants(server_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/mcp-servers/#{encode(server_id)}/grants"))
201
+
202
+ # -- Tokens ------------------------------------------------------------
203
+
204
+ # Paginated; never returns plaintext (the plaintext is only in
205
+ # {#mint_token}'s response). Params: page, per_page. Returns the
206
+ # {data, meta} envelope.
207
+ def list_tokens(agent_id, **params) = @client.request("GET", "/v1/ai-gateway/agents/#{encode(agent_id)}/tokens", query: params.empty? ? nil : params)
208
+
209
+ # Yield every token row for an agent, walking pages transparently.
210
+ # Returns a lazy Enumerator when no block is given.
211
+ def each_token(agent_id, **params, &block)
212
+ enum = paginate(params) { |p| list_tokens(agent_id, **p) }
213
+ block ? enum.each(&block) : enum
214
+ end
215
+
216
+ # Mint a capability token. body: name?, kind? (agent|read|tool|oneshot),
217
+ # dpop_required?, dpop_jkt?, expires_in_seconds?.
218
+ # Defaults to 30 days when omitted; clamped to [60s, 90d]. A non-expiring token cannot be minted.
219
+ # Returns
220
+ # {"id", "name"?, "kind", "prefix", "token", "dpop_required", "expires_at"}
221
+ # — "token" is the plaintext credential, shown ONCE; store it now.
222
+ def mint_token(agent_id, **input) = unwrap(@client.request("POST", "/v1/ai-gateway/agents/#{encode(agent_id)}/tokens", body: input))
223
+ # Returns {"id", "revoked" => true}.
224
+ def revoke_token(agent_id, token_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/agents/#{encode(agent_id)}/tokens/#{encode(token_id)}"))
225
+
226
+ # -- Firewall policies --------------------------------------------------
227
+ #
228
+ # Prompt-firewall policies are TENANT-scoped and shared across gateways;
229
+ # attach one to an agent with firewall_policy_id. An agent with no policy
230
+ # still runs the built-in prompt-injection patterns but can never exceed
231
+ # "warn" — attach a policy with action: "block" to have matching requests
232
+ # refused with HTTP 400 firewall_block on the data plane.
233
+
234
+ # Paginated. Params: page, per_page. Returns the {data, meta} envelope.
235
+ def list_firewall_policies(**params) = @client.request("GET", "/v1/ai-gateway/firewall-policies", query: params.empty? ? nil : params)
236
+
237
+ # Yield every firewall policy, walking pages transparently.
238
+ def each_firewall_policy(**params, &block)
239
+ enum = paginate(params) { |p| list_firewall_policies(**p) }
240
+ block ? enum.each(&block) : enum
241
+ end
242
+
243
+ # body: name:, heuristics? ([{name:, kind: "regex"|"keyword", pattern:,
244
+ # flags?}]), canary_enabled?, action? ("block"|"warn"|"tag", default
245
+ # "warn"). Re-using an existing name creates version N+1.
246
+ #
247
+ # Every regex rule is compiled server-side with the same linear-time
248
+ # engine the data plane runs, so lookahead/lookbehind/backreferences are a
249
+ # 400 here rather than a rule that silently matches nothing at scan time.
250
+ def create_firewall_policy(**input) = unwrap(@client.request("POST", "/v1/ai-gateway/firewall-policies", body: input))
251
+
252
+ def get_firewall_policy(policy_id) = unwrap(@client.request("GET", "/v1/ai-gateway/firewall-policies/#{encode(policy_id)}"))
253
+
254
+ # Updates in place — the version is NOT bumped. Rules are re-validated.
255
+ def update_firewall_policy(policy_id, **patch) = unwrap(@client.request("PATCH", "/v1/ai-gateway/firewall-policies/#{encode(policy_id)}", body: patch))
256
+
257
+ # Refused with 409 policy_in_use while any agent or MCP server is still
258
+ # attached. Returns {"id", "deleted" => true}.
259
+ def delete_firewall_policy(policy_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/firewall-policies/#{encode(policy_id)}"))
260
+
261
+ # Dry-run rules against sample text; saves nothing. Rules are compiled
262
+ # first, so this refuses exactly what create/update refuse. Returns
263
+ # {"matched", "matches" => [{"rule", "span", "matched"}], "skipped" => []}.
264
+ def test_firewall_rules(text:, heuristics: nil)
265
+ body = { text: text }
266
+ body[:heuristics] = heuristics unless heuristics.nil?
267
+ unwrap(@client.request("POST", "/v1/ai-gateway/firewall-policies/test", body: body))
268
+ end
269
+ # Every token under a gateway, INCLUDING gateway-level tokens with no
270
+ # agent (the shape POST /v1/oauth/token mints for MCP). {#list_tokens}
271
+ # filters on the agent and cannot see them. Plaintext is never returned.
272
+ def list_gateway_tokens(gateway_id, **params) = @client.request("GET", "/v1/ai-gateway/gateways/#{encode(gateway_id)}/tokens", query: params.empty? ? nil : params)
273
+
274
+ # Yield every token under a gateway. Lazy Enumerator when no block given.
275
+ def each_gateway_token(gateway_id, **params, &block)
276
+ enum = paginate(params) { |p| list_gateway_tokens(gateway_id, **p) }
277
+ block ? enum.each(&block) : enum
278
+ end
279
+
280
+ # Revoke any token under a gateway, including a gateway-level one. Use
281
+ # this rather than {#revoke_token} for a token minted by
282
+ # POST /v1/oauth/token: that token has no agent, so the per-agent revoke
283
+ # can never match it. Returns {"id", "revoked"}.
284
+ def revoke_gateway_token(gateway_id, token_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/gateways/#{encode(gateway_id)}/tokens/#{encode(token_id)}"))
285
+
286
+ # -- PII policies -------------------------------------------------------
287
+ #
288
+ # PII redaction policies are TENANT-scoped and shared across gateways,
289
+ # exactly like firewall policies; attach one to an agent with
290
+ # pii_redact_policy_id. Until AIGW-160 they lived only on the admin plane,
291
+ # so {#create_agent} accepted a pii_redact_policy_id that no /v1 call
292
+ # could produce.
293
+ #
294
+ # A policy row is {"id", "tenant_id", "name", "version",
295
+ # "recognizer_ids" => [uuid], "default_action" ("redact"|"tokenize"|
296
+ # "whitelist"|"warn"), "description" (may be nil), "created_at"}.
297
+
298
+ # Paginated. Params: page, per_page. Returns the {data, meta} envelope.
299
+ def list_pii_policies(**params) = @client.request("GET", "/v1/ai-gateway/pii-policies", query: params.empty? ? nil : params)
300
+
301
+ # Yield every PII policy, walking pages transparently. Returns a lazy
302
+ # Enumerator when no block is given.
303
+ def each_pii_policy(**params, &block)
304
+ enum = paginate(params) { |p| list_pii_policies(**p) }
305
+ block ? enum.each(&block) : enum
306
+ end
307
+
308
+ # body: name:, recognizer_ids? ([uuid]), default_action? (default
309
+ # "redact"), description?.
310
+ #
311
+ # An EMPTY recognizer_ids means "every enabled recognizer this tenant
312
+ # owns", NOT "none" — omitting the field gives you the WIDEST policy, not
313
+ # an inert one. Every id you do list must be a recognizer this tenant
314
+ # owns: a foreign or unknown id is a 400 recognizer_not_found at write
315
+ # time, rather than a stored value that resolves to nothing at scan time
316
+ # and quietly runs fewer detectors than the policy names.
317
+ def create_pii_policy(**input) = unwrap(@client.request("POST", "/v1/ai-gateway/pii-policies", body: input))
318
+
319
+ def get_pii_policy(policy_id) = unwrap(@client.request("GET", "/v1/ai-gateway/pii-policies/#{encode(policy_id)}"))
320
+
321
+ # Updates in place — the version is NOT bumped. patch: recognizer_ids?,
322
+ # default_action?, description?. recognizer_ids is re-validated the same
323
+ # way, and an empty array still means "every enabled recognizer".
324
+ def update_pii_policy(policy_id, **patch) = unwrap(@client.request("PATCH", "/v1/ai-gateway/pii-policies/#{encode(policy_id)}", body: patch))
325
+
326
+ # Refused with 409 policy_in_use while any agent still references it. The
327
+ # foreign key is ON DELETE SET NULL, so an unchecked delete would detach
328
+ # every bound agent and turn redaction OFF for each of them with no error
329
+ # anywhere. Detach the agents first. Returns {"id", "deleted" => true}.
330
+ def delete_pii_policy(policy_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/pii-policies/#{encode(policy_id)}"))
331
+
332
+ # -- PII recognizers ----------------------------------------------------
333
+ #
334
+ # A recognizer is one tenant-defined detector: {"id", "tenant_id", "name",
335
+ # "kind" ("regex"|"aho_corasick"|"presidio_pattern"|"presidio_ner"|
336
+ # "presidio_custom"), "pattern", "context_words" => [String] (words that
337
+ # must appear nearby for a match to count), "confidence" (Float),
338
+ # "action", "format" (token format for "tokenize", nil otherwise),
339
+ # "enabled", "created_at"}. enabled: false mutes a recognizer without
340
+ # losing its definition.
341
+
342
+ # Paginated. Params: page, per_page. Returns the {data, meta} envelope.
343
+ def list_pii_recognizers(**params) = @client.request("GET", "/v1/ai-gateway/pii-recognizers", query: params.empty? ? nil : params)
344
+
345
+ # Yield every PII recognizer, walking pages transparently. Returns a lazy
346
+ # Enumerator when no block is given.
347
+ def each_pii_recognizer(**params, &block)
348
+ enum = paginate(params) { |p| list_pii_recognizers(**p) }
349
+ block ? enum.each(&block) : enum
350
+ end
351
+
352
+ # body: name:, kind:, pattern:, context_words?, confidence? (0-1, default
353
+ # 0.85), action?, format?, enabled?.
354
+ #
355
+ # A "regex" pattern is compiled server-side with the same linear-time
356
+ # engine the data plane runs, so lookahead, lookbehind and backreferences
357
+ # are a 400 here rather than a recognizer that is silently skipped at scan
358
+ # time (fail open). action "whitelist" exempts the matched shape from
359
+ # every OTHER detector, so a whitelist pattern that matches arbitrary text
360
+ # is a kill switch for the built-in tier and the server refuses it.
361
+ def create_pii_recognizer(**input) = unwrap(@client.request("POST", "/v1/ai-gateway/pii-recognizers", body: input))
362
+
363
+ # Dry-run a candidate pattern against sample text; saves nothing.
364
+ # +pattern+ and +text+ are required; kind:, action:, context_words: and
365
+ # name: are optional.
366
+ #
367
+ # It compiles with the SAME engine the data plane runs, so a pattern that
368
+ # passes here is one that will actually execute. Do NOT preview with a
369
+ # local Regexp: Ruby accepts lookahead, lookbehind and backreferences the
370
+ # server refuses, so a local preview shows matches for a recognizer that
371
+ # can never run and then 400s on save.
372
+ #
373
+ # Returns {"matched", "matches" => [{"span" => [start, stop], "matched",
374
+ # "replacement", "entity_type"}]}.
375
+ def test_pii_recognizer(pattern:, text:, kind: nil, action: nil, context_words: nil, name: nil)
376
+ body = { pattern: pattern, text: text }
377
+ body[:kind] = kind unless kind.nil?
378
+ body[:action] = action unless action.nil?
379
+ body[:context_words] = context_words unless context_words.nil?
380
+ body[:name] = name unless name.nil?
381
+ unwrap(@client.request("POST", "/v1/ai-gateway/pii-recognizers/test", body: body))
382
+ end
383
+
384
+ def get_pii_recognizer(recognizer_id) = unwrap(@client.request("GET", "/v1/ai-gateway/pii-recognizers/#{encode(recognizer_id)}"))
385
+
386
+ # The server validates the MERGED state, not the patch, so
387
+ # action: "whitelist" on its own is still checked against the STORED
388
+ # pattern. patch: any of the create fields.
389
+ def update_pii_recognizer(recognizer_id, **patch) = unwrap(@client.request("PATCH", "/v1/ai-gateway/pii-recognizers/#{encode(recognizer_id)}", body: patch))
390
+
391
+ # Refused with 409 recognizer_in_use while any PII policy still lists it:
392
+ # an empty recognizer_ids means "every enabled recognizer", so dropping
393
+ # the id would WIDEN each listing policy rather than shrink it. Remove it
394
+ # from every policy first. Returns {"id", "deleted" => true}.
395
+ def delete_pii_recognizer(recognizer_id) = unwrap(@client.request("DELETE", "/v1/ai-gateway/pii-recognizers/#{encode(recognizer_id)}"))
396
+
397
+ # -- Usage -------------------------------------------------------------
398
+
399
+ # Cost/token usage rollup. Params: period ("7d"|"30d"|"90d"), agent_id?.
400
+ # Returns {"period_days", "by_model" => [{"provider", "model", "requests",
401
+ # "input_tokens", "output_tokens", "cost_usd", "unpriced_requests"}],
402
+ # "totals" => {...}}.
403
+ def usage(**params) = unwrap(@client.request("GET", "/v1/ai-gateway/usage", query: params.empty? ? nil : params))
404
+
405
+ # FinOps export: aggregated spend grouped by user|team|agent|model|
406
+ # provider|"tag:<key>", over a period. +group_by+ is required; +period+
407
+ # ("7d"|"30d"|"90d") and +agent_id+ are optional. The SDK always sends
408
+ # format=json (nil optionals are dropped by the client's query compaction).
409
+ # Returns {"group_by", "period_days", "rows" => [{"group", "requests",
410
+ # "input_tokens", "output_tokens", "cost_usd", "unpriced_requests"}]}.
411
+ def export_usage(group_by:, period: nil, agent_id: nil)
412
+ query = { group_by: group_by, period: period, agent_id: agent_id, format: "json" }
413
+ unwrap(@client.request("GET", "/v1/ai-gateway/usage/export", query: query))
414
+ end
415
+ end
416
+ end
417
+ end
@@ -0,0 +1,35 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class ApiKeys
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Paginated. Params: page (default 1), per_page (default 20, max 100).
9
+ # Returns the {data, meta} envelope.
10
+ def list(**params) = @client.request("GET", "/v1/api-keys", query: params.empty? ? nil : params)
11
+
12
+ # Yield every API key, walking pages transparently. Returns a lazy
13
+ # Enumerator when no block is given.
14
+ def each(**params, &block)
15
+ enum = paginate(params) { |p| list(**p) }
16
+ block ? enum.each(&block) : enum
17
+ end
18
+
19
+ # The response's "api_key" plaintext is shown exactly once — store it now.
20
+ #
21
+ # `role_ids:` (array of role UUIDs) attaches permission roles in the SAME
22
+ # transaction as the key. Discover them with
23
+ # `client.roles.list(subject_kind: "api_key")`. A key created with no role
24
+ # is default-denied on every policy-gated endpoint.
25
+ #
26
+ # A key can never mint a key more privileged than itself: if a requested
27
+ # role grants something this credential does not hold, the server answers
28
+ # 403 privilege_escalation (a PermissionDeniedError) naming the offending
29
+ # grant verbatim.
30
+ def create(**input) = unwrap(@client.request("POST", "/v1/api-keys", body: input))
31
+ # Returns {"revoked" => true}.
32
+ def revoke(key_id) = unwrap(@client.request("DELETE", "/v1/api-keys/#{encode(key_id)}"))
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class AuditLogs
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Paginated. Params: page (default 1), per_page (default 20, max 100),
9
+ # plus endpoint filters. Returns the {data, meta} envelope.
10
+ def list(**params) = @client.request("GET", "/v1/audit-logs", query: params.empty? ? nil : params)
11
+
12
+ # Yield every audit-log row, walking pages transparently. Returns a
13
+ # lazy Enumerator when no block is given.
14
+ def each(**params, &block)
15
+ enum = paginate(params) { |p| list(**p) }
16
+ block ? enum.each(&block) : enum
17
+ end
18
+
19
+ # One page of the keyset audit event feed — the endpoint a SIEM shipper
20
+ # should use. Params: +cursor+, +limit+, +action+, +action_prefix+,
21
+ # +resource_type+.
22
+ #
23
+ # {#list} is offset-paginated over +created_at DESC+, which is right for
24
+ # a console and wrong for a feed: rows written while you page shift the
25
+ # offsets underneath you, so events are skipped or repeated with no way
26
+ # to tell which. This is ordered by a monotonic sequence and resumes from
27
+ # an opaque cursor.
28
+ #
29
+ # +action+ is exact-match; +action_prefix+ subscribes to a whole SURFACE
30
+ # — <tt>"ai_gateway."</tt> covers every AI-gateway action INCLUDING names
31
+ # added after your integration was built, which exact-match cannot.
32
+ #
33
+ # Delivery is AT LEAST ONCE — dedupe on +id+. +meta.next_cursor+ is
34
+ # OPAQUE; pass it back verbatim.
35
+ def events(**params) = @client.request("GET", "/v1/audit-logs/events", query: params.empty? ? nil : params)
36
+
37
+ # Yield every audit event, walking the cursor until the feed is drained
38
+ # to the watermark. Returns a lazy Enumerator when no block is given.
39
+ def each_event(**params, &block)
40
+ enum = paginate_cursor(params) { |p| events(**p) }
41
+ block ? enum.each(&block) : enum
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,39 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Clients
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Paginated. Params: page (default 1), per_page (default 20, max 100).
9
+ # Returns the {data, meta} envelope.
10
+ def list(**params) = @client.request("GET", "/v1/clients", query: params.empty? ? nil : params)
11
+
12
+ # Yield every calling client, walking pages transparently. Returns a
13
+ # lazy Enumerator when no block is given.
14
+ def each(**params, &block)
15
+ enum = paginate(params) { |p| list(**p) }
16
+ block ? enum.each(&block) : enum
17
+ end
18
+
19
+ def get(client_id) = unwrap(@client.request("GET", "/v1/clients/#{encode(client_id)}"))
20
+ def create(**input) = unwrap(@client.request("POST", "/v1/clients", body: input))
21
+ def update(client_id, **input) = unwrap(@client.request("PATCH", "/v1/clients/#{encode(client_id)}", body: input))
22
+ # Returns {"deleted" => true}.
23
+ def delete(client_id) = unwrap(@client.request("DELETE", "/v1/clients/#{encode(client_id)}"))
24
+
25
+ # Bare array — no pagination. Secret material is redacted server-side.
26
+ def list_credentials(client_id) = unwrap(@client.request("GET", "/v1/clients/#{encode(client_id)}/credentials"))
27
+ # For mtls "issue" mode the response carries a ONE-SHOT "reveal"
28
+ # ({certificate_pem, private_key_pem, ca_chain_pem}) — store it now.
29
+ def create_credential(client_id, kind:, label:, data: nil)
30
+ body = { kind: kind, label: label }
31
+ body[:data] = data if data
32
+ unwrap(@client.request("POST", "/v1/clients/#{encode(client_id)}/credentials", body: body))
33
+ end
34
+ def update_credential(client_id, credential_id, **input) = unwrap(@client.request("PATCH", "/v1/clients/#{encode(client_id)}/credentials/#{encode(credential_id)}", body: input))
35
+ # Returns {"deleted" => true}.
36
+ def delete_credential(client_id, credential_id) = unwrap(@client.request("DELETE", "/v1/clients/#{encode(client_id)}/credentials/#{encode(credential_id)}"))
37
+ end
38
+ end
39
+ end