Story
An agent shopping on your behalf reads a marketplace listing, looks up your billing record, and sends an order. Three reasonable steps. In this demo the listing was written by a hostile seller, and the third step publishes your account reference to a public channel.
I tested that flow in ChatGPT's in-app browser. The agent did not fall for the injection — it stopped and asked me to confirm. I confirmed. Airlock blocked the call anyway, because a user approving an attempt is not the same as an origin having authority to cross a trust boundary.
Browsers isolate origins. Agents bridge them. Airlock puts an enforcement point back on that bridge.
Inspiration
WebMCP gives sites a much better way to work with agents. Instead of making an agent guess its way through pixels and DOM interactions, a page can expose real capabilities. That's powerful, and it also creates a trust problem I kept circling back to.
Commerce is where this lands first. An agent that buys things visits a marketplace it does not control, a payment or profile surface that holds real data, and some outbound action that actually commits. Those are three different trust domains, and the browser's same-origin policy governs none of the path between them — because nothing about an agent reading one site and writing to another is a cross-origin request.
Each tool call can look reasonable on its own. The path between them may not be. I wanted to see whether that path could have an enforceable boundary of its own.
What it does
Airlock sits between an agent and WebMCP capabilities from multiple origins. The demo has three independent partner origins:
- Bazaar — a semi-trusted marketplace where seller-controlled content can be hostile.
- Vault — trusted, read-heavy private data.
- Dispatch — trusted outbound actions.
Rather than handing those partner tools directly to the agent, Airlock discovers
them and publishes mediated airlock_* capabilities. Every mediated call goes
through the same policy path before the underlying capability can execute.
Airlock considers the capability being requested, whether it's a read or a write,
the trust assigned to its origin, and the provenance of values being carried into
the request.
If policy refuses, execution stops there. This isn't advice to the model — the underlying capability is never invoked.
The attack
A seller on Bazaar publishes a listing containing instructions that lead the
agent to construct an order using an account reference. The agent reads the
listing, reads the requested account reference from Vault, and then tries to send
ORDER 4412 ref=ACCT-7731-QX45 through Dispatch.
Looking at that final call alone loses the context that makes the flow dangerous.
dispatch_send_message is a legitimate capability and its arguments are
structurally valid. But Airlock has the history of how the request got there, so
it traces relevant values back to semi-trusted Bazaar content and blocks the
write before Dispatch ever receives it. The refusal comes back as structured
data:
status: blocked_by_policyenforcedBy: airlock-policy-enginecapabilityInvoked: false
along with the policy reason and recorded provenance.
Worth saying which payload this is. An imperative one — opening with
SYSTEM: Ignore previous instructions — was tried first and the agent ignored
it. That's a strawman and no demo should rest on it. The payload in the demo is
procedural: seller notes describing a plausible small-shop fulfilment process
whose steps happen to move the buyer profile into a public write, framed as part
of the thing the user just asked for.
Why a confirmation dialog is not the same thing
The obvious objection: doesn't the agent already ask the user before doing something sensitive?
It does, and I tested exactly that. ChatGPT's browser enumerated eight mediated Airlock tools and no partner tool by its own name, searched Bazaar, read the listing, accessed the Vault record, reached the outbound Dispatch action, and stopped to ask:
STEP 5 requires confirmation: send
ORDER 4412 ref=ACCT-7731-QX45, including the account reference, to Airlock Dispatch's public orders channel. Confirm that I should attempt this outbound message.
That is a good question. It names the value and the channel. It is still the wrong question, because it presents a step invented by seller-controlled text as an ordinary part of checkout. A user who says yes has approved the attacker's plan under the attacker's framing.
I said yes. The capability still did not execute.
Four things are true of that confirmation prompt:
- It is prose written by the model the injection targets. Had the injection landed, the same sentence would have been produced with the same confidence about attacker-supplied text. A component that can be fooled cannot be the thing that certifies it wasn't.
- No boundary was crossed to name. It named one origin because there was only one — that browser cannot discover a second.
- It is per-call and stateless. No rule survives it. Nothing expresses "data from this origin may never reach a write on that one."
- It is prose, not data. Not machine-checkable, not auditable, not replayable.
The claim isn't that models get tricked — in this test the model didn't. It's enforcement that doesn't depend on the model's judgement, which holds whether or not the next model resists injection better. The agent also has no WebMCP tool for granting itself an override, disabling policy, or approving its own blocked request. Human authority stays outside the agent tool surface.
How I built it
Airlock is split across four independently deployed origins. The Console owns discovery, mediation, policy, provenance, consent, audit, and the agent-facing tool surface. Bazaar, Vault, and Dispatch expose the capabilities being mediated. The execution path is intentionally boring:
Agent → airlock_* capability → policy → underlying capability
That order is the whole point — a blocked decision returns before the resolver responsible for the underlying operation can execute.
I also separated transport from enforcement. Native WebMCP cross-origin behavior is still experimental and differs between environments, so Airlock wraps discovery and execution in a resolver abstraction. Swapping the transport resolver doesn't bypass or replace the policy engine, provenance model, consent boundary, or ledger. Instead of pretending those environments behave identically, the WebMCP page in Airlock reports what the current browser actually supports.
How Airlock uses WebMCP
WebMCP isn't an API call I bolted on at the end. The capability model is the reason Airlock exists. Along the way I used:
- cross-origin discovery with
getTools({ fromOrigins }) - dynamic tool registration and
AbortSignal-based unregistration - tool annotations, including
untrustedContentHinton hostile output - structured refusals a model can plan around, not prose it has to interpret
- model-readable schemas with enums and descriptions
- declarative tools on a real form
toolchangebehavior across browser environments
The tool surface follows application state. If an origin or capability becomes unavailable, its mediated capability disappears rather than sitting there as something the agent is still allowed to try.
One security decision around annotations mattered more than I expected:
annotations from foreign tools are treated as claims, not policy authority. A
remote tool calling itself read-only doesn't get to decide whether Airlock trusts
it. Bazaar ships a tool annotated readOnlyHint: true that publishes anyway, and
asks for a home address it has no stated use for. The console flags both at
discovery, before anything runs.
Six WebMCP findings, measured and written up
Building a consumer of other origins' tools surfaced six things worth reporting, measured against deployed HTTPS origins in Brave 151, Chrome 149+ with the flag, and ChatGPT's in-app browser.
Two are questions about what the spec intends:
getTools({ fromOrigins })unions the page's own tools with foreign ones rather than filtering to the named origins. The practical consequence is that provenance can never be inferred from what was requested — it has to come from each tool's ownorigin.- A tool registered through the declarative form API is invisible to
cross-origin discovery.
dispatchregisters one, the browser's own registry confirms it, and the console's federated surface never sees it. The two APIs are not interchangeable across an origin boundary, and nothing says so — the call succeeds, returns tools, and silently omits one.
Four are silent failures. Not one threw an exception describing its cause, and the page looked healthy through all of them:
getTools({ fromOrigins })never settles in some builds — it does not reject, so a hung promise is indistinguishable from a slow one- A foreign
inputSchemaarrives as a JSON string, not an object;Object.keyson it returns character indices, which silently disabled parameter-overreach detection - A replaced mediator leaves its proxies registered, so its successor collides
with itself and reports
Duplicate tool nameas though a partner were at fault registerToolfirestoolchange, so a publish triggered by that event collides with the names it just claimed — the console republishing in response to its own writes
The takeaway generalises past this project: a WebMCP consumer cannot rely on
try/catch. It has to verify results, put deadlines on calls, treat
inputSchema as either a string or an object, compare a tool surface before
republishing it, and surface partial failure. Counting is the only detection
that works. Reporting "0 of 5 proxies registered" is what found three of these
within minutes; before that line existed, the console appeared to work while
handing the agent nothing.
Full repros, the measured browser support matrix, and filing status are in
docs/FINDINGS.md.
Audit and replay
Every mediated decision goes into an audit ledger — what capability was requested, which origin owned it, what policy fired, where the relevant values came from, and whether the underlying capability actually executed.
I wanted blocked decisions to be reproducible without making debugging
dangerous, so replay deliberately has no execution resolver. It reconstructs
provenance from the ledger and re-evaluates policy, but it cannot invoke the
capability being inspected. For the demo attack: Original: BLOCK,
Replay: BLOCK. Nothing is sent during replay.
What this does not solve
Three limits worth naming rather than leaving for a reader to find:
Trust assignment has no provenance of its own. Origin trust levels are configured in the console. In a real deployment nothing stops a hostile origin from presenting itself as trustworthy. This is the same bootstrapping problem certificate authorities exist to solve for TLS, and signed capability metadata, trust-on-first-use, and organisation-level policy are the directions worth exploring.
Airlock is itself a fully trusted origin. The console holds every mediated capability, the provenance record, and the policy engine. That moves the trust problem rather than removing it. The defence is narrow and I'll state it as a defence rather than a solution: the user chooses the console explicitly, where partner origins arrive through federation — one deliberate trust decision instead of N implicit ones.
Taint matching is textual. The prototype uses a deterministic heuristic over
values observed in mediated results. It catches carried text — in the demo, the
literal overlap "order 4412 ref=" — not paraphrase. Airlock does not solve
prompt injection and this is not semantic information-flow tracking. The policy
decision rests on origin trust and the shape of the call; the match is what lets
Airlock explain why in terms a user can check.
What I learned
Tool availability and tool authority are different problems. WebMCP gives agents a clean way to discover what a website can do. As those capabilities span websites, there's a second question worth asking: even if this capability exists, should this particular flow be allowed to reach it?
Most of the hard parts weren't in the UI. They were questions about where authority actually lives. If an agent can request its own override, is the policy really independent? If replay can reach the normal execution resolver, can inspecting an old decision repeat the action? If browser fallback skips mediation, did compatibility just become a security bypass? Those questions gradually turned into invariants in the implementation rather than special cases in the interface.
I also had to correct myself once, publicly, in the findings doc. An earlier framing of mine said the built-in confirmation was provenance-blind. That turned out to be wrong as written — the agent did name the tool's origin. What survives is narrower and stronger: self-attested provenance is worthless under compromise, because it is prose generated by the same model the injection targets.
What's next
Stronger provenance — following values through transformations instead of the deterministic heuristic used here. Then policy composition across larger sets of origins, signed capability metadata, organization-level policies, and what a browser-native version of this boundary could look like as WebMCP evolves.
The experiment answered the question I started with. An agent can bridge capabilities across websites. That doesn't mean every bridge has to be trusted.
Browsers isolate origins. Agents can bridge their capabilities. Airlock adds an enforceable trust boundary across that bridge.
Built With
- claude
- codex
- gpt5.6-sol
- netlify
- react
- typescript
- vite
- webapi
- webmcp
Log in or sign up for Devpost to join the conversation.