agentadmit 1.1.2 → 1.3.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: 8231757da1987653f73ed2c9b1cfa48c8745c2220856d99da8427fc017406616
4
- data.tar.gz: a0128eb51759e86da018e9852db33cafaf853b86fb9f7cd3f15f6aabd06009e6
3
+ metadata.gz: 74299884ea21ad2cb70261b0d2e7025bf4bc07e330f76bfc44009af8bba3b71b
4
+ data.tar.gz: 846ec2f9b8ed3afe7e9113fa7e1c1bf4b2fd7b074ce0f61e96550347db6c185b
5
5
  SHA512:
6
- metadata.gz: 5eb0d7b249d27e9f5455eabc5b3214493dcd82b6be6563530ec05c66669aef52ae164aba352a21c744024be2b2b7a080fa5581bafff3ff9a1dc4aecab76f78a1
7
- data.tar.gz: 8f955a6ac7d15948da7bf679e7fab80d5d0576110c672ba1e179f9e5458d6e1a6ef12dc60325d121dc41ccecdb69d0d709c3bbc8c125c3bff5bc6780c6601c84
6
+ metadata.gz: 25964578da91a2be9763398021fad033e9538cf6270c2fe99d66185c4da6d884514bcf9c9cfd9a2f60d017e0aeecdeb62d9d6ce1ef19ebfa543eb274cf2cc688
7
+ data.tar.gz: fdba91838161e85923cca0e4f1c84d72b724ad7bcf8e1be1c248f6af5ab1c1ec2506345e27016e7c8b3a7a989c5c65c3474fa263c23466cc7b100ec06b4263fc
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  User-mediated AI agent authorization. Plug-and-play for any Rails app.
4
4
 
