agentadmit 1.0.0 → 1.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71a63361a9ebd638c907411b5dcbce487f000d940c2618887d68ffe90a9bc5fa
4
- data.tar.gz: c48534a09f1d07e05d56b34c88dfe2ecc64fcdc61564e880f2c1e948e18c9e67
3
+ metadata.gz: 8231757da1987653f73ed2c9b1cfa48c8745c2220856d99da8427fc017406616
4
+ data.tar.gz: a0128eb51759e86da018e9852db33cafaf853b86fb9f7cd3f15f6aabd06009e6
5
5
  SHA512:
6
- metadata.gz: fc1d745572d89c57767e20bb6320d47816580d8d7a1a07d75333564d2b32ed562843a467483799f793d81b12d5ed185915d8afd376a206ee82029cebd5eb7d9c
7
- data.tar.gz: 8f1f126ad0d27e99fac5e3e58fb43a6064a3078bae8b0b921ce8b3a8499caeaa16a249d7142180c84a76b8d26bc76cfcafaf073965b1e5f3591a83d4493b0889
6
+ metadata.gz: 5eb0d7b249d27e9f5455eabc5b3214493dcd82b6be6563530ec05c66669aef52ae164aba352a21c744024be2b2b7a080fa5581bafff3ff9a1dc4aecab76f78a1
7
+ data.tar.gz: 8f955a6ac7d15948da7bf679e7fab80d5d0576110c672ba1e179f9e5458d6e1a6ef12dc60325d121dc41ccecdb69d0d709c3bbc8c125c3bff5bc6780c6601c84
data/README.md CHANGED
@@ -122,15 +122,17 @@ Full integration guide: https://agentadmit.com/docs/app-owner-guide
122
122
  The AgentAdmit Ruby SDK runs server-side and does not interact with app stores or end-user devices directly.
123
123
 
124
124
  ### What the SDK does
125
- - Validates AgentAdmit tokens presented by AI agents
125
+ - Validates AgentAdmit tokens by calling AgentAdmit's hosted introspection endpoint (`https://api.agentadmit.com/api/v1/verify`) on every agent request — this is mandatory introspection; there is no local or offline validation mode
126
126
  - Enforces scope-based access control on your API routes
127
- - Manages connection lifecycle (create, revoke, audit)
127
+ - Manages connection lifecycle (create, revoke, audit) using your configured storage backend
128
128
 
129
129
  ### What the SDK does NOT do
130
- - Does not collect end-user data
131
- - Does not send telemetry or analytics
132
- - Does not phone home to AgentAdmit servers (all operations use your configured keys and storage)
133
- - Does not track users or devices
130
+ - Does not transmit raw end-user PII (such as name, email, or device identifiers) — each introspection request sends the opaque access token and your API key
131
+ - Does not perform passive background telemetry or analytics — network calls occur only during active token validation
132
+ - Does not maintain its own persistent storage — local state (connections, audit log) lives in the storage backend you configure
133
+
134
+ ### What the AgentAdmit hosted service records
135
+ On every token validation, AgentAdmit's `/api/v1/verify` endpoint receives the access token and API key, resolves the token to its `user_id`, `connection_id`, granted `scopes`, and `agent_label`, and records per-call metadata (including the endpoint and timestamp) for billing, audit logging, the security alerts engine, and usage metering. This is integral to how AgentAdmit works and applies to both test and live keys. See the "Mandatory introspection" notes above and the [compliance guide](https://agentadmit.com/docs/compliance) for the full data-handling description.
134
136
 
135
137
  ### Privacy impact
136
138
  Since this SDK runs on your server, it has no direct App Store or Play Store compliance surface. Your client-side integration (e.g., the AgentAdmit React SDK) handles privacy manifest and data safety requirements.
