agentadmit 1.4.0 → 1.5.1

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: bc3085f5469fd5380afb22df98cf1517e1f74df3a23cf7ab07d7f6c490cd9feb
4
- data.tar.gz: fb0142a79357486644fff3e68903194b998cf5b41c1f140a8bda8f7a2f0c2909
3
+ metadata.gz: 810b958cd0fd3558a19bfc79910e194e45e02d95331b98747ff4edf29e337d8a
4
+ data.tar.gz: 4c18ad2b306eea918107543eeee24c3f2eb7f30010bfb3c1bb6502bcfa94718b
5
5
  SHA512:
6
- metadata.gz: e54b1b2f7c357b546388d09a9accf94c29c98964ade0d5888e87962d3d30219486cf30b932234d503e46d6ea80e4a6348a165a6172a09b4d182390709c605713
7
- data.tar.gz: 3d237b464785de4d0a8a081d992dd7313c77c84a3bdac92f0a3a9dc41a2909137cfec0415d7eb67dd4408c1eb772d24ff514e996184ad0d580b9f047fcd0a59d
6
+ metadata.gz: 4a0a65b2ce47a30683c14de7c397bea637adbe18ed76db7b8b579c5835d5dcdb90881ef93e20c687710cc37184ec3fc796a9fd153e1f6bf19edcb8f1c2875463
7
+ data.tar.gz: b81a5431a0a2b37728e675963ab79e188e5d0e288f4bb2a4e8497fe5df81ba7a962fc15d99b36fd21d72fa6472de266bb2dacef92aab79f46c04b615bcd31e58
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
@@ -96,6 +105,22 @@ head :forbidden unless verdict["granted"]
96
105
 
97
106
  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
107
 
108
+ **One-middleware drop-in.** Instead of wiring the three paths by hand, `AgentAdmit::CallerConsent` classifies the caller from the credential and evaluates the right independent path:
109
+
110
+ ```ruby
111
+ use AgentAdmit::CallerConsent,
112
+ # derive the class from your own credential structure, never caller input
113
+ classify_non_agent: ->(env) {
114
+ env["HTTP_X_INTERNAL_AI"] == ENV["INTERNAL_AI_SECRET"] ? "in_app_ai" : "human_session"
115
+ },
116
+ resolve_data_owner_id: ->(env) { Rack::Request.new(env).params["owner_id"] },
117
+ required_scope: "read:records"
118
+ # Downstream: env["agentadmit.caller_class"], env["agentadmit.consent"], and the
119
+ # standard agent env variables on the external-agent path.
120
+ ```
121
+
122
+ External agents are checked via hosted introspection (consent verdict plus scope); in-app AI via the Consent Ledger (fail closed); the human path defers to your own permission model unless `gate_human: true`. It is a consent gate, not an authenticator, so mount it after your own authentication.
123
+
99
124
  ### Presence verification
100
125
 
