agentadmit 1.5.0 → 1.7.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 +35 -3
- data/lib/agentadmit/caller_consent.rb +31 -7
- data/lib/agentadmit/introspection_client.rb +32 -18
- data/lib/agentadmit/tokens_client.rb +15 -1
- 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: 53205cacbbf7942a6cb2600c9cea4c0c739d991be95bd7c627df405287e5f52c
|
|
4
|
+
data.tar.gz: b9849673334811698875c9733abc1b69c2938111cf4b928cde741cb0e6ba6d0f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ed8c4e40cc18a7749257674d2ba0fba6d8fb79946aeb868cf84fa5f5fa67075eff3cc80456e67e34886157f63cbaaceb1156abc3ce92f87a3400096920354f69
|
|
7
|
+
data.tar.gz: e184a592c70ac59b82f906f00a7cae3c83ecd3b9681e5a35421bad55c3979a11bbfbfe45f85f8135fe4a909ed1f1c394108b03d86b199f91bf999ac8cf40e1bc
|
data/README.md
CHANGED
|
@@ -76,15 +76,24 @@ The token goes to the human, not the agent. No automated delivery = no prompt in
|
|
|
76
76
|
|
|
77
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
78
|
|
|
79
|
-
**External agents:** the verify result already carries the verdict:
|
|
79
|
+
**External agents:** the verify result already carries the verdict. The hosted service deliberately omits the verdict when its consent store is unreadable (degraded mode), so an absent verdict is *unresolved*, never a grant — `consent_granted?` fails closed on it. Resolve an absent or malformed verdict through the ledger:
|
|
80
80
|
|
|
81
81
|
```ruby
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
client = AgentAdmit::IntrospectionClient.new
|
|
83
|
+
result = client.verify(token)
|
|
84
|
+
consent = result.consent
|
|
85
|
+
unless consent.is_a?(Hash) && [true, false].include?(consent["granted"])
|
|
86
|
+
# absent/malformed verdict: the ledger holds the authoritative answer
|
|
87
|
+
consent = client.check_consent(app_user_id: result.user_id,
|
|
88
|
+
caller_class: "external_agent") # fail closed on error
|
|
89
|
+
end
|
|
90
|
+
unless consent["granted"] == true
|
|
84
91
|
# the data owner has switched external agents off: return your own 403
|
|
85
92
|
end
|
|
86
93
|
```
|
|
87
94
|
|
|
95
|
+
The `AgentAdmit::CallerConsent` middleware does all of this for you: it evaluates the consent verdict **before** the scope check (a caller whose class the owner denied learns nothing about scope state) and resolves an absent verdict through the Consent Ledger, fail-closed.
|
|
96
|
+
|
|
88
97
|
**Human sessions and in-app AI** never hold AgentAdmit tokens, so ask directly:
|
|
89
98
|
|
|
90
99
|
```ruby
|
|
@@ -293,3 +302,26 @@ granted = tokens.exchange(connection_token, agent_label: "MyAssistant")
|
|
|
293
302
|
# Revoke when the user disconnects the agent.
|
|
294
303
|
tokens.revoke(granted["connection_id"], reason: "user_requested")
|
|
295
304
|
```
|
|
305
|
+
|
|
306
|
+
### Declared purpose
|
|
307
|
+
|
|
308
|
+
Declared purpose: the user-facing reason recorded on the grant at the consent moment. Review-time record only, never an enforcement input; authorization decisions ride scopes, connection status, and consent.
|
|
309
|
+
|
|
310
|
+
Pass it when issuing a connection token (optional, max 300 characters; omitted from the request when `nil`):
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
issued = tokens.issue_token(
|
|
314
|
+
user_id: "user_42",
|
|
315
|
+
scopes: ["read:orders"],
|
|
316
|
+
purpose: "Book quarterly travel for the sales team"
|
|
317
|
+
)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The verify result carries it back for display in dashboards, review screens, and audit views:
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
result = AgentAdmit::IntrospectionClient.new.verify(token)
|
|
324
|
+
result.purpose # => "Book quarterly travel for the sales team" or nil
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`purpose` is nullable -- connections issued without one (or by servers that predate the field) read as `nil`. Do not branch authorization on it; keep enforcement on scopes, connection status, and consent.
|
|
@@ -18,7 +18,9 @@ module AgentAdmit
|
|
|
18
18
|
#
|
|
19
19
|
# - external_agent: an ag_at_ access token -> hosted introspection, which
|
|
20
20
|
# returns the external-agent consent verdict inline plus the granted
|
|
21
|
-
# scopes.
|
|
21
|
+
# scopes. Consent is evaluated BEFORE scope (a denied class must not
|
|
22
|
+
# learn scope state). A missing or malformed verdict is resolved through
|
|
23
|
+
# the Consent Ledger, fail-closed -- absence is never a grant.
|
|
22
24
|
# - in_app_ai: your application's own server-side AI code path -> the
|
|
23
25
|
# Consent Ledger /consent/check for the in-app-AI class.
|
|
24
26
|
# - human_session: your application's own permission model (sharing,
|
|
@@ -121,8 +123,12 @@ module AgentAdmit
|
|
|
121
123
|
|
|
122
124
|
##
|
|
123
125
|
# External-agent path: hosted introspection carries the verdict and the
|
|
124
|
-
# scopes.
|
|
125
|
-
#
|
|
126
|
+
# scopes. Consent is evaluated first (Patent FIG. 3: the class consent
|
|
127
|
+
# decision precedes scope evaluation) -- checking scope first would leak
|
|
128
|
+
# granted-scope state to callers whose class the owner has denied. The
|
|
129
|
+
# hosted service omits the verdict when its consent-store read fails
|
|
130
|
+
# (designed degraded mode), so an absent or malformed verdict is resolved
|
|
131
|
+
# through the Consent Ledger, fail-closed -- never treated as a grant.
|
|
126
132
|
#
|
|
127
133
|
def call_external_agent(env)
|
|
128
134
|
token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
|
|
@@ -135,6 +141,27 @@ module AgentAdmit
|
|
|
135
141
|
return json_error(502, "introspection_failed", e.message)
|
|
136
142
|
end
|
|
137
143
|
|
|
144
|
+
consent = result.consent
|
|
145
|
+
unless consent.is_a?(Hash) && [true, false].include?(consent["granted"])
|
|
146
|
+
owner = result.user_id
|
|
147
|
+
if !owner.is_a?(String) || owner.empty?
|
|
148
|
+
return json_error(503, "consent_unavailable",
|
|
149
|
+
"Introspection carried no consent verdict and no resolvable data owner")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
begin
|
|
153
|
+
consent = @client.check_consent(app_user_id: owner, caller_class: EXTERNAL_AGENT,
|
|
154
|
+
scope_group: @scope_group)
|
|
155
|
+
rescue StandardError
|
|
156
|
+
return json_error(503, "consent_unavailable", "Consent check failed")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
unless consent.is_a?(Hash) && consent["granted"] == true
|
|
161
|
+
return json_error(403, "consent_not_granted",
|
|
162
|
+
"The data owner has not enabled external agent access.")
|
|
163
|
+
end
|
|
164
|
+
|
|
138
165
|
if @required_scope && !(result.scopes || []).include?(@required_scope)
|
|
139
166
|
return [403, { "Content-Type" => "application/json" },
|
|
140
167
|
[{ error: "insufficient_scope",
|
|
@@ -143,16 +170,13 @@ module AgentAdmit
|
|
|
143
170
|
message: "This action requires '#{@required_scope}' scope." }.to_json]]
|
|
144
171
|
end
|
|
145
172
|
|
|
146
|
-
return json_error(403, "consent_not_granted",
|
|
147
|
-
"The data owner has not enabled external agent access.") unless result.consent_granted?
|
|
148
|
-
|
|
149
173
|
env["agentadmit.auth_type"] = "agent"
|
|
150
174
|
env["agentadmit.user_id"] = result.user_id
|
|
151
175
|
env["agentadmit.scopes"] = result.scopes
|
|
152
176
|
env["agentadmit.connection_id"] = result.connection_id
|
|
153
177
|
env["agentadmit.agent_label"] = result.agent_label
|
|
154
178
|
env["agentadmit.presence"] = result.presence
|
|
155
|
-
env["agentadmit.consent"] =
|
|
179
|
+
env["agentadmit.consent"] = consent
|
|
156
180
|
|
|
157
181
|
@app.call(env)
|
|
158
182
|
end
|
|
@@ -17,7 +17,7 @@ module AgentAdmit
|
|
|
17
17
|
|
|
18
18
|
IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
|
|
19
19
|
:sub, :role, :app_id, :jti, :exp, :consent,
|
|
20
|
-
:presence, keyword_init: true) do
|
|
20
|
+
:presence, :purpose, keyword_init: true) do
|
|
21
21
|
def has_scope?(scope)
|
|
22
22
|
scopes.include?(scope)
|
|
23
23
|
end
|
|
@@ -26,25 +26,32 @@ module AgentAdmit
|
|
|
26
26
|
# nil). A denied verdict means the app returns its own 403 -- the token
|
|
27
27
|
# itself stays valid (consent is orthogonal to revocation).
|
|
28
28
|
#
|
|
29
|
-
# Contract
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
29
|
+
# Contract: grants ONLY on an explicit boolean true. An ABSENT block is
|
|
30
|
+
# NEVER a grant -- the hosted service deliberately omits it when its
|
|
31
|
+
# consent-store read fails (designed degraded mode), so absence here
|
|
32
|
+
# fails closed. A missing or non-boolean granted value is likewise
|
|
33
|
+
# denied (malformed = deny). Callers that want the authoritative answer
|
|
34
|
+
# for an absent/malformed verdict should resolve it through
|
|
35
|
+
# #check_consent for the external_agent class (CallerConsent does this
|
|
36
|
+
# automatically).
|
|
34
37
|
def consent_granted?
|
|
35
|
-
|
|
36
|
-
consent["granted"] == true
|
|
38
|
+
consent.is_a?(Hash) && consent["granted"] == true
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
# Human-presence fact from the WebAuthn step-up (additive; may be nil).
|
|
40
42
|
# True ONLY when the connection was authorized by a human who completed
|
|
41
43
|
# a presence ceremony on the consent page: verified must be the boolean
|
|
42
|
-
# true.
|
|
43
|
-
#
|
|
44
|
-
#
|
|
44
|
+
# true. Absence fails closed -- older servers never send the block, and
|
|
45
|
+
# connections minted without a ceremony carry verified: false, so
|
|
46
|
+
# nil/false/malformed all read as not verified.
|
|
45
47
|
def presence_verified?
|
|
46
48
|
presence.is_a?(Hash) && presence["verified"] == true
|
|
47
49
|
end
|
|
50
|
+
|
|
51
|
+
# `purpose` (String or nil) is the declared purpose: the user-facing
|
|
52
|
+
# reason recorded on the grant at the consent moment. Review-time record
|
|
53
|
+
# only, never an enforcement input; authorization decisions ride scopes,
|
|
54
|
+
# connection status, and consent.
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
def initialize(config = nil)
|
|
@@ -161,20 +168,26 @@ module AgentAdmit
|
|
|
161
168
|
# Validate that consumed fields have the expected types when present.
|
|
162
169
|
validate_introspection_types!(data)
|
|
163
170
|
|
|
164
|
-
# Keep
|
|
165
|
-
#
|
|
166
|
-
# consent_granted
|
|
167
|
-
#
|
|
171
|
+
# Keep the consent block only when it is a Hash; anything else reads
|
|
172
|
+
# as nil. Absent and malformed are both safe: neither is ever a grant
|
|
173
|
+
# (consent_granted? fails closed, and CallerConsent resolves the
|
|
174
|
+
# authoritative verdict through the Consent Ledger).
|
|
168
175
|
consent = data["consent"]
|
|
169
176
|
consent = nil unless consent.is_a?(Hash)
|
|
170
177
|
|
|
171
178
|
# Presence rides along when the platform returns it. Same strictness
|
|
172
179
|
# as active: verified must be the boolean true or false, never coerced.
|
|
173
|
-
#
|
|
174
|
-
#
|
|
180
|
+
# A malformed block is dropped -- presence_verified? fails closed on
|
|
181
|
+
# nil, so dropping cannot fail open.
|
|
175
182
|
presence = data["presence"]
|
|
176
183
|
presence = nil unless presence.is_a?(Hash) && [true, false].include?(presence["verified"])
|
|
177
184
|
|
|
185
|
+
# Declared purpose passes through as-is when it is a String (the
|
|
186
|
+
# hosted /verify returns it nullable). It is a review-time record,
|
|
187
|
+
# never an enforcement input, so a malformed value is simply dropped.
|
|
188
|
+
purpose = data["purpose"]
|
|
189
|
+
purpose = nil unless purpose.is_a?(String)
|
|
190
|
+
|
|
178
191
|
return IntrospectionResult.new(
|
|
179
192
|
user_id: data["user_id"],
|
|
180
193
|
connection_id: data["connection_id"],
|
|
@@ -186,7 +199,8 @@ module AgentAdmit
|
|
|
186
199
|
jti: data["jti"],
|
|
187
200
|
exp: data["exp"],
|
|
188
201
|
consent: consent,
|
|
189
|
-
presence: presence
|
|
202
|
+
presence: presence,
|
|
203
|
+
purpose: purpose
|
|
190
204
|
)
|
|
191
205
|
end
|
|
192
206
|
|
|
@@ -15,6 +15,9 @@ module AgentAdmit
|
|
|
15
15
|
# instead for an until-revoked connection (explicit JSON null).
|
|
16
16
|
UNSET = Object.new.freeze
|
|
17
17
|
|
|
18
|
+
# Maximum length of a declared purpose (matches the hosted API contract).
|
|
19
|
+
PURPOSE_MAX_LENGTH = 300
|
|
20
|
+
|
|
18
21
|
def initialize(config = nil)
|
|
19
22
|
@config = config || AgentAdmit.configuration || Config.new
|
|
20
23
|
@config.validate_api_key!
|
|
@@ -34,13 +37,24 @@ module AgentAdmit
|
|
|
34
37
|
# @param scopes [Array<String>] scopes the connection grants
|
|
35
38
|
# @param role [String, nil] the user's role on the connection
|
|
36
39
|
# @param duration_seconds [Integer, nil, UNSET] see above
|
|
40
|
+
# @param purpose [String, nil] declared purpose: the user-facing reason
|
|
41
|
+
# recorded on the grant at the consent moment. Review-time record only,
|
|
42
|
+
# never an enforcement input; authorization decisions ride scopes,
|
|
43
|
+
# connection status, and consent. Max 300 characters; omitted from the
|
|
44
|
+
# request when nil.
|
|
37
45
|
# @return [Hash] the issue response — "token" is the self-describing
|
|
38
46
|
# ag_ct_… connection token to hand to the user's agent
|
|
47
|
+
# @raise [ArgumentError] if purpose exceeds 300 characters
|
|
39
48
|
# @raise [IntrospectionError] if issuance fails
|
|
40
49
|
#
|
|
41
|
-
def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET)
|
|
50
|
+
def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET, purpose: nil)
|
|
51
|
+
if purpose && purpose.length > PURPOSE_MAX_LENGTH
|
|
52
|
+
raise ArgumentError, "purpose must be at most #{PURPOSE_MAX_LENGTH} characters"
|
|
53
|
+
end
|
|
54
|
+
|
|
42
55
|
body = { "user_id" => user_id, "scopes" => scopes }
|
|
43
56
|
body["role"] = role if role
|
|
57
|
+
body["purpose"] = purpose if purpose
|
|
44
58
|
# Tri-state: the UNSET sentinel omits the key entirely; nil survives
|
|
45
59
|
# JSON.generate as explicit JSON null (no compact, no nil-guard).
|
|
46
60
|
body["duration_seconds"] = duration_seconds unless duration_seconds.equal?(UNSET)
|
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.7.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-
|
|
11
|
+
date: 2026-08-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|