agentadmit 1.12.0 → 1.13.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: 3d3b45175d51202f94a44cfda45acd2eedbd6e96453686633e2c23fe1e9e4b07
4
+ data.tar.gz: 8e6e17f6848db25e36996afcb1e16ed45ec0ab591647cdc9bbd56f51b1b1957d
5
5
  SHA512:
6
- metadata.gz: 39bb513f8747187ffeb5d7bb73f4557c47a9da0a95f1bd8b56130f95b38406081befb47570f425afbdfbc1ca322069459c5ebf3e4fcb1f4c164320242c306882
7
- data.tar.gz: 35577380cb5d5e783fe05e68c67c291238de414dd9b8fb99cb4d9ea8f75a5befed74c501ccd50e1a2eade6ffe3dc3d009d712ef5f44f1b1ba41e7928d8c20a99
6
+ metadata.gz: e77aed424389ce8a3c15445505cf32cadad2f04a004d4eb003b06c07891556c1968dda9c27506045ea7762c054f31b26c5c7271b79c96fc696423edee7655a6f
7
+ data.tar.gz: 4526e4389bc7dad6a0799d0633082f9c885afff37c4aaa80be55065856d82a507bf12d20270e27cb636a057342995e880aeaa5734edfbfe8bd67e2110cb61223
data/README.md CHANGED
@@ -449,6 +449,77 @@ result = AgentAdmit::IntrospectionClient.new.verify(
449
449
 
450
450
  The local `ScopeEnforcement` checks (`require_scope!`, `require_scope_if_agent!`) are unchanged -- defense in depth on top of the hosted decision.
451
451
 
452
+ ### Outcome reporting
453
+
454
+ Successful verify responses may include `result.audit_row_id`, the hosted
455
+ audit row id for that call. After your handler runs, you can append what your
456
+ app observed:
457
+
458
+ ```ruby
459
+ client = AgentAdmit::IntrospectionClient.new
460
+ result = client.verify(token, scope_used: "write:payments",
461
+ endpoint: request.path, method: request.request_method)
462
+
463
+ begin
464
+ # run the app action
465
+ client.report_outcome(result.audit_row_id,
466
+ outcome: "executed",
467
+ status_class: "2xx") if result.audit_row_id
468
+ rescue
469
+ client.report_outcome(result.audit_row_id,
470
+ outcome: "failed",
471
+ status_class: "5xx") if result.audit_row_id
472
+ raise
473
+ end
474
+ ```
475
+
476
+ `outcome` must be `"executed"`, `"failed"`, or `"unknown"`. `unknown` is
477
+ only for an explicit "we cannot truthfully classify this" case; it is never
478
+ chosen automatically. `status_class` may be `"1xx"` through `"5xx"` or `nil`.
479
+ Outcome rows report what the app observed after verification; they are not
480
+ independent proof of execution.
481
+
482
+ Rack middleware can do the common status mapping for you:
483
+
484
+ ```ruby
485
+ use AgentAdmit::Middleware,
486
+ scope_for: "write:payments",
487
+ action_summary: ->(env) { "Pay Alex $50" },
488
+ report_outcome: true
489
+ ```
490
+
491
+ With `report_outcome: true`, the middleware reports only after the downstream
492
+ Rack app returns a response triple and the status has an observable class.
493
+ Statuses below 400 report `"executed"`; statuses 400 and above report
494
+ `"failed"`. If the app raises, aborts before returning a triple, returns no
495
+ observable status, or verify did not include `audit_row_id`, the middleware
496
+ does not guess. Reporting errors are logged and never replace the app's
497
+ response.
498
+
499
+ For downstream code, the Rack env includes `env["agentadmit.audit_row_id"]`
500
+ when the hosted service returned one.
501
+
502
+ ### Replay receipts
503
+
504
+ When an agent retries with an already-consumed confirm-each-time attestation,
505
+ verify may return `result.consumed_receipt`. It is a diagnostic receipt for
506
+ the earlier consumption event:
507
+
508
+ ```ruby
509
+ result.consumed_receipt
510
+ # => {
511
+ # "consumed_at" => "2026-09-30T02:54:07.000Z",
512
+ # "connection_id" => "conn_123",
513
+ # "chain_seq" => nil,
514
+ # "row_hash" => nil
515
+ # }
516
+ ```
517
+
518
+ This is not a fresh authorization and must not be treated as permission to
519
+ run the action. It exists so agents and apps can explain an
520
+ `already_consumed` replay without staging another confirmation. Rack exposes
521
+ the typed block at `env["agentadmit.consumed_receipt"]` when present.
522
+
452
523
  ### Active-error responses are denials
453
524
 
454
525
  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,14 @@ 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, keyword_init: true) do
35
38
  def has_scope?(scope)
36
39
  scopes.include?(scope)
37
40
  end
@@ -83,9 +86,30 @@ module AgentAdmit
83
86
  def action_confirmed?
84
87
  action_confirmation.is_a?(Hash) && action_confirmation["consumed"] == true
85
88
  end
89
+
90
+ # `audit_row_id` (String or nil) identifies the hosted audit row for
91
+ # this successful verify call. Use it with #report_outcome after the
92
+ # application observes whether its handler executed or failed.
93
+
94
+ # `consumed_receipt` (Hash or nil) is a replay diagnostic for an
95
+ # already-consumed confirm-each-time attestation. It is not a fresh
96
+ # authorization and must not be treated as permission to run.
86
97
  end
87
98
 
88
99
  class << self
