src/Internal/Runtime/CredentialLifecycle.php:99 resolves the lease argument of an incoming tool.invoke by calling LeaseManager::get(new LeaseId($leaseArg)). src/Runtime/LeaseManager.php:39 indexes leases only by id and never records or checks the session or principal that the lease was granted to. A peer can therefore submit a tool.invoke whose arguments.lease (or arguments.lease.lease_id) names a lease that belongs to a completely different session and inherit that lease's permission, resource, operation, expiresAt, modelUse, and costBudget. The lease then becomes the base lease for the new job, overlaying it with the attacker's own cost.budget and model.use while keeping the victim's authority scope.
This is a privilege-escalation primitive in the credential-provisioning path: when a CredentialProvisioner is configured, the issued credentials are issued against the smuggled lease scope, and the runtime still routes credential rotation and revocation to the original session via the credential store. The bug is not surfaced by the current credential lifecycle integration tests because they all use one session.
Fix prompt: Record the granting session id (or principal) on each LeaseGranted registration in LeaseManager and have LeaseManager::get() (or a new getForSession() overload) refuse to return a lease that was not granted to the requesting session. In CredentialLifecycle::baseLease(), pass the active session through and translate any mismatch into PERMISSION_DENIED. Add an integration test that grants a lease in session A and asserts session B's tool.invoke with arguments.lease = {lease_id: ...} receives PERMISSION_DENIED.
src/Internal/Runtime/CredentialLifecycle.php:99resolves theleaseargument of an incomingtool.invokeby callingLeaseManager::get(new LeaseId($leaseArg)).src/Runtime/LeaseManager.php:39indexes leases only by id and never records or checks the session or principal that the lease was granted to. A peer can therefore submit atool.invokewhosearguments.lease(orarguments.lease.lease_id) names a lease that belongs to a completely different session and inherit that lease'spermission,resource,operation,expiresAt,modelUse, andcostBudget. The lease then becomes the base lease for the new job, overlaying it with the attacker's owncost.budgetandmodel.usewhile keeping the victim's authority scope.This is a privilege-escalation primitive in the credential-provisioning path: when a
CredentialProvisioneris configured, the issued credentials are issued against the smuggled lease scope, and the runtime still routes credential rotation and revocation to the original session via the credential store. The bug is not surfaced by the current credential lifecycle integration tests because they all use one session.Fix prompt: Record the granting session id (or principal) on each
LeaseGrantedregistration inLeaseManagerand haveLeaseManager::get()(or a newgetForSession()overload) refuse to return a lease that was not granted to the requesting session. InCredentialLifecycle::baseLease(), pass the active session through and translate any mismatch intoPERMISSION_DENIED. Add an integration test that grants a lease in session A and asserts session B'stool.invokewitharguments.lease = {lease_id: ...}receivesPERMISSION_DENIED.