Inspiration

Law firms and accountants across the EU are stuck in a small, expensive trap. Generative AI would genuinely help them review a contract — but the contract is full of client data they are not allowed to paste into a cloud model. Under the AI Act and the GDPR that is not a formality; it is their licence to practise.

The usual answer is a cloud "anonymization API", which moves the leak one hop away and calls it a solution. I wanted the masking to happen where the document already is: on the lawyer's own machine, in the browser tab, with nothing to trust but code you can read.

What it does

AI Act Shield detects personal data — tax codes, national IDs, VAT numbers, IBANs, payment cards, names, emails, phone numbers, addresses, company names — and replaces each with a placeholder, so the masked draft can safely go to any AI. When the answer comes back, it puts the real values back where they belong.

Detection is deterministic: regular expressions plus real checksums (ISO 13616 for IBANs, Luhn for payment cards, the Spanish DNI/NIE and CIF control characters, the official Italian VAT algorithm). Never an LLM. A model would answer differently on the same document from one run to the next, and sending a document to a model to ask what to hide from that model defeats the purpose.

Coverage is Italian, Spanish and English/international.

The WebMCP part

The app is built on one constraint: the text never leaves the browser. That constraint turns out to be exactly what makes a WebMCP tool trustworthy, because registerTool().execute() runs client-side, in the visitor's own tab.

The page registers four tools. The one that carries the argument is mask_document_in_page, and what matters is what it doesn't take:

document.modelContext.registerTool({
  name: "mask_document_in_page",
  description: "Masks the personal data in the document the user has already
    loaded into this page, without the caller ever receiving the document.",
  inputSchema: { type: "object", properties: { customTerms: { /* ... */ } } },
  execute: async (input) => maskPageDocument(input),
});

No text parameter. The person loads the confidential document into the page; the agent calls the tool passing nothing; the page reads its own content and returns a masked draft plus the types of what it found. The document never enters the agent's context, so it cannot leak one even if it tried.

Asked in plain language to mask a contract — without being told which tool to use — ChatGPT's agentic browser produced this row in the page's own log:

1:48:34 PM   external   mask_document_in_page   {}

external means the call came from outside the page rather than from its own buttons. {} means there was nothing to pass. That empty object is the whole project.

This is also the case for "people and agents using an app together": the UI and the tools share one page-local state, so the person watching the tab sees the masking happen live and can step in — switch a detected entity off, add a custom term — while the agent works.

How I built it

Vanilla JavaScript. No framework, no dependencies, no build step — you can clone it and open it. 37 tests on node:test, which is built in, so that promise holds for the test suite too. Served as static files from my own nginx, not a platform: the page is the same folder that nginx serves.

The detection engine is a port of the one shipping in iaCiao, my platform for local — edge — AI: 32 iApps across health, security and mobility, all built so the model runs on your device and the data stays there. AI Act Shield is the clearest expression of that idea, which is why I brought it here. An app whose entire value is that the sensitive part never leaves your machine turns out to be the natural shape for a WebMCP tool, because that is exactly where execute() runs.

Challenges

The ecosystem moved under me during the ten days. ChatGPT Atlas was deprecated days before the deadline. The Model Context Tool Inspector extension reports nothing for my page, because it looks for navigator.modelContext while the specification (26 August 2026) exposes ModelContext on document and never mentions navigator. I left that audit check failing rather than shim an API that does not exist — and left the declarative toolname/tooldescription/toolaction checks failing too, since the spec marks that API "entirely a TODO".

ChatGPT's Chrome extension looks like it works and doesn't. Asked to mask the document, it produced a fluent, accurate answer — by reading the DOM, without calling a single tool. That is precisely the difference this project is about, and it would have been very easy to film it as a success.

Chrome's origin trial has a quirk. It returns inputSchema as a JSON string and rejects a plain-object argument to executeTool() with "Failed to parse input arguments". Nothing in the spec asks for a pre-serialised input, so the code calls the spec-shaped way first and falls back only when that build refuses.

My own buttons sabotaged two takes. The "Agent console" had a button labelled CALL MASK_SENSITIVE_DATA(). Asked to "use the page's tools", the agent clicked it — twice. Not a dim agent: the button had named itself after the API, so it was the closest match to the instruction. Renaming it to "Preview the masking response", and adding a line in llms.txt telling agents to call the tool instead, fixed what a better prompt could not.

Cloudflare hid three deploys. Assets are served with a four-hour browser cache, so the page kept running the previous build and no hard refresh changed it. I lost two hours because I was verifying with curl --resolve ...:127.0.0.1, which goes to the origin and bypasses the edge — always showing me the correct file.

What I learned

The one that mattered: a tool that takes the sensitive data as a parameter cannot protect it.

My first version was mask_sensitive_data(text). It returns only placeholders, never an original value, and I wrote in my notes that "the agent never saw the tax code". That was false, and I did not notice until, halfway through recording, I asked myself whether pasting the contract into the prompt defeated the point of the app. It did — and so, more quietly, did passing it as a tool argument. Whoever calls that tool must already hold the document.

There is a real difference between "the response contains no secrets" and "the caller never receives them", and only the second is a guarantee. mask_document_in_page exists because of that question.

The second lesson: instrument your claims. I added a panel that logs every execute() the page receives and marks whether it came from the page's own buttons or from outside. It exists because you cannot otherwise see WebMCP working — the page looks identical either way. That panel is what caught the Chrome extension reading the DOM, and both takes where the agent clicked a button instead of calling a tool. Without it I would have shipped a video of something that wasn't happening.

And a third, smaller one: tests find the bugs you just wrote. Four surfaced in a single session, three of them minutes old — including a legal form matching inside an ordinary word, which rewrote the heading "CONSULTANCY AGREEMENT" as "[COMPANY_NAME_1]REEMENT".

Honest limits

  • Detection covers Italian, Spanish and English/international formats. A document in another language will still have its IBANs, cards, emails and international phone numbers found, but not its names, addresses or company forms.
  • No PDF or OCR import in this build.
  • Ambiguous candidates — a capitalised pair that may or may not be a person, an amount that usually belongs in the draft — are proposed switched off, and whatever stays readable after masking is listed rather than hidden. An incomplete mask the user trusts is the real failure mode.
  • restore_sensitive_data only works in the tab that masked the document, because the vault is page memory. That is a privacy property, not a missing feature.
  • The guarantee is that the app never hands the document to the caller. It is not that an agent driving a browser is unable to look at the screen.

What's next

PDF and OCR import, pattern packs beyond the EU, and a tool that lets an agent request a masked excerpt rather than the whole document.


Written with Claude (Claude Code), pair-programming style: the specification research, the port of the engine, the Spanish and English coverage and the test suite came out of that collaboration. On a project whose argument is that tools should declare what they do and what they cannot, it would be odd not to say how this one was made.

Built With

Share this project:

Updates

Submission history