agentadmit 1.8.0 → 1.10.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: 9903cf0e0365cc82696a35748e5736582e4d2e9aad33e9c956c62df61c4c5eea
4
- data.tar.gz: e1cbbc38ef3ca551b003574383e759056f3c6f90c2224fc4cd8dc68af836c270
3
+ metadata.gz: ccda6bad890712e5c6060b2c33d02df1b1245ff48918a267cede579acf3f0d84
4
+ data.tar.gz: 5ef222b57006bcba2bd589c2b6276117abcfac031230263a8063ef56449cf355
5
5
  SHA512:
6
- metadata.gz: c3089318cad6fd8c1914a3f087b9e9e2351ee0131b81d6bd288a9fa736868ce6c64937de4a248c0c3c4162c2d4b481e044ef2c642d917d368499e42dd6a72e93
7
- data.tar.gz: 2f2d55a5ea98bc761ccabd6cce0e3e5ac417b0b5b659094c05ca74178abd77752e392c6b6104621ab750ba97aa1264f5b4d541e47c4a243e20b037b1417b69ab
6
+ metadata.gz: a703d18e7a9a55ed4a34b07d2ed295ddfbc49fabdba0caef6b49ef0a57cb670097b78ef5a53849035e22cc3a29709977836df8b8bdd4f660fc09bb20adaac84e
7
+ data.tar.gz: 06a8b43f3a18b696df4ddccb6abb0130c62addff9d4ab948b51ea6ea1d2b0e2bf2cdb11b5cd1da90734fc94d9b6f7e3512f9fae536563418d4d8a361fc058a8b
data/README.md CHANGED
@@ -349,3 +349,64 @@ result.user_intent # => "Book my flights to the Austin offsite in October" or ni
349
349
  ```
350
350
 
351
351
  `user_intent` is nullable -- connections issued without one (or by servers that predate the field) read as `nil`. Months later, a review screen can answer "is this still appropriate?" with the user's own stated boundary, not just the app's. Like purpose, it is a review-time record and never an enforcement input; authorization decisions ride scopes, connection status, and consent.
352
+
353
+ ## App-Attested Presence
354
+
355
+ If your app gates token minting behind its own embedded passkey/WebAuthn ceremony, AgentAdmit never witnesses that ceremony (it is origin-bound), so by default the hosted service reports `presence.verified: false` for those connections. Attest the ceremony fact at issuance to close that gap -- AFTER verifying and consuming your own fresh, purpose-bound attestation:
356
+
357
+ ```ruby
358
+ issued = tokens.issue_token(
359
+ user_id: "user_42",
360
+ scopes: ["read:orders"],
361
+ presence: AgentAdmit::AppAttestedPresence.new(
362
+ method: "my_webauthn", # lowercase alphanumeric/underscore
363
+ verified_at: attestation.created_at # Time or DateTime
364
+ )
365
+ )
366
+ ```
367
+
368
+ The SDK sends it as `presence: {verified: true, uv: true, method, verified_at}` -- `verified`/`uv` are literal true by construction and the class cannot represent anything else; a raw Hash is rejected so the wire contract stays owned by the typed class. The hosted service validates freshness (10-minute window, 60 s future clock-skew slack) and stores the method provenance-marked `app:<method>` so app-attested facts stay distinct from ceremonies AgentAdmit witnessed itself. Introspection, the grant-event ledger, and the evidence API then carry `presence.verified: true` for the connection.
369
+
370
+ Honesty ceiling: this is your app's attestation, recorded and provenance-marked. It is not witnessed by AgentAdmit and not independently verifiable. Only attest a ceremony that verified the user with UV (biometric or PIN user verification); a ceremony without UV carries no presence fact, so pass `nil` (the default). An out-of-contract method (`^[a-z0-9_]+$`, 1-60) raises `ArgumentError` at construction, before any request; Ruby `Time`/`DateTime` always carry an offset, so `verified_at` serializes RFC 3339 with an explicit offset by construction.
371
+
372
+ ## Per-Call Audit Telemetry
373
+
374
+ Every verified call reports the exercised scope, endpoint, and method to your app's tamper-evident audit log on the hosted service. The introspection request body carries three optional fields alongside the token:
375
+
376
+ - `scope_used` -- the single declared scope this call enforces (never a joined list)
377
+ - `endpoint` -- the request path only; the query string is stripped before sending (queries can carry PII) and the path is truncated to 500 characters
378
+ - `method` -- the HTTP method, uppercased, capped at 20 characters
379
+
380
+ Each field is sent whenever it is known and OMITTED when it is not -- never null, never an empty string. When a field is omitted, the audit row honestly records "not reported".
381
+
382
+ The Rack middleware always sends `endpoint` (from `PATH_INFO`) and `method`; declare the enforced scope at mount time to send `scope_used` too:
383
+
384
+ ```ruby
385
+ # Scope resolved per request (env -> scope String or nil) ...
386
+ use AgentAdmit::Middleware, scope_for: ->(env) { SCOPES[env["PATH_INFO"]] }
387
+ # ... or one static scope for everything behind this middleware.
388
+ use AgentAdmit::Middleware, scope_for: "read:orders"
389
+ ```
390
+
391
+ `AgentAdmit::CallerConsent` never sends `scope_used` (its consent gate precedes any scope disclosure; the scope check stays local, after consent) but still reports endpoint and method. Direct client calls pass the same optional keyword arguments:
392
+
393
+ ```ruby
394
+ result = AgentAdmit::IntrospectionClient.new.verify(
395
+ token,
396
+ scope_used: "read:orders",
397
+ endpoint: request.path,
398
+ method: request.request_method
399
+ )
400
+ ```
401
+
402
+ The local `ScopeEnforcement` checks (`require_scope!`, `require_scope_if_agent!`) are unchanged -- defense in depth on top of the hosted decision.
403
+
404
+ ### Active-error responses are denials
405
+
406
+ An introspection response with `active: true` AND a string `error` field means the token itself is valid but the authorization service refused this call. The SDK treats every such response as a denial, never a pass-through -- the downstream app does not run:
407
+
408
+ - `insufficient_scope` -> `AgentAdmit::InsufficientScopeError`; middlewares return 403 with the step-up shape (`error`, `required_scope`, `granted_scopes`)
409
+ - `bound_exceeded` -> `AgentAdmit::BoundExceededError`; middlewares return 403 passing the hosted fields (`error_description`, `bound`, `renewal`) through verbatim
410
+ - any other error string -> `AgentAdmit::ActiveDenialError`; middlewares return 403 with `{error: <code>, error_description: "Call refused by the authorization service."}` -- unknown codes fail closed
411
+
412
+ All three inherit from `AgentAdmit::ActiveDenialError` and expose `#code`, `#data` (the parsed hosted response), and `#denial_body` (the ready-made 403 JSON body) for apps that call `verify` directly.
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module AgentAdmit
6
+ ##
7
+ # App-attested presence: a ceremony fact your app attests at token issuance.
8
+ #
9
+ # Pass an instance to TokensClient#issue_token AFTER verifying and consuming
10
+ # your app's own fresh, purpose-bound WebAuthn/passkey attestation for the
11
+ # mint. The SDK forwards it to the hosted mint as
12
+ # presence {verified: true, uv: true, method, verified_at}; the hosted
13
+ # service stores it method-prefixed "app:<method>" — the provenance marker
14
+ # that keeps app-attested facts distinct from hosted-witnessed ceremonies.
15
+ #
16
+ # Honesty ceiling: this is YOUR attestation, recorded and provenance-marked.
17
+ # It is not witnessed by AgentAdmit and not independently verifiable. Only
18
+ # construct one for a ceremony that verified the user with UV (biometric or
19
+ # PIN user verification); verified/uv serialize as literal true and cannot
20
+ # represent anything else — a ceremony without UV carries no presence fact,
21
+ # so simply pass nil.
22
+ #
23
+ # verified_at must be recent: the hosted service enforces a 10-minute
24
+ # freshness window with 60 seconds of future clock-skew slack. Ruby Time and
25
+ # DateTime always carry an offset, so #iso8601 serializes RFC 3339 with an
26
+ # explicit offset by construction (the hosted contract; offset-less
27
+ # timestamps are rejected with 400).
28
+ #
29
+ class AppAttestedPresence
30
+ METHOD_PATTERN = /\A[a-z0-9_]+\z/
31
+ METHOD_MAX_LENGTH = 60
32
+
33
+ # NOTE: a +method+ reader shadows Object#method on instances — the same
34
+ # trade stdlib's Net::HTTPGenericRequest makes; the name matches the wire
35
+ # field.
36
+ attr_reader :method, :verified_at
37
+
38
+ ##
39
+ # @param method [String] your ceremony mechanism, 1-60 lowercase
40
+ # alphanumeric/underscore characters (e.g. "my_webauthn")
41
+ # @param verified_at [Time, DateTime] when the ceremony completed
42
+ # @raise [ArgumentError] when method is out of contract or verified_at is
43
+ # not a timestamp — validated at construction, before any request,
44
+ # where the fix is obvious
45
+ #
46
+ def initialize(method:, verified_at:)
47
+ unless method.is_a?(String) && !method.empty? &&
48
+ method.length <= METHOD_MAX_LENGTH && METHOD_PATTERN.match?(method)
49
+ raise ArgumentError,
50
+ "method must be 1-#{METHOD_MAX_LENGTH} lowercase alphanumeric/underscore " \
51
+ "characters (e.g. 'my_webauthn')"
52
+ end
53
+ unless verified_at.respond_to?(:iso8601)
54
+ raise ArgumentError,
55
+ "verified_at must be a Time or DateTime (the ceremony that authorized " \
56
+ "this mint just happened)"
57
+ end
58
+
59
+ @method = method
60
+ @verified_at = verified_at
61
+ end
62
+
63
+ ##
64
+ # The exact JSON object forwarded to the hosted mint.
65
+ #
66
+ # @return [Hash]
67
+ #
68
+ def to_wire
69
+ {
70
+ "verified" => true,
71
+ "uv" => true,
72
+ "method" => @method,
73
+ "verified_at" => @verified_at.iso8601
74
+ }
75
+ end
76
+ end
77
+ end
@@ -134,33 +134,37 @@ module AgentAdmit
134
134
  token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
