mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Merge verified chat credential boundaries and rate admission assertions
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/agent-connection-api-parity: test: assert persisted rate admission bucket identity fix: hand off delegated chat verification key changes
This commit is contained in:
commit
3f61303dac
10 files changed
+69
-11
No files matched your search
@@ -18,6 +18,7 @@ import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from
|
||||
import { configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
|
||||
@@ -652,9 +653,33 @@ describe("connection management with production default agents", () => {
|
||||
};
|
||||
try {
|
||||
const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "slack", assignedAgentId: f.agentId, slackSetupMethod: "manual" });
|
||||
const configured = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { botToken: "xoxb-fixture-token", signingSecret: "fixture-signing-secret" } });
|
||||
const setupPath = `/chat-endpoints/${created.body.id}/setup`;
|
||||
const cookie = await server.humanSession(f.userId);
|
||||
const configuredResponse = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "configure", credentials: { botToken: "xoxb-fixture-token", signingSecret: "fixture-signing-secret" } }) });
|
||||
const configured = { status: configuredResponse.status, body: await configuredResponse.json() };
|
||||
expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 });
|
||||
expect(JSON.stringify(configured.body)).not.toContain("xoxb-fixture-token");
|
||||
const [before] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
||||
const botTokenRef = before.credentialSecretRefs.find(ref => ref.configPath === "credentials.botToken")!;
|
||||
const rebound = before.credentialSecretRefs.map(ref => ref.configPath === "credentials.signingSecret" ? { ...ref, secretId: botTokenRef.secretId } : ref);
|
||||
expect(await f.request("PATCH", `/tool-connections/${created.body.connectionId}`, { credentialSecretRefs: rebound }, f.jwt))
|
||||
.toMatchObject({ status: 403, body: { code: "chat_endpoint_credentials_require_setup" } });
|
||||
for (const action of ["reconnect", "configure"] as const) for (const token of [f.key.token, f.jwt]) {
|
||||
const chosen = "agent-chosen-webhook-key";
|
||||
const handoff = await f.request("POST", setupPath, { action, credentials: { signingSecret: chosen } }, token);
|
||||
expect(handoff, JSON.stringify(handoff)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id,
|
||||
requiredUserAction: { kind: "enter_credentials", userId: f.userId, resource: { id: created.body.id } } } });
|
||||
expect(JSON.stringify(handoff.body)).not.toContain(chosen);
|
||||
const [after] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
||||
expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs);
|
||||
const signing = after.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!;
|
||||
expect(await secretService(server.db).resolveSecretValue(f.companyId, signing.secretId, "latest", {
|
||||
accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" },
|
||||
})).toBe("fixture-signing-secret");
|
||||
}
|
||||
// Ordinary write-only bot-token repair remains delegated under current authority.
|
||||
expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 200, body: { id: created.body.id } });
|
||||
// Identity is a provider fixture; production HTTP admission and policy remain real.
|
||||
const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", providerAccountId: "TFIXTURE", externalId: "UHUMAN", isBot: false }).returning();
|
||||
await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "linked", confirmedAt: new Date() });
|
||||
|
||||
@@ -754,6 +754,9 @@ describe("openapi routes", () => {
|
||||
).toEqual(["current", "stale", "unverified"]);
|
||||
|
||||
const setup = spec.paths["/api/chat-endpoints/{endpointId}/setup"].post;
|
||||
const setupResponse = setup.responses["200"].content["application/json"].schema;
|
||||
expect((setupResponse.oneOf ?? setupResponse.anyOf)[1].properties.requiredUserAction.properties.kind.enum).toEqual(["enter_credentials"]);
|
||||
expect(setup.description).toContain("without changing credentials");
|
||||
expect(
|
||||
setup.requestBody.content["application/json"].schema.properties.action
|
||||
.enum,
|
||||
|
||||
@@ -1105,7 +1105,8 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
.where(eq(toolInvocations.id, invocation.id));
|
||||
expect(await svc.decide(input)).toMatchObject({ decision: "rate_limited" });
|
||||
expect(invocation.policyExplanation?.consumedRateAdmissions).toEqual([
|
||||
{ policyId: ratePolicy.id, versionHash: expect.stringMatching(/^[a-f0-9]{64}$/) },
|
||||
{ policyId: ratePolicy.id, versionHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
bucketKey: `${ratePolicy.id}:agent:${input.actor.agentId}|tool:send_email` },
|
||||
]);
|
||||
const counters = await db.select().from(toolRateLimitCounters);
|
||||
expect(await svc.decide(input, { admittedInvocationId: invocation.id })).toMatchObject({ allowed: true });
|
||||
|
||||
@@ -433,9 +433,15 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
|
||||
validate(configureChatEndpointSchema),
|
||||
async (req, res) => {
|
||||
if (!(await assertEndpointManagementAccess(req, res))) return;
|
||||
res.json(
|
||||
await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req)),
|
||||
);
|
||||
res.set("Cache-Control", "no-store");
|
||||
try {
|
||||
res.json(await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req)));
|
||||
} catch (error) {
|
||||
if (req.actor.type !== "agent" || !(error instanceof HttpError) || error.status !== 403
|
||||
|| !error.details || typeof error.details !== "object" || !("code" in error.details)
|
||||
|| error.details.code !== "chat_endpoint_credential_entry_requires_human") throw error;
|
||||
res.json(await setupHandoff(req, "The responsible user must enter provider verification credentials in the saved setup.", "enter_credentials"));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2497,13 +2497,13 @@ registry.registerPath({
|
||||
tags: ["chat-channels"],
|
||||
summary: "Configure or change chat endpoint lifecycle state",
|
||||
description:
|
||||
"Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`; iMessage Photon: `projectSecret`, with nonsecret `photon.projectId` and `photon.lineId` configuration). Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.",
|
||||
"Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`; iMessage Photon: `projectSecret`, with nonsecret `photon.projectId` and `photon.lineId` configuration). Agent-supplied signing secrets, webhook secrets, private keys, client secrets, and project secrets return a saved human credential-entry handoff without changing credentials. Authorized write-only bot tokens and API keys remain supported. Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.",
|
||||
request: {
|
||||
params: z.object({ endpointId: z.string().uuid() }),
|
||||
body: jsonBody(configureChatEndpointSchema),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(chatEndpointResponseSchema),
|
||||
200: r.ok(z.union([chatEndpointResponseSchema, chatEndpointCredentialHandoffSchema])),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import {
|
||||
badRequest,
|
||||
conflict,
|
||||
forbidden,
|
||||
notFound,
|
||||
unprocessable,
|
||||
} from "../../errors.js";
|
||||
@@ -144,6 +145,15 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
|
||||
},
|
||||
);
|
||||
}
|
||||
// These keys establish provider authentication or inbound trust. They
|
||||
// must come from the human setup step or a verified provider handoff.
|
||||
if (managementContext?.actor.type === "agent" && suppliedCredentialKeys.some(
|
||||
key => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(key),
|
||||
)) {
|
||||
throw forbidden("The responsible user must enter provider verification credentials in the saved setup", {
|
||||
code: "chat_endpoint_credential_entry_requires_human",
|
||||
});
|
||||
}
|
||||
}
|
||||
return withCredentialMutationLease(record.endpoint, (credentialLease) =>
|
||||
configureWithCredentialLease(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export function runnerApiSensitiveResponse(method: string, path: string): boolean {
|
||||
if (method === "POST" && /^\/api\/chat-endpoints\/\{[^}]+\}\/setup$/.test(path)) return true;
|
||||
if (/(\/apps\/connect$|\/email\/connections$|setup-secret|\/github\/(setup|identity\/start)|\/slack\/resume|start-authorization|finalize-oauth-access|\/oauth\/|\/registration|\/managed\/authorize|\/install$|\/login|\/setup-token-login-sessions|\/local\/attempts)/.test(path)) return true;
|
||||
if (/\/secrets\/\{[^}]+\}\/value$/.test(path)) return true;
|
||||
return /^\/api\/tool-gateway\/sessions(?:\/|$)/.test(path)
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("bounded response capture receipts", () => {
|
||||
|
||||
describe("connection setup response protection", () => {
|
||||
it.each([
|
||||
["POST", "/api/chat-endpoints/{endpointId}/setup"],
|
||||
["POST", "/api/chat-endpoints/{endpointId}/setup-secret"],
|
||||
["POST", "/api/chat-endpoints/{endpointId}/github/setup"],
|
||||
["POST", "/api/chat-endpoints/{endpointId}/slack/resume"],
|
||||
|
||||
@@ -4592,6 +4592,12 @@ export function toolAccessService(
|
||||
managementContext?: ConnectionManagementContext,
|
||||
): Promise<ToolConnection> => {
|
||||
const existing = await getConnectionRow(connectionId);
|
||||
if (managementContext?.actor.type === "agent" && existing.connectionPurpose === "channel"
|
||||
&& (input.credentialRefs !== undefined || input.credentialSecretRefs !== undefined)) {
|
||||
throw forbidden("Use chat endpoint setup to change channel credentials; verification keys require human entry", {
|
||||
code: "chat_endpoint_credentials_require_setup",
|
||||
});
|
||||
}
|
||||
if (existing.connectionPurpose === "ai" && (input.config || input.transportConfig || input.credentialRefs || input.credentialSecretRefs || (input.credentialPolicy && input.credentialPolicy !== existing.credentialPolicy))) throw badRequest("Use AI account reconnect to change credentials. Provider, sign-in method, and ownership cannot be changed.");
|
||||
const config = normalizeGoogleSheetsConnectionConfig(
|
||||
input.config ?? input.transportConfig ?? existing.config,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { connectionActorUserId } from "../actor.js";
|
||||
import { lockManagedAppPolicies, lockToolAssignmentAgents } from "../assignment-locks.js";
|
||||
import type { ActorInfo, ToolAccessMutationDb } from "../types.js";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
@@ -112,7 +111,9 @@ export function createCatalogDefaultAccess(db: Db) {
|
||||
? (input.actor.actorId ?? null)
|
||||
: null,
|
||||
createdByUserId:
|
||||
connectionActorUserId(input.actor),
|
||||
input.actor?.actorType === "user"
|
||||
? (input.actor.actorId ?? null)
|
||||
: null,
|
||||
});
|
||||
} else if (resetToRecommendedDefaults) {
|
||||
// Removing an app may retain its profile row when another record still
|
||||
@@ -162,7 +163,9 @@ export function createCatalogDefaultAccess(db: Db) {
|
||||
? (input.actor.actorId ?? null)
|
||||
: null,
|
||||
createdByUserId:
|
||||
connectionActorUserId(input.actor),
|
||||
input.actor?.actorType === "user"
|
||||
? (input.actor.actorId ?? null)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,7 +299,9 @@ export function createCatalogDefaultAccess(db: Db) {
|
||||
? (input.actor.actorId ?? null)
|
||||
: null,
|
||||
createdByUserId:
|
||||
connectionActorUserId(input.actor),
|
||||
input.actor?.actorType === "user"
|
||||
? (input.actor.actorId ?? null)
|
||||
: null,
|
||||
})
|
||||
.returning();
|
||||
results.push(toPolicy(created));
|
||||
|
||||
Reference in new issue
Block a user