The problem
To rent a flat in Britain you upload your payslips, your bank statements and your passport to six letting agents. Every one of them keeps a copy, forever.
None of them wanted your salary. They wanted one answer: does your income cover three times the rent, yes or no.
That gap between what is asked for and what is needed is the whole product.
What Vouchsafe is
Two origins. Your file lives on one; a letting agency lives on the other. Your facts never leave your browser. The agency asks a question through WebMCP and gets one word back.
The interesting part is what happens when you change your mind. Withdraw a permission and the tool is not disabled on the agency's side, it is deregistered. Authority here is not a permission check. It is whether the tool exists. A withheld permission is not a guarded tool that refuses; there is no tool, so it cannot be called, guessed at, or talked into existence by anything written into a page or a prompt.
How it is built
Vanilla ES modules. No build step, no framework, no runtime dependencies, and zero third-party requests from either origin, which is enforced by a test rather than asserted in a README.
The cross-origin round trip is the part we could not find anyone else doing in full:
- Your file registers each allowed question with
exposedTonaming the agency's origin. - The agency calls
getTools({fromOrigins})and discovers them. - It re-registers each discovered tool as a local tool, so an agent sees one flat toolset and never has to know two websites were involved.
- On
toolchange, it diffs and tears down proxies for anything that has stopped being offered.
Step 3 and step 4 are what make revocation land mid-conversation.
Things we found out about Chrome's implementation
We wrote these down with reproduction steps because we could not find them documented anywhere.
getTools()hands backinputSchemaas a JSON string, butregisterTool()demands an object. Re-registering a discovered tool fails silently until you parse it.getTools({fromOrigins})also returns your own tools, so a naive proxy loop registers proxies for its own proxies.- A cross-origin iframe's tools do not appear in the host's plain
getTools(). You have to ask for them by origin. - Federation is scoped to the frame tree, not the browser.
toolchangefires once per registration, not once per batch, so a nine permission grant is nine events and the handler has to be idempotent.- Revocation crosses the origin boundary with no channel of our own. The browser does it.
- The declarative form API derives the schema from the form's own inputs. We
get sixteen parameters for free, and omitting
toolautosubmitparks the call for a human, which is a consent gate written in HTML.
The hard part, and the honest limit
A yes-or-no predicate leaks more than one bit if the caller picks the threshold. Ask "is income at least 3x £2000", then £1500, then £1300, and five calls have bracketed a salary to a £3,600 window. Every individual answer is legitimate. The sequence is a salary disclosure wearing a predicate's clothes.
So your file tracks the bracket each origin has established, prices what has been learned in bits, and stops answering once the remaining uncertainty falls below a floor. In the demo you watch five numbers get answered and the sixth refused.
We are not claiming this is leak-proof. A patient caller spread across sessions, or several origins pooling answers, defeats a per-origin window. Adding noise would bound it properly and would also make an affordability check occasionally wrong, which is not a trade a letting decision can absorb. This is the cheap honest version, and it says so in the code.
What we learned about testing this
Playwright was the wrong tool and we can say exactly why: its launcher
appends its own --enable-features flag after ours, so WebMCP is silently
dropped and the API is simply absent. We drive Chrome and Brave over the
DevTools Protocol directly instead, and the same suite runs against both.
Two bugs worth confessing. Two new modules were written and never deployed,
so a 404 killed the whole module graph and both live pages rendered blank
while 108 tests passed, because every one of them ran against a local server
where the files existed. And an announcer banner marked hidden painted on
every page load anyway, because display: grid on the element beats the user
agent's display: none for [hidden]. Both now have tests.
118 tests: 66 unit, 52 in a real browser.
Verify it yourself
Do not take any of this on trust. /proof.html reads the browser's own tool
registry on both origins and redraws on toolchange. It registers nothing
itself, which is what makes it a witness rather than a participant. Toggle a
permission and watch a tool stop existing.
Built With
- brave
- chrome
- chrome-devtools-protocol
- css3
- html5
- javascript
- localstorage
- node.js
- vercel
- web-components
- webmcp
Log in or sign up for Devpost to join the conversation.