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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74299884ea21ad2cb70261b0d2e7025bf4bc07e330f76bfc44009af8bba3b71b
4
- data.tar.gz: 846ec2f9b8ed3afe7e9113fa7e1c1bf4b2fd7b074ce0f61e96550347db6c185b
3
+ metadata.gz: 885a1ed3458153db1420a5b58acf902aafecdea120ef3e51ce238c25e635562a
4
+ data.tar.gz: d0477df5f1302c204fb751a28343a8986ed964e18c814f5b72abfb274037a1e3
5
5
  SHA512:
6
- metadata.gz: 25964578da91a2be9763398021fad033e9538cf6270c2fe99d66185c4da6d884514bcf9c9cfd9a2f60d017e0aeecdeb62d9d6ce1ef19ebfa543eb274cf2cc688
7
- data.tar.gz: fdba91838161e85923cca0e4f1c84d72b724ad7bcf8e1be1c248f6af5ab1c1ec2506345e27016e7c8b3a7a989c5c65c3474fa263c23466cc7b100ec06b4263fc
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, keyword_init: true) do
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.3.0"
4
+ VERSION = "1.5.0"
5
5
  end
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.3.0
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-04 00:00:00.000000000 Z
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