agentadmit 1.4.0 → 1.5.1
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 +28 -3
- data/lib/agentadmit/caller_consent.rb +215 -0
- data/lib/agentadmit/introspection_client.rb +18 -16
- data/lib/agentadmit/version.rb +1 -1
- data/lib/agentadmit.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 810b958cd0fd3558a19bfc79910e194e45e02d95331b98747ff4edf29e337d8a
|
|
4
|
+
data.tar.gz: 4c18ad2b306eea918107543eeee24c3f2eb7f30010bfb3c1bb6502bcfa94718b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a0a65b2ce47a30683c14de7c397bea637adbe18ed76db7b8b579c5835d5dcdb90881ef93e20c687710cc37184ec3fc796a9fd153e1f6bf19edcb8f1c2875463
|
|
7
|
+
data.tar.gz: b81a5431a0a2b37728e675963ab79e188e5d0e288f4bb2a4e8497fe5df81ba7a962fc15d99b36fd21d72fa6472de266bb2dacef92aab79f46c04b615bcd31e58
|
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
|
|
@@ -96,6 +105,22 @@ head :forbidden unless verdict["granted"]
|
|
|
96
105
|
|
|
97
106
|
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
107
|
|
|
108
|
+
**One-middleware drop-in.** Instead of wiring the three paths by hand, `AgentAdmit::CallerConsent` classifies the caller from the credential and evaluates the right independent path:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
use AgentAdmit::CallerConsent,
|
|
112
|
+
# derive the class from your own credential structure, never caller input
|
|
113
|
+
classify_non_agent: ->(env) {
|
|
114
|
+
env["HTTP_X_INTERNAL_AI"] == ENV["INTERNAL_AI_SECRET"] ? "in_app_ai" : "human_session"
|
|
115
|
+
},
|
|
116
|
+
resolve_data_owner_id: ->(env) { Rack::Request.new(env).params["owner_id"] },
|
|
117
|
+
required_scope: "read:records"
|
|
118
|
+
# Downstream: env["agentadmit.caller_class"], env["agentadmit.consent"], and the
|
|
119
|
+
# standard agent env variables on the external-agent path.
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
External agents are checked via hosted introspection (consent verdict plus scope); in-app AI via the Consent Ledger (fail closed); the human path defers to your own permission model unless `gate_human: true`. It is a consent gate, not an authenticator, so mount it after your own authentication.
|
|
123
|
+
|
|
99
124
|
### Presence verification
|
|
100
125
|
|
|
101
126
|
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:
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentAdmit
|
|
4
|
+
##
|
|
5
|
+
# Caller-Identity Consent middleware: the "classify caller, then gate the
|
|
6
|
+
# right independent path" recipe as one Rack middleware, so an app owner
|
|
7
|
+
# does not have to hand-roll it.
|
|
8
|
+
#
|
|
9
|
+
# One endpoint serves every caller class. On each request the middleware:
|
|
10
|
+
#
|
|
11
|
+
# 1. classifies the caller from the STRUCTURE of the credential (a class
|
|
12
|
+
# the caller cannot self-select), before any consent check;
|
|
13
|
+
# 2. routes to that class's ISOLATED consent path; no path reads or
|
|
14
|
+
# inherits another class's preference;
|
|
15
|
+
# 3. permits or denies, and sets env variables
|
|
16
|
+
# (agentadmit.caller_class, agentadmit.consent, plus the standard agent
|
|
17
|
+
# env variables on the external-agent path).
|
|
18
|
+
#
|
|
19
|
+
# - external_agent: an ag_at_ access token -> hosted introspection, which
|
|
20
|
+
# returns the external-agent consent verdict inline plus the granted
|
|
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.
|
|
24
|
+
# - in_app_ai: your application's own server-side AI code path -> the
|
|
25
|
+
# Consent Ledger /consent/check for the in-app-AI class.
|
|
26
|
+
# - human_session: your application's own permission model (sharing,
|
|
27
|
+
# roles, grants). Deferred to your existing authorization by default;
|
|
28
|
+
# opt in to a stored human-session switch with gate_human: true.
|
|
29
|
+
#
|
|
30
|
+
# The three decisions are independent: granting one never grants another.
|
|
31
|
+
#
|
|
32
|
+
# SECURITY: this is a consent gate, not an authenticator. It classifies the
|
|
33
|
+
# caller and enforces the per-class CONSENT decision; it does not by itself
|
|
34
|
+
# authenticate a human session. Mount it AFTER your own authentication. On
|
|
35
|
+
# the human_session path it defers to your application's permission model
|
|
36
|
+
# and calls the app without re-authenticating. The external_agent path is
|
|
37
|
+
# always authenticated (hosted introspection); the in_app_ai path always
|
|
38
|
+
# evaluates the ledger.
|
|
39
|
+
#
|
|
40
|
+
# Usage:
|
|
41
|
+
# use AgentAdmit::CallerConsent,
|
|
42
|
+
# # derive the class from your own credential structure, never caller input
|
|
43
|
+
# classify_non_agent: ->(env) {
|
|
44
|
+
# env["HTTP_X_INTERNAL_AI"] == ENV["INTERNAL_AI_SECRET"] ? "in_app_ai" : "human_session"
|
|
45
|
+
# },
|
|
46
|
+
# resolve_data_owner_id: ->(env) { Rack::Request.new(env).params["owner_id"] },
|
|
47
|
+
# required_scope: "read:records"
|
|
48
|
+
#
|
|
49
|
+
class CallerConsent
|
|
50
|
+
# RFC 7235: the auth-scheme token is case-insensitive.
|
|
51
|
+
BEARER_AGENT_RE = /\Abearer ag_at_/i
|
|
52
|
+
|
|
53
|
+
HUMAN_SESSION = "human_session"
|
|
54
|
+
IN_APP_AI = "in_app_ai"
|
|
55
|
+
EXTERNAL_AGENT = "external_agent"
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# @param app [#call] the downstream Rack app
|
|
59
|
+
# @param resolve_data_owner_id [Proc, nil] env -> your app's identifier
|
|
60
|
+
# for the data owner whose resource is accessed. Required for the
|
|
61
|
+
# in_app_ai path, and for human_session when gate_human is set. The
|
|
62
|
+
# external-agent owner comes from the token, so it is not used there.
|
|
63
|
+
# @param classify_non_agent [Proc, nil] env -> "in_app_ai" or
|
|
64
|
+
# "human_session", derived from the STRUCTURE of the credential (for
|
|
65
|
+
# example an internal service token), never a value the caller can set.
|
|
66
|
+
# Defaults to treating non-agent callers as human sessions.
|
|
67
|
+
# @param required_scope [String, nil] for the external_agent path,
|
|
68
|
+
# require this scope (403 insufficient_scope if not granted).
|
|
69
|
+
# @param scope_group [String, nil] optional finer-than-class consent
|
|
70
|
+
# group for ledger checks.
|
|
71
|
+
# @param gate_human [Boolean] also gate the human_session class against a
|
|
72
|
+
# stored switch. Off by default: the human path belongs to your own
|
|
73
|
+
# permission model.
|
|
74
|
+
#
|
|
75
|
+
def initialize(app, resolve_data_owner_id: nil, classify_non_agent: nil,
|
|
76
|
+
required_scope: nil, scope_group: nil, gate_human: false)
|
|
77
|
+
@app = app
|
|
78
|
+
@client = IntrospectionClient.new
|
|
79
|
+
@resolve_data_owner_id = resolve_data_owner_id
|
|
80
|
+
@classify_non_agent = classify_non_agent
|
|
81
|
+
@required_scope = required_scope
|
|
82
|
+
@scope_group = scope_group
|
|
83
|
+
@gate_human = gate_human
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Classify the caller from credential structure, before any consent
|
|
88
|
+
# check. An ag_at_ bearer token is an external agent; anything else is
|
|
89
|
+
# resolved by classify_non_agent (default: human_session). The class is
|
|
90
|
+
# derived, never self-selected by the caller.
|
|
91
|
+
#
|
|
92
|
+
def classify_caller(env)
|
|
93
|
+
auth = env["HTTP_AUTHORIZATION"] || ""
|
|
94
|
+
return EXTERNAL_AGENT if BEARER_AGENT_RE.match?(auth)
|
|
95
|
+
return IN_APP_AI if @classify_non_agent && @classify_non_agent.call(env) == IN_APP_AI
|
|
96
|
+
|
|
97
|
+
HUMAN_SESSION
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def call(env)
|
|
101
|
+
caller_class = classify_caller(env)
|
|
102
|
+
env["agentadmit.caller_class"] = caller_class
|
|
103
|
+
|
|
104
|
+
case caller_class
|
|
105
|
+
when EXTERNAL_AGENT
|
|
106
|
+
call_external_agent(env)
|
|
107
|
+
when IN_APP_AI
|
|
108
|
+
call_ledger_gated(env, IN_APP_AI, "in_app_ai",
|
|
109
|
+
"The data owner has not enabled in-app AI analysis.")
|
|
110
|
+
else
|
|
111
|
+
if @gate_human
|
|
112
|
+
call_ledger_gated(env, HUMAN_SESSION, "user",
|
|
113
|
+
"The data owner has not enabled this access.")
|
|
114
|
+
else
|
|
115
|
+
# Defer the human path to the app's existing authorization.
|
|
116
|
+
env["agentadmit.auth_type"] = "user"
|
|
117
|
+
@app.call(env)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
##
|
|
125
|
+
# External-agent path: hosted introspection carries the verdict and the
|
|
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.
|
|
132
|
+
#
|
|
133
|
+
def call_external_agent(env)
|
|
134
|
+
token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
result = @client.verify(token)
|
|
138
|
+
rescue InvalidTokenError => e
|
|
139
|
+
return json_error(401, "invalid_token", e.message)
|
|
140
|
+
rescue IntrospectionError => e
|
|
141
|
+
return json_error(502, "introspection_failed", e.message)
|
|
142
|
+
end
|
|
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
|
+
|
|
165
|
+
if @required_scope && !(result.scopes || []).include?(@required_scope)
|
|
166
|
+
return [403, { "Content-Type" => "application/json" },
|
|
167
|
+
[{ error: "insufficient_scope",
|
|
168
|
+
required_scope: @required_scope,
|
|
169
|
+
granted_scopes: result.scopes || [],
|
|
170
|
+
message: "This action requires '#{@required_scope}' scope." }.to_json]]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
env["agentadmit.auth_type"] = "agent"
|
|
174
|
+
env["agentadmit.user_id"] = result.user_id
|
|
175
|
+
env["agentadmit.scopes"] = result.scopes
|
|
176
|
+
env["agentadmit.connection_id"] = result.connection_id
|
|
177
|
+
env["agentadmit.agent_label"] = result.agent_label
|
|
178
|
+
env["agentadmit.presence"] = result.presence
|
|
179
|
+
env["agentadmit.consent"] = consent
|
|
180
|
+
|
|
181
|
+
@app.call(env)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
##
|
|
185
|
+
# Token-less caller class (in_app_ai, or human_session under gate_human),
|
|
186
|
+
# gated on the Consent Ledger. Fail closed: an unreachable or erroring
|
|
187
|
+
# ledger denies, never allows.
|
|
188
|
+
#
|
|
189
|
+
def call_ledger_gated(env, caller_class, auth_type, denied_message)
|
|
190
|
+
owner = @resolve_data_owner_id&.call(env)
|
|
191
|
+
if owner.nil? || owner.empty?
|
|
192
|
+
return json_error(500, "server_error",
|
|
193
|
+
"resolve_data_owner_id is required for this caller class")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
begin
|
|
197
|
+
verdict = @client.check_consent(app_user_id: owner, caller_class: caller_class,
|
|
198
|
+
scope_group: @scope_group)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
return json_error(503, "consent_unavailable", "Consent check failed")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
return json_error(403, "consent_not_granted", denied_message) unless verdict["granted"] == true
|
|
204
|
+
|
|
205
|
+
env["agentadmit.auth_type"] = auth_type
|
|
206
|
+
env["agentadmit.consent"] = verdict
|
|
207
|
+
@app.call(env)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def json_error(status, error, description)
|
|
211
|
+
[status, { "Content-Type" => "application/json" },
|
|
212
|
+
[{ error: error, error_description: description }.to_json]]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -26,22 +26,24 @@ 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
|
|
@@ -161,17 +163,17 @@ module AgentAdmit
|
|
|
161
163
|
# Validate that consumed fields have the expected types when present.
|
|
162
164
|
validate_introspection_types!(data)
|
|
163
165
|
|
|
164
|
-
# Keep
|
|
165
|
-
#
|
|
166
|
-
# consent_granted
|
|
167
|
-
#
|
|
166
|
+
# Keep the consent block only when it is a Hash; anything else reads
|
|
167
|
+
# as nil. Absent and malformed are both safe: neither is ever a grant
|
|
168
|
+
# (consent_granted? fails closed, and CallerConsent resolves the
|
|
169
|
+
# authoritative verdict through the Consent Ledger).
|
|
168
170
|
consent = data["consent"]
|
|
169
171
|
consent = nil unless consent.is_a?(Hash)
|
|
170
172
|
|
|
171
173
|
# Presence rides along when the platform returns it. Same strictness
|
|
172
174
|
# as active: verified must be the boolean true or false, never coerced.
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
+
# A malformed block is dropped -- presence_verified? fails closed on
|
|
176
|
+
# nil, so dropping cannot fail open.
|
|
175
177
|
presence = data["presence"]
|
|
176
178
|
presence = nil unless presence.is_a?(Hash) && [true, false].include?(presence["verified"])
|
|
177
179
|
|
data/lib/agentadmit/version.rb
CHANGED
data/lib/agentadmit.rb
CHANGED
|
@@ -92,6 +92,7 @@ require_relative "agentadmit/tokens_client"
|
|
|
92
92
|
require_relative "agentadmit/alerts_client"
|
|
93
93
|
require_relative "agentadmit/webhook"
|
|
94
94
|
require_relative "agentadmit/middleware"
|
|
95
|
+
require_relative "agentadmit/caller_consent"
|
|
95
96
|
# ScopeEnforcement is an ActiveSupport::Concern — only loadable inside Rails.
|
|
96
97
|
require_relative "agentadmit/scope_enforcement" if defined?(ActiveSupport)
|
|
97
98
|
require_relative "agentadmit/railtie" if defined?(Rails)
|
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.5.1
|
|
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-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|
|
@@ -35,6 +35,7 @@ files:
|
|
|
35
35
|
- README.md
|
|
36
36
|
- lib/agentadmit.rb
|
|
37
37
|
- lib/agentadmit/alerts_client.rb
|
|
38
|
+
- lib/agentadmit/caller_consent.rb
|
|
38
39
|
- lib/agentadmit/config.rb
|
|
39
40
|
- lib/agentadmit/introspection_client.rb
|
|
40
41
|
- lib/agentadmit/middleware.rb
|