agentadmit 1.3.0 → 1.5.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 +37 -0
- data/lib/agentadmit/caller_consent.rb +191 -0
- data/lib/agentadmit/introspection_client.rb +21 -2
- data/lib/agentadmit/middleware.rb +2 -0
- data/lib/agentadmit/scope_enforcement.rb +23 -0
- 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: 885a1ed3458153db1420a5b58acf902aafecdea120ef3e51ce238c25e635562a
|
|
4
|
+
data.tar.gz: d0477df5f1302c204fb751a28343a8986ed964e18c814f5b72abfb274037a1e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ad67e5e7b68872e5f9004e0da11b22d5ce7337f56725338fa9678e209e8689706f135fd0b4cd5eb042760944826757bcfc372f9dbbd223eb94a853e6692cbd75
|
|
7
|
+
data.tar.gz: 27780e1514e4296eba0aa2e345b888de8cbc74e44326ebbec4c3e0abd694c0026862387987b9264774bee8edffa125b44faf19b12e43d85cb36816bac7322c18
|
data/README.md
CHANGED
|
@@ -96,6 +96,43 @@ head :forbidden unless verdict["granted"]
|
|
|
96
96
|
|
|
97
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
98
|
|
|
99
|
+
**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:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
use AgentAdmit::CallerConsent,
|
|
103
|
+
# derive the class from your own credential structure, never caller input
|
|
104
|
+
classify_non_agent: ->(env) {
|
|
105
|
+
env["HTTP_X_INTERNAL_AI"] == ENV["INTERNAL_AI_SECRET"] ? "in_app_ai" : "human_session"
|
|
106
|
+
},
|
|
107
|
+
resolve_data_owner_id: ->(env) { Rack::Request.new(env).params["owner_id"] },
|
|
108
|
+
required_scope: "read:records"
|
|
109
|
+
# Downstream: env["agentadmit.caller_class"], env["agentadmit.consent"], and the
|
|
110
|
+
# standard agent env variables on the external-agent path.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
### Presence verification
|
|
116
|
+
|
|
117
|
+
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:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
result = AgentAdmit::IntrospectionClient.new.verify(token)
|
|
121
|
+
result.presence_verified? # true only when the ceremony completed
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
For sensitive actions, enforce it in your controllers the same way you enforce scopes:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class TransfersController < ApplicationController
|
|
128
|
+
include AgentAdmit::ScopeEnforcement
|
|
129
|
+
|
|
130
|
+
before_action -> { require_presence! }, only: [:create]
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`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.
|
|
135
|
+
|
|
99
136
|
## Rate Limiting
|
|
100
137
|
|
|
101
138
|
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.
|
|
@@ -0,0 +1,191 @@
|
|
|
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. Enforced here directly.
|
|
22
|
+
# - in_app_ai: your application's own server-side AI code path -> the
|
|
23
|
+
# Consent Ledger /consent/check for the in-app-AI class.
|
|
24
|
+
# - human_session: your application's own permission model (sharing,
|
|
25
|
+
# roles, grants). Deferred to your existing authorization by default;
|
|
26
|
+
# opt in to a stored human-session switch with gate_human: true.
|
|
27
|
+
#
|
|
28
|
+
# The three decisions are independent: granting one never grants another.
|
|
29
|
+
#
|
|
30
|
+
# SECURITY: this is a consent gate, not an authenticator. It classifies the
|
|
31
|
+
# caller and enforces the per-class CONSENT decision; it does not by itself
|
|
32
|
+
# authenticate a human session. Mount it AFTER your own authentication. On
|
|
33
|
+
# the human_session path it defers to your application's permission model
|
|
34
|
+
# and calls the app without re-authenticating. The external_agent path is
|
|
35
|
+
# always authenticated (hosted introspection); the in_app_ai path always
|
|
36
|
+
# evaluates the ledger.
|
|
37
|
+
#
|
|
38
|
+
# Usage:
|
|
39
|
+
# use AgentAdmit::CallerConsent,
|
|
40
|
+
# # derive the class from your own credential structure, never caller input
|
|
41
|
+
# classify_non_agent: ->(env) {
|
|
42
|
+
# env["HTTP_X_INTERNAL_AI"] == ENV["INTERNAL_AI_SECRET"] ? "in_app_ai" : "human_session"
|
|
43
|
+
# },
|
|
44
|
+
# resolve_data_owner_id: ->(env) { Rack::Request.new(env).params["owner_id"] },
|
|
45
|
+
# required_scope: "read:records"
|
|
46
|
+
#
|
|
47
|
+
class CallerConsent
|
|
48
|
+
# RFC 7235: the auth-scheme token is case-insensitive.
|
|
49
|
+
BEARER_AGENT_RE = /\Abearer ag_at_/i
|
|
50
|
+
|
|
51
|
+
HUMAN_SESSION = "human_session"
|
|
52
|
+
IN_APP_AI = "in_app_ai"
|
|
53
|
+
EXTERNAL_AGENT = "external_agent"
|
|
54
|
+
|
|
55
|
+
##
|
|
56
|
+
# @param app [#call] the downstream Rack app
|
|
57
|
+
# @param resolve_data_owner_id [Proc, nil] env -> your app's identifier
|
|
58
|
+
# for the data owner whose resource is accessed. Required for the
|
|
59
|
+
# in_app_ai path, and for human_session when gate_human is set. The
|
|
60
|
+
# external-agent owner comes from the token, so it is not used there.
|
|
61
|
+
# @param classify_non_agent [Proc, nil] env -> "in_app_ai" or
|
|
62
|
+
# "human_session", derived from the STRUCTURE of the credential (for
|
|
63
|
+
# example an internal service token), never a value the caller can set.
|
|
64
|
+
# Defaults to treating non-agent callers as human sessions.
|
|
65
|
+
# @param required_scope [String, nil] for the external_agent path,
|
|
66
|
+
# require this scope (403 insufficient_scope if not granted).
|
|
67
|
+
# @param scope_group [String, nil] optional finer-than-class consent
|
|
68
|
+
# group for ledger checks.
|
|
69
|
+
# @param gate_human [Boolean] also gate the human_session class against a
|
|
70
|
+
# stored switch. Off by default: the human path belongs to your own
|
|
71
|
+
# permission model.
|
|
72
|
+
#
|
|
73
|
+
def initialize(app, resolve_data_owner_id: nil, classify_non_agent: nil,
|
|
74
|
+
required_scope: nil, scope_group: nil, gate_human: false)
|
|
75
|
+
@app = app
|
|
76
|
+
@client = IntrospectionClient.new
|
|
77
|
+
@resolve_data_owner_id = resolve_data_owner_id
|
|
78
|
+
@classify_non_agent = classify_non_agent
|
|
79
|
+
@required_scope = required_scope
|
|
80
|
+
@scope_group = scope_group
|
|
81
|
+
@gate_human = gate_human
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# Classify the caller from credential structure, before any consent
|
|
86
|
+
# check. An ag_at_ bearer token is an external agent; anything else is
|
|
87
|
+
# resolved by classify_non_agent (default: human_session). The class is
|
|
88
|
+
# derived, never self-selected by the caller.
|
|
89
|
+
#
|
|
90
|
+
def classify_caller(env)
|
|
91
|
+
auth = env["HTTP_AUTHORIZATION"] || ""
|
|
92
|
+
return EXTERNAL_AGENT if BEARER_AGENT_RE.match?(auth)
|
|
93
|
+
return IN_APP_AI if @classify_non_agent && @classify_non_agent.call(env) == IN_APP_AI
|
|
94
|
+
|
|
95
|
+
HUMAN_SESSION
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def call(env)
|
|
99
|
+
caller_class = classify_caller(env)
|
|
100
|
+
env["agentadmit.caller_class"] = caller_class
|
|
101
|
+
|
|
102
|
+
case caller_class
|
|
103
|
+
when EXTERNAL_AGENT
|
|
104
|
+
call_external_agent(env)
|
|
105
|
+
when IN_APP_AI
|
|
106
|
+
call_ledger_gated(env, IN_APP_AI, "in_app_ai",
|
|
107
|
+
"The data owner has not enabled in-app AI analysis.")
|
|
108
|
+
else
|
|
109
|
+
if @gate_human
|
|
110
|
+
call_ledger_gated(env, HUMAN_SESSION, "user",
|
|
111
|
+
"The data owner has not enabled this access.")
|
|
112
|
+
else
|
|
113
|
+
# Defer the human path to the app's existing authorization.
|
|
114
|
+
env["agentadmit.auth_type"] = "user"
|
|
115
|
+
@app.call(env)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
##
|
|
123
|
+
# External-agent path: hosted introspection carries the verdict and the
|
|
124
|
+
# scopes. A present-and-denied verdict fails closed; an absent verdict
|
|
125
|
+
# means the platform default (external-agent allowed) held.
|
|
126
|
+
#
|
|
127
|
+
def call_external_agent(env)
|
|
128
|
+
token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
result = @client.verify(token)
|
|
132
|
+
rescue InvalidTokenError => e
|
|
133
|
+
return json_error(401, "invalid_token", e.message)
|
|
134
|
+
rescue IntrospectionError => e
|
|
135
|
+
return json_error(502, "introspection_failed", e.message)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if @required_scope && !(result.scopes || []).include?(@required_scope)
|
|
139
|
+
return [403, { "Content-Type" => "application/json" },
|
|
140
|
+
[{ error: "insufficient_scope",
|
|
141
|
+
required_scope: @required_scope,
|
|
142
|
+
granted_scopes: result.scopes || [],
|
|
143
|
+
message: "This action requires '#{@required_scope}' scope." }.to_json]]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
return json_error(403, "consent_not_granted",
|
|
147
|
+
"The data owner has not enabled external agent access.") unless result.consent_granted?
|
|
148
|
+
|
|
149
|
+
env["agentadmit.auth_type"] = "agent"
|
|
150
|
+
env["agentadmit.user_id"] = result.user_id
|
|
151
|
+
env["agentadmit.scopes"] = result.scopes
|
|
152
|
+
env["agentadmit.connection_id"] = result.connection_id
|
|
153
|
+
env["agentadmit.agent_label"] = result.agent_label
|
|
154
|
+
env["agentadmit.presence"] = result.presence
|
|
155
|
+
env["agentadmit.consent"] = result.consent if result.consent
|
|
156
|
+
|
|
157
|
+
@app.call(env)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
##
|
|
161
|
+
# Token-less caller class (in_app_ai, or human_session under gate_human),
|
|
162
|
+
# gated on the Consent Ledger. Fail closed: an unreachable or erroring
|
|
163
|
+
# ledger denies, never allows.
|
|
164
|
+
#
|
|
165
|
+
def call_ledger_gated(env, caller_class, auth_type, denied_message)
|
|
166
|
+
owner = @resolve_data_owner_id&.call(env)
|
|
167
|
+
if owner.nil? || owner.empty?
|
|
168
|
+
return json_error(500, "server_error",
|
|
169
|
+
"resolve_data_owner_id is required for this caller class")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
begin
|
|
173
|
+
verdict = @client.check_consent(app_user_id: owner, caller_class: caller_class,
|
|
174
|
+
scope_group: @scope_group)
|
|
175
|
+
rescue StandardError
|
|
176
|
+
return json_error(503, "consent_unavailable", "Consent check failed")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
return json_error(403, "consent_not_granted", denied_message) unless verdict["granted"] == true
|
|
180
|
+
|
|
181
|
+
env["agentadmit.auth_type"] = auth_type
|
|
182
|
+
env["agentadmit.consent"] = verdict
|
|
183
|
+
@app.call(env)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def json_error(status, error, description)
|
|
187
|
+
[status, { "Content-Type" => "application/json" },
|
|
188
|
+
[{ error: error, error_description: description }.to_json]]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -16,7 +16,8 @@ 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, :consent,
|
|
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
|
|
@@ -34,6 +35,16 @@ module AgentAdmit
|
|
|
34
35
|
return true if consent.nil?
|
|
35
36
|
consent["granted"] == true
|
|
36
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
|
|
37
48
|
end
|
|
38
49
|
|
|
39
50
|
def initialize(config = nil)
|
|
@@ -157,6 +168,13 @@ module AgentAdmit
|
|
|
157
168
|
consent = data["consent"]
|
|
158
169
|
consent = nil unless consent.is_a?(Hash)
|
|
159
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
|
+
|
|
160
178
|
return IntrospectionResult.new(
|
|
161
179
|
user_id: data["user_id"],
|
|
162
180
|
connection_id: data["connection_id"],
|
|
@@ -167,7 +185,8 @@ module AgentAdmit
|
|
|
167
185
|
app_id: data["app_id"],
|
|
168
186
|
jti: data["jti"],
|
|
169
187
|
exp: data["exp"],
|
|
170
|
-
consent: consent
|
|
188
|
+
consent: consent,
|
|
189
|
+
presence: presence
|
|
171
190
|
)
|
|
172
191
|
end
|
|
173
192
|
|
|
@@ -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
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.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-14 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
|