From 6e3249b8ff5507928042ae18282c058974d50ebc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 00:27:07 +0000 Subject: [PATCH] feat: add support for ChatGPT, Grok, Gemini, and OpenWebUI Extend Silent Send to intercept API requests on all major AI chat services. Each service has different API shapes: - ChatGPT: /backend-api/conversation with content.parts arrays - Grok: GraphQL + /2/grok/add_response with message field - Gemini: form-encoded f.req with nested arrays (+ generateContent) - OpenWebUI: /api/chat and /ollama/api/chat (self-hosted) All services share the same substitution pipeline. Manifests updated for both Chrome and Firefox with host_permissions for all domains. OpenWebUI supported via localhost/127.0.0.1 for self-hosted instances. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- README.md | 12 ++- manifest.firefox.json | 33 ++++++- manifest.json | 33 ++++++- src/background/service-worker.js | 26 +++-- src/content/content.js | 164 ++++++++++++++++++++++++------- src/popup/popup.html | 2 +- 6 files changed, 220 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index aeb431a..9561868 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,16 @@ # Silent Send -A browser extension (Chrome + Firefox) that intercepts personal information and substitutes it with user-defined replacements before sending to Claude.ai. +A browser extension (Chrome + Firefox) that intercepts personal information and substitutes it with user-defined replacements before sending to AI services. + +### Supported services + +| Service | Domains | +|---------|---------| +| Claude | claude.ai, claude.ai/code | +| ChatGPT | chatgpt.com, chat.openai.com | +| Grok | grok.x.ai, x.com/i/grok | +| Gemini | gemini.google.com | +| OpenWebUI | localhost, 127.0.0.1 (self-hosted) | ## How it works diff --git a/manifest.firefox.json b/manifest.firefox.json index 0b8ecab..c0dd7dd 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Silent Send", - "version": "0.1.0", + "version": "0.2.0", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", "browser_specific_settings": { "gecko": { @@ -15,7 +15,14 @@ "scripting" ], "host_permissions": [ - "https://claude.ai/*" + "https://claude.ai/*", + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://grok.x.ai/*", + "https://x.com/i/grok*", + "https://gemini.google.com/*", + "http://localhost/*", + "http://127.0.0.1/*" ], "background": { "scripts": ["src/background/service-worker.js"], @@ -23,7 +30,16 @@ }, "content_scripts": [ { - "matches": ["https://claude.ai/*"], + "matches": [ + "https://claude.ai/*", + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://grok.x.ai/*", + "https://x.com/i/grok*", + "https://gemini.google.com/*", + "http://localhost/*", + "http://127.0.0.1/*" + ], "js": ["src/content/injector.js"], "css": ["src/content/content.css"], "run_at": "document_start", @@ -50,7 +66,16 @@ "web_accessible_resources": [ { "resources": ["src/content/content.js"], - "matches": ["https://claude.ai/*"] + "matches": [ + "https://claude.ai/*", + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://grok.x.ai/*", + "https://x.com/*", + "https://gemini.google.com/*", + "http://localhost/*", + "http://127.0.0.1/*" + ] } ] } diff --git a/manifest.json b/manifest.json index 183fb80..9d2932c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Silent Send", - "version": "0.1.0", + "version": "0.2.0", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", "permissions": [ "storage", @@ -9,7 +9,14 @@ "scripting" ], "host_permissions": [ - "https://claude.ai/*" + "https://claude.ai/*", + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://grok.x.ai/*", + "https://x.com/i/grok*", + "https://gemini.google.com/*", + "http://localhost/*", + "http://127.0.0.1/*" ], "background": { "service_worker": "src/background/service-worker.js", @@ -17,7 +24,16 @@ }, "content_scripts": [ { - "matches": ["https://claude.ai/*"], + "matches": [ + "https://claude.ai/*", + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://grok.x.ai/*", + "https://x.com/i/grok*", + "https://gemini.google.com/*", + "http://localhost/*", + "http://127.0.0.1/*" + ], "js": ["src/content/injector.js"], "css": ["src/content/content.css"], "run_at": "document_start", @@ -41,7 +57,16 @@ "web_accessible_resources": [ { "resources": ["src/content/content.js"], - "matches": ["https://claude.ai/*"] + "matches": [ + "https://claude.ai/*", + "https://chatgpt.com/*", + "https://chat.openai.com/*", + "https://grok.x.ai/*", + "https://x.com/*", + "https://gemini.google.com/*", + "http://localhost/*", + "http://127.0.0.1/*" + ] } ] } diff --git a/src/background/service-worker.js b/src/background/service-worker.js index 7986863..f73342d 100644 --- a/src/background/service-worker.js +++ b/src/background/service-worker.js @@ -88,13 +88,25 @@ const messageHandlers = { async 'update:settings'(message) { await Storage.saveSettings(message.settings); - // Broadcast to content scripts - const tabs = await api.tabs.query({ url: 'https://claude.ai/*' }); - for (const tab of tabs) { - api.tabs.sendMessage(tab.id, { - type: 'settings:updated', - settings: message.settings, - }).catch(() => {}); + // Broadcast to content scripts on all supported sites + const SUPPORTED_URLS = [ + 'https://claude.ai/*', + 'https://chatgpt.com/*', + 'https://chat.openai.com/*', + 'https://grok.x.ai/*', + 'https://x.com/i/grok*', + 'https://gemini.google.com/*', + 'http://localhost/*', + 'http://127.0.0.1/*', + ]; + for (const urlPattern of SUPPORTED_URLS) { + const tabs = await api.tabs.query({ url: urlPattern }).catch(() => []); + for (const tab of tabs) { + api.tabs.sendMessage(tab.id, { + type: 'settings:updated', + settings: message.settings, + }).catch(() => {}); + } } }, }; diff --git a/src/content/content.js b/src/content/content.js index 1de8d68..2accf6c 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -283,7 +283,7 @@ } // ============================================================ - // Process a JSON body — handles all known Claude API shapes + // Process a JSON body — handles all known AI service API shapes // ============================================================ function processBody(body) { let modified = false; @@ -298,44 +298,94 @@ return r; } - // Shape 1: { prompt: "..." } - if (typeof body.prompt === 'string') { - const r = processText(body.prompt); - if (r.modified) body.prompt = r.text; - } - - // 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 = processText(item.text); - if (r.modified) body.content[i] = { ...item, text: r.text }; - } + // Walk any string values that look like user content + function walkAndSubstitute(obj, key) { + if (typeof obj[key] === 'string' && obj[key].length > 0) { + const r = processText(obj[key]); + if (r.modified) obj[key] = r.text; } } - // Shape 3: { messages: [{ role: "user", content: "..." }] } + // --- Claude: { prompt: "..." } --- + if (typeof body.prompt === 'string') { + walkAndSubstitute(body, 'prompt'); + } + + // --- Claude / OpenAI / OpenWebUI: { messages: [{ role, 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 = processText(msg.content); - if (r.modified) msg.content = r.text; + walkAndSubstitute(msg, 'content'); } if (Array.isArray(msg.content)) { for (let j = 0; j < msg.content.length; j++) { - if (msg.content[j].type === 'text') { - const r = processText(msg.content[j].text); - if (r.modified) msg.content[j] = { ...msg.content[j], text: r.text }; + const part = msg.content[j]; + if (typeof part === 'string') { + const r = processText(part); + if (r.modified) msg.content[j] = r.text; + } else if (part?.type === 'text' && typeof part.text === 'string') { + walkAndSubstitute(part, 'text'); } } } } } + // --- Claude: { content: [{ type: "text", text }] } --- + if (Array.isArray(body.content) && !Array.isArray(body.messages)) { + for (let i = 0; i < body.content.length; i++) { + const item = body.content[i]; + if (item.type === 'text' && typeof item.text === 'string') { + walkAndSubstitute(item, 'text'); + } + } + } + + // --- ChatGPT: { action: "next", messages: [{ content: { parts: ["..."] } }] } --- + if (Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (msg.content?.parts && Array.isArray(msg.content.parts)) { + for (let i = 0; i < msg.content.parts.length; i++) { + if (typeof msg.content.parts[i] === 'string') { + const r = processText(msg.content.parts[i]); + if (r.modified) msg.content.parts[i] = r.text; + } + } + } + } + } + + // --- Gemini: nested prompts in f.req batch RPC (text strings in arrays) --- + // Gemini uses a complex nested array format. We recursively find strings. + if (Array.isArray(body) || body?.fReq) { + function walkArray(arr) { + for (let i = 0; i < arr.length; i++) { + if (typeof arr[i] === 'string' && arr[i].length > 2) { + const r = processText(arr[i]); + if (r.modified) arr[i] = r.text; + } else if (Array.isArray(arr[i])) { + walkArray(arr[i]); + } + } + } + if (Array.isArray(body)) walkArray(body); + } + + // --- Grok: { message: "...", messages: [...] } --- + if (typeof body.message === 'string') { + walkAndSubstitute(body, 'message'); + } + + // --- OpenWebUI: { prompt: "...", messages: [...], model: "..." } --- + // Already handled by 'prompt' and 'messages' above + + // --- Generic: { query: "..." } or { input: "..." } --- + if (typeof body.query === 'string') walkAndSubstitute(body, 'query'); + if (typeof body.input === 'string') walkAndSubstitute(body, 'input'); + return { modified, replacements: allReplacements }; } @@ -350,6 +400,34 @@ (identity.phones || []).length > 0; } + // ============================================================ + // API URL Detection — matches known AI service endpoints + // ============================================================ + const API_PATTERNS = [ + // Claude + /\/api\/organizations\/.*\/(chat_conversations|completion|messages)/, + // ChatGPT / OpenAI + /\/backend-api\/conversation/, + /\/api\/conversation/, + /\/v1\/chat\/completions/, + // Grok + /\/i\/api\/graphql.*grok/i, + /grok.*\/api\//, + /\/2\/grok\/add_response/, + // Gemini + /\/_\/BardChatUi\/data\//, + /\/google\.internal\.gemini/, + /generativelanguage.*generateContent/, + // OpenWebUI (self-hosted, various paths) + /\/api\/chat\/?/, + /\/ollama\/api\/chat/, + /\/api\/v1\/chat\/completions/, + ]; + + function isTargetApiUrl(url) { + return API_PATTERNS.some(pattern => pattern.test(url)); + } + // ============================================================ // Fetch Interception // ============================================================ @@ -361,14 +439,10 @@ } 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') { + if (isTargetApiUrl(urlStr) && options?.body && typeof options.body === 'string') { try { + // Try JSON first (most services) const body = JSON.parse(options.body); const { modified, replacements } = processBody(body); @@ -380,7 +454,19 @@ ); } } catch (e) { - // Not JSON — pass through + // Not JSON — try form-encoded (Gemini uses f.req param) + try { + if (options.body.includes('f.req=') || options.body.includes('at=')) { + const result = substituteAll(options.body); + if (result.modified) { + options = { ...options, body: result.text }; + notifySubstitutions(result.replacements); + console.log( + `[Silent Send] Substituted ${result.replacements.length} value(s) in form request` + ); + } + } + } catch (e2) { /* pass through */ } } } @@ -402,9 +488,7 @@ if ( settings.enabled && hasSubstitutions() && typeof body === 'string' && this._ssUrl && - (this._ssUrl.includes('/chat_conversations/') || - this._ssUrl.includes('/completion') || - this._ssUrl.includes('/messages')) + isTargetApiUrl(this._ssUrl) ) { try { const parsed = JSON.parse(body); @@ -429,13 +513,27 @@ for (const node of mutation.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; + // Response container selectors for all supported services + const RESPONSE_SELECTORS = [ + // Claude + '[data-is-streaming]', '.font-claude-message', + // ChatGPT + '[data-message-author-role="assistant"]', '.markdown', + // Grok + '[class*="message-bubble"]', '[class*="response"]', + // Gemini + '.model-response-text', '.response-content', 'message-content', + // Generic + '.prose', '[class*="Message"]', '[class*="assistant"]', + ].join(', '); + const responseEls = node.querySelectorAll - ? node.querySelectorAll('[data-is-streaming], .font-claude-message, .prose, [class*="Message"]') + ? node.querySelectorAll(RESPONSE_SELECTORS) : []; for (const el of responseEls) revealInElement(el); - if (node.matches?.('[data-is-streaming], .font-claude-message, .prose, [class*="Message"]')) { + if (node.matches?.(RESPONSE_SELECTORS)) { revealInElement(node); } } diff --git a/src/popup/popup.html b/src/popup/popup.html index 3c577af..319fc00 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -142,7 +142,7 @@

Type text containing your real values to see what would be sent.

- +
What gets sent: