agentadmit 1.10.0 → 1.11.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: ccda6bad890712e5c6060b2c33d02df1b1245ff48918a267cede579acf3f0d84
4
- data.tar.gz: 5ef222b57006bcba2bd589c2b6276117abcfac031230263a8063ef56449cf355
3
+ metadata.gz: 501b7f18f13450e559d3a58aa7060fdee56eb167b33e60245a7b734d900cf0b8
4
+ data.tar.gz: dedcb0707371dfec628f2e0f8dfb0a7ee98d02260626a4601d99de30f22bb28e
5
5
  SHA512:
6
- metadata.gz: a703d18e7a9a55ed4a34b07d2ed295ddfbc49fabdba0caef6b49ef0a57cb670097b78ef5a53849035e22cc3a29709977836df8b8bdd4f660fc09bb20adaac84e
7
- data.tar.gz: 06a8b43f3a18b696df4ddccb6abb0130c62addff9d4ab948b51ea6ea1d2b0e2bf2cdb11b5cd1da90734fc94d9b6f7e3512f9fae536563418d4d8a361fc058a8b
6
+ metadata.gz: e23e66f76f7de6817786c206103e26eb1ebd3c3e6b36888f8d0028f2c092a8c1dd034fcdd4537554af2ea04f3f86ea31428967db5d41ebe93e276bcdb984ab08
7
+ data.tar.gz: 9f2a13f32307706d2ae85c7f04b6cb729a0975645264deb560108d3fa5ab892e0153c0e86467e9b97c01f72dd362581dbed9b905db420fe86c7df7e072087ce6
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,39 @@ 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
+
145
180
  ## Rate Limiting
146
181
 
147
182
  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.
@@ -388,7 +423,7 @@ use AgentAdmit::Middleware, scope_for: ->(env) { SCOPES[env["PATH_INFO"]] }
388
423
  use AgentAdmit::Middleware, scope_for: "read:orders"
389
424
  ```
390
425
 
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:
426
+ `AgentAdmit::CallerConsent` reports its configured scope too and sets the hosted `consent_first` guard automatically, so denied caller classes receive no scope-state disclosure. Direct client calls pass the same optional keyword arguments:
392
427
 
393
428
  ```ruby