5
- > **Get started:** Sign up at [agentadmit.com](https://agentadmit.com) Get your test keys Install the SDK Build.
5
+ > **Get started:** Sign up at [agentadmit.com](https://agentadmit.com) -- Get your test keys -- Install the SDK -- Build.
6
6
  > Test keys are available immediately after signup. Live keys become available when you subscribe an app.
7
7
 
8
8
  ## Quick Start
@@ -14,7 +14,6 @@ gem 'agentadmit'
14
14
 
15
15
  ```bash
16
16
  bundle install
17
- rails generate agentadmit:install
18
17
  ```
19
18
 
20
19
  Add your credentials to `config/credentials.yml.enc` or `.env`:
@@ -24,6 +23,14 @@ AGENTADMIT_APP_ID=app_yourappid
24
23
  AGENTADMIT_API_KEY=aa_test_yourkey
25
24
  ```
26
25
 
26
+ Create an initializer at `config/initializers/agentadmit.rb`:
27
+
28
+ ```ruby
29
+ AgentAdmit.configure do |config|
30
+ # Defaults are read from ENV - nothing required here unless you need overrides.
31
+ end
32
+ ```
33
+
27
34
  Add scope enforcement to any controller:
28
35
 
29
36
  ```ruby
@@ -36,13 +43,16 @@ class OrdersController < ApplicationController
36
43
  end
37
44
  ```
38
45
 
46
+ The `require_scope_if_agent!` method is available in all controllers automatically
47
+ when you use Rails -- the Railtie includes `AgentAdmit::ScopeEnforcement` into
48
+ `ActionController::Base` on load.
49
+
39
50
  Your app now supports AI agent connections with:
40
51
  - Scoped access control (you define the scopes)
41
52
  - User-controlled connection duration
42
53
  - Token generation and exchange
43
54
  - Mandatory introspection (every agent request validated through AgentAdmit)
44
- - Revocation and audit logging
45
- - Discovery endpoint at `/.well-known/agentadmit`
55
+ - Revocation support via `tokens.revoke`
46
56
 
47
57
  ## How It Works
48
58
 
@@ -58,27 +68,49 @@ The token goes to the human, not the agent. No automated delivery = no prompt in
58
68
 
59
69
  **Mandatory introspection.** All token validation goes through api.agentadmit.com. There is no self-hosted mode. No local JWT validation. No bypass. This is required for security, audit logging, and scope enforcement.
60
70
 
61
- **Admin revocation.** As the app operator, you can revoke any user's agent connection via `DELETE /agentadmit/admin/connections/{connection_id}` (requires admin role or `manage:connections` scope).
62
-
63
71
  **Embeddable admin panel.** Drop the `<AgentAdmitAdminPanel>` React component into your admin section to view all agent connections, usage metrics, billing status, and revoke any connection without leaving your app. See the React SDK for details.
64
72
 
65
73
  **In-app AI scopes.** If your app has built-in AI features (analysis, plan generation, photo recognition), do not expose those as agent scopes. The user's AI agent can read the raw data and do the analysis itself. Exposing in-app AI endpoints to agents creates double cost.
66
74
 
75
+ ### Consent Ledger (Caller-Identity Consent)
76
+
77
+ AgentAdmit can host per-user consent switches for three independent caller classes: `human_session`, `in_app_ai`, and `external_agent`. No class's setting implies another's.
78
+
79
+ **External agents:** the verify result already carries the verdict:
80
+
81
+ ```ruby
82
+ result = AgentAdmit::IntrospectionClient.new.verify(token)
83
+ unless result.consent_granted?
84
+ # the data owner has switched external agents off: return your own 403
85
+ end
86
+ ```
87
+
88
+ **Human sessions and in-app AI** never hold AgentAdmit tokens, so ask directly:
89
+
90
+ ```ruby
91
+ verdict = AgentAdmit::IntrospectionClient.new.check_consent(
92
+ app_user_id: "user_8842", caller_class: "in_app_ai"
93
+ )
94
+ head :forbidden unless verdict["granted"]
95
+ ```
96
+
97
+ Consent is orthogonal to revocation: a denied verdict means your app returns its own 403; the connection and token stay valid so the user can flip consent back on without re-connecting. Write switches through `PUT /api/v1/consent/settings` from your backend; export the audit trail with `GET /api/v1/consent/export` (every plan).
98
+
67
99
  ## Rate Limiting
68
100
 
69
- The AgentAdmit introspection endpoint enforces rate limits. The Ruby SDK handles HTTP 429 responses **automatically** with exponential backoff and jitter no changes needed in your middleware code.
101
+ The AgentAdmit introspection endpoint enforces rate limits. The Ruby SDK handles HTTP 429 responses **automatically** with exponential backoff and jitter -- no changes needed in your middleware code.
70
102
 
71
103
  ### Retry behavior
72
104
 
73
105
  | Parameter | Default | Description |
74
106
  |-----------|---------|-------------|
75
107
  | Initial delay | 1 second | First retry wait |
76
- | Backoff multiplier | | Doubles each retry |
108
+ | Backoff multiplier | 2x | Doubles each retry |
77
109
  | Cap | 30 seconds | Maximum wait per retry |
78
- | Jitter | 0500 ms | Random addition to each delay |
110
+ | Jitter | 0-500 ms | Random addition to each delay |
79
111
  | Max retries | **3** | Configurable |
80
112
 
81
- The SDK also respects the `Retry-After` response header if present, it overrides the computed backoff delay.
113
+ The SDK also respects the `Retry-After` response header -- if present, it overrides the computed backoff delay.
82
114
 
83
115
  ### Configuring max retries
84
116
 
@@ -107,10 +139,10 @@ end
107
139
  ```
108
140
 
109
141
  `RateLimitError` attributes:
110
- - `retry_after` seconds from `Retry-After` header (`nil` if absent)
111
- - `limit` `X-RateLimit-Limit` header value (`nil` if absent)
112
- - `remaining` `X-RateLimit-Remaining` header value (`nil` if absent)
113
- - `reset` `X-RateLimit-Reset` Unix timestamp (`nil` if absent)
142
+ - `retry_after` -- seconds from `Retry-After` header (`nil` if absent)
143
+ - `limit` -- `X-RateLimit-Limit` header value (`nil` if absent)
144
+ - `remaining` -- `X-RateLimit-Remaining` header value (`nil` if absent)
145
+ - `reset` -- `X-RateLimit-Reset` Unix timestamp (`nil` if absent)
114
146
 
115
147
  ## Documentation
116
148
 
@@ -122,14 +154,14 @@ Full integration guide: https://agentadmit.com/docs/app-owner-guide
122
154
  The AgentAdmit Ruby SDK runs server-side and does not interact with app stores or end-user devices directly.
123
155
 
124
156
  ### What the SDK does
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
157
+ - 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
158
  - Enforces scope-based access control on your API routes
127
- - Manages connection lifecycle (create, revoke, audit) using your configured storage backend
159
+ - Manages connection lifecycle (create, revoke) using the AgentAdmit hosted service
128
160
 
129
161
  ### What the SDK does NOT do
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
162
+ - 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
163
+ - Does not perform passive background telemetry or analytics -- network calls occur only during active token validation
164
+ - Does not maintain its own persistent local storage
133
165
 
134
166
  ### What the AgentAdmit hosted service records
135
167
  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.
@@ -177,10 +209,10 @@ config = alerts.get_alert_config(app_id: 'app_abc123')
177
209
 
178
210
  ### Notifying Your Users
179
211
 
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.).
212
+ 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
213
 
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:
214
+ - **Poll alerts** -- Use the SDK methods above from your backend to check for new events, then notify users through your existing system.
215
+ - **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
216
 
185
217
  ```ruby
186
218
  # Rails controller
@@ -188,7 +220,7 @@ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes
188
220
  AgentAdmit::Webhook.verify_signature(
189
221
  request.raw_post,
190
222
  request.headers["X-AgentAdmit-Signature"].to_s,
191
- AgentAdmit.configuration.webhook_secret # whsec_ from AGENTADMIT_WEBHOOK_SECRET
223
+ AgentAdmit.configuration.webhook_secret # whsec_... from AGENTADMIT_WEBHOOK_SECRET
192
224
  )
193
225
  event = JSON.parse(request.raw_post)
194
226
  # ...
@@ -198,8 +230,8 @@ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes
198
230
  end
199
231
  ```
200
232
 
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.
233
+ 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).
234
+ - **React SDK** -- Embed the `<AlertsPanel>` component so users can view their own alert history and tighten thresholds.
203
235
 
