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.