Skip to content

Commit 7aba60d

Browse files
committed
Implement v1.1 provisioned credentials
1 parent d0dea78 commit 7aba60d

34 files changed

Lines changed: 1146 additions & 33 deletions

CONFORMANCE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ v1.0.0). No spec MUST/SHOULD in §4–§16 is unimplemented.
1616
| §6.1 Bearer auth scheme | yes | `lib/arcp/auth/bearer.rb` |
1717
| §6.1 Pluggable AuthScheme | yes | `lib/arcp/auth/auth_scheme.rb` |
1818
| §6.2 Capability negotiation (intersection) | yes | `lib/arcp/session/capability_set.rb#intersect` |
19-
| §6.2 Feature names: heartbeat, ack, list_jobs, subscribe, lease_expires_at, cost.budget, progress, result_chunk, agent_versions | yes | `lib/arcp/session/feature.rb` |
19+
| §6.2 Feature names: heartbeat, ack, list_jobs, subscribe, lease_expires_at, cost.budget, progress, result_chunk, agent_versions, model.use, provisioned_credentials | yes | `lib/arcp/session/feature.rb` |
2020
| §6.3 session.welcome with resume_token + resume_window_sec | yes | `lib/arcp/session/welcome.rb`, `lib/arcp/runtime/session_actor.rb` |
2121
| §6.3 Resume by last_event_seq | yes | `lib/arcp/runtime/event_log.rb` |
2222
| §6.4 session.ping / session.pong heartbeats | yes | `lib/arcp/session/ping.rb`, `lib/arcp/session/pong.rb`, `lib/arcp/client.rb#start_heartbeat!` |
@@ -51,6 +51,9 @@ v1.0.0). No spec MUST/SHOULD in §4–§16 is unimplemented.
5151
| §9.6 cost.budget capability (BigDecimal per currency) | yes | `lib/arcp/lease.rb#CostBudget` |
5252
| §9.6 BudgetCounter try_decrement | yes | `lib/arcp/lease.rb#BudgetCounter` |
5353
| §9.6 BUDGET_EXHAUSTED on overspend | yes | `lib/arcp/runtime/lease_manager.rb` |
54+
| §9.7 model.use capability and lease checks | yes | `lib/arcp/lease.rb`, `lib/arcp/runtime/lease_manager.rb#check_model!` |
55+
| §9.8 provisioned credential wire shape | yes | `lib/arcp/credential.rb`, `lib/arcp/job/accepted.rb` |
56+
| §9.8 credential provisioner and revocation lifecycle | yes | `lib/arcp/credential_provisioner.rb`, `lib/arcp/runtime/credential_registry.rb`, `lib/arcp/runtime/job_manager.rb` |
5457
| §10 Delegate event kind with child lease | yes | `lib/arcp/job/event_body/delegate.rb` |
5558
| §10 LEASE_SUBSET_VIOLATION on excess | yes | `lib/arcp/lease.rb#Subsetting.bound` |
5659
| §11 trace_id propagation on envelope | yes | `lib/arcp/envelope.rb`, `lib/arcp/client.rb#send_envelope` |

docs/concepts/leases.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ spec_sections: [§9]
1111
## What
1212

1313
A lease is the runtime's grant of authority to a job: a set of
14-
capabilities, an optional expiry, and an optional per-currency budget.
14+
capabilities, an optional expiry, optional model patterns, and an
15+
optional per-currency budget.
1516
The runtime issues one on `job.accepted` and attaches it to the job
1617
context. Delegation requires the child lease to be a strict subset.
1718

@@ -47,13 +48,37 @@ budget.remaining('USD') # => BigDecimal('1.00')
4748
`BudgetCounter#try_spend!` atomically decrements; overspend raises
4849
`Arcp::Errors::BudgetExhausted`.
4950

51+
## model.use
52+
53+
```ruby
54+
lease_request = Arcp::Lease::LeaseRequest.new(
55+
capabilities: ['cost.spend'],
56+
model_use: ['tier-fast/*']
57+
)
58+
```
59+
60+
`model.use` is a set of glob patterns for model ids. Runtime code in the
61+
path of an LLM call can enforce it with:
62+
63+
```ruby
64+
$arcp_runtime.lease_manager.check_model!(
65+
ctx.job_id,
66+
model_id: 'tier-fast/gpt-4o-mini'
67+
)
68+
```
69+
70+
A miss raises `Arcp::Errors::PermissionDenied`. Delegate subsetting also
71+
checks `model.use`; a child may keep the same pattern or narrow a parent
72+
glob to a literal model id.
73+
5074
## Subsetting on delegate
5175