204
236
  ### Issuing & Exchanging Tokens
205
237
 
@@ -207,18 +239,18 @@ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes
207
239
  tokens = AgentAdmit::TokensClient.new
208
240
 
209
241
  # Duration is tri-state:
210
- # omit the argument AgentAdmit default (30 days)
211
- # nil until the user revokes
212
- # Integer (6031536000) explicit seconds
242
+ # omit the argument => AgentAdmit default (30 days)
243
+ # nil => until the user revokes
244
+ # Integer (60-31536000) => explicit seconds
213
245
  issued = tokens.issue_token(
214
246
  user_id: "user_42",
215
247
  scopes: ["read:orders"],
216
248
  role: "user",
217
249
  duration_seconds: nil # until revoked
218
250
  )
219
- connection_token = issued["token"] # ag_ct_
251
+ connection_token = issued["token"] # ag_ct_...
220
252
 
221
- # Agent side no API key needed; the connection token is the credential.
253
+ # Agent side -- no API key needed; the connection token is the credential.
222
254
  granted = tokens.exchange(connection_token, agent_label: "MyAssistant")
223
255
 
224
256
  # Revoke when the user disconnects the agent.
@@ -5,26 +5,39 @@
5
5
  # There is no self-hosted mode. No local JWT validation. No bypass.
6
6
  # This is required for security, audit logging, and scope enforcement.
7
7
 
8
+ require "uri"
9
+
8
10
  module AgentAdmit
9
11
  class Config
10
- attr_accessor :app_id, :api_key, :verify_url, :api_url,
11
- :token_prefix_access, :token_prefix_connection,
12
+ attr_accessor :app_id, :api_key, :token_prefix_access, :token_prefix_connection,
12
13
  :webhook_secret, :max_retries
13
14
 
15
+ attr_reader :verify_url, :api_url
16
+
14
17
  def initialize
15
18
  @app_id = ENV.fetch("AGENTADMIT_APP_ID", "")
