mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
feat: add governed Braintrust app connections (#17141)
Add hosted US and EU Braintrust OAuth and API-key connections through the existing governed MCP system. Keep reviewed tool risks and catalog review policy in connector modules and a narrow registry. Preserve credential ownership, operator decisions, regional setup and changed-tool quarantine. Include official artwork, setup guidance, focused regressions and sanitized qualification evidence. US OAuth has bounded live read/write, gateway, reconnect and cleanup proof. Remaining account-method and token-lifecycle qualification gaps are documented. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
efc2ddc7bf
commit
92cfd701c3
26 files changed
+1513
-49
No files matched your search
@@ -0,0 +1,252 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents, approvals, companies, companyMemberships, companySecrets, connectionGrants,
|
||||
createDb, heartbeatRuns, issues, toolAccessAuditEvents, toolActionRequests,
|
||||
toolApplications, toolCallEvents, toolConnections, toolOauthStates,
|
||||
} from "@paperclipai/db";
|
||||
import { classifyRisk, toolAccessService, type McpToolDescriptor } from "../services/tool-access.js";
|
||||
import { createToolGatewayService } from "../services/tool-gateway.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const actor = { actorType: "user" as const, actorId: "braintrust-reviewer" };
|
||||
const redirectUri = "https://paperclip.fixture.test/api/tools/oauth/callback";
|
||||
const initialTools: McpToolDescriptor[] = [
|
||||
{ name: "sql_query", inputSchema: { type: "object", properties: { query: { type: "string" } } }, annotations: { readOnlyHint: true } },
|
||||
// Deliberately incorrect provider hints must never downgrade reviewed risk.
|
||||
{ name: "run_eval", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } },
|
||||
{ name: "edit_dataset_rows", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } },
|
||||
{ name: "set_automation_status", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } },
|
||||
];
|
||||
|
||||
describe("Braintrust reviewed tool risks", () => {
|
||||
it.each([
|
||||
"sql_query", "infer_schema", "summarize_experiment", "list_recent_objects", "resolve_object",
|
||||
"generate_permalink", "lookup_users", "lookup_api_keys", "get_trace_work_items", "search_patterns",
|
||||
"generate_monitor_chart", "list_monitoring_views", "get_monitoring_view", "list_automations",
|
||||
"list_slack_channels", "get_project_settings", "search_docs", "load_braintrust_skill",
|
||||
])("allows only reviewed retrieval %s to be read risk", name => {
|
||||
expect(classifyRisk({ name }, "braintrust")).toBe("read");
|
||||
expect(classifyRisk({ name, annotations: { readOnlyHint: false } }, "braintrust")).toBe("write");
|
||||
expect(classifyRisk({ name, annotations: { writeHint: true } }, "braintrust")).toBe("write");
|
||||
expect(classifyRisk({ name, annotations: { destructiveHint: true } }, "braintrust")).toBe("destructive");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"update_trace_work_report", "new_pattern", "update_pattern", "create_preprocessor", "test_preprocessor_on_trace",
|
||||
"create_facet", "test_facet_on_trace", "enable_topics_automation", "set_topics_automation", "rewind_topics_automation",
|
||||
"create_monitoring_view", "update_monitoring_view", "create_log_alert", "create_environment_update_alert",
|
||||
"create_threshold_alert", "create_scheduled_loop_job", "create_prompt", "create_evaluator", "create_human_review_score",
|
||||
"test_evaluator", "update_online_scoring_rule", "run_eval", "add_to_dataset", "set_project_default_preprocessor",
|
||||
"new_provider_tool", "sql_query_v2", "SQL_QUERY",
|
||||
])("keeps %s at write risk even when the provider advertises read-only", name => {
|
||||
expect(classifyRisk({ name, annotations: { readOnlyHint: true } }, "braintrust")).toBe("write");
|
||||
expect(classifyRisk({ name, annotations: { destructive: true } }, "braintrust")).toBe("destructive");
|
||||
});
|
||||
|
||||
it.each(["edit_dataset_rows", "set_automation_status"])("keeps %s destructive regardless of hints", name => {
|
||||
expect(classifyRisk({ name, annotations: { readOnlyHint: true, writeHint: true } }, "braintrust")).toBe("destructive");
|
||||
});
|
||||
});
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("Braintrust connection lifecycle and governed gateway", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
beforeAll(async () => {
|
||||
temp = await startEmbeddedPostgresTestDatabase("paperclip-braintrust-");
|
||||
db = createDb(temp.connectionString);
|
||||
}, 30000);
|
||||
afterAll(async () => { await temp?.cleanup(); });
|
||||
|
||||
async function fixture(options: { methodKey?: string; personal?: boolean; deferOAuth?: boolean } = {}) {
|
||||
const methodKey = options.methodKey ?? "mcp-api-key";
|
||||
const oauth = methodKey.endsWith("oauth");
|
||||
const origin = methodKey.includes("-eu-") ? "https://api-eu.braintrust.dev" : "https://api.braintrust.dev";
|
||||
const endpoint = `${origin}/mcp`;
|
||||
const accessToken = `braintrust-fixture-${randomUUID()}`;
|
||||
const refreshToken = `braintrust-refresh-${randomUUID()}`;
|
||||
const [company] = await db.insert(companies).values({ name: "Braintrust fixture", issuePrefix: `BT${randomUUID().slice(0, 6)}` }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: actor.actorId, status: "active", membershipRole: "admin" });
|
||||
let tools = [...initialTools];
|
||||
let authorized = !oauth;
|
||||
const request = vi.fn(async (url: string, init: RequestInit) => {
|
||||
// A region-specific token must never be sent to another region.
|
||||
expect(new URL(url).origin).toBe(origin);
|
||||
const headers = new Headers(init.headers);
|
||||
const body = String(init.body ?? "");
|
||||
if (url === endpoint && init.method === "POST") {
|
||||
if (!authorized || headers.get("authorization") !== `Bearer ${accessToken}`) {
|
||||
return new Response(null, { status: 401, headers: { "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"` } });
|
||||
}
|
||||
const rpc = JSON.parse(body);
|
||||
if (rpc.method === "initialize") return Response.json({ jsonrpc: "2.0", id: rpc.id,
|
||||
result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "Braintrust fixture", version: "1" } } });
|
||||
if (rpc.method === "notifications/initialized") return new Response(null, { status: 202 });
|
||||
if (rpc.method === "tools/list") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { tools } });
|
||||
if (rpc.method === "tools/call") return Response.json({ jsonrpc: "2.0", id: rpc.id,
|
||||
result: { content: [{ type: "text", text: `Executed ${rpc.params.name}` }], structuredContent: { operation: rpc.params.name, reflectedCredential: accessToken } } });
|
||||
}
|
||||
if (url.includes("oauth-protected-resource")) return Response.json({ resource: endpoint, authorization_servers: [origin], scopes_supported: ["mcp"] });
|
||||
if (url === `${origin}/.well-known/oauth-authorization-server`) return Response.json({
|
||||
issuer: origin, authorization_endpoint: `${origin}/oauth/authorize`, token_endpoint: `${origin}/oauth/token`,
|
||||
registration_endpoint: `${origin}/oauth/register`, code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["mcp"],
|
||||
});
|
||||
if (url === `${origin}/oauth/register`) return Response.json({ ...JSON.parse(body), client_id: "braintrust-fixture-client" });
|
||||
if (url === `${origin}/oauth/token`) {
|
||||
authorized = true;
|
||||
return Response.json({ access_token: accessToken, refresh_token: refreshToken, expires_in: 3600, token_type: "Bearer", scope: "mcp" });
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
|
||||
const connectInput = { galleryKey: "braintrust", connectionMethodKey: methodKey,
|
||||
...(options.personal ? { grantKind: "user" as const } : {}),
|
||||
...(!oauth ? { credentialValues: { "credentials.authorization": accessToken } } : {}) };
|
||||
const connect = () => service.connectGalleryApp(company.id, connectInput, actor);
|
||||
const signIn = async (connectionId: string) => {
|
||||
const start = await service.startOAuth(company.id, connectionId, { redirectUri, actor });
|
||||
const authorization = new URL(start.authorizationUrl);
|
||||
expect(start.registrationSource).toBe("dcr");
|
||||
expect(authorization.origin).toBe(origin);
|
||||
expect(authorization.searchParams.get("scope")).toBe("mcp");
|
||||
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(authorization.searchParams.get("resource")).toBe(endpoint);
|
||||
return service.completeOAuthCallback({ state: authorization.searchParams.get("state")!, code: "fixture-code", iss: origin, redirectUri, actor });
|
||||
};
|
||||
let connected = await connect();
|
||||
if (oauth && options.deferOAuth) return { company, service, request, connect, accessToken, endpoint, origin, signIn, connected,
|
||||
agent: undefined, gateway: undefined, session: undefined, call: undefined, run: undefined, issue: undefined, setTools: undefined };
|
||||
if (oauth && !options.deferOAuth) connected = await signIn(connected.connectionId);
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Braintrust analyst", role: "engineer", adapterType: "process" }).returning();
|
||||
await service.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: connected.catalog.map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] } }, actor);
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Braintrust fixture work", assigneeAgentId: agent.id }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, issueId: issue.id,
|
||||
scopeKind: "issue", invocationSource: "on_demand", status: "running", responsibleUserId: actor.actorId, contextSnapshot: { issueId: issue.id } }).returning();
|
||||
const gateway = createToolGatewayService(db, { remoteHttpRequest: request, toolActionSigningSecret: "braintrust-fixture-signing-key" });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id, issueId: issue.id });
|
||||
const listed = await gateway.listToolsForSession(session.token);
|
||||
const call = (name: string, parameters = {}) => {
|
||||
const tool = listed.find(entry => entry.upstreamToolName === name);
|
||||
expect(tool, name).toBeTruthy();
|
||||
return gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters, idempotencyKey: randomUUID() });
|
||||
};
|
||||
return { company, service, request, connect, accessToken, endpoint, origin, signIn,
|
||||
connected, agent, gateway, session, call, run, issue, setTools: (next: McpToolDescriptor[]) => { tools = next; } };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["mcp-api-key", false], ["mcp-eu-api-key", true], ["mcp-oauth", true], ["mcp-eu-oauth", false],
|
||||
])("connects %s with the selected credential ownership and run-scoped reads and writes", async (methodKey, personal) => {
|
||||
const f = await fixture({ methodKey: String(methodKey), personal: Boolean(personal) });
|
||||
const connected = f.connected!;
|
||||
expect(connected.connection.config).toMatchObject({ url: f.endpoint, sourceTemplateKey: "braintrust", connectionMethodKey: methodKey, quarantineNewEntries: true });
|
||||
expect(connected.catalog.every(entry => entry.status === "active")).toBe(true);
|
||||
expect(connected.suggestedDefaults.askFirstRiskLevels).toEqual([]);
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId));
|
||||
expect(grant.kind).toBe(personal ? "user" : "organization");
|
||||
for (const ref of grant.credentialSecretRefs) {
|
||||
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId));
|
||||
expect(secret.scope).toBe(personal ? "user" : "company");
|
||||
expect(secret.ownerUserId).toBe(personal ? actor.actorId : null);
|
||||
}
|
||||
expect(JSON.stringify(await f.call!("sql_query", { query: "SELECT id FROM project_logs LIMIT 1" }))).toContain("Executed sql_query");
|
||||
const write = JSON.stringify(await f.call!("run_eval"));
|
||||
expect(write).toContain("Executed run_eval");
|
||||
expect(write).not.toContain(f.accessToken);
|
||||
const persisted = { connection: await f.service.getConnection(connected.connectionId),
|
||||
audit: await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id)),
|
||||
calls: await db.select().from(toolCallEvents).where(eq(toolCallEvents.companyId, f.company.id)) };
|
||||
expect(JSON.stringify(persisted)).not.toContain(f.accessToken);
|
||||
});
|
||||
|
||||
it("keeps EU OAuth cancellation resumable with the same region and method", async () => {
|
||||
const f = await fixture({ methodKey: "mcp-eu-oauth", deferOAuth: true });
|
||||
const connected = f.connected!;
|
||||
const start = await f.service.startOAuth(f.company.id, connected.connectionId, { redirectUri, actor });
|
||||
await expect(f.service.completeOAuthCallback({ state: new URL(start.authorizationUrl).searchParams.get("state")!, error: "access_denied", redirectUri, actor })).rejects.toMatchObject({ status: 400 });
|
||||
expect(await db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, connected.connectionId))).toHaveLength(0);
|
||||
const resumed = await f.service.connectGalleryApp(f.company.id, { galleryKey: "braintrust", connectionMethodKey: "mcp-eu-oauth", resumeConnectionId: connected.connectionId }, actor);
|
||||
expect(resumed.connectionId).toBe(connected.connectionId);
|
||||
expect(resumed.connection.config).toMatchObject({ url: f.endpoint, connectionMethodKey: "mcp-eu-oauth" });
|
||||
await f.signIn(connected.connectionId);
|
||||
expect((await f.service.listCatalog(connected.connectionId)).every(entry => entry.status === "active")).toBe(true);
|
||||
});
|
||||
|
||||
it("cleans up vaulted credentials and the new identity after failed discovery", async () => {
|
||||
const [company] = await db.insert(companies).values({ name: "Braintrust failed setup", issuePrefix: `BF${randomUUID().slice(0, 6)}` }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: actor.actorId, status: "active", membershipRole: "admin" });
|
||||
const service = toolAccessService(db, {
|
||||
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }],
|
||||
remoteHttpRequest: async () => new Response("provider-private-detail", { status: 503 }),
|
||||
});
|
||||
await expect(service.connectGalleryApp(company.id, { galleryKey: "braintrust", connectionMethodKey: "mcp-api-key", grantKind: "user",
|
||||
credentialValues: { "credentials.authorization": "braintrust-failed-setup-key" } }, actor)).rejects.toMatchObject({ status: 502 });
|
||||
for (const table of [toolConnections, toolApplications, connectionGrants, companySecrets]) {
|
||||
expect(await db.select().from(table).where(eq(table.companyId, company.id))).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["mcp-api-key", "mcp-eu-oauth"])("preserves policy and quarantines added or changed tools on %s refresh and reconnect", async methodKey => {
|
||||
const f = await fixture({ methodKey });
|
||||
const connected = f.connected!;
|
||||
const offName = "set_automation_status", askName = "edit_dataset_rows";
|
||||
await f.service.finishGalleryAppConnection(f.company.id, connected.connectionId, {
|
||||
enabledCatalogEntryIds: connected.catalog.filter(entry => entry.toolName !== offName).map(entry => entry.id),
|
||||
askFirstCatalogEntryIds: connected.catalog.filter(entry => entry.toolName === askName).map(entry => entry.id), access: { agentIds: [f.agent!.id] },
|
||||
}, actor);
|
||||
f.request.mockClear();
|
||||
await expect(f.call!(offName)).rejects.toThrow();
|
||||
await expect(f.call!(askName)).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
const [pending] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id));
|
||||
if (pending.approvalId) await db.update(approvals).set({ status: "approved", decidedByUserId: actor.actorId, decidedAt: new Date() }).where(eq(approvals.id, pending.approvalId));
|
||||
await f.gateway!.approveActionRequest({ companyId: f.company.id, actionRequestId: pending.id, actor: { userId: actor.actorId } });
|
||||
expect(f.request.mock.calls.filter(([, init]) => String(init.body).includes('"method":"tools/call"'))).toHaveLength(1);
|
||||
f.setTools!([...initialTools.map(tool => tool.name === "sql_query" ? {
|
||||
...tool, inputSchema: { type: "object", properties: { query: { type: "string" }, project_id: { type: "string" } } },
|
||||
} : tool), { name: "provider_added_tool", annotations: { readOnlyHint: true } }]);
|
||||
const refreshed = await f.service.refreshCatalog(connected.connectionId, actor, { enableAllByDefault: true });
|
||||
expect(refreshed.catalog.find(entry => entry.toolName === "sql_query")?.status).toBe("quarantined");
|
||||
expect(refreshed.catalog.find(entry => entry.toolName === "provider_added_tool")).toMatchObject({ riskLevel: "write", status: "quarantined" });
|
||||
if (methodKey.endsWith("oauth")) await f.signIn(connected.connectionId);
|
||||
else await f.service.reconnectGalleryApp(connected.connectionId, f.company.id, { credentialValues: { "credentials.authorization": f.accessToken } }, actor);
|
||||
const afterReconnect = await f.service.refreshCatalog(connected.connectionId, actor, { enableAllByDefault: true });
|
||||
expect(afterReconnect.connection.config.quarantineNewEntries).toBe(true);
|
||||
expect(afterReconnect.connection.transportConfig.quarantineNewEntries).toBe(true);
|
||||
expect(afterReconnect.catalog.filter(entry => ["sql_query", "provider_added_tool"].includes(entry.toolName)).every(entry => entry.status === "quarantined")).toBe(true);
|
||||
f.request.mockClear();
|
||||
await expect(f.call!("sql_query")).rejects.toThrow();
|
||||
await expect(f.call!(offName)).rejects.toThrow();
|
||||
await expect(f.call!(askName, { dataset_id: "new-dataset-after-reconnect" })).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes the EU OAuth grant at its issuer and enforces ownership, company and revocation boundaries", async () => {
|
||||
const f = await fixture({ methodKey: "mcp-eu-oauth", personal: true });
|
||||
const connected = f.connected!;
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId));
|
||||
f.request.mockClear();
|
||||
await f.service.refreshOAuthGrantCredentials({ companyId: f.company.id, connectionId: connected.connectionId, grantId: grant.id, forceRefresh: true, actor });
|
||||
expect(f.request.mock.calls.some(([url, init]) => url === `${f.origin}/oauth/token` && new URLSearchParams(String(init.body)).get("grant_type") === "refresh_token")).toBe(true);
|
||||
expect(JSON.stringify(await f.call!("sql_query"))).not.toContain(f.accessToken);
|
||||
await expect(f.service.getConnection(connected.connectionId, randomUUID())).rejects.toMatchObject({ status: 404 });
|
||||
await expect(f.gateway!.createSession({ companyId: randomUUID(), agentId: f.agent!.id, runId: f.run!.id })).rejects.toThrow();
|
||||
await expect(f.service.reconnectGalleryApp(connected.connectionId, f.company.id, { credentialValues: { "credentials.authorization": "other-user-value" } }, { actorType: "user", actorId: "another-user" })).rejects.toThrow();
|
||||
// Existing gateway sessions must reject a secret that no longer belongs to
|
||||
// the selected personal identity; they cannot fall back to company secrets.
|
||||
const accessRef = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token")!;
|
||||
await db.update(companySecrets).set({ ownerUserId: "another-user" }).where(eq(companySecrets.id, accessRef.secretId));
|
||||
f.request.mockClear();
|
||||
await expect(f.call!("sql_query")).rejects.toMatchObject({ reasonCode: "grant_credential_invalid" });
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
await db.update(companySecrets).set({ ownerUserId: actor.actorId }).where(eq(companySecrets.id, accessRef.secretId));
|
||||
await f.service.revokeConnectionGrant(connected.connectionId, grant.id, actor);
|
||||
await expect(f.call!("sql_query")).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
await f.service.archiveConnection(connected.connectionId, f.company.id, actor);
|
||||
expect(await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, f.company.id), eq(companySecrets.ownerUserId, actor.actorId)))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { companies, createDb } from "@paperclipai/db";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { connectorToolPolicy } from "../services/connectors/tool-policy.js";
|
||||
import { DAYTONA_API_URL, DAYTONA_METHOD } from "../services/daytona.js";
|
||||
import { classifyRisk, toolAccessService, type McpToolDescriptor } from "../services/tool-access.js";
|
||||
import { RAILWAY_MCP_URL } from "../services/railway.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
describe("reviewed connector risk dispatch", () => {
|
||||
it("keeps destructive provider hints above every reviewed classifier", () => {
|
||||
for (const [source, name] of [
|
||||
["railway", "list-projects"], ["daytona", "daytona_api_list_sandboxes"],
|
||||
["enterpret", "run_graph_query"], ["braintrust", "sql_query"],
|
||||
]) {
|
||||
for (const annotations of [{ destructiveHint: true }, { destructive: true }]) {
|
||||
expect(classifyRisk({ name, annotations }, source)).toBe("destructive");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("retains each provider's reviewed annotation precedence", () => {
|
||||
expect(classifyRisk({ name: "listProjects" }, "railway")).toBe("read");
|
||||
expect(classifyRisk({ name: "listProjects", annotations: { writeHint: true } }, "railway")).toBe("write");
|
||||
expect(classifyRisk({ name: "redeploy", annotations: { readOnlyHint: true } }, "railway")).toBe("destructive");
|
||||
// The native catalog owns this risk; a write hint does not replace it.
|
||||
expect(classifyRisk({ name: "daytona_api_list_sandboxes", annotations: { writeHint: true } }, "daytona")).toBe("read");
|
||||
expect(classifyRisk({ name: "unknown", annotations: { readOnlyHint: true } }, "daytona")).toBe("destructive");
|
||||
expect(classifyRisk({ name: "runGraphQuery", annotations: { readOnlyHint: true } }, "enterpret")).toBe("write");
|
||||
expect(classifyRisk({ name: "get_organization_details", annotations: { readOnlyHint: true } }, "enterpret")).toBe("read");
|
||||
expect(classifyRisk({ name: "edit_dataset_rows", annotations: { readOnlyHint: false } }, "braintrust")).toBe("destructive");
|
||||
expect(classifyRisk({ name: "sql_query", annotations: { writeHint: true } }, "braintrust")).toBe("write");
|
||||
expect(classifyRisk({ name: "new_tool", annotations: { readOnlyHint: true } }, "braintrust")).toBe("write");
|
||||
});
|
||||
|
||||
it("retains generic risk behavior when the connector has no reviewed override", () => {
|
||||
expect(classifyRisk({ name: "get_object" }, "custom")).toBe("read");
|
||||
expect(classifyRisk({ name: "new_tool", annotations: { readOnlyHint: true } }, "custom")).toBe("read");
|
||||
expect(connectorToolPolicy("custom").classifyRisk).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewed connector catalog policies", () => {
|
||||
it.each(["railway", "enterpret", "daytona", "braintrust"])("declares the initial drift policy for %s", source => {
|
||||
expect(connectorToolPolicy(source).quarantineNewEntries).toBe(true);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "custom", "notion"])("keeps existing initial defaults for %s", source => {
|
||||
expect(connectorToolPolicy(source).quarantineNewEntries).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["https://mcp.railway.com", "https://mcp.railway.com/"])("recognizes the exact Railway endpoint for a custom connection: %s", url => {
|
||||
const policy = connectorToolPolicy("custom", { transport: "mcp_remote", config: { url } });
|
||||
expect(policy.sourceTemplateKey).toBe("railway");
|
||||
expect(policy.quarantineNewEntries).toBe(true);
|
||||
expect(classifyRisk({ name: "redeploy", annotations: { readOnlyHint: true } }, policy.sourceTemplateKey)).toBe("destructive");
|
||||
});
|
||||
|
||||
it.each(["https://mcp.railway.com.evil.test", "https://mcp.railway.com/custom", "https://other.test"])("does not extend Railway's catalog policy to %s", url => {
|
||||
expect(connectorToolPolicy("custom", { transport: "mcp_remote", config: { url } }).quarantineNewEntries).toBe(false);
|
||||
expect(connectorToolPolicy("railway", { transport: "mcp_remote", config: { url } }).quarantineNewEntries).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves native Daytona review only for the reviewed REST method and endpoint", () => {
|
||||
const config = { sourceTemplateKey: "daytona", connectionMethodKey: DAYTONA_METHOD, url: DAYTONA_API_URL };
|
||||
expect(connectorToolPolicy("daytona", { transport: "rest_api", config }).quarantineNewEntries).toBe(true);
|
||||
for (const connection of [
|
||||
{ transport: "mcp_remote", config },
|
||||
{ transport: "rest_api", config: { ...config, connectionMethodKey: "mcp-oauth" } },
|
||||
{ transport: "rest_api", config: { ...config, url: "https://other.test" } },
|
||||
]) expect(connectorToolPolicy("daytona", connection).quarantineNewEntries).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["enterpret", "braintrust"])("preserves %s review when reconnect resets the saved flag", sourceTemplateKey => {
|
||||
const policy = connectorToolPolicy(sourceTemplateKey, {
|
||||
transport: "mcp_remote", config: { sourceTemplateKey, quarantineNewEntries: false },
|
||||
});
|
||||
expect(policy.quarantineNewEntries).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("custom connection catalog policy", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
beforeAll(async () => {
|
||||
temp = await startEmbeddedPostgresTestDatabase("paperclip-connector-policy-");
|
||||
db = createDb(temp.connectionString);
|
||||
}, 30000);
|
||||
afterAll(async () => { await temp?.cleanup(); });
|
||||
|
||||
it("applies reviewed endpoint risks and keeps changed tools quarantined without enabling a native bridge", async () => {
|
||||
const [company] = await db.insert(companies).values({ name: "Custom policy fixture", issuePrefix: `CP${randomUUID().slice(0, 6)}` }).returning();
|
||||
let tools: McpToolDescriptor[] = [{ name: "redeploy", annotations: { readOnlyHint: true } }];
|
||||
const request = vi.fn(async (url: string, init: RequestInit) => {
|
||||
expect(url.replace(/\/$/, "")).toBe(RAILWAY_MCP_URL);
|
||||
const rpc = JSON.parse(String(init.body));
|
||||
if (rpc.method === "initialize") return Response.json({ jsonrpc: "2.0", id: rpc.id,
|
||||
result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "Policy fixture", version: "1" } } });
|
||||
if (rpc.method === "notifications/initialized") return new Response(null, { status: 202 });
|
||||
return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { tools } });
|
||||
});
|
||||
const service = toolAccessService(db, { remoteHttpRequest: request,
|
||||
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
|
||||
const actor = { actorType: "user" as const, actorId: "policy-reviewer" };
|
||||
const connection = await service.createConnection(company.id, {
|
||||
name: "Custom reviewed endpoint", transport: "mcp_remote", enabled: true, status: "active",
|
||||
config: { sourceTemplateKey: "custom", url: RAILWAY_MCP_URL, quarantineNewEntries: true },
|
||||
}, actor);
|
||||
const initial = await service.refreshCatalog(connection.id, actor, { enableAllByDefault: true });
|
||||
expect(initial.catalog).toEqual([expect.objectContaining({ toolName: "redeploy", riskLevel: "destructive", status: "active" })]);
|
||||
tools = [...tools, { name: "provider_added_tool", annotations: { readOnlyHint: true } }];
|
||||
const refreshed = await service.refreshCatalog(connection.id, actor, { enableAllByDefault: true });
|
||||
expect(refreshed.catalog.find(tool => tool.toolName === "provider_added_tool")).toMatchObject({ riskLevel: "write", status: "quarantined" });
|
||||
expect(refreshed.connection.config.quarantineNewEntries).toBe(true);
|
||||
expect(refreshed.connection.config.sourceTemplateKey).toBe("custom");
|
||||
expect(request).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ToolRiskLevel } from "@paperclipai/shared";
|
||||
|
||||
// Reviewed against Braintrust's hosted MCP tool reference on 2026-10-21:
|
||||
// https://www.braintrust.dev/docs/integrations/developer-tools/mcp#tools
|
||||
// Exact names make additions and renames require review. In particular, test
|
||||
// and evaluation tools can execute code or consume model credits without
|
||||
// changing a source trace, so readOnlyHint cannot make them reads.
|
||||
const READ_TOOLS = new Set([
|
||||
"sql_query",
|
||||
"infer_schema",
|
||||
"summarize_experiment",
|
||||
"list_recent_objects",
|
||||
"resolve_object",
|
||||
"generate_permalink",
|
||||
"lookup_users",
|
||||
"lookup_api_keys",
|
||||
"get_trace_work_items",
|
||||
"search_patterns",
|
||||
"generate_monitor_chart",
|
||||
"list_monitoring_views",
|
||||
"get_monitoring_view",
|
||||
"list_automations",
|
||||
"list_slack_channels",
|
||||
"get_project_settings",
|
||||
"search_docs",
|
||||
"load_braintrust_skill",
|
||||
]);
|
||||
|
||||
const DESTRUCTIVE_TOOLS = new Set([
|
||||
// One action supports insertion, replacement, and permanent deletion.
|
||||
"edit_dataset_rows",
|
||||
// Activating an existing automation can apply retention or deliver data.
|
||||
"set_automation_status",
|
||||
]);
|
||||
|
||||
export function braintrustRisk(tool: {
|
||||
name: string;
|
||||
annotations?: Record<string, unknown>;
|
||||
}): ToolRiskLevel {
|
||||
const annotations = tool.annotations ?? {};
|
||||
if (annotations.destructiveHint === true || annotations.destructive === true
|
||||
|| DESTRUCTIVE_TOOLS.has(tool.name)) return "destructive";
|
||||
if (annotations.readOnlyHint === false || annotations.writeHint === true) return "write";
|
||||
return READ_TOOLS.has(tool.name) ? "read" : "write";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ToolRiskLevel } from "@paperclipai/shared";
|
||||
|
||||
export function enterpretRisk(tool: { name: string }): ToolRiskLevel | null {
|
||||
const name = tool.name.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.toLowerCase().replace(/[:._-]+/g, "-");
|
||||
// This tool advertises readOnlyHint, but Cypher can mutate the graph and
|
||||
// live qualification has not established a read-only execution boundary.
|
||||
return name === "run-graph-query" ? "write" : null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ToolRiskLevel } from "@paperclipai/shared";
|
||||
import { daytonaRisk, isDaytonaConnection } from "../daytona.js";
|
||||
import { classifyRailwayToolRisk, isRailwayEndpoint } from "../railway.js";
|
||||
import { braintrustRisk } from "./braintrust/risk.js";
|
||||
import { enterpretRisk } from "./enterpret/risk.js";
|
||||
|
||||
interface ToolDescriptor {
|
||||
name: string;
|
||||
annotations?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ConnectionIdentity {
|
||||
transport: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ReviewedToolPolicy {
|
||||
sourceTemplateKey: string;
|
||||
classifyRisk?: (tool: ToolDescriptor) => ToolRiskLevel | null;
|
||||
quarantineNewEntries: boolean;
|
||||
matchesConnection?: (connection: ConnectionIdentity) => boolean;
|
||||
matchesEndpoint?: (connection: ConnectionIdentity) => boolean;
|
||||
}
|
||||
|
||||
// Provider selection belongs here; authorization, catalog transitions, and
|
||||
// operator-selected action policies remain owned by the shared tool service.
|
||||
const reviewedPolicies: ReviewedToolPolicy[] = [
|
||||
{
|
||||
sourceTemplateKey: "railway",
|
||||
quarantineNewEntries: true,
|
||||
classifyRisk: classifyRailwayToolRisk,
|
||||
matchesConnection: connection => isRailwayEndpoint(connection.config.url),
|
||||
// A custom connection to the exact reviewed endpoint gets the same risk
|
||||
// review and drift handling, without enabling the native API bridge.
|
||||
matchesEndpoint: connection => isRailwayEndpoint(connection.config.url),
|
||||
},
|
||||
{
|
||||
sourceTemplateKey: "daytona",
|
||||
quarantineNewEntries: true,
|
||||
classifyRisk: tool => daytonaRisk(tool.name),
|
||||
matchesConnection: isDaytonaConnection,
|
||||
},
|
||||
{ sourceTemplateKey: "enterpret", quarantineNewEntries: true, classifyRisk: enterpretRisk },
|
||||
{ sourceTemplateKey: "braintrust", quarantineNewEntries: true, classifyRisk: braintrustRisk },
|
||||
];
|
||||
|
||||
const bySource = new Map(reviewedPolicies.map(policy => [policy.sourceTemplateKey, policy]));
|
||||
|
||||
export function connectorToolPolicy(sourceTemplateKey?: string | null, connection?: ConnectionIdentity): {
|
||||
sourceTemplateKey: string | null;
|
||||
classifyRisk?: (tool: ToolDescriptor) => ToolRiskLevel | null;
|
||||
quarantineNewEntries: boolean;
|
||||
} {
|
||||
const policy = (connection && reviewedPolicies.find(candidate => candidate.matchesEndpoint?.(connection)))
|
||||
|| bySource.get(sourceTemplateKey ?? "");
|
||||
return {
|
||||
sourceTemplateKey: policy?.sourceTemplateKey ?? sourceTemplateKey ?? null,
|
||||
classifyRisk: policy?.classifyRisk,
|
||||
// Initial setup declares this default before discovery. Subsequent
|
||||
// refresh/reconnect preserves it only for the reviewed connection identity.
|
||||
quarantineNewEntries: Boolean(policy?.quarantineNewEntries
|
||||
&& (!connection || !policy.matchesConnection || policy.matchesConnection(connection))),
|
||||
};
|
||||
}
|
||||
@@ -106,6 +106,13 @@ export function railwayRisk(name: string): "read" | "write" | "destructive" {
|
||||
return "write";
|
||||
}
|
||||
|
||||
export function classifyRailwayToolRisk(tool: { name: string; annotations?: Record<string, unknown> }): "read" | "write" | "destructive" {
|
||||
const risk = railwayRisk(tool.name);
|
||||
const annotations = tool.annotations ?? {};
|
||||
return risk === "read" && (annotations.readOnlyHint === false || annotations.writeHint === true)
|
||||
? "write" : risk;
|
||||
}
|
||||
|
||||
export class RailwayError extends Error {
|
||||
constructor(readonly code: string, message: string, readonly status = 502) { super(message); this.name = "RailwayError"; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getAppDefinitionForApplicationKey, isConnectorFeatureEnabled } from "@p
|
||||
import { stableHash, descriptorHash } from "./tool-catalog-hash.js";
|
||||
import { assertProfileEntryInputs } from "./tool-profile-entry-validation.js";
|
||||
import { DAYTONA_TOOLS, daytonaClient, daytonaRisk, isDaytonaConnection, isDaytonaToolName } from "./daytona.js";
|
||||
import { connectorToolPolicy } from "./connectors/tool-policy.js";
|
||||
import { chatCredentialMutationLease } from "./chat-credential-mutation-lease.js";
|
||||
import { notifyChatEndpointWork } from "./chat-work-notifications.js";
|
||||
import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js";
|
||||
@@ -249,7 +250,7 @@ import { connectionCredentialConfigPath as credentialRefConfigPath, connectionGr
|
||||
import { agentmailApi } from "./agentmail-api.js";
|
||||
import type { ConfigureRailwaySsh, RailwaySshSetup } from "@paperclipai/shared";
|
||||
import { generateRailwaySshKey, RAILWAY_SSH_SECRET_PATH, validateRailwayKnownHosts } from "./railway-ssh.js";
|
||||
import { createRailwayClient, discoverRailwayWorkspace, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_TOOLS, RAILWAY_TOOL_PREFIX, railwayRisk, RailwayError } from "./railway.js";
|
||||
import { createRailwayClient, discoverRailwayWorkspace, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_TOOLS, RAILWAY_TOOL_PREFIX, RailwayError } from "./railway.js";
|
||||
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
||||
import {
|
||||
readSignedToolArgumentsPayload,
|
||||
@@ -2401,6 +2402,8 @@ export function classifyRisk(
|
||||
const annotations = tool.annotations ?? {};
|
||||
if (annotations.destructiveHint === true || annotations.destructive === true)
|
||||
return "destructive";
|
||||
const reviewedRisk = connectorToolPolicy(sourceTemplateKey).classifyRisk?.(tool);
|
||||
if (reviewedRisk) return reviewedRisk;
|
||||
const normalizedToolName = normalizedProviderToolName(tool.name);
|
||||
if (isMemoryConnectorId(sourceTemplateKey)) {
|
||||
if (verbMatches(tool.name, "delete|remove|destroy|forget|prune|reset")) return "destructive";
|
||||
@@ -2420,21 +2423,11 @@ export function classifyRisk(
|
||||
if (sourceTemplateKey === "browser-use-cloud") {
|
||||
return BROWSER_USE_TOOLS.find(t => t.name === tool.name)?.annotations.readOnlyHint ? "read" : "destructive";
|
||||
}
|
||||
if (sourceTemplateKey === "daytona") return daytonaRisk(tool.name);
|
||||
if (sourceTemplateKey === "railway") {
|
||||
const reviewed = railwayRisk(normalizedToolName);
|
||||
return reviewed === "read" && (annotations.readOnlyHint === false || annotations.writeHint === true) ? "write" : reviewed;
|
||||
}
|
||||
// Fireflies sharing, moving, and access revocation are mutations even when
|
||||
// a provider omits annotations or mistakenly advertises a read hint.
|
||||
if (sourceTemplateKey === "fireflies" && [
|
||||
"fireflies-share-meeting", "fireflies-revoke-meeting-access", "fireflies-move-meeting",
|
||||
].includes(normalizedToolName)) return "write";
|
||||
// Enterpret's run_graph_query self-reports readOnlyHint: true, but Cypher is
|
||||
// not a read-only language and live validation never established the tool as
|
||||
// safe. Treat it as write so Ask-first defaults can restrict it.
|
||||
if (sourceTemplateKey === "enterpret" && normalizedToolName === "run-graph-query")
|
||||
return "write";
|
||||
// Gmail archive removes a system label. Keep it behind the archive method's
|
||||
// destructive Ask-first default even when the preview provider omits hints.
|
||||
if (sourceTemplateKey === "gmail" && normalizedToolName === "unlabel-thread")
|
||||
@@ -7964,9 +7957,8 @@ export function toolAccessService(
|
||||
isGoogleWorkspaceConnectorProfileId(googleProfileValue)
|
||||
? googleProfileValue
|
||||
: null;
|
||||
const preserveReviewedCatalog =
|
||||
(isRailwayEndpoint(connection.config.url) || sourceTemplateKey === "enterpret" || isDaytonaConnection(connection)) &&
|
||||
existingRows.length > 0;
|
||||
const connectorPolicy = connectorToolPolicy(sourceTemplateKey, connection);
|
||||
const preserveReviewedCatalog = connectorPolicy.quarantineNewEntries && existingRows.length > 0;
|
||||
const quarantineOnRefresh =
|
||||
(!refreshOptions.enableAllByDefault || preserveReviewedCatalog) &&
|
||||
shouldQuarantineNewEntries(connection) &&
|
||||
@@ -7976,7 +7968,7 @@ export function toolAccessService(
|
||||
refreshOptions.quarantineManagedOAuthDraft === true);
|
||||
const safeDefault = asRecord(connection.config).safeDefault === true;
|
||||
for (const descriptor of descriptors) {
|
||||
const riskLevel = classifyRisk(descriptor, isRailwayEndpoint(connection.config.url) ? "railway" : sourceTemplateKey);
|
||||
const riskLevel = classifyRisk(descriptor, connectorPolicy.sourceTemplateKey);
|
||||
const hash = descriptorHash(descriptor, riskLevel);
|
||||
const schemaHash = stableHash(descriptor.inputSchema ?? {});
|
||||
const existing = existingByName.get(descriptor.name);
|
||||
@@ -8043,8 +8035,7 @@ export function toolAccessService(
|
||||
const catalogByName = new Map(catalogRows.map(row => [row.toolName, row]));
|
||||
const updatedEntries = descriptors.map(descriptor => toCatalogEntry(catalogByName.get(descriptor.name)!));
|
||||
|
||||
const preserveQuarantine =
|
||||
isRailwayEndpoint(connection.config.url) || sourceTemplateKey === "enterpret" || isDaytonaConnection(connection);
|
||||
const preserveQuarantine = connectorPolicy.quarantineNewEntries;
|
||||
const normalizedConfig = preserveQuarantine
|
||||
? { ...connection.config, quarantineNewEntries: true }
|
||||
: refreshOptions.enableAllByDefault
|
||||
@@ -12979,7 +12970,7 @@ export function toolAccessService(
|
||||
// Grant-backed setup keeps the full discovered catalog selectable;
|
||||
// the wizard projects the app's action defaults into policies at
|
||||
// finish time instead of using catalog quarantine as access state.
|
||||
quarantineNewEntries: ["railway", "enterpret", "daytona"].includes(galleryEntry.slug),
|
||||
quarantineNewEntries: connectorToolPolicy(galleryEntry.slug).quarantineNewEntries,
|
||||
...(remoteMcpConnector ? {
|
||||
mcpSessionRequired: true,
|
||||
mcpAuthMode: input.authMode ?? "auto",
|
||||
|
||||
Reference in new issue
Block a user