135
135
 
136
136
  begin
137
- result = @client.verify(token)
137
+ # The verify body carries endpoint/method audit telemetry only.
138
+ # scope_used is deliberately NOT sent from this middleware: the
139
+ # hosted refusal body carries no consent verdict and no user_id, so
140
+ # a hosted scope refusal here could not be consent-resolved -- and
141
+ # consent must precede any scope disclosure (Patent FIG. 3). The
142
+ # scope check stays local, after the consent gate, exactly as the
143
+ # other AgentAdmit SDKs do.
144
+ result = @client.verify(token,
145
+ endpoint: env["PATH_INFO"],
146
+ method: env["REQUEST_METHOD"])
147
+ rescue InsufficientScopeError
148
+ # Unreachable when this middleware performs the verify (scope_used
149
+ # is never sent, so the hosted service cannot refuse on scope).
150
+ # Kept as a fail-closed guard that reveals no scope state to a
151
+ # caller class whose consent was never evaluated.
152
+ return [403, { "Content-Type" => "application/json" },
153
+ [{ error: "insufficient_scope",
154
+ message: "Call refused by the authorization service." }.to_json]]
155
+ rescue ActiveDenialError => e
156
+ # Token valid, call refused (bound_exceeded or an unknown error
157
+ # string on an active response). Fail closed: 403, app never runs.
158
+ return [403, { "Content-Type" => "application/json" },
159
+ [e.denial_body.to_json]]
138
160
  rescue InvalidTokenError => e