101
126
  AgentAdmit can attest that the human who authorized a connection completed a WebAuthn presence ceremony on the consent page. The verify result carries the fact:
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentAdmit
4
+ ##
5
+ # Caller-Identity Consent middleware: the "classify caller, then gate the
6
+ # right independent path" recipe as one Rack middleware, so an app owner
7
+ # does not have to hand-roll it.
8
+ #
9
+ # One endpoint serves every caller class. On each request the middleware:
10
+ #
11
+ # 1. classifies the caller from the STRUCTURE of the credential (a class
12
+ # the caller cannot self-select), before any consent check;
13
+ # 2. routes to that class's ISOLATED consent path; no path reads or
14
+ # inherits another class's preference;
15
+ # 3. permits or denies, and sets env variables
16
+ # (agentadmit.caller_class, agentadmit.consent, plus the standard agent
17
+ # env variables on the external-agent path).
18
+ #
19
+ # - external_agent: an ag_at_ access token -> hosted introspection, which
20
+ # returns the external-agent consent verdict inline plus the granted
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.
24
+ # - in_app_ai: your application's own server-side AI code path -> the
25
+ # Consent Ledger /consent/check for the in-app-AI class.
26
+ # - human_session: your application's own permission model (sharing,
27
+ # roles, grants). Deferred to your existing authorization by default;
28
+ # opt in to a stored human-session switch with gate_human: true.
29
+ #
30
+ # The three decisions are independent: granting one never grants another.
31
+ #
32
+ # SECURITY: this is a consent gate, not an authenticator. It classifies the
33
+ # caller and enforces the per-class CONSENT decision; it does not by itself
34
+ # authenticate a human session. Mount it AFTER your own authentication. On
35
+ # the human_session path it defers to your application's permission model
36
+ # and calls the app without re-authenticating. The external_agent path is
37
+ # always authenticated (hosted introspection); the in_app_ai path always
38
+ # evaluates the ledger.
39
+ #
40
+ # Usage:
41
+ # use AgentAdmit::CallerConsent,
42
+ # # derive the class from your own credential structure, never caller input
43
+ # classify_non_agent: ->(env) {
44
+ # env["HTTP_X_INTERNAL_AI"] == ENV["INTERNAL_AI_SECRET"] ? "in_app_ai" : "human_session"
45
+ # },
46
+ # resolve_data_owner_id: ->(env) { Rack::Request.new(env).params["owner_id"] },
47
+ # required_scope: "read:records"
48
+ #
49
+ class CallerConsent
50
+ # RFC 7235: the auth-scheme token is case-insensitive.
51
+ BEARER_AGENT_RE = /\Abearer ag_at_/i
52
+
53
+ HUMAN_SESSION = "human_session"
54
+ IN_APP_AI = "in_app_ai"
55
+ EXTERNAL_AGENT = "external_agent"
56
+
57
+ ##
58
+ # @param app [#call] the downstream Rack app
59
+ # @param resolve_data_owner_id [Proc, nil] env -> your app's identifier
60
+ # for the data owner whose resource is accessed. Required for the
61
+ # in_app_ai path, and for human_session when gate_human is set. The
62
+ # external-agent owner comes from the token, so it is not used there.
63
+ # @param classify_non_agent [Proc, nil] env -> "in_app_ai" or
64
+ # "human_session", derived from the STRUCTURE of the credential (for
65
+ # example an internal service token), never a value the caller can set.
66
+ # Defaults to treating non-agent callers as human sessions.
67
+ # @param required_scope [String, nil] for the external_agent path,
68
+ # require this scope (403 insufficient_scope if not granted).
69
+ # @param scope_group [String, nil] optional finer-than-class consent
70
+ # group for ledger checks.
71
+ # @param gate_human [Boolean] also gate the human_session class against a
72
+ # stored switch. Off by default: the human path belongs to your own
73
+ # permission model.
74
+ #
75
+ def initialize(app, resolve_data_owner_id: nil, classify_non_agent: nil,
76
+ required_scope: nil, scope_group: nil, gate_human: false)
77
+ @app = app
78
+ @client = IntrospectionClient.new
79
+ @resolve_data_owner_id = resolve_data_owner_id
80
+ @classify_non_agent = classify_non_agent
81
+ @required_scope = required_scope
82
+ @scope_group = scope_group
83
+ @gate_human = gate_human
84
+ end
85
+
86
+ ##
87
+ # Classify the caller from credential structure, before any consent
88
+ # check. An ag_at_ bearer token is an external agent; anything else is
89
+ # resolved by classify_non_agent (default: human_session). The class is
90
+ # derived, never self-selected by the caller.
91
+ #
92
+ def classify_caller(env)
93
+ auth = env["HTTP_AUTHORIZATION"] || ""
94
+ return EXTERNAL_AGENT if BEARER_AGENT_RE.match?(auth)
95
+ return IN_APP_AI if @classify_non_agent && @classify_non_agent.call(env) == IN_APP_AI
96
+
97
+ HUMAN_SESSION
98
+ end
99
+
100
+ def call(env)
101
+ caller_class = classify_caller(env)
102
+ env["agentadmit.caller_class"] = caller_class
103
+
104
+ case caller_class
105
+ when EXTERNAL_AGENT
106
+ call_external_agent(env)
107
+ when IN_APP_AI
108
+ call_ledger_gated(env, IN_APP_AI, "in_app_ai",
109
+ "The data owner has not enabled in-app AI analysis.")
110
+ else
111
+ if @gate_human
112
+ call_ledger_gated(env, HUMAN_SESSION, "user",
113
+ "The data owner has not enabled this access.")
114
+ else
115
+ # Defer the human path to the app's existing authorization.
116
+ env["agentadmit.auth_type"] = "user"
117
+ @app.call(env)
118
+ end
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ ##
125
+ # External-agent path: hosted introspection carries the verdict and the
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.
132
+ #
133
+ def call_external_agent(env)
134
+ token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
135
+
136
+ begin
137
+ result = @client.verify(token)
138
+ rescue InvalidTokenError => e
139
+ return json_error(401, "invalid_token", e.message)
140
+ rescue IntrospectionError => e
141
+ return json_error(502, "introspection_failed", e.message)
142
+ end
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
+
165
+ if @required_scope && !(result.scopes || []).include?(@required_scope)
166
+ return [403, { "Content-Type" => "application/json" },
167
+ [{ error: "insufficient_scope",
168
+ required_scope: @required_scope,
169
+ granted_scopes: result.scopes || [],
170
+ message: "This action requires '#{@required_scope}' scope." }.to_json]]
171
+ end
172
+
173
+ env["agentadmit.auth_type"] = "agent"
174
+ env["agentadmit.user_id"] = result.user_id
175
+ env["agentadmit.scopes"] = result.scopes
176
+ env["agentadmit.connection_id"] = result.connection_id
177
+ env["agentadmit.agent_label"] = result.agent_label
178
+ env["agentadmit.presence"] = result.presence
179
+ env["agentadmit.consent"] = consent
180
+
181
+ @app.call(env)
182
+ end
183
+
184
+ ##
185
+ # Token-less caller class (in_app_ai, or human_session under gate_human),
186
+ # gated on the Consent Ledger. Fail closed: an unreachable or erroring
187
+ # ledger denies, never allows.
188
+ #
189
+ def call_ledger_gated(env, caller_class, auth_type, denied_message)
190
+ owner = @resolve_data_owner_id&.call(env)
191
+ if owner.nil? || owner.empty?
192
+ return json_error(500, "server_error",
193
+ "resolve_data_owner_id is required for this caller class")
194
+ end
195
+
196
+ begin
197
+ verdict = @client.check_consent(app_user_id: owner, caller_class: caller_class,
198
+ scope_group: @scope_group)
199
+ rescue StandardError
200
+ return json_error(503, "consent_unavailable", "Consent check failed")
201
+ end
202
+
203
+ return json_error(403, "consent_not_granted", denied_message) unless verdict["granted"] == true
204
+
205
+ env["agentadmit.auth_type"] = auth_type
206
+ env["agentadmit.consent"] = verdict
207
+ @app.call(env)
208
+ end
209
+
210
+ def json_error(status, error, description)
211
+ [status, { "Content-Type" => "application/json" },
212
+ [{ error: error, error_description: description }.to_json]]
213
+ end
214
+ end
215
+ end
@@ -26,22 +26,24 @@ 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
@@ -161,17 +163,17 @@ module AgentAdmit
161
163
  # Validate that consumed fields have the expected types when present.