16
19
  @api_key = ENV.fetch("AGENTADMIT_API_KEY", "")
17
- @verify_url = ENV.fetch("AGENTADMIT_VERIFY_URL", "https://api.agentadmit.com/api/v1/verify")
18
- @api_url = ENV.fetch("AGENTADMIT_API_URL", "https://api.agentadmit.com")
20
+ self.verify_url = ENV.fetch("AGENTADMIT_VERIFY_URL", "https://api.agentadmit.com/api/v1/verify")
21
+ self.api_url = ENV.fetch("AGENTADMIT_API_URL", "https://api.agentadmit.com")
19
22
  @token_prefix_access = "ag_at_"
20
23
  @token_prefix_connection = "ag_ct_"
21
- # Webhook signing secret (whsec_) shown once when you configure the
24
+ # Webhook signing secret (whsec_...) -- shown once when you configure the
22
25
  # alert webhook URL in the dashboard. Used by AgentAdmit::Webhook.
23
26
  @webhook_secret = ENV.fetch("AGENTADMIT_WEBHOOK_SECRET", "")
24
27
  # Max retries on HTTP 429 before raising RateLimitError. Default: 3.
25
28
  @max_retries = ENV.fetch("AGENTADMIT_MAX_RETRIES", "3").to_i
26
29
  end
27
30
 
31
+ def verify_url=(url)
32
+ validate_url!(url, :verify_url)
33
+ @verify_url = url
34
+ end
35
+
36
+ def api_url=(url)
37
+ validate_url!(url, :api_url)
38
+ @api_url = url
39
+ end
40
+
28
41
  ##
29
42
  # Validate the API key prefix (aa_test_/aa_live_) without ever echoing
30
43
  # the key itself.
@@ -37,5 +50,30 @@ module AgentAdmit
37
50
 
38
51
  raise ConfigurationError, "api_key must start with 'aa_test_' or 'aa_live_'"
39
52
  end
53
+
54
+ private
55
+
56
+ # Local loopback hostnames / addresses that are allowed over plain HTTP.
57
+ LOCALHOST_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
58
+
59
+ ##
60
+ # Raise ConfigurationError for non-https URLs unless the host is localhost.
61
+ #
62
+ def validate_url!(url, field)
63
+ return if url.nil? || url.empty?
64
+
65
+ uri = URI.parse(url)
66
+ return if uri.scheme == "https"
67
+
68
+ if uri.scheme == "http" && LOCALHOST_HOSTS.include?(uri.host)
69
+ return
70
+ end
71
+
72
+ raise ConfigurationError,
73
+ "#{field} must use https (got: #{url.inspect}). " \
74
+ "Plain http is only permitted for localhost / 127.0.0.1 / [::1]."
75
+ rescue URI::InvalidURIError
76
+ raise ConfigurationError, "#{field} is not a valid URL: #{url.inspect}"
77
+ end
40
78
  end
41
79
  end
@@ -6,15 +6,34 @@ require "uri"
6
6
 
7
7
  module AgentAdmit
8
8
  ##
9
- # Mandatory introspection client validates tokens via AgentAdmit hosted service.
9
+ # Mandatory introspection client -- validates tokens via AgentAdmit hosted service.
10
10
  # No local JWT decode. Every verification call goes through AgentAdmit.
11
11
  #
12
12
  class IntrospectionClient
13
+ # Hard cap on any single retry wait -- including a server-supplied Retry-After.
14
+ MAX_RETRY_WAIT_MS = 30_000
15
+ # Hard cap on cumulative wait across all retries of a single verify call.
16
+ MAX_RETRY_BUDGET_MS = 120_000
17
+
13
18
  IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
14
- :sub, :role, :app_id, :jti, :exp, keyword_init: true) do
19
+ :sub, :role, :app_id, :jti, :exp, :consent, keyword_init: true) do
15
20
  def has_scope?(scope)
16
21
  scopes.include?(scope)
17
22
  end