5276
```ruby
5377
parent = $arcp_runtime.lease_manager.get(ctx.job_id)
5478
child_request = Arcp::Lease::LeaseRequest.new(
5579
capabilities: ['compute.read'],
5680
budget: Arcp::Lease::CostBudget.parse(['USD:0.25']),
81+
model_use: ['tier-fast/gpt-4o-mini'],
5782
expires_at: nil
5883
)
5984
child = Arcp::Lease::Subsetting.bound(parent: parent, request: child_request)
@@ -66,3 +91,4 @@ above parent's remaining all raise `Arcp::Errors::LeaseSubsetViolation`.
6691

6792
- `concepts/delegation.md`
6893
- `guides/budgets.md`
94+
- `guides/credentials.md`

docs/guides/budgets.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ end
4646
balance goes negative, the runtime emits `job.error` with code
4747
`BUDGET_EXHAUSTED`.
4848

49+
When spend is enforced by an upstream gateway instead of local counters,
50+
configure provisioned credentials so `cost.budget` is baked into the issued
51+
key. See `docs/guides/credentials.md`.
52+
4953
## Client-side exhaustion
5054

5155
```ruby

docs/guides/credentials.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
---
2+
title: Provisioned credentials
3+
sdk: ruby
4+
kind: guide
5+
order: 22
6+
spec_sections: [§9.7, §9.8]
7+
---
8+
9+
# Provisioned credentials
10+
11+
Provisioned credentials let a runtime mint short-lived upstream keys for a
12+
job after the lease is finalized. The key is returned only on
13+
`job.accepted`, scoped by the lease, and revoked when the job terminates.
14+
15+
## Configure the runtime
16+
17+
```ruby
18+
provisioner = Arcp::Credentials::InMemoryProvisioner.new(
19+
endpoint: 'https://llm-gateway.example/v1',
20+
profile: 'openai'
21+
)
22+
23+
runtime = Arcp::Runtime::Runtime.new(
24+
auth_verifier: auth,
25+
credential_provisioner: provisioner,
26+
credential_store: Arcp::Credentials::InMemoryStore.new
27+
)
28+
```
29+
30+
When a provisioner is configured, the runtime advertises the
31+
`model.use` and `provisioned_credentials` features during capability
32+
negotiation. Without a provisioner, both features are omitted.
33+
34+
## Request model access
35+
36+
```ruby
37+
handle = client.submit_job(
38+
agent: 'gateway-caller',
39+
lease_request: Arcp::Lease::LeaseRequest.new(
40+
capabilities: ['cost.spend'],
41+
budget: Arcp::Lease::CostBudget.parse(['USD:1.00']),
42+
model_use: ['tier-fast/*']
43+
)
44+
)
45+
46+
credential = handle.credential_for(endpoint: 'https://llm-gateway.example/v1')
47+
```
48+
49+
The runtime copies `cost.budget`, `model.use`, and `expires_at` into the
50+
credential constraints so an upstream gateway can enforce the same bounds.
51+
52+
## Implement a provisioner
53+
54+
```ruby
55+
class LiteLLMProvisioner
56+
include Arcp::CredentialProvisioner
57+
58+
def issue(lease:, job_id:, agent:, principal_id:)
59+
response = generate_litellm_key(
60+
budget: lease.budget&.to_a,
61+
models: lease.model_use,
62+
expires_at: lease.expires_at
63+
)
64+
65+
[
66+
Arcp::Credential.new(
67+
id: response.fetch('key_alias'),
68+
scheme: Arcp::Credential::SCHEME_BEARER,
69+
value: response.fetch('key'),
70+
endpoint: 'https://llm-gateway.example/v1',
71+
profile: 'openai',
72+
constraints: {
73+
'cost.budget' => lease.budget&.to_a,
74+
'model.use' => lease.model_use,
75+
'expires_at' => lease.expires_at
76+
}.compact
77+
)
78+
]
79+
end
80+
81+
def revoke(credential_id:)
82+
delete_litellm_key(credential_id)
83+
end
84+
end
85+
```
86+
87+
Vendor-specific HTTP clients should live outside the core gem. The SDK only
88+
defines the interface and value objects.
89+
90+
When an upstream gateway reports budget exhaustion, map it back to the ARCP
91+
error boundary:
92+
93+
```ruby
94+
begin
95+
call_gateway(credential)
96+
rescue StandardError => e
97+
raise Arcp::Credentials.translate_upstream_error(e)
98+
end
99+
```
100+
101+
## Rotation and revocation
102+
103+
Agents can rotate a credential value mid-job:
104+
105+
```ruby
106+
ctx.rotate_credential(id: 'cred_job_123_0', new_value: 'sk-new-value')
107+
```
108+
109+
That emits a `status` event with `phase: 'credential_rotated'` and a
110+
`fields` hash containing the new `{ id, value }`. Treat this event as
111+
secret-bearing data.
112+
113+
The runtime revokes outstanding credential ids on success, error,
114+
cancellation, and timeout. `CredentialRegistry` retries transient revoke
115+
failures once and keeps any failed id in the configured store for later
116+
reconciliation.
117+
118+
## Security notes
119+
120+
`Credential#to_h` is the wire representation and includes `value`.
121+
Use `Credential#to_redacted_h` for logs, metrics, and examples.
122+
`session.list_jobs` summaries never include credentials.

