Author SHA1 Message Date
Claude b9acd6d677 Remove claude.ai/code skip
The extension needs to work on claude.ai/code like every other site;
the user toggles it off manually when needed. Dropping the host-level
skip leaves the word-boundary mapping fix as the only behavior change
on this branch.

https://claude.ai/code/session_01Y2YprLMx348eWD5C9Z4zpV
2026-04-16 18:10:04 +00:00
Claude f55f63c254 Revert claude.ai/code to blanket skip
Drops the narrow field-based walker. Matching only known user-input
fields is brittle: the moment Anthropic renames prompt/input/messages
the walker silently stops substituting and real data leaks.

Preferring robustness over convenience here — the extension stays
dormant on claude.ai/code regardless of future API shape changes, and
users handle redaction manually on that one app. The word-boundary
mapping fix from the previous commit is unaffected.

https://claude.ai/code/session_01Y2YprLMx348eWD5C9Z4zpV
2026-04-16 18:03:36 +00:00
Claude 331b1cb2a2 Narrow claude.ai/code interception to user-input fields
Replaces the blanket skip on claude.ai/code with a targeted walker so
the user's prompt and pasted attachments still get substituted, but
tool_use / tool_result / system / IDs / metadata pass through verbatim.

Fields touched:
- top-level prompt and input (classic claude.ai shape)
- attachments[].extracted_content and attachments[].file_name
- messages[].content (or text parts) when role === 'user'

FormData uploads continue to flow through DocumentScanner since they
are user input. Raw non-JSON bodies are skipped on this path because
they are typically tool traffic, not user text.

https://claude.ai/code/session_01Y2YprLMx348eWD5C9Z4zpV
2026-04-16 17:58:48 +00:00
Claude 16c8275f4e Skip claude.ai/code and use word boundaries for mappings
Two user-reported conflicts when using the extension alongside Claude
Code on the web and with short mappings:

1. Claude Code (claude.ai/code) sends real file paths, usernames, and
   shell commands to its tool runtime. Substituting any of these
   corrupts execution, forcing users to disable the extension for that
   app. early-hook.js and injector.js now short-circuit on that path so
   fetch/XHR are never wrapped and no page-world script is injected.

2. Explicit mappings used raw literal regexes, so "not" -> "bad" would
   rewrite "nothing" into "badhing". substitute/reveal/scan/diff (and
   their page-world mirrors) now add \b on word-character edges only,
   leaving non-word edges like "@foo" unchanged so they keep matching.

https://claude.ai/code/session_01Y2YprLMx348eWD5C9Z4zpV
2026-04-16 16:12:04 +00:00
Outis 03dd61d8bd Merge pull request #78 from outis1one/clDemoe/fix-pii-mapping-persist-5qJzt
Cl demoe/fix pii mapping persist 5q jzt
2026-04-04 11:26:24 -04:00
Claude a69c39e8e7 Bump version to 0.9.46
https://claude.ai/code/session_01RfzvB5sHah326acr8Xa7Jn
2026-04-04 15:22:27 +00:00
Claude fb237c91a8 Fix substitute values being re-detected as PII after adding mapping
Two issues fixed:

1. autoDetectPII only skipped identity values (names, emails, etc.) but not
   mapping values. After clicking "+" to map e.g. an IP address, the fake
   substitute IP was immediately re-detected as PII, causing the notification
   to reappear. Now autoDetectPII accepts opts.mappings and adds both real
   and substitute values to the skip set.

2. The local mappings array was only updated after the async background
   round-trip completed. The 150ms re-scan could fire before that, missing
   the new mapping. Now an optimistic temp mapping is added to the local
   array immediately so the re-scan already knows to skip both values.

https://claude.ai/code/session_01RfzvB5sHah326acr8Xa7Jn
2026-04-04 15:18:51 +00:00
Outis 357c7d208b Merge pull request #77 from outis1one/clDemoe/fix-pii-mapping-persist-5qJzt
Bump manifest.firefox.json version to 0.9.45
2026-04-04 10:42:09 -04:00
Claude d8a4e3a668 Bump manifest.firefox.json version to 0.9.45
https://claude.ai/code/session_01RfzvB5sHah326acr8Xa7Jn
2026-04-04 14:24:52 +00:00
Outis 09a279f62f Merge pull request #76 from outis1one/clDemoe/fix-pii-mapping-persist-5qJzt
Cl demoe/fix pii mapping persist 5q jzt
2026-04-04 10:22:24 -04:00
Claude 36b7bf34e9 Bump version to 0.9.45
https://claude.ai/code/session_01RfzvB5sHah326acr8Xa7Jn
2026-04-04 14:21:00 +00:00
Claude 909bb3a847 Fix PII mapping add (plus button) not persisting and not dismissing notification
The plus button handler in the pre-send PII warning had two bugs:

