Merge reviewed connection authorization fixes

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/agent-connection-api-parity:
  fix(connections): enforce sharing and test execution delegation boundaries
This commit is contained in:
DottaandPaperclip committed 2026-10-26 09:46:00 -05:00
commit 7be8df0252
8 files changed
+171 -40

No files matched your search

+1
View File
@@ -1579,6 +1579,7 @@ export interface ToolAccessDecisionInput {
};
runContext?: {
heartbeatRunId?: string | null;
originatingAgentId?: string | null;
issueId?: string | null;
projectId?: string | null;
routineId?: string | null;
@@ -1,14 +1,16 @@
import { SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared";
import { randomUUID, generateKeyPairSync } from "node:crypto";
import { join } from "node:path";
import { createServer } from "node:http";
import { and, eq } from "drizzle-orm";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { activityLog, agentApiKeys, companies, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGatewayTokens, plugins } from "@paperclipai/db";
import { activityLog, agentApiKeys, companies, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGatewayTokens, toolCatalogEntries, toolInvocations, toolProfileBindings, plugins } from "@paperclipai/db";
import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js";
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
import { configureAgentLifecycle } from "../services/agent-lifecycle.js";
import { agentService } from "../services/agents.js";
import { accessService } from "../services/access.js";
@@ -37,7 +39,7 @@ describe("connection management with production default agents", () => {
if (provider) return provider(url, init);
throw new Error("Unexpected external request in provider fixture");
});
server = await startRunnerApiTestServer({ authPublicBaseUrl: "https://paperclip.fixture.invalid", humanSessions: true });
server = await startRunnerApiTestServer({ authPublicBaseUrl: "https://paperclip.fixture.invalid", humanSessions: true, databaseUrl: process.env.PAPERCLIP_CONNECTION_MANAGEMENT_TEST_DATABASE_URL });
lifecycle = configureAgentLifecycle(server.db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(server.root, "master.key"));
await instanceSettingsService(server.db).updateExperimental({ enableChatConnectors: true, enableGitHubReviewBots: true });
@@ -92,6 +94,96 @@ describe("connection management with production default agents", () => {
expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
});
it("checks finish access, all-agent sharing, and retained targets against both management grants", async () => {
const f = await fixture();
const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Finish authority", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
const path = `/companies/${f.companyId}/tools/apps/${connection.body.id}/finish`;
const body = { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [f.agentId] } };
expect(await f.request("POST", path, body)).toMatchObject({ status: 200 });
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
for (const access of [body.access, { agentIds: [] }, "all_agents"]) expect(await f.request("POST", path, { ...body, access })).toMatchObject({ status: 403 });
await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tools:manage_profiles", scope: { targetAgentIds: [f.agentId] } });
expect(await f.request("POST", path, { ...body, access: "all_agents" })).toMatchObject({ status: 403 });
const [other] = await server.db.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.name, "Eval agent")));
const targets = await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId));
const otherId = other?.id ?? targets.find(agent => agent.id !== f.agentId)!.id;
expect(await f.request("POST", path, { ...body, access: { agentIds: [otherId] } })).toMatchObject({ status: 403 });
await server.db.update(principalPermissionGrants).set({ scope: null }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
await server.db.update(principalPermissionGrants).set({ scope: { targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure")));
expect(await f.request("POST", path, { ...body, access: { agentIds: [otherId] } })).toMatchObject({ status: 403 });
const bindings = await server.db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, f.companyId));
expect(bindings.map(binding => binding.targetId)).toEqual([f.agentId]);
});
it.each(["none", "other-agent", "agent", "user", "cancelled", "project-block"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => {
const f = await fixture();
let targetAgentId = f.agentId;
if (revocation === "other-agent") {
const cookie = await server.humanSession(f.userId);
const created = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/agents`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test target", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }) });
const target = await created.json();
expect(created.status, JSON.stringify(target)).toBe(201);
targetAgentId = target.id;
await lifecycle.process(targetAgentId);
await vi.waitFor(async () => expect((await agentService(server.db).getById(targetAgentId))?.status).toBe("idle"));
}
const allowed = revocation === "none" || revocation === "other-agent";
let calls = 0;
const remote = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
const request = JSON.parse(Buffer.concat(chunks).toString() || "{}");
let result: unknown;
if (request.method === "initialize") result = { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "Fixture", version: "1" } };
else if (request.method === "tools/call") { calls++; result = { content: [{ type: "text", text: "Fixture result" }] }; }
else { res.writeHead(req.method === "DELETE" ? 204 : request.method === "notifications/initialized" ? 202 : 405); res.end(); return; }
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }));
});
await new Promise<void>(resolve => remote.listen(0, "127.0.0.1", resolve));
const address = remote.address();
if (!address || typeof address === "string") throw new Error("Fixture did not listen");
const mcpUrl = `http://127.0.0.1:${address.port}/mcp`;
try {
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Governed test", transport: "mcp_remote", authKind: "none", transportConfig: { url: mcpUrl } });
await server.db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, created.body.id));
const [entry] = await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: created.body.applicationId, connectionId: created.body.id,
entryKind: "tool", name: "Fixture probe", toolName: "fixture_probe", inputSchema: { type: "object", properties: {} }, riskLevel: "read", isReadOnly: true, status: "active", versionHash: "fixture-v1" }).returning();
expect(await f.request("POST", `/companies/${f.companyId}/tools/apps/${created.body.id}/finish`, { enabledCatalogEntryIds: [entry.id], access: { agentIds: [targetAgentId] } })).toMatchObject({ status: 200 });
const path = `/tool-connections/${created.body.id}/test-calls`;
const body = { agentId: targetAgentId, runContext: { heartbeatRunId: randomUUID(), issueId: randomUUID(), projectId: randomUUID() }, toolName: "fixture_probe", parameters: {} };
for (const selectors of [{ connectionId: created.body.id, projectId: f.projectId }, { connectionId: created.body.id, issueId: f.issueId }]) {
const [block] = await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Scoped test block", policyType: "block", selectors, priority: 1 }).returning();
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "off" } });
expect(calls).toBe(0);
await server.db.delete(toolPolicies).where(eq(toolPolicies.id, block.id));
}
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Scoped test approval", policyType: "require_approval", selectors: { connectionId: created.body.id, issueId: f.issueId }, priority: 1 });
const pending = await f.request("POST", path, body, f.jwt);
expect(pending, JSON.stringify(pending)).toMatchObject({ status: 200, body: { decision: "ask_first" } });
const [saved] = await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, pending.body.invocationId));
expect(saved).toMatchObject({ runId: f.runId, issueId: f.issueId, actorType: "agent", actorId: f.agentId, agentId: targetAgentId });
expect(calls).toBe(0);
const approvalPath = `/tool-gateway/action-requests/${pending.body.actionRequestId}/approve`;
expect(await f.request("POST", approvalPath, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 });
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
if (revocation === "agent") await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:use")));
if (revocation === "user") for (const permission of ["tools:use", "tools:manage_connections"]) await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalId, f.userId), eq(principalPermissionGrants.permissionKey, permission)));
if (revocation === "cancelled") await server.db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId));
if (revocation === "project-block") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "New project block", policyType: "block", selectors: { connectionId: created.body.id, projectId: f.projectId }, priority: 0 });
const cookie = await server.humanSession(f.userId);
const approve = () => fetch(server.apiUrl + "/api" + approvalPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) });
const response = await approve();
expect(response.status, JSON.stringify(await response.json())).toBe(revocation === "cancelled" ? 409 : 200);
const [settled] = await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, saved.id));
if (revocation === "cancelled") expect(settled.status).not.toBe("succeeded");
else expect(settled, JSON.stringify(settled)).toMatchObject({ status: allowed ? "succeeded" : "failed" });
expect(calls).toBe(allowed ? 1 : 0);
if (allowed) { expect((await approve()).status).toBe(200); expect(calls).toBe(1); }
} finally { remote.closeAllConnections(); await new Promise<void>((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); }
});
it("hands generic app authentication to the saved setup without creating agent-bound OAuth state", async () => {
const f = await fixture();
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, {
@@ -366,6 +458,7 @@ describe("connection management with production default agents", () => {
if (url === "https://api.openai.com/v1/models") return Response.json({ data: [] });
throw new Error("Unexpected AI fixture request");
};
registerServerAdapter({ ...getServerAdapter("paperclip_runner"), testEnvironment: async () => ({ adapterType: "paperclip_runner", status: "pass", checks: [], testedAt: new Date().toISOString() }) });
try {
const account = await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name: "Fixture OpenAI", ownership: "personal", apiKey: "fixture-secret-do-not-return", agentIds: [f.agentId] });
expect(account, JSON.stringify(account)).toMatchObject({ status: 201 });
@@ -393,7 +486,7 @@ describe("connection management with production default agents", () => {
expect(await f.request("DELETE", `/companies/${f.companyId}/ai-connection-pools/${pool.body.id}`, { expectedRevision: 2 })).toMatchObject({ status: 200 });
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name: "No sharing authority", ownership: "personal", apiKey: "fixture-secret", agentIds: [f.agentId] })).toMatchObject({ status: 403 });
} finally { provider = null; }
} finally { provider = null; unregisterServerAdapter("paperclip_runner"); }
});
it("reuses a saved chat authentication request and resumes only while its authority remains valid", async () => {
@@ -7,7 +7,7 @@ import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq, sql } from "drizzle-orm";
import { agents, authUsers, authAccounts, companies, companyMemberships, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, documentRevisions, issueDocuments, issueRelations, issueThreadInteractions, connectionIntentDeliveries, toolApplications, toolConnections, toolConnectionInstalls, connectionGrants, toolCatalogEntries, toolProfiles, toolProfileBindings } from "@paperclipai/db";
import { agents, authUsers, authAccounts, companies, companyMemberships, createDb, applyPendingMigrations, closeRegisteredClients, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, documentRevisions, issueDocuments, issueRelations, issueThreadInteractions, connectionIntentDeliveries, toolApplications, toolConnections, toolConnectionInstalls, connectionGrants, toolCatalogEntries, toolProfiles, toolProfileBindings } from "@paperclipai/db";
import { documentService } from "../../services/documents.js";
import { connectionIntentService } from "../../services/connection-intents.js";
import { initializeRunIdentity } from "../../services/run-identity.js";
@@ -26,9 +26,13 @@ export async function startRunnerApiTestServer(options: {
deploymentMode?: "authenticated" | "local_trusted";
authPublicBaseUrl?: string;
humanSessions?: boolean;
databaseUrl?: string;
} = {}) {
const root = await mkdtemp(join(tmpdir(), "paperclip-api-eval-"));
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-api-eval-db-");
const temporary = options.databaseUrl
? { connectionString: options.databaseUrl, cleanup: () => closeRegisteredClients(options.databaseUrl!) }
: await startEmbeddedPostgresTestDatabase("paperclip-api-eval-db-");
if (options.databaseUrl) await applyPendingMigrations(options.databaseUrl);
const db = createDb(temporary.connectionString);
const storage = createStorageService(createLocalDiskStorageProvider(join(root, "storage")));
const origin = options.authPublicBaseUrl ?? "http://127.0.0.1";
@@ -59,7 +59,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
readOnly: req.method === "GET" && action !== "connections:gateway" && (action !== "connections:audit" || purpose === "chat") };
const targets = new Set<string>();
const extraResources: ConnectionManagementResource[] = [];
const selectors: Record<string, unknown>[] = [body, body.selectors ?? {}, ...(Array.isArray(body.entries) ? body.entries : [])];
const selectors: Record<string, unknown>[] = [body, body.access && typeof body.access === "object" ? body.access : {}, body.selectors ?? {}, ...(Array.isArray(body.entries) ? body.entries : [])];
const bindCompany = (row: { companyId: string } | undefined) => {
if (!row || (req.actor.type === "board" && !hasCompanyAccess(req, row.companyId))) throw notFound(inaccessibleMessage);
if (companyId && companyId !== row.companyId) throw forbidden("Resource is outside this company");
@@ -180,6 +180,12 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
if (!grants.some(grant => grant.subjectUserId === userId) && connection.createdByUserId !== userId) throw forbidden("Another user's personal account is not available for delegated management");
}
}
if (connectionId && operation === "POST /companies/:companyId/tools/apps/:connectionId/finish") {
const [profile] = await db.select({ id: toolProfiles.id }).from(toolProfiles).where(and(
eq(toolProfiles.companyId, companyId!), eq(toolProfiles.profileKey, `app:${resource.connectionId}`),
));
profileId = profile?.id ?? profileId;
}
if (profileId) {
const [profile] = await db.select().from(toolProfiles).where(eq(toolProfiles.id, uuid(profileId)));
bindCompany(profile); resource.profileId = profile.id;
@@ -208,7 +214,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
for (const install of Array.isArray(body.installs) ? body.installs : []) {
if (install.targetType === "agent" && text(install.targetId)) targets.add(install.targetId);
}
if ((operation.includes("ai-connection-pools") && req.method === "POST") || body.allAgents || body.targetType === "company" || body.installs?.some?.((install: { targetType: string }) => install.targetType === "company")) {
if ((operation.includes("ai-connection-pools") && req.method === "POST") || body.allAgents || body.access === "all_agents" || body.targetType === "company" || body.installs?.some?.((install: { targetType: string }) => install.targetType === "company")) {
const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
companyAgents.forEach(agent => targets.add(agent.id));
}
@@ -277,7 +283,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets], mutation: req.method !== "GET" });
}
const changesAudience = req.actor.type === "agent" && req.method !== "GET" && action === "connections:configure" && (
["allAgents", "agentIds", "assignedAgentId", "installs", "bindings", "ownership", "grantKind", "credentialPolicy"].some(key => Object.hasOwn(body, key)) ||
["allAgents", "agentIds", "assignedAgentId", "installs", "bindings", "ownership", "grantKind", "credentialPolicy", "access"].some(key => Object.hasOwn(body, key)) ||
operation.includes("ai-connection-pools"));
if (changesAudience) await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets] });
const authorizeReferenced = async (afterHumanAction?: boolean) => {
+10 -3
View File
@@ -10,6 +10,7 @@ export type ConnectionManagementContext = {
actor: AuthorizationActor;
responsibleUserId: string | null;
resource: ConnectionManagementResource;
workContext: { runId: string | null; issueId: string | null; projectId: string | null };
decision: AuthorizationDecision;
audit: Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId" | "agentApiKeyId" | "responsibleUserIdOverride" | "authorization">;
reauthorize: (continuation?: { afterHumanAction: true }) => Promise<void>;
@@ -30,6 +31,7 @@ export function connectionManagementService(db: Db) {
options: { targetAgentIds?: string[]; mutation?: boolean; afterHumanAction?: boolean; expandedProfile?: boolean; gatewayContextChecked?: boolean } = {},
): Promise<ConnectionManagementContext> {
const responsibleUserId = responsibleConnectionUser(actor);
let workContext: ConnectionManagementContext["workContext"] = { runId: null, issueId: null, projectId: null };
if (actor.type === "agent") {
if (!responsibleUserId || !actor.agentId || actor.companyId !== resource.companyId) {
throw forbidden("Connection management requires a responsible user in this company");
@@ -53,6 +55,7 @@ export function connectionManagementService(db: Db) {
const snapshot = run.contextSnapshot ?? {};
const issueId = run.issueId ?? (typeof snapshot.issueId === "string" ? snapshot.issueId : typeof snapshot.taskId === "string" ? snapshot.taskId : null);
const [issue] = issueId ? await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, resource.companyId))) : [];
workContext = { runId: run.id, issueId, projectId: issue?.projectId ?? null };
if (issueId && (!issue || issue.assigneeAgentId !== actor.agentId || ["cancelled", "done"].includes(issue.status))) throw forbidden("Run no longer owns an eligible task");
if (snapshot.paperclipSkillTest || issue?.workMode === "skill_test") throw forbidden("Skill tests cannot administer connections");
if ((options.mutation ?? action !== "connections:read") && issue && issue.workMode !== "standard") throw forbidden("Connection changes are unavailable in Ask and Plan modes");
@@ -186,12 +189,16 @@ export function connectionManagementService(db: Db) {
if (currentGatewayResource) {
await authorize(actor, action, currentGatewayResource, { ...options, targetAgentIds: [...expandedTargets], expandedProfile: false, gatewayContextChecked: true });
}
return {
actor, responsibleUserId, resource, decision: decision!,
const context: ConnectionManagementContext = {
actor, responsibleUserId, resource, workContext, decision: decision!,
audit: { authorization: { action, operation: resource.operation, reason: decision!.reason }, actorType: actor.type === "agent" ? "agent" : "user", actorId: actor.type === "agent" ? actor.agentId! : actor.userId ?? "board",
agentId: actor.agentId ?? null, runId: actor.runId ?? null, agentApiKeyId: actor.keyId ?? null, responsibleUserIdOverride: responsibleUserId },
reauthorize: async (continuation) => { await authorize(actor, action, resource, { ...options, afterHumanAction: continuation?.afterHumanAction ?? options.afterHumanAction }); },
reauthorize: async (continuation) => {
const current = await authorize(actor, action, resource, { ...options, afterHumanAction: continuation?.afterHumanAction ?? options.afterHumanAction });
context.workContext = current.workContext;
},
};
return context;
}
async function capability(...args: Parameters<typeof authorize>) {
+6 -2
View File
@@ -992,6 +992,10 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr
agentId = agent.id;
}
const originatingAgentId = input.runContext?.originatingAgentId;
if (originatingAgentId && (input.actor.actorType !== "agent" || input.actor.actorId !== originatingAgentId)) {
return { ok: false, redaction, decision: decision("deny", "deny_run_context_mismatch", "Run origin does not match the authenticated actor.", [], [], { redactionPlan: redaction.redactionPlan }) };
}
if (heartbeatRunId) {
const runId = heartbeatRunId;
const [run] = await cachedRead(cache, `run:${runId}`, async () =>
@@ -1005,10 +1009,10 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId)));
if (!run || run.companyId !== input.companyId || (input.actor.actorType === "agent" && run.agentId !== agentId)) {
if (!run || run.companyId !== input.companyId || (input.actor.actorType === "agent" && run.agentId !== (originatingAgentId ?? agentId))) {
return { ok: false, redaction, decision: decision("deny", "deny_run_context_mismatch", "Supplied run context does not match the authenticated actor.", [], [], { redactionPlan: redaction.redactionPlan }) };
}
agentId = run.agentId;
agentId = originatingAgentId ? agentId : run.agentId;
const runIssueId = nonBlankString(run.issueId);
const runProjectId = nonBlankString(run.projectId);
const runRoutineId = nonBlankString(run.routineId);
+1
View File
@@ -3259,6 +3259,7 @@ export function toolAccessService(
const companyTargets = lockedConnection.status !== "active" || !lockedConnection.enabled || (existingProfile && existingProfile.status !== "active")
? [{ targetType: "company", targetId: companyId }] : [];
await lockToolAssignmentAgents(tx, companyId, [...existingBindings, ...bindingInputs, ...existingInstalls, ...companyTargets]);
await actor?.managementContext?.reauthorize();
// A renamed connection can leave its old profile name in use. Resolve
// names against profiles too, including when finalizing an installed draft.
const otherProfiles = await tx
+42 -27
View File
@@ -339,6 +339,7 @@ export interface ToolGatewaySession {
runId: string | null;
issueId: string | null;
projectId: string | null;
originatingAgentId?: string | null;
gatewayId?: string | null;
gatewayPublicId?: string | null;
gatewayName?: string | null;
@@ -2794,6 +2795,7 @@ export function createToolGatewayService(
idempotencyKey: input.idempotencyKey,
consumeRateLimit: input.consumeRateLimit,
heartbeatRunId: input.session.runId,
originatingAgentId: input.session.originatingAgentId,
issueId: input.session.issueId,
projectId: input.session.projectId,
gatewayId: input.session.gatewayId ?? null,
@@ -2810,6 +2812,7 @@ export function createToolGatewayService(
idempotencyKey?: string | null;
consumeRateLimit?: boolean;
heartbeatRunId?: string | null;
originatingAgentId?: string | null;
issueId?: string | null;
projectId?: string | null;
gatewayId?: string | null;
@@ -2826,6 +2829,7 @@ export function createToolGatewayService(
},
runContext: {
heartbeatRunId: input.heartbeatRunId ?? null,
originatingAgentId: input.originatingAgentId ?? null,
issueId: input.issueId ?? null,
projectId: input.projectId ?? null,
gatewayId: input.gatewayId ?? null,
@@ -7411,8 +7415,9 @@ export function createToolGatewayService(
function isTestOriginInvocation(
invocation: typeof toolInvocations.$inferSelect,
): boolean {
if (invocation.policyExplanation?.delegatedConnectionTest) return invocation.connectionId !== null && invocation.gatewayId === null;
return (
(invocation.actorType === "user" || Boolean(invocation.policyExplanation?.delegatedConnectionTest)) &&
invocation.actorType === "user" &&
invocation.runId === null &&
invocation.issueId === null &&
invocation.gatewayId === null &&
@@ -7440,6 +7445,7 @@ export function createToolGatewayService(
matchedPolicyIds: string[];
timeoutMs?: number;
signal?: AbortSignal;
managementContext?: ConnectionManagementContext;
}): Promise<
| { decision: "allowed"; invocationId: string; result: unknown }
| {
@@ -7460,8 +7466,8 @@ export function createToolGatewayService(
session: args.session,
companyId: args.companyId,
agentId: args.agentId,
runId: null,
issueId: null,
runId: args.session.runId,
issueId: args.session.issueId,
actorType: args.session.actorType,
actorId: args.session.actorId ?? args.userId,
action: "tool_gateway.call_allowed",
@@ -7479,6 +7485,14 @@ export function createToolGatewayService(
const startedAt = Date.now();
try {
await args.managementContext?.reauthorize();
if (args.managementContext?.actor.type === "agent") {
Object.assign(args.session, args.managementContext.workContext);
const current = await policyService.decide(policyInputForTool({ session: args.session, tool: args.tool, parameters: args.parameters }));
if (!current.allowed && !(args.session.approvedInvocationId && current.decision === "require_approval")) {
throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode);
}
}
const executionTimeoutMs = timeoutMs(args.timeoutMs, args.tool);
const connectedMcpExecution =
(args.tool.providerType === "mcp_remote_http" || args.tool.providerType === "provider_rest")
@@ -7515,7 +7529,7 @@ export function createToolGatewayService(
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block", classifier: resultClassifier(args.session, args.tool, args.invocationId, { signal: args.signal }),
promptInjectionMode: "block", classifier: resultClassifier(args.session, args.tool, args.invocationId, { signal: args.signal, approvedAction: Boolean(args.session.approvedInvocationId) }),
});
const providerResult = asRecord(resultValidation.value);
if (providerResult?.error) {
@@ -7555,8 +7569,8 @@ export function createToolGatewayService(
session: args.session,
companyId: args.companyId,
agentId: args.agentId,
runId: null,
issueId: null,
runId: args.session.runId,
issueId: args.session.issueId,
actorType: args.session.actorType,
actorId: args.session.actorId ?? args.userId,
action: "tool_gateway.call_completed",
@@ -7634,8 +7648,8 @@ export function createToolGatewayService(
session: args.session,
companyId: args.companyId,
agentId: args.agentId,
runId: null,
issueId: null,
runId: args.session.runId,
issueId: args.session.issueId,
actorType: args.session.actorType,
actorId: args.session.actorId ?? args.userId,
action:
@@ -7703,6 +7717,7 @@ export function createToolGatewayService(
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
let managementContext: ConnectionManagementContext | undefined;
let tool: ToolGatewayDescriptor | undefined;
try {
tool = (
@@ -7746,17 +7761,17 @@ export function createToolGatewayService(
})).summary;
try {
if (delegated) {
await connectionManagementService(db).authorize({ type: "agent", companyId: invocation.companyId,
managementContext = await connectionManagementService(db).authorize({ type: "agent", companyId: invocation.companyId,
agentId: delegated.agentId, onBehalfOfUserId: delegated.responsibleUserId,
runId: delegated.runId, keyId: delegated.keyId,
}, "connections:test", delegated.resource, { targetAgentIds: [agentId] });
const current = await policyService.decide(policyInputForAgentTool({
companyId: invocation.companyId, agentId, actorType: "agent", actorId: delegated.agentId, tool, parameters,
}));
}, "connections:test", delegated.resource, { targetAgentIds: [agentId], afterHumanAction: true });
Object.assign(session, managementContext.workContext, { originatingAgentId: delegated.agentId, approvedInvocationId: invocation.id });
const current = await policyService.decide(policyInputForTool({ session, tool, parameters }));
if (!current.allowed && current.decision !== "require_approval") throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode);
}
await runTestToolInvocation({
const outcome = await runTestToolInvocation({
session,
managementContext,
tool,
parameters,
invocationId: invocation.id,
@@ -7769,7 +7784,8 @@ export function createToolGatewayService(
});
await reflectToolActionInteractionLifecycle({
actionRequestId,
status: "executed",
status: "error" in outcome ? "failed" : "executed",
...("error" in outcome ? { errorCode: outcome.error.reasonCode, errorMessage: outcome.error.message } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -9497,9 +9513,10 @@ export function createToolGatewayService(
token: "test-call",
companyId: input.companyId,
agentId: input.agentId,
runId: null,
issueId: null,
projectId: null,
runId: input.managementContext?.workContext.runId ?? null,
issueId: input.managementContext?.workContext.issueId ?? null,
projectId: input.managementContext?.workContext.projectId ?? null,
originatingAgentId: actingAgentId,
actorType: actingAgentId ? "agent" : "user",
actorId: actingAgentId ?? input.userId,
responsibleUserId: input.userId,
@@ -9539,11 +9556,8 @@ export function createToolGatewayService(
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
const decisionInput = policyInputForAgentTool({
companyId: input.companyId,
agentId: input.agentId,
actorType: actingAgentId ? "agent" : "user",
actorId: actingAgentId ?? input.userId,
const decisionInput = policyInputForTool({
session,
tool,
parameters: requestedParameters,
idempotencyKey: `test-call:${randomUUID()}`,
@@ -9671,8 +9685,8 @@ export function createToolGatewayService(
session,
companyId: input.companyId,
agentId: input.agentId,
runId: null,
issueId: null,
runId: session.runId,
issueId: session.issueId,
actorType: actingAgentId ? "agent" : "user",
actorId: actingAgentId ?? input.userId,
action: "tool_gateway.approval_requested",
@@ -9703,8 +9717,8 @@ export function createToolGatewayService(
session,
companyId: input.companyId,
agentId: input.agentId,
runId: null,
issueId: null,
runId: session.runId,
issueId: session.issueId,
actorType: actingAgentId ? "agent" : "user",
actorId: actingAgentId ?? input.userId,
action: "tool_gateway.call_denied",
@@ -9733,6 +9747,7 @@ export function createToolGatewayService(
await input.managementContext?.reauthorize();
return runTestToolInvocation({
session,
managementContext: input.managementContext,
tool,
parameters: requestedParameters,
invocationId,