agentadmit 1.12.0 → 1.14.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: 35cfc75d6a091ef7f5940c488c472efad1c6d48a68c670e6c5e1522dbe9abb06
4
- data.tar.gz: 0a9c2ecbc1fcdab6255e709519625cc1dbc9944d36d2b9725d5069a44544b222
3
+ metadata.gz: 4614ce271d54fa958f580f2180349bbdad92764566efe17d3a459c0fca62815c
4
+ data.tar.gz: 5c46517cd626d99eda4482cf47fdb14ae98b13f9a451216bcb117f6fd236f9c8
5
5
  SHA512:
6
- metadata.gz: 39bb513f8747187ffeb5d7bb73f4557c47a9da0a95f1bd8b56130f95b38406081befb47570f425afbdfbc1ca322069459c5ebf3e4fcb1f4c164320242c306882
7
- data.tar.gz: 35577380cb5d5e783fe05e68c67c291238de414dd9b8fb99cb4d9ea8f75a5befed74c501ccd50e1a2eade6ffe3dc3d009d712ef5f44f1b1ba41e7928d8c20a99
6
+ metadata.gz: 716a550dd977efc852984419c4f79e8fba5e28fd282a3c5b8630251fc48dba14548ac6e1950d2c548057acf84862d9c023525cdfe1ba5e8c2ef60e5ca0d021a7
7
+ data.tar.gz: c568d905a1930f6376109751c2fe3c464a181049f359b50f280872f9b18042d82d75a73e628119ed0d8f51270bfa843b39ca4585f7867182c4ed3242d6d8000f
data/README.md CHANGED
@@ -398,6 +398,29 @@ result.user_intent # => "Book my flights to the Austin offsite in October" or ni
398
398
 