162
164
  validate_introspection_types!(data)
163
165
 
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.
166
+ # Keep the consent block only when it is a Hash; anything else reads
167
+ # as nil. Absent and malformed are both safe: neither is ever a grant
168
+ # (consent_granted? fails closed, and CallerConsent resolves the
169
+ # authoritative verdict through the Consent Ledger).
168
170
  consent = data["consent"]
169
171
  consent = nil unless consent.is_a?(Hash)
170
172
 
171
173
  # Presence rides along when the platform returns it. Same strictness
172
174
  # 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.
175
+ # A malformed block is dropped -- presence_verified? fails closed on
176
+ # nil, so dropping cannot fail open.
175
177
  presence = data["presence"]
176
178
  presence = nil unless presence.is_a?(Hash) && [true, false].include?(presence["verified"])
177
179
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.4.0"
4
+ VERSION = "1.5.1"
5
5
  end
data/lib/agentadmit.rb CHANGED
@@ -92,6 +92,7 @@ require_relative "agentadmit/tokens_client"
92
92
  require_relative "agentadmit/alerts_client"
93
93
  require_relative "agentadmit/webhook"
94
94
  require_relative "agentadmit/middleware"
95
+ require_relative "agentadmit/caller_consent"
95
96
  # ScopeEnforcement is an ActiveSupport::Concern — only loadable inside Rails.
96
97
  require_relative "agentadmit/scope_enforcement" if defined?(ActiveSupport)
97
98
  require_relative "agentadmit/railtie" if defined?(Rails)
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.0
4
+ version: 1.5.1
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-05 00:00:00.000000000 Z
11
+ date: 2026-07-17 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/caller_consent.rb
38
39
  - lib/agentadmit/config.rb
39
40
  - lib/agentadmit/introspection_client.rb
40
41
  - lib/agentadmit/middleware.rb