agentadmit 1.10.1 → 1.12.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: 305d2a2680095a277efd613bbc48dc1c0e948109f05ce01aa995dbff99c172bc
4
- data.tar.gz: 559cae430ea15ea9c66fb7e758efdab50fa652625d67f31bf7dab06d4606842d
3
+ metadata.gz: 35cfc75d6a091ef7f5940c488c472efad1c6d48a68c670e6c5e1522dbe9abb06
4
+ data.tar.gz: 0a9c2ecbc1fcdab6255e709519625cc1dbc9944d36d2b9725d5069a44544b222
5
5
  SHA512:
6
- metadata.gz: 145b55beeabfec1d0860f8d2bdf772b28e2a4a8ca0926c814aac49c3f516bee31f1a66f4dd26c894803452a178bfd296083ec89143185e4c3925aa8d4ae7ac07
7
- data.tar.gz: f1e2e43b3ea29c1f59a67d2b8791050d8058cac7210edac2cab8b9ef5f6915b35e91479d30ea3cd44709b1ccaaab16c855dc38851f8aab0ce567e1a693b7914f
6
+ metadata.gz: 39bb513f8747187ffeb5d7bb73f4557c47a9da0a95f1bd8b56130f95b38406081befb47570f425afbdfbc1ca322069459c5ebf3e4fcb1f4c164320242c306882
7
+ data.tar.gz: 35577380cb5d5e783fe05e68c67c291238de414dd9b8fb99cb4d9ea8f75a5befed74c501ccd50e1a2eade6ffe3dc3d009d712ef5f44f1b1ba41e7928d8c20a99
data/README.md CHANGED
@@ -5,6 +5,8 @@ User-mediated AI agent authorization. Plug-and-play for any Rails app.
5
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
+ > **Where the consent step runs (live keys).** The agent grant is approved on the AgentAdmit **hosted consent page**, opened on your app's behalf: your backend creates a consent session (`POST /api/v1/apps/{app_id}/consent-sessions`) with your live key and sends the signed-in user to the returned `session_url`. Scope selection, duration, intent, existing-grant review, the passkey ceremony, and the one-time token all happen there. **Direct token issuance (`POST /api/v1/apps/{app_id}/token`, and this SDK's issue-token helpers and any SDK-mounted `generate-token` route) is a sandbox facility for `aa_test_` keys only; a live key receives `403 hosted_consent_required`.** Verification (`/verify`) is unchanged and is the core of this SDK. Full walkthrough: [App Owner Guide, Step 4](https://agentadmit.com/docs/app-owner-guide).
9
+
8
10
  ## Quick Start
9
11
 
10
12
  ```ruby
@@ -142,6 +144,52 @@ end
142
144
 
143
145
  `require_presence!` fails closed: agents whose connection was minted without a completed ceremony get a 403 `presence_required`, and so do connections from servers that predate the feature. `presence_verified?` returns true only on an explicit boolean `verified: true`; absent or malformed presence data reads as not verified.
144
146
 
147
+ ## Confirm Each Time (Exercise-Time Human Confirmation)
148
+
149
+ Some actions should never run on a standing grant alone: moving money,
150
+ sending or publishing on the user's behalf, deleting data, or touching
151
+ production. Mark those scopes `confirm_each_time: true` when you register
152
+ them. Every call that exercises one then requires a fresh human confirmation.
153
+
154
+ ```ruby
155
+ use AgentAdmit::Middleware,
156
+ scope_for: "write:payments",
157
+ action_summary: ->(env) { "Pay Alex $50" }
158
+ ```
159
+
160
+ The first call is refused with HTTP 403 and a `confirmation_required` body.
161
+ The agent gives `confirmation.action_session_url` to the human. After the
162
+ human confirms on AgentAdmit's hosted page with their passkey, the agent
163
+ retries the same request with
164
+ `X-AgentAdmit-Action-Attestation: <action_session_id>`.
165
+
166
+ The SDK always forwards that header. A configured summary also causes the
167
+ middleware to send a `sha256:` digest of the raw Rack body and the summary,
168
+ then rewinds `rack.input` for the application. The hosted signature commits to
169
+ the scope, method, endpoint, digest, and the words shown to the human.
170
+ AgentAdmit proves what was shown but does not verify the summary against the
171
+ request.
172
+
173
+ Custom gates receive a typed `AgentAdmit::ConfirmationRequiredError` carrying
174
+ the strictly parsed ceremony. Malformed ceremony blocks remain generic
175
+ fail-closed `ActiveDenialError` instances with no link. On an accepted retry,
176
+ `result.action_confirmation` and `env["agentadmit.action_confirmation"]`
177
+ expose the consumed ceremony so your own transaction step-up can avoid asking
178
+ the human twice.
179
+
180
+ **The user can decline.** If the user taps Decline on the hosted page, the
181
+ hosted service answers the agent's retry with `confirmation_declined` and
182
+ holds that answer until `declined["hold_until"]`; no new ceremony is staged
183
+ and the user is not notified again. Middlewares return 403 with the strictly
184
+ typed `declined` block (`action_session_id`, `declined_at`, `hold_until`,
185
+ `scope`, plus nullable `method`, `endpoint`, `request_digest`, `summary`) and
186
+ `renewal`; custom gates receive `AgentAdmit::ConfirmationDeclinedError` (an
187
+ `ActiveDenialError`) with `.declined` and `.attestation_status`. Agents should
188
+ relay the decline to the user and not retry unless the user asks; only the
189
+ user can lift a decline, and after the hold ends a retry stages a fresh
190
+ confirmation. A malformed `declined` block degrades to a generic 403 with no
191
+ block.
192
+
145
193
  ## Rate Limiting
146
194
 
147
195
  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.
@@ -407,6 +455,8 @@ An introspection response with `active: true` AND a string `error` field means t
407
455
 
408
456
  - `insufficient_scope` -> `AgentAdmit::InsufficientScopeError`; middlewares return 403 with the step-up shape (`error`, `required_scope`, `granted_scopes`)
409
457
  - `bound_exceeded` -> `AgentAdmit::BoundExceededError`; middlewares return 403 passing the hosted fields (`error_description`, `bound`, `renewal`) through verbatim
458
+ - `confirmation_required` -> `AgentAdmit::ConfirmationRequiredError`; middlewares return 403 with the strictly typed `confirmation` block (`action_session_id`, `action_session_url`, `expires_at`, `scope`, ...) plus `attestation_status`/`attestation_description`/`renewal` when the hosted service sent them, so the agent can relay the confirmation link to the human (see [Confirm Each Time](#confirm-each-time-exercise-time-human-confirmation)); a malformed `confirmation` block degrades to the generic `ActiveDenialError` shape with no link
459
+ - `confirmation_declined` -> `AgentAdmit::ConfirmationDeclinedError`; middlewares return 403 with the strictly typed `declined` block (`action_session_id`, `declined_at`, `hold_until`, `scope`, ...) plus `attestation_status`/`attestation_description`/`renewal` when sent, so the agent can relay the user's decline instead of nagging with a link; a malformed `declined` block degrades to the generic shape with no block
410
460
  - 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
461
 
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.
462
+ All four 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.
@@ -137,11 +137,17 @@ module AgentAdmit
137
137
  # Declare the exact exercised scope in the same hosted round trip.
138
138
  # consent_first guarantees a denied caller class cannot learn scope
139
139
  # state before this middleware returns its consent 403.
140
+ # The agent's confirm-each-time attestation (1.11.0) rides along
141
+ # whenever it is present, so a retry after a completed ceremony is
142
+ # accepted on this path too. This middleware declares no action
143
+ # summary, so it sends no request digest.
140
144
  result = @client.verify(token,
141
145
  scope_used: @required_scope,
142
146
  endpoint: env["PATH_INFO"],
143
147
  method: env["REQUEST_METHOD"],
144
- consent_first: true)
148
+ consent_first: true,
149
+ action_attestation_id:
150
+ IntrospectionClient.action_attestation_from_env(env))
145
151
  rescue InsufficientScopeError
146
152
  # Hosted consent-first ordering guarantees this refusal is reachable
147
153
  # only after consent was granted.
@@ -177,6 +183,9 @@ module AgentAdmit
177
183
  env["agentadmit.agent_label"] = result.agent_label
178
184
  env["agentadmit.presence"] = result.presence
179
185
  env["agentadmit.consent"] = consent
186
+ if result.action_confirmation
187
+ env["agentadmit.action_confirmation"] = result.action_confirmation
188
+ end
180
189
 
181
190
  @app.call(env)
182
191
  end
@@ -14,11 +14,21 @@ module AgentAdmit
14
14
 
15
15
  attr_reader :verify_url, :api_url
16
16
 
17
+ # One hosted-service origin, not two.
18
+ DEFAULT_API_URL = "https://api.agentadmit.com"
19
+ DEFAULT_VERIFY_URL = "#{DEFAULT_API_URL}/api/v1/verify"
20
+ # Path appended to a non-default api_url to derive the verify URL.
21
+ VERIFY_PATH = "/api/v1/verify"
22
+
17
23
  def initialize
18
24
  @app_id = ENV.fetch("AGENTADMIT_APP_ID", "")
19
25
  @api_key = ENV.fetch("AGENTADMIT_API_KEY", "")
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")
26
+ env_verify = ENV["AGENTADMIT_VERIFY_URL"]
27
+ self.verify_url = env_verify.nil? || env_verify.empty? ? DEFAULT_VERIFY_URL : env_verify
28
+ # An AGENTADMIT_VERIFY_URL left unset is not an explicit choice -- it
29
+ # must still follow a non-default api_url (see #api_url=).
30
+ @verify_url_explicit = !(env_verify.nil? || env_verify.empty?)
31
+ self.api_url = ENV.fetch("AGENTADMIT_API_URL", DEFAULT_API_URL)
22
32
  @token_prefix_access = "ag_at_"
23
33
  @token_prefix_connection = "ag_ct_"
24
34
  # Webhook signing secret (whsec_...) -- shown once when you configure the
@@ -28,14 +38,41 @@ module AgentAdmit
28
38
  @max_retries = ENV.fetch("AGENTADMIT_MAX_RETRIES", "3").to_i
29
39
  end
30
40
 
41
+ ##
42
+ # Set the /verify endpoint explicitly. An explicit verify URL always
43
+ # wins -- assigning it pins the endpoint, and a later api_url no longer
44
+ # derives over it.
45
+ #
31
46
  def verify_url=(url)
32
47
  validate_url!(url, :verify_url)
33
48
  @verify_url = url
49
+ @verify_url_explicit = true
34
50
  end
35
51
 
52
+ ##
53
+ # Set the hosted API origin. When the verify URL was never chosen
54
+ # explicitly, it FOLLOWS a non-default api_url.
55
+ #
56
+ # One hosted-service origin, not two: an operator who points api_url at a
57
+ # staging service or a local rig and leaves the verify URL alone expects
58
+ # verify to follow. Without this, the scope catalog and token operations
59
+ # go to one service while every per-call verify silently goes to
60
+ # production -- exactly the split caught on the TrainerTracer dogfood rig
61
+ # (Sep 3, 2026).
62
+ #
36
63
  def api_url=(url)
37
64
  validate_url!(url, :api_url)
38
65
  @api_url = url
66
+
67
+ return if @verify_url_explicit
68
+ return if url.nil? || url.empty?
69
+
70
+ origin = url.sub(%r{/\z}, "")
71
+ return if origin == DEFAULT_API_URL.sub(%r{/\z}, "")
72
+
73
+ derived = "#{origin}#{VERIFY_PATH}"
74
+ validate_url!(derived, :verify_url)
75
+ @verify_url = derived
39
76
  end
40
77
 
41
78
  ##
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
+ require "digest"
6
7
 
7
8
  module AgentAdmit
8
9
  ##
@@ -15,9 +16,22 @@ module AgentAdmit
15
16
  # Hard cap on cumulative wait across all retries of a single verify call.
16
17
  MAX_RETRY_BUDGET_MS = 120_000
17
18
 
19
+ # Confirm-each-time (1.11.0). Request header an agent sets on its retry
20
+ # after the human completed the hosted confirmation ceremony, and the
21
+ # Rack env key Rack exposes it under.
22
+ ACTION_ATTESTATION_HEADER = "X-AgentAdmit-Action-Attestation"
23
+ ACTION_ATTESTATION_RACK_KEY = "HTTP_X_AGENTADMIT_ACTION_ATTESTATION"
24
+
25
+ # Hosted BodySchema caps on the verify route for the confirm-each-time
26
+ # fields (endpoint <=500 and method <=20 are enforced elsewhere).
27
+ ATTESTATION_MAX = 120
28
+ DIGEST_MAX = 128
29
+ SUMMARY_MAX = 200
30
+
18
31
  IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
19
32
  :sub, :role, :app_id, :jti, :exp, :consent,
20
- :presence, :purpose, :user_intent, keyword_init: true) do
33
+ :presence, :purpose, :user_intent,
34
+ :action_confirmation, keyword_init: true) do
21
35
  def has_scope?(scope)
22
36
  scopes.include?(scope)
23
37
  end
@@ -58,6 +72,105 @@ module AgentAdmit
58
72
  # user_intent is the user's). Review-time record only, never an
59
73
  # enforcement input; authorization decisions ride scopes, connection
60
74
  # status, and consent.
75
+
76
+ # `action_confirmation` (Hash or nil) is the confirm-each-time
77
+ # attestation the hosted service CONSUMED to accept this call:
78
+ # {"action_session_id" => String, "consumed" => true}. Present only
79
+ # when a fresh human confirmation was spent on exactly this action, and
80
+ # only when the block is strictly typed -- anything else is dropped. An
81
+ # app that runs its own transaction step-up can treat this as that
82
+ # confirmation instead of asking the human twice.
83
+ def action_confirmed?
84
+ action_confirmation.is_a?(Hash) && action_confirmation["consumed"] == true
85
+ end
86
+ end
87
+
88
+ class << self
89
+ ##
90
+ # `sha256:<hex>` over the RAW request body bytes, so a confirmation
91
+ # covers the exact payload and not merely the route. nil for an empty
92
+ # or absent body (the field is then omitted, never sent as null).
93
+ #
94
+ # @param body [String, nil] raw request body bytes
95
+ # @return [String, nil]
96
+ #
97
+ def request_digest_for(body)
98
+ return nil unless body.is_a?(String) && !body.empty?
99
+
100
+ "sha256:#{Digest::SHA256.hexdigest(body)}"
101
+ end
102
+
103
+ ##
104
+ # A strictly-typed copy of the wire `confirmation` block, or nil when
105
+ # it is malformed. ids/urls/expiry/scope must be Strings; method,
106
+ # endpoint, request_digest and summary are nullable Strings (anything
107
+ # else reads as nil). Nothing outside the contract is copied through.
108
+ #
109
+ # @param raw [Object] the `confirmation` value from the hosted response
110
+ # @return [Hash, nil]
111
+ #
112
+ def parse_action_confirmation(raw)
113
+ return nil unless raw.is_a?(Hash)
114
+ return nil unless %w[action_session_id action_session_url expires_at scope]
115
+ .all? { |key| raw[key].is_a?(String) }
116
+
117
+ nullable = ->(value) { value.is_a?(String) ? value : nil }
118
+ { "action_session_id" => raw["action_session_id"],
119
+ "action_session_url" => raw["action_session_url"],
120
+ "expires_at" => raw["expires_at"],
121
+ "scope" => raw["scope"],
122
+ "method" => nullable.call(raw["method"]),
123
+ "endpoint" => nullable.call(raw["endpoint"]),
124
+ "request_digest" => nullable.call(raw["request_digest"]),
125
+ "summary" => nullable.call(raw["summary"]) }
126
+ end
127
+
128
+ ##
129
+ # A strictly-typed copy of the wire `declined` block (SDK 1.12.0), or
130
+ # nil when it is malformed. action_session_id, declined_at, hold_until
131
+ # and scope must be Strings; method, endpoint, request_digest and
132
+ # summary are nullable Strings (anything else reads as nil). Nothing
133
+ # outside the contract is copied through.
134
+ #
135
+ # @param raw [Object] the `declined` value from the hosted response
136
+ # @return [Hash, nil]
137
+ #
138
+ def parse_action_decline(raw)
139
+ return nil unless raw.is_a?(Hash)
140
+ return nil unless %w[action_session_id declined_at hold_until scope]
141
+ .all? { |key| raw[key].is_a?(String) }
142
+
143
+ nullable = ->(value) { value.is_a?(String) ? value : nil }
144
+ { "action_session_id" => raw["action_session_id"],
145
+ "declined_at" => raw["declined_at"],
146
+ "hold_until" => raw["hold_until"],
147
+ "scope" => raw["scope"],
148
+ "method" => nullable.call(raw["method"]),
149
+ "endpoint" => nullable.call(raw["endpoint"]),
150
+ "request_digest" => nullable.call(raw["request_digest"]),
151
+ "summary" => nullable.call(raw["summary"]) }
152
+ end
153
+
154
+ ##
155
+ # The agent's X-AgentAdmit-Action-Attestation header from a Rack env:
156
+ # first value only, trimmed, capped at 120 characters. nil when absent
157
+ # or empty, so the field is omitted from the verify body.
158
+ #
159
+ # @param env [Hash] the Rack env
160
+ # @return [String, nil]
161
+ #
162
+ def action_attestation_from_env(env)
163
+ return nil unless env.is_a?(Hash)
164
+
165
+ raw = env[ACTION_ATTESTATION_RACK_KEY]
166
+ raw = raw.first if raw.is_a?(Array)
167
+ return nil unless raw.is_a?(String)
168
+
169
+ # Rack folds a repeated header into one comma-joined String; an
170
+ # attestation id is a single opaque value, so take the first.
171
+ value = raw.split(",").first.to_s.strip
172
+ value.empty? ? nil : value[0, ATTESTATION_MAX]
173
+ end
61
174
  end
62
175
 
63
176
  def initialize(config = nil)
@@ -83,15 +196,29 @@ module AgentAdmit
83
196
  # truncated to 500 characters.
84
197
  # @param method [String, nil] inbound HTTP method; sent uppercased,
85
198
  # capped at 20 characters.
199
+ # @param action_attestation_id [String, nil] confirm-each-time (1.11.0):
200
+ # the single-use attestation id from a completed hosted ceremony, which
201
+ # the agent presents on its retry via the
202
+ # X-AgentAdmit-Action-Attestation header. Capped at 120 characters.
203
+ # @param request_digest [String, nil] `sha256:<hex>` over the raw request
204
+ # body, so the confirmation covers the exact payload, not just the
205
+ # route. Capped at 128 characters.
206
+ # @param action_summary [String, nil] the app's plain-language
207
+ # description of THIS action, shown to the human on the hosted
208
+ # confirmation page and committed into the signature. Trimmed and
209
+ # capped at 200 characters. AgentAdmit does not verify the summary
210
+ # against the request; it proves what the human was shown.
86
211
  # @return [IntrospectionResult]
87
212
  # @raise [InvalidTokenError] if validation fails
88
213
  # @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
214
+ # {BoundExceededError}, {ConfirmationRequiredError}) if the response is
215
+ # active but carries an error string -- the service refused this call;
216
+ # always a denial
91
217
  # @raise [IntrospectionError] if the service is unreachable
92
218
  # @raise [RateLimitError] if rate-limited and retries exhausted
93
219
  #
94
- def verify(token, scope_used: nil, endpoint: nil, method: nil, consent_first: false)
220
+ def verify(token, scope_used: nil, endpoint: nil, method: nil, consent_first: false,
221
+ action_attestation_id: nil, request_digest: nil, action_summary: nil)
95
222
  unless token.start_with?(@config.token_prefix_access)
96
223
  raise InvalidTokenError, "Not an AgentAdmit access token"
97
224
  end
@@ -106,7 +233,10 @@ module AgentAdmit
106
233
  (0..max_retries).each do |attempt|
107
234
  request = build_request(uri, token, scope_used: scope_used,
108
235
  endpoint: endpoint, method: method,
109
- consent_first: consent_first)
236
+ consent_first: consent_first,
237
+ action_attestation_id: action_attestation_id,
238
+ request_digest: request_digest,
239
+ action_summary: action_summary)
110
240
 
111
241
  begin
112
242
  response = http.request(request)
@@ -219,6 +349,20 @@ module AgentAdmit
219
349
  user_intent = data["user_intent"]
220
350
  user_intent = nil unless user_intent.is_a?(String)
221
351
 
352
+ # Confirm-each-time (1.11.0): the confirmation this accepted call
353
+ # SPENT. Strict -- a String session id and a literal boolean true
354
+ # consumed flag, or the block is dropped entirely. Surfacing a
355
+ # half-formed block would let an app skip its own step-up on a
356
+ # confirmation that was never actually consumed.
357
+ action_confirmation = data["action_confirmation"]
358
+ action_confirmation =
359
+ if action_confirmation.is_a?(Hash) &&
360
+ action_confirmation["action_session_id"].is_a?(String) &&
361
+ action_confirmation["consumed"] == true
362
+ { "action_session_id" => action_confirmation["action_session_id"],
363
+ "consumed" => true }
364
+ end
365
+
222
366
  return IntrospectionResult.new(
223
367
  user_id: data["user_id"],
224
368
  connection_id: data["connection_id"],
@@ -232,7 +376,8 @@ module AgentAdmit
232
376
  consent: consent,
233
377
  presence: presence,
234
378
  purpose: purpose,
235
- user_intent: user_intent
379
+ user_intent: user_intent,
380
+ action_confirmation: action_confirmation
236
381
  )
237
382
  end
238
383
 
@@ -338,6 +483,15 @@ module AgentAdmit
338
483
  # hosted response when present.
339
484
  # - bound_exceeded: the hosted bounded-capabilities layer refused the
340
485
  # call; hosted fields ride along verbatim on the error's data.
486
+ # - confirmation_required: the scope IS granted but this call needs a
487
+ # fresh human confirmation; the staged ceremony rides along, strictly
488
+ # typed, so the agent can hand the link to the human. A malformed
489
+ # ceremony block degrades to the generic denial -- fail closed rather
490
+ # than relay an unusable confirmation.
491
+ # - confirmation_declined: the user declined this exact action on the
492
+ # hosted page; the hold rides along, strictly typed, so the agent can
493
+ # relay the decline to the user. A malformed block degrades to the
494
+ # generic denial.
341
495
  # - anything else: unknown refusal -> generic typed denial. Fail closed.
342
496
  #
343
497
  def raise_active_denial!(data, scope_used)
@@ -356,6 +510,47 @@ module AgentAdmit
356
510
  data["error_description"] || "Call refused by the authorization service.",
357
511
  data: data
358
512
  )
513
+ when "confirmation_required"
514
+ confirmation = self.class.parse_action_confirmation(data["confirmation"])
515
+ if confirmation
516
+ description = data["error_description"]
517
+ description = ConfirmationRequiredError::DESCRIPTION unless
518
+ description.is_a?(String) && !description.empty?
519
+ status = data["attestation_status"]
520
+ raise ConfirmationRequiredError.new(
521
+ description,
522
+ confirmation: confirmation,
523
+ attestation_status: status.is_a?(String) ? status : nil,
524
+ data: data
525
+ )
526
+ end
527
+ # Malformed ceremony: fall through to the generic denial below.
528
+ raise ActiveDenialError.new(
529
+ "Call refused by the authorization service.",
530
+ code: "confirmation_required", data: data
531
+ )
532
+ when "confirmation_declined"
533
+ # SDK 1.12.0: the user declined exactly this action on the hosted
534
+ # page and the hold still runs. Relay the decline so the agent can
535
+ # tell the user instead of nagging with a link.
536
+ declined = self.class.parse_action_decline(data["declined"])
537
+ if declined
538
+ description = data["error_description"]
539
+ description = ConfirmationDeclinedError::DESCRIPTION unless
540
+ description.is_a?(String) && !description.empty?
541
+ status = data["attestation_status"]
542
+ raise ConfirmationDeclinedError.new(
543
+ description,
544
+ declined: declined,
545
+ attestation_status: status.is_a?(String) ? status : nil,
546
+ data: data
547
+ )
548
+ end
549
+ # Malformed decline: generic denial, fail closed, no block.
550
+ raise ActiveDenialError.new(
551
+ "Call refused by the authorization service.",
552
+ code: "confirmation_declined", data: data
553
+ )
359
554
  else
360
555
  raise ActiveDenialError.new(
361
556
  "Call refused by the authorization service.",
@@ -372,8 +567,13 @@ module AgentAdmit
372
567
  # 20). Unknown fields are OMITTED, never sent as null or empty string --
373
568
  # the hosted audit row then honestly records "not reported".
374
569
  #
570
+ # Confirm-each-time (1.11.0) adds three more optional fields under the
571
+ # same rule: action_attestation_id (<=120), request_digest (<=128) and
572
+ # action_summary (trimmed, <=200).
573
+ #
375
574
  def build_request(uri, token, scope_used: nil, endpoint: nil, method: nil,
376
- consent_first: false)
575
+ consent_first: false, action_attestation_id: nil,
576
+ request_digest: nil, action_summary: nil)
377
577
  req = Net::HTTP::Post.new(uri.path)
378
578
  req["Authorization"] = "Bearer #{@config.api_key}"
379
579
  req["Content-Type"] = "application/json"
@@ -387,6 +587,13 @@ module AgentAdmit
387
587
  body[:method] = verb.upcase[0, 20] if verb
388
588
  body[:consent_first] = true if consent_first
389
589
 
590
+ attestation = trimmed_presence_of(action_attestation_id)
591
+ body[:action_attestation_id] = attestation[0, ATTESTATION_MAX] if attestation
592
+ digest = presence_of(request_digest)
593
+ body[:request_digest] = digest[0, DIGEST_MAX] if digest
594
+ summary = trimmed_presence_of(action_summary)
595
+ body[:action_summary] = summary[0, SUMMARY_MAX] if summary
596
+
390
597
  req.body = JSON.generate(body)
391
598
  req
392
599
  end
@@ -396,6 +603,12 @@ module AgentAdmit
396
603
  value.is_a?(String) && !value.empty? ? value : nil
397
604
  end
398
605
 
606
+ # Same, after stripping surrounding whitespace (agent-supplied header
607
+ # values and app-supplied summaries both arrive padded).
608
+ def trimmed_presence_of(value)
609
+ presence_of(value.is_a?(String) ? value.strip : nil)
610
+ end
611
+
399
612
  # Path only: strip everything from the first "?" (query strings can
400
613
  # carry PII) and cap at 500 characters. nil when nothing usable remains
401
614
  # so the field is omitted, never null.
@@ -12,6 +12,11 @@ module AgentAdmit
12
12
  # env['agentadmit.connection_id'] -- connection identifier
13
13
  # env['agentadmit.agent_label'] -- agent display name
14
14
  # env['agentadmit.presence'] -- human-presence block (Hash) or nil
15
+ # env['agentadmit.action_confirmation']
16
+ # -- {"action_session_id" =>, "consumed" => true}
17
+ # when a confirm-each-time confirmation
18
+ # was spent on THIS call; key absent
19
+ # otherwise
15
20
  #
16
21
  # Every verify call carries per-call audit telemetry: the request path
17
22
  # (PATH_INFO -- no query string) and the uppercase HTTP method, plus the
@@ -28,19 +33,45 @@ module AgentAdmit
28
33
  #
29
34
  # An introspection response with active: true AND an error string is a
30
35
  # 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.
36
+ # call (insufficient_scope, bound_exceeded, confirmation_required, or an
37
+ # error code this SDK has never heard of). The middleware returns 403 and
38
+ # never calls the app.
39
+ #
40
+ # Confirm-each-time (1.11.0). The agent's
41
+ # X-AgentAdmit-Action-Attestation header is ALWAYS forwarded when present
42
+ # -- with or without an action summary. Declare an action summary to make
43
+ # a route confirm-each-time-ready: the middleware then also digests the
44
+ # raw request body and sends the human-readable summary, so the hosted
45
+ # ceremony (and the passkey signature) covers the exact payload:
46
+ #
47
+ # use AgentAdmit::Middleware, scope_for: "write:payments",
48
+ # action_summary: ->(env) { "Pay #{params(env)['trainer']} $#{params(env)['amount']}" }
49
+ # # ... a static String, or a block, work too:
50
+ # use AgentAdmit::Middleware, scope_for: "write:payments" do |env|
51
+ # "Publish the draft post"
52
+ # end
33
53
  #
34
54
  class Middleware
35
55
  # RFC 7235: the auth-scheme token is case-insensitive.
36
56
  # Match "bearer", "Bearer", "BEARER", etc. followed by the ag_at_ prefix.
37
57
  BEARER_AGENT_RE = /\Abearer ag_at_/i
38
58
 
39
- def initialize(app, scope_for: nil)
59
+ ##
60
+ # @param app [#call] the downstream Rack app
61
+ # @param scope_for [Proc, String, nil] the scope this request enforces
62
+ # @param action_summary [Proc, String, nil] confirm-each-time (1.11.0):
63
+ # the plain-language description of THIS action for the human ("Pay
64
+ # Alex $50"). A Proc (env -> String or nil) or a static String; a block
65
+ # is accepted as an alternative. Supplying one also turns on the raw
66
+ # request-body digest for this middleware. AgentAdmit does not verify
67
+ # the summary against the request; it proves what the human was shown.
68
+ #
69
+ def initialize(app, scope_for: nil, action_summary: nil, &action_summary_block)
40
70
  @app = app
41
71
  @client = IntrospectionClient.new
42
72
  @config = AgentAdmit.configuration || Config.new
43
73
  @scope_for = scope_for
74
+ @action_summary = action_summary || action_summary_block
44
75
  end
45
76
 
46
77
  def call(env)
@@ -54,13 +85,23 @@ module AgentAdmit
54
85
  result = @client.verify(token,
55
86
  scope_used: resolve_scope(env),
56
87
  endpoint: env["PATH_INFO"],
57
- method: env["REQUEST_METHOD"])
88
+ method: env["REQUEST_METHOD"],
89
+ action_attestation_id:
90
+ IntrospectionClient.action_attestation_from_env(env),
91
+ request_digest: resolve_request_digest(env),
92
+ action_summary: resolve_action_summary(env))
58
93
  env["agentadmit.auth_type"] = "agent"
59
94
  env["agentadmit.user_id"] = result.user_id
60
95
  env["agentadmit.scopes"] = result.scopes
61
96
  env["agentadmit.connection_id"] = result.connection_id
62
97
  env["agentadmit.agent_label"] = result.agent_label
63
98
  env["agentadmit.presence"] = result.presence
99
+ # Only set when the hosted service actually SPENT a confirmation on
100
+ # this call; the key stays absent otherwise, so `env.key?` is a
101
+ # truthful test.
102
+ if result.action_confirmation
103
+ env["agentadmit.action_confirmation"] = result.action_confirmation
104
+ end
64
105
  rescue ActiveDenialError => e
65
106
  # Token valid, call refused (active: true + error). Fail closed:
66
107
  # 403 with the denial's contract shape; the app never runs.
@@ -90,5 +131,42 @@ module AgentAdmit
90
131
 
91
132
  @scope_for
92
133
  end
134
+
135
+ ##
136
+ # The plain-language action summary for this request, when the app
137
+ # declared one at mount time. A Proc (env -> String or nil) or a static
138
+ # String. A summary is telemetry for the human's confirmation page, never
139
+ # an authorization input, so a callback that raises must not turn a
140
+ # legitimate call into an error: it degrades to no summary.
141
+ #
142
+ def resolve_action_summary(env)
143
+ return nil unless @action_summary
144
+
145
+ summary = @action_summary.respond_to?(:call) ? @action_summary.call(env) : @action_summary
146
+ summary.is_a?(String) ? summary : nil
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ ##
152
+ # `sha256:<hex>` over the RAW request body, computed only for a
153
+ # middleware configured with an action summary (a confirm-each-time
154
+ # route) -- the confirmation must cover the exact payload, not just the
155
+ # route. The body is rewound afterwards so the downstream app still reads
156
+ # it; a body this middleware cannot read simply yields no digest.
157
+ #
158
+ def resolve_request_digest(env)
159
+ return nil unless @action_summary
160
+
161
+ input = env["rack.input"]
162
+ return nil unless input.respond_to?(:read)
163
+
164
+ input.rewind if input.respond_to?(:rewind)
165
+ raw = input.read
166
+ input.rewind if input.respond_to?(:rewind)
167
+ IntrospectionClient.request_digest_for(raw)
168
+ rescue StandardError
169
+ nil
170
+ end
93
171
  end
94
172
  end
@@ -87,6 +87,11 @@ module AgentAdmit
87
87
  connection_id: request.env["agentadmit.connection_id"],
88
88
  agent_label: request.env["agentadmit.agent_label"],
89
89
  presence: request.env["agentadmit.presence"],
90
+ # Confirm-each-time (1.11.0): the confirmation the hosted service
91
+ # spent to accept THIS call, or nil. An app running its own
92
+ # transaction step-up can treat a present block as that confirmation
93
+ # instead of asking the human twice.
94
+ action_confirmation: request.env["agentadmit.action_confirmation"],
90
95
  }
91
96
  end
92
97
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.10.1"
4
+ VERSION = "1.12.0"
5
5
  end
data/lib/agentadmit.rb CHANGED
@@ -98,6 +98,114 @@ module AgentAdmit
98
98
  end
99
99
  end
100
100
 
101
+ ##
102
+ # `active: true` + `error: "confirmation_required"` (SDK 1.11.0,
103
+ # confirm-each-time) -- the scope IS granted, but THIS call needs a fresh
104
+ # human confirmation before it may run. The hosted service has staged a
105
+ # one-time ceremony for exactly this action; {#confirmation} is the
106
+ # strictly-typed block describing it.
107
+ #
108
+ # The agent hands `confirmation["action_session_url"]` to the human. Only a
109
+ # user-verified passkey on that hosted page produces an attestation; the
110
+ # agent cannot complete the ceremony itself. The agent then retries the
111
+ # SAME request with the header
112
+ # `X-AgentAdmit-Action-Attestation: <action_session_id>`, which the SDK
113
+ # forwards as `action_attestation_id` on the next verify.
114
+ #
115
+ # {#attestation_status} explains why a presented attestation was NOT
116
+ # accepted, when one was presented (e.g. already_consumed, action_mismatch,
117
+ # expired, not_confirmed).
118
+ #
119
+ # A malformed confirmation block never reaches this class: the client falls
120
+ # back to a generic {ActiveDenialError} (fail closed, 403, no confirmation
121
+ # block) rather than relaying an unusable ceremony.
122
+ #
123
+ class ConfirmationRequiredError < ActiveDenialError
124
+ # The canonical agent-facing description. The hosted `error_description`
125
+ # wins when the service sends one.
126
+ DESCRIPTION = "This action requires a fresh human confirmation. " \
127
+ "Give the confirmation link to the user, then retry with " \
128
+ "the X-AgentAdmit-Action-Attestation header."
129
+
130
+ # @return [Hash] the strictly-typed staged ceremony: action_session_id,
131
+ # action_session_url, expires_at, scope (Strings), and method, endpoint,
132
+ # request_digest, summary (String or nil).
133
+ attr_reader :confirmation
134
+ # @return [String, nil] why a presented attestation was not accepted.
135
+ attr_reader :attestation_status
136
+
137
+ def initialize(message = DESCRIPTION, confirmation:, attestation_status: nil, data: {})
138
+ super(message, code: "confirmation_required", data: data)
139
+ @confirmation = confirmation
140
+ @attestation_status = attestation_status
141
+ end
142
+
143
+ # The 403 body: the refusal, the human-readable description, the staged
144
+ # ceremony, and the hosted attestation diagnostics when present. Nothing
145
+ # else from the wire -- a refusal must not leak identity or scope state.
146
+ def denial_body
147
+ body = { "error" => "confirmation_required",
148
+ "error_description" => message,
149
+ "confirmation" => confirmation }
150
+ %w[attestation_status attestation_description renewal].each do |field|
151
+ body[field] = data[field] if data[field].is_a?(String)
152
+ end
153
+ body
154
+ end
155
+ end
156
+
157
+ ##
158
+ # `active: true` + `error: "confirmation_declined"` (SDK 1.12.0,
159
+ # confirm-each-time) -- the user declined exactly this action on the hosted
160
+ # confirmation page, and the hosted service holds that answer until
161
+ # `declined["hold_until"]`. No new ceremony is staged and the user is not
162
+ # notified again while the hold runs. {#declined} is the strictly-typed
163
+ # block naming the declined session, when, how long, and the exact action.
164
+ #
165
+ # Agents should relay the decline to the user and not retry unless the user
166
+ # asks; only the user can lift a decline. After the hold ends, a retry
167
+ # stages a fresh confirmation.
168
+ #
169
+ # {#attestation_status} explains why a presented attestation was NOT
170
+ # accepted, when one was presented (e.g. declined).
171
+ #
172
+ # A malformed declined block never reaches this class: the client falls
173
+ # back to a generic {ActiveDenialError} (fail closed, 403, no declined
174
+ # block).
175
+ #
176
+ class ConfirmationDeclinedError < ActiveDenialError
177
+ # The canonical agent-facing description. The hosted `error_description`
178
+ # wins when the service sends one.
179
+ DESCRIPTION = "The user declined this action on the hosted confirmation page. " \
180
+ "Do not retry it unless the user asks you to."
181
+
182
+ # @return [Hash] the strictly-typed decline: action_session_id,
183
+ # declined_at, hold_until, scope (Strings), and method, endpoint,
184
+ # request_digest, summary (String or nil).
185
+ attr_reader :declined
186
+ # @return [String, nil] why a presented attestation was not accepted.
187
+ attr_reader :attestation_status
188
+
189
+ def initialize(message = DESCRIPTION, declined:, attestation_status: nil, data: {})
190
+ super(message, code: "confirmation_declined", data: data)
191
+ @declined = declined
192
+ @attestation_status = attestation_status
193
+ end
194
+
195
+ # The 403 body: the refusal, the human-readable description, the decline,
196
+ # and the hosted attestation diagnostics when present. Nothing else from
197
+ # the wire -- a refusal must not leak identity or scope state.
198
+ def denial_body
199
+ body = { "error" => "confirmation_declined",
200
+ "error_description" => message,
201
+ "declined" => declined }
202
+ %w[attestation_status attestation_description renewal].each do |field|
203
+ body[field] = data[field] if data[field].is_a?(String)
204
+ end
205
+ body
206
+ end
207
+ end
208
+
101
209
  class IntrospectionError < Error; end
102
210
  class ConfigurationError < Error; end
103
211
 
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.10.1
4
+ version: 1.12.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-09-01 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json