agentadmit 1.5.1 → 1.8.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: 810b958cd0fd3558a19bfc79910e194e45e02d95331b98747ff4edf29e337d8a
4
- data.tar.gz: 4c18ad2b306eea918107543eeee24c3f2eb7f30010bfb3c1bb6502bcfa94718b
3
+ metadata.gz: 9903cf0e0365cc82696a35748e5736582e4d2e9aad33e9c956c62df61c4c5eea
4
+ data.tar.gz: e1cbbc38ef3ca551b003574383e759056f3c6f90c2224fc4cd8dc68af836c270
5
5
  SHA512:
6
- metadata.gz: 4a0a65b2ce47a30683c14de7c397bea637adbe18ed76db7b8b579c5835d5dcdb90881ef93e20c687710cc37184ec3fc796a9fd153e1f6bf19edcb8f1c2875463
7
- data.tar.gz: b81a5431a0a2b37728e675963ab79e188e5d0e288f4bb2a4e8497fe5df81ba7a962fc15d99b36fd21d72fa6472de266bb2dacef92aab79f46c04b615bcd31e58
6
+ metadata.gz: c3089318cad6fd8c1914a3f087b9e9e2351ee0131b81d6bd288a9fa736868ce6c64937de4a248c0c3c4162c2d4b481e044ef2c642d917d368499e42dd6a72e93
7
+ data.tar.gz: 2f2d55a5ea98bc761ccabd6cce0e3e5ac417b0b5b659094c05ca74178abd77752e392c6b6104621ab750ba97aa1264f5b4d541e47c4a243e20b037b1417b69ab
data/README.md CHANGED
@@ -258,7 +258,7 @@ config = alerts.get_alert_config(app_id: 'app_abc123')
258
258
  AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes connections. **How you notify your own users is up to you.** AgentAdmit provides the data -- you deliver it through your own system (in-app notifications, email, push, etc.).
259
259
 
260
260
  - **Poll alerts** -- Use the SDK methods above from your backend to check for new events, then notify users through your existing system.
261
- - **Webhook delivery** -- Configure a webhook URL in your AgentAdmit dashboard. When an alert fires, AgentAdmit POSTs the payload to your server, signed with your `whsec_...` secret. Always verify the signature against the raw request body before trusting the payload:
261
+ - **Webhook delivery** -- Configure a webhook URL in your AgentAdmit dashboard. When an alert fires, AgentAdmit POSTs the payload to your server, signed with your `whsec_...` secret. The payload carries `alert_id`, `alert_type`, `severity`, the connection's `agent_label`, and the grant's declared `purpose`; the full shape is documented in the Webhook Delivery section of the MCP guide at https://agentadmit.com/docs/mcp-guide. Always verify the signature against the raw request body before trusting the payload:
262
262
 