@@ -140,3 +142,85 @@ For complete compliance guidance, see our [compliance guide](https://agentadmit.
140
142
  ## License
141
143
 
142
144
  All rights reserved. Patent pending.
145
+
146
+ ## Security Alerts
147
+
148
+ ```ruby
149
+ alerts = AgentAdmit::AlertsClient.new
150
+ ```
151
+
152
+ Six alert type constants: `ALERT_TYPE_VOLUME_SPIKE`, `ALERT_TYPE_FAILED_SCOPE_ATTEMPTS`, `ALERT_TYPE_BURST_PATTERN`, `ALERT_TYPE_STALE_REACTIVATION`, `ALERT_TYPE_NEW_SCOPE_USAGE`, `ALERT_TYPE_REVOKED_CONNECTION_ATTEMPT`.
153
+
154
+ ### Configure
155
+
156
+ ```ruby
157
+ alerts.configure_alerts(
158
+ app_id: 'app_abc123',
159
+ alert_type: AgentAdmit::AlertsClient::ALERT_TYPE_VOLUME_SPIKE,
160
+ enabled: true, threshold_value: 100, threshold_window_minutes: 5,
161
+ kill_switch_enabled: true,
162
+ )
163
+ ```
164
+
165
+ ### List Events
166
+
167
+ ```ruby
168
+ result = alerts.list_alerts(app_id: 'app_abc123', alert_type: AgentAdmit::AlertsClient::ALERT_TYPE_VOLUME_SPIKE)
169
+ ```
170
+
171
+ ### Get Config
172
+
173
+ ```ruby
174
+ config = alerts.get_alert_config(app_id: 'app_abc123')
175
+ ```
176
+
177
+
178
+ ### Notifying Your Users
179
+
180
+ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes connections. **How you notify your own users is up to you.** AgentAdmit provides the data — you deliver it through your own system (in-app notifications, email, push, etc.).
181
+
182
+ - **Poll alerts** — Use the SDK methods above from your backend to check for new events, then notify users through your existing system.
183
+ - **Webhook delivery** — Configure a webhook URL in your AgentAdmit dashboard. When an alert fires, AgentAdmit POSTs the payload to your server, signed with your `whsec_…` secret. Always verify the signature against the raw request body before trusting the payload:
184
+
185
+ ```ruby
186
+ # Rails controller
187
+ def alerts
188
+ AgentAdmit::Webhook.verify_signature(
189
+ request.raw_post,
190
+ request.headers["X-AgentAdmit-Signature"].to_s,
191
+ AgentAdmit.configuration.webhook_secret # whsec_… from AGENTADMIT_WEBHOOK_SECRET
192
+ )
193
+ event = JSON.parse(request.raw_post)
194
+ # ...
195
+ head :ok
196
+ rescue AgentAdmit::WebhookSignatureError
197
+ head :bad_request
198
+ end
199
+ ```
200
+
201
+ The header format is `t=<unix_ts>,v1=<hex>` — an HMAC-SHA256 of `{t}.{raw_body}` keyed with your signing secret. Verification compares in constant time and rejects timestamps more than 5 minutes off (replay protection).
202
+ - **React SDK** — Embed the `<AlertsPanel>` component so users can view their own alert history and tighten thresholds.
203
+
204
+ ### Issuing & Exchanging Tokens
205
+
206
+ ```ruby
207
+ tokens = AgentAdmit::TokensClient.new
208
+
209
+ # Duration is tri-state:
210
+ # omit the argument → AgentAdmit default (30 days)
211
+ # nil → until the user revokes
212
+ # Integer (60–31536000) → explicit seconds
213
+ issued = tokens.issue_token(
214
+ user_id: "user_42",
215
+ scopes: ["read:orders"],
216
+ role: "user",
217
+ duration_seconds: nil # until revoked
218
+ )
219
+ connection_token = issued["token"] # ag_ct_…
220
+
221
+ # Agent side — no API key needed; the connection token is the credential.
222
+ granted = tokens.exchange(connection_token, agent_label: "MyAssistant")
223
+
224
+ # Revoke when the user disconnects the agent.
225
+ tokens.revoke(granted["connection_id"], reason: "user_requested")
226
+ ```
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module AgentAdmit
8
+ ##
9
+ # AlertsClient — configure and query security alerts via the AgentAdmit hosted service.
10
+ #
11
+ # Supported alert types:
12
+ # ALERT_TYPE_VOLUME_SPIKE, ALERT_TYPE_FAILED_SCOPE_ATTEMPTS,
13
+ # ALERT_TYPE_BURST_PATTERN, ALERT_TYPE_STALE_REACTIVATION,
14
+ # ALERT_TYPE_NEW_SCOPE_USAGE, ALERT_TYPE_REVOKED_CONNECTION_ATTEMPT
15
+ #
16
+ # @example
17
+ # client = AgentAdmit::AlertsClient.new
18
+ #
19
+ # client.configure_alerts(
20
+ # app_id: "app_abc123",
21
+ # alert_type: AgentAdmit::AlertsClient::ALERT_TYPE_VOLUME_SPIKE,
22
+ # enabled: true,
23
+ # threshold_value: 100,
24
+ # threshold_window_minutes: 5,
25
+ # )
26
+ #
27
+ class AlertsClient
28
+ ALERT_TYPE_VOLUME_SPIKE = "volume_spike"
29
+ ALERT_TYPE_FAILED_SCOPE_ATTEMPTS = "failed_scope_attempts"
30
+ ALERT_TYPE_BURST_PATTERN = "burst_pattern"
31
+ ALERT_TYPE_STALE_REACTIVATION = "stale_reactivation"
32
+ ALERT_TYPE_NEW_SCOPE_USAGE = "new_scope_usage"
33
+ ALERT_TYPE_REVOKED_CONNECTION_ATTEMPT = "revoked_connection_attempt"
34
+
35
+ def initialize(config = nil)
36
+ @config = config || AgentAdmit.configuration || Config.new
37
+ end
38
+
39
+ ##
40
+ # Configure alert thresholds for an app or connection.
41
+ # POST /api/v1/alerts
42
+ #
43
+ # @param app_id [String]
44
+ # @param alert_type [String] One of the ALERT_TYPE_* constants
45
+ # @param connection_id [String, nil]
46
+ # @param enabled [Boolean, nil]
47
+ # @param threshold_value [Numeric, nil]
48
+ # @param threshold_window_minutes [Integer, nil]
49
+ # @param threshold_rate_per_minute [Numeric, nil]
50
+ # @param stale_days [Integer, nil]
51
+ # @param kill_switch_enabled [Boolean, nil]
52
+ # @param kill_switch_threshold_value [Numeric, nil]
53
+ # @param kill_switch_threshold_window_minutes [Integer, nil]
54
+ # @return [Hash] { "ok" => true, "config" => {...} }
55
+ # @raise [IntrospectionError]
56
+ #
57
+ def configure_alerts(
58
+ app_id:,
59
+ alert_type:,
60
+ connection_id: nil,
61
+ enabled: nil,
62
+ threshold_value: nil,
63
+ threshold_window_minutes: nil,
64
+ threshold_rate_per_minute: nil,
65
+ stale_days: nil,
66
+ kill_switch_enabled: nil,
67
+ kill_switch_threshold_value: nil,
68
+ kill_switch_threshold_window_minutes: nil
69
+ )
70
+ body = { app_id: app_id, alert_type: alert_type }
71
+ body[:connection_id] = connection_id unless connection_id.nil?
72
+ body[:enabled] = enabled unless enabled.nil?
73
+ body[:threshold_value] = threshold_value unless threshold_value.nil?
74
+ body[:threshold_window_minutes] = threshold_window_minutes unless threshold_window_minutes.nil?
75
+ body[:threshold_rate_per_minute] = threshold_rate_per_minute unless threshold_rate_per_minute.nil?
76
+ body[:stale_days] = stale_days unless stale_days.nil?
77
+ body[:kill_switch_enabled] = kill_switch_enabled unless kill_switch_enabled.nil?
78
+ body[:kill_switch_threshold_value] = kill_switch_threshold_value unless kill_switch_threshold_value.nil?
79
+ body[:kill_switch_threshold_window_minutes] = kill_switch_threshold_window_minutes unless kill_switch_threshold_window_minutes.nil?
80
+
81
+ post_json("/api/v1/alerts", body)
82
+ end
83
+
84
+ ##
85
+ # List alert events for an app.
86
+ # GET /api/v1/alerts
87
+ #
88
+ # @param app_id [String]
89
+ # @param connection_id [String, nil]
90
+ # @param alert_type [String, nil]
91
+ # @param limit [Integer] default 50
92
+ # @param offset [Integer] default 0
93
+ # @return [Hash] { "events" => [...], "total" => Integer, "limit" => Integer, "offset" => Integer }
94
+ # @raise [IntrospectionError]
95
+ #
96
+ def list_alerts(app_id:, connection_id: nil, alert_type: nil, limit: 50, offset: 0)
97
+ params = { app_id: app_id, limit: limit, offset: offset }
98
+ params[:connection_id] = connection_id if connection_id
99
+ params[:alert_type] = alert_type if alert_type
100
+
101
+ get_json("/api/v1/alerts", params)
102
+ end
103
+
104
+ ##
105
+ # Get the current alert configuration for an app.
106
+ # GET /api/v1/alerts/config
107
+ #
108
+ # @param app_id [String]
109
+ # @param connection_id [String, nil]
110
+ # @return [Hash] { "app_id", "app_level", "connection_overrides", "alert_types" }
111
+ # @raise [IntrospectionError]
112
+ #
113
+ def get_alert_config(app_id:, connection_id: nil)
114
+ params = { app_id: app_id }
115
+ params[:connection_id] = connection_id if connection_id
116
+
117
+ get_json("/api/v1/alerts/config", params)
118
+ end
119
+
120
+ private
121
+
122
+ def api_base
123
+ base = @config.respond_to?(:api_url) ? @config.api_url : "https://api.agentadmit.com"
124
+ base.to_s.chomp("/")
125
+ end
126
+
127
+ def auth_headers
128
+ {
129
+ "Authorization" => "Bearer #{@config.api_key}",
130
+ "X-App-Id" => @config.app_id.to_s,
131
+ "Content-Type" => "application/json",
132
+ }
133
+ end
134
+
135
+ def post_json(path, body)
136
+ uri = URI.parse("#{api_base}#{path}")
137
+ http = build_http(uri)
138
+ req = Net::HTTP::Post.new(uri.path)
139
+ auth_headers.each { |k, v| req[k] = v }
140
+ req.body = JSON.generate(body)
141
+
142
+ response = http.request(req)
143
+ check_status(response, "POST #{path}")
144
+ JSON.parse(response.body)
145
+ rescue IntrospectionError
146
+ raise
147
+ rescue StandardError => e
148
+ raise IntrospectionError, "AgentAdmit alerts request failed: #{e.message}"
149
+ end
150
+
151
+ def get_json(path, params = {})
152
+ query = URI.encode_www_form(params)
153
+ uri = URI.parse("#{api_base}#{path}?#{query}")
154
+ http = build_http(uri)
155
+ req = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
156
+ auth_headers.each { |k, v| req[k] = v }
157
+
158
+ response = http.request(req)
159
+ check_status(response, "GET #{path}")
160
+ JSON.parse(response.body)
161
+ rescue IntrospectionError
162
+ raise
163
+ rescue StandardError => e
164
+ raise IntrospectionError, "AgentAdmit alerts request failed: #{e.message}"
165
+ end
166
+
167
+ def build_http(uri)
168
+ http = Net::HTTP.new(uri.host, uri.port)
169
+ http.use_ssl = uri.scheme == "https"
170
+ http.read_timeout = 10
171
+ http.open_timeout = 5
172
+ http
173
+ end
174
+
175
+ def check_status(response, operation)
176
+ status = response.code.to_i
177
+ return if status < 400
178
+
179
+ raise IntrospectionError,
180
+ "AgentAdmit #{operation} failed with HTTP #{status}: #{response.body}"
181
+ end
182
+ end
183
+ end
@@ -9,17 +9,33 @@ module AgentAdmit
9
9
  class Config
10
10
  attr_accessor :app_id, :api_key, :verify_url, :api_url,
11
11
  :token_prefix_access, :token_prefix_connection,
12
- :max_retries
12
+ :webhook_secret, :max_retries
13
13
 
14
14
  def initialize
15
15
  @app_id = ENV.fetch("AGENTADMIT_APP_ID", "")
16
16
  @api_key = ENV.fetch("AGENTADMIT_API_KEY", "")
17
- @verify_url = ENV.fetch("AGENTADMIT_VERIFY_URL", "https://api.agentadmit.com/v1/verify")
17
+ @verify_url = ENV.fetch("AGENTADMIT_VERIFY_URL", "https://api.agentadmit.com/api/v1/verify")
18
18
  @api_url = ENV.fetch("AGENTADMIT_API_URL", "https://api.agentadmit.com")
19
19
  @token_prefix_access = "ag_at_"
20
20
  @token_prefix_connection = "ag_ct_"
21
+ # Webhook signing secret (whsec_…) — shown once when you configure the
22
+ # alert webhook URL in the dashboard. Used by AgentAdmit::Webhook.
23
+ @webhook_secret = ENV.fetch("AGENTADMIT_WEBHOOK_SECRET", "")
21
24
  # Max retries on HTTP 429 before raising RateLimitError. Default: 3.
22
25
  @max_retries = ENV.fetch("AGENTADMIT_MAX_RETRIES", "3").to_i
23
26
  end
27
+
28
+ ##
29
+ # Validate the API key prefix (aa_test_/aa_live_) without ever echoing
30
+ # the key itself.
31
+ #
32
+ # @raise [ConfigurationError] if a non-empty key has the wrong prefix
33
+ #
34
+ def validate_api_key!
35
+ return if api_key.nil? || api_key.empty?
36
+ return if api_key.start_with?("aa_test_", "aa_live_")
37
+
38
+ raise ConfigurationError, "api_key must start with 'aa_test_' or 'aa_live_'"
39
+ end
24
40
  end
25
41
  end
@@ -10,7 +10,8 @@ module AgentAdmit
10
10
  # No local JWT decode. Every verification call goes through AgentAdmit.
11
11
  #
12
12
  class IntrospectionClient
13
- IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label, keyword_init: true) do
13
+ IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
14
+ :sub, :role, :app_id, :jti, :exp, keyword_init: true) do
14
15
  def has_scope?(scope)
