Skip to content

Commit 9e05378

Browse files
nficanocursoragent
andcommitted
fix: reject widening overlays on referenced leases (§9.4) (#61)
When a tool.invoke references an existing lease and overlays expires_at, model.use, or cost.budget, CredentialLifecycle::overlayLease() now validates the derived lease against the parent via ensureSubset(), rejecting any widening with LEASE_SUBSET_VIOLATION. The tool-invocation handler surfaces any lease ARCPException (permission denied, subset violation, ...) as a correlated tool.error. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7dee939 commit 9e05378

3 files changed

Lines changed: 50 additions & 2 deletions

File tree

src/Internal/Runtime/CredentialLifecycle.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ private function overlayLease(
133133
?ModelUse $modelUse,
134134
?CostBudget $costBudget,
135135
): LeaseGranted {
136-
return new LeaseGranted(
136+
$candidate = new LeaseGranted(
137137
$base->leaseId,
138138
$base->permission,
139139
$base->resource,
@@ -144,6 +144,11 @@ private function overlayLease(
144144
// lease does not alias (and mutate) the shared counter (§9.6).
145145
$costBudget ?? $base->costBudget?->snapshot(),
146146
);
147+
// §9.4: a lease derived from a referenced parent may only narrow it.
148+
// Reject any overlay that widens model.use, cost.budget, or expires_at.
149+
$this->runtime->leases->ensureSubset($base, $candidate);
150+
151+
return $candidate;
147152
}
148153

149154
private function expiresAt(mixed $leaseArg): ?\DateTimeImmutable

src/Internal/Runtime/ToolInvocationHandler.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ public function handle(Session $session, Envelope $env, ToolInvoke $msg): void
7171
}
7272
try {
7373
$lease = $this->credentials->leaseFromArguments($msg->arguments, $resolved, $session);
74-
} catch (\Arcp\Errors\PermissionDeniedException $e) {
74+
} catch (ARCPException $e) {
75+
// Lease resolution can fail with PERMISSION_DENIED (scope/owner),
76+
// LEASE_SUBSET_VIOLATION (widening overlay, §9.4), or another
77+
// lease error; surface any of them as a correlated tool.error.
7578
$this->runtime->emit($session, new ToolError(ErrorPayload::fromException($e)), [
7679
'correlation_id' => $env->id,
7780
'trace_id' => $env->traceId,

tests/Integration/PermissionLeaseTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,46 @@ public function invoke(array $arguments, JobContext $ctx, ?Cancellation $cancell
108108
$serverFuture->await();
109109
}
110110

111+
public function testReferencedLeaseOverlayCannotWidenBudget(): void
112+
{
113+
$runtime = new ARCPRuntime(authRouter: new AuthRouter([new NoneAuth()]));
114+
$runtime->registerTool('spend', new class () implements ToolHandler {
115+
#[\Override]
116+
public function invoke(array $arguments, JobContext $ctx, ?Cancellation $cancellation = null): mixed
117+
{
118+
return ['ok' => true];
119+
}
120+
});
121+
[$serverT, $clientT] = MemoryTransport::pair();
122+
$serverFuture = $runtime->serveAsync($serverT);
123+
$client = new ARCPClient($clientT);
124+
$client->open(Auth::none(), new PeerInfo('cli', '0.1'), new Capabilities(anonymous: true));
125+
126+
$leaseId = LeaseId::random();
127+
$runtime->leases->register(
128+
new LeaseGranted(
129+
$leaseId,
130+
'tool.invoke',
131+
'spend',
132+
'run',
133+
new \DateTimeImmutable('+5 minutes'),
134+
null,
135+
CostBudget::fromPatterns(['USD:0.50']),
136+
),
137+
$client->session->sessionId,
138+
);
139+
140+
// Overlay a wider budget than the parent grants: §9.4 forbids it.
141+
$args = ['lease' => ['lease_id' => (string) $leaseId, 'cost.budget' => ['USD:5.00']]];
142+
$this->expectException(\Arcp\Errors\LeaseSubsetViolationException::class);
143+
try {
144+
$client->invokeTool('spend', $args);
145+
} finally {
146+
$client->close();
147+
$serverFuture->await();
148+
}
149+
}
150+
111151
public function testPermissionDenyRaisesException(): void
112152
{
113153
$denyHandler = new class () implements PermissionHandler {

0 commit comments

Comments
 (0)