<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Apoorva Sharma</title>
    <description>The latest articles on DEV Community by Apoorva Sharma (@thealexrider).</description>
    <link>https://dev.to/thealexrider</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3810201%2F1cb3efc5-6b13-42b9-bed4-3e83fad51fb6.png</url>
      <title>DEV Community: Apoorva Sharma</title>
      <link>https://dev.to/thealexrider</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thealexrider"/>
    <language>en</language>
    <item>
      <title>How I Built Real-Time PII Detection Inside ChatGPT's Hostile Text Editor (Without Breaking It)</title>
      <dc:creator>Apoorva Sharma</dc:creator>
      <pubDate>Fri, 06 Mar 2026 16:24:18 +0000</pubDate>
      <link>https://dev.to/thealexrider/how-i-built-real-time-pii-detection-inside-chatgpts-hostile-text-editor-without-breaking-it-4a68</link>
      <guid>https://dev.to/thealexrider/how-i-built-real-time-pii-detection-inside-chatgpts-hostile-text-editor-without-breaking-it-4a68</guid>
      <description>&lt;p&gt;If you've ever tried to build a Chrome extension that modifies text inside ChatGPT, Gemini, or Claude, you know it's not like injecting into a normal &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These apps use rich text editors — ProseMirror (ChatGPT), Draft.js-style frameworks — that actively fight external DOM manipulation. They reconcile state internally, swallow events, and will silently undo your changes on the next keystroke.&lt;/p&gt;

&lt;p&gt;I spent months figuring out how to detect and highlight sensitive data (API keys, passwords, PII) inside these editors &lt;strong&gt;without breaking them&lt;/strong&gt;. This is the technical story of what I built, what failed, and the architecture I landed on.&lt;/p&gt;

&lt;p&gt;The project is called &lt;a href="https://prompt-armour.vercel.app/" rel="noopener noreferrer"&gt;Prompt Armour&lt;/a&gt; — a Chrome extension that intercepts your input on AI chatbot platforms and catches sensitive data &lt;strong&gt;before&lt;/strong&gt; it's sent to the LLM. It runs 100% client-side. No servers, no data collection.&lt;/p&gt;

&lt;p&gt;But this article isn't a product pitch. It's about the engineering.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: You Can't Just Modify the DOM
&lt;/h2&gt;

&lt;p&gt;My first instinct was simple. Watch the input, find sensitive strings with regex, wrap them in a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; with a red background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The naive approach — DO NOT do this inside ProseMirror&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s2"&gt;`&amp;lt;span class="highlight"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;`&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for about 200 milliseconds. Then ProseMirror's internal state reconciliation runs, sees DOM nodes it didn't create, and either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strips them out&lt;/strong&gt; silently&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates content&lt;/strong&gt; as it tries to reconcile&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaks the cursor position&lt;/strong&gt; so the user is suddenly typing in the wrong place&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;ChatGPT's editor isn't a &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;. It's a &lt;code&gt;contenteditable&lt;/code&gt; div managed by ProseMirror, which maintains its own document model. Any DOM mutation you make outside ProseMirror's transaction system gets treated as corruption.&lt;/p&gt;

&lt;p&gt;Gemini and Claude have similar architectures. These are &lt;strong&gt;hostile environments&lt;/strong&gt; for extension developers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solution: CSS Custom Highlight API
&lt;/h2&gt;

&lt;p&gt;The breakthrough was the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API" rel="noopener noreferrer"&gt;CSS Custom Highlight API&lt;/a&gt; — a relatively new browser API that lets you apply visual highlights to arbitrary text ranges &lt;strong&gt;without modifying the DOM at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's the key insight: instead of wrapping text in &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; elements, you create &lt;code&gt;Range&lt;/code&gt; objects pointing at the text nodes, group them into a &lt;code&gt;Highlight&lt;/code&gt; object, and register it with &lt;code&gt;CSS.highlights&lt;/code&gt;. The browser renders the visual highlight at the paint level. ProseMirror never knows anything happened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Create a range pointing at the sensitive text&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Range&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;matchStart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;matchEnd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Register it as a CSS highlight&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;highlight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Highlight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;highlights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prompt-armour-pii&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;highlight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;::highlight&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;prompt-armour-pii&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;239&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inherit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is &lt;strong&gt;non-destructive highlighting&lt;/strong&gt;. The DOM stays exactly as ProseMirror expects. No nodes added, no attributes changed, no state corruption. The user sees red highlights on their API keys and SSNs, but the editor has zero awareness that anything happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Is Hard to Find
&lt;/h3&gt;