263
263
  ```ruby
264
264
  # Rails controller
@@ -302,3 +302,50 @@ granted = tokens.exchange(connection_token, agent_label: "MyAssistant")
302
302
  # Revoke when the user disconnects the agent.
303
303
  tokens.revoke(granted["connection_id"], reason: "user_requested")
304
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.
328
+
329
+ ### User-declared intent
330
+
331
+ User-declared intent: the user's OWN words, typed at the consent moment. `purpose` is the app's words for why the connection exists; `user_intent` is what the user actually said they wanted ("build me a weekly workout summary"). On the hosted consent page the user can type it into an optional field; apps collecting consent in their own UI can pass it at token issuance.
332
+
333
+ Pass it when issuing a connection token (optional, 1-300 characters; a malformed value -- non-string, empty, or over 300 characters -- normalizes to `nil` and is omitted from the request rather than rejected):
334
+
335
+ ```ruby
336
+ issued = tokens.issue_token(
337
+ user_id: "user_42",
338
+ scopes: ["read:orders"],
339
+ purpose: "Book quarterly travel for the sales team",
340
+ user_intent: "Book my flights to the Austin offsite in October"
341
+ )
342
+ ```
343
+
344
+ It flows exactly like purpose: stored on the connection, returned by verify, stamped into every audit row, and carried on grant and revocation ledger events. When the hosted presence ceremony runs, the user's own words are included in the verifiable-consent-evidence commitment, so their authenticator signs what they said they wanted.
345
+
346
+ ```ruby
347
+ result = AgentAdmit::IntrospectionClient.new.verify(token)
348
+ result.user_intent # => "Book my flights to the Austin offsite in October" or nil
349
+ ```
350
+
351
+ `user_intent` is nullable -- connections issued without one (or by servers that predate the field) read as `nil`. Months later, a review screen can answer "is this still appropriate?" with the user's own stated boundary, not just the app's. Like purpose, it is a review-time record and never an enforcement input; authorization decisions ride scopes, connection status, and consent.
@@ -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, :user_intent, keyword_init: true) do
21
21
  def has_scope?(scope)
22
22
  scopes.include?(scope)
23
23
  end
@@ -47,6 +47,17 @@ module AgentAdmit
47
47
  def presence_verified?
48
48
  presence.is_a?(Hash) && presence["verified"] == true
49
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.
55
+
56
+ # `user_intent` (String or nil) is the user-declared intent: the user's
57
+ # OWN words, typed at the consent moment (purpose is the app's words;
58
+ # user_intent is the user's). Review-time record only, never an
59
+ # enforcement input; authorization decisions ride scopes, connection
60
+ # status, and consent.
50
61
  end
51
62
 
52
63
  def initialize(config = nil)
@@ -177,6 +188,18 @@ module AgentAdmit
177
188
  presence = data["presence"]
178
189
  presence = nil unless presence.is_a?(Hash) && [true, false].include?(presence["verified"])
179
190
 
191
+ # Declared purpose passes through as-is when it is a String (the
192
+ # hosted /verify returns it nullable). It is a review-time record,
193
+ # never an enforcement input, so a malformed value is simply dropped.
194
+ purpose = data["purpose"]
195
+ purpose = nil unless purpose.is_a?(String)
196
+
197
+ # User-declared intent passes through the same way (the hosted
198
+ # /verify returns it nullable). Review-time record, never an
199
+ # enforcement input, so a malformed value is simply dropped.
200
+ user_intent = data["user_intent"]
201
+ user_intent = nil unless user_intent.is_a?(String)
202
+
180
203
  return IntrospectionResult.new(
181
204
  user_id: data["user_id"],
182
205
  connection_id: data["connection_id"],
@@ -188,7 +211,9 @@ module AgentAdmit
188
211
  jti: data["jti"],
189
212
  exp: data["exp"],
190
213
  consent: consent,
191
- presence: presence
214
+ presence: presence,
215
+ purpose: purpose,
216
+ user_intent: user_intent
192
217
  )
193
218
  end
194
219
 
@@ -15,6 +15,13 @@ 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
+
21
+ # Maximum length of a user-declared intent (matches the hosted API
22
+ # contract: optional string, 1..300 characters).
23
+ USER_INTENT_MAX_LENGTH = 300
24
+
18
25
  def initialize(config = nil)
19
26
  @config = config || AgentAdmit.configuration || Config.new
20
27
  @config.validate_api_key!
@@ -34,13 +41,46 @@ module AgentAdmit
34
41
  # @param scopes [Array<String>] scopes the connection grants
35
42
  # @param role [String, nil] the user's role on the connection
36
43
  # @param duration_seconds [Integer, nil, UNSET] see above
44
+ # @param purpose [String, nil] declared purpose: the user-facing reason
45
+ # recorded on the grant at the consent moment. Review-time record only,
46
+ # never an enforcement input; authorization decisions ride scopes,
47
+ # connection status, and consent. Max 300 characters; omitted from the
48
+ # request when nil.
49
+ # @param user_intent [String, nil] user-declared intent: the user's OWN
50
+ # words, typed at the consent moment (distinct from purpose, which is
51
+ # the app's words). Optional, 1-300 characters. Validated like purpose:
52
+ # a non-String, non-nil value or a string over 300 characters raises
53
+ # ArgumentError before any request is sent — silently discarding the
54
+ # user's typed words would be data loss. Empty/whitespace-only strings
55
+ # normalize to nil and are omitted. Like purpose, it is a review-time
56
+ # record, never an enforcement input.
37
57
  # @return [Hash] the issue response — "token" is the self-describing
38
58
  # ag_ct_… connection token to hand to the user's agent
59
+ # @raise [ArgumentError] if purpose exceeds 300 characters, or if
60
+ # user_intent is a non-String (other than nil) or exceeds 300 characters
39
61
  # @raise [IntrospectionError] if issuance fails
40
62
  #
41
- def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET)
63
+ def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET, purpose: nil,
64
+ user_intent: nil)
65
+ if purpose && purpose.length > PURPOSE_MAX_LENGTH
66
+ raise ArgumentError, "purpose must be at most #{PURPOSE_MAX_LENGTH} characters"
67
+ end
68
+
69
+ # User-declared intent is validated like purpose: reject out-of-contract
70
+ # values before any request rather than silently discarding the user's
71
+ # typed words (data loss). Empty/whitespace-only normalizes to nil-omit.
72
+ unless user_intent.nil? || user_intent.is_a?(String)
73
+ raise ArgumentError, "user_intent must be a String or nil"
74
+ end
75
+ if user_intent && user_intent.length > USER_INTENT_MAX_LENGTH
76
+ raise ArgumentError, "user_intent must be at most #{USER_INTENT_MAX_LENGTH} characters"
77
+ end
78
+ user_intent = nil if user_intent && user_intent.strip.empty?
79
+
42
80
  body = { "user_id" => user_id, "scopes" => scopes }
43
81
  body["role"] = role if role
82
+ body["purpose"] = purpose if purpose
83
+ body["user_intent"] = user_intent if user_intent
44
84
  # Tri-state: the UNSET sentinel omits the key entirely; nil survives
45
85
  # JSON.generate as explicit JSON null (no compact, no nil-guard).
46
86
  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.1"
4
+ VERSION = "1.8.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.1
4
+ version: 1.8.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-17 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json