15
16
  scopes.include?(scope)
16
17
  end
@@ -18,6 +19,7 @@ module AgentAdmit
18
19
 
19
20
  def initialize(config = nil)
20
21
  @config = config || AgentAdmit.configuration || Config.new
22
+ @config.validate_api_key!
21
23
  end
22
24
 
23
25
  ##
@@ -89,11 +91,19 @@ module AgentAdmit
89
91
  data = JSON.parse(response.body)
90
92
 
91
93
  # Check active flag (RFC 7662 introspection pattern).
92
- # The verify endpoint returns {active: false} with HTTP 200 for invalid/
93
- # expired/revoked tokens. Without this check, we'd read empty scopes.
94
+ # The verify endpoint returns {active: false} with HTTP 200 for
95
+ # invalid/expired/revoked tokens; the error code is one of
96
+ # VERIFY_ERROR_CODES (e.g. token_expired, connection_expired,
97
+ # environment_mismatch). Without this check, we'd read empty scopes.
94
98
  unless data["active"]
95
99
  reason = data["error"] || "invalid_token"
96
- raise InvalidTokenError, "Token is not active: #{reason}"
100
+ raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
101
+ end
102
+
103
+ # insufficient_scope arrives with active: true (token valid,
104
+ # requested scope not granted).
105
+ if data["error"] == "insufficient_scope"
106
+ raise InsufficientScopeError, data["error_description"] || "Scope not granted"
97
107
  end