100
+ def status_class_for(status)
101
+ code = Integer(status) rescue nil
102
+ return nil unless code && (100..599).cover?(code)
103
+
104
+ "#{code / 100}xx"
105
+ end
106
+
107
+ def outcome_for_status(status)
108
+ status_class_for(status) ? (Integer(status) < 400 ? "executed" : "failed") : nil
109
+ rescue ArgumentError, TypeError
110
+ nil
111
+ end
112
+
89
113
  ##
90
114
  # `sha256:<hex>` over the RAW request body bytes, so a confirmation
91
115
  # covers the exact payload and not merely the route. nil for an empty
@@ -151,6 +175,23 @@ module AgentAdmit
151
175
  "summary" => nullable.call(raw["summary"]) }
152
176
  end
153
177
 
178
+ ##
179
+ # A strictly-typed replay diagnostic for an already-consumed action
180
+ # attestation, or nil when the block is absent/malformed. This is a
181
+ # receipt for an earlier consumption event, not authorization for this
182
+ # call.
183
+ #
184
+ def parse_consumed_receipt(raw)
185
+ return nil unless raw.is_a?(Hash)
186
+ return nil unless raw["consumed_at"].is_a?(String)
187
+ return nil unless raw["connection_id"].is_a?(String)
188
+
189
+ { "consumed_at" => raw["consumed_at"],
190
+ "connection_id" => raw["connection_id"],
191
+ "chain_seq" => raw["chain_seq"].is_a?(Integer) ? raw["chain_seq"] : nil,
192
+ "row_hash" => raw["row_hash"].is_a?(String) ? raw["row_hash"] : nil }
193
+ end
194
+
154
195
  ##
155
196
  # The agent's X-AgentAdmit-Action-Attestation header from a Rack env:
156
197
  # first value only, trimmed, capped at 120 characters. nil when absent
@@ -363,6 +404,10 @@ module AgentAdmit
363
404
  "consumed" => true }
364
405
  end
365
406
 
407
+ audit_row_id = data["audit_row_id"]
408
+ audit_row_id = nil unless audit_row_id.is_a?(String)
409
+ consumed_receipt = self.class.parse_consumed_receipt(data["consumed_receipt"])
410
+
366
411
  return IntrospectionResult.new(
367
412
  user_id: data["user_id"],
368
413
  connection_id: data["connection_id"],
@@ -377,7 +422,9 @@ module AgentAdmit
377
422
  presence: presence,
378
423
  purpose: purpose,
379
424
  user_intent: user_intent,
380
- action_confirmation: action_confirmation
425
+ action_confirmation: action_confirmation,
426
+ audit_row_id: audit_row_id,
427
+ consumed_receipt: consumed_receipt
381
428
  )
382
429
  end
383
430
 
@@ -385,6 +432,60 @@ module AgentAdmit
385
432
  raise IntrospectionError, "Unexpected exit from retry loop"
386
433
  end
387
434
 
435
+ ##
436
+ # Report what the application observed after a successful verify call.
437
+ #
438
+ # This appends an outcome row to the hosted tamper-evident audit chain.
439
+ # It reports the app's observed result; it is not independent proof of
440
+ # execution. Use outcome: "unknown" only when you explicitly cannot
441
+ # classify the result.
442
+ #
443
+ # @param audit_row_id [String] result.audit_row_id from #verify
444
+ # @param outcome [String] "executed" | "failed" | "unknown"
445
+ # @param status_class [String, nil] "1xx".."5xx" or nil
446
+ # @return [Hash] parsed hosted outcome response
447
+ # @raise [ArgumentError] invalid arguments
448
+ # @raise [IntrospectionError] hosted service unreachable or rejected the report
449
+ #
450
+ def report_outcome(audit_row_id, outcome:, status_class: nil)
451
+ unless audit_row_id.is_a?(String) && !audit_row_id.empty?
452
+ raise ArgumentError, "audit_row_id is required"
453
+ end
454
+ unless OUTCOMES.include?(outcome)
455
+ raise ArgumentError, "outcome must be executed, failed, or unknown"
456
+ end
457
+ unless status_class.nil? || STATUS_CLASSES.include?(status_class)
458
+ raise ArgumentError, "status_class must be 1xx, 2xx, 3xx, 4xx, 5xx, or nil"
459
+ end
460
+
461
+ origin = @config.api_url.sub(%r{/\z}, "")
462
+ uri = URI.parse("#{origin}/api/v1/audit/#{audit_row_id}/outcome")
463
+ http = build_http(uri)
464
+
465
+ request = Net::HTTP::Post.new(uri.path)
466
+ request["Authorization"] = "Bearer #{@config.api_key}"
467
+ request["Content-Type"] = "application/json"
468
+ request.body = JSON.generate(outcome: outcome, status_class: status_class)
469
+
470
+ response = begin
471
+ http.request(request)
472
+ rescue StandardError => e
473
+ raise IntrospectionError, "Outcome report failed: #{e.message}"
474
+ end
475
+
476
+ unless (200..299).cover?(response.code.to_i)
477
+ data = JSON.parse(response.body) rescue {}
478
+ raise IntrospectionError,
479
+ data["error_description"] || data["error"] || "Outcome report returned #{response.code}"
480
+ end
481
+
482
+ begin
483
+ JSON.parse(response.body)
484
+ rescue JSON::ParserError
485
+ raise IntrospectionError, "Outcome report response is not valid JSON"
486
+ end
487
+ end
488
+
388
489
  CALLER_CLASSES = %w[human_session in_app_ai external_agent].freeze
389
490
 
390
491
  ##
@@ -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.13.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.13.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-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json