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
22 changes: 22 additions & 0 deletions docs/privacy-and-data-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,14 @@ the stored copies are not separately synced to WebBrain.

### Trace Recorder

**Local feedback diagnostics** is enabled by default under Settings → Display.
When ordinary trace recording is off, it retains metadata-only diagnostics for
up to ten completed runs, seven days, and 2 MiB (with a 128 KiB per-run cap).
These automatic records never inherit lossless tracing and contain no raw
conversation/tool text or screenshot bytes. Turning the setting off stops this
recording and removes its automatic history; explicitly recorded traces remain.
Retention and event-budget omissions are identified in feedback exports.

When enabled (Settings → Display → "Record traces"), every agent run is written
to the local `webbrain_traces` IndexedDB database in one of two privacy tiers.
An external/local provider with **Share queries for research** enabled also
Expand Down Expand Up @@ -480,6 +488,19 @@ The only outbound HTTP requests are:
7. **Encrypted Cloud Sync calls** to `https://api.webbrain.one/v1/sync` (only after a subscriber explicitly enables sync; vault content is encrypted before upload)
8. **Slash-driven tab/screen recording** creates no outbound traffic (the .webm is saved to the Downloads folder via `chrome.downloads.download`)
9. **Voluntary research shares** to `https://api.webbrain.one/v1/improvement/generations` and `/v1/improvement/diagnostic-traces` (only for a local or bring-your-own provider with its separate sharing switch enabled)
10. **Feedback trace attachments** uploaded by GitHub's native issue editor,
only after the user chooses **Upload trace and open GitHub** in the extension.
Preparing, viewing, and downloading the trace are local operations. The
confirmation identifies the conversation, recorded content tier, run and
screenshot counts, size, and any fallback or omissions. GitHub makes the
attachment publicly accessible immediately, before the issue is submitted.
**Continue without trace** uploads no trace. Export scrubs credential-shaped
values, but conversation/page text and screenshots can still be sensitive.
Temporary exports expire after one hour and are removed after completion,
cancellation, or closing the destination tab. Failed handoffs can retry the
same authorized bytes. Full traces that exceed the attachment limit use an
explicitly disclosed diagnostic fallback; the original stays available as
a local download.

The `webRequest` API shortcut observer is on by default and does not
create outbound requests; it observes replay metadata for requests
Expand Down Expand Up @@ -718,6 +739,7 @@ CDP capture → JPEG/PNG data URL
| Provider prompt/tool tier | Choose Compact, Mid, or Full tool exposure for non-cloud providers |
| Ask / Act / Dev mode | Choose read-only, normal action, or developer/page-inspection mode |
| Tracing toggle | Controls ordinary local trace recording; a separately opted-in provider research share records a run for metadata-only diagnostic upload even when this toggle is off |
| Local feedback diagnostics | Keeps bounded metadata locally when ordinary tracing is off; disabling it clears automatic history. Uploading a feedback attachment requires separate confirmation. |
| Screenshot fallback | Controls whether page images are sent to the LLM |
| Auto-screenshot mode | Controls how frequently viewport captures are sent |
| Strict secret handling | Keeps credentials out of assistant text and completion summaries: an instruction to the model, plus exact-match redaction in cloud runs of anything it typed, sent, or read from a labelled field |
Expand Down
24 changes: 21 additions & 3 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,28 @@ persistent setting, which remains active until the user turns it off.

## Trace Data Isolation

The separately configurable **Local feedback diagnostics** setting is enabled
by default. When ordinary recording is disabled, it keeps only metadata locally,
bounded to ten completed runs, seven days, 2 MiB total, and 128 KiB per run.
It cannot enable lossless recording. Disabling it clears automatic records and
leaves explicit recordings intact.

Feedback prepares a snapshot of the conversation selected when the rating prompt
opened. A dedicated dialog must authorize the actual attachment before it leaves
the browser: GitHub uploads files immediately, even before issue submission.
Viewing a trace and continuing without it never authorize an upload. The handoff
is restricted to the chat panel, an exact GitHub repository and destination tab,
the top frame, an expiring random token, and immutable staged bytes. Transfers
are chunked; reloads do not automatically repeat an uncertain upload. The user
reviews and submits the GitHub issue separately. Legacy and lossless feedback
exports mask credential-shaped data, but this does not anonymize screenshots or
arbitrary conversation/page content.