98
108
 
99
109
  raise InvalidTokenError, "Introspection returned no user" if data["user_id"].nil?
@@ -102,7 +112,12 @@ module AgentAdmit
102
112
  user_id: data["user_id"],
103
113
  connection_id: data["connection_id"],
104
114
  scopes: data["scopes"] || [],
105
- agent_label: data["agent_label"] || "Unknown Agent"
115
+ agent_label: data["agent_label"] || "Unknown Agent",
116
+ sub: data["sub"],
117
+ role: data["role"],
118
+ app_id: data["app_id"],
119
+ jti: data["jti"],
120
+ exp: data["exp"]
106
121
  )
107
122
  when 401
108
123
  data = JSON.parse(response.body) rescue {}
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module AgentAdmit
8
+ ##
9
+ # TokensClient — issue, exchange, and revoke connection tokens via the
10
+ # AgentAdmit hosted service.
11
+ #
12
+ class TokensClient
13
+ # Sentinel for issue_token's duration_seconds: leave the field out of the
14
+ # request entirely, so AgentAdmit applies its default (30 days). Pass nil
15
+ # instead for an until-revoked connection (explicit JSON null).
16
+ UNSET = Object.new.freeze
17
+
18
+ def initialize(config = nil)
19
+ @config = config || AgentAdmit.configuration || Config.new
20
+ @config.validate_api_key!
21
+ end
22
+
23
+ ##
24
+ # Issue a connection token for one of your users.
25
+ # Calls POST /api/v1/apps/{app_id}/token.
26
+ #
27
+ # The duration is tri-state:
28
+ # - omit the argument — field omitted; AgentAdmit applies its default
29
+ # (30 days)
30
+ # - nil — explicit JSON null; the connection lasts until revoked
31
+ # - Integer — explicit duration in seconds (60–31536000)
32
+ #
33
+ # @param user_id [String] your app's identifier for the user
34
+ # @param scopes [Array<String>] scopes the connection grants
35
+ # @param role [String, nil] the user's role on the connection
36
+ # @param duration_seconds [Integer, nil, UNSET] see above
37
+ # @return [Hash] the issue response — "token" is the self-describing
38
+ # ag_ct_… connection token to hand to the user's agent
39
+ # @raise [IntrospectionError] if issuance fails
40
+ #
41
+ def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET)
42
+ body = { "user_id" => user_id, "scopes" => scopes }
43
+ body["role"] = role if role
44
+ # Tri-state: the UNSET sentinel omits the key entirely; nil survives
45
+ # JSON.generate as explicit JSON null (no compact, no nil-guard).
46
+ body["duration_seconds"] = duration_seconds unless duration_seconds.equal?(UNSET)
47
+
48
+ post("/api/v1/apps/#{@config.app_id}/token", body, authenticated: true, op: "issue_token")
49
+ end
50
+
51
+ ##
52
+ # Exchange a single-use connection token for an access token.
53
+ # Calls POST /api/v1/exchange — unauthenticated by design: the connection
54
+ # token itself is the credential, so the operator API key is NOT sent.
55
+ #
56
+ # @param connection_token [String] the ag_ct_… connection token
57
+ # @param agent_label [String, nil] human-readable agent name
58
+ # @param agent_id [String, nil] agent identifier
59
+ # @return [Hash] the exchange response — "access_token" is the ag_at_… token
60
+ # @raise [IntrospectionError] if the exchange fails
61
+ #
62
+ def exchange(connection_token, agent_label: nil, agent_id: nil)
63
+ body = { "token" => connection_token }
64
+ body["agent_label"] = agent_label if agent_label
65
+ body["agent_id"] = agent_id if agent_id
66
+
67
+ post("/api/v1/exchange", body, authenticated: false, op: "exchange")
68
+ end
69
+
70
+ ##
71
+ # Revoke a connection (and its access tokens).
72
+ # Calls POST /api/v1/revoke.
73
+ #
74
+ # @param connection_id [String] the connection to revoke
75
+ # @param reason [String, nil] optional human-readable reason
76
+ # @return [Hash] the revoke response — { "ok" => true, ... }
77
+ # @raise [IntrospectionError] if the revocation fails
78
+ #
79
+ def revoke(connection_id, reason: nil)
80
+ body = { "connection_id" => connection_id }
81
+ body["reason"] = reason if reason
82
+
83
+ post("/api/v1/revoke", body, authenticated: true, op: "revoke")
84
+ end
85
+
86
+ private
87
+
88
+ def post(path, body, authenticated:, op:)
89
+ uri = URI.parse("#{@config.api_url.chomp('/')}#{path}")
90
+ http = Net::HTTP.new(uri.host, uri.port)
91
+ http.use_ssl = uri.scheme == "https"
92
+ http.read_timeout = 10
93
+ http.open_timeout = 10
94
+
95
+ request = Net::HTTP::Post.new(uri.path)
96
+ request["Content-Type"] = "application/json"
97
+ if authenticated
98
+ # No Authorization on /exchange — the connection token is the credential.
99
+ request["Authorization"] = "Bearer #{@config.api_key}"
100
+ request["X-App-Id"] = @config.app_id
101
+ end
102
+ request.body = JSON.generate(body)
103
+
104
+ begin
105
+ response = http.request(request)
106
+ rescue StandardError => e
107
+ raise IntrospectionError, "#{op} failed: #{e.message}"
108
+ end
109
+
110
+ status = response.code.to_i
111
+ raise IntrospectionError, "#{op} returned #{status}" if status >= 400
112
+
113
+ JSON.parse(response.body)
114
+ rescue JSON::ParserError
115
+ raise IntrospectionError, "#{op} returned an unparseable response"
116
+ end
117
+ end
118
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.2"
5
5
  end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module AgentAdmit
