Skip to content
4 changes: 2 additions & 2 deletions docs/privacy-and-data-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,10 @@ the stored copies are not separately synced to WebBrain.
When enabled (Settings → Display → "Record traces"), every agent run is written to an IndexedDB database (`webbrain_traces`):

- **`runs` store**: model, provider, token totals, timestamps, user message, final content
- **`events` store**: per-step LLM request provenance, model responses, and tool calls with args and results. Request provenance contains counts, controlled prompt/mode labels, and declared prompt/tool policy revisions; it neither duplicates nor fingerprints raw system prompts, message text, tool schemas, or tool names.
- **`events` store**: per-step LLM request provenance, model responses, and tool calls with args and results. By default, request provenance contains counts, controlled prompt/mode labels, and declared prompt/tool policy revisions; it neither duplicates nor fingerprints raw system prompts, message text, tool schemas, or tool names. Users can explicitly enable the lossless debug tier in Settings → Display; those runs are visibly marked and retain bounded request messages and tool schemas for debugging. Both Markdown and JSON exports mask credential-shaped values for lossless runs before writing a file.
- **`shots` store**: screenshot blobs

The Traces page (`ui/traces.html`) reads from local IndexedDB only. Export produces a JSON blob saved to the user's Downloads folder. **No trace data ever leaves the browser.**
The Traces page (`ui/traces.html`) reads from local IndexedDB only. Export produces a JSON blob saved to the user's Downloads folder. **No trace data ever leaves the browser.** Lossless recording is off by default and uses per-request/result bounds; treat a lossless trace as sensitive even though exported credentials are masked.

Each run also records an allowlisted effective runtime snapshot (including mode
and prompt tier). Trace Markdown surfaces that snapshot and the privacy-safe
Expand Down
71 changes: 70 additions & 1 deletion src/chrome/src/agent/trace-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,81 @@
*/

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

const ARGS_LIMIT = 300;
const RESULT_LIMIT = 600;
const LOSSILESS_MESSAGE_PREVIEW_LIMIT = 2000;
const FOOTER = '_Screenshot pixels and vision descriptions are omitted here — see the Traces page for the complete record._';
const UNKNOWN_EVENTS_NOTE = (n) => `_Note: ${n} unknown event(s) skipped._`;

// Credential masking for the opt-in lossless tier. Exports of lossless runs
// contain real request content, so obvious secret shapes are masked before
// they reach a Markdown file — the same spirit as the strict-redaction path
// used elsewhere, kept pure and browser-neutral here.
const SECRET_PATTERNS = [
/\bsk-[A-Za-z0-9_-]{8,}/g,
/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
];

function maskSecrets(text) {
let out = String(text ?? '');
for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, '[redacted]');
out = out
.replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]')
.replace(/((?:^|[^a-zA-Z0-9_])["']?(?:authorization|cookie|password|passwd|passphrase|passcode|pincode|(?:verification|confirmation|security|auth|email|twofactor|2fa|mfa|onetime|recovery)[_ -]?code|secret|credential|private[_ -]?key|api[_ -]?key|(?:access|refresh)[_ -]?token|client[_ -]?secret|token|access[_ -]?key[_ -]?id|secret[_ -]?access[_ -]?key|otp)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}\]]+)/gi, '$1[redacted]')
.replace(/([?&](?:api[_-]?key|access[_-]?token|token|key)=)[^&\s]+/gi, '$1[redacted]');
return out;
}

function redactExportValue(value, key = '') {
if (isSensitiveCloudKey(key)) return '[redacted]';
if (typeof value === 'string') return maskSecrets(value);
if (Array.isArray(value)) return value.map(item => redactExportValue(item));
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value).map(([childKey, item]) => [childKey, redactExportValue(item, childKey)]));
}

export function sanitizeTraceExport(payload) {
return payload?.run?.lossless === true ? redactExportValue(payload) : payload;
}

