Merge branch 'codex/agent-connection-api-parity' into codex/agent-connection-api-parity-maps

* codex/agent-connection-api-parity:
  Enforce caller authority on delegated connection tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 17:28:02 -05:00
commit f832df022f
4 files changed
+145 -20

No files matched your search

@@ -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 }) });
+1
View File
@@ -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,
+33 -11
View File
@@ -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<ReturnType<typeof effectiveProfiles>>, 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<boolean> {
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<ToolAccessDecision> {
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<ToolAccessDecision> {
// 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,
+75 -4
View File
@@ -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<ToolConnectionTestCallStatus> {
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);
},