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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67f416bd1136c1ff49766c515270c9c8ef4102a2474960923af9648d49406e64
4
- data.tar.gz: 2b86e062d60f5c3c762f0845ba1a13b8c60e394ad9b7db1b0fd296d804ac7087
3
+ metadata.gz: bc3085f5469fd5380afb22df98cf1517e1f74df3a23cf7ab07d7f6c490cd9feb
4
+ data.tar.gz: fb0142a79357486644fff3e68903194b998cf5b41c1f140a8bda8f7a2f0c2909
5
5
  SHA512:
6
- metadata.gz: 4e48106a82e8b4bfe8d89828972347b5a33afbfb60908cae5b1b3e9d92887c52f8d118d4817dc8d880dcae51a41d9cf88f3d92f53557fb4d4ae619c1186eeeb7
7
- data.tar.gz: 7f370525b8e98ff57a5849d083a2e5a6ddc8af4edcdfe229dbe7499db9188d922fc9f60bfe0d441d7b6dbe8b1320048fbf743c91a9b5de64401733b9ae4c9c3f
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, 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
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.2.0"
4
+ VERSION = "1.4.0"
5
5
  end
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.2.0
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-03 00:00:00.000000000 Z
11
+ date: 2026-07-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json