394
429
  result = AgentAdmit::IntrospectionClient.new.verify(
@@ -407,6 +442,7 @@ An introspection response with `active: true` AND a string `error` field means t
407
442
 
408
443
  - `insufficient_scope` -> `AgentAdmit::InsufficientScopeError`; middlewares return 403 with the step-up shape (`error`, `required_scope`, `granted_scopes`)
409
444
  - `bound_exceeded` -> `AgentAdmit::BoundExceededError`; middlewares return 403 passing the hosted fields (`error_description`, `bound`, `renewal`) through verbatim
445
+ - `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
410
446
  - 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
447
 
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.
448
+ 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.
@@ -134,21 +134,23 @@ module AgentAdmit
134
134
  token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
135
135
 
136
136
  begin
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.
137
+ # Declare the exact exercised scope in the same hosted round trip.
138
+ # consent_first guarantees a denied caller class cannot learn scope
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.
144
144
  result = @client.verify(token,
145
+ scope_used: @required_scope,
145
146
  endpoint: env["PATH_INFO"],
146
- method: env["REQUEST_METHOD"])
147
+ method: env["REQUEST_METHOD"],
148
+ consent_first: true,
149
+ action_attestation_id:
150
+ IntrospectionClient.action_attestation_from_env(env))
147
151
  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
+ # Hosted consent-first ordering guarantees this refusal is reachable
153
+ # only after consent was granted.
152
154
  return [403, { "Content-Type" => "application/json" },
153
155
  [{ error: "insufficient_scope",
154
156
  message: "Call refused by the authorization service." }.to_json]]
@@ -181,6 +183,9 @@ module AgentAdmit
181
183
  env["agentadmit.agent_label"] = result.agent_label
182
184
  env["agentadmit.presence"] = result.presence
183
185
  env["agentadmit.consent"] = consent
186
+ if result.action_confirmation
187
+ env["agentadmit.action_confirmation"] = result.action_confirmation
188
+ end
184
189
 
185
190
  @app.call(env)
186
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,79 @@ 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
+ # The agent's X-AgentAdmit-Action-Attestation header from a Rack env:
130
+ # first value only, trimmed, capped at 120 characters. nil when absent
131
+ # or empty, so the field is omitted from the verify body.
132
+ #
133
+ # @param env [Hash] the Rack env
134
+ # @return [String, nil]
135
+ #
136
+ def action_attestation_from_env(env)
137
+ return nil unless env.is_a?(Hash)
138
+
139
+ raw = env[ACTION_ATTESTATION_RACK_KEY]
140
+ raw = raw.first if raw.is_a?(Array)
141
+ return nil unless raw.is_a?(String)
142
+
143
+ # Rack folds a repeated header into one comma-joined String; an
144
+ # attestation id is a single opaque value, so take the first.
145
+ value = raw.split(",").first.to_s.strip
146
+ value.empty? ? nil : value[0, ATTESTATION_MAX]
147
+ end
61
148
  end
62
149
 
63
150
  def initialize(config = nil)
@@ -83,15 +170,29 @@ module AgentAdmit
83
170
  # truncated to 500 characters.
84
171
  # @param method [String, nil] inbound HTTP method; sent uppercased,
85
172
  # capped at 20 characters.
173
+ # @param action_attestation_id [String, nil] confirm-each-time (1.11.0):
174
+ # the single-use attestation id from a completed hosted ceremony, which
175
+ # the agent presents on its retry via the
176
+ # X-AgentAdmit-Action-Attestation header. Capped at 120 characters.
177
+ # @param request_digest [String, nil] `sha256:<hex>` over the raw request
178
+ # body, so the confirmation covers the exact payload, not just the
179
+ # route. Capped at 128 characters.
180
+ # @param action_summary [String, nil] the app's plain-language
181
+ # description of THIS action, shown to the human on the hosted
182
+ # confirmation page and committed into the signature. Trimmed and
183
+ # capped at 200 characters. AgentAdmit does not verify the summary
184
+ # against the request; it proves what the human was shown.
86
185
  # @return [IntrospectionResult]
87
186
  # @raise [InvalidTokenError] if validation fails
88
187
  # @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
188
+ # {BoundExceededError}, {ConfirmationRequiredError}) if the response is
189
+ # active but carries an error string -- the service refused this call;
190
+ # always a denial
91
191
  # @raise [IntrospectionError] if the service is unreachable
92
192
  # @raise [RateLimitError] if rate-limited and retries exhausted
93
193
  #
94
- def verify(token, scope_used: nil, endpoint: nil, method: nil)
194
+ def verify(token, scope_used: nil, endpoint: nil, method: nil, consent_first: false,
195
+ action_attestation_id: nil, request_digest: nil, action_summary: nil)
95
196
  unless token.start_with?(@config.token_prefix_access)
96
197
  raise InvalidTokenError, "Not an AgentAdmit access token"
97
198
  end
@@ -105,7 +206,11 @@ module AgentAdmit
105
206
 
106
207
  (0..max_retries).each do |attempt|
107
208
  request = build_request(uri, token, scope_used: scope_used,
108
- endpoint: endpoint, method: method)
209
+ endpoint: endpoint, method: method,
210
+ consent_first: consent_first,
211
+ action_attestation_id: action_attestation_id,
212
+ request_digest: request_digest,
213
+ action_summary: action_summary)
109
214
 
110
215
  begin
111
216
  response = http.request(request)
@@ -218,6 +323,20 @@ module AgentAdmit
218
323
  user_intent = data["user_intent"]
219
324
  user_intent = nil unless user_intent.is_a?(String)
220
325
 
326
+ # Confirm-each-time (1.11.0): the confirmation this accepted call
327
+ # SPENT. Strict -- a String session id and a literal boolean true
328
+ # consumed flag, or the block is dropped entirely. Surfacing a
329
+ # half-formed block would let an app skip its own step-up on a
330
+ # confirmation that was never actually consumed.
331
+ action_confirmation = data["action_confirmation"]
332
+ action_confirmation =
333
+ if action_confirmation.is_a?(Hash) &&
334
+ action_confirmation["action_session_id"].is_a?(String) &&
335
+ action_confirmation["consumed"] == true
336
+ { "action_session_id" => action_confirmation["action_session_id"],
337
+ "consumed" => true }
338
+ end
339
+
221
340
  return IntrospectionResult.new(
222
341
  user_id: data["user_id"],
223
342
  connection_id: data["connection_id"],
@@ -231,7 +350,8 @@ module AgentAdmit
231
350
  consent: consent,
232
351
  presence: presence,
233
352
  purpose: purpose,
234
- user_intent: user_intent
353
+ user_intent: user_intent,
354
+ action_confirmation: action_confirmation
235
355
  )
236
356
  end
237
357
 
@@ -337,6 +457,11 @@ module AgentAdmit
337
457
  # hosted response when present.
338
458
  # - bound_exceeded: the hosted bounded-capabilities layer refused the
339
459
  # call; hosted fields ride along verbatim on the error's data.
460
+ # - confirmation_required: the scope IS granted but this call needs a
461
+ # fresh human confirmation; the staged ceremony rides along, strictly
462
+ # typed, so the agent can hand the link to the human. A malformed
463
+ # ceremony block degrades to the generic denial -- fail closed rather
464
+ # than relay an unusable confirmation.
340
465
  # - anything else: unknown refusal -> generic typed denial. Fail closed.
341
466
  #
342
467
  def raise_active_denial!(data, scope_used)
@@ -355,6 +480,25 @@ module AgentAdmit
355
480
  data["error_description"] || "Call refused by the authorization service.",
356
481
  data: data
357
482
  )
483
+ when "confirmation_required"
484
+ confirmation = self.class.parse_action_confirmation(data["confirmation"])
485
+ if confirmation
486
+ description = data["error_description"]
487
+ description = ConfirmationRequiredError::DESCRIPTION unless
488
+ description.is_a?(String) && !description.empty?
489
+ status = data["attestation_status"]
490
+ raise ConfirmationRequiredError.new(
491
+ description,
492
+ confirmation: confirmation,
493
+ attestation_status: status.is_a?(String) ? status : nil,
494
+ data: data
495
+ )
496
+ end
497
+ # Malformed ceremony: fall through to the generic denial below.
498
+ raise ActiveDenialError.new(
499
+ "Call refused by the authorization service.",
500
+ code: "confirmation_required", data: data
501
+ )
358
502
  else
359
503
  raise ActiveDenialError.new(
360
504
  "Call refused by the authorization service.",
@@ -371,7 +515,13 @@ module AgentAdmit
371
515
  # 20). Unknown fields are OMITTED, never sent as null or empty string --
372
516
  # the hosted audit row then honestly records "not reported".
373
517
  #
374
- def build_request(uri, token, scope_used: nil, endpoint: nil, method: nil)
518
+ # Confirm-each-time (1.11.0) adds three more optional fields under the
519
+ # same rule: action_attestation_id (<=120), request_digest (<=128) and
520
+ # action_summary (trimmed, <=200).
521
+ #
522
+ def build_request(uri, token, scope_used: nil, endpoint: nil, method: nil,
523
+ consent_first: false, action_attestation_id: nil,
524
+ request_digest: nil, action_summary: nil)
375
525
  req = Net::HTTP::Post.new(uri.path)
376
526
  req["Authorization"] = "Bearer #{@config.api_key}"
377
527
  req["Content-Type"] = "application/json"
@@ -383,6 +533,14 @@ module AgentAdmit
383
533
  body[:endpoint] = path if path
384
534
  verb = presence_of(method)
385
535
  body[:method] = verb.upcase[0, 20] if verb
536
+ body[:consent_first] = true if consent_first
537
+
538
+ attestation = trimmed_presence_of(action_attestation_id)
539
+ body[:action_attestation_id] = attestation[0, ATTESTATION_MAX] if attestation
540
+ digest = presence_of(request_digest)
541
+ body[:request_digest] = digest[0, DIGEST_MAX] if digest
542
+ summary = trimmed_presence_of(action_summary)
543
+ body[:action_summary] = summary[0, SUMMARY_MAX] if summary
386
544
 
387
545
  req.body = JSON.generate(body)
388
546
  req
@@ -393,6 +551,12 @@ module AgentAdmit
393
551
  value.is_a?(String) && !value.empty? ? value : nil
394
552
  end
395
553
 
554
+ # Same, after stripping surrounding whitespace (agent-supplied header
555
+ # values and app-supplied summaries both arrive padded).
556
+ def trimmed_presence_of(value)
557
+ presence_of(value.is_a?(String) ? value.strip : nil)
558
+ end
559
+
396
560
  # Path only: strip everything from the first "?" (query strings can
397
561
  # carry PII) and cap at 500 characters. nil when nothing usable remains
398
562
  # 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.0"
4
+ VERSION = "1.11.0"
5
5
  end
data/lib/agentadmit.rb CHANGED
@@ -98,6 +98,62 @@ 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
+
101
157
  class IntrospectionError < Error; end
102
158
  class ConfigurationError < Error; end
103
159
 
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.0
4
+ version: 1.11.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-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json