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>
This commit is contained in:
Garry Tan
2026-08-21 15:15:11 -07:00
committed by Garry Tan
co-authored by Claude Fable 5
parent 51932eceef
commit 783504d7ea
5 changed files with 110 additions and 8 deletions
+34 -1
View File
@@ -6,7 +6,7 @@ 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,
__resetRegistry,
} from '../src/token-registry';
@@ -19,6 +19,39 @@ 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('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');
});
});
describe('root token', () => {
it('identifies root token correctly', () => {
expect(isRootToken('root-token-for-tests')).toBe(true);