agentadmit 1.5.0 → 1.7.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: 885a1ed3458153db1420a5b58acf902aafecdea120ef3e51ce238c25e635562a
4
- data.tar.gz: d0477df5f1302c204fb751a28343a8986ed964e18c814f5b72abfb274037a1e3
3
+ metadata.gz: 53205cacbbf7942a6cb2600c9cea4c0c739d991be95bd7c627df405287e5f52c
4
+ data.tar.gz: b9849673334811698875c9733abc1b69c2938111cf4b928cde741cb0e6ba6d0f
5
5
  SHA512:
6
- metadata.gz: ad67e5e7b68872e5f9004e0da11b22d5ce7337f56725338fa9678e209e8689706f135fd0b4cd5eb042760944826757bcfc372f9dbbd223eb94a853e6692cbd75
7
- data.tar.gz: 27780e1514e4296eba0aa2e345b888de8cbc74e44326ebbec4c3e0abd694c0026862387987b9264774bee8edffa125b44faf19b12e43d85cb36816bac7322c18
6
+ metadata.gz: ed8c4e40cc18a7749257674d2ba0fba6d8fb79946aeb868cf84fa5f5fa67075eff3cc80456e67e34886157f63cbaaceb1156abc3ce92f87a3400096920354f69
7
+ data.tar.gz: e184a592c70ac59b82f906f00a7cae3c83ecd3b9681e5a35421bad55c3979a11bbfbfe45f85f8135fe4a909ed1f1c394108b03d86b199f91bf999ac8cf40e1bc
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
- result = AgentAdmit::IntrospectionClient.new.verify(token)
83
- unless result.consent_granted?
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
@@ -293,3 +302,26 @@ granted = tokens.exchange(connection_token, agent_label: "MyAssistant")
293
302
  # Revoke when the user disconnects the agent.
294
303
  tokens.revoke(granted["connection_id"], reason: "user_requested")
