agentadmit 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71a63361a9ebd638c907411b5dcbce487f000d940c2618887d68ffe90a9bc5fa
4
- data.tar.gz: c48534a09f1d07e05d56b34c88dfe2ecc64fcdc61564e880f2c1e948e18c9e67
3
+ metadata.gz: f9dee1c87344bc7be4a6ae662446c18c5ac25fe40dbe384091524026127c091a
4
+ data.tar.gz: 477af00d19a84da65d0405f804982272bceceec85661e7f9a4afba8332c408f3
5
5
  SHA512:
6
- metadata.gz: fc1d745572d89c57767e20bb6320d47816580d8d7a1a07d75333564d2b32ed562843a467483799f793d81b12d5ed185915d8afd376a206ee82029cebd5eb7d9c
7
- data.tar.gz: 8f1f126ad0d27e99fac5e3e58fb43a6064a3078bae8b0b921ce8b3a8499caeaa16a249d7142180c84a76b8d26bc76cfcafaf073965b1e5f3591a83d4493b0889
6
+ metadata.gz: 97aef14949cfaea7090597db980330fc7cc7bfadf63909cdaced2d4e6c9889d0492f03108a0f6db4a31f42243e0dea8eb04c921a3058e6c4c37cab56ecade685
7
+ data.tar.gz: 30b10080024db2dc44b238bf2afa0941b15f697d465c58ed966208a1afbf9d9294fd212f5fe353c832f4ea0bc3491e16e0f63c37089fd3da961aa7d54cb5efaf
data/README.md CHANGED
@@ -140,3 +140,85 @@ For complete compliance guidance, see our [compliance guide](https://agentadmit.
140
140
  ## License
141
141
 
142
142
  All rights reserved. Patent pending.
143
+
144
+ ## Security Alerts
145
+
146
+ ```ruby
147
+ alerts = AgentAdmit::AlertsClient.new
148
+ ```
149
+
150
+ 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`.
151
+
152
+ ### Configure
153
+
154
+ ```ruby
155
+ alerts.configure_alerts(
156
+ app_id: 'app_abc123',
157
+ alert_type: AgentAdmit::AlertsClient::ALERT_TYPE_VOLUME_SPIKE,
158
+ enabled: true, threshold_value: 100, threshold_window_minutes: 5,
159
+ kill_switch_enabled: true,
160
+ )
161
+ ```
162
+
163
+ ### List Events
164
+
165
+ ```ruby
166
+ result = alerts.list_alerts(app_id: 'app_abc123', alert_type: AgentAdmit::AlertsClient::ALERT_TYPE_VOLUME_SPIKE)
167
+ ```
168
+
169
+ ### Get Config
170
+
171
+ ```ruby
172
+ config = alerts.get_alert_config(app_id: 'app_abc123')
173
+ ```
174
+
175
+
176
+ ### Notifying Your Users
177
+
178
+ 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.).
179
+
180
+ - **Poll alerts** — Use the SDK methods above from your backend to check for new events, then notify users through your existing system.
181
+ - **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:
182
+
183
+ ```ruby
184
+ # Rails controller
185
+ def alerts
186
+ AgentAdmit::Webhook.verify_signature(
187
+ request.raw_post,
188
+ request.headers["X-AgentAdmit-Signature"].to_s,
189
+ AgentAdmit.configuration.webhook_secret # whsec_… from AGENTADMIT_WEBHOOK_SECRET
190
+ )
191
+ event = JSON.parse(request.raw_post)
192
+ # ...
193
+ head :ok
194
+ rescue AgentAdmit::WebhookSignatureError
195
+ head :bad_request
196
+ end
197
+ ```
198
+
199
+ 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).
200
+ - **React SDK** — Embed the `<AlertsPanel>` component so users can view their own alert history and tighten thresholds.
201
+
202
+ ### Issuing & Exchanging Tokens
203
+
204
+ ```ruby
205
+ tokens = AgentAdmit::TokensClient.new
206
+
207
+ # Duration is tri-state:
208
+ # omit the argument → AgentAdmit default (30 days)
209
+ # nil → until the user revokes
210
+ # Integer (60–31536000) → explicit seconds
211
+ issued = tokens.issue_token(
212
+ user_id: "user_42",
213
+ scopes: ["read:orders"],
214
+ role: "user",
215
+ duration_seconds: nil # until revoked
216
+ )
217
+ connection_token = issued["token"] # ag_ct_…
218
+
219
+ # Agent side — no API key needed; the connection token is the credential.
220
+ granted = tokens.exchange(connection_token, agent_label: "MyAssistant")
221
+
222
+ # Revoke when the user disconnects the agent.
223
+ tokens.revoke(granted["connection_id"], reason: "user_requested")
224
+ ```
@@ -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.0"
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.0
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-11 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: []