From c932e42f9b7c93c6502a8882c346e3cae301b450 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 11:36:50 -0800 Subject: [PATCH 01/13] improvement(extension): enable page access across websites --- .../content/docs/browser-extension/index.mdx | 6 +- apps/extension/DESIGN.md | 14 +- apps/extension/README.md | 8 +- apps/extension/package.json | 2 +- apps/extension/scripts/test-e2e.ts | 35 +-- apps/extension/src/capture.ts | 2 +- apps/extension/src/shortcut-hint.tsx | 48 ---- apps/extension/src/sidepanel.tsx | 20 +- apps/extension/src/site-access.tsx | 255 +++++------------- apps/extension/src/site-permissions.ts | 48 ---- .../app/(landing)/privacy/privacy-content.tsx | 2 +- apps/sim/app/extension/hooks/use-composer.tsx | 2 +- .../sim/scripts/test-browser-extension-e2e.ts | 10 +- 13 files changed, 111 insertions(+), 341 deletions(-) delete mode 100644 apps/extension/src/shortcut-hint.tsx delete mode 100644 apps/extension/src/site-permissions.ts diff --git a/apps/docs/content/docs/browser-extension/index.mdx b/apps/docs/content/docs/browser-extension/index.mdx index 20eb0d16ba8..00b5620eed5 100644 --- a/apps/docs/content/docs/browser-extension/index.mdx +++ b/apps/docs/content/docs/browser-extension/index.mdx @@ -15,13 +15,13 @@ To ask about a passage, select the text on the page, right-click, and choose **A Open **+ → Reference current page on send** to include the active tab each time you send a message. This is off by default. The setting appears above the workspace list, and Sim remembers your choice in this Chrome profile. The globe shows **Current page** while this is on. If you attached a page or selection yourself, Sim uses those snapshots instead. Turn the setting off in the same menu. -If Chrome needs access after you switch tabs, your message stays in the composer. Select **Request site access**. If Chrome hasn’t shared the site address with Sim, paste it into **Site address**. Check the site shown on **Allow access**, approve it in Chrome’s permission dialog, then send again. For one-time access, click the Sim toolbar icon on the active tab and retry. Open **+ → Site access** to remove a saved permission. +In version 0.1.4 or later, choose **+ → Page access → Allow across websites** and approve the browser prompt once. Page attachments and send-time references then work across HTTP and HTTPS sites without per-site setup. If a message was waiting for access, its draft stays in the composer so you can send it after approval. Manage or revoke access in Chrome’s extension settings. In earlier versions, **+ → Site access** grants access to an individual site; clicking the Sim toolbar icon gives temporary access to the current page. To share a screenshot, drag the image file onto the open chat. It uploads to Sim and appears as an attachment you can remove before sending. If a website supplies only an image URL, paste the image itself or use the file picker. For faster access, press **Command + Shift + S** on macOS or **Alt + Shift + S** on Windows and Linux. The shortcut attaches highlighted text when you have a selection. It won't substitute the whole page for an unreadable selection. With no selection, it attaches the page or uses your send-time reference setting. On an already-open sidebar, Chrome may keep keyboard focus on the website. Click the message field, or use Chrome’s pane navigation (on Mac, **Command + Option + Up**). -If Chrome needs access to a new tab, the sidebar shows the shortcut assigned in this Chrome profile, or **Set shortcut** if none is assigned. You can change it under **Extensions → Keyboard shortcuts**. +Change or assign the shortcut under **Extensions → Keyboard shortcuts**. ## Roll out to a team @@ -33,6 +33,6 @@ If your organization has [Links](/search/sim-links), type `sim`, press **Space** ## Page access and privacy -Sim reads a page or selection when you attach it, or when you send a message with **Reference current page** enabled. For repeat use on one site, choose **+ → Site access** to grant or revoke that site’s permission. Granting access doesn't start background capture. The page text goes to the hosted composer when attached and becomes part of the conversation when you send it. +Sim reads a page or selection when you attach it, or when you send a message with **Reference current page** enabled. With version 0.1.4 or later, **Allow across websites** provides access across tabs without repeated site approvals. Granting access doesn't start background capture. The page text goes to the hosted composer when attached and becomes part of the conversation when you send it. See [Sim’s browser extension privacy disclosure](https://www.sim.ai/privacy#browser-extension). diff --git a/apps/extension/DESIGN.md b/apps/extension/DESIGN.md index bff44d7135d..92c570c7a7d 100644 --- a/apps/extension/DESIGN.md +++ b/apps/extension/DESIGN.md @@ -16,8 +16,8 @@ or create a conversation and explicitly attach the current page or highlighted t The composer’s + menu offers Current page and Selected text. Pending snapshots sit inside the composer as compact removable chips alongside file attachments. Opening one shows the captured text, source, capture time, and conversation-retention disclosure. -Partial captures remain labeled in the attachment row. Site access lives in the -composer’s Site access action, which opens an extension-owned dialog; successful delivery adds no persistent status banner. +Partial captures remain labeled in the attachment row. Page access lives in the +composer’s Page access action, which opens an extension-owned dialog; successful delivery adds no persistent status banner. Snapshots are immutable: navigating to another page cannot silently change an attached source. Removing an attachment, changing organization or workspace, or starting a new conversation clears pending page context. Several explicitly attached pages can support comparison. @@ -34,7 +34,7 @@ Existing Sim server tools remain subject to their existing authorization and app ## Ownership and data flow 1. `apps/extension`: packaged MV3 service worker, side-panel host, bounded content - extraction, per-site permission controls, build artifacts, browser verification. + extraction, browser-wide page permission setup, build artifacts, browser verification. 2. `packages/browser-extension-protocol`: versioned, strictly validated messages and immutable page snapshots shared by the extension, app, and server. 3. `apps/sim/app/extension`: session gate, organization landing, optional workspace picker, app providers, @@ -57,9 +57,9 @@ other workspace resources or browser-control tools. and short-lived session storage for selection handoff. Required host access is limited to the configured Sim origin and to `http(s)://sim/*` and `http(s)://go/*`, which exist only so one static redirect rule can send top-level navigations to those hosts to the link resolver. -- Other HTTP(S) origins are optional grants requested for the current site from a - user gesture. Each grant can be revoked. Keeping the panel open is not a grant - to read every subsequently selected tab. +- HTTP(S) page access is one optional browser-wide grant, requested from a direct + user gesture. The browser owns revocation and restrictions. Approval enables + capture across sites; switching tabs alone does not extract content. - No history, bookmarks, cookie API, debugger, native messaging, external messaging, or arbitrary remote code execution. The only network interception is the static `sim/` and `go/` redirect rule above, plus opt-in search recovery. @@ -126,7 +126,7 @@ An absent backend or account is an unverified gate, not a pass. - [Chrome activeTab](https://developer.chrome.com/docs/extensions/develop/concepts/activeTab) and [optional permissions](https://developer.chrome.com/docs/extensions/reference/api/permissions): temporary access follows user invocation; cross-origin navigation requires fresh - authority. Request persistent access per site only when selected by the user. + authority. Request optional access across HTTP(S) websites once, matching Glean’s all-websites page-context option. - [Chrome extension security](https://developer.chrome.com/docs/extensions/develop/security-privacy/stay-secure) and [OWASP extension guidance](https://cheatsheetseries.owasp.org/cheatsheets/Browser_Extension_Vulnerabilities_Cheat_Sheet.html): minimize privileges and treat all incoming page messages as attacker-controlled. diff --git a/apps/extension/README.md b/apps/extension/README.md index 0a194b3b8d9..883cd905b70 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -17,7 +17,7 @@ bun run --cwd apps/extension build In Chrome, open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select `apps/extension/dist`. Copy the extension ID into the Sim server's `BROWSER_EXTENSION_IDS` configuration. It accepts comma-separated Chrome extension IDs. Restart the app after changing that configuration. Unconfigured IDs cannot embed the app. -Click the Sim toolbar icon to open chat and attach the current page. **+ → Current page** adds a fresh snapshot; **Selected text** attaches the highlighted passage. Attachments appear as chips inside the composer. Attach up to three pages or selections to summarize, extract facts, or compare sources. To quote a passage, select it and choose **Ask Sim about selection** from the context menu. Review or remove the attachment before sending. **+ → Site access** opens the extension-owned permission dialog: **Allow access to [site]** grants access only to that site; **Remove access to [site]** revokes it. Persistent site permission never enables background capture. +Click the Sim toolbar icon to open chat and attach the current page. **+ → Current page** adds a fresh snapshot; **Selected text** attaches the highlighted passage. Attachments appear as chips inside the composer. Attach up to three pages or selections to summarize, extract facts, or compare sources. To quote a passage, select it and choose **Ask Sim about selection** from the context menu. Review or remove the attachment before sending. **+ → Page access** offers **Allow across websites**. Approve the browser prompt once to use pages across HTTP and HTTPS sites without per-site setup. Manage access in the browser’s extension settings. Permission alone never enables background capture. For local development: @@ -33,17 +33,17 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app - Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome can treat bare `go/` or `sim/` as a search before the redirect rule runs. Typing `http://go/` once lets Chrome learn the short host in that profile. A trailing slash (`go//`) also selects navigation without relying on that history. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. - The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. For these suggestions, the background worker sends the text typed before the first `/` or `?`, and nothing else, unless that text contains a space (a search) or is longer than a link name can be, to `/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested. - **+ → Browser shortcuts** separately opts into recovery for exact Google, Bing, DuckDuckGo, or Yahoo HTTPS search hosts. After a direct Chrome permission grant, dynamic request rules redirect only top-level GET requests with one query parameter containing a whole `go/` or `sim/` shortcut. Only that value reaches `/sim-links/recover`, which validates it and redirects to the existing authenticated resolver. Ordinary search terms, additional search parameters, subframes, and unsupported inputs are not forwarded. Chrome applies these rules without an extension search-URL observer. Turning a provider off removes its rules while preserving site access; revoking access clears its recovery choice. This does not enable page reference or change the default search provider. -- Optional HTTP/HTTPS site access is requested only by a menu action naming that site. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. +- Optional HTTP/HTTPS access is requested together by **Allow across websites**, from a direct user action. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. - Page capture runs on demand in Chrome's isolated world. It excludes editable fields, inputs, hidden elements, scripts, and embedded documents. It reads only the top frame. PDF viewers and canvas documents may have no accessible text. - Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation. - Source URLs omit credentials, query parameters, and fragments. Human-readable text can still contain sensitive information; it is the user-selected material they review and send into their conversation. - Snapshots stay in memory until handed to Sim and acknowledged or expired after 30 seconds. Extension storage contains only a short-lived intent with a tab/document identity and a SHA-256 URL digest, never raw URLs, captured text, or credentials. Closing a tab or navigating invalidates its pending intent. Sim applies its normal conversation retention once a message is sent. - No browser history, bookmarks, cookie access, debugger access, remote extension JavaScript, arbitrary network proxy, or background content collection is requested. Incognito use is disabled. -- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, or the native site-access and browser-shortcut dialogs. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned dialog. Shortcut settings are advertised separately so older installed shells keep working without the new action. +- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, or the native site-access and browser-shortcut dialogs. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned page-access control and browser approval. Shortcut settings are advertised separately so older installed shells keep working without the new action. - Captures and queued toolbar intents are scoped to the receiving conversation. Changing conversation, workspace, or account discards an unfinished handoff. Captured attachments remain explicit snapshots when the source tab changes. - The complete hosted document, including portaled source dialogs and menus, is blocked from session replay. Clipboard writing is delegated only to the configured Sim origin. Clipboard reading and microphone capture are not delegated; voice input remains available in the main app. -With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. The extension stores only the preference in local storage. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. When Chrome hides the active address, **Site access** lets the user enter a site and confirm its origin in Chrome’s permission dialog. The browser grants the selected scheme, host and port. Uploaded screenshot files stay in the hosted app’s normal attachment flow. +With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. The extension stores only the preference in local storage. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. **Allow across websites** works even when Chrome hides the active address; no address entry is required. The grant covers HTTP and HTTPS sites across tabs until the user revokes or restricts it in browser settings. Uploaded screenshot files stay in the hosted app’s normal attachment flow. ## Verification diff --git a/apps/extension/package.json b/apps/extension/package.json index 577d69f3edf..655e487affe 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@sim/extension", - "version": "0.1.3", + "version": "0.1.4", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 83421d35c3d..1efb88f20f7 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -1061,7 +1061,7 @@ try { ) await panel.bringToFront() await openAccess() - const dialog = panel.getByRole('dialog', { name: 'Site access' }) + const dialog = panel.getByRole('dialog', { name: 'Page access' }) await expect(dialog).toBeVisible() await settleMessages() assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`) @@ -1324,7 +1324,7 @@ try { } ) await check( - 'Site access requests only the address the user confirms, without reading the page', + 'Page access asks the browser for website access without reading before approval', async () => { const source = 'https://private-source.example' const privateUrl = `${source}/sensitive-path?token=PRIVATE_URL_SENTINEL#fragment` @@ -1358,34 +1358,11 @@ try { return execute(...args) }) as typeof chrome.scripting.executeScript }) - await panel.getByRole('button', { name: 'Request site access' }).click() - const dialog = panel.getByRole('dialog', { name: 'Site access' }) - await expect(dialog).toBeVisible() - const address = dialog.getByRole('textbox', { name: 'Site address' }) - await expect(address).toHaveValue('') - const allow = dialog.getByRole('button', { name: /^Allow access/ }) - for (const unsafe of [ - 'https://user:password@example.com/', - 'https://*.example.com/', - 'https://%2A.example.com/', - 'https://*.example.com/', - '', - 'chrome://settings/', - ]) { - await address.fill(unsafe) - await expect(allow, `Do not offer to grant access for ${unsafe}`).toHaveAccessibleName( - 'Allow access' - ) - await expect(allow).toBeDisabled() - } - await address.fill('http://intranet.example:8080/private-path') - await expect(allow).toHaveAccessibleName('Allow access to http://intranet.example:8080') - const allowedHost = 'https://approved-site.example' - await address.fill(`${allowedHost}/private-path?token=INPUT_SECRET#fragment`) - await expect(allow).toHaveAccessibleName(`Allow access to ${allowedHost}`) + const allow = panel.getByRole('button', { name: 'Allow across websites', exact: true }) + await expect(allow).toBeEnabled() assert( (await panel.locator('body').getAttribute('data-requested-access')) === null, - 'Typing initiated a permission request' + 'A failed capture initiated a permission request without an explicit action' ) const grantedSites = () => worker.evaluate(async () => ((await chrome.permissions.getAll()).origins ?? []).sort()) @@ -1394,7 +1371,7 @@ try { await expect(panel.locator('body')).toHaveAttribute('data-request-gesture', 'true') const requested = await panel.locator('body').getAttribute('data-requested-access') assert(requested, 'The user action never reached Chrome') - expect(JSON.parse(requested)).toEqual([{ origins: ['https://approved-site.example:443/*'] }]) + expect(JSON.parse(requested)).toEqual([{ origins: ['https://*/*', 'http://*/*'] }]) expect(await grantedSites(), 'A site received persistent access without approval').toEqual( previousAccess ) diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts index 984874204a9..e86f3278741 100644 --- a/apps/extension/src/capture.ts +++ b/apps/extension/src/capture.ts @@ -34,7 +34,7 @@ export class SiteAccessRequiredError extends PageCaptureError { readonly tabId: number, readonly windowId: number ) { - super('Allow site access to attach this page.') + super('Enable page access once to use pages across websites. ') } } diff --git a/apps/extension/src/shortcut-hint.tsx b/apps/extension/src/shortcut-hint.tsx deleted file mode 100644 index 543390bdb1c..00000000000 --- a/apps/extension/src/shortcut-hint.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useState } from 'react' -import { OPEN_SIM_COMMAND } from '@/config' - -/** Reads the binding in this Chrome profile, including changes made in the shortcut settings. */ -export function ShortcutHint() { - const [shortcut, setShortcut] = useState(null) - useEffect(() => { - let revision = 0 - const read = () => { - const current = ++revision - void chrome.commands - .getAll() - .then((commands) => { - if (current === revision) - setShortcut(commands.find((entry) => entry.name === OPEN_SIM_COMMAND)?.shortcut ?? '') - }) - .catch(() => { - if (current === revision) setShortcut(null) - }) - } - read() - window.addEventListener('focus', read) - return () => { - revision++ - window.removeEventListener('focus', read) - } - }, []) - if (shortcut === null) return null - return ( -

- {shortcut ? ( - <> - For one-time access, press {shortcut} on the page. - - ) : ( - - )} -

- ) -} diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx index dc74d3d56f8..3eabe6371aa 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -4,7 +4,6 @@ import { type ExtensionToAppMessage, type PageSnapshot, } from '@sim/browser-extension-protocol' -import { Chip } from '@sim/emcn' import { createRoot } from 'react-dom/client' import { captureActivePage, @@ -17,8 +16,7 @@ import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIG import { type CaptureIntent, captureIntentSchema } from '@/intent' import { createReferenceCapture } from '@/reference' import { SearchRecoveryDialog } from '@/search-recovery-dialog' -import { ShortcutHint } from '@/shortcut-hint' -import { SiteAccessDialog } from '@/site-access' +import { PageAccessButton, SiteAccessDialog } from '@/site-access' import '@/shell.css' const BLOCKED_SELECTION = @@ -404,12 +402,11 @@ function SidePanel({ origin }: SidePanelProps) { > {typeof status === 'string' ? status : status.message} {status instanceof SiteAccessRequiredError && ( - <> - setAccessOpen(true)}> - Request site access - - - + + setStatus((current) => (current instanceof SiteAccessRequiredError ? '' : current)) + } + /> )}