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 +4 -4
- data/README.md +38 -2
- data/lib/agentadmit/caller_consent.rb +17 -12
- data/lib/agentadmit/config.rb +39 -2
- data/lib/agentadmit/introspection_client.rb +171 -7
- data/lib/agentadmit/middleware.rb +82 -4
- data/lib/agentadmit/scope_enforcement.rb +5 -0
- data/lib/agentadmit/version.rb +1 -1
- data/lib/agentadmit.rb +56 -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: 501b7f18f13450e559d3a58aa7060fdee56eb167b33e60245a7b734d900cf0b8
|
|
4
|
+
data.tar.gz: dedcb0707371dfec628f2e0f8dfb0a7ee98d02260626a4601d99de30f22bb28e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
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
|
|
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
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
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
|
-
#
|
|
149
|
-
#
|
|
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
|
data/lib/agentadmit/config.rb
CHANGED
|
@@ -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
|
-
|
|
21
|
-
self.
|
|
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,
|
|
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
|
|
90
|
-
# string -- the service refused this call;
|
|
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
|
-
|
|
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
|
|
32
|
-
# never heard of). The middleware returns 403 and
|
|
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
|
-
|
|
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
|
data/lib/agentadmit/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-09-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|