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:
DottaandPaperclip committed 2026-10-26 18:11:34 -05:00
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 });
+9 -3
View File
@@ -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"));
}
},
);
+2 -2
View File
@@ -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"],
+6
View File
@@ -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));