23
+
24
+ # Consent Ledger verdict for the external-agent path (additive; may be
25
+ # nil). A denied verdict means the app returns its own 403 -- the token
26
+ # itself stays valid (consent is orthogonal to revocation).
27
+ #
28
+ # Contract (matches the PHP and Java SDKs): an ABSENT consent block
29
+ # means a legacy server that never sends one -- treated as allowed,
30
+ # which is also the platform default for the external-agent class. A
31
+ # PRESENT block grants only on an explicit boolean true; a missing or
32
+ # non-boolean granted value is treated as denied (malformed = deny).
33
+ def consent_granted?
34
+ return true if consent.nil?
35
+ consent["granted"] == true
36
+ end
18
37
  end
19
38
 
20
39
  def initialize(config = nil)
@@ -41,6 +60,7 @@ module AgentAdmit
41
60
 
42
61
  max_retries = @config.respond_to?(:max_retries) ? @config.max_retries.to_i : 3
43
62
  delay_ms = 1000 # initial backoff in milliseconds
63
+ waited_ms = 0 # cumulative wait across retries
44
64
 
45
65
  uri = URI.parse(@config.verify_url)
46
66
  http = build_http(uri)
@@ -72,11 +92,25 @@ module AgentAdmit
72
92
  )
73
93
  end
74
94
 
75
- # Compute wait: honor Retry-After header or use exponential backoff, cap at 30s
76
- wait_ms = retry_after ? (retry_after * 1000).ceil : [delay_ms, 30_000].min
95
+ # Compute wait: Retry-After beats exponential backoff, but both are
96
+ # capped -- Retry-After is untrusted server input and must not pin
97
+ # the caller.
98
+ requested_ms = retry_after ? (retry_after * 1000).ceil : delay_ms
99
+ wait_ms = [[requested_ms, 0].max, MAX_RETRY_WAIT_MS].min
77
100
  jitter_ms = rand(0..500)
78
101
  total_ms = wait_ms + jitter_ms
79
102
 
103
+ if waited_ms + total_ms > MAX_RETRY_BUDGET_MS
104
+ raise RateLimitError.new(
105
+ "AgentAdmit rate limit retry budget (#{MAX_RETRY_BUDGET_MS / 1000}s) exhausted.",
106
+ retry_after: retry_after,
107
+ limit: rl_limit,
108
+ remaining: rl_remaining,
109
+ reset: rl_reset
110
+ )
111
+ end
112
+ waited_ms += total_ms
113
+
80
114
  warn "[AgentAdmit] Rate-limited (attempt #{attempt + 1}/#{max_retries}). " \
81
115
  "Retrying in #{total_ms}ms."
82
116
 
@@ -85,54 +119,143 @@ module AgentAdmit
85
119
  next
86
120
  end
87
121
 
88
- # Non-429 response process normally
89
- case status
90
- when 200
91
- data = JSON.parse(response.body)
92
-
93
- # Check active flag (RFC 7662 introspection pattern).
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.
98
- unless data["active"]
99
- reason = data["error"] || "invalid_token"
100
- raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
122
+ # Non-429: only treat 2xx as a candidate for a valid token.
123
+ unless (200..299).cover?(status)
124
+ if status == 401
125
+ data = JSON.parse(response.body) rescue {}
126
+ raise InvalidTokenError, data["error_description"] || "Token validation failed"
101
127
  end
128
+ raise IntrospectionError, "Verification service returned #{response.code}"
129
+ end
102
130
 
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"
107
- end
131
+ # 2xx -- parse and strictly validate the response body.
132
+ data = begin
133
+ JSON.parse(response.body)
134
+ rescue JSON::ParserError
135
+ raise IntrospectionError, "Introspection response is not valid JSON"
136
+ end
108
137
 
109
- raise InvalidTokenError, "Introspection returned no user" if data["user_id"].nil?
110
-
111
- return IntrospectionResult.new(
112
- user_id: data["user_id"],
113
- connection_id: data["connection_id"],
114
- scopes: data["scopes"] || [],
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"]
121
- )
122
- when 401
123
- data = JSON.parse(response.body) rescue {}
124
- raise InvalidTokenError, data["error_description"] || "Token validation failed"
125
- else
126
- raise IntrospectionError, "Verification service returned #{response.code}"
138
+ # active must be strictly true (boolean).
139
+ unless data["active"] == true
140
+ reason = data["error"] || "invalid_token"
141
+ raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
127
142
  end
