agentadmit 1.7.0 → 1.9.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: 53205cacbbf7942a6cb2600c9cea4c0c739d991be95bd7c627df405287e5f52c
4
- data.tar.gz: b9849673334811698875c9733abc1b69c2938111cf4b928cde741cb0e6ba6d0f
3
+ metadata.gz: 677087b98e731945e7485c16db8c8a858dabc54fa346565b3697948db26f9fcb
4
+ data.tar.gz: 189ff20ab78520f0ae0ce4a7db1cd32ba8067153c5c5787f4cd04984730eece3
5
5
  SHA512:
6
- metadata.gz: ed8c4e40cc18a7749257674d2ba0fba6d8fb79946aeb868cf84fa5f5fa67075eff3cc80456e67e34886157f63cbaaceb1156abc3ce92f87a3400096920354f69
7
- data.tar.gz: e184a592c70ac59b82f906f00a7cae3c83ecd3b9681e5a35421bad55c3979a11bbfbfe45f85f8135fe4a909ed1f1c394108b03d86b199f91bf999ac8cf40e1bc
6
+ metadata.gz: 4e374766292936272c50ca95fd2b63ae97f5cb044d194a879dc268009e453630406e090fb57227dcb3dc19f964a04ec3c12dd7b40500cbfd1f1f6b218771e7cc
7
+ data.tar.gz: c18398441c0d63526cb6fce609907eea627830a7dc71803688542a29950d8b0cf7ed1bd9d30dc3c0be3c154f53ba4f568707b855c3e01e001f5cdbc7ed9a1ff6
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
@@ -325,3 +325,46 @@ result.purpose # => "Book quarterly travel for the sales team" or nil
325
325
  ```
326
326
 
327
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.
352
+
353
+ ## App-Attested Presence
354
+
355
+ If your app gates token minting behind its own embedded passkey/WebAuthn ceremony, AgentAdmit never witnesses that ceremony (it is origin-bound), so by default the hosted service reports `presence.verified: false` for those connections. Attest the ceremony fact at issuance to close that gap -- AFTER verifying and consuming your own fresh, purpose-bound attestation:
356
+
357
+ ```ruby
358
+ issued = tokens.issue_token(
359
+ user_id: "user_42",
360
+ scopes: ["read:orders"],
361
+ presence: AgentAdmit::AppAttestedPresence.new(
362
+ method: "my_webauthn", # lowercase alphanumeric/underscore
363
+ verified_at: attestation.created_at # Time or DateTime
364
+ )
365
+ )
366
+ ```
367
+
368
+ The SDK sends it as `presence: {verified: true, uv: true, method, verified_at}` -- `verified`/`uv` are literal true by construction and the class cannot represent anything else; a raw Hash is rejected so the wire contract stays owned by the typed class. The hosted service validates freshness (10-minute window, 60 s future clock-skew slack) and stores the method provenance-marked `app:<method>` so app-attested facts stay distinct from ceremonies AgentAdmit witnessed itself. Introspection, the grant-event ledger, and the evidence API then carry `presence.verified: true` for the connection.
369
+
370
+ Honesty ceiling: this is your app's attestation, recorded and provenance-marked. It is not witnessed by AgentAdmit and not independently verifiable. Only attest a ceremony that verified the user with UV (biometric or PIN user verification); a ceremony without UV carries no presence fact, so pass `nil` (the default). An out-of-contract method (`^[a-z0-9_]+$`, 1-60) raises `ArgumentError` at construction, before any request; Ruby `Time`/`DateTime` always carry an offset, so `verified_at` serializes RFC 3339 with an explicit offset by construction.
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module AgentAdmit
6
+ ##
7
+ # App-attested presence: a ceremony fact your app attests at token issuance.
8
+ #
9
+ # Pass an instance to TokensClient#issue_token AFTER verifying and consuming
10
+ # your app's own fresh, purpose-bound WebAuthn/passkey attestation for the
11
+ # mint. The SDK forwards it to the hosted mint as
12
+ # presence {verified: true, uv: true, method, verified_at}; the hosted
13
+ # service stores it method-prefixed "app:<method>" — the provenance marker
14
+ # that keeps app-attested facts distinct from hosted-witnessed ceremonies.
15
+ #
16
+ # Honesty ceiling: this is YOUR attestation, recorded and provenance-marked.
17
+ # It is not witnessed by AgentAdmit and not independently verifiable. Only
18
+ # construct one for a ceremony that verified the user with UV (biometric or
19
+ # PIN user verification); verified/uv serialize as literal true and cannot
20
+ # represent anything else — a ceremony without UV carries no presence fact,
21
+ # so simply pass nil.
22
+ #
23
+ # verified_at must be recent: the hosted service enforces a 10-minute
24
+ # freshness window with 60 seconds of future clock-skew slack. Ruby Time and
25
+ # DateTime always carry an offset, so #iso8601 serializes RFC 3339 with an
26
+ # explicit offset by construction (the hosted contract; offset-less
27
+ # timestamps are rejected with 400).
28
+ #
29
+ class AppAttestedPresence
30
+ METHOD_PATTERN = /\A[a-z0-9_]+\z/
31
+ METHOD_MAX_LENGTH = 60
32
+
33
+ # NOTE: a +method+ reader shadows Object#method on instances — the same
34
+ # trade stdlib's Net::HTTPGenericRequest makes; the name matches the wire
35
+ # field.
36
+ attr_reader :method, :verified_at
37
+
38
+ ##
39
+ # @param method [String] your ceremony mechanism, 1-60 lowercase
40
+ # alphanumeric/underscore characters (e.g. "my_webauthn")
41
+ # @param verified_at [Time, DateTime] when the ceremony completed
42
+ # @raise [ArgumentError] when method is out of contract or verified_at is
43
+ # not a timestamp — validated at construction, before any request,
44
+ # where the fix is obvious
45
+ #
46
+ def initialize(method:, verified_at:)
47
+ unless method.is_a?(String) && !method.empty? &&
48
+ method.length <= METHOD_MAX_LENGTH && METHOD_PATTERN.match?(method)
49
+ raise ArgumentError,
50
+ "method must be 1-#{METHOD_MAX_LENGTH} lowercase alphanumeric/underscore " \
51
+ "characters (e.g. 'my_webauthn')"
52
+ end
53
+ unless verified_at.respond_to?(:iso8601)
54
+ raise ArgumentError,
55
+ "verified_at must be a Time or DateTime (the ceremony that authorized " \
56
+ "this mint just happened)"
57
+ end
58
+
59
+ @method = method
60
+ @verified_at = verified_at
61
+ end
62
+
63
+ ##
64
+ # The exact JSON object forwarded to the hosted mint.
65
+ #
66
+ # @return [Hash]
67
+ #
68
+ def to_wire
69
+ {
70
+ "verified" => true,
71
+ "uv" => true,
72
+ "method" => @method,
73
+ "verified_at" => @verified_at.iso8601
74
+ }
75
+ end
76
+ end
77
+ 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, :purpose, 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
@@ -52,6 +52,12 @@ module AgentAdmit
52
52
  # reason recorded on the grant at the consent moment. Review-time record
53
53
  # only, never an enforcement input; authorization decisions ride scopes,
54
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.
55
61
  end
56
62
 
57
63
  def initialize(config = nil)
@@ -188,6 +194,12 @@ module AgentAdmit
188
194
  purpose = data["purpose"]
189
195
  purpose = nil unless purpose.is_a?(String)
190
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
+
191
203
  return IntrospectionResult.new(
192
204
  user_id: data["user_id"],
193
205
  connection_id: data["connection_id"],
@@ -200,7 +212,8 @@ module AgentAdmit
200
212
  exp: data["exp"],
201
213
  consent: consent,
202
214
  presence: presence,
203
- purpose: purpose
215
+ purpose: purpose,
216
+ user_intent: user_intent
204
217
  )
205
218
  end
206
219
 
@@ -18,6 +18,10 @@ module AgentAdmit
18
18
  # Maximum length of a declared purpose (matches the hosted API contract).
19
19
  PURPOSE_MAX_LENGTH = 300
20
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
+
21
25
  def initialize(config = nil)
22
26
  @config = config || AgentAdmit.configuration || Config.new
23
27
  @config.validate_api_key!
@@ -42,19 +46,56 @@ module AgentAdmit
42
46
  # never an enforcement input; authorization decisions ride scopes,
43
47
  # connection status, and consent. Max 300 characters; omitted from the
44
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.
57
+ # @param presence [AppAttestedPresence, nil] app-attested ceremony fact:
58
+ # set it AFTER verifying and consuming your app's own fresh,
59
+ # purpose-bound WebAuthn/passkey attestation for this mint. Forwarded
60
+ # as presence {verified: true, uv: true, method, verified_at} and
61
+ # stored provenance-marked "app:<method>"; omitted when nil (omitting
62
+ # the field is the only way to say "no ceremony").
45
63
  # @return [Hash] the issue response — "token" is the self-describing
46
64
  # ag_ct_… connection token to hand to the user's agent
47
- # @raise [ArgumentError] if purpose exceeds 300 characters
65
+ # @raise [ArgumentError] if purpose exceeds 300 characters, if
66
+ # user_intent is a non-String (other than nil) or exceeds 300
67
+ # characters, or if presence is neither nil nor an AppAttestedPresence
48
68
  # @raise [IntrospectionError] if issuance fails
49
69
  #
50
- def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET, purpose: nil)
70
+ def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET, purpose: nil,
71
+ user_intent: nil, presence: nil)
51
72
  if purpose && purpose.length > PURPOSE_MAX_LENGTH
52
73
  raise ArgumentError, "purpose must be at most #{PURPOSE_MAX_LENGTH} characters"
53
74
  end
54
75
 
76
+ # User-declared intent is validated like purpose: reject out-of-contract
77
+ # values before any request rather than silently discarding the user's
78
+ # typed words (data loss). Empty/whitespace-only normalizes to nil-omit.
79
+ unless user_intent.nil? || user_intent.is_a?(String)
80
+ raise ArgumentError, "user_intent must be a String or nil"
81
+ end
82
+ if user_intent && user_intent.length > USER_INTENT_MAX_LENGTH
83
+ raise ArgumentError, "user_intent must be at most #{USER_INTENT_MAX_LENGTH} characters"
84
+ end
85
+ user_intent = nil if user_intent && user_intent.strip.empty?
86
+
87
+ # Presence is typed-only: a raw Hash is rejected so the wire contract
88
+ # (literal-true verified/uv, offset-carrying verified_at) stays owned
89
+ # by AppAttestedPresence, never hand-rolled at call sites.
90
+ unless presence.nil? || presence.is_a?(AppAttestedPresence)
91
+ raise ArgumentError, "presence must be an AgentAdmit::AppAttestedPresence or nil"
92
+ end
93
+
55
94
  body = { "user_id" => user_id, "scopes" => scopes }
56
95
  body["role"] = role if role
57
96
  body["purpose"] = purpose if purpose
97
+ body["user_intent"] = user_intent if user_intent
98
+ body["presence"] = presence.to_wire if presence
58
99
  # Tri-state: the UNSET sentinel omits the key entirely; nil survives
59
100
  # JSON.generate as explicit JSON null (no compact, no nil-guard).
60
101
  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.7.0"
4
+ VERSION = "1.9.0"
5
5
  end
data/lib/agentadmit.rb CHANGED
@@ -88,6 +88,7 @@ end
88
88
 
89
89
  require_relative "agentadmit/config"
90
90
  require_relative "agentadmit/introspection_client"
91
+ require_relative "agentadmit/app_attested_presence"
91
92
  require_relative "agentadmit/tokens_client"
92
93
  require_relative "agentadmit/alerts_client"
93
94
  require_relative "agentadmit/webhook"
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.7.0
4
+ version: 1.9.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-08-05 00:00:00.000000000 Z
11
+ date: 2026-08-13 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/app_attested_presence.rb
38
39
  - lib/agentadmit/caller_consent.rb
39
40
  - lib/agentadmit/config.rb
40
41
  - lib/agentadmit/introspection_client.rb