feat: initial Silent Send browser extension

Chrome Manifest V3 extension that intercepts personal data and
substitutes it with user-defined replacements before sending to
Claude.ai. Hooks fetch() in the page's main world to catch API
requests, with bidirectional substitution (real→fake on send,
fake→real on display via reveal mode).

Includes popup UI with mapping management, live test/diff view,
activity log with badge count, options page with import/export,
and Shadow DOM traversal for Claude.ai compatibility.

https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw
This commit is contained in:
Claude
2026-03-25 23:26:13 +00:00
commit 4576597e64
19 changed files with 2100 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
/* Silent Send - Content Script Styles */
/* Subtle indicator when input contains sensitive data that will be substituted */
.ss-has-sensitive {
box-shadow: inset 0 -2px 0 0 rgba(16, 185, 129, 0.3) !important;
}
/* Highlight class for substituted text (when highlights enabled) */
.ss-highlight {
background: rgba(16, 185, 129, 0.15);
border-bottom: 1.5px solid rgba(16, 185, 129, 0.5);
border-radius: 2px;
padding: 0 1px;
}
/* Revealed text styling (when reveal mode shows real values in responses) */
.ss-revealed {
background: rgba(59, 130, 246, 0.1);
border-bottom: 1.5px dashed rgba(59, 130, 246, 0.5);
border-radius: 2px;
padding: 0 1px;
}
+313
View File
@@ -0,0 +1,313 @@
/**
* Silent Send - Page World Script
*
* Runs in the MAIN page world (injected by injector.js) so it can
* hook the real fetch() and XMLHttpRequest used by Claude.ai.
*
* Communicates back to the content script via window.postMessage.
*/
(function () {
'use strict';
// ============================================================
// Load config from the injector script's data attribute
// ============================================================
let mappings = [];
let settings = { enabled: true, revealMode: false, showHighlights: false };
try {
const configEl = document.querySelector('script[data-ss-config]');
if (configEl) {
const config = JSON.parse(configEl.getAttribute('data-ss-config'));
mappings = config.mappings || [];
settings = { ...settings, ...(config.settings || {}) };
}
} catch (e) {
console.warn('[Silent Send] Failed to parse initial config:', e);
}
// Listen for config updates from the content script
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data?.type === 'ss:config-updated') {
if (event.data.mappings) mappings = event.data.mappings;
if (event.data.settings) settings = { ...settings, ...event.data.settings };
}
});
// ============================================================
// Substitution Engine (inline — no module imports in page world)
// ============================================================
function substitute(text, maps) {
const replacements = [];
let result = text;
const sorted = [...maps].sort((a, b) => b.real.length - a.real.length);
for (const m of sorted) {
if (!m.enabled || !m.real || !m.substitute) continue;
const escaped = m.real.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escaped, m.caseSensitive ? 'g' : 'gi');
let match;
while ((match = regex.exec(result)) !== null) {
replacements.push({
original: match[0],
replaced: m.substitute,
category: m.category || 'general',
});
}
result = result.replace(regex, m.substitute);
}
return { text: result, replacements };
}
function reveal(text, maps) {
let result = text;
const sorted = [...maps].sort((a, b) => b.substitute.length - a.substitute.length);
for (const m of sorted) {
if (!m.enabled || !m.real || !m.substitute) continue;
const escaped = m.substitute.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escaped, m.caseSensitive ? 'g' : 'gi');
result = result.replace(regex, m.real);
}
return result;
}
// ============================================================
// Notify content script of substitutions (for badge + logging)
// ============================================================
function notifySubstitutions(replacements) {
window.postMessage({
type: 'ss:substitution-performed',
count: replacements.length,
replacements,
}, '*');
}
// ============================================================
// Process a JSON body — handles all known Claude API shapes
// ============================================================
function processBody(body) {
let modified = false;
const allReplacements = [];
// Shape 1: { prompt: "..." }
if (typeof body.prompt === 'string') {
const r = substitute(body.prompt, mappings);
if (r.replacements.length > 0) {
body.prompt = r.text;
allReplacements.push(...r.replacements);
modified = true;
}
}
// Shape 2: { content: [{ type: "text", text: "..." }] }
if (Array.isArray(body.content)) {
for (let i = 0; i < body.content.length; i++) {
const item = body.content[i];
if (item.type === 'text' && typeof item.text === 'string') {
const r = substitute(item.text, mappings);
if (r.replacements.length > 0) {
body.content[i] = { ...item, text: r.text };
allReplacements.push(...r.replacements);
modified = true;
}
}
}
}
// Shape 3: { messages: [{ role: "user", content: "..." }] }
if (Array.isArray(body.messages)) {
for (const msg of body.messages) {
if (msg.role !== 'user' && msg.role !== 'human') continue;
if (typeof msg.content === 'string') {
const r = substitute(msg.content, mappings);
if (r.replacements.length > 0) {
msg.content = r.text;
allReplacements.push(...r.replacements);
modified = true;
}
}
if (Array.isArray(msg.content)) {
for (let j = 0; j < msg.content.length; j++) {
if (msg.content[j].type === 'text') {
const r = substitute(msg.content[j].text, mappings);
if (r.replacements.length > 0) {
msg.content[j] = { ...msg.content[j], text: r.text };
allReplacements.push(...r.replacements);
modified = true;
}
}
}
}
}
}
return { modified, replacements: allReplacements };
}
// ============================================================
// Fetch Interception
// ============================================================
const originalFetch = window.fetch;
window.fetch = async function (url, options) {
if (!settings.enabled || mappings.length === 0) {
return originalFetch.call(this, url, options);
}
const urlStr = typeof url === 'string' ? url : url?.url || '';
const isTargetApi =
urlStr.includes('/api/organizations/') &&
(urlStr.includes('/chat_conversations/') ||
urlStr.includes('/completion') ||
urlStr.includes('/messages'));
if (isTargetApi && options?.body && typeof options.body === 'string') {
try {
const body = JSON.parse(options.body);
const { modified, replacements } = processBody(body);
if (modified) {
options = { ...options, body: JSON.stringify(body) };
notifySubstitutions(replacements);
console.log(
`[Silent Send] Substituted ${replacements.length} value(s) in fetch request`
);
}
} catch (e) {
// Not JSON — pass through
}
}
return originalFetch.call(this, url, options);
};
// ============================================================
// XMLHttpRequest Interception (fallback)
// ============================================================
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._ssUrl = url;
return origOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
if (
settings.enabled && mappings.length > 0 &&
typeof body === 'string' && this._ssUrl &&
(this._ssUrl.includes('/chat_conversations/') ||
this._ssUrl.includes('/completion') ||
this._ssUrl.includes('/messages'))
) {
try {
const parsed = JSON.parse(body);
const { modified, replacements } = processBody(parsed);
if (modified) {
body = JSON.stringify(parsed);
notifySubstitutions(replacements);
}
} catch (e) { /* pass through */ }
}
return origSend.call(this, body);
};
// ============================================================
// Response Observer (reveal mode)
// ============================================================
function observeResponses() {
const observer = new MutationObserver((mutations) => {
if (!settings.revealMode || mappings.length === 0) return;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// Claude response selectors
const responseEls = node.querySelectorAll
? node.querySelectorAll('[data-is-streaming], .font-claude-message, .prose, [class*="Message"]')
: [];
for (const el of responseEls) revealInElement(el);
if (node.matches?.('[data-is-streaming], .font-claude-message, .prose, [class*="Message"]')) {
revealInElement(node);
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function revealInElement(el) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let textNode;
while ((textNode = walker.nextNode())) {
const original = textNode.textContent;
const revealed = reveal(original, mappings);
if (revealed !== original) {
textNode.textContent = revealed;
}
}
}
// ============================================================
// Shadow DOM Traversal
// ============================================================
function traverseShadowRoots() {
const visited = new WeakSet();
function walk(root) {
const allElements = root.querySelectorAll('*');
for (const el of allElements) {
if (el.shadowRoot && !visited.has(el.shadowRoot)) {
visited.add(el.shadowRoot);
walk(el.shadowRoot);
}
}
}
// Initial + periodic scan
walk(document);
setInterval(() => walk(document), 3000);
}
// ============================================================
// Input Highlighting
// ============================================================
document.addEventListener('input', (e) => {
if (!settings.showHighlights || mappings.length === 0) return;
const target = e.target;
if (target.matches?.('[contenteditable], textarea, input[type="text"]')) {
const text = target.textContent || target.value || '';
let hasMatches = false;
for (const m of mappings) {
if (!m.enabled || !m.real) continue;
if (text.toLowerCase().includes(m.real.toLowerCase())) {
hasMatches = true;
break;
}
}
target.classList.toggle('ss-has-sensitive', hasMatches);
}
}, true);
// ============================================================
// Boot
// ============================================================
if (document.body) {
observeResponses();
} else {
document.addEventListener('DOMContentLoaded', observeResponses);
}
traverseShadowRoots();
console.log(
`[Silent Send] Active on ${location.hostname} with ${mappings.length} mapping(s)`
);
})();
+60
View File
@@ -0,0 +1,60 @@
/**
* Silent Send - Content Script Injector
*
* This runs in Chrome's ISOLATED content script world, where it has
* access to chrome.storage. It then injects the fetch-hooking code
* into the MAIN page world so it can intercept the actual fetch() calls.
*
* Communication: page script <-> content script via window.postMessage
*/
'use strict';
// Load mappings and settings, then inject into page
async function init() {
const result = await chrome.storage.local.get(['ss_mappings', 'ss_settings']);
const mappings = result.ss_mappings || [];
const settings = result.ss_settings || { enabled: true };
// Inject the main interception script into the page's world
const script = document.createElement('script');
script.setAttribute('data-ss-config', JSON.stringify({ mappings, settings }));
script.src = chrome.runtime.getURL('src/content/content.js');
(document.head || document.documentElement).appendChild(script);
script.onload = () => script.remove();
// Listen for substitution events from the page script
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data?.type === 'ss:substitution-performed') {
chrome.runtime.sendMessage({
type: 'substitution:performed',
count: event.data.count,
replacements: event.data.replacements,
}).catch(() => {});
}
});
// Forward storage changes to the page script
chrome.storage.onChanged.addListener((changes) => {
if (changes.ss_mappings || changes.ss_settings) {
window.postMessage({
type: 'ss:config-updated',
mappings: changes.ss_mappings?.newValue,
settings: changes.ss_settings?.newValue,
}, '*');
}
});
// Listen for settings updates from popup via runtime messages
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'settings:updated') {
window.postMessage({
type: 'ss:config-updated',
settings: message.settings,
}, '*');
}
});
}
init();