% ============================================================================= % Sentinel — Project Story % MLH x Midnight 2026 — AI Track % Compile with: xelatex STORY.tex (recommended, for unicode emoji) % pdflatex STORY.tex (works too; loses the shield glyph) % ============================================================================= \documentclass[11pt,a4paper]{article}

% ---- Fonts & unicode -------------------------------------------------------- \usepackage{iftex} \ifXeTeX \usepackage{fontspec} \setmainfont{Latin Modern Roman} \usepackage{emoji} \else \usepackage[utf8]{inputenc} \usepackage[T1]{fontenc} \usepackage{lmodern} \fi

\usepackage{microtype} \usepackage{graphicx}

% ---- Math ------------------------------------------------------------------- \usepackage{amsmath, amssymb, amsthm} \usepackage{mathtools} \usepackage{bm}

% ---- Layout & lists --------------------------------------------------------- \usepackage[margin=1in]{geometry} \usepackage{parskip} \usepackage{enumitem}

% ---- Code listings ------------------------------------------------------------ \usepackage{listings} \usepackage{xcolor}

% ---- Tables ----------------------------------------------------------------- \usepackage{booktabs} \usepackage{array}

% ---- Hyperlinks ------------------------------------------------------------- \usepackage[hidelinks, colorlinks=true, linkcolor=blue!60!black, urlcolor=blue!60!black]{hyperref}

% ---- Diagrams --------------------------------------------------------------- \usepackage{tikz} \usetikzlibrary{positioning, arrows.meta, shapes.geometric, fit, backgrounds, calc}

% ---- Listings style --------------------------------------------------------- \definecolor{codebg}{HTML}{F6F8FA} \definecolor{codekw}{HTML}{0050B3} \definecolor{codeid}{HTML}{6F42C1} \definecolor{codestr}{HTML}{D6336C} \definecolor{codegray}{HTML}{6A737D}

\lstdefinestyle{sentinel}{ basicstyle=\ttfamily\small, keywordstyle=\color{codekw}\bfseries, commentstyle=\color{codegray}\itshape, stringstyle=\color{codestr}, numberstyle=\tiny\color{codegray}, numbers=left, numbersep=8pt, frame=single, framerule=0.4pt, rulecolor=\color{gray!50}, backgroundcolor=\color{codebg}, breaklines=true, breakatwhitespace=true, showstringspaces=false, tabsize=2, xleftmargin=18pt, framexleftmargin=14pt, aboveskip=1em, belowskip=1em, } \lstset{style=sentinel}

% ---- Title ------------------------------------------------------------------ \title{\textbf{\Huge \texorpdfstring{\emoji{shield}}{Sentinel}}\[4pt] \Large Project Story --- The Privacy Wrapper for the AI You Already Use} \date{MLH $\times$ Midnight 2026 --- AI Track}

% ---- Operators & shortcuts -------------------------------------------------- \DeclareMathOperator{\Hdom}{H_{dom}} \DeclareMathOperator{\pad}{pad} \DeclareMathOperator{\attest}{attest} \DeclareMathOperator{\eval}{evaluate} \DeclareMathOperator{\redact}{redact}

\begin{document} \maketitle

% ============================================================================= \section{Inspiration} % =============================================================================

We started Sentinel as a full-stack web app: a React UI that runs a local model on sensitive data, then anchors a Midnight attestation. The architecture was clean and the demo ran, but a single thought kept nagging us on day two: \textbf{we would not use this product ourselves.}

Real people do not paste medical records into a new web app. Real people open ChatGPT, Claude, Gemini, DeepSeek, or Poe --- whichever they have already decided is the best for them --- and paste the record there. The privacy tool that wins is not the one with the best model. It is the one that wraps the model the user already chose. That is the entire pivot.

The MLH $\times$ Midnight 2026 brief said \emph{``models act on private data, and Midnight proves the rules were followed.''} We still believe that sentence. We just decided the model should not be ours. It should be the user's. Our job is to sit between the user and the model and make sure the rules hold.