399
399
  `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.
400
400
 
401
+ ## Agent Designation
402
+
403
+ On AgentAdmit's consent page the user says which agent app a connection is for and, optionally, which AI model family it mainly uses. That is the user's statement, recorded with the grant; it is not a verified identity of the agent. Separately, an agent may report its own platform and model when it redeems the connection token; that is unverified too.
404
+
405
+ The verify response reports both on every introspection, and the SDK surfaces them on the result as `designated_agent` and `agent_reported` (`nil` when the connection carries none):
406
+
407
+ ```ruby
408
+ result = AgentAdmit::IntrospectionClient.new.verify(token)
409
+
410
+ # {"agent_id" => ..., "name" => ..., "source" => "user_declared",
411
+ # "model" => {"model_id" => ..., "name" => ...} or nil}
412
+ named = result.designated_agent
413
+ # {"platform" => ..., "model" => {"id" => ..., "version" => ...} or nil}
414
+ reported = result.agent_reported
415
+
416
+ puts "Named by the user: #{named ? named['name'] : '(not named)'}"
417
+ puts "Reported by the agent: #{reported&.dig('model', 'id') || '(nothing)'}"
418
+ ```
419
+
420
+ Use `designated_agent["name"]` when you tell a user which of their connections did something: it is the name they chose. `agent_id` and `model["model_id"]` are the ids of your list entries, or `nil` when the user typed a name that is not on your list.
421
+
422
+ Do not branch authorization on either field. They are records for people reviewing a connection, not a gate. Scope checks, connection status, and consent verdicts remain the only decision inputs. To act on a designation, use the hosted revoke API (`designated_agent_id`, `designated_model_id`) or your list's block setting in the dashboard.
423
+
401
424
  ## App-Attested Presence
402
425
 
403
426
  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:
@@ -449,6 +472,77 @@ result = AgentAdmit::IntrospectionClient.new.verify(
449
472
 
450
473
  The local `ScopeEnforcement` checks (`require_scope!`, `require_scope_if_agent!`) are unchanged -- defense in depth on top of the hosted decision.
451
474
 
475
+ ### Outcome reporting
476
+
477
+ Successful verify responses may include `result.audit_row_id`, the hosted
478
+ audit row id for that call. After your handler runs, you can append what your
479
+ app observed:
480
+
481
+ ```ruby
482
+ client = AgentAdmit::IntrospectionClient.new
483
+ result = client.verify(token, scope_used: "write:payments",
484
+ endpoint: request.path, method: request.request_method)
485
+
486
+ begin
487
+ # run the app action
488
+ client.report_outcome(result.audit_row_id,
489
+ outcome: "executed",
490
+ status_class: "2xx") if result.audit_row_id
491
+ rescue
492
+ client.report_outcome(result.audit_row_id,
493
+ outcome: "failed",
494
+ status_class: "5xx") if result.audit_row_id
495
+ raise
496
+ end
497
+ ```
498
+
499
+ `outcome` must be `"executed"`, `"failed"`, or `"unknown"`. `unknown` is
500
+ only for an explicit "we cannot truthfully classify this" case; it is never
501
+ chosen automatically. `status_class` may be `"1xx"` through `"5xx"` or `nil`.
502
+ Outcome rows report what the app observed after verification; they are not
503
+ independent proof of execution.
504
+
505
+ Rack middleware can do the common status mapping for you:
506
+
507
+ ```ruby
508
+ use AgentAdmit::Middleware,
509
+ scope_for: "write:payments",
510
+ action_summary: ->(env) { "Pay Alex $50" },
511
+ report_outcome: true
512
+ ```
513
+
514
+ With `report_outcome: true`, the middleware reports only after the downstream
515
+ Rack app returns a response triple and the status has an observable class.
516
+ Statuses below 400 report `"executed"`; statuses 400 and above report
517
+ `"failed"`. If the app raises, aborts before returning a triple, returns no
518
+ observable status, or verify did not include `audit_row_id`, the middleware
519
+ does not guess. Reporting errors are logged and never replace the app's
520
+ response.
521
+
522
+ For downstream code, the Rack env includes `env["agentadmit.audit_row_id"]`
523
+ when the hosted service returned one.
524
+
525
+ ### Replay receipts
526
+
527
+ When an agent retries with an already-consumed confirm-each-time attestation,
528
+ verify may return `result.consumed_receipt`. It is a diagnostic receipt for
529
+ the earlier consumption event:
530
+
531
+ ```ruby
532
+ result.consumed_receipt
533
+ # => {
534
+ # "consumed_at" => "2026-09-30T02:54:07.000Z",
535
+ # "connection_id" => "conn_123",
536
+ # "chain_seq" => nil,
537
+ # "row_hash" => nil
538
+ # }
539
+ ```
540
+
541
+ This is not a fresh authorization and must not be treated as permission to
542
+ run the action. It exists so agents and apps can explain an
543
+ `already_consumed` replay without staging another confirmation. Rack exposes
544
+ the typed block at `env["agentadmit.consumed_receipt"]` when present.
545
+
452
546
  ### Active-error responses are denials
453
547
 
454
548
  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:
@@ -27,11 +27,15 @@ module AgentAdmit
27
27
  ATTESTATION_MAX = 120
28
28
  DIGEST_MAX = 128
29
29
  SUMMARY_MAX = 200
30
+ OUTCOMES = %w[executed failed unknown].freeze
31
+ STATUS_CLASSES = %w[1xx 2xx 3xx 4xx 5xx].freeze
30
32
 
31
33
  IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
32
34
  :sub, :role, :app_id, :jti, :exp, :consent,
33
35
  :presence, :purpose, :user_intent,
34
- :action_confirmation, keyword_init: true) do
36
+ :action_confirmation, :audit_row_id,
37
+ :consumed_receipt, :designated_agent,
38
+ :agent_reported, keyword_init: true) do
35
39
  def has_scope?(scope)
36
40
  scopes.include?(scope)
37
41
  end
@@ -83,9 +87,48 @@ module AgentAdmit
83
87
  def action_confirmed?
84
88
  action_confirmation.is_a?(Hash) && action_confirmation["consumed"] == true
85
89
  end
90
+
91
+ # `audit_row_id` (String or nil) identifies the hosted audit row for
92
+ # this successful verify call. Use it with #report_outcome after the
93
+ # application observes whether its handler executed or failed.
94
+
95
+ # `consumed_receipt` (Hash or nil) is a replay diagnostic for an
96
+ # already-consumed confirm-each-time attestation. It is not a fresh
97
+ # authorization and must not be treated as permission to run.
98
+
99
+ # `designated_agent` (Hash or nil) is what the USER said on AgentAdmit's
100
+ # consent page: which agent app the connection is for and, optionally,
101
+ # which AI model family it mainly uses:
102
+ # {"agent_id" => String|nil, "name" => String,
103
+ # "source" => "user_declared",
104
+ # "model" => {"model_id" => String|nil, "name" => String} | nil}.
105
+ # It is the user's statement, recorded with the grant; it is not a
106
+ # verified identity of the agent. `agent_id` / `model_id` are nil when
107
+ # the user typed a name that is not on the app's list.
108
+
109
+ # `agent_reported` (Hash or nil) is what the AGENT reported about itself
110
+ # when it redeemed the connection token:
111
+ # {"platform" => String|nil,
112
+ # "model" => {"id" => String, "version" => String|nil} | nil}.
113
+ # Unverified. Both fields are review-time records only, never an
114
+ # enforcement input; authorization decisions ride scopes, connection
115
+ # status, and consent.
86
116
  end
87
117
 
88
118
  class << self
119
+ def status_class_for(status)
120
+ code = Integer(status) rescue nil
121
+ return nil unless code && (100..599).cover?(code)
122
+
123
+ "#{code / 100}xx"
124
+ end
125
+
126
+ def outcome_for_status(status)
127
+ status_class_for(status) ? (Integer(status) < 400 ? "executed" : "failed") : nil
128
+ rescue ArgumentError, TypeError
129
+ nil
130
+ end
131
+
89
132
  ##
90
133
  # `sha256:<hex>` over the RAW request body bytes, so a confirmation
91
134
  # covers the exact payload and not merely the route. nil for an empty
@@ -151,6 +194,75 @@ module AgentAdmit
151
194
  "summary" => nullable.call(raw["summary"]) }
152
195
  end
153
196
 
197
+ ##
198
+ # A strictly-typed replay diagnostic for an already-consumed action
199
+ # attestation, or nil when the block is absent/malformed. This is a
200
+ # receipt for an earlier consumption event, not authorization for this
201
+ # call.
202
+ #
203
+ def parse_consumed_receipt(raw)
204
+ return nil unless raw.is_a?(Hash)
205
+ return nil unless raw["consumed_at"].is_a?(String)
206
+ return nil unless raw["connection_id"].is_a?(String)
207
+
208
+ { "consumed_at" => raw["consumed_at"],
209
+ "connection_id" => raw["connection_id"],
210
+ "chain_seq" => raw["chain_seq"].is_a?(Integer) ? raw["chain_seq"] : nil,
211
+ "row_hash" => raw["row_hash"].is_a?(String) ? raw["row_hash"] : nil }
212
+ end
213
+
214
+ ##
215
+ # The user's agent designation from a verify response, or nil when the
216
+ # block is absent, null, or not a user-declared designation. Strict: a
217
+ # non-empty String name and source "user_declared", or the whole block
218
+ # is dropped. A malformed model is dropped and the app is kept. This is
219
+ # what the user said on the consent page, not a verified identity.
220
+ #
221
+ # @param raw [Object] the `designated_agent` value from the verify body
222
+ # @return [Hash, nil]
223
+ #
224
+ def parse_designated_agent(raw)
225
+ return nil unless raw.is_a?(Hash)
226
+ name = raw["name"]
227
+ return nil unless name.is_a?(String) && !name.empty?
228
+ return nil unless raw["source"] == "user_declared"
229
+
230
+ m = raw["model"]
231
+ model =
232
+ if m.is_a?(Hash) && m["name"].is_a?(String) && !m["name"].empty?
233
+ { "model_id" => m["model_id"].is_a?(String) ? m["model_id"] : nil,
234
+ "name" => m["name"] }
235
+ end
236
+
237
+ { "agent_id" => raw["agent_id"].is_a?(String) ? raw["agent_id"] : nil,
238
+ "name" => name,
239
+ "source" => "user_declared",
240
+ "model" => model }
241
+ end
242
+
243
+ ##
244
+ # What the agent reported about itself at token exchange, or nil when
245
+ # the block carries neither a platform nor a model id. Unverified.
246
+ #
247
+ # @param raw [Object] the `agent_reported` value from the verify body
248
+ # @return [Hash, nil]
249
+ #
250
+ def parse_agent_reported(raw)
251
+ return nil unless raw.is_a?(Hash)
252
+
253
+ platform = raw["platform"]
254
+ platform = nil unless platform.is_a?(String) && !platform.empty?
255
+ m = raw["model"]
256
+ model =
257
+ if m.is_a?(Hash) && m["id"].is_a?(String) && !m["id"].empty?
258
+ { "id" => m["id"],
259
+ "version" => m["version"].is_a?(String) ? m["version"] : nil }
260
+ end
261
+ return nil if platform.nil? && model.nil?
262
+
263
+ { "platform" => platform, "model" => model }
264
+ end
265
+
154
266
  ##
155
267
  # The agent's X-AgentAdmit-Action-Attestation header from a Rack env:
156
268
  # first value only, trimmed, capped at 120 characters. nil when absent
@@ -363,6 +475,17 @@ module AgentAdmit
363
475
  "consumed" => true }
364
476
  end
365
477
 
478
+ audit_row_id = data["audit_row_id"]
479
+ audit_row_id = nil unless audit_row_id.is_a?(String)
480
+ consumed_receipt = self.class.parse_consumed_receipt(data["consumed_receipt"])
481
+
482
+ # Agent designation (1.14.0): what the user said the connection is
483
+ # for, and what the agent reported about itself. Review-time records,
484
+ # never an enforcement input, so a malformed block is dropped rather
485
+ # than failing the verify.
486
+ designated_agent = self.class.parse_designated_agent(data["designated_agent"])
487
+ agent_reported = self.class.parse_agent_reported(data["agent_reported"])
488
+
366
489
  return IntrospectionResult.new(
367
490
  user_id: data["user_id"],
368
491
  connection_id: data["connection_id"],
@@ -377,7 +500,11 @@ module AgentAdmit
377
500
  presence: presence,
378
501
  purpose: purpose,
379
502
  user_intent: user_intent,
380
- action_confirmation: action_confirmation
503
+ action_confirmation: action_confirmation,
504
+ audit_row_id: audit_row_id,
505
+ consumed_receipt: consumed_receipt,
506
+ designated_agent: designated_agent,
507
+ agent_reported: agent_reported
381
508
  )
382
509
  end
383
510
 
@@ -385,6 +512,60 @@ module AgentAdmit
385
512
  raise IntrospectionError, "Unexpected exit from retry loop"
386
513
  end
387
514
 
515
+ ##
516
+ # Report what the application observed after a successful verify call.
517
+ #
518
+ # This appends an outcome row to the hosted tamper-evident audit chain.
519
+ # It reports the app's observed result; it is not independent proof of
520
+ # execution. Use outcome: "unknown" only when you explicitly cannot
521
+ # classify the result.
522
+ #
523
+ # @param audit_row_id [String] result.audit_row_id from #verify
524
+ # @param outcome [String] "executed" | "failed" | "unknown"
525
+ # @param status_class [String, nil] "1xx".."5xx" or nil
526
+ # @return [Hash] parsed hosted outcome response
527
+ # @raise [ArgumentError] invalid arguments
528
+ # @raise [IntrospectionError] hosted service unreachable or rejected the report
529
+ #
530
+ def report_outcome(audit_row_id, outcome:, status_class: nil)
531
+ unless audit_row_id.is_a?(String) && !audit_row_id.empty?
532
+ raise ArgumentError, "audit_row_id is required"
533
+ end
534
+ unless OUTCOMES.include?(outcome)
535
+ raise ArgumentError, "outcome must be executed, failed, or unknown"
536
+ end
537
+ unless status_class.nil? || STATUS_CLASSES.include?(status_class)
538
+ raise ArgumentError, "status_class must be 1xx, 2xx, 3xx, 4xx, 5xx, or nil"
539
+ end
540
+
541
+ origin = @config.api_url.sub(%r{/\z}, "")
542
+ uri = URI.parse("#{origin}/api/v1/audit/#{audit_row_id}/outcome")
543
+ http = build_http(uri)
544
+
545
+ request = Net::HTTP::Post.new(uri.path)
546
+ request["Authorization"] = "Bearer #{@config.api_key}"
547
+ request["Content-Type"] = "application/json"
548
+ request.body = JSON.generate(outcome: outcome, status_class: status_class)
549
+
550
+ response = begin
551
+ http.request(request)
552
+ rescue StandardError => e
553
+ raise IntrospectionError, "Outcome report failed: #{e.message}"
554
+ end
555
+
556
+ unless (200..299).cover?(response.code.to_i)
557
+ data = JSON.parse(response.body) rescue {}
558
+ raise IntrospectionError,
559
+ data["error_description"] || data["error"] || "Outcome report returned #{response.code}"
560
+ end
561
+
562
+ begin
563
+ JSON.parse(response.body)
564
+ rescue JSON::ParserError
565
+ raise IntrospectionError, "Outcome report response is not valid JSON"
566
+ end
567
+ end
568
+
388
569
  CALLER_CLASSES = %w[human_session in_app_ai external_agent].freeze
389
570
 
390
571
  ##
@@ -12,6 +12,10 @@ 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.audit_row_id'] -- hosted audit row id for this verify
16
+ # env['agentadmit.consumed_receipt']
17
+ # -- replay diagnostic for an already-
18
+ # consumed confirmation, or nil
15
19
  # env['agentadmit.action_confirmation']
16
20
  # -- {"action_session_id" =>, "consumed" => true}
17
21
  # when a confirm-each-time confirmation
@@ -66,16 +70,25 @@ module AgentAdmit
66
70
  # request-body digest for this middleware. AgentAdmit does not verify
67
71
  # the summary against the request; it proves what the human was shown.
68
72
  #
69
- def initialize(app, scope_for: nil, action_summary: nil, &action_summary_block)
73
+ # @param report_outcome [Boolean] when true, reports the downstream
74
+ # Rack response status class to AgentAdmit after the app returns a
75
+ # response triple. Reporting is skipped when the app raises, returns no
76
+ # observable Rack triple, or has no audit_row_id. Reporting failures are
77
+ # logged and never replace the app's response.
78
+ #
79
+ def initialize(app, scope_for: nil, action_summary: nil, report_outcome: false,
80
+ &action_summary_block)
70
81
  @app = app
71
82
  @client = IntrospectionClient.new
72
83
  @config = AgentAdmit.configuration || Config.new
73
84
  @scope_for = scope_for
74
85
  @action_summary = action_summary || action_summary_block
86
+ @report_outcome = report_outcome
75
87
  end
76
88
 
77
89
  def call(env)
78
90
  auth = env["HTTP_AUTHORIZATION"] || ""
91
+ result = nil
79
92
 
80
93
  if BEARER_AGENT_RE.match?(auth)
81
94
  # Strip the scheme prefix (case-insensitively) to get the bare token.
@@ -96,6 +109,8 @@ module AgentAdmit
96
109
  env["agentadmit.connection_id"] = result.connection_id
97
110
  env["agentadmit.agent_label"] = result.agent_label
98
111
  env["agentadmit.presence"] = result.presence
112
+ env["agentadmit.audit_row_id"] = result.audit_row_id
113
+ env["agentadmit.consumed_receipt"] = result.consumed_receipt if result.consumed_receipt
99
114
  # Only set when the hosted service actually SPENT a confirmation on
100
115
  # this call; the key stays absent otherwise, so `env.key?` is a
101
116
  # truthful test.
@@ -116,7 +131,9 @@ module AgentAdmit
116
131
  end
117
132
  end
118
133
 
119
- @app.call(env)
134
+ response = @app.call(env)
135
+ report_outcome_after_response(result, response) if @report_outcome && result
136
+ response
120
137
  end
121
138
 
122
139
  private
@@ -168,5 +185,20 @@ module AgentAdmit
168
185
  rescue StandardError
169
186
  nil
170
187
  end
188
+
189
+ def report_outcome_after_response(result, response)
190
+ return unless result.audit_row_id
191
+ return unless response.is_a?(Array) && response.length >= 3
192
+
193
+ status_class = IntrospectionClient.status_class_for(response[0])
194
+ outcome = IntrospectionClient.outcome_for_status(response[0])
195
+ return unless status_class && outcome
196
+
197
+ @client.report_outcome(result.audit_row_id,
198
+ outcome: outcome,
199
+ status_class: status_class)
200
+ rescue StandardError => e
201
+ warn "[AgentAdmit] Outcome report failed: #{e.message}"
202
+ end
171
203
  end
172
204
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.12.0"
4
+ VERSION = "1.14.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agentadmit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.0
4
+ version: 1.14.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-23 00:00:00.000000000 Z
11
+ date: 2026-11-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json