diff --git a/AGENTS.md b/AGENTS.md index 718da3d..c52f9c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -445,6 +445,20 @@ rewrites JS (`getByRole('button')` became `getByRole(button)` → `/restrictions` and `/agent-preferences` (user settings, including free-text instructions). Never add a route to the allowlist without establishing that its body is safe for any page the user visits to read. +- Profile-wide clears are refused by default (`PROFILE_WIDE_CLEAR_METHODS`: + `Network.clearBrowserCookies`, `Network.clearBrowserCache`, + `Storage.clearCookies`). These are not tab-scoped — they wipe every cookie + for every domain in the user's profile, signing them out of mail, source + control and banking at once, and Chrome offers no scoped variant. The guard + sits in `_handleCdpClientMessage` **before** routing, because these arrive + both tab-scoped (`Network.*`, with a sessionId) and browser-scoped + (`Storage.clearCookies`, without one). It fails closed via + `_getRestrictionsSafe()`: an unreadable setting never reads as granted. + The opt-in is the popup checkbox `allowProfileWideClear`, deliberately not an + env var — an agent runs shell commands and could set an env var for itself, + but cannot tick a checkbox. Scoped operations stay allowed and the refusal + names them, so an agent recovers without asking: `Network.getCookies` + + `Network.deleteCookies`, or `Storage.clearDataForOrigin`. ## Operational Non-Goals diff --git a/extension/background.js b/extension/background.js index e15ddef..18ac5d8 100644 --- a/extension/background.js +++ b/extension/background.js @@ -330,12 +330,13 @@ async function executeCommand(msg) { return cdpCommand(msg.params); case 'getRestrictions': return new Promise((resolve) => { - chrome.storage.local.get(['mode', 'lockUrl', 'noNewTabs', 'readOnly', 'userInstructions'], (s) => { + chrome.storage.local.get(['mode', 'lockUrl', 'noNewTabs', 'readOnly', 'allowProfileWideClear', 'userInstructions'], (s) => { resolve({ mode: s.mode || 'auto', lockUrl: !!s.lockUrl, noNewTabs: !!s.noNewTabs, readOnly: !!s.readOnly, + allowProfileWideClear: !!s.allowProfileWideClear, instructions: s.userInstructions || '', }); }); @@ -640,7 +641,7 @@ const INPUT_METHODS = new Set([ async function checkRestriction(method, params, tabId) { const settings = await new Promise((resolve) => { - chrome.storage.local.get(['mode', 'lockUrl', 'noNewTabs', 'readOnly', 'userInstructions'], resolve); + chrome.storage.local.get(['mode', 'lockUrl', 'noNewTabs', 'readOnly', 'allowProfileWideClear', 'userInstructions'], resolve); }); // No restrictions active -> allow @@ -1260,12 +1261,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } if (msg.type === 'getRestrictions') { - chrome.storage.local.get(['mode', 'lockUrl', 'noNewTabs', 'readOnly', 'userInstructions'], (s) => { + chrome.storage.local.get(['mode', 'lockUrl', 'noNewTabs', 'readOnly', 'allowProfileWideClear', 'userInstructions'], (s) => { sendResponse({ mode: s.mode || 'auto', lockUrl: !!s.lockUrl, noNewTabs: !!s.noNewTabs, readOnly: !!s.readOnly, + allowProfileWideClear: !!s.allowProfileWideClear, instructions: s.userInstructions || '', }); }); diff --git a/extension/popup.html b/extension/popup.html index 0f45af1..9c65b35 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -107,6 +107,10 @@

BrowserForce

Read-only (observe only) + diff --git a/extension/popup.js b/extension/popup.js index 279a765..36ac672 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -31,6 +31,7 @@ const parallelVisibilitySelect = document.getElementById('bf-parallel-visibility const lockUrlCb = document.getElementById('bf-lock-url'); const noNewTabsCb = document.getElementById('bf-no-new-tabs'); const readOnlyCb = document.getElementById('bf-read-only'); +const allowProfileWideClearCb = document.getElementById('bf-allow-profile-wide-clear'); const dedicatedWindowCb = document.getElementById('bf-dedicated-window'); const ghostCursorCb = document.getElementById('bf-ghost-cursor'); // Mirrors extension/agent-defaults.js — popup.js is a classic script and cannot @@ -56,7 +57,7 @@ document.querySelectorAll('.tab-btn').forEach((btn) => { const SETTINGS_KEYS = [ 'relayUrl', 'autoDetachMinutes', 'autoCloseMinutes', - 'mode', 'lockUrl', 'noNewTabs', 'readOnly', 'userInstructions', + 'mode', 'lockUrl', 'noNewTabs', 'readOnly', 'allowProfileWideClear', 'userInstructions', 'executionMode', 'parallelVisibilityMode', 'dedicatedWindow', 'ghostCursorEnabled', ]; @@ -74,6 +75,7 @@ chrome.storage.local.get(SETTINGS_KEYS, (s) => { lockUrlCb.checked = !!s.lockUrl; noNewTabsCb.checked = !!s.noNewTabs; readOnlyCb.checked = !!s.readOnly; + allowProfileWideClearCb.checked = !!s.allowProfileWideClear; dedicatedWindowCb.checked = s.dedicatedWindow !== false; ghostCursorCb.checked = !!s.ghostCursorEnabled; instructionsEl.value = s.userInstructions || ''; @@ -185,6 +187,7 @@ function onRestrictionToggle() { lockUrl: lockUrlCb.checked, noNewTabs: noNewTabsCb.checked, readOnly: readOnlyCb.checked, + allowProfileWideClear: allowProfileWideClearCb.checked, }); updateInstructions(); } @@ -192,6 +195,7 @@ function onRestrictionToggle() { lockUrlCb.addEventListener('change', onRestrictionToggle); noNewTabsCb.addEventListener('change', onRestrictionToggle); readOnlyCb.addEventListener('change', onRestrictionToggle); +allowProfileWideClearCb.addEventListener('change', onRestrictionToggle); // Save user edits to instructions (debounced) let instrTimeout; diff --git a/relay/src/index.js b/relay/src/index.js index 7755b0d..1b3e881 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -13,7 +13,11 @@ const COMMAND_TIMEOUT_MS = 30000; const PING_INTERVAL_MS = 5000; const DEFAULT_CDP_LOG_BUFFER_LIMIT = 10000; const RESTRICTIONS_FETCH_TIMEOUT_MS = 2000; -const RESTRICTIONS_FAIL_CLOSED = Object.freeze({ mode: 'manual', noNewTabs: true }); +const RESTRICTIONS_FAIL_CLOSED = Object.freeze({ + mode: 'manual', + noNewTabs: true, + allowProfileWideClear: false, +}); // Leak guard for label-keyed window affinity entries (which outlive their // connection by design). FIFO-evict the oldest pin beyond this size. const MAX_AFFINITY_ENTRIES = 50; @@ -186,6 +190,30 @@ const INIT_ONLY_METHODS = new Set([ 'Emulation.setUserAgentOverride', 'Emulation.setGeolocationOverride', ]); +// CDP commands whose blast radius is the WHOLE Chrome profile, not the target +// tab. `Network.clearBrowserCookies` does not clear "this page's cookies" — it +// clears every cookie for every domain the user is signed in to, so one stray +// snippet signs them out of mail, source control and banking at once. Chrome +// offers no scoped variant of these, so the relay refuses them by default and +// the popup owns the opt-in (an env var would not: an agent runs shell commands +// and could set one for itself, whereas it cannot tick a checkbox). +// +// Deliberately NOT listed: `Storage.clearDataForOrigin` / +// `clearDataForStorageKey` take an origin and are already scoped, and +// `Network.deleteCookies` deletes one named cookie. Those are the safe paths, +// and the refusal below names them so the agent can recover without asking. +const PROFILE_WIDE_CLEAR_METHODS = new Set([ + 'Network.clearBrowserCookies', + 'Network.clearBrowserCache', + 'Storage.clearCookies', +]); + +function profileWideClearRefusal(method) { + return `${method} is blocked by BrowserForce: it clears data for EVERY site in the user's Chrome profile, not just this tab, and would sign them out everywhere. ` + + 'To clear one site, read its cookies with Network.getCookies({ urls: [...] }) and remove them individually with Network.deleteCookies, or scope storage with Storage.clearDataForOrigin. ' + + 'If the user truly wants the entire profile cleared, ask them first, then have them tick "Allow profile-wide data clearing" in the BrowserForce extension popup.'; +} + // Return a well-shaped synthetic response for init commands that need more than {}. function syntheticInitResponse(method, target) { switch (method) { @@ -462,7 +490,7 @@ class RelayServer { if (url.pathname === '/restrictions') { if (!this.ext) { - res.end(JSON.stringify({ mode: 'auto', lockUrl: false, noNewTabs: false, readOnly: false, instructions: '' })); + res.end(JSON.stringify({ mode: 'auto', lockUrl: false, noNewTabs: false, readOnly: false, allowProfileWideClear: false, instructions: '' })); return; } try { @@ -1082,7 +1110,8 @@ class RelayServer { // ─── Restrictions Guard (fail-closed) ────────────────────────────────────── /** - * Fetch restrictions for the Target.createTarget guard. Fail-closed: every + * Fetch restrictions for the Target.createTarget and profile-wide-clear + * guards. Fail-closed: every * inability-to-read path (extension missing, timeout, malformed response, * extension error, transport failure) returns manual+noNewTabs so tab * creation is blocked deterministically. Do not cache — settings can change @@ -1097,6 +1126,7 @@ class RelayServer { return { mode: raw.mode === 'manual' ? 'manual' : 'auto', noNewTabs: !!raw.noNewTabs, + allowProfileWideClear: !!raw.allowProfileWideClear, }; } catch { return RESTRICTIONS_FAIL_CLOSED; @@ -1281,6 +1311,16 @@ class RelayServer { }); try { + // Guard before routing, not inside one branch: these arrive tab-scoped + // (Network.*, with a sessionId) or browser-scoped (Storage.clearCookies, + // without one), and both reach chrome.debugger. + if (PROFILE_WIDE_CLEAR_METHODS.has(method)) { + const restrictions = await this._getRestrictionsSafe(); + if (!restrictions.allowProfileWideClear) { + throw new Error(profileWideClearRefusal(method)); + } + } + let result; if (sessionId && sessionId !== BF_BROWSER_SESSION_ID) { result = await this._forwardToTab(sessionId, method, params, id, clientId); diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index 38081a5..0d2f3a9 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -3122,6 +3122,7 @@ describe('GET /restrictions endpoint', () => { lockUrl: false, noNewTabs: false, readOnly: false, + allowProfileWideClear: false, instructions: '', }); }); @@ -4201,3 +4202,152 @@ describe('synthetic target ids are unique per registration', () => { } }); }); + +describe('profile-wide clear guard', () => { + let relay; + let port; + + const PERMISSIVE = { + mode: 'auto', noNewTabs: false, lockUrl: false, readOnly: false, instructions: '', + }; + + /** + * Fake extension exposing one tab. `restrictions` is what getRestrictions + * answers (pass null to stay silent, so the relay's fetch times out). + * Records every cdpCommand that reaches it — keeping these away from + * chrome.debugger is the guard's entire job. + */ + function fakeExtension(ext, restrictions) { + const forwarded = []; + ext.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.method === 'ping') { ext.send(JSON.stringify({ method: 'pong' })); return; } + if (msg.id && msg.method === 'getRestrictions') { + if (restrictions) ext.send(JSON.stringify({ id: msg.id, result: restrictions })); + return; + } + if (msg.id && msg.method === 'listTabs') { + ext.send(JSON.stringify({ + id: msg.id, + result: { tabs: [{ tabId: 41, windowId: 3, url: 'https://a.test/', title: 'A', active: true }] }, + })); + return; + } + if (msg.id && msg.method === 'attachTab') { + ext.send(JSON.stringify({ + id: msg.id, + result: { + tabId: msg.params.tabId, + targetId: `real-target-${msg.params.tabId}`, + targetInfo: { targetId: `real-target-${msg.params.tabId}`, type: 'page', title: 'A', url: 'https://a.test/', windowId: 3 }, + sessionId: msg.params.sessionId, + }, + })); + return; + } + if (msg.id && msg.method === 'cdpCommand') { + forwarded.push(msg.params?.method); + ext.send(JSON.stringify({ id: msg.id, result: {} })); + } + }); + return forwarded; + } + + /** + * Drive the path Playwright actually takes: auto-attach, then issue the + * command against the page's session id. Sending it without a sessionId + * would hit the browser-level handler, which answers {} for anything it does + * not know — so the command would never reach the extension and the test + * would pass whether or not the guard exists. + */ + async function openTabSession(restrictions) { + const ext = await connectWs(`ws://127.0.0.1:${port}/extension`, { + headers: { Origin: 'chrome-extension://test' }, + }); + const forwarded = fakeExtension(ext, restrictions); + const cdp = await connectWs(`ws://127.0.0.1:${port}/cdp?token=${relay.authToken}`); + const events = []; + cdp.on('message', (data) => events.push(JSON.parse(data.toString()))); + cdp.send(JSON.stringify({ id: 1, method: 'Target.setAutoAttach', params: { autoAttach: true, flatten: true } })); + await sleep(300); + const attached = events.find((m) => m.method === 'Target.attachedToTarget'); + assert.ok(attached, 'tab session required for this test to mean anything'); + return { ext, cdp, events, forwarded, sessionId: attached.params.sessionId }; + } + + before(async () => { + port = getRandomPort(); + relay = new RelayServer({ port }); + await relay.start({ writeCdpUrl: false }); + }); + + after(() => relay.stop()); + + for (const method of ['Network.clearBrowserCookies', 'Network.clearBrowserCache', 'Storage.clearCookies']) { + it(`refuses ${method} on a tab session and never forwards it`, async () => { + const { ext, cdp, events, forwarded, sessionId } = await openTabSession({ ...PERMISSIVE }); + try { + cdp.send(JSON.stringify({ id: 9, method, sessionId })); + await sleep(300); + const res = events.find((m) => m.id === 9); + assert.ok(res, 'a response is required'); + assert.ok(res.error, 'the command must fail, not succeed silently'); + assert.match(res.error.message, /EVERY site/); + assert.match(res.error.message, /Network\.deleteCookies/, + 'the refusal must name the scoped alternative so the agent can recover'); + assert.match(res.error.message, /ask them first/, + 'the refusal must tell the agent to ask the user, not merely fail'); + assert.ok(!forwarded.includes(method), 'nothing may reach chrome.debugger'); + } finally { + cdp.close(); ext.close(); await sleep(50); + } + }); + } + + it('forwards the clear once the user ticks the popup permission', async () => { + const { ext, cdp, events, forwarded, sessionId } = + await openTabSession({ ...PERMISSIVE, allowProfileWideClear: true }); + try { + cdp.send(JSON.stringify({ id: 9, method: 'Network.clearBrowserCookies', sessionId })); + await sleep(300); + const res = events.find((m) => m.id === 9); + assert.ok(res && !res.error, `expected success, got ${res?.error?.message}`); + assert.ok(forwarded.includes('Network.clearBrowserCookies'), + 'the permission must actually let the command through'); + } finally { + cdp.close(); ext.close(); await sleep(50); + } + }); + + it('fails closed when the extension cannot answer getRestrictions', async () => { + // An unreadable permission must never read as "granted". + const { ext, cdp, events, forwarded, sessionId } = await openTabSession(null); + try { + cdp.send(JSON.stringify({ id: 9, method: 'Network.clearBrowserCookies', sessionId })); + await sleep(3000); + const res = events.find((m) => m.id === 9); + assert.ok(res?.error, 'an unreadable permission must refuse'); + assert.ok(!forwarded.includes('Network.clearBrowserCookies')); + } finally { + cdp.close(); ext.close(); await sleep(50); + } + }); + + it('leaves the scoped alternative alone', async () => { + const { ext, cdp, events, forwarded, sessionId } = await openTabSession({ ...PERMISSIVE }); + try { + cdp.send(JSON.stringify({ + id: 9, + method: 'Network.deleteCookies', + params: { name: 'sid', url: 'https://a.test/' }, + sessionId, + })); + await sleep(300); + const res = events.find((m) => m.id === 9); + assert.ok(res && !res.error, `scoped delete must still work, got ${res?.error?.message}`); + assert.ok(forwarded.includes('Network.deleteCookies')); + } finally { + cdp.close(); ext.close(); await sleep(50); + } + }); +}); diff --git a/skills/browserforce/SKILL.md b/skills/browserforce/SKILL.md index 7ba0947..44577dc 100644 --- a/skills/browserforce/SKILL.md +++ b/skills/browserforce/SKILL.md @@ -170,7 +170,14 @@ browserforce -e " layer cannot express the task. 5. **Command errors teach the next step** — stale ref → re-snapshot; unknown tab → `browserforce tabs`. Fix and retry. -6. **Backend fallback is visible** — `auto` uses real Chrome when the extension +6. **Never clear cookies profile-wide** — `Network.clearBrowserCookies`, + `Network.clearBrowserCache` and `Storage.clearCookies` wipe every site in + the user's Chrome, not the current page; they would sign the user out of + everything. The relay refuses them. To clear one site, read its cookies with + `Network.getCookies({ urls: [...] })` and remove them with + `Network.deleteCookies`. If a whole-profile clear is genuinely wanted, ask + the user first — they enable it in the extension popup. +7. **Backend fallback is visible** — `auto` uses real Chrome when the extension is connected and warns if it falls back to managed Chrome; use `--real` to fail instead of falling back.