143
+
144
+ # insufficient_scope arrives with active: true (token valid,
145
+ # requested scope not granted).
146
+ if data["error"] == "insufficient_scope"
147
+ raise InsufficientScopeError, data["error_description"] || "Scope not granted"
148
+ end
149
+
150
+ # Validate that consumed fields have the expected types when present.
151
+ validate_introspection_types!(data)
152
+
153
+ # Keep any PRESENT consent hash, even with a malformed granted value:
154
+ # coercing it to nil would read as "legacy server, allowed" in
155
+ # consent_granted?, silently failing open. A malformed verdict must
156
+ # stay visible so consent_granted? can deny it.
157
+ consent = data["consent"]
158
+ consent = nil unless consent.is_a?(Hash)
159
+
160
+ return IntrospectionResult.new(
161
+ user_id: data["user_id"],
162
+ connection_id: data["connection_id"],
163
+ scopes: data["scopes"] || [],
164
+ agent_label: data["agent_label"] || "Unknown Agent",
165
+ sub: data["sub"],
166
+ role: data["role"],
167
+ app_id: data["app_id"],
168
+ jti: data["jti"],
169
+ exp: data["exp"],
170
+ consent: consent
171
+ )
128
172
  end
129
173
 
130
174
  # Should never be reached
131
175
  raise IntrospectionError, "Unexpected exit from retry loop"
132
176
  end
133
177
 
178
+ CALLER_CLASSES = %w[human_session in_app_ai external_agent].freeze
179
+
180
+ ##
181
+ # Ask the Consent Ledger whether a caller class may act on a user's data.
182
+ # Decision point for the token-less caller classes (human_session,
183
+ # in_app_ai); external agents get the same verdict on the verify result.
184
+ #
185
+ # @param app_user_id [String] your app's identifier for the data owner
186
+ # @param caller_class [String] "human_session" | "in_app_ai" | "external_agent"
187
+ # @param scope_group [String, nil] optional finer-than-class group
188
+ # @return [Hash] verdict: granted, caller_class, scope_group, source, evaluated_at
189
+ # @raise [ArgumentError] unknown caller_class
190
+ # @raise [IntrospectionError] hosted service unreachable or rejected the call
191
+ #
192
+ def check_consent(app_user_id:, caller_class:, scope_group: nil)
193
+ unless CALLER_CLASSES.include?(caller_class)
194
+ raise ArgumentError, "caller_class must be one of #{CALLER_CLASSES.join(', ')}"
195
+ end
196
+
197
+ uri = URI.parse("#{@config.api_url.sub(%r{/\z}, '')}/api/v1/consent/check")
198
+ http = build_http(uri)
199
+
200
+ request = Net::HTTP::Post.new(uri.path)
201
+ request["Authorization"] = "Bearer #{@config.api_key}"
202
+ request["Content-Type"] = "application/json"
203
+ body = { app_user_id: app_user_id, caller_class: caller_class }
204
+ body[:scope_group] = scope_group if scope_group
205
+ request.body = JSON.generate(body)
206
+
207
+ response = begin
208
+ http.request(request)
209
+ rescue StandardError => e
210
+ raise IntrospectionError, "Consent check failed: #{e.message}"
211
+ end
212
+
213
+ unless (200..299).cover?(response.code.to_i)
214
+ data = JSON.parse(response.body) rescue {}
215
+ raise IntrospectionError,
216
+ data["error_description"] || data["error"] || "Consent check returned #{response.code}"
217
+ end
218
+
219
+ # 2xx -- parse strictly; a garbage body must not read as an empty verdict.
220
+ begin
221
+ JSON.parse(response.body)
222
+ rescue JSON::ParserError
223
+ raise IntrospectionError, "Consent check response is not valid JSON"
224
+ end
225
+ end
226
+
134
227
  private
135
228
 