139
161
  return json_error(401, "invalid_token", e.message)
140
162
  rescue IntrospectionError => e
141
163
  return json_error(502, "introspection_failed", e.message)
142
164
  end
143
165
 
144
- consent = result.consent
145
- unless consent.is_a?(Hash) && [true, false].include?(consent["granted"])
146
- owner = result.user_id
147
- if !owner.is_a?(String) || owner.empty?
148
- return json_error(503, "consent_unavailable",
149
- "Introspection carried no consent verdict and no resolvable data owner")
150
- end
151
-
152
- begin
153
- consent = @client.check_consent(app_user_id: owner, caller_class: EXTERNAL_AGENT,
154
- scope_group: @scope_group)
155
- rescue StandardError
156
- return json_error(503, "consent_unavailable", "Consent check failed")
157
- end
158
- end
159
-
160
- unless consent.is_a?(Hash) && consent["granted"] == true
161
- return json_error(403, "consent_not_granted",
162
- "The data owner has not enabled external agent access.")
163
- end
166
+ status, consent = resolve_external_consent(result.consent, result.user_id)
167
+ return consent unless status == :granted
164
168
 
165
169
  if @required_scope && !(result.scopes || []).include?(@required_scope)
166
170
  return [403, { "Content-Type" => "application/json" },
@@ -181,6 +185,50 @@ module AgentAdmit
181
185
  @app.call(env)
182
186
  end
183
187
 
188
+ ##
189
+ # Resolve the external-agent consent verdict, fail-closed. An inline
190
+ # boolean verdict is authoritative; an absent or malformed one is
191
+ # resolved through the Consent Ledger (absence is never a grant), using
192
+ # the owner from the introspection payload.
193
+ #
194
+ # @return [Array] [:granted, verdict Hash] when the class is allowed,
195
+ # otherwise [:denied | :unavailable, Rack response triple] ready to
196
+ # return.
197
+ #
198
+ def resolve_external_consent(consent, owner)
199
+ unless consent.is_a?(Hash) && [true, false].include?(consent["granted"])
200
+ if !owner.is_a?(String) || owner.empty?
201
+ return [:unavailable,
202
+ json_error(503, "consent_unavailable",
203
+ "Introspection carried no consent verdict and no resolvable data owner")]
204
+ end
205
+
206
+ begin
207
+ consent = @client.check_consent(app_user_id: owner, caller_class: EXTERNAL_AGENT,
208
+ scope_group: @scope_group)
209
+ rescue StandardError
210
+ return [:unavailable, json_error(503, "consent_unavailable", "Consent check failed")]
211
+ end
212
+ end
213
+
214
+ unless consent.is_a?(Hash) && consent["granted"] == true
215
+ return [:denied,
216
+ json_error(403, "consent_not_granted",
217
+ "The data owner has not enabled external agent access.")]
218
+ end
219
+
220
+ [:granted, consent]
221
+ end
222
+
223
+ ##
224
+ # A hosted insufficient_scope refusal (active: true, valid token). The
225
+ # class consent decision still comes first: a caller whose class the
226
+ # owner denied must not learn scope state, so the verdict is resolved
227
+ # from the hosted payload (or the ledger, fail-closed) and a denied or
228
+ # unresolvable class gets its consent response, never the step-up shape.
229
+ # Only a consent-granted caller sees the 403 step-up body.
230
+ #
231
+
184
232
  ##
185
233
  # Token-less caller class (in_app_ai, or human_session under gate_human),
186
234
  # gated on the Consent Ledger. Fail closed: an unreachable or erroring
@@ -71,13 +71,27 @@ module AgentAdmit
71
71
  # Automatically retries on HTTP 429 with exponential backoff + jitter.
72
72
  # Raises {RateLimitError} when retries are exhausted.
73
73
  #
74
+ # Per-call audit telemetry (all optional, all omitted from the request
75
+ # body when unknown -- never sent as null or empty string):
76
+ #
74
77
  # @param token [String] The full token including ag_at_ prefix
78
+ # @param scope_used [String, nil] the single scope this call enforces
79
+ # (from the scope-enforcing integration point). Never a joined list;
80
+ # omit for bare auth resolution / presence-only gates.
81
+ # @param endpoint [String, nil] inbound request path. Sent path-only:
82
+ # the query string is stripped (queries can carry PII) and the path is
83
+ # truncated to 500 characters.
84
+ # @param method [String, nil] inbound HTTP method; sent uppercased,
85
+ # capped at 20 characters.
75
86
  # @return [IntrospectionResult]
76
87
  # @raise [InvalidTokenError] if validation fails
88
+ # @raise [ActiveDenialError] (incl. {InsufficientScopeError},
89
+ # {BoundExceededError}) if the response is active but carries an error
90
+ # string -- the service refused this call; always a denial
77
91
  # @raise [IntrospectionError] if the service is unreachable
78
92
  # @raise [RateLimitError] if rate-limited and retries exhausted
79
93
  #
80
- def verify(token)
94
+ def verify(token, scope_used: nil, endpoint: nil, method: nil)
81
95
  unless token.start_with?(@config.token_prefix_access)
82
96
  raise InvalidTokenError, "Not an AgentAdmit access token"
83
97
  end
@@ -90,7 +104,8 @@ module AgentAdmit
90
104
  http = build_http(uri)
91
105
 
92
106
  (0..max_retries).each do |attempt|
93
- request = build_request(uri, token)
107
+ request = build_request(uri, token, scope_used: scope_used,
108
+ endpoint: endpoint, method: method)
94
109
 
95
110
  begin
96
111
  response = http.request(request)
@@ -165,10 +180,13 @@ module AgentAdmit
165
180
  raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
166
181
  end
167
182
 
168
- # insufficient_scope arrives with active: true (token valid,
169
- # requested scope not granted).
170
- if data["error"] == "insufficient_scope"
171
- raise InsufficientScopeError, data["error_description"] || "Scope not granted"
183
+ # Active-error fail-closed: `active: true` with a string `error`
184
+ # means the token is valid but the authorization service refused
185
+ # THIS call (insufficient_scope, bound_exceeded, or anything the
186
+ # service may add later). Every such response is a DENIAL, never a
187
+ # pass-through -- unknown error strings included.
188
+ if data["error"].is_a?(String) && !data["error"].empty?
189
+ raise_active_denial!(data, scope_used)
172
190
  end
173
191
 
174
192
  # Validate that consumed fields have the expected types when present.
@@ -310,14 +328,84 @@ module AgentAdmit
310
328
  http
311
329
  end
312
330
 
313
- def build_request(uri, token)
331
+ ##
332
+ # Map an active-response error string to its typed denial (always raises).
333
+ #
334
+ # - insufficient_scope: token valid, enforced scope not granted. Carries
335
+ # the step-up fields: required_scope from the hosted response when
336
+ # present, else the scope this call enforced; granted_scopes from the
337
+ # hosted response when present.
338
+ # - bound_exceeded: the hosted bounded-capabilities layer refused the
339
+ # call; hosted fields ride along verbatim on the error's data.
340
+ # - anything else: unknown refusal -> generic typed denial. Fail closed.
341
+ #
342
+ def raise_active_denial!(data, scope_used)
343
+ case data["error"]
344
+ when "insufficient_scope"
345
+ required = data["required_scope"].is_a?(String) ? data["required_scope"] : scope_used
346
+ granted = data["granted_scopes"]
347
+ granted = data["scopes"] unless granted.is_a?(Array)
348
+ granted = nil unless granted.is_a?(Array)
349
+ raise InsufficientScopeError.new(
350
+ data["error_description"] || "Scope not granted",
351
+ required_scope: required, granted_scopes: granted, data: data
352
+ )
353
+ when "bound_exceeded"
354
+ raise BoundExceededError.new(
355
+ data["error_description"] || "Call refused by the authorization service.",
356
+ data: data
357
+ )
358
+ else
359
+ raise ActiveDenialError.new(
360
+ "Call refused by the authorization service.",
361
+ code: data["error"], data: data
362
+ )
363
+ end
364
+ end
365
+
366
+ ##
367
+ # Build the introspection POST. Beyond the token, the body carries the
368
+ # per-call audit telemetry when known: scope_used (the single scope this
369
+ # call enforces), endpoint (path only -- query stripped, queries can
370
+ # carry PII -- truncated to 500 chars), method (uppercase, capped at
371
+ # 20). Unknown fields are OMITTED, never sent as null or empty string --
372
+ # the hosted audit row then honestly records "not reported".
373
+ #
374
+ def build_request(uri, token, scope_used: nil, endpoint: nil, method: nil)
314
375
  req = Net::HTTP::Post.new(uri.path)
315
376
  req["Authorization"] = "Bearer #{@config.api_key}"
316
377
  req["Content-Type"] = "application/json"
317
- req.body = JSON.generate({ token: token })
378
+
379
+ body = { token: token }
380
+ scope = presence_of(scope_used)
381
+ body[:scope_used] = scope if scope
382
+ path = normalize_endpoint(endpoint)
383
+ body[:endpoint] = path if path
384
+ verb = presence_of(method)
385
+ body[:method] = verb.upcase[0, 20] if verb
386
+
387
+ req.body = JSON.generate(body)
318
388
  req
319
389
  end
320
390
 
391
+ # The value when it is a non-empty String, else nil (field omitted).
392
+ def presence_of(value)
393
+ value.is_a?(String) && !value.empty? ? value : nil
394
+ end
395
+
396
+ # Path only: strip everything from the first "?" (query strings can
397
+ # carry PII) and cap at 500 characters. nil when nothing usable remains
398
+ # so the field is omitted, never null.
399
+ def normalize_endpoint(endpoint)
400
+ path = presence_of(endpoint)
401
+ return nil unless path
402
+
403
+ path = path.split("?", 2).first
404
+ return nil if path.nil? || path.empty?
405
+
406
+ path[0, 500]
407
+ end
408
+
321
409
  ##
322
410
  # Parse a response header as Float, returning nil if absent or non-numeric.
323
411
  #
@@ -13,15 +13,34 @@ module AgentAdmit
13
13
  # env['agentadmit.agent_label'] -- agent display name
14
14
  # env['agentadmit.presence'] -- human-presence block (Hash) or nil
15
15
  #
16
+ # Every verify call carries per-call audit telemetry: the request path
17
+ # (PATH_INFO -- no query string) and the uppercase HTTP method, plus the
18
+ # scope the call enforces when the middleware is mounted with one:
19
+ #
20
+ # # scope resolved per request (env -> scope String or nil) ...
21
+ # use AgentAdmit::Middleware, scope_for: ->(env) { SCOPES[env["PATH_INFO"]] }
22
+ # # ... or one static scope for everything behind this middleware
23
+ # use AgentAdmit::Middleware, scope_for: "read:orders"
24
+ #
25
+ # When no scope is known the field is omitted (never null) and the hosted
26
+ # audit row records "not reported". The local ScopeEnforcement checks
27
+ # remain unchanged -- defense in depth.
28
+ #
29
+ # An introspection response with active: true AND an error string is a
30
+ # DENIAL: the token is valid but the authorization service refused this
31
+ # call (insufficient_scope, bound_exceeded, or an error code this SDK has
32
+ # never heard of). The middleware returns 403 and never calls the app.
33
+ #
16
34
  class Middleware
17
35
  # RFC 7235: the auth-scheme token is case-insensitive.
18
36
  # Match "bearer", "Bearer", "BEARER", etc. followed by the ag_at_ prefix.
19
37
  BEARER_AGENT_RE = /\Abearer ag_at_/i
20
38
 
21
- def initialize(app)
39
+ def initialize(app, scope_for: nil)
22
40
  @app = app
23
41
  @client = IntrospectionClient.new
24
42
  @config = AgentAdmit.configuration || Config.new
43
+ @scope_for = scope_for
25
44
  end
26
45
 
27
46
  def call(env)
@@ -32,13 +51,21 @@ module AgentAdmit
32
51
  token = auth.sub(/\Abearer /i, "")
33
52
 
34
53
  begin
35
- result = @client.verify(token)
54
+ result = @client.verify(token,
55
+ scope_used: resolve_scope(env),
56
+ endpoint: env["PATH_INFO"],
57
+ method: env["REQUEST_METHOD"])
36
58
  env["agentadmit.auth_type"] = "agent"
37
59
  env["agentadmit.user_id"] = result.user_id
38
60
  env["agentadmit.scopes"] = result.scopes
39
61
  env["agentadmit.connection_id"] = result.connection_id
40
62
  env["agentadmit.agent_label"] = result.agent_label
41
63
  env["agentadmit.presence"] = result.presence
64
+ rescue ActiveDenialError => e
65
+ # Token valid, call refused (active: true + error). Fail closed:
66
+ # 403 with the denial's contract shape; the app never runs.
67
+ return [403, { "Content-Type" => "application/json" },
68
+ [e.denial_body.to_json]]
42
69
  rescue InvalidTokenError => e
43
70
  return [401, { "Content-Type" => "application/json" },
44
71
  [{ error: "invalid_token", error_description: e.message }.to_json]]
@@ -50,5 +77,18 @@ module AgentAdmit
50
77
 
51
78
  @app.call(env)
52
79
  end
80
+
81
+ private
82
+
83
+ ##
84
+ # The scope this request enforces, when the app declared one at mount
85
+ # time. scope_for may be a Proc (env -> scope String or nil) or a
86
+ # static String; nil (the default) omits scope_used from the verify body.
87
+ #
88
+ def resolve_scope(env)
89
+ return @scope_for.call(env) if @scope_for.respond_to?(:call)
90
+
91
+ @scope_for
92
+ end
53
93
  end
54
94
  end
@@ -54,14 +54,21 @@ module AgentAdmit
54
54
  # user's typed words would be data loss. Empty/whitespace-only strings
55
55
  # normalize to nil and are omitted. Like purpose, it is a review-time
56
56
  # record, never an enforcement input.
57
+ # @param presence [AppAttestedPresence, nil] app-attested ceremony fact:
58
+ # set it AFTER verifying and consuming your app's own fresh,
59
+ # purpose-bound WebAuthn/passkey attestation for this mint. Forwarded
60
+ # as presence {verified: true, uv: true, method, verified_at} and
61
+ # stored provenance-marked "app:<method>"; omitted when nil (omitting
62
+ # the field is the only way to say "no ceremony").
57
63
  # @return [Hash] the issue response — "token" is the self-describing
58
64
  # ag_ct_… connection token to hand to the user's agent
59
- # @raise [ArgumentError] if purpose exceeds 300 characters, or if
60
- # user_intent is a non-String (other than nil) or exceeds 300 characters
65
+ # @raise [ArgumentError] if purpose exceeds 300 characters, if
66
+ # user_intent is a non-String (other than nil) or exceeds 300
67
+ # characters, or if presence is neither nil nor an AppAttestedPresence
61
68
  # @raise [IntrospectionError] if issuance fails
62
69
  #
63
70
  def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET, purpose: nil,
64
- user_intent: nil)
71
+ user_intent: nil, presence: nil)
65
72
  if purpose && purpose.length > PURPOSE_MAX_LENGTH
