mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-20 20:00:45 +02:00
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
65b967c53a
commit
6fe3e67736
@@ -7,6 +7,8 @@ import {
|
||||
serializeRegistry, restoreRegistry, checkConnectRateLimit,
|
||||
SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META,
|
||||
DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError,
|
||||
revokeSetupKeys, getClientSession, grantReducesAccess,
|
||||
type TokenInfo, type ResolvedGrant,
|
||||
__resetRegistry,
|
||||
} from '../src/token-registry';
|
||||
|
||||
@@ -40,6 +42,16 @@ describe('token-registry', () => {
|
||||
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,
|
||||
@@ -52,6 +64,49 @@ describe('token-registry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user