1. It read/wrote mappings directly via the storage bridge (api.storage.local),
   bypassing the Storage module's encryption layer. When at-rest encryption was
   enabled, getStorageData returned an encrypted blob instead of an array,
   causing .push() to throw a TypeError that silently aborted the handler —
   the mapping was never saved and replaceInInput never ran.

2. Unlike the ignore button which immediately removes the DOM item, the plus
   button relied on a re-scan at 150ms to dismiss the notification. If
   replaceInInput didn't stick (e.g. React-controlled inputs), the re-scan
   found PII again and the notification persisted.

Fix: Route mapping creation through the background service worker via a new
'add:mapping' message handler (which uses Storage.addMapping with proper
encryption support), and immediately dismiss the notification item from the
DOM like the ignore button does.

https://claude.ai/code/session_01RfzvB5sHah326acr8Xa7Jn
2026-04-04 14:14:15 +00:00
Outis f6ebb3dc15 Merge pull request #75 from outis1one/claude/review-silent-send-gHgCS
Fix encryption enable by removing broken org UI references, bump to 0…
2026-04-02 10:25:29 -04:00
Outis 22c90f28e8 Merge pull request #74 from outis1one/claude/review-silent-send-gHgCS
Claude/review silent send g hg cs
2026-04-02 09:28:26 -04:00
Outis 3c37309a1e Merge pull request #73 from outis1one/claude/review-silent-send-gHgCS
Claude/review silent send g hg cs
2026-04-02 09:14:21 -04:00
Outis 993aadaad5 Merge pull request #72 from outis1one/claude/review-silent-send-gHgCS
Fix fresh-install pull blocked by saveSettings timestamp
2026-04-01 20:38:52 -04:00
Outis 70c154a5d3 Merge pull request #71 from outis1one/clDemoe/review-silent-send-gHgCS
Bump version to 0.9.41
2026-04-01 16:05:26 -04:00
Outis 05c9b299c9 Merge pull request #70 from outis1one/clDemoe/review-silent-send-gHgCS
Add Ignore button to auto-detect warnings for permanent per-value dis…
2026-04-01 16:04:23 -04:00
Outis abecb2fdd1 Merge pull request #69 from outis1one/clDemoe/review-silent-send-gHgCS
ClDemoe/review silent send g hg cs
2026-04-01 12:14:47 -04:00
Outis 4ce884e9de Merge pull request #68 from outis1one/clDemoe/review-silent-send-gHgCS
Bump version to 0.9.39
2026-04-01 11:03:53 -04:00
Outis 5ce7fd610a Merge pull request #67 from outis1one/claude/review-silent-send-gHgCS
Fix race condition: vault:unlocked message lost when service worker r…
2026-04-01 10:52:47 -04:00
8 changed files with 118 additions and 35 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Silent Send",
"version": "0.9.44",
"version": "0.9.46",
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
"browser_specific_settings": {
"gecko": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Silent Send",
"version": "0.9.44",
"version": "0.9.46",
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
"permissions": [
"storage",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "silent-send",
"version": "0.9.44",
"version": "0.9.46",
"private": true,
"license": "MIT",
"description": "Browser extension that substitutes personal data before sending to AI services",
+6
View File
@@ -138,6 +138,12 @@ const messageHandlers = {
sendResponse({ mappings });
},
async 'add:mapping'(message, _sender, sendResponse) {
const newMapping = await Storage.addMapping(message.mapping);
const mappings = await Storage.getMappings();
sendResponse({ mapping: newMapping, mappings });
},
async 'get:settings'(_message, _sender, sendResponse) {
const settings = await Storage.getSettings();
sendResponse({ settings });
+59 -24
View File
@@ -51,8 +51,8 @@
for (const m of sorted) {
if (!m.enabled || !m.real?.trim() || !m.substitute?.trim()) continue;
const escaped = esc(m.real);
const regex = new RegExp(escaped, m.caseSensitive ? 'g' : 'gi');
const pattern = wordBoundary(m.real);
const regex = new RegExp(pattern, m.caseSensitive ? 'g' : 'gi');
let match;
while ((match = regex.exec(result)) !== null) {
replacements.push({
@@ -71,8 +71,8 @@
const sorted = [...maps].sort((a, b) => b.substitute.length - a.substitute.length);
for (const m of sorted) {
if (!m.enabled || !m.real?.trim() || !m.substitute?.trim()) continue;
const escaped = esc(m.substitute);
const regex = new RegExp(escaped, m.caseSensitive ? 'g' : 'gi');
const pattern = wordBoundary(m.substitute);
const regex = new RegExp(pattern, m.caseSensitive ? 'g' : 'gi');
result = result.replace(regex, m.real);
}
return result;
@@ -82,6 +82,15 @@
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Add \b only on word-character edges so "not" doesn't match inside "nothing",
// while leaving non-word edges (e.g. "@foo") alone since they self-delimit.
function wordBoundary(str) {
const escaped = esc(str);
const left = /^\w/.test(str) ? '\\b' : '';
const right = /\w$/.test(str) ? '\\b' : '';
return left + escaped + right;
}
// ============================================================
// Smart Pattern Engine (inline for page world)
// ============================================================
@@ -280,7 +289,7 @@
// 4. Auto-detect: scan the FINAL text for unconfigured PII
// Auto-redact if enabled, otherwise just warn
if (settings.autoDetect !== false) {
const warnings = autoDetectPII(finalText, identity, { detectProperNouns: settings.detectProperNouns === true })
const warnings = autoDetectPII(finalText, identity, { detectProperNouns: settings.detectProperNouns === true, mappings })
.filter(w => !ignoredDetections.has(w.value));
if (warnings.length > 0) {
// Auto-redact detected PII in the outbound text
@@ -471,6 +480,14 @@
addAll(ident.names); addAll(ident.emails);
addAll(ident.usernames); addAll(ident.hostnames); addAll(ident.phones);
}
// Also skip values covered by mappings (both real and substitute)
if (opts?.mappings) {
for (const m of opts.mappings) {
if (!m.enabled) continue;
if (m.real) configured.add(m.real.toLowerCase());
if (m.substitute) configured.add(m.substitute.toLowerCase());
}
}
const findings = [];
for (const pat of PII_PATTERNS) {
@@ -1035,8 +1052,8 @@
const pairs = getRevealPairs();
let result = text;
for (const p of pairs) {
const escaped = esc(p.from);
const regex = new RegExp(escaped, p.caseSensitive ? 'g' : 'gi');
const pattern = wordBoundary(p.from);
const regex = new RegExp(pattern, p.caseSensitive ? 'g' : 'gi');
result = result.replace(regex, p.to);
}
return result;
@@ -1077,9 +1094,9 @@
const pairs = getRevealPairs();
let result = text;
for (const p of pairs) {
const escaped = esc(p.to); // p.to is the real value
const regex = new RegExp(escaped, p.caseSensitive ? 'g' : 'gi');
result = result.replace(regex, p.from); // p.from is the substitute
const pattern = wordBoundary(p.to);
const regex = new RegExp(pattern, p.caseSensitive ? 'g' : 'gi');
result = result.replace(regex, p.from);
}
return result;
}
@@ -1406,21 +1423,29 @@
const fake = decodeURIComponent(btn.dataset.fake);
const cat = btn.dataset.cat || 'general';
// Add to mappings via storage
const result = await getStorageData('ss_mappings');
const currentMappings = result || [];
currentMappings.push({
// Immediately dismiss this notification item (same as ignore)
btn.closest('.ss-ps-item').remove();
if (!preSendWarningEl.querySelector('.ss-ps-item')) {
preSendWarningEl.classList.remove('visible');
}
// Optimistically add to local mappings so re-scan skips this value
const tempMapping = {
id: crypto.randomUUID(),
real, substitute: fake,
category: cat,
caseSensitive: false,
enabled: true,
createdAt: Date.now(),
});
await setStorageData('ss_mappings', currentMappings);
};
mappings = [...mappings, tempMapping];
// Update local mappings so the fetch interceptor uses them immediately
mappings = currentMappings;
// Persist via background script (handles encryption);
// update local mappings with the authoritative list on success
addMappingViaBackground({ real, substitute: fake, category: cat })
.then(updatedMappings => {
if (updatedMappings.length) mappings = updatedMappings;
});
// Replace the PII value in the current input right now
if (inputEl) {
@@ -1429,11 +1454,6 @@
if (inputScanTimer) clearTimeout(inputScanTimer);
inputScanTimer = setTimeout(() => scanInputForPII(inputEl), 150);
}
// Visual feedback
btn.textContent = '\u2714';
btn.style.color = '#4ade80';
btn.disabled = true;
});
});
@@ -1493,6 +1513,21 @@
window.postMessage({ type: 'ss:storage-set', key, value }, '*');
}
function addMappingViaBackground(mapping) {
return new Promise(resolve => {
const id = 'ss-add-' + Math.random();
const handler = (event) => {
if (event.data?.type === 'ss:add-mapping-result' && event.data.id === id) {
window.removeEventListener('message', handler);
resolve(event.data.mappings || []);
}
};
window.addEventListener('message', handler);
window.postMessage({ type: 'ss:add-mapping', mapping, id }, '*');
setTimeout(() => { window.removeEventListener('message', handler); resolve([]); }, 2000);
});
}
// Scan input on type and paste
let inputScanTimer = null;
@@ -1503,7 +1538,7 @@
return;
}
const warnings = autoDetectPII(text, identity, { detectProperNouns: settings.detectProperNouns === true })
const warnings = autoDetectPII(text, identity, { detectProperNouns: settings.detectProperNouns === true, mappings })
.filter(w => !ignoredDetections.has(w.value));
if (warnings.length > 0) {
showPreSendWarning(warnings, target);
+20
View File
@@ -193,6 +193,26 @@
if (event.data?.type === 'ss:storage-set') {
await api.storage.local.set({ [event.data.key]: event.data.value });
}
if (event.data?.type === 'ss:add-mapping') {
try {
const response = await api.runtime.sendMessage({
type: 'add:mapping',
mapping: event.data.mapping,
});
window.postMessage({
type: 'ss:add-mapping-result',
id: event.data.id,
mappings: response?.mappings || [],
}, '*');
} catch {
window.postMessage({
type: 'ss:add-mapping-result',
id: event.data.id,
mappings: [],
}, '*');
}
}
});
}
+18 -8
View File
@@ -23,8 +23,8 @@ const SubstitutionEngine = {
for (const mapping of sorted) {
if (!mapping.enabled || !mapping.real?.trim() || !mapping.substitute?.trim()) continue;
const escaped = this._escapeRegex(mapping.real);
const regex = new RegExp(escaped, mapping.caseSensitive ? 'g' : 'gi');
const pattern = this._wordBoundaryPattern(mapping.real);
const regex = new RegExp(pattern, mapping.caseSensitive ? 'g' : 'gi');
let match;
while ((match = regex.exec(result)) !== null) {
@@ -56,8 +56,8 @@ const SubstitutionEngine = {
for (const mapping of sorted) {
if (!mapping.enabled || !mapping.real?.trim() || !mapping.substitute?.trim()) continue;
const escaped = this._escapeRegex(mapping.substitute);
const regex = new RegExp(escaped, mapping.caseSensitive ? 'g' : 'gi');
const pattern = this._wordBoundaryPattern(mapping.substitute);
const regex = new RegExp(pattern, mapping.caseSensitive ? 'g' : 'gi');
result = result.replace(regex, mapping.real);
}
@@ -73,8 +73,8 @@ const SubstitutionEngine = {
for (const mapping of mappings) {
if (!mapping.enabled || !mapping.real?.trim()) continue;
const escaped = this._escapeRegex(mapping.real);
const regex = new RegExp(escaped, mapping.caseSensitive ? 'g' : 'gi');
const pattern = this._wordBoundaryPattern(mapping.real);
const regex = new RegExp(pattern, mapping.caseSensitive ? 'g' : 'gi');
if (regex.test(text)) {
found.push({
@@ -105,8 +105,8 @@ const SubstitutionEngine = {
for (const mapping of sorted) {
if (!mapping.enabled || !mapping.real?.trim() || !mapping.substitute?.trim()) continue;
const escaped = this._escapeRegex(mapping.real);
const regex = new RegExp(escaped, mapping.caseSensitive ? 'g' : 'gi');
const pattern = this._wordBoundaryPattern(mapping.real);
const regex = new RegExp(pattern, mapping.caseSensitive ? 'g' : 'gi');
let match;
while ((match = regex.exec(original)) !== null) {
@@ -145,6 +145,16 @@ const SubstitutionEngine = {
_escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
},
// Wrap an escaped literal in \b only on edges that are word characters,
// so "not" → "bad" matches "not" but not "nothing", while mappings whose
// edges aren't word chars (e.g. "@foo", "foo.com ") still work.
_wordBoundaryPattern(str) {
const escaped = this._escapeRegex(str);
const left = /^\w/.test(str) ? '\\b' : '';
const right = /\w$/.test(str) ? '\\b' : '';
return left + escaped + right;
},
};
// Support both module and content-script contexts
+12
View File
@@ -345,6 +345,18 @@ test('Disabled mapping is skipped', () => {
if (result.text.includes('Alex Demo')) throw 'Disabled mapping should not substitute';
});
test('Mapping matches whole words only', () => {
const result = SubstitutionEngine.substitute('nothing is not a thing, not even this', [
{ real: 'not', substitute: 'bad', enabled: true }
]);
if (result.text.includes('bahing') || result.text.includes('badhing')) {
throw `Should not match inside "nothing", got: ${result.text}`;
}
if (!/\bbad\b/.test(result.text)) {
throw `Should still match standalone "not", got: ${result.text}`;
}
});
// ============================================================
// SMART PATTERNS
// ============================================================