66
73
  raise ArgumentError, "purpose must be at most #{PURPOSE_MAX_LENGTH} characters"
67
74
  end
@@ -77,10 +84,18 @@ module AgentAdmit
77
84
  end
78
85
  user_intent = nil if user_intent && user_intent.strip.empty?
79
86
 
87
+ # Presence is typed-only: a raw Hash is rejected so the wire contract
88
+ # (literal-true verified/uv, offset-carrying verified_at) stays owned
89
+ # by AppAttestedPresence, never hand-rolled at call sites.
90
+ unless presence.nil? || presence.is_a?(AppAttestedPresence)
91
+ raise ArgumentError, "presence must be an AgentAdmit::AppAttestedPresence or nil"
92
+ end
93
+
80
94
  body = { "user_id" => user_id, "scopes" => scopes }
81
95
  body["role"] = role if role
82
96
  body["purpose"] = purpose if purpose
83
97
  body["user_intent"] = user_intent if user_intent
98
+ body["presence"] = presence.to_wire if presence
84
99
  # Tri-state: the UNSET sentinel omits the key entirely; nil survives
85
100
  # JSON.generate as explicit JSON null (no compact, no nil-guard).
86
101
  body["duration_seconds"] = duration_seconds unless duration_seconds.equal?(UNSET)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.8.0"