\begin{quote} \textit{``User opens their favourite AI, enables our plugin, uploads a PDF either in the chat or in our plugin, and we pass only the safe data to their AI chat model. They use the AI they already love. We hold the line on privacy. Isn't that possible?''} \end{quote}

That sentence is the project. Everything else --- the regex engine, the PDF parser, the SHA-256 commitments, the Midnight contract --- is plumbing in service of it.

% ============================================================================= \section{What it does} % =============================================================================

Sentinel is a \textbf{Manifest V3 browser extension} for Chrome, Edge, Brave, and any Chromium 121+ browser. The user installs it once. The extension then activates on five sites:

\begin{center} \begin{tabular}{@{}lll@{}} \toprule \textbf{Site} & \textbf{URL} & \textbf{Status} \ \midrule ChatGPT & \verb|chat.openai.com|, \verb|chatgpt.com| & full \ Claude & \verb|claude.ai| & full \ Gemini & \verb|gemini.google.com| & full \ DeepSeek & \verb|chat.deepseek.com| & full \ Poe & \verb|poe.com| & best-effort (iframe-heavy) \ \bottomrule \end{tabular} \end{center}

The flow runs entirely client-side. There is no Sentinel server. There is no API key. There is no model hosted by us.

\begin{enumerate}[leftmargin=1.2em] \item \textbf{User drops a file} (or pastes text) into the AI's chat input. Sentinel has already attached a listener to that \verb|| via a content script.

\item \textbf{Sentinel reads the bytes} locally. If the magic bytes are \verb|%PDF-|, it pipes through \verb|pdfjs-dist| to extract the text. Otherwise it UTF-8 decodes the raw bytes.

\item \textbf{The policy engine runs.} We reuse the same rule engine from the original Sentinel CLI/UI (\verb|@sentinel/core|): four built-in policies (\emph{HIPAA}, \emph{GDPR}, \emph{attorney-client}, \emph{custom}). Each rule is a deterministic regex + a \verb|results[]| pass/fail, a redacted output, and a \verb|rulesHash| that maps 1:1 to the Midnight contract.

\item \textbf{A dialog appears} over the AI's UI: counts of failed rules, the file metadata, and three buttons --- \emph{Send sanitized}, \emph{Send original}, \emph{Cancel}. The user is always in control; the policy just informs.

\item \textbf{If ``Send sanitized''}:} \verb|pdf-lib| re-emits a sanitized PDF (or returns a \verb|.sanitized.txt|), a \verb|DataTransfer| replaces the file in the \verb||, and the \emph{sanitized} version is what reaches the AI.

\item \textbf{A \verb|MutationObserver|} watches the AI's response container. If the response contains a token that would have failed the policy, it gets wrapped in \verb|| with a tooltip. The user sees the leak, not the model.

\item \textbf{Attest} (optional, side panel): real SHA-256 \verb|inputCmt| / \verb|outputCmt|, real \verb|rulesHash|, mock \verb|attestationId| and \verb|txHash| from \verb|crypto.getRandomValues|. The receipt lives in \verb|chrome.storage.local| and is verifiable in \verb|/verify|. \end{enumerate}

The product is a privacy guard at the door of every AI the user already uses.

% ============================================================================= \section{How we built it} % =============================================================================

The architecture has two halves: the \textbf{policy engine} (already done from the hackathon days 1--2) and the \textbf{interception layer} (the new half, the half that makes it a product).

