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
+455
View File
@@ -0,0 +1,455 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 400px;
min-height: 300px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
color: #1a1a1a;
background: #fff;
}
.container {
display: flex;
flex-direction: column;
min-height: 300px;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.title {
font-size: 15px;
font-weight: 600;
color: #111;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
transition: background 0.2s;
}
.status-dot.disabled {
background: #6b7280;
}
.status-dot.off-site {
background: #f59e0b;
}
/* Toggle */
.toggle {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.toggle input { display: none; }
.toggle-slider {
position: absolute;
inset: 0;
background: #d1d5db;
border-radius: 20px;
cursor: pointer;
transition: 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 2px;
bottom: 2px;
background: #fff;
border-radius: 50%;
transition: 0.2s;
}
.toggle input:checked + .toggle-slider {
background: #10b981;
}
.toggle input:checked + .toggle-slider::before {
transform: translateX(16px);
}
/* Button styles */
.btn-icon {
background: none;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 4px 6px;
cursor: pointer;
color: #6b7280;
display: flex;
align-items: center;
transition: all 0.15s;
}
.btn-icon:hover {
background: #f3f4f6;
color: #111;
}
.btn-icon.active {
background: #eff6ff;
border-color: #3b82f6;
color: #3b82f6;
}
.btn {
padding: 6px 14px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: #111;
color: #fff;
}
.btn-primary:hover {
background: #333;
}
.btn-text {
background: none;
border: none;
color: #6b7280;
cursor: pointer;
font-size: 12px;
}
.btn-text:hover {
color: #111;
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid #e5e7eb;
padding: 0 16px;
}
.tab {
background: none;
border: none;
padding: 8px 16px;
font-size: 12px;
font-weight: 500;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.tab:hover { color: #111; }
.tab.active {
color: #111;
border-bottom-color: #111;
}
.tab-content {
display: none;
padding: 12px 16px;
flex: 1;
overflow-y: auto;
max-height: 350px;
}
.tab-content.active { display: block; }
/* Inputs */
.input-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.input-row.secondary {
margin-bottom: 0;
}
.input {
flex: 1;
padding: 7px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 12px;
outline: none;
transition: border-color 0.15s;
}
.input:focus {
border-color: #111;
}
.select {
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 12px;
outline: none;
background: #fff;
}
.arrow {
color: #9ca3af;
font-size: 16px;
flex-shrink: 0;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #6b7280;
white-space: nowrap;
}
/* Mapping list */
.add-mapping {
padding-bottom: 12px;
border-bottom: 1px solid #f3f4f6;
margin-bottom: 8px;
}
.mapping-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mapping-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
background: #f9fafb;
transition: background 0.15s;
}
.mapping-item:hover {
background: #f3f4f6;
}
.mapping-values {
flex: 1;
min-width: 0;
}
.mapping-real {
font-weight: 500;
color: #dc2626;
font-size: 12px;
text-decoration: line-through;
opacity: 0.7;
}
.mapping-sub {
font-weight: 500;
color: #059669;
font-size: 12px;
}
.mapping-category {
font-size: 10px;
color: #9ca3af;
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
}
.mapping-actions {
display: flex;
gap: 4px;
}
.mapping-actions button {
background: none;
border: none;
cursor: pointer;
color: #9ca3af;
font-size: 14px;
padding: 2px 4px;
border-radius: 4px;
}
.mapping-actions button:hover {
background: #e5e7eb;
color: #111;
}
/* Activity */
.activity-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.badge {
font-size: 11px;
color: #6b7280;
background: #f3f4f6;
padding: 3px 8px;
border-radius: 10px;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: #f9fafb;
font-size: 12px;
}
.activity-time {
color: #9ca3af;
font-size: 11px;
white-space: nowrap;
}
.activity-detail {
flex: 1;
}
.activity-original {
color: #dc2626;
text-decoration: line-through;
}
.activity-replaced {
color: #059669;
}
/* Test tab */
.help-text {
font-size: 12px;
color: #6b7280;
margin-bottom: 8px;
}
.textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
resize: vertical;
outline: none;
}
.textarea:focus {
border-color: #111;
}
.diff-view {
margin-top: 10px;
padding: 10px;
background: #f9fafb;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.diff-label {
font-size: 11px;
font-weight: 600;
color: #6b7280;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.diff-output {
font-size: 12px;
line-height: 1.6;
word-break: break-word;
min-height: 20px;
color: #374151;
}
.diff-output .sub-highlight {
background: #dcfce7;
color: #059669;
padding: 1px 4px;
border-radius: 3px;
font-weight: 500;
}
.diff-stats {
margin-top: 6px;
font-size: 11px;
color: #6b7280;
}
/* Empty state */
.empty-state {
text-align: center;
color: #9ca3af;
padding: 24px 0;
font-size: 12px;
}
/* Footer */
.footer {
padding: 8px 16px;
border-top: 1px solid #e5e7eb;
text-align: center;
}
.footer a {
font-size: 11px;
color: #6b7280;
text-decoration: none;
}
.footer a:hover {
color: #111;
}
+100
View File
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=400">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<div class="header-left">
<h1 class="title">Silent Send</h1>
<span class="status-dot" id="statusDot"></span>
</div>
<div class="header-right">
<button class="btn-icon" id="btnReveal" title="Reveal Mode - show real data">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 3C4.5 3 1.7 5.1 0.5 8c1.2 2.9 4 5 7.5 5s6.3-2.1 7.5-5c-1.2-2.9-4-5-7.5-5z" stroke="currentColor" stroke-width="1.5" fill="none"/>
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>
</button>
<label class="toggle" title="Enable/disable Silent Send">
<input type="checkbox" id="enableToggle" checked>
<span class="toggle-slider"></span>
</label>
</div>
</header>
<!-- Tab Bar -->
<nav class="tabs">
<button class="tab active" data-tab="mappings">Mappings</button>
<button class="tab" data-tab="activity">Activity</button>
<button class="tab" data-tab="test">Test</button>
</nav>
<!-- Mappings Tab -->
<section class="tab-content active" id="tab-mappings">
<div class="add-mapping">
<div class="input-row">
<input type="text" id="inputReal" placeholder="Real value (e.g. John Smith)" class="input">
<span class="arrow">&rarr;</span>
<input type="text" id="inputSub" placeholder="Substitute (e.g. Alex Demo)" class="input">
</div>
<div class="input-row secondary">
<select id="inputCategory" class="select">
<option value="name">Name</option>
<option value="email">Email</option>
<option value="phone">Phone</option>
<option value="address">Address</option>
<option value="ssn">SSN</option>
<option value="dob">DOB</option>
<option value="general">General</option>
</select>
<label class="checkbox-label">
<input type="checkbox" id="inputCaseSensitive">
Case sensitive
</label>
<button class="btn btn-primary" id="btnAdd">Add</button>
</div>
</div>
<div class="mapping-list" id="mappingList">
<div class="empty-state">
No mappings yet. Add your first one above.
</div>
</div>
</section>
<!-- Activity Tab -->
<section class="tab-content" id="tab-activity">
<div class="activity-header">
<span class="badge" id="sessionCount">0 substitutions this session</span>
<button class="btn-text" id="btnClearLog">Clear</button>
</div>
<div class="activity-list" id="activityList">
<div class="empty-state">No activity yet.</div>
</div>
</section>
<!-- Test Tab -->
<section class="tab-content" id="tab-test">
<p class="help-text">Type text containing your real values to see what would be sent.</p>
<textarea id="testInput" class="textarea" placeholder="Try typing: My name is John Smith and my email is john@example.com" rows="4"></textarea>
<div class="diff-view" id="diffView">
<div class="diff-label">What gets sent:</div>
<div class="diff-output" id="diffOutput"></div>
</div>
<div class="diff-stats" id="diffStats"></div>
</section>
<!-- Footer -->
<footer class="footer">
<a href="#" id="btnOptions">Options</a>
</footer>
</div>
<script src="popup.js" type="module"></script>
</body>
</html>
+233
View File
@@ -0,0 +1,233 @@
import SubstitutionEngine from '../lib/substitution-engine.js';
import Storage from '../lib/storage.js';
// --- State ---
let mappings = [];
let settings = {};
// --- DOM refs ---
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
renderActivity();
updateStatusDot();
$('#enableToggle').checked = settings.enabled;
// Tab switching
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
$$('.tab').forEach((t) => t.classList.remove('active'));
$$('.tab-content').forEach((c) => c.classList.remove('active'));
tab.classList.add('active');
$(`#tab-${tab.dataset.tab}`).classList.add('active');
if (tab.dataset.tab === 'activity') renderActivity();
});
});
// Enable toggle
$('#enableToggle').addEventListener('change', async (e) => {
settings.enabled = e.target.checked;
await Storage.saveSettings(settings);
updateStatusDot();
chrome.runtime.sendMessage({
type: 'update:settings',
settings,
});
});
// Reveal mode toggle
$('#btnReveal').addEventListener('click', async () => {
settings.revealMode = !settings.revealMode;
await Storage.saveSettings(settings);
$('#btnReveal').classList.toggle('active', settings.revealMode);
chrome.runtime.sendMessage({
type: 'update:settings',
settings,
});
});
$('#btnReveal').classList.toggle('active', settings.revealMode);
// Add mapping
$('#btnAdd').addEventListener('click', addMapping);
$('#inputSub').addEventListener('keydown', (e) => {
if (e.key === 'Enter') addMapping();
});
// Clear log
$('#btnClearLog').addEventListener('click', async () => {
await Storage.clearLog();
renderActivity();
});
// Test tab - live diff
$('#testInput').addEventListener('input', renderTestDiff);
// Options link
$('#btnOptions').addEventListener('click', (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
});
// --- Add Mapping ---
async function addMapping() {
const real = $('#inputReal').value.trim();
const sub = $('#inputSub').value.trim();
const category = $('#inputCategory').value;
const caseSensitive = $('#inputCaseSensitive').checked;
if (!real || !sub) return;
const mapping = await Storage.addMapping({
real,
substitute: sub,
category,
caseSensitive,
});
mappings.push(mapping);
renderMappings();
// Clear inputs
$('#inputReal').value = '';
$('#inputSub').value = '';
$('#inputCaseSensitive').checked = false;
$('#inputReal').focus();
}
// --- Render Mappings ---
function renderMappings() {
const list = $('#mappingList');
if (mappings.length === 0) {
list.innerHTML = '<div class="empty-state">No mappings yet. Add your first one above.</div>';
return;
}
list.innerHTML = mappings
.map(
(m) => `
<div class="mapping-item" data-id="${m.id}">
<div class="mapping-values">
<span class="mapping-real">${escapeHtml(m.real)}</span>
&rarr;
<span class="mapping-sub">${escapeHtml(m.substitute)}</span>
</div>
<span class="mapping-category">${m.category}</span>
<div class="mapping-actions">
<button class="btn-toggle" title="${m.enabled ? 'Disable' : 'Enable'}">${m.enabled ? '&#x2714;' : '&#x25CB;'}</button>
<button class="btn-delete" title="Delete">&times;</button>
</div>
</div>
`
)
.join('');
// Bind actions
list.querySelectorAll('.btn-delete').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.closest('.mapping-item').dataset.id;
await Storage.deleteMapping(id);
mappings = mappings.filter((m) => m.id !== id);
renderMappings();
});
});
list.querySelectorAll('.btn-toggle').forEach((btn) => {
btn.addEventListener('click', async () => {
const item = btn.closest('.mapping-item');
const id = item.dataset.id;
const mapping = mappings.find((m) => m.id === id);
if (!mapping) return;
mapping.enabled = !mapping.enabled;
await Storage.updateMapping(id, { enabled: mapping.enabled });
renderMappings();
});
});
}
// --- Render Activity Log ---
async function renderActivity() {
const log = await Storage.getLog();
const list = $('#activityList');
const countEl = $('#sessionCount');
countEl.textContent = `${log.length} substitution${log.length !== 1 ? 's' : ''} logged`;
if (log.length === 0) {
list.innerHTML = '<div class="empty-state">No activity yet.</div>';
return;
}
list.innerHTML = log
.slice(0, 50)
.map((entry) => {
const time = new Date(entry.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return `
<div class="activity-item">
<span class="activity-time">${time}</span>
<div class="activity-detail">
<span class="activity-original">${escapeHtml(entry.original)}</span>
&rarr;
<span class="activity-replaced">${escapeHtml(entry.replaced)}</span>
</div>
</div>
`;
})
.join('');
}
// --- Test Diff ---
function renderTestDiff() {
const input = $('#testInput').value;
const output = $('#diffOutput');
const stats = $('#diffStats');
if (!input) {
output.innerHTML = '';
stats.textContent = '';
return;
}
const { text, replacements } = SubstitutionEngine.substitute(input, mappings);
const chunks = SubstitutionEngine.diff(input, text, mappings);
output.innerHTML = chunks
.map((chunk) => {
if (chunk.type === 'substituted') {
return `<span class="sub-highlight" title="Was: ${escapeHtml(chunk.original)}">${escapeHtml(chunk.replacement)}</span>`;
}
return escapeHtml(chunk.text);
})
.join('');
stats.textContent =
replacements.length > 0
? `${replacements.length} substitution${replacements.length !== 1 ? 's' : ''} would be made`
: 'No substitutions detected';
}
// --- Status Dot ---
function updateStatusDot() {
const dot = $('#statusDot');
dot.classList.toggle('disabled', !settings.enabled);
}
// --- Util ---
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}