feat(web): WebMCP untrustedContentHint + consent verification checklist (#767)

Set annotations.untrustedContentHint=true on WebMCP tool descriptors for
every catalog tool whose output surfaces user-authored workspace content.
Signals the browser agent to treat results as unverified input
(prompt-injection hardening, DR-2). Derivation is a small pure helper
(surfacesUntrustedContent) parallel to isAllReadOnly; the builder stays
side-effect-free.

The rule is derived from the action set, not the tool name: a tool surfaces
content unless EVERY action is content-free server introspection (server-info
/ version / tool-surface). This correctly flags pad_meta — despite its name it
exposes a `bootstrap` action that returns the workspace bootstrap blob (user +
workspace content) — while still exempting a hypothetical pure version/meta
surface.

Add a consent manual-verification checklist (web/src/lib/webmcp/README.md)
capturing the Chrome-149 origin-trial steps that can't run in CI: read tools
run quiet, mutating tools prompt per-invocation consent, no workspace arg is
offered, untrusted-content honesty.

Refs TASK-1896 / PLAN-1888

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
This commit is contained in:
xarmian
2026-06-24 23:58:16 -04:00
committed by GitHub
parent 2ee7dd3fac
commit fb40a62e23
3 changed files with 203 additions and 4 deletions
+64
View File
@@ -0,0 +1,64 @@
# WebMCP browser surface
Browser-side WebMCP layer (PLAN-1888) that registers Pad's catalog tools with
`document.modelContext.registerTool()` so browser-native AI agents can call them
against the logged-in web session. Gated behind the opt-in `webmcp_enabled`
platform setting (default off); native-API-only with feature detection.
Module pieces:
- `descriptors.ts` — pure tool-surface JSON → `ModelContextTool` builder.
- `dispatch.ts``(tool, action, args) → client.ts`, forcing the route `wsSlug`.
- `register.ts` — lifecycle: fetch surface, build, register, unregister.
- `types.d.ts``document.modelContext` type stub.
## Tool annotations (DR-2)
Two browser-only annotations are derived in `descriptors.ts`:
- **`readOnlyHint`** — set only when *every* action a tool exposes is a read
(`isAllReadOnly`). Mixed tools (e.g. `pad_item`) omit it, so the host prompts
for per-invocation consent on every call.
- **`untrustedContentHint`** — set for every tool whose output can echo
user-authored workspace content (`surfacesUntrustedContent`): a tool surfaces
content unless **every** action it exposes is content-free server
introspection (`server-info` / `version` / `tool-surface`). Tells the agent to
treat the result as unverified input — prompt-injection hardening against a
malicious item body smuggling instructions back to the agent. **Note:**
`pad_meta` DOES carry the hint — despite its name it also exposes `bootstrap`,
which returns the workspace bootstrap blob (user + workspace content). All 9
catalog tools therefore carry it today; the exemption only applies to a
hypothetical pure version/meta surface.
## Consent manual-verification checklist
The Chrome WebMCP origin-trial consent behavior can't be exercised in CI (no
headless origin-trial harness), so verify it by hand after touching the
descriptor/annotation layer. Run in **Chrome 149+** with the WebMCP origin
trial enabled and the platform setting `webmcp_enabled` turned on, signed in to
a workspace.
1. **Read tools run quietly.** Have the browser agent call an **all-read** tool
— one whose every action is a read (e.g. `pad_search action=query`,
`pad_project action=dashboard`). It should execute **without** a consent
prompt. These carry `readOnlyHint: true`. NB: `readOnlyHint` is per-*tool*,
so a read action on a *mixed* tool (e.g. `pad_item action=list`) still
prompts — `pad_item` carries no `readOnlyHint` because it also exposes
writes. Verify that too: `pad_item action=list` should prompt.
2. **Mutating tools prompt for consent.** Have the agent call a write action on
a mixed tool — `pad_item action=create` and `pad_item action=delete`. Each
call must trigger a **per-invocation consent prompt** before it runs.
Declining must abort the call (no item created/deleted). Mixed tools carry
**no** `readOnlyHint`, which is what makes the host prompt.
3. **No `workspace` arg is offered (DR-4).** Inspect the tools the agent sees
(its tool list / schema). No tool's `inputSchema` should expose a
`workspace` property — it's stripped in `buildDescriptor`. Confirm the agent
cannot target a workspace other than the current route's; every dispatch is
forced to the route `wsSlug`.
4. **Untrusted-content honesty.** Confirm content-surfacing tool results are
flagged with `untrustedContentHint: true` (e.g. via the agent host's tool
inspector). Every catalog tool — including `pad_meta`, whose `bootstrap`
action returns workspace content — carries the hint today.
Record the result (pass/fail per step + Chrome version) in the PR or task when
re-verifying.
+98 -4
View File
@@ -1,6 +1,11 @@
import { describe, it, expect } from 'vitest';
import type { ToolSurfaceTool, ToolSurfaceResponse } from '$lib/api/client';
import { buildDescriptor, buildDescriptors, isAllReadOnly } from './descriptors';
import {
buildDescriptor,
buildDescriptors,
isAllReadOnly,
surfacesUntrustedContent,
} from './descriptors';
// A no-op execute closure — the builder is pure aside from carrying it through.
const noopExecute = async (): Promise<ModelContextToolResult> => ({
@@ -41,6 +46,43 @@ const padItem: ToolSurfaceTool = {
],
};
// pad_meta as the REAL catalog serves it: server-info/version/tool-surface are
// pure introspection, but `bootstrap` returns the workspace bootstrap blob
// (user + workspace content). So pad_meta IS content-surfacing — it must carry
// untrustedContentHint. (Mirrors internal/mcp/catalog_meta.go's Actions map.)
const padMeta: ToolSurfaceTool = {
name: 'pad_meta',
description: 'Server introspection + the agent bootstrap blob.',
workspace: true,
actions: [
{ name: 'server-info', read_only: true },
{ name: 'version', read_only: true },
{ name: 'tool-surface', read_only: true },
{ name: 'bootstrap', read_only: true },
],
params: [
{
name: 'action',
type: 'string',
enum: ['server-info', 'version', 'tool-surface', 'bootstrap'],
},
],
};
// A hypothetical PURE version/meta surface — every action is content-free
// server introspection, no bootstrap. The only shape that must NOT carry
// untrustedContentHint.
const pureMeta: ToolSurfaceTool = {
name: 'pad_meta_pure',
description: 'Version/meta only.',
workspace: false,
actions: [
{ name: 'version', read_only: true },
{ name: 'tool-surface', read_only: true },
],
params: [{ name: 'action', type: 'string', enum: ['version', 'tool-surface'] }],
};
describe('isAllReadOnly (DR-2)', () => {
it('true when every action is read_only', () => {
expect(isAllReadOnly(padSearch)).toBe(true);
@@ -55,6 +97,56 @@ describe('isAllReadOnly (DR-2)', () => {
});
});
describe('surfacesUntrustedContent (DR-2, prompt-injection hardening)', () => {
it('true for a content-surfacing tool (pad_search)', () => {
expect(surfacesUntrustedContent(padSearch)).toBe(true);
});
it('true for a mixed read/write content tool (pad_item)', () => {
expect(surfacesUntrustedContent(padItem)).toBe(true);
});
it('true for pad_meta — its bootstrap action surfaces workspace content', () => {
expect(surfacesUntrustedContent(padMeta)).toBe(true);
});
it('false only for a pure version/meta surface (no bootstrap)', () => {
expect(surfacesUntrustedContent(pureMeta)).toBe(false);
});
it('true for a zero-action tool (conservative)', () => {
expect(surfacesUntrustedContent({ ...padSearch, actions: [] })).toBe(true);
});
});
describe('buildDescriptor — untrustedContentHint (DR-2)', () => {
it('sets untrustedContentHint=true for a content tool', () => {
const { tool } = buildDescriptor(padSearch, noopExecute);
expect(tool.annotations?.untrustedContentHint).toBe(true);
});
it('sets untrustedContentHint=true even on a read-only content tool', () => {
// pad_search is all-read, so it ALSO gets readOnlyHint — both coexist.
const { tool } = buildDescriptor(padSearch, noopExecute);
expect(tool.annotations?.readOnlyHint).toBe(true);
expect(tool.annotations?.untrustedContentHint).toBe(true);
});
it('sets untrustedContentHint=true for the real pad_meta (bootstrap)', () => {
const { tool } = buildDescriptor(padMeta, noopExecute);
// All-read → readOnlyHint, and content-surfacing via bootstrap → hint.
expect(tool.annotations?.readOnlyHint).toBe(true);
expect(tool.annotations?.untrustedContentHint).toBe(true);
});
it('omits untrustedContentHint for a pure version/meta surface', () => {
const { tool } = buildDescriptor(pureMeta, noopExecute);
// All-read → readOnlyHint set, but NOT untrustedContentHint.
expect(tool.annotations?.readOnlyHint).toBe(true);
expect('untrustedContentHint' in (tool.annotations ?? {})).toBe(false);
});
});
describe('buildDescriptor — workspace strip (DR-4)', () => {
it('strips the workspace param from the inputSchema', () => {
const { tool } = buildDescriptor(padSearch, noopExecute);
@@ -88,10 +180,12 @@ describe('buildDescriptor — readOnlyHint (DR-2)', () => {
expect(tool.annotations?.readOnlyHint).toBe(true);
});
it('omits annotations entirely for a mixed tool', () => {
it('omits readOnlyHint for a mixed tool (but still flags untrusted content)', () => {
const { tool } = buildDescriptor(padItem, noopExecute);
// No readOnlyHint at all → the host prompts for per-invocation consent.
expect(tool.annotations).toBeUndefined();
// No readOnlyHint → the host prompts for per-invocation consent. The
// tool still surfaces user content, so untrustedContentHint is set.
expect(tool.annotations?.readOnlyHint).toBeUndefined();
expect(tool.annotations?.untrustedContentHint).toBe(true);
});
});
+41
View File
@@ -11,6 +11,12 @@
// - DR-2: `annotations.readOnlyHint = true` only when EVERY action the tool
// exposes is `read_only`. Mixed tools (e.g. pad_item) get no hint, so the
// host prompts for per-invocation consent on writes.
// - DR-2 (prompt-injection hardening): `annotations.untrustedContentHint =
// true` for every tool whose output can echo user-authored workspace
// content — i.e. every tool except a pure version/meta surface (every
// action is content-free server introspection). Signals the agent to treat
// the result as unverified input. NB: `pad_meta` carries the hint, since
// its `bootstrap` action returns the workspace bootstrap blob.
import type {
ToolSurfaceTool,
@@ -74,12 +80,46 @@ export function isAllReadOnly(tool: ToolSurfaceTool): boolean {
return tool.actions.every((a) => a.read_only);
}
// The catalog actions whose output is pure server introspection — version
// metadata, the tool-catalog dump, server-info — read from in-memory server
// state, never from any item/comment/dashboard. A tool is content-free ONLY if
// EVERY action it exposes is one of these. NB: `pad_meta` is NOT content-free
// despite its name — it also exposes `bootstrap`, which returns the workspace
// bootstrap blob (user + workspace content). So `pad_meta` correctly DOES carry
// untrustedContentHint; only a hypothetical pure version/meta surface wouldn't.
const META_INTROSPECTION_ACTIONS: ReadonlySet<string> = new Set([
'server-info',
'version',
'tool-surface',
]);
/**
* Derive `untrustedContentHint` per DR-2 (prompt-injection hardening): true for
* every tool whose output can surface user-authored workspace content — i.e.
* every catalog tool except a pure version/meta-only surface (every action is
* server introspection). The hint tells the browser agent to treat the result
* as unverified data so a malicious item body can't smuggle instructions back
* to the agent.
*
* Derived from the action set, NOT the tool name: a tool surfaces content
* unless EVERY action it exposes is a content-free introspection action. This
* correctly flags `pad_meta` (its `bootstrap` action returns workspace content)
* while still exempting a purely version/meta tool. A tool with zero actions
* (shouldn't happen for catalog tools) is treated as content-surfacing — the
* conservative direction.
*/
export function surfacesUntrustedContent(tool: ToolSurfaceTool): boolean {
if (!tool.actions || tool.actions.length === 0) return true;
return !tool.actions.every((a) => META_INTROSPECTION_ACTIONS.has(a.name));
}
/**
* Build a single ModelContextTool descriptor from a tool-surface tool.
*
* - STRIPS the `workspace` param (DR-4) — it is never placed in the schema.
* - `action` stays required (the catalog dispatch verb).
* - readOnlyHint applied per DR-2.
* - untrustedContentHint applied per DR-2 for content-surfacing tools.
*
* `execute` is supplied by the caller (the dispatcher closure) so this stays
* pure and side-effect-free for testing.
@@ -109,6 +149,7 @@ export function buildDescriptor(
const annotations: ModelContextToolAnnotations = {};
if (isAllReadOnly(tool)) annotations.readOnlyHint = true;
if (surfacesUntrustedContent(tool)) annotations.untrustedContentHint = true;
const descriptor: ModelContextTool = {
name: tool.name,