6
+ ##
7
+ # Verification for inbound AgentAdmit alert webhooks.
8
+ #
9
+ # AgentAdmit signs every alert webhook delivery with the app's webhook
10
+ # signing secret (whsec_…, returned once when the webhook URL is
11
+ # configured). The signature arrives in the X-AgentAdmit-Signature header:
12
+ #
13
+ # X-AgentAdmit-Signature: t=<unix_ts>,v1=<hex hmac-sha256>
14
+ #
15
+ # where the HMAC input is "{t}.{raw_body}" keyed with the full whsec_
16
+ # secret. Always verify against the raw request body (request.raw_post in
17
+ # Rails), before any JSON parsing.
18
+ #
19
+ # @example Rails controller
20
+ # def alerts
21
+ # AgentAdmit::Webhook.verify_signature(
22
+ # request.raw_post,
23
+ # request.headers["X-AgentAdmit-Signature"].to_s,
24
+ # AgentAdmit.configuration.webhook_secret
25
+ # )
26
+ # event = JSON.parse(request.raw_post)
27
+ # # ...
28
+ # rescue AgentAdmit::WebhookSignatureError
29
+ # head :bad_request
30
+ # end
31
+ #
32
+ module Webhook
33
+ # Header AgentAdmit signs alert webhook deliveries with.
34
+ SIGNATURE_HEADER = "X-AgentAdmit-Signature"
35
+
36
+ # Default maximum clock skew (seconds) allowed for replay protection.
37
+ DEFAULT_TOLERANCE_SECONDS = 300
38
+
39
+ module_function
40
+
41
+ ##
42
+ # Verify the X-AgentAdmit-Signature header on an inbound alert webhook.
43
+ #
44
+ # @param payload [String] the raw request body
45
+ # @param header [String] the X-AgentAdmit-Signature header value
46
+ # @param secret [String] the app's webhook signing secret (whsec_…)
47
+ # @param tolerance [Integer] max clock skew in seconds (0 disables the check)
48
+ # @param now [Integer, nil] override the current Unix timestamp (for tests)
49
+ # @raise [WebhookSignatureError] if the header is missing/malformed, the
50
+ # timestamp is outside the tolerance window, or no signature matches;
51
+ # the message never includes the secret or the payload
52
+ # @return [void]
53
+ #
54
+ def verify_signature(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now: nil)
55
+ raise WebhookSignatureError, "Webhook signing secret is required" if secret.nil? || secret.empty?
56
+ raise WebhookSignatureError, "Missing X-AgentAdmit-Signature header" if header.nil? || header.empty?
57
+
58
+ timestamp = nil
59
+ candidates = []
60
+ header.split(",").each do |part|
61
+ key, _, value = part.strip.partition("=")
62
+ case key
63
+ when "t"
64
+ raise WebhookSignatureError, "Malformed signature header" unless value.match?(/\A\d+\z/)
65
+
66
+ timestamp = Integer(value)
67
+ when "v1"
68
+ candidates << value
69
+ end
70
+ end
71
+
72
+ raise WebhookSignatureError, "Malformed signature header" if timestamp.nil? || candidates.empty?
73
+
74
+ if tolerance.positive? && ((now || Time.now.to_i) - timestamp).abs > tolerance
75
+ raise WebhookSignatureError, "Signature timestamp outside tolerance window"
76
+ end
77
+
78
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
79
+ matched = candidates.any? { |candidate| secure_compare(expected, candidate) }
80
+
81
+ raise WebhookSignatureError, "Webhook signature verification failed" unless matched
82
+ end
83
+
84
+ ##
85
+ # Boolean form of {verify_signature}.
86
+ #
87
+ # @return [Boolean]
88
+ #
89
+ def valid_signature?(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now: nil)
90
+ verify_signature(payload, header, secret, tolerance: tolerance, now: now)
91
+ true
92
+ rescue WebhookSignatureError
93
+ false
94
+ end
95
+
96
+ ##
97
+ # Constant-time string comparison (portable — OpenSSL.secure_compare is
98
+ # not available on every openssl gem version).
99
+ #
100
+ # @api private
101
+ #
102
+ def secure_compare(expected, candidate)
103
+ return false unless expected.bytesize == candidate.bytesize
104
+
105
+ diff = 0
106
+ expected_bytes = expected.unpack("C*")
107
+ candidate.each_byte.with_index { |byte, i| diff |= byte ^ expected_bytes[i] }
108
+ diff.zero?
109
+ end
110
+ end
111
+ end
data/lib/agentadmit.rb CHANGED
@@ -1,17 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "agentadmit/version"
4
- require_relative "agentadmit/config"
5
- require_relative "agentadmit/introspection_client"
6
- require_relative "agentadmit/middleware"
7
- require_relative "agentadmit/scope_enforcement"
8
- require_relative "agentadmit/railtie" if defined?(Rails)
9
4
 
