Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
});
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 || '',
});
});
Expand Down
4 changes: 4 additions & 0 deletions extension/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ <h1>BrowserForce</h1>
<input type="checkbox" id="bf-read-only">
<span>Read-only (observe only)</span>
</label>
<label class="checkbox-row">
<input type="checkbox" id="bf-allow-profile-wide-clear">
<span>Allow profile-wide data clearing — signs you out of every site</span>
</label>
</div>
</section>

Expand Down
6 changes: 5 additions & 1 deletion extension/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
];

Expand All @@ -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 || '';
Expand Down Expand Up @@ -185,13 +187,15 @@ function onRestrictionToggle() {
lockUrl: lockUrlCb.checked,
noNewTabs: noNewTabsCb.checked,
readOnly: readOnlyCb.checked,
allowProfileWideClear: allowProfileWideClearCb.checked,
});
updateInstructions();
}

lockUrlCb.addEventListener('change', onRestrictionToggle);
noNewTabsCb.addEventListener('change', onRestrictionToggle);
readOnlyCb.addEventListener('change', onRestrictionToggle);
allowProfileWideClearCb.addEventListener('change', onRestrictionToggle);

// Save user edits to instructions (debounced)
let instrTimeout;
Expand Down
46 changes: 43 additions & 3 deletions relay/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -1097,6 +1126,7 @@ class RelayServer {
return {
mode: raw.mode === 'manual' ? 'manual' : 'auto',
noNewTabs: !!raw.noNewTabs,
allowProfileWideClear: !!raw.allowProfileWideClear,
};
} catch {
return RESTRICTIONS_FAIL_CLOSED;
Expand Down Expand Up @@ -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);
Expand Down
150 changes: 150 additions & 0 deletions relay/test/relay-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3122,6 +3122,7 @@ describe('GET /restrictions endpoint', () => {
lockUrl: false,
noNewTabs: false,
readOnly: false,
allowProfileWideClear: false,
instructions: '',
});
});
Expand Down Expand Up @@ -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);
}
});
});
9 changes: 8 additions & 1 deletion skills/browserforce/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down