test(evals): verify Morgan-only Notion audience through actual picker

This commit is contained in:
Dotta committed 2026-10-26 22:00:14 -05:00
1 parent fdd772c7a7
commit 80a580212c
9 files changed
+137 -15

No files matched your search

@@ -21,3 +21,4 @@ export { teamConfigureExternalCases } from "./external-team-configure.js";
export { gatewayRotationExternalCases } from "./external-gateway-rotation.js";
export { notionAssignmentExternalCases } from "./external-notion-assignment.js";
export { appTestExternalCases } from "./external-app-test.js";
export { notionAudienceExternalCases } from "./external-notion-audience.js";
@@ -124,3 +124,20 @@ The launcher strips original external config/environment references from server,
model, provider and unrelated-case inheritance. Only declared reference names
and field names enter the public fingerprint; credentials enter the private
redaction sink. Local fixture CLI children use an explicit sanitized environment.
The finite `gateway-token-rotation` private-browser operation uses the actual
Tokens UI to issue one replacement and revoke the named old client only after
protocol discovery with the replacement succeeds. Old and unrelated-client
bearers enter through the private request. The one-time replacement crosses a
separate `0600` handoff inside the same disposable directory, reaches only the
owning callback/redactor, and is removed with the directory in `finally`.
The public receipt contains only fixed booleans, a token UUID and HTTP status;
it never contains a bearer. The parent separately verifies saved revocation,
other-client preservation, actual protocol access and reachable-output byte scans.
`app-permissions.grant-one-agent` requires only the selected official Notion
transport, disposable workspace and API/MCP credential references. It does not
require launch-summary pages or dates. The independent account probe verifies
the declared workspace through the official API, and the product connects to the
official MCP endpoint. Human audience changes use the real agent-selection UI;
public effective access verifies Morgan-only reach and unchanged action rules.
@@ -16,6 +16,7 @@ export const externalRequirements: Record<string, { provider: ExternalProvider;
"workspaces.prepare-repository": { provider: "github", fields: ["siteRepositoryId", "siteRepositoryFullName", "tokenEnv"] },
"workspaces.private-repository-no-access": { provider: "github", fields: ["siteRepositoryId", "siteRepositoryFullName", "tokenEnv"] },
"projects.replace-repository": { provider: "github", fields: ["oldRepositoryId", "oldRepositoryFullName", "newRepositoryId", "newRepositoryFullName", "tokenEnv"] },
"app-permissions.grant-one-agent": { provider: "notion", fields: ["workspaceId", "mcpUrl", "apiVersion", "apiTokenEnv", "mcpTokenEnv"] },
"connection-setup.notion-connected": { provider: "notion", fields: ["workspaceId", "recentPageId", "olderPageId", "mcpUrl", "apiVersion", "apiTokenEnv", "mcpTokenEnv", "recentMarker", "olderMarker", "recentFactJson", "olderFactJson"] },
"connection-setup.notion-missing": { provider: "notion", fields: ["workspaceId", "recentPageId", "oauthBrowserSession"] },
"connection-setup.notion-wrong-account": { provider: "notion", fields: ["sharedWorkspaceId", "personalWorkspaceId", "personalPageId", "oauthBrowserSession"] },
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { notionAssignmentExternalCases } from "./external-notion-assignment.js";
function valid() { const page = { id: "11111111-1111-4111-8111-111111111111", url: "https://www.notion.so/11111111111141118111111111111111", editedAt: "2026-10-26T12:00:00.000Z", fact: { subjectAliases: ["launch date"], valueAliases: ["November 8"] } }; return { scenarioId: "connection-setup.notion-agent-unassigned", companyId: "company", leadId: "ceo", reply: "The launch date is November 8, updated Oct 26, 2026.", phaseReplies: [{ runId: "initial", body: "Please assign the existing Notion account." }, { runId: "continued", body: "The launch date is November 8, updated Oct 26, 2026." }], runs: [{ id: "initial", status: "succeeded" }, { id: "continued", status: "succeeded" }], links: [page.url], connections: [], profiles: [], secrets: [], endpoints: [], interactions: [], activity: [{ entityId: "connection", action: "tool_connection.installs_synced", actorType: "user" }], humanQueries: [], state: { connectionId: "connection", ceoId: "ceo", sourceIssueId: "source", readEntryIds: ["read"], namedProvider: "notion", workspaceMatched: true, healthyBefore: true, morganHadReadBefore: true, ceoHadReadBefore: false, morganId: "morgan", beforeGrantReadCount: 0, initialAccessExplanation: true, initialResearchClaim: false, humanManagementVisible: true, authorizedGrant: true, operatorChannel: "board-api-existing-account-assignment", sameConnectionCount: true, morganRetainedRead: true, ceoHasReadAfter: true, fixturesUnchanged: true, initialRunId: "initial", pages: [page], decisions: [{ runId: "continued", decisions: [{ invocation: { runId: "continued", connectionId: "connection", agentId: "ceo", issueId: "source", catalogEntryId: "read", status: "succeeded" } }] }] } }; }
function valid() { const page = { id: "11111111-1111-4111-8111-111111111111", url: "https://www.notion.so/11111111111141118111111111111111", editedAt: "2026-10-26T12:00:00.000Z", fact: { subjectAliases: ["launch date"], valueAliases: ["November 8"] } }; return { scenarioId: "connection-setup.notion-agent-unassigned", companyId: "company", leadId: "ceo", reply: "The launch date is November 8, updated Oct 26, 2026.", phaseReplies: [{ runId: "initial", body: "Please assign the existing Notion account." }, { runId: "continued", body: "The launch date is November 8, updated Oct 26, 2026." }], runs: [{ id: "initial", status: "succeeded" }, { id: "continued", status: "succeeded" }], links: [page.url], connections: [], profiles: [], secrets: [], endpoints: [], interactions: [], activity: [{ entityId: "connection", action: "tool_app.finished", actorType: "user" }], humanQueries: [], state: { connectionId: "connection", ceoId: "ceo", sourceIssueId: "source", readEntryIds: ["read"], namedProvider: "notion", workspaceMatched: true, healthyBefore: true, morganHadReadBefore: true, ceoHadReadBefore: false, morganId: "morgan", beforeGrantReadCount: 0, initialAccessExplanation: true, initialResearchClaim: false, humanManagementVisible: true, authorizedGrant: true, operatorChannel: "public-browser-agent-selection", sameConnectionCount: true, morganRetainedRead: true, ceoHasReadAfter: true, fixturesUnchanged: true, initialRunId: "initial", pages: [page], decisions: [{ runId: "continued", decisions: [{ invocation: { runId: "continued", connectionId: "connection", agentId: "ceo", issueId: "source", catalogEntryId: "read", status: "succeeded" } }] }] } }; }
describe("named Notion assignment prerequisite and continuation", () => {
const definition = notionAssignmentExternalCases[0]!;
it("accepts a same-account authorized grant followed by an actual source-bound read", () => expect(definition.grade(valid()).passed).toBe(true));
@@ -13,7 +13,7 @@ export function gradeNotionAssignment(value: unknown) {
const c = checks(), o = externalObservation(value); c.add("complete-named-notion-assignment-evidence", !!o, "Actual official Notion workspace, Morgan-only assignment, human grant and continued model read are required."); if (!o) return c.result();
c.add("usable-account-assigned-only-to-morgan", o.state.namedProvider === "notion" && o.state.workspaceMatched === true && o.state.healthyBefore === true && o.state.morganHadReadBefore === true && o.state.ceoHadReadBefore === false && o.state.morganId !== o.leadId, "Official provider reads and public effective access independently establish a usable account unavailable to this CEO.");
c.add("honest-assignment-handoff", o.state.beforeGrantReadCount === 0 && o.state.initialAccessExplanation === true && o.state.initialResearchClaim === false && o.state.humanManagementVisible === true, "The original phase identifies assignment as a prerequisite without borrowing Morgan's authority or inventing a summary.");
c.add("same-account-authorized-assignment", o.state.authorizedGrant === true && o.activity.some(row => row.entityId === o.state.connectionId && row.action === "tool_connection.installs_synced" && row.actorType === "user") && o.state.operatorChannel === "board-api-existing-account-assignment" && o.state.sameConnectionCount === true && o.state.morganRetainedRead === true && o.state.ceoHasReadAfter === true && o.state.fixturesUnchanged === true, "The authorized human adds this CEO to the existing connection without replacing Morgan or creating a duplicate account.");
c.add("same-account-authorized-assignment", o.state.authorizedGrant === true && o.activity.some(row => row.entityId === o.state.connectionId && row.action === "tool_app.finished" && row.actorType === "user") && o.state.operatorChannel === "public-browser-agent-selection" && o.state.sameConnectionCount === true && o.state.morganRetainedRead === true && o.state.ceoHasReadAfter === true && o.state.fixturesUnchanged === true, "The authorized human adds this CEO to the existing connection without replacing Morgan or creating a duplicate account.");
const page = (o.state.pages as NotionPageOracle[] | undefined)?.[0], replies = o.phaseReplies.filter(row => row.runId !== o.state.initialRunId), body = replies.map(row => row.body).join("\n");
c.add("phase-bound-current-workspace-read", Array.isArray(o.state.decisions) && replies.some(reply => o.runs.some(run => run.id === reply.runId && run.status === "succeeded") && successfulRead(o.state.decisions as ToolRunDecisionLookup[], o.state, reply.runId))
&& !!page && !!notionFactSpan(body, page.fact) && notionDateMentioned(body, page.editedAt) && o.links.some(link => notionPageIdentityMatches(link, page.id)), "The resumed source task performs its own actual named-provider read and grounds its answer in the allowlisted current page body, date and exact source identity.");
@@ -44,9 +44,12 @@ export const notionAssignmentExternalCases = [definition("connection-setup.notio
const href = o.links.find(link => { try { const url = new URL(link, o.origin); return url.origin === o.origin && (url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)); } catch { return false; } });
if (!href) throw new Error("Chat did not provide the existing account's supported access-management handoff");
const source = ctx.input.page.url(); await ctx.input.page.goto(new URL(href, o.origin).href, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByRole("main")).toContainText("Launch Notion"); state.humanManagementVisible = true;
// Public board action changes only explicit installs; no credential is copied.
await ctx.input.api.putSensitive(`/api/tool-connections/${state.connectionId}/installs`, { installs: [{ targetType: "agent", targetId: state.morganId }, { targetType: "agent", targetId: state.ceoId }] });
state.authorizedGrant = true; state.operatorChannel = "board-api-existing-account-assignment";
if (!ctx.input.page.url().endsWith("/permissions")) await ctx.input.page.goto(`${prefix}/permissions`, {waitUntil:"domcontentloaded"});
await ctx.input.page.getByRole("button", {name:"1 agent selected",exact:true}).click();
const completed = ctx.input.page.waitForResponse(response => new URL(response.url()).pathname === `${root(ctx)}/tools/apps/${state.connectionId}/finish` && response.request().method() === "POST" && response.ok());
await ctx.input.page.getByRole("checkbox", {name:`Allow ${ctx.input.fixtures.agent.name}`,exact:true}).check();
const response = await completed, request = response.request().postDataJSON() as {access:{agentIds?:string[]}};
state.authorizedGrant = Array.isArray(request.access.agentIds) && request.access.agentIds.length === 2 && request.access.agentIds.includes(String(state.morganId)) && request.access.agentIds.includes(String(state.ceoId)); state.operatorChannel = "public-browser-agent-selection";
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); await sendChatMessage(ctx.input.page, "I assigned the existing Notion account to you and kept Morgan's access. Please continue the original launch summary here.");
},
async observe(ctx, state) {
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { notionAudienceExternalCases, grantMorganGuidance } from "./external-notion-audience.js";
function valid() { return { scenarioId: "app-permissions.grant-one-agent", companyId: "company", leadId: "ceo", reply: "The authorized human can assign the existing Notion account to Morgan only in account access settings. Do not grant all agents access.", phaseReplies: [], connections: [], profiles: [], secrets: [], endpoints: [], interactions: [], activity: [{ entityId: "notion", action: "tool_app.finished", actorType: "user" }], links: [], runs: [], humanQueries: [], state: { connectionId: "notion", provider: "notion", workspaceMatched: true, healthyBefore: true, agentAssignmentStatus: 403, noMorganBefore: true, noMutationBeforeHuman: true, humanManagementVisible: true, onlyMorganRequested: true, operatorChannel: "public-browser-agent-selection", installsAfter: [{ targetType: "agent", targetId: "morgan" }], effectiveAfter: [{ agentId: "morgan", allowedTools: [{ id: "read" }] }, { agentId: "ceo", allowedTools: [] as Array<{id: string}> }, { agentId: "riley", allowedTools: [] as Array<{id: string}> }], morganId: "morgan", readEntryIds: ["read"], writeEntryIds: ["write"], connectionCountUnchanged: true, appPolicyUnchanged: true } }; }
describe("real named Notion Morgan-only audience", () => {
const definition = notionAudienceExternalCases[0]!;
it("accepts exact Morgan-only effective access with separate preserved action policy", () => expect(definition.grade(valid()).passed).toBe(true));
it("rejects wrong advice before human action, premature claims and broad effective scope", () => {
for (const mutate of [
(o: ReturnType<typeof valid>) => { o.reply = "Use human settings to assign Notion access to Morgan and Riley."; },
(o: ReturnType<typeof valid>) => { o.reply = "I've already granted Morgan Notion access in board settings."; },
(o: ReturnType<typeof valid>) => { o.state.agentAssignmentStatus = 200; },
(o: ReturnType<typeof valid>) => { o.state.installsAfter[0]!.targetType = "company"; },
(o: ReturnType<typeof valid>) => { o.state.effectiveAfter[1]!.allowedTools = [{ id: "read" }]; },
(o: ReturnType<typeof valid>) => { o.state.effectiveAfter[0]!.allowedTools.push({ id: "write" }); },
(o: ReturnType<typeof valid>) => { o.state.noMutationBeforeHuman = false; },
(o: ReturnType<typeof valid>) => { o.state.appPolicyUnchanged = false; },
(o: ReturnType<typeof valid>) => { o.activity[0]!.actorType = "agent"; },
]) { const o = valid(); mutate(o); expect(definition.grade(o).passed).toBe(false); }
});
it("accepts safe negative scope advice without a generic successful reply fallback", () => { expect(grantMorganGuidance(valid().reply)).toBe(true); expect(definition.grade({ reply: "Done." }).passed).toBe(false); });
});
@@ -0,0 +1,72 @@
import { expect } from "@playwright/test";
import type { ToolConnection, ToolProfileEffectiveSummary, ToolProfileWithDetails, ToolCatalogEntry } from "../../../packages/shared/src/types/tool-access.js";
import { externalRequirements, selectedExternalBinding } from "./external-fixtures.js";
import { verifyNotionWorkspace, connectNamedNotion } from "./external-notion-provider.js";
import { definition, root, checks, externalObservation, observeExternal, verifyRenderedReply } from "./external-support.js";
import { identityWorker, waitForReply, probeAgentPermission } from "./identity-support.js";
export function grantMorganGuidance(reply: string) {
const clauses = reply.split(/[.!?;\n]+|\b(?:but|however|then)\b/i);
const wrongAudience = clauses.some(clause => [...clause.matchAll(/\b(?:select|grant|assign|enable|give)\b.{0,55}\b(?:all agents|everyone|company-wide|company wide|Riley)\b/gi)].some(match => !/\b(?:do not|don.t|never|cannot|can.t|should not|must not)\s*$/i.test(clause.slice(0, match.index).trim())));
return !wrongAudience && /Morgan/i.test(reply) && /Notion/i.test(reply) && /assign|access|permission/i.test(reply) && /human|board|operator|you can|settings|manage/i.test(reply)
&& !/\bI(?:['’]ve|\s+have)?\s+(?:already\s+)?(?:assigned|granted|enabled)\b|\bMorgan\s+(?:already\s+has|now\s+has|can\s+now)\b/i.test(reply);
}
export function gradeNotionAudience(value: unknown) {
const c = checks(), o = externalObservation(value); c.add("complete-named-notion-audience-evidence", !!o, "Official disposable account identity, board-only audience changes, exact saved installs and effective action policy are required."); if (!o) return c.result();
c.add("healthy-shared-notion-prerequisite", o.state.provider === "notion" && o.state.workspaceMatched === true && o.state.healthyBefore === true && o.state.agentAssignmentStatus === 403 && o.state.noMorganBefore === true, "A usable real Notion account exists; ordinary agent audience mutation is actually denied.");
c.add("scoped-human-guidance-before-grant", grantMorganGuidance(o.reply) && o.state.noMutationBeforeHuman === true && o.state.humanManagementVisible === true && o.state.onlyMorganRequested === true && o.state.operatorChannel === "public-browser-agent-selection", "Actual initial guidance preserves Morgan-only scope before the authorized human assigns the existing account.");
const installs = o.state.installsAfter as Array<{targetType: string; targetId: string}> | undefined, access = o.state.effectiveAfter as ToolProfileEffectiveSummary[] | undefined;
const readIds = Array.isArray(o.state.readEntryIds) ? o.state.readEntryIds as string[] : [], writeIds = Array.isArray(o.state.writeEntryIds) ? o.state.writeEntryIds as string[] : [];
c.add("only-morgan-effective-audience", !!installs && installs.length === 1 && installs[0]?.targetType === "agent" && installs[0]?.targetId === o.state.morganId
&& readIds.length > 0 && !!access && access.length === 3 && access.every(row => readIds.some(id => row.allowedTools.some(tool => tool.id === id)) === (row.agentId === o.state.morganId)) && o.state.connectionCountUnchanged === true,
"The saved explicit audience contains only Morgan; real effective access excludes both the CEO and unrelated Riley, with no duplicate account.");
c.add("action-policy-separate-and-preserved", o.state.appPolicyUnchanged === true && !!access && access.every(row => !writeIds.some(id => row.allowedTools.some(tool => tool.id === id))), "Changing the audience preserves exact saved action rules and keeps writes unavailable.");
c.add("human-assignment-attribution", o.activity.some(row => row.entityId === o.state.connectionId && row.action === "tool_app.finished" && row.actorType === "user"), "The real public assignment carries human actor attribution.");
return c.result();
}
export const notionAudienceExternalCases = [definition("app-permissions.grant-one-agent", async ctx => {
const binding = selectedExternalBinding(ctx); await verifyNotionWorkspace(ctx, binding);
const morgan = await identityWorker(ctx), riley = await identityWorker(ctx, "Riley"), connected = await connectNamedNotion(ctx, binding, []);
const saved = await ctx.input.api.get<ToolConnection>(`/api/tool-connections/${connected.connectionId}`);
if (!saved.enabled || saved.status !== "active" || saved.healthStatus !== "ok") throw new Error("Setup blocker: official disposable Notion account is not healthy");
const catalog = await ctx.input.api.get<{ catalog: ToolCatalogEntry[] }>(`/api/tool-connections/${connected.connectionId}/catalog`);
const writeEntryIds = catalog.catalog.filter(row => !connected.readEntryIds.includes(row.id)).map(row => row.id);
if (!catalog.catalog.length) throw new Error("Setup blocker: real Notion account exposes no catalog actions");
const current = await ctx.input.api.get<{ profiles: ToolProfileWithDetails[] }>(`${root(ctx)}/tools/profiles`);
const policy = current.profiles.filter(row => row.profileKey === `app:${connected.connectionId}`);
const effective = await ctx.input.api.get<ToolProfileEffectiveSummary>(`${root(ctx)}/tools/profiles/effective/agents/${morgan.id}`);
if (connected.readEntryIds.some(id => effective.allowedTools.some(tool => tool.id === id))) throw new Error("Setup blocker: Morgan already has account access before the requested grant");
const agentAssignmentStatus = await probeAgentPermission(ctx, `/api/tool-connections/${connected.connectionId}/installs`, "PUT", { installs: [{ targetType: "agent", targetId: morgan.id }] });
await ctx.input.api.post(`${root(ctx)}/chats/${ctx.input.fixtures.agent.id}`); await ctx.refreshIssue();
await ctx.input.api.patch(`/api/issues/${ctx.issue()!.id}`, { description: `Use the existing Launch Notion shared account (${connected.connectionId}). Intended consumer: Morgan (${morgan.id}), and nobody else. Riley (${riley.id}) and the CEO must not receive access. Keep the existing read-only action policy unchanged; an authorized human manages audience.` });
return { ...connected, provider: "notion", workspaceMatched: true, healthyBefore: true, morganId: morgan.id, rileyId: riley.id, noMorganBefore: true, agentAssignmentStatus, writeEntryIds, appPolicyBefore: policy, connectionCountBefore: (await ctx.input.api.get<{connections: unknown[]}>(`${root(ctx)}/tools/connections`)).connections.length };
}, gradeNotionAudience, {
authentication: "human-session", externalInput: externalRequirements["app-permissions.grant-one-agent"],
sourceFiles: ["chat-catalog-domains/external-notion-audience.ts", "chat-catalog-domains/external-notion-provider.ts", "chat-catalog-domains/external-notion-semantics.ts", "chat-catalog-domains/external-fixtures.ts", "chat-catalog-domains/external-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/identity-support.ts", "chat-scenarios/app-permissions.yaml"],
async act(ctx, state) {
await waitForReply(ctx); const o = await observeExternal(ctx, state); if (!grantMorganGuidance(o.reply)) throw new Error("Chat guidance must preserve the requested Morgan-only Notion audience before human action");
const before = await ctx.input.api.get<{installs: unknown[]}>(`/api/tool-connections/${state.connectionId}/installs`); state.noMutationBeforeHuman = before.installs.length === 0;
const prefix = `/${ctx.input.fixtures.company.issuePrefix}/apps/${state.connectionId}`, href = o.links.find(link => { try { const url = new URL(link, o.origin); return url.origin === o.origin && (url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)); } catch { return false; } });
if (!href) throw new Error("Existing Notion account management path missing"); const source = ctx.input.page.url();
await ctx.input.page.goto(new URL(href, o.origin).href, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByRole("main")).toContainText("Launch Notion"); state.humanManagementVisible = true;
if (!ctx.input.page.url().endsWith("/permissions")) await ctx.input.page.goto(`${prefix}/permissions`, { waitUntil: "domcontentloaded" });
await expect(ctx.input.page.getByRole("radio", {name:/Just agents I pick/})).toBeChecked();
await ctx.input.page.getByRole("button", {name:"Choose agents",exact:true}).click();
const completed = ctx.input.page.waitForResponse(response => new URL(response.url()).pathname === `${root(ctx)}/tools/apps/${state.connectionId}/finish` && response.request().method() === "POST" && response.ok());
await ctx.input.page.getByRole("checkbox", {name:"Allow Morgan",exact:true}).check();
const response = await completed, request = response.request().postDataJSON() as {access: {agentIds?: string[]}};
state.onlyMorganRequested = Array.isArray(request.access.agentIds) && request.access.agentIds.length === 1 && request.access.agentIds[0] === state.morganId;
state.operatorChannel = "public-browser-agent-selection";
await ctx.input.page.reload({ waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByRole("button", {name:"1 agent selected",exact:true})).toBeVisible(); await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
},
async observe(ctx, state) {
state.installsAfter = (await ctx.input.api.get<{installs: unknown[]}>(`/api/tool-connections/${state.connectionId}/installs`)).installs;
state.effectiveAfter = await Promise.all([state.morganId, state.rileyId, ctx.input.fixtures.agent.id].map(id => ctx.input.api.get<ToolProfileEffectiveSummary>(`${root(ctx)}/tools/profiles/effective/agents/${id}`)));
state.connectionCountUnchanged = (await ctx.input.api.get<{connections: unknown[]}>(`${root(ctx)}/tools/connections`)).connections.length === state.connectionCountBefore;
const policy = (await ctx.input.api.get<{ profiles: ToolProfileWithDetails[] }>(`${root(ctx)}/tools/profiles`)).profiles.filter(row => row.profileKey === `app:${state.connectionId}`);
// Installation legitimately changes bindings, not the app's catalog-entry rules.
const rules = (rows: ToolProfileWithDetails[]) => rows.map(row => ({ id: row.id, defaultAction: row.defaultAction, entries: row.entries.map(entry => ({selectorType:entry.selectorType,effect:entry.effect,applicationId:entry.applicationId,connectionId:entry.connectionId,catalogEntryId:entry.catalogEntryId,toolName:entry.toolName,riskLevel:entry.riskLevel,conditions:entry.conditions})).sort((a,b)=>JSON.stringify(a).localeCompare(JSON.stringify(b))) }));
state.appPolicyUnchanged = JSON.stringify(rules(policy)) === JSON.stringify(rules(state.appPolicyBefore as ToolProfileWithDetails[])); return observeExternal(ctx, state);
},
async verifyResult(ctx, _state, value) { await verifyRenderedReply(ctx, value); },
async cleanup(ctx, state) { if (typeof state.connectionId === "string") await ctx.input.api.delete(`/api/tool-connections/${state.connectionId}`, { allowNotFound: true }); },
})];
@@ -8,14 +8,16 @@ interface Block { id: string; type: string; has_children: boolean; [key: string]
const normalized = (id: string) => id.replaceAll("-", "").toLowerCase();
/** Only the selected disposable resource allowlist is accepted. This does not
* enumerate an account, search workspaces, or infer permission from a token. */
export function assertNotionFixture(binding: ProviderBinding) {
const p = binding.prerequisites;
for (const field of ["workspaceId", "recentPageId", "olderPageId"]) {
const id = p[field]; if (!id || !/^[a-f0-9-]{32,36}$/i.test(id) || !binding.resourceIds.some(resource => normalized(resource) === normalized(id))) throw new Error(`Setup blocker: Notion ${field} must be an explicitly allowlisted disposable UUID`);
}
export function assertNotionConnection(binding: ProviderBinding) {
const p = binding.prerequisites, id = p.workspaceId;
if (!id || !/^[a-f0-9-]{32,36}$/i.test(id) || !binding.resourceIds.some(resource => normalized(resource) === normalized(id))) throw new Error("Setup blocker: Notion workspaceId must be an explicitly allowlisted disposable UUID");
const url = new URL(p.mcpUrl!);
if (url.protocol !== "https:" || url.hostname !== "mcp.notion.com" || url.pathname !== "/mcp" || url.username || url.password || url.search || url.hash) throw new Error("Setup blocker: named Notion proof requires the actual official Notion MCP resource");
if (!/^\d{4}-\d{2}-\d{2}$/.test(p.apiVersion ?? "")) throw new Error("Setup blocker: Notion API version reference is missing");
}
export function assertNotionFixture(binding: ProviderBinding) {
assertNotionConnection(binding); const p = binding.prerequisites;
for (const field of ["recentPageId", "olderPageId"]) { const id = p[field]; if (!id || !/^[a-f0-9-]{32,36}$/i.test(id) || !binding.resourceIds.some(resource => normalized(resource) === normalized(id))) throw new Error(`Setup blocker: Notion ${field} must be an explicitly allowlisted disposable UUID`); }
if (!p.recentMarker || !p.olderMarker || p.recentMarker === p.olderMarker) throw new Error("Setup blocker: distinct synthetic recent/background Notion marker references are required");
parseNotionFact(p.recentFactJson); parseNotionFact(p.olderFactJson);
}
@@ -46,14 +48,19 @@ async function blocksText(token: string, version: string, pageId: string, maxChi
}
return children(pageId, 0);
}
export async function verifyNotionWorkspace(ctx: ChatCatalogContext, binding: ProviderBinding) {
assertNotionConnection(binding); const p = binding.prerequisites, token = selectedExternalCredential(ctx, binding, "apiTokenEnv");
const self = await notionJson<{ object: string; type: string; bot?: { workspace_id?: string } }>(token, p.apiVersion!, "/v1/users/me");
if (self.object !== "user" || self.type !== "bot" || !self.bot?.workspace_id || normalized(self.bot.workspace_id) !== normalized(p.workspaceId!)) throw new Error("Setup blocker: actual Notion token workspace differs from its authorized disposable workspace reference");
return { provider: "notion" as const, workspaceMatched: true };
}
export async function readNotionFixturePages(ctx: ChatCatalogContext, binding = selectedExternalBinding(ctx)) {
assertNotionFixture(binding); const p = binding.prerequisites;
const token = selectedExternalCredential(ctx, binding, "apiTokenEnv");
// Provider identity is checked privately; names, email and the raw account
// receipt never enter state/evidence. Provider contract:
// https://developers.notion.com/reference/user#bots
const self = await notionJson<{ object: string; type: string; bot?: { workspace_id?: string } }>(token, p.apiVersion!, "/v1/users/me");
if (self.object !== "user" || self.type !== "bot" || !self.bot?.workspace_id || normalized(self.bot.workspace_id) !== normalized(p.workspaceId!)) throw new Error("Setup blocker: actual Notion token workspace differs from its authorized disposable workspace reference");
await verifyNotionWorkspace(ctx, binding);
const result: NotionPageOracle[] = [];
for (const [idField, markerField] of [["recentPageId", "recentMarker"], ["olderPageId", "olderMarker"]] as const) {
const id = p[idField]!, marker = p[markerField]!;
@@ -70,7 +77,7 @@ export async function readNotionFixturePages(ctx: ChatCatalogContext, binding =
return result;
}
export async function connectNamedNotion(ctx: ChatCatalogContext, binding: ProviderBinding, agentIds: string[]) {
assertNotionFixture(binding);
assertNotionConnection(binding);
const token = selectedExternalCredential(ctx, binding, "mcpTokenEnv");
const result = await ctx.input.api.postSensitive<{ connectionId: string; actions: { readOnly: Array<{ catalogEntryId: string }>; canMakeChanges: Array<{ catalogEntryId: string }> } }>(`${root(ctx)}/tools/apps/connect`, {
link: binding.prerequisites.mcpUrl, name: "Launch Notion", authMode: "bearer", credentialValues: { "credentials.authorization": token }, grantKind: "organization",
@@ -196,8 +196,8 @@ describe("private disposable provider prerequisite contract", () => {
expect(() => selectedExternalCredential(ctx, binding, "PAPERCLIP_CHAT_EVAL_OTHER_AUTH")).toThrow(/not declared/);
expect(() => selectedExternalBinding({ ...ctx, input: { ...ctx.input, privateInputs: undefined } })).toThrow(/private inputs/);
});
it("enumerates the 16 named-provider journeys and four authorized repository journeys", () => {
expect(Object.keys(externalRequirements)).toHaveLength(20);
it("enumerates the 17 named-provider journeys and four authorized repository journeys", () => {
expect(Object.keys(externalRequirements)).toHaveLength(21);
expect(Object.entries(externalRequirements).filter(([, requirement]) => requirement.provider === "github").map(([id]) => id))
.toEqual(["projects.add-repository", "workspaces.prepare-repository", "workspaces.private-repository-no-access", "projects.replace-repository"]);
});