229
+ ##
230
+ # Enforce that the fields the middleware relies on have the correct types.
231
+ # Any type mismatch means we cannot safely use the response -- treat as invalid.
232
+ #
233
+ # @raise [InvalidTokenError]
234
+ #
235
+ def validate_introspection_types!(data)
236
+ # user_id is required and must be a String.
237
+ unless data["user_id"].is_a?(String)
238
+ raise InvalidTokenError, "Introspection returned no user"
239
+ end
240
+
241
+ # agent_id, connection_id -- must be String when present.
242
+ %w[agent_id connection_id].each do |field|
243
+ val = data[field]
244
+ next if val.nil?
245
+ unless val.is_a?(String)
246
+ raise InvalidTokenError, "Introspection field '#{field}' must be a String"
247
+ end
248
+ end
249
+
250
+ # scopes -- must be Array of Strings when present.
251
+ if data.key?("scopes")
252
+ scopes = data["scopes"]
253
+ unless scopes.is_a?(Array) && scopes.all? { |s| s.is_a?(String) }
254
+ raise InvalidTokenError, "Introspection field 'scopes' must be an Array of Strings"
255
+ end
256
+ end
257
+ end
258
+
136
259
  def build_http(uri)
137
260
  http = Net::HTTP.new(uri.host, uri.port)
138
261
  http.use_ssl = uri.scheme == "https"
@@ -6,13 +6,17 @@ module AgentAdmit
6
6
  # and validates them via introspection.
7
7
  #
8
8
  # Sets env variables for downstream use:
9
- # env['agentadmit.auth_type'] "agent" or nil
10
- # env['agentadmit.user_id'] validated user ID
11
- # env['agentadmit.scopes'] granted scopes array
12
- # env['agentadmit.connection_id'] connection identifier
13
- # env['agentadmit.agent_label'] agent display name
9
+ # env['agentadmit.auth_type'] -- "agent" or nil
10
+ # env['agentadmit.user_id'] -- validated user ID
11
+ # env['agentadmit.scopes'] -- granted scopes array
12
+ # env['agentadmit.connection_id'] -- connection identifier
13
+ # env['agentadmit.agent_label'] -- agent display name
14
14
  #
15
15
  class Middleware
16
+ # RFC 7235: the auth-scheme token is case-insensitive.
17
+ # Match "bearer", "Bearer", "BEARER", etc. followed by the ag_at_ prefix.
18
+ BEARER_AGENT_RE = /\Abearer ag_at_/i
19
+
16
20
  def initialize(app)
17
21
  @app = app
18
22
  @client = IntrospectionClient.new
@@ -22,8 +26,9 @@ module AgentAdmit
22
26
  def call(env)
23
27
  auth = env["HTTP_AUTHORIZATION"] || ""
24
28
 
25
- if auth.start_with?("Bearer #{@config.token_prefix_access}")
26
- token = auth.sub("Bearer ", "")
29
+ if BEARER_AGENT_RE.match?(auth)
30
+ # Strip the scheme prefix (case-insensitively) to get the bare token.
31
+ token = auth.sub(/\Abearer /i, "")
27
32
 
28
33
  begin
29
34
  result = @client.verify(token)
@@ -4,6 +4,7 @@ module AgentAdmit
4
4
  class Railtie < Rails::Railtie
5
5
  initializer "agentadmit.middleware" do |app|
6
6
  app.middleware.use AgentAdmit::Middleware
7
+ Rails.logger.info "[AgentAdmit] Middleware and scope enforcement active."
7
8
  end
8
9
 
9
10
  initializer "agentadmit.configure" do
@@ -11,5 +12,12 @@ module AgentAdmit
11
12
  # Config loaded from environment variables by default
12
13
  end
13
14
  end
15
+
16
+ # Include ScopeEnforcement into every ActionController so that
17
+ # require_scope_if_agent! and require_scope! are available without
18
+ # an explicit `include` in each controller.
19
+ ActiveSupport.on_load(:action_controller) do
20
+ include AgentAdmit::ScopeEnforcement
21
+ end
14
22
  end
15
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.1.2"
4
+ VERSION = "1.3.0"
5
5
  end
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.1.2
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Emerson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-17 00:00:00.000000000 Z
11
+ date: 2026-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json