v1.68.3.0 fix(pairing): re-pair to narrow revokes the old grant on the spot (#2665)

* fix(pairing): reject reserved clientId 'root' at all token writers

'root' is the sentinel checkScope/checkDomain/checkRate and the server
command gate use for the omnipotent caller, so a scoped token carrying it
bypasses every enforcement path. Add ReservedClientIdError + a shared
assertValidClientId; createToken/createSetupKey throw, restoreRegistry
skips-and-logs (a corrupt state file must not brick boot). /pair and /token
surface it as a named 400, and the CLI fast-fails --client root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pairing): release tab ownership on revoke

tabOwnership cleared only on tab close, so after DELETE /token a same-name
re-pair inherited the revoked agent's authenticated tabs (own-only access
keys on owner === clientId). Add BrowserManager.releaseClientTabs and run it
unconditionally in DELETE /token (ownership outlives the token, so an
expired-token client can still own tabs); 404 only when both nothing was
revoked and nothing released. Response now carries tabs_released.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v1.68.3.0 fix(pairing): re-pair to narrow revokes the old grant on the spot

POST /pair minted a new setup key but never touched the agent's live
session, so re-pairing --client X --restrict read while X was connected (or
whose 5-min key expired unexchanged) left the original full-access session,
eval included, alive up to 24h.

A reducing re-pair (fewer scopes, tighter domains, lower rate, stricter tab
policy) now revokes the live session and releases its tabs before minting
the new key (grantReducesAccess + revokeClientFully; superseded in the
response). Non-reducing re-pairs keep the session and only drop stale PENDING
setup keys, so a broaden/refresh never strands a working agent and a
narrowing re-pair issued before the agent connects can't leave the old broad
key exchangeable. Revoke happens before mint (revokeToken deletes all of a
client's tokens). CLI prints a version-skew-safe supersede notice and warns
when a re-pair-shaped call omits --client. Docs + CHANGELOG + VERSION.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pairing): harden re-pair per adversarial review

Adversarial review of the diff found four issues, now fixed:
- Validate the requested grant BEFORE the supersede revoke: a reducing
  re-pair with a bad scope/rate no longer destroys the live session and
  then fails to mint a replacement (assertValidTokenOptions runs up front).
- A re-pair with no live session releases tabs orphaned by an expired
  incarnation, closing the tab-inheritance gap /pair had (DELETE /token
  already released unconditionally).
