mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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:
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 }) });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user