// Lossless requests carry the full message/tool shape. Render a bounded,
// masked preview per message so the export stays readable without dumping
// every token of a 500 KB request.
function renderLosslessRequest(messages, tools) {
if (messages?._truncated === true) {
const total = Number(messages.length) || 0;
const head = truncate(oneLine(maskSecrets(messages.head || '')), LOSSILESS_MESSAGE_PREVIEW_LIMIT);
const toolNames = Array.isArray(messages.toolNames) ? messages.toolNames : [];
const lines = [`request truncated (${humanSize(total)} total): ${head || '(head unavailable)'}`];
if (toolNames.length) lines.push(`tools: ${toolNames.join(', ')}`);
return `\n${lines.join('\n')}`;
}
const list = Array.isArray(messages) ? messages : [];
if (!list.length) return ' (empty request log)';
const lines = [];
for (const message of list.slice(0, 12)) {
const role = oneLine(message?.role || '?');
const content = maskSecrets(
typeof message?.content === 'string'
? message.content
: (Array.isArray(message.content)
? message.content.map(block => block?.text || block?.image_url?.url || '').join(' ')
: ''),
);
const body = content ? truncate(oneLine(content), LOSSILESS_MESSAGE_PREVIEW_LIMIT) : '(no text)';
lines.push(`**${role}:** ${body}`);
}
if (list.length > 12) lines.push(`… +${list.length - 12} more message(s) omitted`);
if (Array.isArray(tools) && tools.length) {
lines.push(`tools: ${tools.map(tool => oneLine(tool?.function?.name || '?')).join(', ')}`);
} else if (tools?._truncated === true && Array.isArray(tools.toolNames) && tools.toolNames.length) {
lines.push(`tools: ${tools.toolNames.join(', ')}`);
}
return `\n${lines.join('\n')}`;
}

function oneLine(t) { return String(t ?? '').replace(/\s+/g, ' ').trim(); }
function humanSize(n) { return n >= 1024 ? `${(n / 1024).toFixed(1)}kb` : `${n}b`; }

Expand Down Expand Up @@ -202,7 +271,7 @@ export function tracesToMarkdown(runsWithEvents, {
Number.isFinite(d.imageBlockCount) ? `${d.imageBlockCount} image block${d.imageBlockCount === 1 ? '' : 's'}` : '',
Number.isFinite(d.documentBlockCount) ? `${d.documentBlockCount} document block${d.documentBlockCount === 1 ? '' : 's'}` : '',
].filter(Boolean).join(' · ');
md += `- 🧠 Model request: ${Number(d.messageCount) || 0} messages · ${Number(d.toolsCount) || 0} tools${media ? ` · ${media}` : ''}${renderLocalWikipediaRag(d.localWikipediaRag)}${renderPromptProvenance(d.promptProvenance)}\n`;
md += `- 🧠 Model request: ${Number(d.messageCount) || 0} messages · ${Number(d.toolsCount) || 0} tools${media ? ` · ${media}` : ''}${renderLocalWikipediaRag(d.localWikipediaRag)}${renderPromptProvenance(d.promptProvenance)}${d.lossless === true ? renderLosslessRequest(d.messages, d.tools) : ''}\n`;
} else if (ev.kind === 'llm_response') {
const content = String(d.content || '').trim();
if (!content) continue;
Expand Down
4 changes: 2 additions & 2 deletions src/chrome/src/cloud-runs.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const CLOUD_PERSIST_BYTES_LIMIT = 4 * 1024 * 1024;
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'aborted']);
// Suffix match on normalized keys (non-alnum stripped). Avoid bare `pin` as a
// suffix — it over-matches `spin`, `mapPin`, etc. Short exact keys live in the set.
const SENSITIVE_CLOUD_KEY = /(?:authorization|cookie|password|passwd|passphrase|passcode|pincode|(?:verification|confirmation|security|auth|email|twofactor|2fa|mfa|onetime)code|secret|credential|privatekey|apikey|token|accesskeyid|secretaccesskey)$/i;
const SENSITIVE_CLOUD_KEY = /(?:authorization|cookie|password|passwd|passphrase|passcode|pincode|(?:verification|confirmation|security|auth|email|twofactor|2fa|mfa|onetime|recovery)code|secret|credential|privatekey|apikey|token|accesskeyid|secretaccesskey)$/i;
const SENSITIVE_CLOUD_KEY_EXACT = new Set(['code', 'pin', 'otp', 'cvv', 'cvc', 'ssn']);
const LARGE_IMAGE_KEY = /(?:attachimage|screenshot|image|imagedata|dataurl)$/i;
const CLOUD_TEXT_ENTRY_TOOLS = new Set(['set_field', 'type_ax', 'type_text', 'iframe_type']);
Expand Down Expand Up @@ -81,7 +81,7 @@ export function normalizeCloudBridgeUrl(value = DEFAULT_CLOUD_BRIDGE_URL) {
return url.href;
}

function isSensitiveCloudKey(key) {
export function isSensitiveCloudKey(key) {
const normalizedKey = normalizedCloudKey(key);
if (!normalizedKey) return false;
return SENSITIVE_CLOUD_KEY.test(normalizedKey) || SENSITIVE_CLOUD_KEY_EXACT.has(normalizedKey);
Expand Down
Loading
Loading