4
+ VERSION = "1.10.0"
5
5
  end
data/lib/agentadmit.rb CHANGED
@@ -20,7 +20,84 @@ module AgentAdmit
20
20
  end
21
21
  end
22
22
 
23
- class InsufficientScopeError < Error; end
23
+ ##
24
+ # Raised when the hosted /verify returns `active: true` together with a
25
+ # string `error` field: the token itself is valid, but the authorization
26
+ # service refused THIS call. Always a denial, never a pass-through --
27
+ # middlewares map it to HTTP 403, and unknown error strings fail closed
28
+ # (forward compatible: an error code this SDK version has never heard of
29
+ # must never be treated as a pass).
30
+ #
31
+ # {#code} carries the machine-readable error string; {#data} carries the
32
+ # parsed hosted response so denial responses can pass hosted fields
33
+ # through; {#denial_body} is the ready-made 403 JSON body.
34
+ #
35
+ class ActiveDenialError < Error
36
+ # @return [String] machine-readable error code from the active response
37
+ attr_reader :code
38
+ # @return [Hash] the parsed hosted introspection response
39
+ attr_reader :data
40
+
41
+ def initialize(message = "Call refused by the authorization service.",
42
+ code: "access_denied", data: {})
43
+ super(message)
44
+ @code = code
45
+ @data = data
46
+ end
47
+
48
+ # The HTTP 403 body for this denial. Unknown codes get the generic
49
+ # fail-closed shape; subclasses override with their contract shape.
50
+ # @return [Hash]
51
+ def denial_body
52
+ { "error" => code,
53
+ "error_description" => "Call refused by the authorization service." }
54
+ end
55
+ end
56
+
57
+ ##
58
+ # `active: true` + `error: "insufficient_scope"` -- token valid, the scope
59
+ # this call enforces not granted. {#denial_body} is the spec step-up shape
60
+ # (error, required_scope, granted_scopes).
61
+ #
62
+ class InsufficientScopeError < ActiveDenialError
63
+ # @return [String, nil] the scope the call enforced (hosted value when
64
+ # present, else the scope_used this SDK sent)
65
+ attr_reader :required_scope
66
+ # @return [Array<String>, nil] granted scopes from the hosted response
67
+ attr_reader :granted_scopes
68
+
69
+ def initialize(message = "Scope not granted", required_scope: nil,
70
+ granted_scopes: nil, data: {})
71
+ super(message, code: "insufficient_scope", data: data)
72
+ @required_scope = required_scope
73
+ @granted_scopes = granted_scopes
74
+ end
75
+
76
+ def denial_body
77
+ { "error" => "insufficient_scope",
78
+ "required_scope" => required_scope,
79
+ "granted_scopes" => granted_scopes || [] }
80
+ end
81
+ end
82
+
83
+ ##
84
+ # `active: true` + `error: "bound_exceeded"` -- the hosted bounded-
85
+ # capabilities layer refused the call. {#denial_body} passes the hosted
86
+ # fields (error_description, bound, renewal) through verbatim.
87
+ #
88
+ class BoundExceededError < ActiveDenialError
89
+ def initialize(message = "Call refused by the authorization service.", data: {})
90
+ super(message, code: "bound_exceeded", data: data)
91
+ end
92
+
93
+ def denial_body
94
+ body = { "error" => "bound_exceeded", "error_description" => message }
95
+ body["bound"] = data["bound"] if data.key?("bound")
96
+ body["renewal"] = data["renewal"] if data.key?("renewal")
97
+ body
98
+ end
99
+ end
100
+
24
101
  class IntrospectionError < Error; end
25
102
  class ConfigurationError < Error; end
26
103
 
@@ -88,6 +165,7 @@ end
88
165
 
89
166
  require_relative "agentadmit/config"
90
167
  require_relative "agentadmit/introspection_client"
168
+ require_relative "agentadmit/app_attested_presence"
91
169
  require_relative "agentadmit/tokens_client"
92
170
  require_relative "agentadmit/alerts_client"
93
171
  require_relative "agentadmit/webhook"
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.8.0
4
+ version: 1.10.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-08-11 00:00:00.000000000 Z
11
+ date: 2026-09-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -35,6 +35,7 @@ files:
35
35
  - README.md
36
36
  - lib/agentadmit.rb
37
37
  - lib/agentadmit/alerts_client.rb
38
+ - lib/agentadmit/app_attested_presence.rb
38
39
  - lib/agentadmit/caller_consent.rb
39
40
  - lib/agentadmit/config.rb
40
41
  - lib/agentadmit/introspection_client.rb