10
5
  module AgentAdmit
11
6
  class Error < StandardError; end
12
- class InvalidTokenError < Error; end
7
+
8
+ ##
9
+ # Raised when token validation fails. {#code} carries the machine-readable
10
+ # reason from the API — one of VERIFY_ERROR_CODES (e.g. token_expired,
11
+ # connection_expired, environment_mismatch); unknown codes pass through.
12
+ #
13
+ class InvalidTokenError < Error
14
+ # @return [String] machine-readable error code
15
+ attr_reader :code
16
+
17
+ def initialize(message = "Invalid access token", code: "invalid_token")
18
+ super(message)
19
+ @code = code
20
+ end
21
+ end
22
+
13
23
  class InsufficientScopeError < Error; end
14
24
  class IntrospectionError < Error; end
25
+ class ConfigurationError < Error; end
26
+
27
+ ##
28
+ # Raised when an inbound alert webhook fails X-AgentAdmit-Signature
29
+ # verification.
30
+ #
31
+ class WebhookSignatureError < Error; end
32
+
33
+ ##
34
+ # Error codes /api/v1/verify returns with HTTP 200 and active: false
35
+ # (insufficient_scope arrives with active: true — token valid, scope not
36
+ # granted).
37
+ #
38
+ VERIFY_ERROR_CODES = %w[
39
+ invalid_token
40
+ token_expired
41
+ token_revoked
42
+ connection_revoked
43
+ connection_expired
44
+ environment_mismatch
45
+ insufficient_scope
46
+ ].freeze
15
47
 