&lt;p&gt;Most extension developers don't know this API exists. If you Google "highlight text in contenteditable Chrome extension," you'll get 50 Stack Overflow answers telling you to wrap text in &lt;code&gt;&amp;lt;mark&amp;gt;&lt;/code&gt; tags or use &lt;code&gt;document.execCommand&lt;/code&gt;. All of which will break inside ProseMirror.&lt;/p&gt;

&lt;p&gt;The CSS Custom Highlight API has been stable in Chromium since Chrome 105, but adoption is still low because most use cases don't involve fighting a hostile rich text framework. For Prompt Armour, it was the only viable path.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Detection Engine: Regex + Shannon Entropy
&lt;/h2&gt;

&lt;p&gt;Highlighting is just the display layer. The actual detection runs a multi-pass scanning engine on every input change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pass 1: Pattern Matching
&lt;/h3&gt;

&lt;p&gt;A library of regex patterns catches known formats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Emails              → standard RFC-ish pattern
Phone numbers       → standard + fuzzy (no dashes, spaces, etc.)
Credit cards        → Luhn-validated 13-19 digit sequences
SSNs                → XXX-XX-XXXX with area number validation
AWS Access Keys     → AKIA[0-9A-Z]{16}
AWS Secret Keys     → 40-char base64 following known prefixes
AWS ARNs            → arn:aws:*
EC2 Instance IDs    → i-[0-9a-f]{8,17}
MongoDB URIs        → mongodb(+srv)?://...
Postgres/MySQL URIs → postgres(ql)?://... , mysql://...
Redis URIs          → redis://...
Bearer Tokens       → Bearer [A-Za-z0-9\-._~+/]+=*
Basic Auth          → Basic [A-Za-z0-9+/]+=*
Session Cookies     → session patterns with base64/hex values
IPv4/IPv6           → standard patterns with private range flagging
MAC Addresses       → colon and dash separated hex octets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pass 2: Shannon Entropy Scanner
&lt;/h3&gt;

&lt;p&gt;This is where it gets interesting. Not every secret matches a known pattern. Random passwords, custom API keys, encrypted tokens — these just look like high-entropy gibberish.&lt;/p&gt;

&lt;p&gt;Shannon entropy measures the randomness of a string. English text averages around 3.5-4.0 bits per character. A random 32-character API key hits 5.5-6.0+.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shannonEntropy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;char&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;entropy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;char&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;entropy&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entropy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any token-like string (no spaces, sufficient length, mix of character classes) that crosses the entropy threshold gets flagged as a potential secret. This catches things regex can't — custom tokens, generated passwords, encrypted blobs pasted into prompts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pass 3: NLP Name and Location Detection
&lt;/h3&gt;

&lt;p&gt;Using the &lt;a href="https://github.com/spencermountain/compromise" rel="noopener noreferrer"&gt;compromise&lt;/a&gt; library for lightweight client-side NLP. It identifies person names and location references that regex alone would miss. It's not perfect — NLP in the browser never is — but it catches "Sarah Johnson" and "deployed to us-east-1" which pure regex would skip.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twin-Write Architecture: Solving the Storage Race Condition
&lt;/h2&gt;

&lt;p&gt;Chrome extension storage (&lt;code&gt;chrome.storage.sync&lt;/code&gt;) is asynchronous. When a user toggles a setting — say, switching redaction style from &lt;code&gt;[REDACTED]&lt;/code&gt; to masked (&lt;code&gt;****&lt;/code&gt;) — there's a real delay before the new value is readable from storage.&lt;/p&gt;

&lt;p&gt;If the user changes a setting and immediately types something that triggers a redaction, the detection engine might read the &lt;strong&gt;old&lt;/strong&gt; setting because the storage write hasn't completed.&lt;/p&gt;

&lt;p&gt;My solution is what I call &lt;strong&gt;Twin-Write Architecture&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User changes setting
  → Write 1: Local in-memory object (instant, synchronous)
  → Write 2: chrome.storage.sync (persistent, async)