\begin{figure}[h] \centering \begin{tikzpicture}[ node distance=0.9cm and 1.1cm, box/.style={draw, rounded corners=2pt, thick, fill=blue!5, minimum height=1cm, minimum width=2.4cm, align=center, font=\small\ttfamily}, site/.style={draw, rounded corners=2pt, thick, fill=orange!10, minimum height=1cm, minimum width=2.4cm, align=center, font=\small\ttfamily}, arrow/.style={-{Stealth[length=2.5mm]}, thick}, every label/.style={font=\footnotesize} ] \nodebox {Sentinel\extension (MV3)}; \nodesite, right=of ext {ChatGPT\\textit{user's model}}; \nodebox, below=1.2cm of ext, fill=purple!10 {Policy engine\\verb|@sentinel/core|}; \nodebox, right=1.4cm of engine, fill=green!10 {Midnight\ledger};

\drawarrow -- node[above]{\scriptsize sanitized} (ai); \drawarrow .. controls +(1.5,0) and +(0,1.5) .. node[right, pos=0.3]{\scriptsize response} (ext.east); \drawarrow -- (engine); \drawarrow -- (chain); \end{tikzpicture} \caption{Sentinel as middleware between the user and their AI. The extension owns the interception; the engine owns the rules; the chain anchors the receipt.} \end{figure}

The key technical decisions:

\paragraph{Manifest V3, single codebase.} MV3 is the only path forward for Chrome / Edge / Brave. The single codebase, no per-store fork, keeps the build pipeline trivial.

\paragraph{Content scripts, not API calls.} Sentinel never sees the user's prompt, the user's API key, or any traffic between the user and their AI. The interception is purely at the DOM layer: a listener on the AI site's \verb|| and a \verb|MutationObserver| on its response container. Privacy guarantee follows from architecture, not from a privacy policy.

\paragraph{Reuse \texttt{@sentinel/core} verbatim.} The rule engine from the original web app is already browser-safe --- it uses WebCrypto, has no Node-only APIs, and is the same code the CLI uses offline. We renamed \verb|@sentinel/api| to \verb|@sentinel/core| and pulled it into the extension as a \verb|file:../core| dependency. Zero refactor.

\paragraph{PDF support is the value prop.} Half the documents people want to use AI on are PDFs. \verb|pdfjs-dist| extracts the text, \verb|pdf-lib| re-emits a sanitized PDF. v0.1 loses the original layout (we re-typeset in Helvetica); v0.2 will preserve layout by overlaying redaction rectangles on the source PDF.

\paragraph{Mock attestation is honest.} The Midnight contract already exists and compiles. The wallet integration and proof server are not production-ready in the Lace / MidnightJS 2026 stack. We ship v0.1 with real commitments and a clearly-labeled mock on-chain submission, so the receipts are real artifacts the user can verify locally. v0.2 swaps the mock ledger for the real one --- a one-line change because the interface is already shaped correctly.

The build pipeline is small. Vite + \verb|@crxjs/vite-plugin| + the manifest. The output is a standard \verb|extension/dist/| directory you load as an unpacked extension in \verb|chrome://extensions|.

% ============================================================================= \section{Challenges we ran into} % =============================================================================

The honest list:

\begin{enumerate}[leftmargin=1.2em] \item \textbf{Why would I switch to your AI?''} The first half of the hackathon was spent building a model runner. The second half was spent deleting it. The pivot fromour AI'' to ``wrap the user's AI'' was the single biggest decision, and it only happened because someone in the team asked the question out loud.

\item \textbf{Five AI sites, five DOMs.} ChatGPT, Claude, Gemini, DeepSeek, and Poe each have a different file input, a different response container, and a different set of contenteditable divergences. We wrote a \verb|SiteAdapter| interface and one \textasciitilde 100-line file per site. Adding a sixth site is roughly 100 lines and a manifest entry.

\item \textbf{Poe is an iframe city.} Poe embeds every chat in an iframe whose \verb|src| we cannot read. The Poe adapter is best-effort: it hooks what it can and skips the rest. This is the honest answer for v0.1.

\item \textbf{PDFs without a worker.} \verb|pdfjs-dist| ships with a \textasciitilde 1\,MB worker that does not load cleanly under MV3 content-script CSP. We run it on the main thread for v0.1. This is fine for small documents; we will route through a worker shim in v0.2.

\item \textbf{WSL + npm workspaces + symlinks.} The WSL interop filesystem does not handle \verb|symlink()| reliably, and npm 11 doubles the UNC path on \verb|file:| dependencies. The fix was to remove \verb|extension/| from the root \verb|workspaces| array and depend on core via \verb|file:../core|. Cost: an extra \verb|npm install| command. Benefit: the extension actually installs.

\item \textbf{CSP and inline styles in content scripts.} MV3 content scripts cannot \verb|eval| or \verb|new Function|. The Sentinel dialog is built via \verb|document.createElement| + \verb|innerHTML| with a bundled CSS file referenced by id. No string-based style injection.

\item \textbf{Receipts that survive across the browser's lifetime.} \verb|chrome.storage.local| is the right place. It survives reloads, is per-extension, and never leaves the device. \end{enumerate}

% ============================================================================= \section{Accomplishments that we're proud of} % =============================================================================

A few things that were not obvious they would work, and do:

\begin{itemize}[leftmargin=1.2em] \item \textbf{Five site adapters, one engine.} The same \verb|@sentinel/core| evaluates the same rules against the same files in five different chat UIs. The interception is per-site; the decisions are global.

\item \textbf{A real PDF flow that round-trips.} Drop a PDF, see the rule-by-rule pass/fail, click ``Send sanitized'', and a new PDF (with the same content minus the PII) reaches the AI. No \verb|transformers.js|, no NER model, no network calls. ~1.5\,MB of browser-side code.

\item \textbf{Receipts that match the contract.} The SHA-256 commitments and the \verb|rulesHash| we produce locally have the exact same shape and bit-pattern the Midnight \verb|attest()| circuit expects. When v0.2 swaps the mock ledger for the real one, no receipt format work is needed.

\item \textbf{Response monitoring that catches the AI leaking your PII back.} The \verb|MutationObserver| on the response container is the second half of the privacy guarantee: even if the user's prompt is sanitized, the model might still echo PII from its training data. Sentinel flags that in-place with a \verb||.

\item \textbf{A web app + a CLI + an extension + a contract that all share one engine.} The same \verb|@sentinel/core| powers the React web app (still in \verb|sentinel-ui/|), the Node CLI (\verb|sentinel-cli/|), the browser extension (\verb|extension/|), and the Midnight contract's witness hash (\verb|contract/|). One rule engine, four entry points.

\item \textbf{Honest mocking.} v0.1's on-chain step is faked. The UI says so. The crypto is real, the storage is real, the receipts are real artifacts. That is the difference between a prototype and a product. \end{itemize}

% ============================================================================= \section{What we learned} % =============================================================================

\paragraph{The product is the wrapper, not the model.} Wrapping a model is two weeks of work. Beating ChatGPT is ten years and billions of dollars. The privacy guarantee does not require beating anyone.

\paragraph{DOM interception is the right boundary.} The content-script-then-\verb|| boundary gives you a privacy guarantee you cannot get from a backend: the AI literally cannot see the unsanitized input because the unsanitized input never reaches the wire. Architectural privacy, not policy privacy.

\paragraph{Cross-runtime crypto is easier than it used to be.} WebCrypto works in Node 22, every modern browser, and edge workers. We use the same \verb|crypto.subtle| everywhere. No polyfill, no conditional imports.

\paragraph{PDFs are the worst case and the most common case.} Healthcare, legal, finance --- all the verticals the brief calls out --- ship their documents as PDFs. A privacy tool that does not handle PDFs is a privacy tool for tech demos.

\paragraph{Mock the last mile, but mock it honestly.} v0.1's mock attestation is clearly labeled in the UI. That is the difference between faking it'' anddeferring it''. The receipts are real artifacts the user can audit. The on-chain step is the only thing that is faked, and the migration is one line.

\paragraph{Wallets are UX, not crypto.} This lesson carried over from the original web app: half the ``Web3 is hard'' problem is just \emph{detecting} the wallet gracefully and falling back to a demo stub when it is not there. v0.2's Lace integration will inherit this.

\paragraph{Per-site adapters are 100 lines each, not 1000.} The \verb|SiteAdapter| interface is small. The per-site code is small. The fear that ``every site is special'' is wrong: there are five ways AI chats ingest files, and we have all five.

% ============================================================================= \section{What's next for Sentinel --- The Privacy Wrapper for Every AI You Use} % =============================================================================

Sentinel v0.1 is a usable privacy product. v0.2 is the privacy product plus a real chain. v0.3 is a privacy platform. The roadmap:

\paragraph{v0.2 (4--6 weeks after v0.1):} \begin{itemize}[leftmargin=1.2em] \item Real Lace wallet integration. Replace the mock ledger; receipts become real Midnight attestations. \item Real ZK proof server calls. The \verb|rulesHash| and \verb|outputCmt| are already in the right shape. \item PDF layout preservation. Overlay redaction rectangles on the original PDF instead of re-typesetting. \item Worker-thread PDF parsing. Move \verb|pdfjs-dist| off the main thread; large PDFs stop blocking the UI. \end{itemize}

\paragraph{v0.3:} \begin{itemize}[leftmargin=1.2em] \item \verb|transformers.js| NER as an opt-in toggle. Catches the PII regexes miss (names, addresses, dates of birth) at the cost of a $\sim$50\,MB model download. Default off; on for healthcare / legal / finance verticals. \item Firefox. Different manifest format (MV2 fallback), separate review. \end{itemize}

\paragraph{v1.0:} \begin{itemize}[leftmargin=1.2em] \item Team policy sharing. A hospital system publishes its standard policy once; every Sentinel install in its network evaluates against it. \item Attestation expiry + renewal. \verb|retentionEpoch| is already in the contract; the UI for it is a v1.0 item. \item Verifier dashboard for regulators. Read-only view of any attestation log a regulator is authorized to audit. \end{itemize}

The thesis has not changed since day one: \textbf{privacy on private data should be provable, not promised}. The wrapper is just the form that thesis takes when you stop trying to build the model and start protecting the user who already has one.

% ============================================================================= \section*{Sources} % =============================================================================

\begin{itemize}[leftmargin=1.2em] \item \href{https://docs.midnight.network}{Midnight Docs} --- concepts, Compact reference, building blocks. \item \href{https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3}{Chrome MV3 docs} --- the manifest format and CSP model. \item \href{https://mozilla.github.io/pdf.js/}{\texttt{pdfjs-dist}} --- Mozilla's PDF parser (used in Firefox). \item \href{https://pdf-lib.js.org/}{\texttt{pdf-lib}} --- pure-JS PDF builder, no native deps. \item \href{https://crxjs.dev/vite-plugin}{\texttt{@crxjs/vite-plugin}} --- HMR + manifest-aware bundling. \item \href{https://github.com/midnightntwrk/example-bboard}{\texttt{midnightntwrk/example-bboard}} --- repo-structure inspiration. \item \href{https://github.com/joacolinares/kyc-midnight}{\texttt{kyc-midnight}} --- privacy-preserving attestation pattern. \item \href{https://www.morningstar.com/news/business-wire/20260115005137/midnight-and-mlh-partner-to-power-the-next-generation-of-privacy-preserving-ai-at-hackathon-2026}{MLH $\times$ Midnight 2026 AI Track announcement}. \end{itemize}

\vspace{2em} \hrulefill\ \textit{Stack: Manifest V3, React 19, Vite 5, \verb|@crxjs/vite-plugin|, pdfjs-dist, pdf-lib, WebCrypto, TypeScript 5.9, Compact (Midnight).}\ \textit{License: Apache-2.0.}

\end{document}

Built With

  • chrome
  • chrome.storage
  • compact
  • manifest-v3
  • midnight
  • mutationobserver
  • pdf-lib
  • pdfjs-dist
  • react-19
  • typescript-5.9
  • vite-5
  • webcrypto
Share this project:

Updates

Submission history