diff --git a/web/src/lib/webmcp/README.md b/web/src/lib/webmcp/README.md new file mode 100644 index 00000000..f07041f5 --- /dev/null +++ b/web/src/lib/webmcp/README.md @@ -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. diff --git a/web/src/lib/webmcp/descriptors.test.ts b/web/src/lib/webmcp/descriptors.test.ts index 13001210..5e829dd3 100644 --- a/web/src/lib/webmcp/descriptors.test.ts +++ b/web/src/lib/webmcp/descriptors.test.ts @@ -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 => ({ @@ -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); }); }); diff --git a/web/src/lib/webmcp/descriptors.ts b/web/src/lib/webmcp/descriptors.ts index 31c01458..ad26e8cd 100644 --- a/web/src/lib/webmcp/descriptors.ts +++ b/web/src/lib/webmcp/descriptors.ts @@ -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 = 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,