Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Rules:
- Never use the Dewee gateway master token as a client credential. Self-hosted mode uses a personal scoped API key (`operator.read`/`operator.write`).
- Never render agent-generated HTML. Cards render from a fixed list of kinds validated with Zod; markdown is sanitized with DOMPurify.
- No remote code, no `eval`, strict extension CSP. Host permissions are requested per gateway origin at runtime, never `<all_urls>`.
- The side panel never reads the active page.
- The side panel never reads the active page. The only exception is the opt-in "Ask Dewee" page script, which sends the text the person selected (never form fields) when they click its button, and only as an editable draft.

## Code layout

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The extension is a thin Chrome MV3 client. Dewee (Gateway, or the CCP app for De
- The panel calls the API directly. Moving every call through the service worker would add a message hop per SSE frame and a second place where tokens live. Only OAuth refresh, which must be single flight across pages, runs in the background.
- Secrets never cross `chrome.runtime` messaging. The background message schema has no secret fields.
- Card field labels and target names come from the extension's own copy. Agent text is shown as plain text, so an agent cannot dress up a phishing form.
- Host permissions are optional and requested per origin at connect time. There is no `<all_urls>` and no content script.
- Host permissions are optional and requested per origin at connect time. There is no `<all_urls>`. The only page script is the opt-in "Ask Dewee" button: turning it on in Settings requests `https://*/*` and registers the script at runtime; turning it off unregisters it. A click sends the selected text, title and address to the service worker, which opens the side panel and leaves a quoted draft in `storage.session` for the composer of that window. Nothing is sent to Dewee until the person submits.
- One workspace per view. A load error in one workspace never falls back to another workspace or profile.

See [api-contract.md](api-contract.md) for the wire contract and the two threat models for security reasoning.
5 changes: 4 additions & 1 deletion docs/auth-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ The Dewee gateway master token is never accepted as an extension credential, and
| Wrong workspace or tenant | Every call names the workspace; Dewee enforces membership and returns 404 for non-members. The client never falls back to another workspace or profile. |
| Expired or revoked credential | 401 → one renew (Cloud) → on failure the credential is cleared and the panel asks to reconnect that same profile. |
| Sign-out leaves a live token | Sign-out calls revoke (best effort) and clears both storage areas. Removing a profile also drops its host permission when no other profile uses the origin. |
| Messages from other extensions or pages | The background accepts messages only from its own extension id and validates them with Zod. It returns the `Authorization` value only to those pages, which hold it in memory for the request. A network failure while renewing keeps the credential; only a 401 signs the profile out. |
| Messages from other extensions or pages | The background accepts messages only from its own extension id and validates them with Zod. It answers auth requests only from its own extension pages (a content script shares the id but has a web URL), which hold the `Authorization` value in memory for the request. A network failure while renewing keeps the credential; only a 401 signs the profile out. |
| A web page abusing the "Ask Dewee" script | The script runs only when the person turns the feature on and grants site access. The script is registered only after `storage.local` (remembered credentials) is limited to extension pages. A message from it (a tab sender, top frame, non-extension URL) goes only to the selection handler: one schema-checked `ask.selection` that opens the panel and leaves a quoted draft in `storage.session`, never sent automatically. It never reaches the auth handlers or a credential. The page sees only a closed shadow-root button, which ignores clicks made by page script. Once the feature is off, a page that still has the script cannot open the panel. |
| Page secrets carried into a draft | Selections inside inputs, text areas and editable regions, or made while one has focus, are ignored. The address drops its fragment, user info and query values whose names look like credentials. The draft is plain text the person reads and edits before sending. |
| Remote code | Strict CSP (`script-src 'self'`), no `eval`, no remote scripts. |

## Residual risks

- Malware with access to the Chrome profile can read `storage.session` while the browser runs. Tokens are short-lived and revocable.
- "Remember" keeps the credential on disk in the Chrome profile; the person accepts this when they tick it.
- "Ask Dewee" needs read access to every https page while it is on. Turning it off removes the script, but Chrome keeps the site access until the person removes it in the extension settings, because removing it could also drop gateway origins covered by the same grant.
- Self-hosted operators must allowlist the extension origin (`GOCLAW_ALLOWED_ORIGINS`) and keep API keys scoped.
131 changes: 131 additions & 0 deletions src/ask/selection-button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* The "Ask Dewee" button shown next to text the person selects on a web page. It reads only the
* selection, the page title and the address, and only when the button is clicked. Selections in
* form fields and editable areas are ignored, so typed passwords and drafts are never offered.
*/
import { MAX_SELECTION, type AskSelection } from '@/protocol/ask-selection';

const STYLE = `
:host { all: initial; }
button {
position: fixed; z-index: 2147483647; display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px; border: 1px solid rgb(255 255 255 / 0.14); border-radius: 999px;
background: #1b1840; color: #f4f2ff; box-shadow: 0 6px 20px rgb(15 12 40 / 0.28);
font: 500 12px/1 system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: 0; cursor: pointer;
}
button:hover { background: #2a2560; }
button:focus-visible { outline: 2px solid #8f7cff; outline-offset: 2px; }
svg { width: 13px; height: 13px; }
`;

const SPARKLE =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/></svg>';

const FIELD = 'input, textarea, select';

/** Fields whose selection must never be sent: inputs, text areas and editable regions. */
function inEditable(node: Node | null): boolean {
const element = node instanceof Element ? node : node?.parentElement;
return Boolean(element && (element.closest(FIELD) || (element as HTMLElement).isContentEditable || element.closest('[contenteditable]:not([contenteditable="false" i])')));
}

/** Chrome reports text selected inside an input or text area with the field's parent as anchor, so check focus too. */
function focusInField(doc: Document): boolean {
// Follow focus into web components, whose host element is all the document reports.
let active = doc.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return Boolean(active && (active.matches(FIELD) || (active as HTMLElement).isContentEditable));
}

export function selectedText(doc: Document): { text: string; rect: DOMRect } | null {
const selection = doc.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
if (focusInField(doc) || inEditable(selection.anchorNode) || inEditable(selection.focusNode)) return null;
const text = selection.toString().trim();
if (!text) return null;
return { text: text.slice(0, MAX_SELECTION + 1), rect: selection.getRangeAt(0).getBoundingClientRect() };
}

/**
* Mounts the hidden button and its listeners; dispose removes both. `byPerson` decides whether a click
* came from the person rather than page script (tests replace it, since they cannot make trusted events).
*/
export function mountAskButton(
doc: Document,
send: (message: AskSelection) => unknown,
label = defaultLabel(doc),
byPerson: (event: MouseEvent) => boolean = (event) => event.isTrusted,
): { button: HTMLButtonElement; dispose(): void } {
const host = doc.createElement('dewee-ask');
const root = host.attachShadow({ mode: 'closed' });
if ('adoptedStyleSheets' in root && typeof CSSStyleSheet === 'function' && 'replaceSync' in CSSStyleSheet.prototype) {
// Constructed sheets are not blocked by a page's style-src policy.
const sheet = new CSSStyleSheet();
sheet.replaceSync(STYLE);
root.adoptedStyleSheets = [sheet];
} else {
const style = doc.createElement('style');
style.textContent = STYLE;
root.append(style);
}
const button = doc.createElement('button');
button.type = 'button';
button.innerHTML = SPARKLE; // Static markup from this file, never page or agent content.
button.append(doc.createTextNode(label));
button.hidden = true;
root.append(button);
let current: string | null = null;

const hide = () => {
button.hidden = true;
current = null;
};
const show = () => {
const found = selectedText(doc);
if (!found) return hide();
current = found.text;
const view = doc.defaultView;
const width = view?.innerWidth ?? 1024;
const height = view?.innerHeight ?? 768;
const top = found.rect.bottom + 8 > height - 40 ? Math.max(8, found.rect.top - 40) : found.rect.bottom + 8;
button.style.top = `${Math.round(top)}px`;
button.style.left = `${Math.round(Math.min(Math.max(8, found.rect.right - 100), width - 120))}px`;
if (!host.isConnected) doc.documentElement.append(host);
button.hidden = false;
};
// Wait for the selection to settle; events on the button itself must not show it again after a click.
const later = (event: Event) => {
if (!event.composedPath().includes(host)) setTimeout(show, 0);
};
const onKey = (event: KeyboardEvent) => (event.key === 'Escape' ? hide() : later(event));
const onDown = (event: MouseEvent) => {
if (!event.composedPath().includes(host)) hide();
};

// Keep the page selection while the button is pressed.
button.addEventListener('mousedown', (event) => event.preventDefault());
// Only a real click: a page cannot press the button from script to slip its own text into a draft.
button.addEventListener('click', (event) => {
if (!current || !byPerson(event)) return;
send({ type: 'ask.selection', text: current, url: doc.location.href, title: doc.title });
hide();
});
doc.addEventListener('mouseup', later);
doc.addEventListener('keyup', onKey);
doc.addEventListener('mousedown', onDown, true);
doc.defaultView?.addEventListener('scroll', hide, { passive: true, capture: true });

const dispose = () => {
doc.removeEventListener('mouseup', later);
doc.removeEventListener('keyup', onKey);
doc.removeEventListener('mousedown', onDown, true);
doc.defaultView?.removeEventListener('scroll', hide, { capture: true });
host.remove();
};
return { button, dispose };
}

function defaultLabel(doc: Document): string {
const lang = doc.defaultView?.navigator.language ?? 'en';
return lang.toLowerCase().startsWith('vi') ? 'Hỏi Dewee' : 'Ask Dewee';
}
84 changes: 84 additions & 0 deletions src/background/ask-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Opt-in "Ask Dewee" on selected page text. The content script is registered only while the
* preference is on and the person has granted site access; otherwise it is unregistered and the
* extension runs nothing in web pages.
*/
import { browser } from 'wxt/browser';
import { ASK_DRAFT_KEY, ASK_ORIGINS, askSelectionSchema, formatAskDraft, type AskDraft } from '@/protocol/ask-selection';
import { loadSettings } from '@/shared/settings';

export const ASK_SCRIPT_ID = 'ask-dewee';
const ASK_SCRIPT_FILE = 'content-scripts/ask-dewee.js';

let syncing: Promise<unknown> = Promise.resolve();
/** Last known state of the page script; undefined until the first sync in this worker. */
let active: boolean | undefined;
let localLocked = false;

/**
* Limits storage.local, where remembered credentials live, to extension pages. The page script is
* registered only once this has succeeded.
*/
export async function lockLocalStorage(): Promise<boolean> {
if (localLocked) return true;
try {
await chrome.storage.local.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' });
localLocked = true;
} catch {
// Not supported or refused: the page script stays off, and the next sync tries again.
}
return localLocked;
}

/** Registers or removes the content script to match the preference and the granted access. Runs one at a time. */
export function syncAskScript(): Promise<boolean> {
const run = syncing.then(async () => {
const [settings, allowed, locked] = await Promise.all([loadSettings(), browser.permissions.contains({ origins: ASK_ORIGINS }), lockLocalStorage()]);
const wanted = settings.prefs.askOnSelection && allowed && locked;
const registered = (await chrome.scripting.getRegisteredContentScripts({ ids: [ASK_SCRIPT_ID] })).length > 0;
// Not kept across browser restarts: restored tabs must not run the script before the worker locks storage.local again.
if (wanted && !registered) {
await chrome.scripting.registerContentScripts([{ id: ASK_SCRIPT_ID, js: [ASK_SCRIPT_FILE], matches: ASK_ORIGINS, runAt: 'document_idle', allFrames: false, persistAcrossSessions: false }]);
} else if (!wanted && registered) {
await chrome.scripting.unregisterContentScripts({ ids: [ASK_SCRIPT_ID] });
}
active = wanted;
return wanted;
});
syncing = run.catch(() => undefined);
return run;
}

/** A message from one of this extension's own pages (side panel, options). Only these reach the auth handlers. */
export function isFromExtensionPage(sender: chrome.runtime.MessageSender): boolean {
return sender.id === browser.runtime.id && Boolean(sender.url?.startsWith(browser.runtime.getURL('/')));
}

/** A message sent by the content script in a tab's top-level web page. */
export function isFromWebPage(sender: chrome.runtime.MessageSender): sender is chrome.runtime.MessageSender & { tab: chrome.tabs.Tab } {
if (sender.id !== browser.runtime.id || !sender.tab || !sender.url || isFromExtensionPage(sender)) return false;
// A prerendered page keeps a non-zero frame id after activation, so prefer the frame type when Chrome reports it.
const { frameType } = sender as { frameType?: string };
return frameType ? frameType === 'outermost_frame' : sender.frameId === 0;
}

/**
* Handles the button click from a web page. The panel must open while the click still counts as a
* user gesture, so `sidePanel.open` runs before anything is awaited; the draft is saved after.
* Returns false for anything that is not a valid `ask.selection` from a top-level web page.
*/
export function handleAskSelection(message: unknown, sender: chrome.runtime.MessageSender): boolean {
if (!isFromWebPage(sender)) return false;
const parsed = askSelectionSchema.safeParse(message);
// A page loaded while the feature was on keeps its script after it is turned off; ignore it then.
if (!parsed.success || active === false) return false;
const windowId = sender.tab.windowId;
void chrome.sidePanel.open({ windowId }).catch(() => undefined);
void (async () => {
// The worker may have just started and not synced yet: check the preference before keeping the draft.
if (!(await loadSettings()).prefs.askOnSelection) return;
const draft: AskDraft = { id: crypto.randomUUID(), text: formatAskDraft({ ...parsed.data, url: sender.tab.url ?? parsed.data.url }), createdAt: Date.now(), windowId };
await browser.storage.session.set({ [ASK_DRAFT_KEY]: draft });
})().catch(() => undefined);
return true;
}
40 changes: 33 additions & 7 deletions src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
import { browser } from 'wxt/browser';
import { isApiError } from '@/api/errors';
import { requestSchema, type BackgroundResponse } from '@/protocol/messages';
import { SETTINGS_KEY } from '@/shared/settings';
import { handleAskSelection, isFromExtensionPage, isFromWebPage, lockLocalStorage, syncAskScript } from './ask-selection';
import { BADGE_ALARM, refreshPendingBadge } from './pending-badge';
import { authorizationFor, renewFor, signIn, signOut } from './token-manager';

async function handle(message: unknown, senderId: string | undefined): Promise<BackgroundResponse> {
// Only this extension's own pages may talk to the worker.
if (senderId !== browser.runtime.id) return { ok: false, code: 'forbidden' };
async function handle(message: unknown, sender: chrome.runtime.MessageSender): Promise<BackgroundResponse> {
// Only this extension's own pages may talk to the worker; a content script carries the same id but a web URL.
if (!isFromExtensionPage(sender)) return { ok: false, code: 'forbidden' };
const parsed = requestSchema.safeParse(message);
if (!parsed.success) return { ok: false, code: 'invalid_request' };
const request = parsed.data;
Expand Down Expand Up @@ -39,18 +41,42 @@ async function handle(message: unknown, senderId: string | undefined): Promise<B
}

export function startServiceWorker(): void {
// Open the side panel when the toolbar icon is clicked (a user gesture); never inject into pages.
// Open the side panel when the toolbar icon is clicked (a user gesture). Nothing runs in web pages unless
// the person turns on "Ask Dewee" for selected text and grants site access.
void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
// Remembered credentials live in `storage.local`; keep it out of reach of the opt-in page script.
void lockLocalStorage();

browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
void handle(message, sender.id).then(sendResponse);
// A web page's content script may only send `ask.selection`; it never reaches the auth handlers.
if (isFromWebPage(sender)) {
handleAskSelection(message, sender);
return false;
}
void handle(message, sender).then(sendResponse);
return true;
});

// The page script follows the preference and the granted site access.
const syncAsk = () => void syncAskScript().catch(() => undefined);
browser.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && SETTINGS_KEY in changes) syncAsk();
});
browser.permissions.onAdded.addListener(syncAsk);
browser.permissions.onRemoved.addListener(syncAsk);
// Learn the current state in a freshly started worker, so a stale page script is ignored.
syncAsk();

void browser.alarms.create(BADGE_ALARM, { periodInMinutes: 1 });
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === BADGE_ALARM) void refreshPendingBadge();
});
browser.runtime.onStartup.addListener(() => void refreshPendingBadge());
browser.runtime.onInstalled.addListener(() => void refreshPendingBadge());
browser.runtime.onStartup.addListener(() => {
void refreshPendingBadge();
syncAsk();
});
browser.runtime.onInstalled.addListener(() => {
void refreshPendingBadge();
syncAsk();
});
}
13 changes: 13 additions & 0 deletions src/entrypoints/ask-dewee.content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Opt-in page script for "Ask Dewee" on selected text. It is not in the manifest: the service worker
* registers it only after the person turns the feature on and grants site access.
*/
import { mountAskButton } from '@/ask/selection-button';

export default defineContentScript({
matches: ['https://*/*'],
registration: 'runtime',
main() {
mountAskButton(document, (message) => void browser.runtime.sendMessage(message).catch(() => undefined));
},
});
Loading
Loading