295
304
  ```
305
+
306
+ ### Declared purpose
307
+
308
+ Declared purpose: the user-facing reason recorded on the grant at the consent moment. Review-time record only, never an enforcement input; authorization decisions ride scopes, connection status, and consent.
309
+
310
+ Pass it when issuing a connection token (optional, max 300 characters; omitted from the request when `nil`):
311
+
312
+ ```ruby
313
+ issued = tokens.issue_token(
314
+ user_id: "user_42",
315
+ scopes: ["read:orders"],
316
+ purpose: "Book quarterly travel for the sales team"
317
+ )
318
+ ```
319
+
320
+ The verify result carries it back for display in dashboards, review screens, and audit views:
321
+
322
+ ```ruby
323
+ result = AgentAdmit::IntrospectionClient.new.verify(token)
324
+ result.purpose # => "Book quarterly travel for the sales team" or nil
325
+ ```
326
+
327
+ `purpose` is nullable -- connections issued without one (or by servers that predate the field) read as `nil`. Do not branch authorization on it; keep enforcement on scopes, connection status, and consent.
@@ -18,7 +18,9 @@ module AgentAdmit
18
18
  #
19
19
  # - external_agent: an ag_at_ access token -> hosted introspection, which
20
20
  # returns the external-agent consent verdict inline plus the granted
21
- # scopes. Enforced here directly.
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.
22
24
  # - in_app_ai: your application's own server-side AI code path -> the
23
25
  # Consent Ledger /consent/check for the in-app-AI class.
24
26
  # - human_session: your application's own permission model (sharing,
@@ -121,8 +123,12 @@ module AgentAdmit
121
123
 
122
124
  ##
123
125
  # 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
+ # 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.
126
132
  #
127
133
  def call_external_agent(env)
128
134
  token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
@@ -135,6 +141,27 @@ module AgentAdmit
135
141
  return json_error(502, "introspection_failed", e.message)
136
142
  end
137
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
+
138
165
  if @required_scope && !(result.scopes || []).include?(@required_scope)
139
166
  return [403, { "Content-Type" => "application/json" },
140
167
  [{ error: "insufficient_scope",
@@ -143,16 +170,13 @@ module AgentAdmit
143
170
  message: "This action requires '#{@required_scope}' scope." }.to_json]]
144
171
  end
145
172
 
146
- return json_error(403, "consent_not_granted",
147
- "The data owner has not enabled external agent access.") unless result.consent_granted?
148
-
149
173
  env["agentadmit.auth_type"] = "agent"
150
174
  env["agentadmit.user_id"] = result.user_id
151
175
  env["agentadmit.scopes"] = result.scopes
152
176
  env["agentadmit.connection_id"] = result.connection_id
153
177
  env["agentadmit.agent_label"] = result.agent_label
154
178
  env["agentadmit.presence"] = result.presence
155
- env["agentadmit.consent"] = result.consent if result.consent
179
+ env["agentadmit.consent"] = consent
156
180
 
157
181
  @app.call(env)
158
182
  end
@@ -17,7 +17,7 @@ module AgentAdmit
17
17
 
18
18
  IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
19
19
  :sub, :role, :app_id, :jti, :exp, :consent,
20
- :presence, keyword_init: true) do
20
+ :presence, :purpose, keyword_init: true) do
21
21
  def has_scope?(scope)
22
22
  scopes.include?(scope)
23
23
  end
@@ -26,25 +26,32 @@ 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 (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).
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
- return true if consent.nil?
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. 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.
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
50
+
51
+ # `purpose` (String or nil) is the declared purpose: the user-facing
52
+ # reason recorded on the grant at the consent moment. Review-time record
53
+ # only, never an enforcement input; authorization decisions ride scopes,
54
+ # connection status, and consent.
48
55
  end
49
56
 
50
57
  def initialize(config = nil)
@@ -161,20 +168,26 @@ module AgentAdmit
161
168
  # Validate that consumed fields have the expected types when present.
162
169
  validate_introspection_types!(data)
163
170
 
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.
171
+ # Keep the consent block only when it is a Hash; anything else reads
172
+ # as nil. Absent and malformed are both safe: neither is ever a grant
173
+ # (consent_granted? fails closed, and CallerConsent resolves the
174
+ # authoritative verdict through the Consent Ledger).
168
175
  consent = data["consent"]
169
176
  consent = nil unless consent.is_a?(Hash)
170
177
 
171
178
  # Presence rides along when the platform returns it. Same strictness
172
179
  # 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.
180
+ # A malformed block is dropped -- presence_verified? fails closed on
181
+ # nil, so dropping cannot fail open.
175
182
  presence = data["presence"]
176
183
  presence = nil unless presence.is_a?(Hash) && [true, false].include?(presence["verified"])
177
184
 
185
+ # Declared purpose passes through as-is when it is a String (the
186
+ # hosted /verify returns it nullable). It is a review-time record,
187
+ # never an enforcement input, so a malformed value is simply dropped.
188
+ purpose = data["purpose"]
189
+ purpose = nil unless purpose.is_a?(String)
190
+
178
191
  return IntrospectionResult.new(
179
192
  user_id: data["user_id"],
180
193
  connection_id: data["connection_id"],
@@ -186,7 +199,8 @@ module AgentAdmit
186
199
  jti: data["jti"],
187
200
  exp: data["exp"],
188
201
  consent: consent,
189
- presence: presence
202
+ presence: presence,
203
+ purpose: purpose
190
204
  )
191
205
  end
192
206
 
@@ -15,6 +15,9 @@ module AgentAdmit
15
15
  # instead for an until-revoked connection (explicit JSON null).
16
16
  UNSET = Object.new.freeze
17
17
 
18
+ # Maximum length of a declared purpose (matches the hosted API contract).
19
+ PURPOSE_MAX_LENGTH = 300
20
+
18
21
  def initialize(config = nil)
19
22
  @config = config || AgentAdmit.configuration || Config.new
20
23
  @config.validate_api_key!
@@ -34,13 +37,24 @@ module AgentAdmit
34
37
  # @param scopes [Array<String>] scopes the connection grants
35
38
  # @param role [String, nil] the user's role on the connection
36
39
  # @param duration_seconds [Integer, nil, UNSET] see above
40
+ # @param purpose [String, nil] declared purpose: the user-facing reason
41
+ # recorded on the grant at the consent moment. Review-time record only,
42
+ # never an enforcement input; authorization decisions ride scopes,
43
+ # connection status, and consent. Max 300 characters; omitted from the
44
+ # request when nil.
37
45
  # @return [Hash] the issue response — "token" is the self-describing
38
46
  # ag_ct_… connection token to hand to the user's agent
47
+ # @raise [ArgumentError] if purpose exceeds 300 characters
39
48
  # @raise [IntrospectionError] if issuance fails
40
49
  #
41
- def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET)
50
+ def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET, purpose: nil)
51
+ if purpose && purpose.length > PURPOSE_MAX_LENGTH
52
+ raise ArgumentError, "purpose must be at most #{PURPOSE_MAX_LENGTH} characters"
53
+ end
54
+
42
55
  body = { "user_id" => user_id, "scopes" => scopes }
43
56
  body["role"] = role if role
57
+ body["purpose"] = purpose if purpose
44
58
  # Tri-state: the UNSET sentinel omits the key entirely; nil survives
45
59
  # JSON.generate as explicit JSON null (no compact, no nil-guard).
46
60
  body["duration_seconds"] = duration_seconds unless duration_seconds.equal?(UNSET)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.5.0"
4
+ VERSION = "1.7.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.5.0
4
+ version: 1.7.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-14 00:00:00.000000000 Z
11
+ date: 2026-08-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json