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 +4 -4
- data/README.md +71 -0
- data/lib/agentadmit/introspection_client.rb +103 -2
- data/lib/agentadmit/middleware.rb +34 -2
- data/lib/agentadmit/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d3b45175d51202f94a44cfda45acd2eedbd6e96453686633e2c23fe1e9e4b07
|
|
4
|
+
data.tar.gz: 8e6e17f6848db25e36996afcb1e16ed45ec0ab591647cdc9bbd56f51b1b1957d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
|
|
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
|
data/lib/agentadmit/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|