16
48
  ##
17
49
  # Raised when the AgentAdmit introspection endpoint returns HTTP 429 and
@@ -53,3 +85,13 @@ module AgentAdmit
53
85
  end
54
86
  end
55
87
  end
88
+
89
+ require_relative "agentadmit/config"
90
+ require_relative "agentadmit/introspection_client"
91
+ require_relative "agentadmit/tokens_client"
92
+ require_relative "agentadmit/alerts_client"
93
+ require_relative "agentadmit/webhook"
94
+ require_relative "agentadmit/middleware"
95
+ # ScopeEnforcement is an ActiveSupport::Concern — only loadable inside Rails.
96
+ require_relative "agentadmit/scope_enforcement" if defined?(ActiveSupport)
97
+ require_relative "agentadmit/railtie" if defined?(Rails)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agentadmit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Emerson
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-03 00:00:00.000000000 Z
11
+ date: 2026-06-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -26,7 +26,7 @@ dependencies:
26
26
  version: '0'
27
27
  description: Integrate AgentAdmit into your Rails app. Mandatory introspection, scope
28
28
  enforcement, and secure AI agent connections.
29
- email:
29
+ email:
30
30
  executables: []
31
31
  extensions: []
32
32
  extra_rdoc_files: []
@@ -34,17 +34,20 @@ files:
34
34
  - LICENSE
