agentadmit 1.2.0 → 1.4.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 +45 -0
- data/lib/agentadmit/introspection_client.rb +92 -2
- data/lib/agentadmit/middleware.rb +2 -0
- data/lib/agentadmit/scope_enforcement.rb +23 -0
- 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: bc3085f5469fd5380afb22df98cf1517e1f74df3a23cf7ab07d7f6c490cd9feb
|
|
4
|
+
data.tar.gz: fb0142a79357486644fff3e68903194b998cf5b41c1f140a8bda8f7a2f0c2909
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e54b1b2f7c357b546388d09a9accf94c29c98964ade0d5888e87962d3d30219486cf30b932234d503e46d6ea80e4a6348a165a6172a09b4d182390709c605713
|
|
7
|
+
data.tar.gz: 3d237b464785de4d0a8a081d992dd7313c77c84a3bdac92f0a3a9dc41a2909137cfec0415d7eb67dd4408c1eb772d24ff514e996184ad0d580b9f047fcd0a59d
|
data/README.md
CHANGED
|
@@ -72,6 +72,51 @@ The token goes to the human, not the agent. No automated delivery = no prompt in
|
|
|
72
72
|
|
|
73
73
|
**In-app AI scopes.** If your app has built-in AI features (analysis, plan generation, photo recognition), do not expose those as agent scopes. The user's AI agent can read the raw data and do the analysis itself. Exposing in-app AI endpoints to agents creates double cost.
|
|
74
74
|
|
|
75
|
+
### Consent Ledger (Caller-Identity Consent)
|
|
76
|
+
|
|
77
|
+
AgentAdmit can host per-user consent switches for three independent caller classes: `human_session`, `in_app_ai`, and `external_agent`. No class's setting implies another's.
|
|
78
|
+
|
|
79
|
+
**External agents:** the verify result already carries the verdict:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
result = AgentAdmit::IntrospectionClient.new.verify(token)
|
|
83
|
+
unless result.consent_granted?
|
|
84
|
+
# the data owner has switched external agents off: return your own 403
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Human sessions and in-app AI** never hold AgentAdmit tokens, so ask directly:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
verdict = AgentAdmit::IntrospectionClient.new.check_consent(
|
|
92
|
+
app_user_id: "user_8842", caller_class: "in_app_ai"
|
|
93
|
+
)
|
|
94
|
+
head :forbidden unless verdict["granted"]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Consent is orthogonal to revocation: a denied verdict means your app returns its own 403; the connection and token stay valid so the user can flip consent back on without re-connecting. Write switches through `PUT /api/v1/consent/settings` from your backend; export the audit trail with `GET /api/v1/consent/export` (every plan).
|
|
98
|
+
|
|
99
|
+
### Presence verification
|
|
100
|
+
|
|
101
|
+
AgentAdmit can attest that the human who authorized a connection completed a WebAuthn presence ceremony on the consent page. The verify result carries the fact:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
result = AgentAdmit::IntrospectionClient.new.verify(token)
|
|
105
|
+
result.presence_verified? # true only when the ceremony completed
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
For sensitive actions, enforce it in your controllers the same way you enforce scopes:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class TransfersController < ApplicationController
|
|
112
|
+
include AgentAdmit::ScopeEnforcement
|
|
113
|
+
|
|
114
|
+
before_action -> { require_presence! }, only: [:create]
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`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.
|
|
119
|
+
|
|
75
120
|
## Rate Limiting
|
|
76
121
|
|
|
77
122
|
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.
|
|
@@ -16,10 +16,35 @@ module AgentAdmit
|
|
|
16
16
|
MAX_RETRY_BUDGET_MS = 120_000
|
|
17
17
|
|
|
18
18
|
IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
|
|
19
|
-
:sub, :role, :app_id, :jti, :exp,
|
|
19
|
+
:sub, :role, :app_id, :jti, :exp, :consent,
|
|
20
|
+
:presence, keyword_init: true) do
|
|
20
21
|
def has_scope?(scope)
|
|
21
22
|
scopes.include?(scope)
|
|
22
23
|
end
|
|
24
|
+
|
|
25
|
+
# Consent Ledger verdict for the external-agent path (additive; may be
|
|
26
|
+
# nil). A denied verdict means the app returns its own 403 -- the token
|
|
27
|
+
# itself stays valid (consent is orthogonal to revocation).
|
|
28
|
+
#
|
|
29
|
+
# Contract (matches the PHP and Java SDKs): an ABSENT consent block
|
|
30
|
+
# means a legacy server that never sends one -- treated as allowed,
|
|
31
|
+
# which is also the platform default for the external-agent class. A
|
|
32
|
+
# PRESENT block grants only on an explicit boolean true; a missing or
|
|
33
|
+
# non-boolean granted value is treated as denied (malformed = deny).
|
|
34
|
+
def consent_granted?
|
|
35
|
+
return true if consent.nil?
|
|
36
|
+
consent["granted"] == true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Human-presence fact from the WebAuthn step-up (additive; may be nil).
|
|
40
|
+
# True ONLY when the connection was authorized by a human who completed
|
|
41
|
+
# a presence ceremony on the consent page: verified must be the boolean
|
|
42
|
+
# true. Unlike consent, absence fails closed -- older servers never send
|
|
43
|
+
# the block, and connections minted without a ceremony carry
|
|
44
|
+
# verified: false, so nil/false/malformed all read as not verified.
|
|
45
|
+
def presence_verified?
|
|
46
|
+
presence.is_a?(Hash) && presence["verified"] == true
|
|
47
|
+
end
|
|
23
48
|
end
|
|
24
49
|
|
|
25
50
|
def initialize(config = nil)
|
|
@@ -136,6 +161,20 @@ module AgentAdmit
|
|
|
136
161
|
# Validate that consumed fields have the expected types when present.
|
|
137
162
|
validate_introspection_types!(data)
|
|
138
163
|
|
|
164
|
+
# Keep any PRESENT consent hash, even with a malformed granted value:
|
|
165
|
+
# coercing it to nil would read as "legacy server, allowed" in
|
|
166
|
+
# consent_granted?, silently failing open. A malformed verdict must
|
|
167
|
+
# stay visible so consent_granted? can deny it.
|
|
168
|
+
consent = data["consent"]
|
|
169
|
+
consent = nil unless consent.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
# Presence rides along when the platform returns it. Same strictness
|
|
172
|
+
# as active: verified must be the boolean true or false, never coerced.
|
|
173
|
+
# Unlike consent, a malformed block is dropped -- presence_verified?
|
|
174
|
+
# fails closed on nil, so dropping cannot fail open.
|
|
175
|
+
presence = data["presence"]
|
|
176
|
+
presence = nil unless presence.is_a?(Hash) && [true, false].include?(presence["verified"])
|
|
177
|
+
|
|
139
178
|
return IntrospectionResult.new(
|
|
140
179
|
user_id: data["user_id"],
|
|
141
180
|
connection_id: data["connection_id"],
|
|
@@ -145,7 +184,9 @@ module AgentAdmit
|
|
|
145
184
|
role: data["role"],
|
|
146
185
|
app_id: data["app_id"],
|
|
147
186
|
jti: data["jti"],
|
|
148
|
-
exp: data["exp"]
|
|
187
|
+
exp: data["exp"],
|
|
188
|
+
consent: consent,
|
|
189
|
+
presence: presence
|
|
149
190
|
)
|
|
150
191
|
end
|
|
151
192
|
|
|
@@ -153,6 +194,55 @@ module AgentAdmit
|
|
|
153
194
|
raise IntrospectionError, "Unexpected exit from retry loop"
|
|
154
195
|
end
|
|
155
196
|
|
|
197
|
+
CALLER_CLASSES = %w[human_session in_app_ai external_agent].freeze
|
|
198
|
+
|
|
199
|
+
##
|
|
200
|
+
# Ask the Consent Ledger whether a caller class may act on a user's data.
|
|
201
|
+
# Decision point for the token-less caller classes (human_session,
|
|
202
|
+
# in_app_ai); external agents get the same verdict on the verify result.
|
|
203
|
+
#
|
|
204
|
+
# @param app_user_id [String] your app's identifier for the data owner
|
|
205
|
+
# @param caller_class [String] "human_session" | "in_app_ai" | "external_agent"
|
|
206
|
+
# @param scope_group [String, nil] optional finer-than-class group
|
|
207
|
+
# @return [Hash] verdict: granted, caller_class, scope_group, source, evaluated_at
|
|
208
|
+
# @raise [ArgumentError] unknown caller_class
|
|
209
|
+
# @raise [IntrospectionError] hosted service unreachable or rejected the call
|
|
210
|
+
#
|
|
211
|
+
def check_consent(app_user_id:, caller_class:, scope_group: nil)
|
|
212
|
+
unless CALLER_CLASSES.include?(caller_class)
|
|
213
|
+
raise ArgumentError, "caller_class must be one of #{CALLER_CLASSES.join(', ')}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
uri = URI.parse("#{@config.api_url.sub(%r{/\z}, '')}/api/v1/consent/check")
|
|
217
|
+
http = build_http(uri)
|
|
218
|
+
|
|
219
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
220
|
+
request["Authorization"] = "Bearer #{@config.api_key}"
|
|
221
|
+
request["Content-Type"] = "application/json"
|
|
222
|
+
body = { app_user_id: app_user_id, caller_class: caller_class }
|
|
223
|
+
body[:scope_group] = scope_group if scope_group
|
|
224
|
+
request.body = JSON.generate(body)
|
|
225
|
+
|
|
226
|
+
response = begin
|
|
227
|
+
http.request(request)
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
raise IntrospectionError, "Consent check failed: #{e.message}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
unless (200..299).cover?(response.code.to_i)
|
|
233
|
+
data = JSON.parse(response.body) rescue {}
|
|
234
|
+
raise IntrospectionError,
|
|
235
|
+
data["error_description"] || data["error"] || "Consent check returned #{response.code}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# 2xx -- parse strictly; a garbage body must not read as an empty verdict.
|
|
239
|
+
begin
|
|
240
|
+
JSON.parse(response.body)
|
|
241
|
+
rescue JSON::ParserError
|
|
242
|
+
raise IntrospectionError, "Consent check response is not valid JSON"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
156
246
|
private
|
|
157
247
|
|
|
158
248
|
##
|
|
@@ -11,6 +11,7 @@ module AgentAdmit
|
|
|
11
11
|
# env['agentadmit.scopes'] -- granted scopes array
|
|
12
12
|
# env['agentadmit.connection_id'] -- connection identifier
|
|
13
13
|
# env['agentadmit.agent_label'] -- agent display name
|
|
14
|
+
# env['agentadmit.presence'] -- human-presence block (Hash) or nil
|
|
14
15
|
#
|
|
15
16
|
class Middleware
|
|
16
17
|
# RFC 7235: the auth-scheme token is case-insensitive.
|
|
@@ -37,6 +38,7 @@ module AgentAdmit
|
|
|
37
38
|
env["agentadmit.scopes"] = result.scopes
|
|
38
39
|
env["agentadmit.connection_id"] = result.connection_id
|
|
39
40
|
env["agentadmit.agent_label"] = result.agent_label
|
|
41
|
+
env["agentadmit.presence"] = result.presence
|
|
40
42
|
rescue InvalidTokenError => e
|
|
41
43
|
return [401, { "Content-Type" => "application/json" },
|
|
42
44
|
[{ error: "invalid_token", error_description: e.message }.to_json]]
|
|
@@ -10,6 +10,7 @@ module AgentAdmit
|
|
|
10
10
|
#
|
|
11
11
|
# before_action -> { require_scope!("read:orders") }, only: [:index, :show]
|
|
12
12
|
# before_action -> { require_scope_if_agent!("create:orders") }, only: [:create]
|
|
13
|
+
# before_action -> { require_presence! }, only: [:destroy]
|
|
13
14
|
# end
|
|
14
15
|
#
|
|
15
16
|
module ScopeEnforcement
|
|
@@ -54,6 +55,27 @@ module AgentAdmit
|
|
|
54
55
|
end
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
##
|
|
59
|
+
# Enforce human-presence verification -- agent's connection MUST have been
|
|
60
|
+
# authorized with a completed WebAuthn presence ceremony or gets 403.
|
|
61
|
+
# Fail closed: connections from servers that predate the presence feature
|
|
62
|
+
# (no presence block) are treated as not verified.
|
|
63
|
+
#
|
|
64
|
+
def require_presence!
|
|
65
|
+
unless request.env["agentadmit.auth_type"] == "agent"
|
|
66
|
+
render json: { error: "invalid_token", error_description: "AgentAdmit token required" }, status: :unauthorized
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
presence = request.env["agentadmit.presence"]
|
|
71
|
+
unless presence.is_a?(Hash) && presence["verified"] == true
|
|
72
|
+
render json: {
|
|
73
|
+
error: "presence_required",
|
|
74
|
+
error_description: "This action requires a connection authorized with human presence verification."
|
|
75
|
+
}, status: :forbidden
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
57
79
|
##
|
|
58
80
|
# Get the current agent/user context.
|
|
59
81
|
#
|
|
@@ -64,6 +86,7 @@ module AgentAdmit
|
|
|
64
86
|
scopes: request.env["agentadmit.scopes"],
|
|
65
87
|
connection_id: request.env["agentadmit.connection_id"],
|
|
66
88
|
agent_label: request.env["agentadmit.agent_label"],
|
|
89
|
+
presence: request.env["agentadmit.presence"],
|
|
67
90
|
}
|
|
68
91
|
end
|
|
69
92
|
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.4.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-07-
|
|
11
|
+
date: 2026-07-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|