From 291a2c3b8ec62d65abe5b072b4ca2006f9d71a06 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 09:45:56 -0500 Subject: [PATCH] fix(connections): enforce sharing and test execution delegation boundaries Authorize nested finish targets and saved profile access. Carry authenticated task context through delegated test policy, persistence, execution, and human approval after a yielded run. Recheck current user, agent, task, and action policy before dispatch. Qualify the paths with actual authentication and an isolated PostgreSQL database. Co-Authored-By: Paperclip --- packages/shared/src/types/tool-access.ts | 1 + .../connection-management.integration.test.ts | 99 ++++++++++++++++++- .../__tests__/helpers/runner-api-server.ts | 8 +- .../src/routes/connection-management-authz.ts | 12 ++- server/src/services/connection-management.ts | 13 ++- server/src/services/tool-access-policy.ts | 8 +- server/src/services/tool-access.ts | 1 + server/src/services/tool-gateway.ts | 69 ++++++++----- 8 files changed, 171 insertions(+), 40 deletions(-) diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index e137714ebb3..b85ef4b83fd 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -1579,6 +1579,7 @@ export interface ToolAccessDecisionInput { }; runContext?: { heartbeatRunId?: string | null; + originatingAgentId?: string | null; issueId?: string | null; projectId?: string | null; routineId?: string | null; diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 80259357a03..5d46ae6bc5c 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -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(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((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 () => { diff --git a/server/src/__tests__/helpers/runner-api-server.ts b/server/src/__tests__/helpers/runner-api-server.ts index b4bfe9f651b..e59e1bb61fe 100644 --- a/server/src/__tests__/helpers/runner-api-server.ts +++ b/server/src/__tests__/helpers/runner-api-server.ts @@ -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"; diff --git a/server/src/routes/connection-management-authz.ts b/server/src/routes/connection-management-authz.ts index 5bf2df5a733..47965e44639 100644 --- a/server/src/routes/connection-management-authz.ts +++ b/server/src/routes/connection-management-authz.ts @@ -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(); const extraResources: ConnectionManagementResource[] = []; - const selectors: Record[] = [body, body.selectors ?? {}, ...(Array.isArray(body.entries) ? body.entries : [])]; + const selectors: Record[] = [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) => { diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts index adeebeab55a..486268773bd 100644 --- a/server/src/services/connection-management.ts +++ b/server/src/services/connection-management.ts @@ -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; reauthorize: (continuation?: { afterHumanAction: true }) => Promise; @@ -30,6 +31,7 @@ export function connectionManagementService(db: Db) { options: { targetAgentIds?: string[]; mutation?: boolean; afterHumanAction?: boolean; expandedProfile?: boolean; gatewayContextChecked?: boolean } = {}, ): Promise { 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) { diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index 8ebda1efeb6..cbc8323abe6 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -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); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 68a7cba466c..49c87de7680 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -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 diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index c0ba74cf7fd..ad4fcd16291 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -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,