35
35
  - README.md
36
36
  - lib/agentadmit.rb
37
+ - lib/agentadmit/alerts_client.rb
37
38
  - lib/agentadmit/config.rb
38
39
  - lib/agentadmit/introspection_client.rb
39
40
  - lib/agentadmit/middleware.rb
40
41
  - lib/agentadmit/railtie.rb
41
42
  - lib/agentadmit/scope_enforcement.rb
43
+ - lib/agentadmit/tokens_client.rb
42
44
  - lib/agentadmit/version.rb
45
+ - lib/agentadmit/webhook.rb
43
46
  homepage: https://agentadmit.com/docs
44
47
  licenses:
45
48
  - Nonstandard
46
49
  metadata: {}
47
- post_install_message:
50
+ post_install_message:
48
51
  rdoc_options: []
49
52
  require_paths:
50
53
  - lib
@@ -59,8 +62,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
59
62
  - !ruby/object:Gem::Version
60
63
  version: '0'
61
64
  requirements: []
62
- rubygems_version: 3.0.3.1
63
- signing_key:
65
+ rubygems_version: 3.4.19
66
+ signing_key:
64
67
  specification_version: 4
65
68
  summary: AgentAdmit SDK for Ruby on Rails — User-mediated AI agent authorization
66
69
  test_files: []