From 70b7c973c87a8fda445c6992bb1f52cfe00dfa41 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 17:27:19 -0500 Subject: [PATCH] Enforce caller authority on delegated connection tests Recheck private task access before exposing saved results; intersect caller and target profiles, policies, credentials, and rate admissions. Co-Authored-By: Paperclip --- .../connection-management.integration.test.ts | 41 ++++++++-- server/src/routes/tool-access.ts | 1 + server/src/services/tool-access-policy.ts | 44 ++++++++--- server/src/services/tool-gateway.ts | 79 ++++++++++++++++++- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 1d0b85a2a5d..2d18dbe40f6 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -231,10 +231,10 @@ describe("connection management with production default agents", () => { expect(bindings.map(binding => binding.targetId)).toEqual([f.agentId]); }); - it.each(["none", "other-agent", "agent", "user", "cancelled", "project-block", "rate-limit", "agent-rate-approval", "authorized-policy-edit"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => { + it.each(["none", "other-agent", "other-agent-no-access", "other-agent-caller-ask", "other-agent-caller-rate", "other-agent-shared-rate", "other-agent-credential", "other-agent-caller-revoked", "private-status", "agent", "user", "cancelled", "project-block", "rate-limit", "agent-rate-approval", "authorized-policy-edit"] 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") { + if (revocation.startsWith("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 } } }) }); @@ -244,7 +244,7 @@ describe("connection management with production default agents", () => { 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"; + const allowed = revocation === "none" || revocation === "other-agent" || revocation === "other-agent-caller-ask"; let calls = 0; const remote = createServer(async (req, res) => { const chunks: Buffer[] = []; @@ -266,9 +266,30 @@ describe("connection management with production default agents", () => { 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 }); + expect(await f.request("POST", `/companies/${f.companyId}/tools/apps/${created.body.id}/finish`, { enabledCatalogEntryIds: [entry.id], access: { agentIds: revocation.startsWith("other-agent") && revocation !== "other-agent-no-access" ? [f.agentId, targetAgentId] : [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: {} }; + if (revocation === "other-agent-no-access") { + for (const token of [f.key.token, f.jwt]) expect(await f.request("POST", path, body, token)).toMatchObject({ status: 403, body: { reasonCode: "test_caller_connection_unavailable" } }); + expect(calls).toBe(0); + return; + } + if (revocation === "other-agent-credential") { + await server.db.update(toolConnections).set({ authKind: "api_key", credentialPolicy: "per_agent" }).where(eq(toolConnections.id, created.body.id)); + await server.db.insert(connectionGrants).values([f.agentId, targetAgentId].map(subjectAgentId => ({ companyId: f.companyId, connectionId: created.body.id, kind: "agent" as const, subjectAgentId, status: "active" }))); + expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 403, body: { reasonCode: "test_credential_identity_mismatch" } }); + expect(calls).toBe(0); + return; + } + if (revocation === "other-agent-caller-rate" || revocation === "other-agent-shared-rate") { + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller rate", policyType: "rate_limit", selectors: { connectionId: created.body.id, ...(revocation === "other-agent-caller-rate" ? { agentId: f.agentId } : {}) }, config: { limit: 1, windowSeconds: 60, keyBy: revocation === "other-agent-caller-rate" ? ["agent", "tool"] : ["company", "tool"] } }); + const admitted = await f.request("POST", path, body, f.jwt); + expect(admitted, JSON.stringify(admitted)).toMatchObject({ status: 200, body: { decision: "allowed", result: { content: "Fixture result" } } }); + expect(calls).toBe(1); + expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "rate_limited" } } }); + expect(calls).toBe(1); + return; + } 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" } }); @@ -311,12 +332,21 @@ describe("connection management with production default agents", () => { expect(calls).toBe(1); return; } - 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 }); + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Scoped test approval", policyType: "require_approval", selectors: { connectionId: created.body.id, issueId: f.issueId, ...(revocation === "other-agent-caller-ask" ? { agentId: f.agentId } : {}) }, 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); + if (revocation === "private-status") { + const statusPath = `${path}/${pending.body.actionRequestId}`; + expect(await f.request("GET", statusPath)).toMatchObject({ status: 200 }); + const [hidden] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private test task", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning(); + await server.db.update(toolInvocations).set({ issueId: hidden.id }).where(eq(toolInvocations.id, saved.id)); + for (const token of [f.key.token, f.jwt]) expect(await f.request("GET", statusPath, undefined, token)).toMatchObject({ status: 403 }); + expect(calls).toBe(0); + return; + } const approvalPath = `/tool-gateway/action-requests/${pending.body.actionRequestId}/approve`; expect(await f.request("POST", approvalPath, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 }); if (revocation === "authorized-policy-edit") { @@ -335,6 +365,7 @@ describe("connection management with production default agents", () => { 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 === "other-agent-caller-revoked") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller access revoked", policyType: "block", selectors: { connectionId: created.body.id, agentId: f.agentId }, priority: 0 }); 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 }) }); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index d9ca0608e06..1218ff4809d 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -2159,6 +2159,7 @@ function connectorEnrollmentPrincipal(req: Request): string { await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]); try { const status = await options.toolGateway.getTestCallStatus({ + actor: req.actor, companyId: connection.companyId, connectionId: connection.id, actionRequestId: req.params.actionRequestId as string, diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index c99127f51e1..b16c33ecebd 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -190,7 +190,7 @@ function sha256(value: unknown): string { return createHash("sha256").update(stableStringify(value)).digest("hex"); } -type ConsumedRateAdmission = { policyId: string; versionHash: string }; +type ConsumedRateAdmission = { policyId: string; versionHash: string; bucketKey?: string }; function ratePolicyVersionHash(policy: typeof toolPolicies.$inferSelect): string { return sha256({ ...policy, createdAt: policy.createdAt.toISOString(), updatedAt: policy.updatedAt.toISOString() }); } @@ -1238,6 +1238,19 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr return { profiles: activeProfiles, entries }; } + function profileAllowsTool(profileState: Awaited>, ctx: ToolAccessContext) { + return profileState.profiles.some(profile => { + const entries = profileState.entries.filter(entry => entry.profileId === profile.id && profileEntryMatches(entry, ctx)); + return !entries.some(entry => entry.effect === "exclude") + && (profile.defaultAction === "allow" || entries.some(entry => entry.effect === "include")); + }); + } + + async function hasProfileAccess(input: ToolAccessDecisionInput) { + const loaded = await loadContext(input); + return loaded.ok && profileAllowsTool(await effectiveProfiles(loaded.ctx), loaded.ctx); + } + async function explicitGrant(ctx: ToolAccessContext, cache?: ToolAccessDecisionCache): Promise { const principalType = ctx.actorType === "agent" ? "agent" : ctx.actorType === "user" ? "user" : null; const principalId = ctx.actorType === "agent" ? ctx.agentId : ctx.actorId; @@ -1386,18 +1399,21 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr status: toolInvocations.status, policyDecision: toolInvocations.policyDecision, approvalState: toolInvocations.approvalState, policyExplanation: toolInvocations.policyExplanation, }).from(toolInvocations).where(and(eq(toolInvocations.companyId, ctx.companyId), eq(toolInvocations.id, invocationId))); + const delegated = invocation?.policyExplanation?.delegatedConnectionTest as { agentId?: string } | undefined; + const isDelegatedCaller = invocation?.actorType === "agent" && invocation.actorId === ctx.agentId + && delegated?.agentId === ctx.agentId && invocation.agentId !== ctx.agentId; const authorized = invocation && ( (invocation.status === "executing" && (invocation.policyDecision === "allow" || (invocation.policyDecision === "require_approval" && invocation.approvalState === "approved"))) || (invocation.status === "awaiting_approval" && invocation.policyDecision === "require_approval" && ["pending", "approved"].includes(invocation.approvalState)) ) - && invocation.actorType === ctx.actorType && invocation.actorId === ctx.actorId && invocation.agentId === ctx.agentId + && invocation.actorType === ctx.actorType && invocation.actorId === ctx.actorId && (invocation.agentId === ctx.agentId || isDelegatedCaller) && invocation.runId === ctx.heartbeatRunId && invocation.issueId === ctx.issueId && invocation.gatewayId === ctx.gatewayId && invocation.applicationId === ctx.applicationId && invocation.connectionId === ctx.connectionId && invocation.catalogEntryId === ctx.catalogEntryId && invocation.toolName === ctx.toolName && invocation.providerType === ctx.providerType && invocation.applicationKey === ctx.applicationKey && invocation.upstreamToolName === ctx.upstreamToolName && invocation.argumentsHash === ctx.argumentsHash; - const stored = authorized ? invocation.policyExplanation?.consumedRateAdmissions : null; + const stored = authorized ? invocation.policyExplanation?.[isDelegatedCaller ? "delegatedCallerConsumedRateAdmissions" : "consumedRateAdmissions"] : null; return Array.isArray(stored) ? stored.filter((item): item is ConsumedRateAdmission => isRecord(item) && typeof item.policyId === "string" && CANONICAL_UUID_RE.test(item.policyId) && typeof item.versionHash === "string" && /^[a-f0-9]{64}$/.test(item.versionHash)) : []; @@ -1410,7 +1426,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr */ async function decide( input: ToolAccessDecisionInput, - options: { cache?: ToolAccessDecisionCache; admittedInvocationId?: string } = {}, + options: { cache?: ToolAccessDecisionCache; admittedInvocationId?: string; sharedRateDecision?: ToolAccessDecision } = {}, ): Promise { const consumedRateAdmissions: ConsumedRateAdmission[] = []; const result = await decideInternal(input, options, consumedRateAdmissions); @@ -1421,7 +1437,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr async function decideInternal( input: ToolAccessDecisionInput, - options: { cache?: ToolAccessDecisionCache; admittedInvocationId?: string }, + options: { cache?: ToolAccessDecisionCache; admittedInvocationId?: string; sharedRateDecision?: ToolAccessDecision }, consumedRateAdmissions: ConsumedRateAdmission[], ): Promise { // A decision that consumes a rate limit can also record a trust-rule hit @@ -1438,11 +1454,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr ? await admittedRateLimitAdmissions(ctx, options.admittedInvocationId) : []; const profileState = await effectiveProfiles(ctx, cache); const effectiveProfileIds = profileState.profiles.map((profile) => profile.id); - const permittedByProfile = profileState.profiles.some((profile) => { - const matchingEntries = profileState.entries.filter((entry) => entry.profileId === profile.id && profileEntryMatches(entry, ctx)); - return !matchingEntries.some((entry) => entry.effect === "exclude") - && (profile.defaultAction === "allow" || matchingEntries.some((entry) => entry.effect === "include")); - }); + const permittedByProfile = profileAllowsTool(profileState, ctx); const policies = await cachedRead(cache, `enabled-policies:${ctx.companyId}`, async () => db.select().from(toolPolicies).where(and(eq(toolPolicies.companyId, ctx.companyId), eq(toolPolicies.enabled, true))).orderBy(asc(toolPolicies.priority), asc(toolPolicies.createdAt))); for (const policy of policies) { @@ -1509,11 +1521,20 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr } const versionHash = ratePolicyVersionHash(policy); if (admittedRates.some(admission => admission.policyId === policy.id && admission.versionHash === versionHash)) continue; + // Paired caller/target checks for one delegated test share a slot only + // when the exact policy version and counter bucket are identical. + const shared = options.sharedRateDecision?.policyExplanation?.consumedRateAdmissions; + const bucketKey = `${policy.id}:${rateBucket(rateLimitRule(policy)!, ctx)}`; + if (input.consumeRateLimit === true && Array.isArray(shared) && shared.some(item => isRecord(item) + && item.policyId === policy.id && item.versionHash === versionHash && item.bucketKey === bucketKey)) { + consumedRateAdmissions.push({ policyId: policy.id, versionHash, bucketKey }); + continue; + } const state = await enforceRateLimit(policy, ctx, input.consumeRateLimit === true); if (state?.limited) { return decision("rate_limited", "rate_limited", "Tool access rate limit exceeded.", effectiveProfileIds, [policy.id], { rateLimitState: state, redactionPlan: redaction.redactionPlan, policyExplanation }); } - if (state && input.consumeRateLimit === true) consumedRateAdmissions.push({ policyId: policy.id, versionHash }); + if (state && input.consumeRateLimit === true) consumedRateAdmissions.push({ policyId: policy.id, versionHash, bucketKey: state.bucketKey }); continue; } if (policy.policyType === "trust_rule") { @@ -2161,6 +2182,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr return { decide, + hasProfileAccess, writeAudit, recordInvocation, summarizeAndRedact, diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 5c62a0e4a9f..adc802e7ce1 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -16,6 +16,7 @@ import { browserUseService } from "./browser-use.js"; import { isBrowserUseConnection } from "./browser-use-client.js"; import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "./cognee-connection.js"; import { HttpError } from "../errors.js"; +import { authorizationService, canActorReadIssuePrivacy, type AuthorizationActor } from "./authorization.js"; import { claimSlackRateLimitRetry } from "./connectors/slack-retry.js"; import { resolveSlackTaskAuthority } from "./connectors/slack-authority.js"; import { SLACK_TOOLS } from "@paperclipai/shared"; @@ -1195,11 +1196,11 @@ export function createToolGatewayService( signal?.throwIfAborted(); // Legacy/plugin sessions may not carry identity fields. Resolve them from // the admitted run, never from parameters. Signed approvals keep their origin. - let identity = session; + let identity = session.originatingAgentId ? { ...session, agentId: session.originatingAgentId } : session; if (session.runId && !approvedAction && !session.identityContextId) { const [run] = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns) .where(and(eq(heartbeatRuns.id, session.runId), eq(heartbeatRuns.companyId, session.companyId))); - identity = await captureSessionIdentity({ ...session, responsibleUserId: run?.responsibleUserId ?? session.responsibleUserId }); + identity = await captureSessionIdentity({ ...identity, responsibleUserId: run?.responsibleUserId ?? session.responsibleUserId }); } return injectionPrevention.classify({ companyId: identity.companyId, source: "gateway", actorType: identity.actorType === "user" ? "user" : identity.agentId ? "agent" : "system", @@ -3827,7 +3828,7 @@ export function createToolGatewayService( try { return (await resolveConnectionGrantSecret(db, connection, grant, ref, { consumerType: "tool_connection", consumerId: connection.id, - actorType: "system", actorId: session.agentId, + actorType: "system", actorId: session.originatingAgentId ?? session.agentId, responsibleUserId: grant.subjectUserId, issueId: session.issueId, heartbeatRunId: session.runId, })).value; } catch (error) { @@ -7425,6 +7426,41 @@ export function createToolGatewayService( ); } + function delegatedTestCallerSession(session: ToolGatewaySession): ToolGatewaySession | null { + const callerId = session.originatingAgentId; + return callerId && callerId !== session.agentId ? { ...session, agentId: callerId } : null; + } + + async function assertDelegatedTestCredentialAccess(session: ToolGatewaySession, tool: ToolGatewayDescriptor) { + const caller = delegatedTestCallerSession(session); + if (!caller || !tool.connectionId) return; + if (!(await policyService.hasProfileAccess(policyInputForTool({ session: caller, tool })))) { + throw new ToolGatewayHttpError(403, "Connection is not available in the acting agent's profiles", "test_caller_connection_unavailable"); + } + const [connection] = await db.select().from(toolConnections).where(and( + eq(toolConnections.companyId, session.companyId), eq(toolConnections.id, tool.connectionId), + )); + if (!connection) throw new ToolGatewayHttpError(404, "Tool connection not found", "connection_not_found"); + if (connection.authKind === "none") return; + // Resolve consent metadata before any vault read. Testing another agent + // cannot select that agent's dedicated credential or a different identity. + const callerGrant = await resolveConnectionGrant(caller, connection, false); + const targetGrant = await resolveConnectionGrant(session, connection, false); + if (callerGrant.id !== targetGrant.id) { + throw new ToolGatewayHttpError(403, "Tests cannot use another agent's credential identity", "test_credential_identity_mismatch"); + } + } + + async function assertCurrentDelegatedTestCaller(session: ToolGatewaySession, tool: ToolGatewayDescriptor, parameters: unknown, invocationId: string) { + const caller = delegatedTestCallerSession(session); + if (!caller) return; + const current = await policyService.decide(policyInputForTool({ session: caller, tool, parameters }), { admittedInvocationId: invocationId }); + if (!current.allowed && !(session.approvedInvocationId && current.decision === "require_approval")) { + throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode); + } + await assertDelegatedTestCredentialAccess(session, tool); + } + /** * Execute an already-authorized test-tab tool call against the connected MCP * server and record the result/error onto the invocation. Shared by @@ -7488,6 +7524,7 @@ export function createToolGatewayService( await args.managementContext?.reauthorize(); if (args.managementContext?.actor.type === "agent") { Object.assign(args.session, args.managementContext.workContext); + await assertCurrentDelegatedTestCaller(args.session, args.tool, args.parameters, args.invocationId); const current = await policyService.decide(policyInputForTool({ session: args.session, tool: args.tool, parameters: args.parameters }), { admittedInvocationId: args.invocationId }); if (!current.allowed && !(args.session.approvedInvocationId && current.decision === "require_approval")) { throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode); @@ -9564,7 +9601,23 @@ export function createToolGatewayService( idempotencyKey: `test-call:${randomUUID()}`, consumeRateLimit: true, }); - const accessDecision = await policyService.decide(decisionInput); + await assertDelegatedTestCredentialAccess(session, tool); + const callerSession = delegatedTestCallerSession(session); + const callerDecisionInput = callerSession ? policyInputForTool({ session: callerSession, tool, parameters: requestedParameters, consumeRateLimit: true }) : null; + const callerDecision = callerDecisionInput ? await policyService.decide(callerDecisionInput) : null; + const targetDecision = await policyService.decide(decisionInput, { sharedRateDecision: callerDecision ?? undefined }); + // Both principals' action controls apply. Either denial wins; either + // Ask first decision creates the ordinary human approval request. + const chosenDecision = callerDecision && !callerDecision.allowed && callerDecision.decision !== "require_approval" ? callerDecision + : !targetDecision.allowed ? targetDecision + : callerDecision?.decision === "require_approval" ? callerDecision : targetDecision; + const accessDecision = { ...chosenDecision, policyExplanation: { + ...chosenDecision.policyExplanation, + consumedRateAdmissions: targetDecision.policyExplanation?.consumedRateAdmissions, + delegatedCallerConsumedRateAdmissions: callerDecision?.policyExplanation?.consumedRateAdmissions, + } }; + if (callerDecisionInput && callerDecision) await policyService.writeAudit(callerDecisionInput, callerDecision); + if (accessDecision.allowed || accessDecision.decision === "require_approval") await assertDelegatedTestCredentialAccess(session, tool); const recorded = await recordToolInvocation( decisionInput, accessDecision, @@ -9772,6 +9825,7 @@ export function createToolGatewayService( companyId: string; connectionId: string; actionRequestId: string; + actor?: AuthorizationActor; }): Promise { const [actionRequest] = await db .select() @@ -9807,6 +9861,23 @@ export function createToolGatewayService( "action_request_not_found", ); } + if (input.actor?.type === "agent" && invocation.actorId !== input.actor.agentId) { + throw new ToolGatewayHttpError(403, "Test belongs to another principal", "test_principal_access_denied"); + } + if (invocation.issueId) { + if (!input.actor) throw new ToolGatewayHttpError(403, "Task access is required", "test_task_access_denied"); + const [issue] = await db.select().from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, invocation.issueId))); + const decision = issue ? await authorizationService(db).decide({ actor: input.actor, action: "issue:read", resource: { + type: "issue", companyId: input.companyId, issueId: issue.id, projectId: issue.projectId, + parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, + } }) : null; + if (!issue || !decision?.allowed || (input.actor.type === "agent" && ( + !(await canActorReadIssuePrivacy(db, input.actor, issue)) || !input.actor.onBehalfOfUserId + || !(await canActorReadIssuePrivacy(db, { type: "board", userId: input.actor.onBehalfOfUserId }, issue)) + ))) { + throw new ToolGatewayHttpError(403, "Task is unavailable to this principal", "test_task_access_denied"); + } + } return buildTestCallStatus(actionRequest, invocation); },