From 6fe3e67736c4cf2ba9dbde5c3d9885f452abd39e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 20 Aug 2026 22:25:27 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 34 ++++++++++ VERSION | 2 +- browse/src/cli.ts | 13 ++++ browse/src/server.ts | 33 ++++++++- browse/src/token-registry.ts | 104 +++++++++++++++++++++++++++++ browse/test/pair-agent-e2e.test.ts | 55 +++++++++++++++ browse/test/token-registry.test.ts | 55 +++++++++++++++ docs/REMOTE_BROWSER_ACCESS.md | 2 + package.json | 2 +- pair-agent/SKILL.md | 12 +++- pair-agent/SKILL.md.tmpl | 12 +++- 11 files changed, 317 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df5b486f6..a0f611701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## [1.68.3.0] - 2026-08-20 + +**Re-pairing a browser agent to narrow its access now revokes the old access on** +**the spot, revoke frees the agent's tabs, and `root` is a reserved client name.** + +Tightening a paired agent is supposed to be one re-pair away. It wasn't. `POST /pair` minted a fresh setup key but never touched the agent's live session, so `pair-agent --client codex --restrict read` against an agent that had already connected (or whose new 5-minute key simply expired unexchanged) left the original full-access session, `eval` included, alive for up to 24 hours. Revocation also never released tab ownership, so an agent re-paired under the same name inherited the previous one's authenticated tabs. And because `root` is the sentinel the scope, domain, rate, and tab checks use for the omnipotent caller, `--client root` minted a "scoped" token that skipped all of them. + +### The numbers that matter + +Source: the before/after `BROWSE_HEADLESS_SKIP=1` daemon transcript in the PR and the regression tests in `browse/test/pair-agent-e2e.test.ts`, `browse/test/token-registry.test.ts`, and `browse/test/tab-isolation.test.ts`, which fail on the previous release. + +| Property | Before | After | +|--------|--------|-------| +| Re-pair to narrow, agent hasn't reconnected | old wide session lives ~24h | old session 401s immediately | +| Narrowing re-pair before the agent connects | stale broad setup key still exchangeable | broad key dead, only the narrow key works | +| Broaden/refresh re-pair mid-task | old session lingers alongside a new key | working session kept, stale key dropped, no outage | +| Revoke a paired agent | tabs stay owned; same-name re-pair inherits them | tab ownership released; own-only access denied | +| `--client root` | "scoped" token bypasses all enforcement | rejected with a named 400 and a CLI fast-fail | + +### What this means for you + +Re-pair is now the real tightening lever. Re-pair an agent with its **same `--client` name** and a narrower `--restrict`/`--domain`, and the previous session is revoked and its tabs released the instant you run it, so the old access can't linger while you wait for the agent to reconnect. Broadening or refreshing the same agent leaves its working session alone, so you never strand an agent mid-task. Revoking (or a narrowing re-pair) also frees the tabs the agent opened, so reusing a client name can't hand the next agent someone else's logged-in page. `root` is rejected as a client name on both the CLI and the daemon. + +### Itemized changes + +#### Fixed +- A reducing re-pair (`/pair` with fewer scopes, tighter domains, a lower rate, or a stricter tab policy) revokes the client's live session and releases its tabs before minting the new key; the response carries `superseded`. Non-reducing re-pairs keep the session and only drop stale pending setup keys, so a broaden or refresh never strands a working agent. (`browse/src/server.ts`, `browse/src/token-registry.ts`) +- A narrowing re-pair issued before the agent connects invalidates the earlier, broader setup key, so it can no longer be exchanged. (`browse/src/token-registry.ts`) +- Revoking an agent releases the tab ownership it held: `DELETE /token` runs the release unconditionally (ownership outlives the token) and reports `tabs_released`, and an own-only client re-paired under the same name can no longer read those tabs. (`browse/src/browser-manager.ts`, `browse/src/server.ts`) +- `root` is rejected as a `clientId` at every token writer, so a scoped token can never carry the sentinel that bypasses scope, domain, rate, and tab checks; `/pair` and `/token` return a named 400 and the CLI rejects `--client root` before it reaches the daemon. A persisted `root` entry is skipped when the registry is restored. (`browse/src/token-registry.ts`, `browse/src/cli.ts`) + +#### For contributors +- Regression coverage pins each property: the reduce / broaden / shadow-key re-pair behaviors and the `grantReducesAccess` truth table (scope, domain direction, rate `0`=unlimited, tab policy) in `browse/test/pair-agent-e2e.test.ts` and `browse/test/token-registry.test.ts`; tab-ownership release and post-release denial in `browse/test/tab-isolation.test.ts`; reserved-name rejection across writers, routes, and registry restore. + ## [1.68.2.0] - 2026-08-20 **Revoking a paired agent now revokes everything it holds, and the** diff --git a/VERSION b/VERSION index 5764e2e90..f6cb3fdd4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.68.2.0 +1.68.3.0 diff --git a/browse/src/cli.ts b/browse/src/cli.ts index ff620014b..a76686df0 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -1313,8 +1313,21 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise 0) { + console.log(`[browse] Superseded the previous session for "${clientName}" (${pairData.superseded.tokens_deleted} token(s), ${pairData.superseded.tabs_released} tab(s) released). The agent must reconnect with the new key.`); + } + // A re-pair narrows/changes an EXISTING agent only when it reuses that agent's + // --client name. Without one, this mints a brand-new agent and the old grant + // lives on — warn when the intent looks like a re-pair. + if (!parseFlag(args, '--client') && (restrict || domains)) { + console.warn(`[browse] No --client given: this pairs a NEW agent and does NOT narrow an existing one. To change an agent's access, re-pair with its --client name (see 'browse tunnel agents').`); + } + // Determine the URL to use let serverUrl: string; if (pairData.tunnel_url) { diff --git a/browse/src/server.ts b/browse/src/server.ts index 1a6cb0ce9..e368ede93 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -32,7 +32,8 @@ import { checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken, listTokens, recordCommand, isRootToken, checkConnectRateLimit, type TokenInfo, type ScopeCategory, - DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError, + DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError, assertValidClientId, + revokeSetupKeys, getClientSession, grantReducesAccess, } from './token-registry'; import { validateTempPath } from './path-security'; import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config'; @@ -2397,6 +2398,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { } try { const pairBody = await req.json() as any; + // Reject a reserved/invalid clientId up front (createSetupKey enforces + // it too, but this makes the 400 unambiguous and skips the teardown). + if (pairBody.clientId !== undefined) assertValidClientId(pairBody.clientId); // Default: DEFAULT_PAIR_SCOPES (full page access). The trust boundary // is the pairing ceremony itself, not the scope. --control adds // browser-wide destructive commands (stop, restart, disconnect). @@ -2411,6 +2415,32 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { const scopes = pairBody.control || pairBody.admin ? [...DEFAULT_PAIR_SCOPES, 'control' as const] : ((pairBody.scopes || [...DEFAULT_PAIR_SCOPES]) as ScopeCategory[]); + // D1: a re-pair supersedes prior grants. ALWAYS drop stale setup keys + // so a superseded broad key can never be exchanged — this closes the + // shadow-key hole where a narrowing re-pair before the agent connects + // would otherwise leave the old broad key live. Revoke the live + // SESSION only when the new grant actually reduces access, so a + // broaden/refresh never strands a working agent mid-task. Compare + // against the resolved grant (not raw pairBody) so dropping 'control' + // or a default re-pair is classified correctly. Revoke runs BEFORE + // createSetupKey — revokeToken deletes all of a clientId's tokens, so + // minting first would nuke the fresh key. + const grant = { + scopes: [...scopes] as ScopeCategory[], + domains: pairBody.domains as string[] | undefined, + rateLimit: pairBody.rateLimit ?? 10, + tabPolicy: 'own-only' as const, + }; + const priorSession = pairBody.clientId ? getClientSession(pairBody.clientId) : null; + let superseded: { tokens_deleted: number; tabs_released: number } | undefined; + if (priorSession && grantReducesAccess(priorSession, grant)) { + const tokensDeleted = revokeToken(pairBody.clientId); + const tabsReleased = browserManager.releaseClientTabs(pairBody.clientId).length; + superseded = { tokens_deleted: tokensDeleted, tabs_released: tabsReleased }; + console.log(`[browse] Superseded ${tokensDeleted} token(s), released ${tabsReleased} tab(s) for reducing re-pair: ${pairBody.clientId}`); + } else if (pairBody.clientId) { + revokeSetupKeys(pairBody.clientId); + } const setupKey = createSetupKey({ clientId: pairBody.clientId, scopes: [...scopes], @@ -2445,6 +2475,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { scopes: setupKey.scopes, tunnel_url: verifiedTunnelUrl, server_url: `http://127.0.0.1:${browsePort}`, + ...(superseded ? { superseded } : {}), }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (err) { // Name the caller's typo (bad scope, negative rateLimit, reserved diff --git a/browse/src/token-registry.ts b/browse/src/token-registry.ts index 211fc447d..7e5d9ace7 100644 --- a/browse/src/token-registry.ts +++ b/browse/src/token-registry.ts @@ -495,6 +495,110 @@ export function revokeToken(clientId: string): number { return deleted; } +/** + * Revoke the PENDING (unspent) setup keys for a client, leaving any live + * session AND spent keys untouched. A re-pair always drops pending keys so a + * superseded broad key can never be exchanged — this closes the shadow-key + * hole (a reducing re-pair before the agent connects would otherwise leave the + * old broad key live) without touching the spent key that #2646 keeps for + * idempotent re-exchange on a tunnel drop. Returns the number deleted. + */ +export function revokeSetupKeys(clientId: string): number { + let deleted = 0; + for (const [token, info] of tokens) { + // usesRemaining !== 0 = still exchangeable (pending). Spent keys (0) are + // harmless: their session is either kept here or revoked on the reduce path. + if (info.clientId === clientId && info.type === 'setup' && info.usesRemaining !== 0) { + tokens.delete(token); + deleted++; + } + } + return deleted; +} + +/** The live (non-expired) session token for a client, if any. */ +export function getClientSession(clientId: string): TokenInfo | null { + const now = new Date(); + for (const info of tokens.values()) { + if (info.clientId !== clientId || info.type !== 'session') continue; + if (info.expiresAt && new Date(info.expiresAt) < now) continue; + return info; + } + return null; +} + +/** The effective grant a re-pair is requesting, resolved to concrete values. */ +export interface ResolvedGrant { + scopes: ScopeCategory[]; + domains?: string[]; + rateLimit: number; + tabPolicy: 'own-only' | 'shared'; +} + +/** + * Does `grant` remove any capability the live `prior` session holds? Drives the + * /pair supersede decision: a reducing re-pair revokes the old session + * immediately (the narrowing must not wait for a reconnect that may never + * happen); a broaden/refresh leaves it working (no outage). Fails toward + * revocation on an unprovable domain superset — a spurious revoke costs one + * reconnect, a missed one leaves wide access live. + */ +export function grantReducesAccess(prior: TokenInfo, grant: ResolvedGrant): boolean { + return scopesReduced(prior.scopes, grant.scopes) + || domainsReduced(prior.domains, grant.domains) + || rateReduced(prior.rateLimit, grant.rateLimit) + || tabPolicyReduced(prior.tabPolicy, grant.tabPolicy); +} + +function scopesReduced(prior: ScopeCategory[], next: ScopeCategory[]): boolean { + // Any scope the prior held that the new grant omits (also catches dropping 'control'). + return prior.some(s => !next.includes(s)); +} + +function domainsReduced(prior: string[] | undefined, next: string[] | undefined): boolean { + const priorUnrestricted = !prior || prior.length === 0; + const nextUnrestricted = !next || next.length === 0; + if (priorUnrestricted) return !nextUnrestricted; // universe → restricted = reduce + if (nextUnrestricted) return false; // restricted → universe = broaden + // Both restricted: reduced if any host the prior allowlist admits is no longer + // admitted by the new one. Approximate over patterns — prior is covered iff + // every prior pattern is covered by some next pattern; anything unprovable + // counts as reduced (fail toward revocation). + return prior!.some(p => !next!.some(n => domainGlobCovers(n, p))); +} + +/** Does allowlist pattern `wide` admit every host that `narrow` admits? Mirrors + * matchDomainGlob's suffix/exact rules. */ +function domainGlobCovers(wide: string, narrow: string): boolean { + if (wide === narrow) return true; + const wideGlob = wide.startsWith('*.'); + if (wideGlob) { + const wideSuffix = wide.slice(1); // ".example.com" + const wideApex = wide.slice(2); // "example.com" + if (!narrow.startsWith('*.')) { + // narrow is an exact host; covered iff the wide glob matches it. + return narrow === wideApex || narrow.endsWith(wideSuffix); + } + // narrow is also a glob; its apex must fall under the wide suffix. + const narrowApex = narrow.slice(2); + return narrowApex === wideApex || narrowApex.endsWith(wideSuffix); + } + // wide is an exact host: covers only the identical host (handled by === above). + return false; +} + +function rateReduced(prior: number, next: number): boolean { + const priorUnlimited = prior <= 0; // 0 = unlimited + const nextUnlimited = next <= 0; + if (priorUnlimited) return !nextUnlimited; // unlimited → capped = reduce + if (nextUnlimited) return false; // capped → unlimited = broaden + return next < prior; // both capped: a lower cap = reduce +} + +function tabPolicyReduced(prior: 'own-only' | 'shared', next: 'own-only' | 'shared'): boolean { + return prior === 'shared' && next === 'own-only'; +} + /** * Rotate the root token. All scoped tokens are invalidated. * Returns the new root token. diff --git a/browse/test/pair-agent-e2e.test.ts b/browse/test/pair-agent-e2e.test.ts index 4e5fce023..953bc0299 100644 --- a/browse/test/pair-agent-e2e.test.ts +++ b/browse/test/pair-agent-e2e.test.ts @@ -318,6 +318,61 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => { expect(body.error).not.toBe('Invalid request body'); }); + // ─── D1: a reducing re-pair supersedes the prior grant immediately ──── + + const pairAs = async (body: any) => (await (await fetch(`${daemon.baseUrl}/pair`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` }, + body: JSON.stringify(body), + })).json()) as any; + const connectKey = async (setup_key: string) => { + const r = await fetch(`${daemon.baseUrl}/connect`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ setup_key }), + }); + return { status: r.status, body: await r.json().catch(() => ({})) as any }; + }; + const statusWith = (token: string) => fetch(`${daemon.baseUrl}/command`, { + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ command: 'status', args: [] }), + }); + + test('reducing re-pair revokes the prior session immediately, without exchanging the new key', async () => { + const { setup_key: k1 } = await pairAs({ clientId: 'reduce-me' }); // broad + const { body: c1 } = await connectKey(k1); + const s1 = c1.token as string; + expect((await statusWith(s1)).status).not.toBe(401); // works + // Narrow WITHOUT exchanging the new key — this is the whole bug. + const rp = await pairAs({ clientId: 'reduce-me', scopes: ['read'] }); + expect(rp.superseded?.tokens_deleted).toBeGreaterThanOrEqual(1); + expect((await statusWith(s1)).status).toBe(401); // old session revoked + // The new narrow key still works and yields the reduced scope. + const c2 = await connectKey(rp.setup_key); + expect(c2.status).toBe(200); + expect(c2.body.scopes).toEqual(['read']); + expect((await statusWith(c2.body.token)).status).not.toBe(401); + }); + + test('reducing re-pair BEFORE connect kills the stale broad setup key; only the narrow key works', async () => { + const { setup_key: broad } = await pairAs({ clientId: 'shadow' }); // never connected + const rp = await pairAs({ clientId: 'shadow', scopes: ['read'] }); // narrowing re-pair + expect(rp.superseded).toBeUndefined(); // no live session existed + expect((await connectKey(broad)).status).toBe(401); // stale broad key dead + const c = await connectKey(rp.setup_key); + expect(c.status).toBe(200); + expect(c.body.scopes).toEqual(['read']); // narrow key survives + }); + + test('broadening re-pair does NOT revoke the working session (no outage)', async () => { + const first = await pairAs({ clientId: 'broaden', scopes: ['read'] }); + expect(first.superseded).toBeUndefined(); // first pair supersedes nothing + const { body: c } = await connectKey(first.setup_key); + const s = c.token as string; + expect((await statusWith(s)).status).not.toBe(401); + const rp = await pairAs({ clientId: 'broaden', scopes: ['read', 'write'] }); // broaden + expect(rp.superseded).toBeUndefined(); // session not superseded + expect((await statusWith(s)).status).not.toBe(401); // still working + }); + // ─── D3: DELETE /token releases tabs unconditionally; 404 only when empty ─ test('DELETE /token returns tabs_released and 404 only when nothing to revoke or release', async () => { diff --git a/browse/test/token-registry.test.ts b/browse/test/token-registry.test.ts index 07e202889..3d2639b83 100644 --- a/browse/test/token-registry.test.ts +++ b/browse/test/token-registry.test.ts @@ -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 => ({ + 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 => ({ + 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); diff --git a/docs/REMOTE_BROWSER_ACCESS.md b/docs/REMOTE_BROWSER_ACCESS.md index d4f28e7c6..950d23b86 100644 --- a/docs/REMOTE_BROWSER_ACCESS.md +++ b/docs/REMOTE_BROWSER_ACCESS.md @@ -150,6 +150,8 @@ CSS selectors. Always `snapshot -i` first, then use the refs. Paired agents get `read+write+admin+meta` by default; the pairing ceremony is the trust boundary. `--restrict` narrows the list (it can never grant `control`). `--control` adds the control scope (`--admin` is a legacy alias). Over the tunnel, the `js`/`cookies`/`storage` commands are blocked by the command allowlist regardless of scope; `eval` works. Pair with `--restrict "read,write"` when the agent will read untrusted web content — scope caps the prompt-injection blast radius. +To tighten an already-paired agent, re-pair it with the **same `--client` name** and the narrower `--restrict`/`--domain`: a reducing re-pair revokes the previous session and releases its tabs immediately (the agent must reconnect with the new key), so the old wide access never lingers. Broadening or refreshing keeps the working session with no outage. Re-pairing without `--client` mints a new agent instead. `root` is a reserved client name. + ## Tab Isolation Each agent owns the tabs it creates. Rules: diff --git a/package.json b/package.json index 91ee3f247..fa7526cc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.68.2", + "version": "1.68.3", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/pair-agent/SKILL.md b/pair-agent/SKILL.md index 8650396e6..2f8246068 100644 --- a/pair-agent/SKILL.md +++ b/pair-agent/SKILL.md @@ -1102,6 +1102,13 @@ With --restrict (`--restrict read`, `--restrict "read,write"`): a trusted agent can be prompt-injected by pages it reads, and scope caps the blast radius (eval works over the tunnel). - `--restrict` never grants `control`; that scope stays behind --control. +- To tighten an agent that is ALREADY paired, re-pair it with the **same + `--client` name** and the narrower `--restrict`/`--domain`. A reducing re-pair + revokes the previous session immediately and releases its tabs — the agent + must reconnect with the new key, so the old wide access does not linger. + Re-pairing without `--client` mints a brand-new agent and leaves the old one + untouched. Broadening or refreshing keeps the working session (no outage). +- `root` is a reserved `--client` name (it would bypass all scope enforcement). With --control (--admin is the legacy alias): - Everything, plus browser-wide destructive ops (stop, restart, disconnect) @@ -1112,8 +1119,9 @@ With --control (--admin is the legacy alias): **"Tab not owned by your agent"** — The remote agent tried to interact with a tab it didn't create. Tell it to run `newtab` first to get its own tab. -**"Domain not allowed"** — The token has domain restrictions. Re-pair with broader -domain access or no domain restrictions. +**"Domain not allowed"** — The token has domain restrictions. Re-pair with the +same `--client` name and broader (or no) `--domain`. A broadening re-pair keeps +the working session; a narrowing one revokes it immediately. **"Rate limit exceeded"** — The agent is sending > 10 requests/second. It should wait for the Retry-After header and slow down. diff --git a/pair-agent/SKILL.md.tmpl b/pair-agent/SKILL.md.tmpl index 188bc5e91..2f66ec44b 100644 --- a/pair-agent/SKILL.md.tmpl +++ b/pair-agent/SKILL.md.tmpl @@ -287,6 +287,13 @@ With --restrict (`--restrict read`, `--restrict "read,write"`): a trusted agent can be prompt-injected by pages it reads, and scope caps the blast radius (eval works over the tunnel). - `--restrict` never grants `control`; that scope stays behind --control. +- To tighten an agent that is ALREADY paired, re-pair it with the **same + `--client` name** and the narrower `--restrict`/`--domain`. A reducing re-pair + revokes the previous session immediately and releases its tabs — the agent + must reconnect with the new key, so the old wide access does not linger. + Re-pairing without `--client` mints a brand-new agent and leaves the old one + untouched. Broadening or refreshing keeps the working session (no outage). +- `root` is a reserved `--client` name (it would bypass all scope enforcement). With --control (--admin is the legacy alias): - Everything, plus browser-wide destructive ops (stop, restart, disconnect) @@ -297,8 +304,9 @@ With --control (--admin is the legacy alias): **"Tab not owned by your agent"** — The remote agent tried to interact with a tab it didn't create. Tell it to run `newtab` first to get its own tab. -**"Domain not allowed"** — The token has domain restrictions. Re-pair with broader -domain access or no domain restrictions. +**"Domain not allowed"** — The token has domain restrictions. Re-pair with the +same `--client` name and broader (or no) `--domain`. A broadening re-pair keeps +the working session; a narrowing one revokes it immediately. **"Rate limit exceeded"** — The agent is sending > 10 requests/second. It should wait for the Retry-After header and slow down.