agentadmit 1.11.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 +85 -0
- data/lib/agentadmit/introspection_client.rb +155 -2
- data/lib/agentadmit/middleware.rb +34 -2
- data/lib/agentadmit/version.rb +1 -1
- data/lib/agentadmit.rb +52 -0
- 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
|
@@ -177,6 +177,19 @@ fail-closed `ActiveDenialError` instances with no link. On an accepted retry,
|
|
|
177
177
|
expose the consumed ceremony so your own transaction step-up can avoid asking
|
|
178
178
|
the human twice.
|
|
179
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
|
+
|
|
180
193
|
## Rate Limiting
|
|
181
194
|
|
|
182
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.
|
|
@@ -436,6 +449,77 @@ result = AgentAdmit::IntrospectionClient.new.verify(
|
|
|
436
449
|
|
|
437
450
|
The local `ScopeEnforcement` checks (`require_scope!`, `require_scope_if_agent!`) are unchanged -- defense in depth on top of the hosted decision.
|
|
438
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
|
+
|
|
439
523
|
### Active-error responses are denials
|
|
440
524
|
|
|
441
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:
|
|
@@ -443,6 +527,7 @@ An introspection response with `active: true` AND a string `error` field means t
|
|
|
443
527
|
- `insufficient_scope` -> `AgentAdmit::InsufficientScopeError`; middlewares return 403 with the step-up shape (`error`, `required_scope`, `granted_scopes`)
|
|
444
528
|
- `bound_exceeded` -> `AgentAdmit::BoundExceededError`; middlewares return 403 passing the hosted fields (`error_description`, `bound`, `renewal`) through verbatim
|
|
445
529
|
- `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
|
|
530
|
+
- `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
|
|
446
531
|
- any other error string -> `AgentAdmit::ActiveDenialError`; middlewares return 403 with `{error: <code>, error_description: "Call refused by the authorization service."}` -- unknown codes fail closed
|
|
447
532
|
|
|
448
533
|
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.
|
|
@@ -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
|
|
@@ -125,6 +149,49 @@ module AgentAdmit
|
|
|
125
149
|
"summary" => nullable.call(raw["summary"]) }
|
|
126
150
|
end
|
|
127
151
|
|
|
152
|
+
##
|
|
153
|
+
# A strictly-typed copy of the wire `declined` block (SDK 1.12.0), or
|
|
154
|
+
# nil when it is malformed. action_session_id, declined_at, hold_until
|
|
155
|
+
# and scope must be Strings; method, endpoint, request_digest and
|
|
156
|
+
# summary are nullable Strings (anything else reads as nil). Nothing
|
|
157
|
+
# outside the contract is copied through.
|
|
158
|
+
#
|
|
159
|
+
# @param raw [Object] the `declined` value from the hosted response
|
|
160
|
+
# @return [Hash, nil]
|
|
161
|
+
#
|
|
162
|
+
def parse_action_decline(raw)
|
|
163
|
+
return nil unless raw.is_a?(Hash)
|
|
164
|
+
return nil unless %w[action_session_id declined_at hold_until scope]
|
|
165
|
+
.all? { |key| raw[key].is_a?(String) }
|
|
166
|
+
|
|
167
|
+
nullable = ->(value) { value.is_a?(String) ? value : nil }
|
|
168
|
+
{ "action_session_id" => raw["action_session_id"],
|
|
169
|
+
"declined_at" => raw["declined_at"],
|
|
170
|
+
"hold_until" => raw["hold_until"],
|
|
171
|
+
"scope" => raw["scope"],
|
|
172
|
+
"method" => nullable.call(raw["method"]),
|
|
173
|
+
"endpoint" => nullable.call(raw["endpoint"]),
|
|
174
|
+
"request_digest" => nullable.call(raw["request_digest"]),
|
|
175
|
+
"summary" => nullable.call(raw["summary"]) }
|
|
176
|
+
end
|
|
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
|
+
|
|
128
195
|
##
|
|
129
196
|
# The agent's X-AgentAdmit-Action-Attestation header from a Rack env:
|
|
130
197
|
# first value only, trimmed, capped at 120 characters. nil when absent
|
|
@@ -337,6 +404,10 @@ module AgentAdmit
|
|
|
337
404
|
"consumed" => true }
|
|
338
405
|
end
|
|
339
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
|
+
|
|
340
411
|
return IntrospectionResult.new(
|
|
341
412
|
user_id: data["user_id"],
|
|
342
413
|
connection_id: data["connection_id"],
|
|
@@ -351,7 +422,9 @@ module AgentAdmit
|
|
|
351
422
|
presence: presence,
|
|
352
423
|
purpose: purpose,
|
|
353
424
|
user_intent: user_intent,
|
|
354
|
-
action_confirmation: action_confirmation
|
|
425
|
+
action_confirmation: action_confirmation,
|
|
426
|
+
audit_row_id: audit_row_id,
|
|
427
|
+
consumed_receipt: consumed_receipt
|
|
355
428
|
)
|
|
356
429
|
end
|
|
357
430
|
|
|
@@ -359,6 +432,60 @@ module AgentAdmit
|
|
|
359
432
|
raise IntrospectionError, "Unexpected exit from retry loop"
|
|
360
433
|
end
|
|
361
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
|
+
|
|
362
489
|
CALLER_CLASSES = %w[human_session in_app_ai external_agent].freeze
|
|
363
490
|
|
|
364
491
|
##
|
|
@@ -462,6 +589,10 @@ module AgentAdmit
|
|
|
462
589
|
# typed, so the agent can hand the link to the human. A malformed
|
|
463
590
|
# ceremony block degrades to the generic denial -- fail closed rather
|
|
464
591
|
# than relay an unusable confirmation.
|
|
592
|
+
# - confirmation_declined: the user declined this exact action on the
|
|
593
|
+
# hosted page; the hold rides along, strictly typed, so the agent can
|
|
594
|
+
# relay the decline to the user. A malformed block degrades to the
|
|
595
|
+
# generic denial.
|
|
465
596
|
# - anything else: unknown refusal -> generic typed denial. Fail closed.
|
|
466
597
|
#
|
|
467
598
|
def raise_active_denial!(data, scope_used)
|
|
@@ -499,6 +630,28 @@ module AgentAdmit
|
|
|
499
630
|
"Call refused by the authorization service.",
|
|
500
631
|
code: "confirmation_required", data: data
|
|
501
632
|
)
|
|
633
|
+
when "confirmation_declined"
|
|
634
|
+
# SDK 1.12.0: the user declined exactly this action on the hosted
|
|
635
|
+
# page and the hold still runs. Relay the decline so the agent can
|
|
636
|
+
# tell the user instead of nagging with a link.
|
|
637
|
+
declined = self.class.parse_action_decline(data["declined"])
|
|
638
|
+
if declined
|
|
639
|
+
description = data["error_description"]
|
|
640
|
+
description = ConfirmationDeclinedError::DESCRIPTION unless
|
|
641
|
+
description.is_a?(String) && !description.empty?
|
|
642
|
+
status = data["attestation_status"]
|
|
643
|
+
raise ConfirmationDeclinedError.new(
|
|
644
|
+
description,
|
|
645
|
+
declined: declined,
|
|
646
|
+
attestation_status: status.is_a?(String) ? status : nil,
|
|
647
|
+
data: data
|
|
648
|
+
)
|
|
649
|
+
end
|
|
650
|
+
# Malformed decline: generic denial, fail closed, no block.
|
|
651
|
+
raise ActiveDenialError.new(
|
|
652
|
+
"Call refused by the authorization service.",
|
|
653
|
+
code: "confirmation_declined", data: data
|
|
654
|
+
)
|
|
502
655
|
else
|
|
503
656
|
raise ActiveDenialError.new(
|
|
504
657
|
"Call refused by the authorization service.",
|
|
@@ -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
data/lib/agentadmit.rb
CHANGED
|
@@ -154,6 +154,58 @@ module AgentAdmit
|
|
|
154
154
|
end
|
|
155
155
|
end
|
|
156
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
|
+
|
|
157
209
|
class IntrospectionError < Error; end
|
|
158
210
|
class ConfigurationError < Error; end
|
|
159
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.
|
|
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
|