Detection engine reads setting
  → Read from local memory first (always current)
  → Falls back to chrome.storage only on cold start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified twin-write pattern&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* defaults */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateSetting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Write 1: instant (synchronous)&lt;/span&gt;
  &lt;span class="nx"&gt;localState&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Write 2: persistent (async)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readSetting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Always reads from local memory — no async, no race condition&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;localState&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On extension startup, local memory is hydrated from &lt;code&gt;chrome.storage.sync&lt;/code&gt; once. After that, all reads hit local memory. All writes go to both targets simultaneously.&lt;/p&gt;

&lt;p&gt;This eliminates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UI flicker&lt;/strong&gt; (settings apply instantly)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Race conditions&lt;/strong&gt; (detection engine never reads stale data)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start lag&lt;/strong&gt; (only one async read on init)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Shadow DOM Isolation: Don't Pollute, Don't Get Polluted
&lt;/h2&gt;

&lt;p&gt;Prompt Armour injects UI components (toast notifications, redaction tooltips) into the host page. Without isolation, two things go wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your CSS leaks out&lt;/strong&gt; and breaks ChatGPT's styling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Their CSS leaks in&lt;/strong&gt; and breaks your components&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The solution is Shadow DOM. Every injected UI component lives inside a shadow root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shadow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachShadow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;closed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Styles are scoped — they can't leak out&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;style&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/* component styles */`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Component renders inside the shadow boundary&lt;/span&gt;
&lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;renderComponent&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plasmo (the framework I'm using) handles some of this automatically for content script UIs, but the toast notification system and redaction tooltips needed manual shadow DOM management to work correctly across ChatGPT, Gemini, and Claude — each of which has different CSS resets and global styles that would otherwise corrupt injected components.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│                  Host Page                    │
│  (ChatGPT / Gemini / Claude)                 │
│                                              │
│   ┌──────────────┐    ┌──────────────────┐   │
│   │  ProseMirror  │    │  Shadow DOM UI    │   │
│   │  Editor       │    │  (Toast, Tooltip) │   │
│   │  [untouched]  │    │  [isolated]       │   │
│   └──────┬───────┘    └──────────────────┘   │
│          │                                    │
│   ┌──────▼───────────────────────────────┐   │
│   │  Content Script (protector.ts)        │   │
│   │                                       │   │
│   │  MutationObserver → watches input     │   │
│   │  Detection Engine → regex + entropy   │   │
│   │  CSS Highlight API → visual overlay   │   │
│   │  Twin-Write → instant settings        │   │
│   │  Redaction Handler → modifies text    │   │
│   └──────────────────────────────────────┘   │
│                                              │
└─────────────────────────────────────────────┘
         │
         │ chrome.storage.sync
         ▼
┌─────────────────┐
│  Background SW   │
│  (badge count)   │
└─────────────────┘
         │
         ▼
┌─────────────────┐
│  Popup UI        │
│  (React + TW)    │
│  Settings/Stats  │
└─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use a proper parser instead of pure regex.&lt;/strong&gt; Regex handles 90% of cases but gets fragile with edge cases — especially multi-line connection strings and tokens with unusual delimiters. A tokenizer-based approach would be more maintainable. It's on my roadmap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start with the Highlight API from day one.&lt;/strong&gt; I wasted two weeks trying DOM manipulation approaches before discovering CSS Custom Highlight API. If you're building any Chrome extension that needs to visually annotate text inside a rich editor — start here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't underestimate SPA navigation.&lt;/strong&gt; ChatGPT, Gemini, and Claude all use client-side routing. Your content script's MutationObserver needs to handle the editor being destroyed and recreated without a page load. Cleanup and re-initialization logic is critical and boring and absolutely necessary.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It / Contribute
&lt;/h2&gt;

&lt;p&gt;Prompt Armour is live on the &lt;a href="https://chromewebstore.google.com/detail/prompt-armour-ai-privacy/kahecjbmmcenhacihcpkgapcnaggehjo" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It's free. Core PII and API key detection, all redaction styles, all supported platforms — no paywall. A Pro tier is planned later for custom regex patterns and team features, but the core protection stays free.&lt;/p&gt;

&lt;p&gt;If you've dealt with hostile SPA injection, ProseMirror wrangling, or built detection engines in the browser, I'd love to hear how you approached it. I'm a solo developer and genuinely learning as I ship.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://prompt-armour.vercel.app/" rel="noopener noreferrer"&gt;Marketing site&lt;/a&gt; | &lt;a href="https://chromewebstore.google.com/detail/prompt-armour-ai-privacy/kahecjbmmcenhacihcpkgapcnaggehjo" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>extensions</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
