agentadmit 1.11.0 → 1.12.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: 501b7f18f13450e559d3a58aa7060fdee56eb167b33e60245a7b734d900cf0b8
4
- data.tar.gz: dedcb0707371dfec628f2e0f8dfb0a7ee98d02260626a4601d99de30f22bb28e
3
+ metadata.gz: 35cfc75d6a091ef7f5940c488c472efad1c6d48a68c670e6c5e1522dbe9abb06
4
+ data.tar.gz: 0a9c2ecbc1fcdab6255e709519625cc1dbc9944d36d2b9725d5069a44544b222
5
5
  SHA512:
6
- metadata.gz: e23e66f76f7de6817786c206103e26eb1ebd3c3e6b36888f8d0028f2c092a8c1dd034fcdd4537554af2ea04f3f86ea31428967db5d41ebe93e276bcdb984ab08
7
- data.tar.gz: 9f2a13f32307706d2ae85c7f04b6cb729a0975645264deb560108d3fa5ab892e0153c0e86467e9b97c01f72dd362581dbed9b905db420fe86c7df7e072087ce6
6
+ metadata.gz: 39bb513f8747187ffeb5d7bb73f4557c47a9da0a95f1bd8b56130f95b38406081befb47570f425afbdfbc1ca322069459c5ebf3e4fcb1f4c164320242c306882
7
+ data.tar.gz: 35577380cb5d5e783fe05e68c67c291238de414dd9b8fb99cb4d9ea8f75a5befed74c501ccd50e1a2eade6ffe3dc3d009d712ef5f44f1b1ba41e7928d8c20a99
data/README.md CHANGED
@@ -177,6 +177,19 @@ fail-closed `ActiveDenialError` instances with no link. On an accepted retry,
177
177
  expose the consumed ceremony so your own transaction step-up can avoid asking
178
178
  the human twice.
179
179
 
180
+ **The user can decline.** If the user taps Decline on the hosted page, the
181
+ hosted service answers the agent's retry with `confirmation_declined` and
182
+ holds that answer until `declined["hold_until"]`; no new ceremony is staged
183
+ and the user is not notified again. Middlewares return 403 with the strictly
184
+ typed `declined` block (`action_session_id`, `declined_at`, `hold_until`,
185
+ `scope`, plus nullable `method`, `endpoint`, `request_digest`, `summary`) and
186
+ `renewal`; custom gates receive `AgentAdmit::ConfirmationDeclinedError` (an
187
+ `ActiveDenialError`) with `.declined` and `.attestation_status`. Agents should
188
+ relay the decline to the user and not retry unless the user asks; only the
189
+ user can lift a decline, and after the hold ends a retry stages a fresh
190
+ confirmation. A malformed `declined` block degrades to a generic 403 with no
191
+ block.
192
+
180
193
  ## Rate Limiting
181
194
 
182
195
  The AgentAdmit introspection endpoint enforces rate limits. The Ruby SDK handles HTTP 429 responses **automatically** with exponential backoff and jitter -- no changes needed in your middleware code.
@@ -443,6 +456,7 @@ An introspection response with `active: true` AND a string `error` field means t
443
456
  - `insufficient_scope` -> `AgentAdmit::InsufficientScopeError`; middlewares return 403 with the step-up shape (`error`, `required_scope`, `granted_scopes`)
444
457
  - `bound_exceeded` -> `AgentAdmit::BoundExceededError`; middlewares return 403 passing the hosted fields (`error_description`, `bound`, `renewal`) through verbatim
445
458
  - `confirmation_required` -> `AgentAdmit::ConfirmationRequiredError`; middlewares return 403 with the strictly typed `confirmation` block (`action_session_id`, `action_session_url`, `expires_at`, `scope`, ...) plus `attestation_status`/`attestation_description`/`renewal` when the hosted service sent them, so the agent can relay the confirmation link to the human (see [Confirm Each Time](#confirm-each-time-exercise-time-human-confirmation)); a malformed `confirmation` block degrades to the generic `ActiveDenialError` shape with no link
459
+ - `confirmation_declined` -> `AgentAdmit::ConfirmationDeclinedError`; middlewares return 403 with the strictly typed `declined` block (`action_session_id`, `declined_at`, `hold_until`, `scope`, ...) plus `attestation_status`/`attestation_description`/`renewal` when sent, so the agent can relay the user's decline instead of nagging with a link; a malformed `declined` block degrades to the generic shape with no block
446
460
  - any other error string -> `AgentAdmit::ActiveDenialError`; middlewares return 403 with `{error: <code>, error_description: "Call refused by the authorization service."}` -- unknown codes fail closed
447
461
 
448
462
  All four inherit from `AgentAdmit::ActiveDenialError` and expose `#code`, `#data` (the parsed hosted response), and `#denial_body` (the ready-made 403 JSON body) for apps that call `verify` directly.
@@ -125,6 +125,32 @@ module AgentAdmit
125
125
  "summary" => nullable.call(raw["summary"]) }
126
126
  end
127
127
 
128
+ ##
129
+ # A strictly-typed copy of the wire `declined` block (SDK 1.12.0), or
130
+ # nil when it is malformed. action_session_id, declined_at, hold_until
131
+ # and scope must be Strings; method, endpoint, request_digest and
132
+ # summary are nullable Strings (anything else reads as nil). Nothing
133
+ # outside the contract is copied through.
134
+ #
135
+ # @param raw [Object] the `declined` value from the hosted response
136
+ # @return [Hash, nil]
137
+ #
138
+ def parse_action_decline(raw)
139
+ return nil unless raw.is_a?(Hash)
140
+ return nil unless %w[action_session_id declined_at hold_until scope]
141
+ .all? { |key| raw[key].is_a?(String) }
142
+
143
+ nullable = ->(value) { value.is_a?(String) ? value : nil }
144
+ { "action_session_id" => raw["action_session_id"],
145
+ "declined_at" => raw["declined_at"],
146
+ "hold_until" => raw["hold_until"],
147
+ "scope" => raw["scope"],
148
+ "method" => nullable.call(raw["method"]),
149
+ "endpoint" => nullable.call(raw["endpoint"]),
150
+ "request_digest" => nullable.call(raw["request_digest"]),
151
+ "summary" => nullable.call(raw["summary"]) }
152
+ end
153
+
128
154
  ##
129
155
  # The agent's X-AgentAdmit-Action-Attestation header from a Rack env:
130
156
  # first value only, trimmed, capped at 120 characters. nil when absent
@@ -462,6 +488,10 @@ module AgentAdmit
462
488
  # typed, so the agent can hand the link to the human. A malformed
463
489
  # ceremony block degrades to the generic denial -- fail closed rather
464
490
  # than relay an unusable confirmation.
491
+ # - confirmation_declined: the user declined this exact action on the
492
+ # hosted page; the hold rides along, strictly typed, so the agent can
493
+ # relay the decline to the user. A malformed block degrades to the
494
+ # generic denial.
465
495
  # - anything else: unknown refusal -> generic typed denial. Fail closed.
466
496
  #
467
497
  def raise_active_denial!(data, scope_used)
@@ -499,6 +529,28 @@ module AgentAdmit
499
529
  "Call refused by the authorization service.",
500
530
  code: "confirmation_required", data: data
501
531
  )
532
+ when "confirmation_declined"
533
+ # SDK 1.12.0: the user declined exactly this action on the hosted
534
+ # page and the hold still runs. Relay the decline so the agent can
535
+ # tell the user instead of nagging with a link.
536
+ declined = self.class.parse_action_decline(data["declined"])
537
+ if declined
538
+ description = data["error_description"]
539
+ description = ConfirmationDeclinedError::DESCRIPTION unless
540
+ description.is_a?(String) && !description.empty?
541
+ status = data["attestation_status"]
542
+ raise ConfirmationDeclinedError.new(
543
+ description,
544
+ declined: declined,
545
+ attestation_status: status.is_a?(String) ? status : nil,
546
+ data: data
547
+ )
548
+ end
549
+ # Malformed decline: generic denial, fail closed, no block.
550
+ raise ActiveDenialError.new(
551
+ "Call refused by the authorization service.",
552
+ code: "confirmation_declined", data: data
553
+ )
502
554
  else
503
555
  raise ActiveDenialError.new(
504
556
  "Call refused by the authorization service.",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.11.0"
4
+ VERSION = "1.12.0"
5
5
  end
data/lib/agentadmit.rb CHANGED
@@ -154,6 +154,58 @@ module AgentAdmit
154
154
  end
155
155
  end
156
156
 
157
+ ##
158
+ # `active: true` + `error: "confirmation_declined"` (SDK 1.12.0,
159
+ # confirm-each-time) -- the user declined exactly this action on the hosted
160
+ # confirmation page, and the hosted service holds that answer until
161
+ # `declined["hold_until"]`. No new ceremony is staged and the user is not
162
+ # notified again while the hold runs. {#declined} is the strictly-typed
163
+ # block naming the declined session, when, how long, and the exact action.
164
+ #
165
+ # Agents should relay the decline to the user and not retry unless the user
166
+ # asks; only the user can lift a decline. After the hold ends, a retry
167
+ # stages a fresh confirmation.
168
+ #
169
+ # {#attestation_status} explains why a presented attestation was NOT
170
+ # accepted, when one was presented (e.g. declined).
171
+ #
172
+ # A malformed declined block never reaches this class: the client falls
173
+ # back to a generic {ActiveDenialError} (fail closed, 403, no declined
174
+ # block).
175
+ #
176
+ class ConfirmationDeclinedError < ActiveDenialError
177
+ # The canonical agent-facing description. The hosted `error_description`
178
+ # wins when the service sends one.
179
+ DESCRIPTION = "The user declined this action on the hosted confirmation page. " \
180
+ "Do not retry it unless the user asks you to."
181
+
182
+ # @return [Hash] the strictly-typed decline: action_session_id,
183
+ # declined_at, hold_until, scope (Strings), and method, endpoint,
184
+ # request_digest, summary (String or nil).
185
+ attr_reader :declined
186
+ # @return [String, nil] why a presented attestation was not accepted.
187
+ attr_reader :attestation_status
188
+
189
+ def initialize(message = DESCRIPTION, declined:, attestation_status: nil, data: {})
190
+ super(message, code: "confirmation_declined", data: data)
191
+ @declined = declined
192
+ @attestation_status = attestation_status
193
+ end
194
+
195
+ # The 403 body: the refusal, the human-readable description, the decline,
196
+ # and the hosted attestation diagnostics when present. Nothing else from
197
+ # the wire -- a refusal must not leak identity or scope state.
198
+ def denial_body
199
+ body = { "error" => "confirmation_declined",
200
+ "error_description" => message,
201
+ "declined" => declined }
202
+ %w[attestation_status attestation_description renewal].each do |field|
203
+ body[field] = data[field] if data[field].is_a?(String)
204
+ end
205
+ body
206
+ end
207
+ end
208
+
157
209
  class IntrospectionError < Error; end
158
210
  class ConfigurationError < Error; end
159
211
 
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.11.0
4
+ version: 1.12.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-09-15 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json