- Test the DELETE /token revoked=0/tabs>0 path and the /pair orphaned-tab
  release at the handler level (HTTP e2e can't, headless owns no tabs).
- Test the CLI --client root fast-fail; fix its null-guard (parseFlag
  returns null when --client is absent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garry@ycombinator.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-21 15:32:26 -07:00
committed by GitHub
co-authored by Claude Fable 5 Garry Tan
parent 51932eceef
commit 85fd9db554
15 changed files with 592 additions and 18 deletions
+89 -1
View File
@@ -6,7 +6,9 @@ import {
revokeToken, rotateRoot, listTokens, recordCommand,
serializeRegistry, restoreRegistry, checkConnectRateLimit,
SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META,
DEFAULT_PAIR_SCOPES, InvalidScopeError,
DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError,
revokeSetupKeys, getClientSession, grantReducesAccess,
type TokenInfo, type ResolvedGrant,
__resetRegistry,
} from '../src/token-registry';
@@ -19,6 +21,92 @@ describe('token-registry', () => {
initRegistry('root-token-for-tests');
});
// D2: `root` is the sentinel checkScope/checkDomain/checkRate use for the
// omnipotent caller; a scoped token carrying it bypasses all enforcement.
describe('reserved clientId (D2)', () => {
it('createToken rejects clientId "root" and lookalikes', () => {
expect(() => createToken({ clientId: 'root' })).toThrow(ReservedClientIdError);
expect(() => createToken({ clientId: 'ROOT' })).toThrow(ReservedClientIdError);
expect(() => createToken({ clientId: ' root ' })).toThrow(ReservedClientIdError);
});
it('createToken rejects empty / whitespace clientId', () => {
expect(() => createToken({ clientId: '' })).toThrow(ReservedClientIdError);
expect(() => createToken({ clientId: ' ' })).toThrow(ReservedClientIdError);
});
it('createSetupKey rejects clientId "root" but allows an omitted one', () => {
expect(() => createSetupKey({ clientId: 'root' })).toThrow(ReservedClientIdError);
// Omitted clientId gets a safe generated default, not a throw.
const key = createSetupKey({});
expect(key.clientId.startsWith('remote-')).toBe(true);
});
it('revokeSetupKeys drops only PENDING keys, keeping the spent key and the session', () => {
const k1 = createSetupKey({ clientId: 'x' }); // pending
exchangeSetupKey(k1.token); // k1 now spent + a session exists
createSetupKey({ clientId: 'x' }); // pending k2
expect(revokeSetupKeys('x')).toBe(1); // only the pending k2
expect(getClientSession('x')).not.toBeNull(); // session kept
// The spent key survives for idempotent re-exchange (#2646).
expect(exchangeSetupKey(k1.token)).not.toBeNull();
});
it('restoreRegistry skips a persisted "root" entry instead of injecting a bypass token', () => {
restoreRegistry({ agents: {
root: { token: 'gsk_sess_evil', type: 'session', scopes: ['read', 'write', 'admin', 'meta', 'control'], tabPolicy: 'shared', rateLimit: 0, expiresAt: null, createdAt: new Date().toISOString() } as any,
good: { token: 'gsk_sess_good', type: 'session', scopes: ['read'], tabPolicy: 'own-only', rateLimit: 10, expiresAt: null, createdAt: new Date().toISOString() } as any,
} });
// The evil root entry is dropped; the valid one still restores.
expect(validateToken('gsk_sess_evil')).toBeNull();
const good = validateToken('gsk_sess_good');
expect(good?.clientId).toBe('good');
});
});
// D1: drives the /pair supersede decision. Direction matters — dropping an
// allowlisted domain is the reduction, not adding one; 0 = unlimited rate.
describe('grantReducesAccess (D1)', () => {
const prior = (o: Partial<TokenInfo> = {}): TokenInfo => ({
token: 't', clientId: 'c', type: 'session',
scopes: ['read', 'write', 'admin', 'meta'], tabPolicy: 'own-only',
rateLimit: 10, expiresAt: null, createdAt: '', commandCount: 0, ...o,
});
const grant = (o: Partial<ResolvedGrant> = {}): ResolvedGrant => ({
scopes: ['read', 'write', 'admin', 'meta'], rateLimit: 10, tabPolicy: 'own-only', ...o,
});
it('scopes: drop → reduce; add/equal → not; dropping control → reduce', () => {
expect(grantReducesAccess(prior({ scopes: ['read', 'write', 'admin', 'meta'] }), grant({ scopes: ['read'] }))).toBe(true);
expect(grantReducesAccess(prior({ scopes: ['read'] }), grant({ scopes: ['read', 'write'] }))).toBe(false);
expect(grantReducesAccess(prior({ scopes: ['read'] }), grant({ scopes: ['read'] }))).toBe(false);
expect(grantReducesAccess(prior({ scopes: ['read', 'control'] }), grant({ scopes: ['read'] }))).toBe(true);
});
it('domains: drop → reduce; add/equal → not; unrestricted→restricted → reduce; glob narrowing → reduce', () => {
expect(grantReducesAccess(prior({ domains: ['a.com', 'b.com'] }), grant({ domains: ['a.com'] }))).toBe(true);
expect(grantReducesAccess(prior({ domains: ['a.com'] }), grant({ domains: ['a.com', 'b.com'] }))).toBe(false);
expect(grantReducesAccess(prior({ domains: ['a.com'] }), grant({ domains: ['a.com'] }))).toBe(false);
expect(grantReducesAccess(prior({ domains: undefined }), grant({ domains: ['a.com'] }))).toBe(true);
expect(grantReducesAccess(prior({ domains: ['a.com'] }), grant({ domains: undefined }))).toBe(false);
expect(grantReducesAccess(prior({ domains: ['*.example.com'] }), grant({ domains: ['*.com'] }))).toBe(false); // widen
expect(grantReducesAccess(prior({ domains: ['*.com'] }), grant({ domains: ['*.example.com'] }))).toBe(true); // narrow
});
it('rate (0 = unlimited): unlimited→capped → reduce; lower cap → reduce; higher/equal → not', () => {
expect(grantReducesAccess(prior({ rateLimit: 0 }), grant({ rateLimit: 10 }))).toBe(true);
expect(grantReducesAccess(prior({ rateLimit: 10 }), grant({ rateLimit: 5 }))).toBe(true);
expect(grantReducesAccess(prior({ rateLimit: 5 }), grant({ rateLimit: 10 }))).toBe(false);
expect(grantReducesAccess(prior({ rateLimit: 10 }), grant({ rateLimit: 10 }))).toBe(false);
expect(grantReducesAccess(prior({ rateLimit: 10 }), grant({ rateLimit: 0 }))).toBe(false); // → unlimited = broaden
});
it('tabPolicy: shared → own-only → reduce; the reverse → not', () => {
expect(grantReducesAccess(prior({ tabPolicy: 'shared' }), grant({ tabPolicy: 'own-only' }))).toBe(true);
expect(grantReducesAccess(prior({ tabPolicy: 'own-only' }), grant({ tabPolicy: 'shared' }))).toBe(false);
});
});
describe('root token', () => {
it('identifies root token correctly', () => {
expect(isRootToken('root-token-for-tests')).toBe(true);