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,306 @@
1
+ require "socket"
2
+ require "securerandom"
3
+ require "openssl"
4
+ require "uri"
5
+ require "rbconfig"
6
+
7
+ module KnoxCall
8
+ module CLI
9
+ # `knoxcall login` — auth-code+PKCE via loopback redirect, or device flow.
10
+ module Login
11
+ DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
12
+
13
+ module_function
14
+
15
+ # -- PKCE (RFC 7636, S256 only) --------------------------------------------
16
+
17
+ # Return [code_verifier, code_challenge] — S256, unpadded base64url.
18
+ def generate_pkce_pair
19
+ verifier = base64url(SecureRandom.bytes(48))
20
+ challenge = base64url(OpenSSL::Digest::SHA256.digest(verifier))
21
+ [verifier, challenge]
22
+ end
23
+
24
+ def base64url(bytes)
25
+ [bytes].pack("m0").tr("+/", "-_").delete("=")
26
+ end
27
+
28
+ def build_authorize_url(base_url, redirect_uri:, state:, code_challenge:, tenant: nil)
29
+ params = {
30
+ "response_type" => "code",
31
+ "client_id" => CLI_CLIENT_ID,
32
+ "redirect_uri" => redirect_uri,
33
+ "state" => state,
34
+ "code_challenge" => code_challenge,
35
+ "code_challenge_method" => "S256"
36
+ }
37
+ params["tenant"] = tenant if CredentialsFile.presence(tenant)
38
+ "#{base_url}/oauth/authorize?#{URI.encode_www_form(params)}"
39
+ end
40
+
41
+ # -- Loopback redirect receiver (RFC 8252 §7.3) ------------------------------
42
+
43
+ # One-shot loopback HTTP server on 127.0.0.1:0 for the authorize redirect.
44
+ #
45
+ # Serves until one GET /callback arrives (anything else — favicon probes
46
+ # and the like — gets a 404 and the listener keeps going).
47
+ class LoopbackServer
48
+ attr_reader :port
49
+
50
+ def initialize(host = "127.0.0.1")
51
+ @server = TCPServer.new(host, 0)
52
+ @port = @server.addr[1]
53
+ @mutex = Mutex.new
54
+ @cond = ConditionVariable.new
55
+ @result = nil
56
+ @thread = Thread.new { serve }
57
+ @thread.report_on_exception = false
58
+ end
59
+
60
+ # Block until the browser hits /callback; validate state, return the code.
61
+ def wait_for_code(expected_state:, timeout: 300.0)
62
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
63
+ result = @mutex.synchronize do
64
+ while @result.nil?
65
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ raise Error, "timed out waiting for the browser sign-in to complete" if remaining <= 0
67
+ @cond.wait(@mutex, remaining)
68
+ end
69
+ @result
70
+ end
71
+
72
+ if CredentialsFile.presence(result["error"])
73
+ detail = CredentialsFile.presence(result["error_description"]) || result["error"]
74
+ raise Error, "authorization failed: #{detail}"
75
+ end
76
+ unless OpenSSL.secure_compare(result["state"].to_s, expected_state)
77
+ raise Error, "state mismatch in the OAuth callback — possible CSRF, aborting"
78
+ end
79
+ code = CredentialsFile.presence(result["code"])
80
+ raise Error, "no authorization code in the OAuth callback" unless code
81
+ code
82
+ end
83
+
84
+ def close
85
+ begin
86
+ @server.close
87
+ rescue IOError
88
+ # already closed
89
+ end
90
+ @thread.join(2) || @thread.kill
91
+ end
92
+
93
+ private
94
+
95
+ def serve
96
+ loop do
97
+ socket = @server.accept
98
+ begin
99
+ handle(socket)
100
+ ensure
101
+ begin
102
+ socket.close
103
+ rescue IOError, SystemCallError
104
+ # peer already gone
105
+ end
106
+ end
107
+ break unless @mutex.synchronize { @result.nil? }
108
+ end
109
+ rescue IOError, SystemCallError
110
+ # listener closed — shutting down
111
+ end
112
+
113
+ def handle(socket)
114
+ request_line = socket.gets
115
+ return unless request_line
116
+ while (line = socket.gets) # drain headers
117
+ break if line.strip.empty?
118
+ end
119
+ method, target, = request_line.split(" ", 3)
120
+ path, query = target.to_s.split("?", 2)
121
+ unless method == "GET" && path == "/callback"
122
+ respond(socket, "404 Not Found", page(failed: true))
123
+ return
124
+ end
125
+
126
+ result = {}
127
+ begin
128
+ URI.decode_www_form(query.to_s).each { |k, v| result[k] = v unless result.key?(k) }
129
+ rescue ArgumentError
130
+ # malformed query — treated as an empty callback below
131
+ end
132
+ failed = result.key?("error") || result["code"].to_s.empty?
133
+ respond(socket, "200 OK", page(failed: failed))
134
+
135
+ @mutex.synchronize do
136
+ @result ||= result
137
+ @cond.signal
138
+ end
139
+ end
140
+
141
+ def page(failed:)
142
+ "<!doctype html><meta charset='utf-8'><title>KnoxCall CLI</title>" \
143
+ "<body style='font-family:system-ui;margin:4rem auto;max-width:28rem'>" +
144
+ (if failed
145
+ "<h1>Sign-in failed</h1><p>Return to your terminal for details.</p>"
146
+ else
147
+ "<h1>Signed in</h1><p>You can close this window and return to your terminal.</p>"
148
+ end) + "</body>"
149
+ end
150
+
151
+ def respond(socket, status_line, html)
152
+ body = html.encode("utf-8")
153
+ socket.write(
154
+ "HTTP/1.1 #{status_line}\r\n" \
155
+ "Content-Type: text/html; charset=utf-8\r\n" \
156
+ "Content-Length: #{body.bytesize}\r\n" \
157
+ "Connection: close\r\n\r\n"
158
+ )
159
+ socket.write(body)
160
+ rescue IOError, SystemCallError
161
+ # browser hung up mid-response — the query is what matters
162
+ end
163
+ end
164
+
165
+ # -- Flows -------------------------------------------------------------------
166
+
167
+ def auth_code_flow(base_url, tenant: nil, open_browser: nil, timeout: 300.0)
168
+ verifier, challenge = generate_pkce_pair
169
+ state = SecureRandom.urlsafe_base64(24)
170
+ server = LoopbackServer.new
171
+ begin
172
+ redirect_uri = "http://127.0.0.1:#{server.port}/callback"
173
+ url = build_authorize_url(base_url, redirect_uri: redirect_uri, state: state,
174
+ code_challenge: challenge, tenant: tenant)
175
+ puts "Opening your browser to sign in. If it does not open, visit:\n\n #{url}\n\n"
176
+ begin
177
+ (open_browser || method(:open_browser_default)).call(url)
178
+ rescue StandardError
179
+ # URL is printed; a broken browser launcher is not fatal
180
+ end
181
+ code = server.wait_for_code(expected_state: state, timeout: timeout)
182
+ ensure
183
+ server.close
184
+ end
185
+
186
+ status, body = Common.post_form(
187
+ "#{base_url}/oauth/token",
188
+ {
189
+ "grant_type" => "authorization_code",
190
+ "code" => code,
191
+ "redirect_uri" => redirect_uri,
192
+ "client_id" => CLI_CLIENT_ID,
193
+ "code_verifier" => verifier
194
+ }
195
+ )
196
+ if status >= 400 || !CredentialsFile.presence(body["access_token"])
197
+ raise Error, Common.token_error_message(status, body)
198
+ end
199
+ body
200
+ end
201
+
202
+ # Open the system browser; failures are non-fatal (the URL is always
203
+ # printed first). start/open/xdg-open per OS.
204
+ def open_browser_default(url)
205
+ clean = url.gsub('"', "%22")
206
+ case RbConfig::CONFIG["host_os"]
207
+ when /mswin|mingw|cygwin/
208
+ # single-string form runs through cmd.exe, where `start` lives; the
209
+ # quotes keep &-separated query params inside one argument
210
+ system(%(start "" "#{clean}"), out: File::NULL, err: File::NULL)
211
+ when /darwin/
212
+ system("open", url, out: File::NULL, err: File::NULL)
213
+ else
214
+ system("xdg-open", url, out: File::NULL, err: File::NULL)
215
+ end
216
+ end
217
+
218
+ # Poll the token endpoint per RFC 8628 §3.5, honoring interval + slow_down.
219
+ # Sleeps BEFORE the first poll; `sleeper` is injectable for tests.
220
+ def poll_device_token(base_url, device_code, client_id: CLI_CLIENT_ID, interval: 5,
221
+ expires_in: 900.0, sleeper: ->(s) { sleep(s) })
222
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + expires_in
223
+ loop do
224
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
225
+ raise Error, "device authorization expired — run `knoxcall login` again"
226
+ end
227
+ sleeper.call(interval)
228
+ status, body = Common.post_form(
229
+ "#{base_url}/oauth/token",
230
+ { "grant_type" => DEVICE_GRANT, "device_code" => device_code, "client_id" => client_id }
231
+ )
232
+ return body if status < 400 && CredentialsFile.presence(body["access_token"])
233
+
234
+ case body["error"]
235
+ when "authorization_pending"
236
+ next
237
+ when "slow_down"
238
+ interval += 5
239
+ when "expired_token"
240
+ raise Error, "the device code expired — run `knoxcall login` again"
241
+ when "access_denied"
242
+ raise Error, "sign-in was denied"
243
+ else
244
+ raise Error, Common.token_error_message(status, body)
245
+ end
246
+ end
247
+ end
248
+
249
+ def device_flow(base_url, sleeper: ->(s) { sleep(s) })
250
+ status, body = Common.post_form(
251
+ "#{base_url}/oauth/device_authorization", { "client_id" => CLI_CLIENT_ID }
252
+ )
253
+ if status >= 400 || !CredentialsFile.presence(body["device_code"])
254
+ raise Error, Common.token_error_message(status, body)
255
+ end
256
+
257
+ verification_uri = body["verification_uri"].to_s
258
+ user_code = body["user_code"].to_s
259
+ puts "To sign in, open:\n\n #{verification_uri}\n\nand enter the code:\n\n #{user_code}\n\n"
260
+ complete = CredentialsFile.presence(body["verification_uri_complete"])
261
+ puts "(or open #{complete} directly)\n\n" if complete
262
+ puts "Waiting for approval…"
263
+
264
+ interval = begin
265
+ Integer(body["interval"] || 5)
266
+ rescue ArgumentError, TypeError
267
+ 5
268
+ end
269
+ expires_in = begin
270
+ Float(body["expires_in"] || 900)
271
+ rescue ArgumentError, TypeError
272
+ 900.0
273
+ end
274
+ poll_device_token(base_url, body["device_code"],
275
+ interval: interval, expires_in: expires_in, sleeper: sleeper)
276
+ end
277
+
278
+ # -- Command entry -------------------------------------------------------------
279
+
280
+ def run(options, sleeper: nil, open_browser: nil)
281
+ base_url = (options[:base_url] || Common.default_base_url(options[:sandbox])).chomp("/")
282
+ path = CredentialsFile.resolve_path
283
+ profile = CredentialsFile.resolve_profile(options[:profile])
284
+
285
+ token_body =
286
+ if options[:device] || options[:no_browser]
287
+ device_flow(base_url, sleeper: sleeper || ->(s) { sleep(s) })
288
+ else
289
+ auth_code_flow(base_url, tenant: options[:tenant], open_browser: open_browser)
290
+ end
291
+
292
+ record = Common.persist_login(path: path, profile: profile, base_url: base_url,
293
+ token_body: token_body, fallback_tenant: options[:tenant])
294
+ tenant = CredentialsFile.presence(record["tenant"]) || "(tenant not reported)"
295
+ puts "\nLogged in to #{tenant} (#{base_url})"
296
+ scope = CredentialsFile.presence(record["scope"])
297
+ puts "Scopes: #{scope}" if scope
298
+ puts "Credentials written to #{path} (profile '#{profile}')"
299
+ unless CredentialsFile.presence(record["refresh_token"])
300
+ puts "Warning: no refresh token was issued — access will expire without renewal."
301
+ end
302
+ 0
303
+ end
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,41 @@
1
+ module KnoxCall
2
+ module CLI
3
+ # `knoxcall logout` — best-effort revoke, then remove the stored profile.
4
+ module Logout
5
+ module_function
6
+
7
+ def run(options)
8
+ path = CredentialsFile.resolve_path
9
+ profile = CredentialsFile.resolve_profile(options[:profile])
10
+ record = CredentialsFile.read_profile(path, profile)
11
+ if record.nil?
12
+ puts "No stored credentials for profile '#{profile}' — nothing to do."
13
+ return 0
14
+ end
15
+
16
+ refresh_token = CredentialsFile.presence(record["refresh_token"])
17
+ base_url = record["base_url"].to_s.chomp("/")
18
+ if refresh_token && !base_url.empty?
19
+ begin
20
+ Common.post_form(
21
+ "#{base_url}/oauth/revoke",
22
+ {
23
+ "token" => refresh_token,
24
+ "token_type_hint" => "refresh_token",
25
+ "client_id" => CredentialsFile.presence(record["client_id"]) || CLI_CLIENT_ID
26
+ }
27
+ )
28
+ rescue Error
29
+ # best-effort: removal proceeds even when revocation is unreachable
30
+ end
31
+ end
32
+
33
+ CredentialsFile::Lock.new(path).with_lock do
34
+ CredentialsFile.remove_profile(path, profile)
35
+ end
36
+ puts "Logged out — removed profile '#{profile}' from #{path}."
37
+ 0
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ module KnoxCall
2
+ module CLI
3
+ # `knoxcall whoami` — show the signed-in tenant via the SDK client.
4
+ module Whoami
5
+ module_function
6
+
7
+ def run(options)
8
+ path = CredentialsFile.resolve_path
9
+ profile = CredentialsFile.resolve_profile(options[:profile])
10
+ if CredentialsFile.read_profile(path, profile).nil?
11
+ raise Error, "not logged in (profile '#{profile}') — run `knoxcall login`"
12
+ end
13
+
14
+ client = Client.new(bootstrap: StoredCredentials.new(path: path, profile: profile))
15
+ account = client.account.get || {}
16
+
17
+ slug = account["slug"].to_s
18
+ name = (account["name"] || account["company_name"]).to_s
19
+ plan = (account["plan"] || account["subscription_plan"]).to_s
20
+ tenant = name.empty? ? slug : name
21
+ puts "Tenant: #{tenant.empty? ? '(unknown)' : tenant}"
22
+ puts "Slug: #{slug}" unless slug.empty?
23
+ puts "Plan: #{plan}" unless plan.empty?
24
+ puts "Profile: #{profile} (#{path})"
25
+ 0
26
+ end
27
+ end
28
+ end
29
+ end