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 +4 -4
- data/README.md +44 -1
- data/lib/agentadmit/app_attested_presence.rb +77 -0
- data/lib/agentadmit/introspection_client.rb +15 -2
- data/lib/agentadmit/tokens_client.rb +43 -2
- data/lib/agentadmit/version.rb +1 -1
- data/lib/agentadmit.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 677087b98e731945e7485c16db8c8a858dabc54fa346565b3697948db26f9fcb
|
|
4
|
+
data.tar.gz: 189ff20ab78520f0ae0ce4a7db1cd32ba8067153c5c5787f4cd04984730eece3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
data/lib/agentadmit/version.rb
CHANGED
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.
|
|
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-
|
|
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
|