agentadmit 1.2.0 → 1.3.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: 74299884ea21ad2cb70261b0d2e7025bf4bc07e330f76bfc44009af8bba3b71b
4
+ data.tar.gz: 846ec2f9b8ed3afe7e9113fa7e1c1bf4b2fd7b074ce0f61e96550347db6c185b
5
5
  SHA512:
6
- metadata.gz: 4e48106a82e8b4bfe8d89828972347b5a33afbfb60908cae5b1b3e9d92887c52f8d118d4817dc8d880dcae51a41d9cf88f3d92f53557fb4d4ae619c1186eeeb7
7
- data.tar.gz: 7f370525b8e98ff57a5849d083a2e5a6ddc8af4edcdfe229dbe7499db9188d922fc9f60bfe0d441d7b6dbe8b1320048fbf743c91a9b5de64401733b9ae4c9c3f
6
+ metadata.gz: 25964578da91a2be9763398021fad033e9538cf6270c2fe99d66185c4da6d884514bcf9c9cfd9a2f60d017e0aeecdeb62d9d6ce1ef19ebfa543eb274cf2cc688
7
+ data.tar.gz: fdba91838161e85923cca0e4f1c84d72b724ad7bcf8e1be1c248f6af5ab1c1ec2506345e27016e7c8b3a7a989c5c65c3474fa263c23466cc7b100ec06b4263fc
data/README.md CHANGED
@@ -72,6 +72,30 @@ 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
+
75
99
  ## Rate Limiting
76
100
 
77
101
  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,24 @@ 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, keyword_init: true) do
20
20
  def has_scope?(scope)
21
21
  scopes.include?(scope)
22
22
  end
23
+
24
+ # Consent Ledger verdict for the external-agent path (additive; may be
25
+ # nil). A denied verdict means the app returns its own 403 -- the token
26
+ # itself stays valid (consent is orthogonal to revocation).
27
+ #
28
+ # Contract (matches the PHP and Java SDKs): an ABSENT consent block
29
+ # means a legacy server that never sends one -- treated as allowed,
30
+ # which is also the platform default for the external-agent class. A
31
+ # PRESENT block grants only on an explicit boolean true; a missing or
32
+ # non-boolean granted value is treated as denied (malformed = deny).
33
+ def consent_granted?
34
+ return true if consent.nil?
35
+ consent["granted"] == true
36
+ end
23
37
  end
24
38
 
25
39
  def initialize(config = nil)
@@ -136,6 +150,13 @@ module AgentAdmit
136
150
  # Validate that consumed fields have the expected types when present.
137
151
  validate_introspection_types!(data)
138
152
 
153
+ # Keep any PRESENT consent hash, even with a malformed granted value:
154
+ # coercing it to nil would read as "legacy server, allowed" in
155
+ # consent_granted?, silently failing open. A malformed verdict must
156
+ # stay visible so consent_granted? can deny it.
157
+ consent = data["consent"]
158
+ consent = nil unless consent.is_a?(Hash)
159
+
139
160
  return IntrospectionResult.new(
140
161
  user_id: data["user_id"],
141
162
  connection_id: data["connection_id"],
@@ -145,7 +166,8 @@ module AgentAdmit
145
166
  role: data["role"],
146
167
  app_id: data["app_id"],
147
168
  jti: data["jti"],
148
- exp: data["exp"]
169
+ exp: data["exp"],
170
+ consent: consent
149
171
  )
150
172
  end
151
173
 
@@ -153,6 +175,55 @@ module AgentAdmit
153
175
  raise IntrospectionError, "Unexpected exit from retry loop"
154
176
  end
155
177
 
178
+ CALLER_CLASSES = %w[human_session in_app_ai external_agent].freeze
179
+
180
+ ##
181
+ # Ask the Consent Ledger whether a caller class may act on a user's data.
182
+ # Decision point for the token-less caller classes (human_session,
183
+ # in_app_ai); external agents get the same verdict on the verify result.
184
+ #
185
+ # @param app_user_id [String] your app's identifier for the data owner
186
+ # @param caller_class [String] "human_session" | "in_app_ai" | "external_agent"
187
+ # @param scope_group [String, nil] optional finer-than-class group
188
+ # @return [Hash] verdict: granted, caller_class, scope_group, source, evaluated_at
189
+ # @raise [ArgumentError] unknown caller_class
190
+ # @raise [IntrospectionError] hosted service unreachable or rejected the call
191
+ #
192
+ def check_consent(app_user_id:, caller_class:, scope_group: nil)
193
+ unless CALLER_CLASSES.include?(caller_class)
194
+ raise ArgumentError, "caller_class must be one of #{CALLER_CLASSES.join(', ')}"
195
+ end
196
+
197
+ uri = URI.parse("#{@config.api_url.sub(%r{/\z}, '')}/api/v1/consent/check")
198
+ http = build_http(uri)
199
+
200
+ request = Net::HTTP::Post.new(uri.path)
201
+ request["Authorization"] = "Bearer #{@config.api_key}"
202
+ request["Content-Type"] = "application/json"
203
+ body = { app_user_id: app_user_id, caller_class: caller_class }
204
+ body[:scope_group] = scope_group if scope_group
205
+ request.body = JSON.generate(body)
206
+
207
+ response = begin
208
+ http.request(request)
209
+ rescue StandardError => e
210
+ raise IntrospectionError, "Consent check failed: #{e.message}"
211
+ end
212
+
213
+ unless (200..299).cover?(response.code.to_i)
214
+ data = JSON.parse(response.body) rescue {}
215
+ raise IntrospectionError,
216
+ data["error_description"] || data["error"] || "Consent check returned #{response.code}"
217
+ end
218
+
219
+ # 2xx -- parse strictly; a garbage body must not read as an empty verdict.
220
+ begin
221
+ JSON.parse(response.body)
222
+ rescue JSON::ParserError
223
+ raise IntrospectionError, "Consent check response is not valid JSON"
224
+ end
225
+ end
226
+
156
227
  private
157
228
 
158
229
  ##
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.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.3.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-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json