The trace recorder (`trace/recorder.js`) writes to IndexedDB on the user's
machine when explicitly enabled (Settings → Display → "Record traces") or
when a local/bring-your-own provider's separate **Share queries for research**
switch is enabled for that run. The latter forces a local record so a bounded,
machine for bounded automatic feedback diagnostics, when explicitly enabled
(Settings → Display → "Record traces"), or when a local/bring-your-own provider's
separate **Share queries for research** switch is enabled for that run. The latter
forces a local record so a bounded,
content-free diagnostic timeline can be uploaded under that explicit consent.
The default tier is metadata-only: run records omit user and final assistant
text; event records keep allowlisted counts, timings, usage, status/error codes,
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"private": true,
"type": "module",
"scripts": {
"test": "npm run test:cms && npm run test:markdown && npm run test:generative-media && npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs test/capsolver-cloud-broker.mjs test/captcha-providers.mjs test/captcha-weighted-fallback.mjs test/captcha-hcaptcha-providers.mjs test/captcha-native-providers.mjs test/captcha-runtime-policy.mjs test/captcha-callback-bridge.mjs test/captcha-aws-recovery.mjs test/captcha-documented-contracts.mjs test/huggingface-signup-recovery.mjs test/trace-full-export.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:billing-notice && npm run test:compass-quota && npm run test:provider-limits && npm run test:provider-catalog && npm run test:openrouter-capabilities && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:cloud-bridge-approval && npm run test:discord && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && npm run test:workflow-editor && node --test test/profile-sync-stats.mjs && node --test test/memcode-recall.mjs && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security",
"test": "npm run test:cms && npm run test:markdown && npm run test:generative-media && npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs test/capsolver-cloud-broker.mjs test/captcha-providers.mjs test/captcha-weighted-fallback.mjs test/captcha-hcaptcha-providers.mjs test/captcha-native-providers.mjs test/captcha-runtime-policy.mjs test/captcha-callback-bridge.mjs test/captcha-aws-recovery.mjs test/captcha-documented-contracts.mjs test/huggingface-signup-recovery.mjs test/trace-full-export.mjs test/feedback-traces.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:billing-notice && npm run test:compass-quota && npm run test:provider-limits && npm run test:provider-catalog && npm run test:openrouter-capabilities && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:cloud-bridge-approval && npm run test:discord && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && npm run test:workflow-editor && node --test test/profile-sync-stats.mjs && node --test test/memcode-recall.mjs && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security",
"test:captcha:ui": "node test/captcha-settings-ui.mjs && node test/captcha-application-ui.mjs",
"test:captcha:bridge:ui": "node test/captcha-callback-bridge-ui.mjs",
"test:billing-notice": "node test/billing-payment-notice.mjs",
Expand Down Expand Up @@ -71,7 +71,8 @@
"test:compass-quota:ui": "node test/compass-quota-ui.mjs",
"test:generative-media": "node --test test/generative-media.mjs test/generative-media-availability.mjs",
"test:generative-media:ui": "node test/generative-media-ui.mjs",
"test:generative-media:extension": "node test/generative-media-extension.mjs"
"test:generative-media:extension": "node test/generative-media-extension.mjs",
"test:feedback": "node --test test/feedback-traces.mjs"
},
"devDependencies": {
"playwright": "^1.48.0",
Expand Down
32 changes: 29 additions & 3 deletions src/chrome/src/agent/trace-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*/

import { isKnownKind } from '../trace/event-model.js';
import { isErrorCode } from '../trace/error-codes.js';
import { isSensitiveCloudKey } from '../cloud-runs.js';

const ARGS_LIMIT = 300;
Expand Down Expand Up @@ -55,15 +56,40 @@ function redactExportValue(value, key = '') {
return Object.fromEntries(Object.entries(value).map(([childKey, item]) => [childKey, redactExportValue(item, childKey)]));
}

export function sanitizeTraceExport(payload) {
if (payload?.run?.lossless === true) return redactExportValue(payload);
const DIAGNOSTIC_CODE_KINDS = new Set(['error', 'turn_start', 'turn_end', 'step_start', 'step_end']);

function restoreDiagnosticCodes(entry, redacted) {
if (!Array.isArray(entry?.events) || !Array.isArray(redacted?.events)) return;
entry.events.forEach((event, index) => {
const data = redacted.events[index]?.data;
if (!data || typeof data !== 'object') return;
// Only catalog codes in recorder metadata are public diagnostics. A code
// in tool arguments/results or conversation content remains a credential.
if (DIAGNOSTIC_CODE_KINDS.has(event.kind) && isErrorCode(event.data?.code)) data.code = event.data.code;
if (event.kind === 'note' && data.extra && isErrorCode(event.data?.extra?.code)) data.extra.code = event.data.extra.code;
});
}

function redactTraceExport(payload) {
const redacted = redactExportValue(payload);
restoreDiagnosticCodes(payload, redacted);
if (Array.isArray(payload?.runs) && Array.isArray(redacted?.runs)) {
payload.runs.forEach((entry, index) => restoreDiagnosticCodes(entry, redacted.runs[index]));
}
return redacted;
}

export function sanitizeTraceExport(payload, { allRuns = false } = {}) {
// Feedback can include legacy rows that predate the lossless marker.
if (allRuns) return redactTraceExport(payload);
if (payload?.run?.lossless === true) return redactTraceExport(payload);
if (!Array.isArray(payload?.runs)) return payload;

let changed = false;
const runs = payload.runs.map((entry) => {
if (entry?.run?.lossless !== true) return entry;
changed = true;
return redactExportValue(entry);
return redactTraceExport(entry);
});

return changed ? { ...payload, runs } : payload;
Expand Down
3 changes: 3 additions & 0 deletions src/chrome/src/background.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { resolveDecisionConfig, listDecisionModels } from './agent/decision-config.js';
import { probeDecisionVision } from './agent/decision-vision-probe.js';
import { installSafeSocialBackground } from './safesocial/background.js';
import { createFeedbackHandoff } from './feedback-handoff.js';
const feedbackHandoff = createFeedbackHandoff(chrome);
import { ProviderManager } from './providers/manager.js';
import {
WEBGPU_COMPASS_TINY_V2_MODEL_ID,
Expand Down Expand Up @@ -2837,6 +2839,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
});

async function handleMessage(msg, sender) {
if (String(msg.action || '').startsWith('feedback_')) return feedbackHandoff.handle(msg, sender);
if (msg.action === 'chat_steer') {
// Content scripts must never turn page text into a trusted human correction.
if (sender?.url?.split(/[?#]/)[0] !== chrome.runtime.getURL('src/ui/sidepanel.html')) {
Expand Down
2 changes: 2 additions & 0 deletions src/chrome/src/config-transfer.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const DEFAULT_CONFIG_SETTINGS = Object.freeze({
completionConfetti: true,
completionFlashTab: true,
tracingEnabled: false,
feedbackDiagnosticsEnabled: true,
strictSecretMode: false,
agentAllowLocalNetwork: false,
scheduledTasksEnabled: true,
Expand Down Expand Up @@ -136,6 +137,7 @@ const BOOLEAN_KEYS = new Set([
'completionConfetti',
'completionFlashTab',
'tracingEnabled',
'feedbackDiagnosticsEnabled',
'strictSecretMode',
'agentAllowLocalNetwork',
'scheduledTasksEnabled',
Expand Down
137 changes: 137 additions & 0 deletions src/chrome/src/content/feedback-attachment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
(() => {
if (globalThis.__webbrainFeedbackAttachment) return;
globalThis.__webbrainFeedbackAttachment = true;
const api = typeof browser !== 'undefined' ? browser : chrome;
const destination = () => location.origin === 'https://github.com'
&& location.pathname === '/webbrain-one/webbrain/issues/new';
if (!destination()) return;
let draft, busy = false, banner, message, retryButton, downloadButton;
const send = async (action, extra = {}) => {
const result = await api.runtime.sendMessage({ target: 'background', action, id: draft?.id, token: draft?.token, ...extra });
if (!result?.ok) throw new Error(result?.error || 'Feedback handoff failed.');
return result;
};
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function editor() {
const scope = document.querySelector('main') || document.body;
return scope.querySelector('textarea[name="issue[body]"], textarea#issue_body')
|| [...scope.querySelectorAll('textarea')].find(input => !input.disabled && !input.readOnly);
}
function fileInput(input) {
if (!input) return null;
for (const scope of [input.closest('file-attachment'), input.closest('form'), document.querySelector('main')]) {
if (!scope) continue;
const candidates = [...scope.querySelectorAll('input[type="file"]')].filter(field => !field.disabled);
if (candidates.length === 1) return candidates[0];
}
return null;
}
function attached(input) {
// An arbitrary link or the filename by itself is not upload evidence.
return [...String(input?.value || '').matchAll(/\[([^\]]+)\]\((https:\/\/[^\s)]+)\)/g)].some(match => {
try {
const url = new URL(match[2]);
return match[1] === draft.filename && url.origin === 'https://github.com'
&& (url.pathname.startsWith('/user-attachments/files/') || url.pathname.startsWith('/files/'));
} catch { return false; }
});
}
function show(text, failed = false) {
message.textContent = text;
retryButton.hidden = !failed || !draft.includeTrace;
downloadButton.hidden = !failed || !draft.includeTrace;
}
function createBanner() {
banner = document.createElement('aside');
banner.setAttribute('role', 'status');
banner.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;max-width:420px;padding:16px;border:1px solid #8b949e;border-radius:8px;background:#fff;color:#24292f;box-shadow:0 4px 24px #0003;font:14px/1.5 system-ui';
message = document.createElement('p'); message.style.margin = '0 0 8px';
retryButton = document.createElement('button'); retryButton.type = 'button';
retryButton.textContent = draft.copy.retry || 'Retry attachment'; retryButton.hidden = true;
retryButton.addEventListener('click', async () => {
if (busy) return;
retryButton.disabled = true;
try { await send('feedback_retry'); await upload(); }
catch (error) { show(error.message, true); }
finally { retryButton.disabled = false; }
});
downloadButton = document.createElement('button'); downloadButton.type = 'button';
downloadButton.textContent = draft.copy.download || 'Download trace'; downloadButton.hidden = true;
downloadButton.addEventListener('click', () => void send('feedback_preview').catch(error => show(error.message, true)));
const close = document.createElement('button'); close.type = 'button'; close.textContent = '×';
close.setAttribute('aria-label', draft.copy.close || 'Close');
close.style.cssText = 'float:right;margin-left:8px'; close.addEventListener('click', () => banner.remove());
banner.append(close, message, retryButton, downloadButton); document.body.append(banner);
}
async function waitForEditor() {
for (let attempt = 0; attempt < 120 && destination(); attempt++) {
const input = editor();
if (input) return input;
await sleep(250);
}
throw new Error(draft.copy.failed || 'The GitHub editor is not available.');
}
async function complete() {
await send('feedback_result', { success: true });
show(draft.copy.ready || 'Trace attached. Review and submit your feedback on GitHub.');
}
async function upload() {
if (busy || !destination()) return;
busy = true;
try {
const input = await waitForEditor();
if (attached(input)) { await complete(); return; }
show(draft.copy.uploading || 'Attaching your trace. Please wait before submitting the issue.');
const chunks = [];
for (let offset = 0; offset < draft.size; offset += draft.chunkBytes) {
if (!destination()) throw new Error('The feedback destination changed.');
const response = await send('feedback_chunk', { offset });
const binary = atob(response.base64);
chunks.push(Uint8Array.from(binary, character => character.charCodeAt(0)));
}
let target;
for (let attempt = 0; attempt < 80 && destination(); attempt++) {
target = fileInput(editor());
if (target) break;
await sleep(250);
}
if (!target || !destination()) throw new Error(draft.copy.failed || 'The GitHub upload control is not available.');
await send('feedback_upload_started');
const transfer = new DataTransfer();
transfer.items.add(new File(chunks, draft.filename, { type: draft.mimeType || 'application/json' }));
target.files = transfer.files;
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
for (let attempt = 0; attempt < 240 && destination(); attempt++) {
if (attached(editor())) { await complete(); return; }
await sleep(250);
}
throw new Error(draft.copy.failed || 'The trace attachment could not be confirmed.');
} catch (error) {
await send('feedback_result', { success: false }).catch(() => {});
show(draft.copy.failed || error.message, true);
} finally { busy = false; }
}
void (async () => {
draft = await send('feedback_claim');
draft.copy ||= {};
createBanner();
const input = await waitForEditor();
// React listens to native input events. Never replace text the user edited.
if (!input.value || input.value.trim() === draft.initialBody.trim()) {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
setter.call(input, draft.body);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
if (!draft.includeTrace) {
await send('feedback_result', { success: true });
show(draft.copy.draftReady || 'Review and submit your feedback on GitHub.');
} else if (attached(input)) await complete();
else if (draft.status === 'uploading' || draft.status === 'failed') show(draft.copy.failed || 'Check the draft, then retry the attachment if needed.', true);
else await upload();
})().catch(error => {
if (banner) show(draft?.copy?.failed || error.message, true);
else globalThis.__webbrainFeedbackAttachment = false;
});
})();
Loading
Loading