mirror of https://github.com/garrytan/gstack.git
feat: default paired agents to full access, split SCOPE_CONTROL
The trust boundary for paired agents is the pairing ceremony itself, not the scope. An agent with write scope can already click anything and navigate anywhere. Gating js/cookies behind --admin was security theater. Changes: - Default pair scopes: read+write+admin+meta (was read+write) - New SCOPE_CONTROL for browser-wide destructive ops (stop, restart, disconnect, state, handoff, resume, connect) - --admin flag now grants control scope (backward compat) - New --restrict flag for limited access (e.g., --restrict read) - Updated hint text: "re-pair with --control" instead of "--admin" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
73f5d0b77d
commit
a6e0277fc4
|
|
@ -566,7 +566,7 @@ COMMAND REFERENCE:
|
||||||
New tab: {"command": "newtab", "args": ["URL"]}
|
New tab: {"command": "newtab", "args": ["URL"]}
|
||||||
|
|
||||||
SCOPES: ${scopeDesc}.
|
SCOPES: ${scopeDesc}.
|
||||||
${scopes.includes('admin') ? '' : `To get admin access (JS, cookies, storage), ask the user to re-pair with --admin.\n`}
|
${scopes.includes('control') ? '' : `To get browser control access (stop, restart, disconnect), ask the user to re-pair with --control.\n`}
|
||||||
TOKEN: Expires ${expiresAt}. Revoke: ask the user to run
|
TOKEN: Expires ${expiresAt}. Revoke: ask the user to run
|
||||||
$B tunnel revoke <your-name>
|
$B tunnel revoke <your-name>
|
||||||
|
|
||||||
|
|
@ -591,10 +591,13 @@ function hasFlag(args: string[], flag: string): boolean {
|
||||||
async function handlePairAgent(state: ServerState, args: string[]): Promise<void> {
|
async function handlePairAgent(state: ServerState, args: string[]): Promise<void> {
|
||||||
const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`;
|
const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`;
|
||||||
const domains = parseFlag(args, '--domain')?.split(',').map(d => d.trim());
|
const domains = parseFlag(args, '--domain')?.split(',').map(d => d.trim());
|
||||||
const admin = hasFlag(args, '--admin');
|
const control = hasFlag(args, '--control') || hasFlag(args, '--admin');
|
||||||
|
const restrict = parseFlag(args, '--restrict');
|
||||||
const localHost = parseFlag(args, '--local');
|
const localHost = parseFlag(args, '--local');
|
||||||
|
|
||||||
// Call POST /pair to create a setup key
|
// Call POST /pair to create a setup key
|
||||||
|
// Default: full access (read+write+admin+meta). --control adds browser-wide ops.
|
||||||
|
// --restrict limits: --restrict read (read-only), --restrict "read,write" (no admin)
|
||||||
const pairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, {
|
const pairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -603,9 +606,9 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
domains,
|
domains,
|
||||||
|
|
||||||
clientId: clientName,
|
clientId: clientName,
|
||||||
admin,
|
control,
|
||||||
|
...(restrict ? { scopes: restrict.split(',').map(s => s.trim()) } : {}),
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1455,9 +1455,12 @@ async function start() {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const pairBody = await req.json() as any;
|
const pairBody = await req.json() as any;
|
||||||
const scopes = pairBody.admin
|
// Default: full access (read+write+admin+meta). The trust boundary is
|
||||||
? ['read', 'write', 'admin', 'meta'] as const
|
// the pairing ceremony itself, not the scope. --control adds browser-wide
|
||||||
: (pairBody.scopes || ['read', 'write']) as const;
|
// destructive commands (stop, restart, disconnect). --restrict limits scope.
|
||||||
|
const scopes = pairBody.control || pairBody.admin
|
||||||
|
? ['read', 'write', 'admin', 'meta', 'control'] as const
|
||||||
|
: (pairBody.scopes || ['read', 'write', 'admin', 'meta']) as const;
|
||||||
const setupKey = createSetupKey({
|
const setupKey = createSetupKey({
|
||||||
clientId: pairBody.clientId,
|
clientId: pairBody.clientId,
|
||||||
scopes: [...scopes],
|
scopes: [...scopes],
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,16 @@ export const SCOPE_WRITE = new Set([
|
||||||
'dialog-accept', 'dialog-dismiss',
|
'dialog-accept', 'dialog-dismiss',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Dangerous commands — JS execution, credential access, browser-wide mutations */
|
/** Page-level power tools — JS execution, credential access, page mutations */
|
||||||
export const SCOPE_ADMIN = new Set([
|
export const SCOPE_ADMIN = new Set([
|
||||||
'eval', 'js', 'cookies', 'storage',
|
'eval', 'js', 'cookies', 'storage',
|
||||||
'cookie', 'cookie-import', 'cookie-import-browser',
|
'cookie', 'cookie-import', 'cookie-import-browser',
|
||||||
'header', 'useragent',
|
'header', 'useragent',
|
||||||
'style', 'cleanup', 'prettyscreenshot',
|
'style', 'cleanup', 'prettyscreenshot',
|
||||||
// Browser-wide destructive commands (from Codex adversarial finding):
|
]);
|
||||||
|
|
||||||
|
/** Browser-wide destructive commands — can kill the server, disconnect headed mode */
|
||||||
|
export const SCOPE_CONTROL = new Set([
|
||||||
'state', 'handoff', 'resume', 'stop', 'restart', 'connect', 'disconnect',
|
'state', 'handoff', 'resume', 'stop', 'restart', 'connect', 'disconnect',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -66,12 +69,13 @@ export const SCOPE_META = new Set([
|
||||||
'watch', 'inbox', 'focus',
|
'watch', 'inbox', 'focus',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type ScopeCategory = 'read' | 'write' | 'admin' | 'meta';
|
export type ScopeCategory = 'read' | 'write' | 'admin' | 'meta' | 'control';
|
||||||
|
|
||||||
const SCOPE_MAP: Record<ScopeCategory, Set<string>> = {
|
const SCOPE_MAP: Record<ScopeCategory, Set<string>> = {
|
||||||
read: SCOPE_READ,
|
read: SCOPE_READ,
|
||||||
write: SCOPE_WRITE,
|
write: SCOPE_WRITE,
|
||||||
admin: SCOPE_ADMIN,
|
admin: SCOPE_ADMIN,
|
||||||
|
control: SCOPE_CONTROL,
|
||||||
meta: SCOPE_META,
|
meta: SCOPE_META,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -170,7 +174,7 @@ export function createToken(opts: CreateTokenOptions): TokenInfo {
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta'];
|
const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta', 'control'];
|
||||||
for (const s of scopes) {
|
for (const s of scopes) {
|
||||||
if (!validScopes.includes(s as ScopeCategory)) {
|
if (!validScopes.includes(s as ScopeCategory)) {
|
||||||
throw new Error(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`);
|
throw new Error(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`);
|
||||||
|
|
@ -297,7 +301,7 @@ export function validateToken(token: string): TokenInfo | null {
|
||||||
token: rootToken,
|
token: rootToken,
|
||||||
clientId: 'root',
|
clientId: 'root',
|
||||||
type: 'session',
|
type: 'session',
|
||||||
scopes: ['read', 'write', 'admin', 'meta'],
|
scopes: ['read', 'write', 'admin', 'meta', 'control'],
|
||||||
tabPolicy: 'shared',
|
tabPolicy: 'shared',
|
||||||
rateLimit: 0, // unlimited
|
rateLimit: 0, // unlimited
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
|
|
|
||||||
|
|
@ -113,15 +113,15 @@ describe('generateInstructionBlock', () => {
|
||||||
expect(block).not.toContain('re-pair with --admin');
|
expect(block).not.toContain('re-pair with --admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows re-pair hint when admin not included', () => {
|
it('shows re-pair hint when control not included', () => {
|
||||||
const block = generateInstructionBlock({
|
const block = generateInstructionBlock({
|
||||||
setupKey: 'gsk_setup_nonadmin',
|
setupKey: 'gsk_setup_nocontrol',
|
||||||
serverUrl: 'https://test.ngrok.dev',
|
serverUrl: 'https://test.ngrok.dev',
|
||||||
scopes: ['read', 'write'],
|
scopes: ['read', 'write', 'admin', 'meta'],
|
||||||
expiresAt: '2026-04-06T00:00:00Z',
|
expiresAt: '2026-04-06T00:00:00Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(block).toContain('re-pair with --admin');
|
expect(block).toContain('re-pair with --control');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes newtab as step 2 (agents must own their tab)', () => {
|
it('includes newtab as step 2 (agents must own their tab)', () => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
validateToken, checkScope, checkDomain, checkRate,
|
validateToken, checkScope, checkDomain, checkRate,
|
||||||
revokeToken, rotateRoot, listTokens, recordCommand,
|
revokeToken, rotateRoot, listTokens, recordCommand,
|
||||||
serializeRegistry, restoreRegistry, checkConnectRateLimit,
|
serializeRegistry, restoreRegistry, checkConnectRateLimit,
|
||||||
SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_META,
|
SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META,
|
||||||
} from '../src/token-registry';
|
} from '../src/token-registry';
|
||||||
|
|
||||||
describe('token-registry', () => {
|
describe('token-registry', () => {
|
||||||
|
|
@ -25,7 +25,7 @@ describe('token-registry', () => {
|
||||||
const info = validateToken('root-token-for-tests');
|
const info = validateToken('root-token-for-tests');
|
||||||
expect(info).not.toBeNull();
|
expect(info).not.toBeNull();
|
||||||
expect(info!.clientId).toBe('root');
|
expect(info!.clientId).toBe('root');
|
||||||
expect(info!.scopes).toEqual(['read', 'write', 'admin', 'meta']);
|
expect(info!.scopes).toEqual(['read', 'write', 'admin', 'meta', 'control']);
|
||||||
expect(info!.rateLimit).toBe(0);
|
expect(info!.rateLimit).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -324,7 +324,7 @@ describe('token-registry', () => {
|
||||||
it('every command in commands.ts is covered by a scope', () => {
|
it('every command in commands.ts is covered by a scope', () => {
|
||||||
// Import the command sets to verify coverage
|
// Import the command sets to verify coverage
|
||||||
const allInScopes = new Set([
|
const allInScopes = new Set([
|
||||||
...SCOPE_READ, ...SCOPE_WRITE, ...SCOPE_ADMIN, ...SCOPE_META,
|
...SCOPE_READ, ...SCOPE_WRITE, ...SCOPE_ADMIN, ...SCOPE_CONTROL, ...SCOPE_META,
|
||||||
]);
|
]);
|
||||||
// chain is a special case (checked via meta scope but dispatches subcommands)
|
// chain is a special case (checked via meta scope but dispatches subcommands)
|
||||||
allInScopes.add('chain');
|
allInScopes.add('chain');
|
||||||
|
|
@ -339,8 +339,12 @@ describe('token-registry', () => {
|
||||||
expect(SCOPE_ADMIN.has('cookies')).toBe(true);
|
expect(SCOPE_ADMIN.has('cookies')).toBe(true);
|
||||||
expect(SCOPE_ADMIN.has('storage')).toBe(true);
|
expect(SCOPE_ADMIN.has('storage')).toBe(true);
|
||||||
expect(SCOPE_ADMIN.has('useragent')).toBe(true);
|
expect(SCOPE_ADMIN.has('useragent')).toBe(true);
|
||||||
expect(SCOPE_ADMIN.has('state')).toBe(true);
|
// Browser-wide destructive commands moved to SCOPE_CONTROL
|
||||||
expect(SCOPE_ADMIN.has('handoff')).toBe(true);
|
expect(SCOPE_CONTROL.has('state')).toBe(true);
|
||||||
|
expect(SCOPE_CONTROL.has('handoff')).toBe(true);
|
||||||
|
expect(SCOPE_CONTROL.has('stop')).toBe(true);
|
||||||
|
expect(SCOPE_CONTROL.has('restart')).toBe(true);
|
||||||
|
expect(SCOPE_CONTROL.has('disconnect')).toBe(true);
|
||||||
|
|
||||||
// Verify safe read commands are NOT in admin
|
// Verify safe read commands are NOT in admin
|
||||||
expect(SCOPE_ADMIN.has('text')).toBe(false);
|
expect(SCOPE_ADMIN.has('text')).toBe(false);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue