mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
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:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user