lib/arcp.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
require_relative 'arcp/envelope'
1010
require_relative 'arcp/trace'
1111
require_relative 'arcp/lease'
12+
require_relative 'arcp/credential'
13+
require_relative 'arcp/credential_provisioner'
1214
require_relative 'arcp/session'
1315
require_relative 'arcp/job'
1416
require_relative 'arcp/auth'

lib/arcp/client.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,9 @@ def submit_job(agent:, input: nil, lease_request: nil, lease_constraints: nil,
140140
accepted = Arcp::Job::Accepted.from_h(accepted_env.payload)
141141
Arcp::Job::Handle.new(
142142
job_id: accepted.job_id, agent: accepted.agent,
143-
submitted_at: accepted.accepted_at, lease: accepted.lease
143+
submitted_at: accepted.accepted_at,
144+
lease: accepted.lease,
145+
credentials: accepted.credentials
144146
)
145147
end
146148

lib/arcp/credential.rb

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
module Arcp
4+
Credential = Data.define(:id, :scheme, :value, :endpoint, :profile, :constraints) do
5+
def initialize(id:, scheme:, value:, endpoint:, profile: nil, constraints: nil)
6+
super(
7+
id: id,
8+
scheme: scheme,
9+
value: value,
10+
endpoint: endpoint,
11+
profile: profile,
12+
constraints: constraints || {}
13+
)
14+
end
15+
16+
def self.from_h(h)
17+
h = h.transform_keys(&:to_s)
18+
new(
19+
id: h.fetch('id'),
20+
scheme: h.fetch('scheme'),
21+
value: h.fetch('value'),
22+
endpoint: h.fetch('endpoint'),
23+
profile: h['profile'],
24+
constraints: h['constraints'] || {}
25+
)
26+
end
27+
28+
def to_h
29+
out = { 'id' => id, 'scheme' => scheme, 'value' => value, 'endpoint' => endpoint }
30+
out['profile'] = profile if profile
31+
out['constraints'] = constraints if constraints && !constraints.empty?
32+
out
33+
end
34+
35+
def to_redacted_h
36+
to_h.merge('value' => '[REDACTED]')
37+
end
38+
end
39+
40+
Credential.const_set(:SCHEME_BEARER, 'bearer') unless Credential.const_defined?(:SCHEME_BEARER)
41+
42+
module ModelPattern
43+
FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
44+
45+
module_function
46+
47+
def match?(patterns, model_id)
48+
Array(patterns).any? { |pattern| File.fnmatch?(pattern, model_id, FLAGS) }
49+
end
50+
51+
def implied_by?(parent_patterns, child_pattern)
52+
Array(parent_patterns).any? do |parent|
53+
child_pattern == parent || literal_match?(parent, child_pattern)
54+
end
55+
end
56+
57+
def literal_match?(parent_pattern, child_pattern)
58+
!glob?(child_pattern) && match?([parent_pattern], child_pattern)
59+
end
60+
61+
def glob?(pattern)
62+
pattern.match?(/[*?\[\]{}]/)
63+
end
64+
end
65+
end

0 commit comments

Comments
 (0)