mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip manages agent work and its governed tool calls. > - A human approval can persist before the approving process dispatches the tool. > - Recovery currently scans approved requests on every maintenance tick. > - Completed and deliberately inert approvals do not owe another execution. > - Test-tab approval also needs a shared execution claim before recovery can safely race the foreground path. > - This PR wakes recovery from committed changes and retries only unresolved approvals. > - Idle history becomes quiet while existing approval and execution checks remain with the gateway. ## Linked Issues or Issue Description Refs #17453. This is the fifth slice of the recurring-server sleep migration, stacked on `codex/sleep-04-tool-review-expiry`. **Problem** Approved tool requests are scanned periodically even when no executable work remains. The Test-tab foreground path lacks the request claim needed to admit concurrent recovery safely. **Proposed Solution** Use the existing transaction-aware review notification boundary and shared deadline worker. Keep signed-payload interpretation, current policy and target checks, and provider dispatch in the gateway. Share a claim between Test-tab foreground and recovery, then persist both authoritative outcome rows atomically. **Alternatives Considered** A longer interval still scans idle history. A new durable queue would duplicate existing request/invocation state. Dispatching Test-tab approvals without a claim risks duplicate external calls. ## What Changed - Recheck both principals for delegated Test-tab approvals. Save their rate receipts in the same claim transaction, share only the same bucket, and roll back new charges on denial. - Preserve delegated gateway authorization and both issuer/target rate receipts inside the existing claimed-invocation transaction; retain the durable actor during approved replay. - Expire stale managed connector payloads before consuming approved-tool admission. - Persist current rate-policy charges and their invocation receipts atomically before dispatch, preserving exact approval claim fencing and read-only preflight. - Recover approved requests at startup and after committed review changes; retry only unresolved approvals. - Leave legacy agent approvals without execute-on-approve intent inert. Repair completed Test-tab history without another provider call. - Fence Test-tab execution and outcome settlement with an atomic claim shared by both callers. Revalidate the approved target and current policy before dispatch. - Keep provider calls outside database transactions. Retain quarantine for interrupted execution and unknown outcomes. - Add a concurrent, idempotent partial index for approved-request pagination. Reuse the parent's company-scoped review signals and shared scoped deadline worker. - Remove approved scanning from the aggregate tick. Preserve the public combined sweep and scheduler-disabled startup behavior. - Document producer ownership, quiet states, lifecycle behavior, and remaining recovery duties. ## Verification Latest published head: `23868c1751c100a27f58087f7ce16b93c52d4eba`, targeting master. The review-found delegated gateway pool deadlock is repaired by passing the admission transaction through token and issuer-authority reads. Foreground and recovery each reproduced the deadlock with a one-connection pool; all three constrained-pool cases, including revoked authority, now pass. All 81 HTTP integration cases pass. The historical foreground rate-slot regression also reproduced before its repair; verified preview hashes are accepted only by read-only preflight, while consuming admission still requires the complete identity under the exact claim. Five full suites passed 329 cases; the gateway service then passed all 141 cases after two additional mixed historical hash controls. Production types/build passed before the final normal integration of master `35820cf398` (including independent Grok/Runner changes); integration validation is running. Initial fixture/assertion errors and the prior E2E restoration/cancellation deadlock remain recorded in [the owning audit](doc/idle-approved-tool-recovery.md#gateway-admission-uses-its-transaction-connection). Fresh-head CI, Greptile 5/5, and passing Ponytail are still required before merge. - Master landing reconciliation at `8dfe3958db6e4e8a0b7499f3f5be0a396ca77361` after #17453 merged: production and server test files are byte-identical to the accepted 320-case repair. The landed migration journal prefix is unchanged, and #17928's independent eval reporting changes are preserved. Fresh CI and reviews on this exact head are required before merge. - Historical-identity repair published at `096b7c7198c3ca864aca9377e261cbaa0a8aad6f` (production/test source identical to integrated acceptance `81231824d773a79fc50c835eccf7d9b3977e79fc`): all 320 accepted gateway, policy, startup, preparation-timing and authenticated HTTP management cases pass. Server types and the production server build pass. Four real historical sensitive-argument regressions failed before repair; invalid-signature, foreign-hash and stale-claim controls remain denied without dispatch or identity changes. Both agent and Test-tab calls deliver the original signed arguments once, and new rate policies still apply. - The first integrated combined run passed 319 cases and hit a Slack bot ownership collision left in a reused HTTP test database. The guard correctly returned 409. The full 78-case HTTP suite passed on a fresh dedicated fixture without product or assertion changes. Earlier fixture-construction errors are distinguished from the four real regressions in the audit document. Fresh remote CI and reviews remain required. - Parent reconciliation at `736e7f56097055e21ce13b4e45d9033391483af8`: 299 cases pass (221 full gateway/policy/startup cases and 78 assembled-server connection management cases), with no skipped cases. Server types pass. This includes real delegated Test-tab approval admission for newly added shared and caller rate rules. Fresh remote CI and Greptile review remain required. Approved delegated Test-tab calls now evaluate caller and target policies in the existing claim transaction. Real HTTP regressions first showed a missing caller charge and shared-limit self-rejection. All 78 connection-management cases and 92 gateway cases now pass on the owning branch, along with server production typechecking. All 78 connection-management cases also pass at combined tip `d25f830db89c1623bff3547bfc68015c1c66e0cf`. The three new cases cover shared charges, separate charges, rollback on denial, duplicate approval, and rejection of a later authorized call. Intermediate counter/identity fixture failures and a reused-database Slack collision are recorded in [the owning audit](https://github.com/paperclipai/paperclip/blob/bcca5b006976a471f4afd1f70c4031aa40a2b3f7/doc/idle-approved-tool-recovery.md#delegated-test-tab-admission-after-policy-changes). The earlier combined checks below remain evidence for their stated revisions. Current-head CI and review remain required. Current head: `bcca5b006976a471f4afd1f70c4031aa40a2b3f7`. This remains a single-duty PR stacked on #17453. - Integrated pinned master `8548173fc9efdc0fa07644fb92dcc0b6851a95e5` with normal merges. Every branch passed generated-schema checks and its target-branch migration-order gate. Existing SQL is unchanged; the 25 stack migrations follow master’s new migration. - Duty-specific producer coverage, lifecycle tests, and earlier failure records: [2026-10-24-recurring-server-sleep-stack.md](https://github.com/paperclipai/paperclip/blob/a021467f04d22be42ee5b0c87b6583b2300a62a9/doc/plans/2026-10-24-recurring-server-sleep-stack.md). - Combined tip `51e6cbe67979b55b340ea9f0f434c5b5c682ccd6`: **355 startup/cancellation/recovery/dispatch/preservation tests**, **75 connection-management tests**, and production server typechecking pass. These combined checks do not mean every intermediate PR independently ran the full repository suite. - Real master-to-stack database upgrade passes: **379 unchanged prior migration receipts plus 25 new migrations**, with the gateway responsibility fixture preserved. - Owning-branch checks: 84 runner installation/settlement cases plus runner typechecking and generated TypeScript/Rust profile agreement; 253 GitHub review workflow cases. Stop-attribution and held-worker readiness regressions failed before their fixes. - Failure accounting: the initial combined run passed 349 cases and failed six idle assertions in a reused preservation database containing old cancelled runs. Repeating the combined command with a fresh preservation database passed. Earlier invalid project selection, merge import/type errors, and the interrupted full local suite are not counted as passing checks. - **Latest-head CI and review remain required.** Prior CI findings include GitHub idle observation, route teardown, attention cleanup, touch-picker E2E, workspace health, and voice-cleanup timing; local passes do not erase those failures. No full local repository-suite, deployed sleep, fleet cost, or live Copilot qualification claim is made. ## Risks - Notifications cover the existing database-owner, single-active-writer contract. Independent SQL writers and other replicas need an explicit wake path. - Unknown provider outcomes cannot be replayed safely. Failed result persistence retains an executing claim and the existing stale-execution quarantine. - Interrupted execution and terminal interaction projection repair remain periodic until their separate slices. This PR changes only approved recovery. - A pending-only review write can trigger one empty approved-recovery pass. Settled idle work schedules no further scan. - The partial index uses the existing top-level concurrent migration executor. Snapshot pruning follows repository policy. - Cloud sleep eligibility and external alarm coverage remain unchanged. Worker quiet is not proof of deployed instance suspension. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and code execution. The exact serving model identifier and context window were not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1874 lines
81 KiB
TypeScript
1874 lines
81 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
companies,
|
|
companySecrets,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issues,
|
|
principalPermissionGrants,
|
|
projects,
|
|
toolAccessAuditEvents,
|
|
toolActionRequests,
|
|
toolApplications,
|
|
toolCatalogEntries,
|
|
toolCallEvents,
|
|
toolConnections,
|
|
toolInvocations,
|
|
toolMcpGateways,
|
|
toolPolicies,
|
|
toolProfileBindings,
|
|
toolProfileEntries,
|
|
toolProfiles,
|
|
toolRateLimitCounters,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js";
|
|
import { canonicalToolArguments, signToolArguments } from "../services/tool-content-guards.js";
|
|
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TOOL_POLICY_TEST_DATABASE_URL");
|
|
const embeddedPostgresSupport = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
function legacySideEffectKey(invocation: typeof toolInvocations.$inferSelect, argumentsHash: string) {
|
|
const scope = { companyId: invocation.companyId, runId: invocation.runId, issueId: invocation.issueId,
|
|
applicationId: invocation.applicationId, connectionId: invocation.connectionId,
|
|
catalogEntryId: invocation.catalogEntryId, toolName: invocation.toolName, argumentsHash };
|
|
return `side_effect:${createHash("sha256").update(JSON.stringify(Object.fromEntries(
|
|
Object.entries(scope).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0),
|
|
))).digest("hex")}`;
|
|
}
|
|
|
|
async function createCompany(db: ReturnType<typeof createDb>) {
|
|
return db.insert(companies).values({
|
|
name: `Tool Access ${randomUUID()}`,
|
|
issuePrefix: `TA${randomUUID().slice(0, 6).toUpperCase()}`,
|
|
}).returning().then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createAgent(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
permissions: Record<string, unknown> = {},
|
|
) {
|
|
return db.insert(agents).values({
|
|
companyId,
|
|
name: `Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions,
|
|
}).returning().then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createRun(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
agentId: string,
|
|
contextSnapshot: Record<string, unknown> = {},
|
|
) {
|
|
return db.insert(heartbeatRuns).values({
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
contextSnapshot,
|
|
}).returning().then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createIssue(db: ReturnType<typeof createDb>, companyId: string, title = "Tool issue") {
|
|
return db.insert(issues).values({
|
|
companyId,
|
|
title: `${title} ${randomUUID()}`,
|
|
status: "in_progress",
|
|
}).returning().then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createTool(db: ReturnType<typeof createDb>, companyId: string) {
|
|
const application = await db.insert(toolApplications).values({
|
|
companyId,
|
|
applicationKey: `fixture-${randomUUID()}`,
|
|
name: `Fixture ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const connection = await db.insert(toolConnections).values({
|
|
companyId,
|
|
applicationId: application.id,
|
|
name: `Connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://example.invalid/mcp" },
|
|
}).returning().then((rows) => rows[0]!);
|
|
const catalogEntry = await db.insert(toolCatalogEntries).values({
|
|
companyId,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "send_email",
|
|
toolName: "send_email",
|
|
riskLevel: "write",
|
|
versionHash: randomUUID(),
|
|
schemaHash: randomUUID(),
|
|
}).returning().then((rows) => rows[0]!);
|
|
return { application, connection, catalogEntry };
|
|
}
|
|
|
|
async function createApprovedToolAction(input: {
|
|
db: ReturnType<typeof createDb>;
|
|
companyId: string;
|
|
agentId: string;
|
|
connectionId: string;
|
|
catalogEntryId: string;
|
|
issueId?: string | null;
|
|
argumentsValue: Record<string, unknown>;
|
|
status?: "approved" | "executed";
|
|
}) {
|
|
const svc = toolAccessPolicyService(input.db);
|
|
const decisionInput = {
|
|
companyId: input.companyId,
|
|
actor: { actorType: "agent" as const, actorId: input.agentId, agentId: input.agentId },
|
|
runContext: { issueId: input.issueId ?? null },
|
|
request: {
|
|
connectionId: input.connectionId,
|
|
catalogEntryId: input.catalogEntryId,
|
|
toolName: "send_email",
|
|
arguments: input.argumentsValue,
|
|
},
|
|
};
|
|
const decision = await svc.decide(decisionInput);
|
|
const recorded = await svc.recordInvocation(decisionInput, decision);
|
|
if (!recorded.actionRequest) throw new Error("Expected approval-required action request");
|
|
const [updated] = await input.db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: input.status ?? "approved",
|
|
resolvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolActionRequests.id, recorded.actionRequest.id))
|
|
.returning();
|
|
return { decisionInput, invocation: recorded.invocation, actionRequest: updated };
|
|
}
|
|
|
|
type IdRef = { id: string };
|
|
|
|
function sendEmailGrantRow(company: IdRef, agent: IdRef): typeof principalPermissionGrants.$inferInsert {
|
|
return {
|
|
companyId: company.id,
|
|
principalType: "agent",
|
|
principalId: agent.id,
|
|
permissionKey: "tools:use",
|
|
scope: { toolName: "send_email" },
|
|
};
|
|
}
|
|
|
|
function agentProfileBindingRow(company: IdRef, profile: IdRef, agent: IdRef): typeof toolProfileBindings.$inferInsert {
|
|
return {
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
targetType: "agent",
|
|
targetId: agent.id,
|
|
};
|
|
}
|
|
|
|
function sendEmailDecisionInput(company: IdRef, agent: IdRef, connection: IdRef, catalogEntry: IdRef): Parameters<ReturnType<typeof toolAccessPolicyService>["decide"]>[0] {
|
|
return {
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
|
};
|
|
}
|
|
|
|
function reviewSendPolicyRow(company: IdRef, name: string): typeof toolPolicies.$inferInsert {
|
|
return {
|
|
companyId: company.id,
|
|
name: name,
|
|
policyType: "require_approval",
|
|
selectors: { toolName: "send_email" },
|
|
};
|
|
}
|
|
|
|
function executedActionInput(db: ReturnType<typeof createDb>, company: IdRef, agent: IdRef, connection: IdRef, catalogEntry: IdRef, args: Record<string, unknown>): Parameters<typeof createApprovedToolAction>[0] {
|
|
return {
|
|
db,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
argumentsValue: args,
|
|
status: "executed",
|
|
};
|
|
}
|
|
|
|
describeEmbeddedPostgres("tool access policy service", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
if (externalDatabaseUrl) db = createDb(externalDatabaseUrl);
|
|
else {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-access-policy-");
|
|
db = createDb(tempDb.connectionString);
|
|
}
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(toolRateLimitCounters);
|
|
await db.delete(toolActionRequests);
|
|
await db.delete(toolInvocations);
|
|
await db.delete(toolCallEvents);
|
|
await db.delete(toolAccessAuditEvents);
|
|
await db.delete(toolPolicies);
|
|
await db.delete(toolProfileEntries);
|
|
await db.delete(toolProfileBindings);
|
|
await db.delete(toolMcpGateways);
|
|
await db.delete(toolProfiles);
|
|
await db.delete(toolCatalogEntries);
|
|
await db.delete(toolConnections);
|
|
await db.delete(toolApplications);
|
|
await db.delete(companySecrets);
|
|
await db.delete(principalPermissionGrants);
|
|
await db.delete(issues);
|
|
await db.delete(projects);
|
|
await db.delete(activityLog);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 1 });
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("denies direct execution without an effective profile or grant", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
|
|
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_default",
|
|
});
|
|
});
|
|
|
|
it("allows calls through an effective agent profile", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const profile = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `profile-${randomUUID()}`,
|
|
name: "Write tools",
|
|
defaultAction: "deny",
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
});
|
|
|
|
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: true,
|
|
decision: "allow",
|
|
reasonCode: "allow_profile",
|
|
effectiveProfileIds: [profile.id],
|
|
});
|
|
});
|
|
|
|
it("uses issue-scoped profiles ahead of company defaults", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const issue = await createIssue(db, company.id, "Scoped deny");
|
|
|
|
const [companyProfile, issueProfile] = await db.insert(toolProfiles).values([
|
|
{
|
|
companyId: company.id,
|
|
profileKey: `company-allow-${randomUUID()}`,
|
|
name: "Company allow",
|
|
defaultAction: "deny",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
profileKey: `issue-deny-${randomUUID()}`,
|
|
name: "Issue deny",
|
|
defaultAction: "deny",
|
|
},
|
|
]).returning();
|
|
await db.insert(toolProfileBindings).values([
|
|
{
|
|
companyId: company.id,
|
|
profileId: companyProfile!.id,
|
|
targetType: "company",
|
|
targetId: company.id,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
profileId: issueProfile!.id,
|
|
targetType: "issue",
|
|
targetId: issue.id,
|
|
},
|
|
]);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: companyProfile!.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
});
|
|
|
|
const result = await toolAccessPolicyService(db).decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
runContext: { issueId: issue.id },
|
|
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_default",
|
|
effectiveProfileIds: [issueProfile!.id],
|
|
});
|
|
});
|
|
|
|
it("uses agent-scoped profiles ahead of project defaults", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const project = await db.insert(projects).values({
|
|
companyId: company.id,
|
|
name: `Project ${randomUUID()}`,
|
|
}).returning().then((rows) => rows[0]!);
|
|
|
|
const [projectProfile, agentProfile] = await db.insert(toolProfiles).values([
|
|
{
|
|
companyId: company.id,
|
|
profileKey: `project-allow-${randomUUID()}`,
|
|
name: "Project allow",
|
|
defaultAction: "deny",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
profileKey: `agent-deny-${randomUUID()}`,
|
|
name: "Agent deny",
|
|
defaultAction: "deny",
|
|
},
|
|
]).returning();
|
|
await db.insert(toolProfileBindings).values([
|
|
{
|
|
companyId: company.id,
|
|
profileId: projectProfile!.id,
|
|
targetType: "project",
|
|
targetId: project.id,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
profileId: agentProfile!.id,
|
|
targetType: "agent",
|
|
targetId: agent.id,
|
|
},
|
|
]);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: projectProfile!.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
});
|
|
|
|
const result = await toolAccessPolicyService(db).decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
runContext: { projectId: project.id },
|
|
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_default",
|
|
effectiveProfileIds: [agentProfile!.id],
|
|
});
|
|
});
|
|
|
|
it("uses issue-scoped allows ahead of broader company denies", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const issue = await createIssue(db, company.id, "Scoped allow");
|
|
|
|
const [companyProfile, issueProfile] = await db.insert(toolProfiles).values([
|
|
{
|
|
companyId: company.id,
|
|
profileKey: `company-deny-${randomUUID()}`,
|
|
name: "Company deny",
|
|
defaultAction: "deny",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
profileKey: `issue-allow-${randomUUID()}`,
|
|
name: "Issue allow",
|
|
defaultAction: "deny",
|
|
},
|
|
]).returning();
|
|
await db.insert(toolProfileBindings).values([
|
|
{
|
|
companyId: company.id,
|
|
profileId: companyProfile!.id,
|
|
targetType: "company",
|
|
targetId: company.id,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
profileId: issueProfile!.id,
|
|
targetType: "issue",
|
|
targetId: issue.id,
|
|
},
|
|
]);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: issueProfile!.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
});
|
|
|
|
const result = await toolAccessPolicyService(db).decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
runContext: { issueId: issue.id },
|
|
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: true,
|
|
decision: "allow",
|
|
reasonCode: "allow_profile",
|
|
effectiveProfileIds: [issueProfile!.id],
|
|
});
|
|
});
|
|
|
|
it("denies calls through draft and archived profiles", async () => {
|
|
const company = await createCompany(db);
|
|
const draftAgent = await createAgent(db, company.id);
|
|
const archivedAgent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const [draftProfile, archivedProfile] = await db.insert(toolProfiles).values([
|
|
{
|
|
companyId: company.id,
|
|
profileKey: `draft-profile-${randomUUID()}`,
|
|
name: "Draft write tools",
|
|
status: "draft",
|
|
defaultAction: "allow",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
profileKey: `archived-profile-${randomUUID()}`,
|
|
name: "Archived write tools",
|
|
status: "archived",
|
|
defaultAction: "allow",
|
|
},
|
|
]).returning();
|
|
await db.insert(toolProfileBindings).values([
|
|
{
|
|
companyId: company.id,
|
|
profileId: draftProfile!.id,
|
|
targetType: "agent",
|
|
targetId: draftAgent.id,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
profileId: archivedProfile!.id,
|
|
targetType: "agent",
|
|
targetId: archivedAgent.id,
|
|
},
|
|
]);
|
|
await db.insert(toolProfileEntries).values([
|
|
{
|
|
companyId: company.id,
|
|
profileId: draftProfile!.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
profileId: archivedProfile!.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
},
|
|
]);
|
|
|
|
for (const agent of [draftAgent, archivedAgent]) {
|
|
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_default",
|
|
effectiveProfileIds: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
it("denies calls through disabled applications before explicit grants and allows them after reactivation", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { application, connection, catalogEntry } = await createTool(db, company.id);
|
|
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
|
const input = {
|
|
companyId: company.id,
|
|
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
|
};
|
|
|
|
await expect(toolAccessPolicyService(db).decide(input)).resolves.toMatchObject({
|
|
allowed: true,
|
|
decision: "allow",
|
|
reasonCode: "allow_explicit_grant",
|
|
});
|
|
|
|
await db
|
|
.update(toolApplications)
|
|
.set({ status: "disabled", updatedAt: new Date() })
|
|
.where(eq(toolApplications.id, application.id));
|
|
await expect(toolAccessPolicyService(db).decide(input)).resolves.toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_disabled_application",
|
|
explanation: "Application is disabled.",
|
|
});
|
|
|
|
await db
|
|
.update(toolApplications)
|
|
.set({ status: "active", updatedAt: new Date() })
|
|
.where(eq(toolApplications.id, application.id));
|
|
await expect(toolAccessPolicyService(db).decide(input)).resolves.toMatchObject({
|
|
allowed: true,
|
|
decision: "allow",
|
|
reasonCode: "allow_explicit_grant",
|
|
});
|
|
});
|
|
|
|
it("manages generic tool policies without exposing trust rules", async () => {
|
|
const company = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const svc = toolAccessPolicyService(db);
|
|
const [trustRule] = await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Promoted trust rule",
|
|
policyType: "trust_rule",
|
|
selectors: { toolName: "send_email" },
|
|
config: { trustRule: { hitCount: 0 } },
|
|
}).returning();
|
|
|
|
const created = await svc.createPolicy(company.id, {
|
|
name: "Block destructive senders",
|
|
description: "Block a dangerous tool family.",
|
|
policyType: "block",
|
|
priority: 10,
|
|
enabled: true,
|
|
selectors: { toolNames: ["send_email", "delete_email"] },
|
|
conditions: null,
|
|
config: null,
|
|
}, { userId: "board-user" });
|
|
|
|
expect(created).toMatchObject({
|
|
companyId: company.id,
|
|
name: "Block destructive senders",
|
|
policyType: "block",
|
|
priority: 10,
|
|
createdByUserId: "board-user",
|
|
});
|
|
|
|
await expect(svc.createPolicy(company.id, {
|
|
name: "Generic trust rule",
|
|
description: null,
|
|
policyType: "trust_rule",
|
|
priority: 100,
|
|
enabled: true,
|
|
selectors: {},
|
|
conditions: null,
|
|
config: null,
|
|
})).rejects.toMatchObject({ status: 422 });
|
|
|
|
await expect(svc.updatePolicy({
|
|
companyId: otherCompany.id,
|
|
policyId: created.id,
|
|
body: { enabled: false },
|
|
})).rejects.toMatchObject({ status: 404 });
|
|
|
|
const listed = await svc.listPolicies(company.id);
|
|
expect(listed.map((policy) => policy.id)).toEqual([created.id]);
|
|
|
|
const updated = await svc.updatePolicy({
|
|
companyId: company.id,
|
|
policyId: created.id,
|
|
body: {
|
|
name: "Require review for destructive senders",
|
|
policyType: "require_approval",
|
|
enabled: false,
|
|
selectors: { toolName: "delete_email" },
|
|
},
|
|
});
|
|
expect(updated).toMatchObject({
|
|
id: created.id,
|
|
name: "Require review for destructive senders",
|
|
policyType: "require_approval",
|
|
enabled: false,
|
|
selectors: { toolName: "delete_email" },
|
|
});
|
|
|
|
const deleted = await svc.deletePolicy({ companyId: company.id, policyId: created.id });
|
|
expect(deleted.id).toBe(created.id);
|
|
await expect(svc.deletePolicy({ companyId: company.id, policyId: trustRule.id }))
|
|
.rejects.toMatchObject({ status: 404 });
|
|
expect(await svc.listPolicies(company.id)).toEqual([]);
|
|
const remainingTrustRules = await svc.listTrustRules(company.id);
|
|
expect(remainingTrustRules.map((policy) => policy.id)).toEqual([trustRule.id]);
|
|
});
|
|
|
|
it("treats glob-looking action-name selectors as exact names", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const profile = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `profile-${randomUUID()}`,
|
|
name: "Write tools",
|
|
defaultAction: "allow",
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
|
const wildcardPolicy = await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review wildcard-looking sends",
|
|
policyType: "require_approval",
|
|
priority: 10,
|
|
selectors: { toolName: "*send*" },
|
|
}).returning().then((rows) => rows[0]!);
|
|
|
|
const input = {
|
|
companyId: company.id,
|
|
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
|
};
|
|
|
|
await expect(toolAccessPolicyService(db).decide(input)).resolves.toMatchObject({
|
|
allowed: true,
|
|
decision: "allow",
|
|
reasonCode: "allow_profile",
|
|
effectiveProfileIds: [profile.id],
|
|
matchedPolicyIds: [],
|
|
});
|
|
|
|
const exactPolicy = await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review exact sends",
|
|
policyType: "require_approval",
|
|
priority: 5,
|
|
selectors: { toolName: "send_email" },
|
|
}).returning().then((rows) => rows[0]!);
|
|
|
|
await expect(toolAccessPolicyService(db).decide(input)).resolves.toMatchObject({
|
|
allowed: false,
|
|
decision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
effectiveProfileIds: [profile.id],
|
|
matchedPolicyIds: [exactPolicy.id],
|
|
});
|
|
expect(wildcardPolicy.selectors).toEqual({ toolName: "*send*" });
|
|
});
|
|
|
|
it("matches tool name selectors against connected MCP upstream tool names", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const policy = await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review upstream todo writes",
|
|
policyType: "require_approval",
|
|
priority: 10,
|
|
selectors: { toolNames: ["todo.add"] },
|
|
}).returning().then((rows) => rows[0]!);
|
|
|
|
await expect(toolAccessPolicyService(db).decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
request: {
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
toolName: "mcp.smoke-fixture:todo-add",
|
|
upstreamToolName: "todo.add",
|
|
},
|
|
})).resolves.toMatchObject({
|
|
allowed: false,
|
|
decision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
matchedPolicyIds: [policy.id],
|
|
});
|
|
});
|
|
|
|
it("rejects agent-supplied run context that belongs to another agent", async () => {
|
|
const company = await createCompany(db);
|
|
const actorAgent = await createAgent(db, company.id);
|
|
const otherAgent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
const run = await db.insert(heartbeatRuns).values({
|
|
companyId: company.id,
|
|
agentId: otherAgent.id,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
}).returning().then((rows) => rows[0]!);
|
|
|
|
const result = await toolAccessPolicyService(db).decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: actorAgent.id, agentId: actorAgent.id },
|
|
runContext: { heartbeatRunId: run.id },
|
|
request: { connectionId: connection.id, toolName: "send_email" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
reasonCode: "deny_run_context_mismatch",
|
|
});
|
|
});
|
|
|
|
it("rejects agent-supplied issue context that differs from the stored heartbeat context", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const canonicalIssue = await createIssue(db, company.id, "Canonical");
|
|
const escalatedIssue = await createIssue(db, company.id, "Escalated");
|
|
const { connection } = await createTool(db, company.id);
|
|
const run = await createRun(db, company.id, agent.id, { issueId: canonicalIssue.id });
|
|
|
|
const result = await toolAccessPolicyService(db).decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
runContext: { heartbeatRunId: run.id, issueId: escalatedIssue.id },
|
|
request: { connectionId: connection.id, toolName: "send_email" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
reasonCode: "deny_run_context_mismatch",
|
|
});
|
|
});
|
|
|
|
it("audits denied calls without storing secret argument values", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
const input = {
|
|
companyId: company.id,
|
|
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: {
|
|
connectionId: connection.id,
|
|
toolName: "send_email",
|
|
arguments: { to: "ops@example.com", apiKey: "sk-test-secret-value-123456" },
|
|
},
|
|
};
|
|
|
|
const decision = await toolAccessPolicyService(db).decide(input);
|
|
await toolAccessPolicyService(db).writeAudit(input, decision);
|
|
const [legacyAudit] = await db.select().from(toolAccessAuditEvents);
|
|
const [callEvent] = await db.select().from(toolCallEvents);
|
|
const serialized = JSON.stringify({ legacy: legacyAudit.details, dedicated: callEvent });
|
|
|
|
expect(decision.reasonCode).toBe("deny_default");
|
|
expect(callEvent).toMatchObject({
|
|
eventType: "policy_decision",
|
|
outcome: "denied",
|
|
reasonCode: "deny_default",
|
|
decision: "deny",
|
|
matchedPolicyIds: [],
|
|
requestHash: expect.any(String),
|
|
});
|
|
expect(serialized).not.toContain("sk-test-secret-value");
|
|
expect(serialized).toContain("[REDACTED]");
|
|
});
|
|
|
|
it("records approval-required invocations and action requests with matched policy IDs", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
const policy = await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review writes",
|
|
policyType: "require_approval",
|
|
selectors: { toolName: "send_email" },
|
|
description: "Writes require board review.",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const input = {
|
|
companyId: company.id,
|
|
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: {
|
|
connectionId: connection.id,
|
|
toolName: "send_email",
|
|
arguments: { to: "ops@example.com", body: "ship it" },
|
|
sideEffecting: true,
|
|
},
|
|
};
|
|
|
|
const decision = await toolAccessPolicyService(db).decide(input);
|
|
const recorded = await toolAccessPolicyService(db).recordInvocation(input, decision);
|
|
await toolAccessPolicyService(db).writeAudit(input, decision);
|
|
const [callEvent] = await db.select().from(toolCallEvents);
|
|
|
|
expect(decision).toMatchObject({
|
|
allowed: false,
|
|
decision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
matchedPolicyIds: [policy.id],
|
|
});
|
|
expect(recorded.actionRequest).toMatchObject({
|
|
invocationId: recorded.invocation.id,
|
|
requestedByAgentId: agent.id,
|
|
status: "pending",
|
|
});
|
|
expect(recorded.invocation).toMatchObject({
|
|
approvalState: "pending",
|
|
status: "awaiting_approval",
|
|
matchedPolicyIds: [policy.id],
|
|
});
|
|
expect(callEvent).toMatchObject({
|
|
eventType: "policy_decision",
|
|
outcome: "pending",
|
|
decision: "require_approval",
|
|
matchedPolicyIds: [policy.id],
|
|
requestSummary: expect.objectContaining({ summary: expect.any(String) }),
|
|
});
|
|
});
|
|
|
|
it("replays side-effecting calls with the same idempotency key instead of creating a new invocation", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
|
const input = {
|
|
companyId: company.id,
|
|
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: {
|
|
connectionId: connection.id,
|
|
toolName: "send_email",
|
|
arguments: { to: "ops@example.com" },
|
|
sideEffecting: true,
|
|
idempotencyKey: "send-email-1",
|
|
},
|
|
};
|
|
|
|
const decision = await toolAccessPolicyService(db).decide(input);
|
|
const first = await toolAccessPolicyService(db).recordInvocation(input, decision);
|
|
const replay = await toolAccessPolicyService(db).recordInvocation(input, decision);
|
|
|
|
expect(first.replayed).toBe(false);
|
|
expect(replay.replayed).toBe(true);
|
|
expect(replay.invocation.id).toBe(first.invocation.id);
|
|
});
|
|
|
|
it("derives a canonical idempotency key for side-effecting calls without caller-supplied keys", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
|
const input = {
|
|
companyId: company.id,
|
|
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: {
|
|
connectionId: connection.id,
|
|
toolName: "send_email",
|
|
arguments: { to: "ops@example.com", body: "only once" },
|
|
sideEffecting: true,
|
|
},
|
|
};
|
|
|
|
const decision = await toolAccessPolicyService(db).decide(input);
|
|
const first = await toolAccessPolicyService(db).recordInvocation(input, decision);
|
|
const replay = await toolAccessPolicyService(db).recordInvocation(input, decision);
|
|
|
|
expect(first.replayed).toBe(false);
|
|
expect(first.invocation.idempotencyKey).toMatch(/^side_effect:v2:/);
|
|
expect(replay.replayed).toBe(true);
|
|
expect(replay.invocation.id).toBe(first.invocation.id);
|
|
});
|
|
|
|
it.each([
|
|
[{ password: "old-value" }, { password: "new-value" }],
|
|
[{ body: `${"x".repeat(500)}old` }, { body: `${"x".repeat(500)}new` }],
|
|
[{ items: [...Array.from({ length: 50 }, (_, index) => index), "old"] }, { items: [...Array.from({ length: 50 }, (_, index) => index), "new"] }],
|
|
])("blocks unverifiable legacy default-key writes until an explicit new operation is requested (%#)", async (original, replacement) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
|
const svc = toolAccessPolicyService(db, { toolActionSigningSecret: "legacy-identity-fixture-key" });
|
|
const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: { connectionId: connection.id, toolName: "send_email", arguments: original, sideEffecting: true } };
|
|
const decision = await svc.decide(input);
|
|
const first = await svc.recordInvocation(input, decision);
|
|
const oldHash = first.invocation.argumentsSummary!.sha256!;
|
|
await db.update(toolInvocations).set({ argumentsHash: oldHash,
|
|
idempotencyKey: legacySideEffectKey(first.invocation, oldHash), status: "succeeded" })
|
|
.where(eq(toolInvocations.id, first.invocation.id));
|
|
// A complete new request can equal the old shortened preview verbatim.
|
|
// That must not bypass the legacy verifier through the current-key lookup.
|
|
const previewArguments = JSON.parse(first.invocation.argumentsSummary!.summary);
|
|
for (const args of [original, replacement, previewArguments]) {
|
|
await expect(svc.recordInvocation({ ...input, request: { ...input.request, arguments: args } }, decision))
|
|
.rejects.toMatchObject({ status: 409, details: {
|
|
reasonCode: "legacy_tool_idempotency_unverifiable", invocationId: first.invocation.id,
|
|
} });
|
|
}
|
|
expect(await db.select().from(toolInvocations)).toHaveLength(1);
|
|
const explicit = await svc.recordInvocation({ ...input, request: { ...input.request, arguments: replacement,
|
|
idempotencyKey: "intentional-new-operation" } }, decision);
|
|
expect(explicit.replayed).toBe(false);
|
|
expect(explicit.invocation.id).not.toBe(first.invocation.id);
|
|
});
|
|
|
|
it.each(["preview", "complete"])("replays a legacy default-key result when its %s identity proves an exact match", async (identityKind) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
|
const svc = toolAccessPolicyService(db, { toolActionSigningSecret: "legacy-identity-fixture-key" });
|
|
// Historical sorting was ordinal; the new canonical serializer uses locale order.
|
|
const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: { connectionId: connection.id, toolName: "send_email", arguments: identityKind === "preview"
|
|
? { Z: "last", a: "first" } : { password: "saved-secret" }, sideEffecting: true } };
|
|
const decision = await svc.decide(input);
|
|
const first = await svc.recordInvocation(input, decision);
|
|
expect(first.invocation.argumentsHash).not.toBe(first.invocation.argumentsSummary!.sha256);
|
|
const oldHash = identityKind === "preview" ? first.invocation.argumentsSummary!.sha256! : first.invocation.argumentsHash!;
|
|
const resultSummary = { summary: "saved result" };
|
|
await db.update(toolInvocations).set({ argumentsHash: oldHash, resultSummary,
|
|
idempotencyKey: legacySideEffectKey(first.invocation, oldHash), status: "succeeded" })
|
|
.where(eq(toolInvocations.id, first.invocation.id));
|
|
expect(await svc.recordInvocation(input, decision)).toMatchObject({ replayed: true,
|
|
invocation: { id: first.invocation.id, resultSummary } });
|
|
expect(await db.select().from(toolInvocations)).toHaveLength(1);
|
|
});
|
|
|
|
it("uses authenticated legacy arguments to distinguish an exact retry from a new secret operation", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
|
const signingSecret = "legacy-identity-fixture-key";
|
|
const svc = toolAccessPolicyService(db, { toolActionSigningSecret: signingSecret });
|
|
const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: { connectionId: connection.id, toolName: "send_email", arguments: { password: "old-value" }, sideEffecting: true } };
|
|
const decision = await svc.decide(input);
|
|
const first = await svc.recordInvocation(input, decision);
|
|
const oldHash = first.invocation.argumentsSummary!.sha256!;
|
|
await db.update(toolInvocations).set({ argumentsHash: oldHash,
|
|
idempotencyKey: legacySideEffectKey(first.invocation, oldHash), status: "succeeded" })
|
|
.where(eq(toolInvocations.id, first.invocation.id));
|
|
await db.insert(toolActionRequests).values({ companyId: company.id, invocationId: first.invocation.id,
|
|
status: "executed", canonicalArgumentsHash: oldHash, canonicalArgumentsSummary: first.invocation.argumentsSummary!, signedArguments: signToolArguments({
|
|
invocationId: first.invocation.id, toolName: "send_email", canonicalArguments: canonicalToolArguments(input.request.arguments), signingSecret,
|
|
}) });
|
|
expect(await svc.recordInvocation(input, decision)).toMatchObject({ replayed: true, invocation: { id: first.invocation.id } });
|
|
const replacement = await svc.recordInvocation({ ...input, request: { ...input.request, arguments: { password: "new-value" } } }, decision);
|
|
expect(replacement.replayed).toBe(false);
|
|
expect(await db.select().from(toolInvocations)).toHaveLength(2);
|
|
});
|
|
|
|
it("separates no-run default keys by actor and never exposes another actor's legacy result", async () => {
|
|
const company = await createCompany(db);
|
|
const firstAgent = await createAgent(db, company.id);
|
|
const secondAgent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
await db.insert(principalPermissionGrants).values([sendEmailGrantRow(company, firstAgent), sendEmailGrantRow(company, secondAgent)]);
|
|
const svc = toolAccessPolicyService(db);
|
|
const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: firstAgent.id, agentId: firstAgent.id },
|
|
request: { connectionId: connection.id, toolName: "send_email", arguments: { body: "same request" }, sideEffecting: true } };
|
|
const secondInput = { ...input, actor: { actorType: "agent" as const, actorId: secondAgent.id, agentId: secondAgent.id } };
|
|
const first = await svc.recordInvocation(input, await svc.decide(input));
|
|
const second = await svc.recordInvocation(secondInput, await svc.decide(secondInput));
|
|
expect(second.replayed).toBe(false);
|
|
expect(second.invocation.idempotencyKey).not.toBe(first.invocation.idempotencyKey);
|
|
const oldHash = first.invocation.argumentsSummary!.sha256!;
|
|
await db.update(toolInvocations).set({ argumentsHash: oldHash, resultSummary: { summary: "first actor's result" },
|
|
idempotencyKey: legacySideEffectKey(first.invocation, oldHash), status: "succeeded" })
|
|
.where(eq(toolInvocations.id, first.invocation.id));
|
|
await db.delete(toolInvocations).where(eq(toolInvocations.id, second.invocation.id));
|
|
const error = await svc.recordInvocation(secondInput, await svc.decide(secondInput)).then(() => null, failure => failure);
|
|
expect(error).toMatchObject({ status: 409 });
|
|
expect(error.details).toEqual({ reasonCode: "legacy_tool_idempotency_unverifiable" });
|
|
expect(JSON.stringify(error)).not.toContain(first.invocation.id);
|
|
expect(await db.select().from(toolInvocations)).toHaveLength(1);
|
|
});
|
|
|
|
it("enforces rate-limit policies before explicit grants", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "One send per minute",
|
|
policyType: "rate_limit",
|
|
selectors: { toolName: "send_email" },
|
|
config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] },
|
|
});
|
|
const input = {
|
|
companyId: company.id,
|
|
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: { connectionId: connection.id, toolName: "send_email" },
|
|
consumeRateLimit: true,
|
|
};
|
|
|
|
const first = await toolAccessPolicyService(db).decide(input);
|
|
const second = await toolAccessPolicyService(db).decide(input);
|
|
|
|
expect(first.allowed).toBe(true);
|
|
expect(second).toMatchObject({
|
|
allowed: false,
|
|
decision: "rate_limited",
|
|
reasonCode: "rate_limited",
|
|
});
|
|
});
|
|
|
|
it("atomically consumes the final rate-limit slot", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createTool(db, company.id);
|
|
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "One concurrent send per minute",
|
|
policyType: "rate_limit",
|
|
selectors: { toolName: "send_email" },
|
|
config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] },
|
|
});
|
|
const input = {
|
|
companyId: company.id,
|
|
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
|
request: { connectionId: connection.id, toolName: "send_email" },
|
|
consumeRateLimit: true,
|
|
};
|
|
|
|
const decisions = await Promise.all([
|
|
toolAccessPolicyService(db).decide(input),
|
|
toolAccessPolicyService(db).decide(input),
|
|
]);
|
|
|
|
expect(decisions.filter((decision) => decision.allowed)).toHaveLength(1);
|
|
expect(decisions.filter((decision) => decision.reasonCode === "rate_limited")).toHaveLength(1);
|
|
});
|
|
|
|
async function admittedRateLimitedInvocation(limit = 1) {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const issue = await createIssue(db, company.id);
|
|
const run = await createRun(db, company.id, agent.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const [profile] = await db.insert(toolProfiles).values({ companyId: company.id,
|
|
profileKey: "admitted-gateway", name: "Admitted gateway", defaultAction: "deny" }).returning();
|
|
const [gateway] = await db.insert(toolMcpGateways).values({ companyId: company.id,
|
|
name: "Admitted gateway", slug: "admitted-gateway", profileId: profile.id }).returning();
|
|
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
|
const [ratePolicy] = await db.insert(toolPolicies).values({ companyId: company.id, name: "One admitted send",
|
|
policyType: "rate_limit", selectors: { toolName: "send_email" }, priority: 1,
|
|
config: { limit, windowSeconds: 3600, keyBy: ["agent", "tool"] } }).returning();
|
|
const input = { ...sendEmailDecisionInput(company, agent, connection, catalogEntry),
|
|
runContext: { heartbeatRunId: run.id, issueId: issue.id, gatewayId: gateway.id }, consumeRateLimit: true,
|
|
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email", arguments: { to: "recipient@example.test" } } };
|
|
const svc = toolAccessPolicyService(db);
|
|
const initial = await svc.decide(input);
|
|
expect(initial.allowed).toBe(true);
|
|
const { invocation } = await svc.recordInvocation(input, initial);
|
|
expect(invocation.gatewayId).toBe(gateway.id);
|
|
await db.update(toolInvocations).set({ status: "executing" }).where(eq(toolInvocations.id, invocation.id));
|
|
return { company, agent, input: { ...input, consumeRateLimit: false }, invocation, ratePolicy, svc };
|
|
}
|
|
|
|
it.each(["allow", "approved"] as const)("retains the consumed final rate-limit slot for the same executing %s invocation", async authorization => {
|
|
const { input, invocation, ratePolicy, svc } = await admittedRateLimitedInvocation();
|
|
if (authorization === "approved") await db.update(toolInvocations).set({ policyDecision: "require_approval", approvalState: "approved" })
|
|
.where(eq(toolInvocations.id, invocation.id));
|
|
expect(await svc.decide(input)).toMatchObject({ decision: "rate_limited" });
|
|
expect(invocation.policyExplanation?.consumedRateAdmissions).toEqual([
|
|
{ policyId: ratePolicy.id, versionHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
bucketKey: `${ratePolicy.id}:agent:${input.actor.agentId}|tool:send_email` },
|
|
]);
|
|
const counters = await db.select().from(toolRateLimitCounters);
|
|
expect(await svc.decide(input, { admittedInvocationId: invocation.id })).toMatchObject({ allowed: true });
|
|
expect(await svc.decide(input, { admittedInvocationId: invocation.id })).toMatchObject({ allowed: true });
|
|
expect(await db.select().from(toolRateLimitCounters)).toEqual(counters);
|
|
expect((await db.select().from(toolRateLimitCounters))[0].remaining).toBe(0);
|
|
// A fresh invocation cannot borrow another invocation's already-spent slot.
|
|
// Execution revalidation retains its own durable admission.
|
|
expect(await svc.decide(input, { admittedInvocationId: randomUUID() })).toMatchObject({ decision: "rate_limited" });
|
|
expect(await svc.admitInvocation(input, invocation.id)).toMatchObject({ allowed: true });
|
|
expect(await db.select().from(toolRateLimitCounters)).toEqual(counters);
|
|
});
|
|
|
|
it("atomically charges a new rule once across concurrent retries of the same invocation", async () => {
|
|
const { company, input, invocation, svc } = await admittedRateLimitedInvocation();
|
|
const added = await svc.createPolicy(company.id, { name: "New admission", policyType: "rate_limit", priority: 0,
|
|
selectors: { toolName: "send_email" }, config: { limit: 1, windowSeconds: 3600, keyBy: ["agent", "tool"] } });
|
|
const oldCounters = await db.select().from(toolRateLimitCounters);
|
|
expect(await svc.decide(input, { admittedInvocationId: invocation.id })).toMatchObject({ allowed: true });
|
|
expect(await db.select().from(toolRateLimitCounters)).toEqual(oldCounters);
|
|
const results = await Promise.all(Array.from({ length: 4 }, () => svc.admitInvocation(input, invocation.id)));
|
|
expect(results.every(result => result.allowed)).toBe(true);
|
|
expect(await db.select().from(toolRateLimitCounters).where(eq(toolRateLimitCounters.policyId, added.id)))
|
|
.toMatchObject([{ limit: 1, remaining: 0 }]);
|
|
const [saved] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, invocation.id));
|
|
expect(saved.policyExplanation?.consumedRateAdmissions).toHaveLength(2);
|
|
const { consumedRateAdmissions: oldAdmissions, ...oldExplanation } = invocation.policyExplanation!;
|
|
expect(saved.policyExplanation).toMatchObject(oldExplanation);
|
|
expect(saved.policyExplanation?.consumedRateAdmissions).toEqual(expect.arrayContaining(oldAdmissions as unknown[]));
|
|
});
|
|
|
|
it("allows only one of two admitted invocations to spend a newly added final slot", async () => {
|
|
const { company, input, invocation, svc } = await admittedRateLimitedInvocation(2);
|
|
const initial = await svc.decide({ ...input, consumeRateLimit: true });
|
|
expect(initial.allowed).toBe(true);
|
|
const { invocation: second } = await svc.recordInvocation(input, initial);
|
|
await db.update(toolInvocations).set({ status: "executing" }).where(eq(toolInvocations.id, second.id));
|
|
const added = await svc.createPolicy(company.id, { name: "One competing slot", policyType: "rate_limit", priority: 0,
|
|
selectors: { toolName: "send_email" }, config: { limit: 1, windowSeconds: 3600, keyBy: ["agent", "tool"] } });
|
|
const results = await Promise.all([invocation.id, second.id].map(id => svc.admitInvocation(input, id)));
|
|
expect(results.filter(result => result.allowed)).toHaveLength(1);
|
|
expect(results.filter(result => result.decision === "rate_limited")).toHaveLength(1);
|
|
expect(await db.select().from(toolRateLimitCounters).where(eq(toolRateLimitCounters.policyId, added.id)))
|
|
.toMatchObject([{ limit: 1, remaining: 0 }]);
|
|
});
|
|
|
|
it("rolls back new rate charges when a later policy rejects the invocation", async () => {
|
|
const { company, input, invocation, svc } = await admittedRateLimitedInvocation();
|
|
await svc.createPolicy(company.id, { name: "Before rejection", policyType: "rate_limit", priority: 0,
|
|
selectors: { toolName: "send_email" }, config: { limit: 1, windowSeconds: 3600, keyBy: ["agent", "tool"] } });
|
|
await svc.createPolicy(company.id, { name: "Later rejection", policyType: "block", priority: 10,
|
|
selectors: { toolName: "send_email" } });
|
|
const counters = await db.select().from(toolRateLimitCounters);
|
|
expect(await svc.admitInvocation(input, invocation.id)).toMatchObject({ decision: "deny" });
|
|
expect(await db.select().from(toolRateLimitCounters)).toEqual(counters);
|
|
expect((await db.select().from(toolInvocations).where(eq(toolInvocations.id, invocation.id)))[0].policyExplanation)
|
|
.toEqual(invocation.policyExplanation);
|
|
});
|
|
|
|
it("rolls back rate charges when saving the admission receipt fails and admits a retry once", async () => {
|
|
const { company, input, invocation, svc } = await admittedRateLimitedInvocation();
|
|
const added = await svc.createPolicy(company.id, { name: "Receipt failure", policyType: "rate_limit", priority: 0,
|
|
selectors: { toolName: "send_email" }, config: { limit: 1, windowSeconds: 3600, keyBy: ["agent", "tool"] } });
|
|
await db.execute(sql`create function sleep_admission_fail() returns trigger language plpgsql as $$ begin
|
|
raise exception 'receipt write unavailable'; end $$`);
|
|
await db.execute(sql`create trigger sleep_admission_fail before update of policy_explanation on tool_invocations
|
|
for each row execute function sleep_admission_fail()`);
|
|
try {
|
|
await expect(svc.admitInvocation(input, invocation.id)).rejects.toThrow();
|
|
expect(await db.select().from(toolRateLimitCounters).where(eq(toolRateLimitCounters.policyId, added.id))).toEqual([]);
|
|
expect((await db.select().from(toolInvocations).where(eq(toolInvocations.id, invocation.id)))[0].policyExplanation)
|
|
.toEqual(invocation.policyExplanation);
|
|
} finally {
|
|
await db.execute(sql`drop trigger sleep_admission_fail on tool_invocations`);
|
|
await db.execute(sql`drop function sleep_admission_fail()`);
|
|
}
|
|
expect(await svc.admitInvocation(input, invocation.id)).toMatchObject({ allowed: true });
|
|
expect(await svc.admitInvocation(input, invocation.id)).toMatchObject({ allowed: true });
|
|
expect(await db.select().from(toolRateLimitCounters).where(eq(toolRateLimitCounters.policyId, added.id)))
|
|
.toMatchObject([{ limit: 1, remaining: 0 }]);
|
|
});
|
|
|
|
it.each([2, 4])("preserves spent slots when a rate limit changes from three to %s", async limit => {
|
|
const { company, input, ratePolicy, svc } = await admittedRateLimitedInvocation(3);
|
|
await svc.updatePolicy({ companyId: company.id, policyId: ratePolicy.id,
|
|
body: { config: { limit, windowSeconds: 3600, keyBy: ["agent", "tool"] } } });
|
|
const results = await Promise.all(Array.from({ length: limit }, () => svc.decide({ ...input, consumeRateLimit: true })));
|
|
expect(results.filter(result => result.allowed)).toHaveLength(limit - 1);
|
|
expect(results.filter(result => result.decision === "rate_limited")).toHaveLength(1);
|
|
expect(await db.select().from(toolRateLimitCounters)).toMatchObject([{ limit, remaining: 0 }]);
|
|
});
|
|
|
|
it.each(["pending", "approved"] as const)("retains a waiting invocation's consumed slot without bypassing %s approval policy", async approvalState => {
|
|
const { company, input, invocation, svc } = await admittedRateLimitedInvocation();
|
|
await db.update(toolInvocations).set({ status: "awaiting_approval", policyDecision: "require_approval", approvalState })
|
|
.where(eq(toolInvocations.id, invocation.id));
|
|
await db.insert(toolPolicies).values({ companyId: company.id, name: "Review admitted sends",
|
|
policyType: "require_approval", priority: 2, selectors: { toolName: "send_email" } });
|
|
const counters = await db.select().from(toolRateLimitCounters);
|
|
expect(await svc.decide(input, { admittedInvocationId: invocation.id }))
|
|
.toMatchObject({ allowed: false, decision: "require_approval" });
|
|
expect(await db.select().from(toolRateLimitCounters)).toEqual(counters);
|
|
expect(await svc.decide(input)).toMatchObject({ decision: "rate_limited" });
|
|
});
|
|
|
|
it("rechecks a newly added rate policy rather than borrowing an older policy's consumed slot", async () => {
|
|
const { company, input, invocation, svc } = await admittedRateLimitedInvocation();
|
|
await svc.createPolicy(company.id, { name: "New send limit", policyType: "rate_limit", priority: 0,
|
|
selectors: { toolName: "send_email" }, config: { limit: 1, windowSeconds: 3600, keyBy: ["agent", "tool"] } });
|
|
// Consume the new rule through normal admission; the exhausted original
|
|
// rule then denies this separate request before any provider dispatch.
|
|
expect(await svc.decide({ ...input, consumeRateLimit: true })).toMatchObject({ decision: "rate_limited" });
|
|
const counters = await db.select().from(toolRateLimitCounters);
|
|
expect(counters).toHaveLength(2);
|
|
expect(await svc.decide(input, { admittedInvocationId: invocation.id })).toMatchObject({ decision: "rate_limited" });
|
|
expect(await db.select().from(toolRateLimitCounters)).toEqual(counters);
|
|
});
|
|
|
|
it("rechecks an edited rate-policy generation without consuming another slot", async () => {
|
|
const { company, input, invocation, ratePolicy, svc } = await admittedRateLimitedInvocation(2);
|
|
expect(await svc.decide({ ...input, consumeRateLimit: true })).toMatchObject({ allowed: true });
|
|
await svc.updatePolicy({ companyId: company.id, policyId: ratePolicy.id,
|
|
body: { config: { limit: 1, windowSeconds: 3600, keyBy: ["agent", "tool"] } } });
|
|
const counters = await db.select().from(toolRateLimitCounters);
|
|
expect(await svc.decide(input, { admittedInvocationId: invocation.id })).toMatchObject({ decision: "rate_limited" });
|
|
expect(await db.select().from(toolRateLimitCounters)).toEqual(counters);
|
|
});
|
|
|
|
it("rechecks rate limits for historical executing invocations without durable consumption proof", async () => {
|
|
const { input, invocation, svc } = await admittedRateLimitedInvocation();
|
|
await db.update(toolInvocations).set({ policyExplanation: null }).where(eq(toolInvocations.id, invocation.id));
|
|
expect(await svc.decide(input, { admittedInvocationId: invocation.id })).toMatchObject({ decision: "rate_limited" });
|
|
});
|
|
|
|
it("reevaluates current blocks, grants, and approval policies for admitted work", async () => {
|
|
const { company, input, invocation, svc } = await admittedRateLimitedInvocation();
|
|
const options = { admittedInvocationId: invocation.id };
|
|
const [block] = await db.insert(toolPolicies).values({ companyId: company.id, name: "Block admitted sends",
|
|
policyType: "block", priority: 0, selectors: { toolName: "send_email" } }).returning();
|
|
expect(await svc.decide(input, options)).toMatchObject({ decision: "deny", reasonCode: "deny_policy_block" });
|
|
await db.delete(toolPolicies).where(eq(toolPolicies.id, block.id));
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, company.id));
|
|
expect(await svc.decide(input, options)).toMatchObject({ decision: "deny", reasonCode: "deny_default" });
|
|
await db.insert(toolPolicies).values({ companyId: company.id, name: "Require new review",
|
|
policyType: "require_approval", priority: 2, selectors: { toolName: "send_email" } });
|
|
expect(await svc.decide(input, options)).toMatchObject({ decision: "require_approval" });
|
|
});
|
|
|
|
it("does not retain rate admission for a mismatched or no-longer-authorized invocation", async () => {
|
|
const { input, invocation, svc } = await admittedRateLimitedInvocation();
|
|
const options = { admittedInvocationId: invocation.id };
|
|
const mutations: Partial<typeof toolInvocations.$inferInsert>[] = [
|
|
{ actorType: "system" }, { actorId: randomUUID() }, { agentId: null }, { runId: null }, { issueId: null }, { gatewayId: null },
|
|
{ applicationId: null }, { connectionId: null }, { catalogEntryId: null }, { toolName: "different_tool" },
|
|
{ providerType: null }, { applicationKey: null }, { upstreamToolName: null }, { argumentsHash: "different" },
|
|
{ status: "authorized" }, { status: "succeeded" }, { status: "awaiting_approval" },
|
|
{ status: "awaiting_approval", policyDecision: "require_approval", approvalState: "rejected" }, { policyDecision: "deny" }, { policyDecision: "require_approval", approvalState: "pending" },
|
|
];
|
|
for (const mutation of mutations) {
|
|
await db.update(toolInvocations).set(mutation).where(eq(toolInvocations.id, invocation.id));
|
|
expect(await svc.decide(input, options), JSON.stringify(mutation)).toMatchObject({ decision: "rate_limited" });
|
|
expect(await svc.admitInvocation(input, invocation.id), JSON.stringify(mutation)).toMatchObject({ decision: "deny" });
|
|
await db.update(toolInvocations).set({ ...invocation, status: "executing" }).where(eq(toolInvocations.id, invocation.id));
|
|
}
|
|
expect(await svc.decide({ ...input, request: { ...input.request, arguments: { to: "another@example.test" } } }, options))
|
|
.toMatchObject({ decision: "rate_limited" });
|
|
expect(await svc.admitInvocation({ ...input, request: { ...input.request, arguments: { to: "another@example.test" } } }, invocation.id))
|
|
.toMatchObject({ decision: "deny" });
|
|
});
|
|
|
|
it("rejects unsupported policy semantics at create and update time", async () => {
|
|
const company = await createCompany(db);
|
|
const svc = toolAccessPolicyService(db);
|
|
const policy = await svc.createPolicy(company.id, {
|
|
name: "Require review",
|
|
policyType: "require_approval",
|
|
selectors: { toolName: "send_email" },
|
|
});
|
|
|
|
await expect(svc.createPolicy(company.id, {
|
|
name: "Dead redact policy",
|
|
policyType: "redact" as never,
|
|
selectors: { toolName: "send_email" },
|
|
config: { redact: { fields: ["to", "body"] } },
|
|
})).rejects.toThrow("Tool policy type 'redact' is not supported at runtime");
|
|
await expect(svc.createPolicy(company.id, {
|
|
name: "Dead custom check",
|
|
policyType: "validate" as never,
|
|
selectors: { toolName: "send_email" },
|
|
config: { schema: { required: ["body"] } },
|
|
})).rejects.toThrow("Tool policy type 'validate' is not supported at runtime");
|
|
await expect(svc.createPolicy(company.id, {
|
|
name: "Malformed conditional policy",
|
|
policyType: "allow",
|
|
selectors: { toolName: "send_email" },
|
|
conditions: { args: { body: "safe" } } as never,
|
|
})).rejects.toThrow("Tool policy conditions include unsupported runtime semantics");
|
|
await expect(svc.createPolicy(company.id, {
|
|
name: "Ignored approval config",
|
|
policyType: "require_approval",
|
|
selectors: { toolName: "send_email" },
|
|
config: { validate: { required: ["body"] } },
|
|
})).rejects.toThrow("Tool policy type 'require_approval' does not support config");
|
|
await expect(svc.createPolicy(company.id, {
|
|
name: "Invalid rate limit",
|
|
policyType: "rate_limit",
|
|
selectors: { toolName: "send_email" },
|
|
config: { rateLimit: { limit: 0, windowSeconds: 60 } },
|
|
})).rejects.toThrow("Rate-limit policy config requires positive numeric limit and windowSeconds");
|
|
await expect(svc.updatePolicy({
|
|
companyId: company.id,
|
|
policyId: policy.id,
|
|
body: { conditions: { args: { body: "safe" } } as never },
|
|
})).rejects.toThrow("Tool policy conditions include unsupported runtime semantics");
|
|
});
|
|
|
|
it("rejects fieldMatches patterns with nested quantifiers", async () => {
|
|
const company = await createCompany(db);
|
|
const svc = toolAccessPolicyService(db);
|
|
|
|
await expect(svc.createPolicy(company.id, {
|
|
name: "Unsafe regex",
|
|
policyType: "allow",
|
|
priority: 10,
|
|
selectors: { toolName: "send_email" },
|
|
conditions: {
|
|
arguments: { fieldMatches: { body: "^(a+)+$" } },
|
|
},
|
|
})).rejects.toThrow("unsafe regular expression");
|
|
});
|
|
|
|
it("rejects fieldMatches patterns with ambiguous alternation", async () => {
|
|
const company = await createCompany(db);
|
|
const svc = toolAccessPolicyService(db);
|
|
|
|
await expect(svc.createPolicy(company.id, {
|
|
name: "Unsafe alternation",
|
|
policyType: "allow",
|
|
conditions: {
|
|
arguments: { fieldMatches: { body: "^(a|aa)*b$" } },
|
|
},
|
|
})).rejects.toThrow("unsafe regular expression");
|
|
});
|
|
|
|
it("allows a write policy only for a safe argument subset and blocks unsafe arguments", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const svc = toolAccessPolicyService(db);
|
|
const allowPolicy = await svc.createPolicy(company.id, {
|
|
name: "Allow safe destination",
|
|
policyType: "allow",
|
|
priority: 10,
|
|
selectors: { toolName: "send_email" },
|
|
conditions: {
|
|
arguments: {
|
|
fieldEquals: { to: "ops@example.com" },
|
|
fieldMatches: { body: "^[\\s\\S]{1,200}$" },
|
|
},
|
|
risk: { isWrite: true },
|
|
},
|
|
});
|
|
const blockPolicy = await svc.createPolicy(company.id, {
|
|
name: "Block external destination",
|
|
policyType: "block",
|
|
priority: 5,
|
|
selectors: { toolName: "send_email" },
|
|
conditions: {
|
|
arguments: {
|
|
fieldNotEquals: { to: "ops@example.com" },
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(svc.decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
request: {
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
toolName: "send_email",
|
|
arguments: { to: "ops@example.com", body: "safe update" },
|
|
},
|
|
})).resolves.toMatchObject({
|
|
allowed: true,
|
|
decision: "allow",
|
|
reasonCode: "allow_policy",
|
|
matchedPolicyIds: [allowPolicy.id],
|
|
policyExplanation: {
|
|
conditionsMatched: ["arguments", "risk"],
|
|
},
|
|
});
|
|
|
|
await expect(svc.decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
request: {
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
toolName: "send_email",
|
|
arguments: { to: "outside@example.com", body: "exfiltrate" },
|
|
},
|
|
})).resolves.toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_policy_block",
|
|
matchedPolicyIds: [blockPolicy.id],
|
|
});
|
|
});
|
|
|
|
it("fails closed for legacy unsupported redact and validate policies", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const profile = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `profile-${randomUUID()}`,
|
|
name: "Fallback allow",
|
|
defaultAction: "allow",
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
|
const [redactPolicy] = await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Legacy redact",
|
|
policyType: "redact" as never,
|
|
selectors: { toolName: "send_email" },
|
|
config: { redact: { fields: ["to", "body"] } },
|
|
priority: 1,
|
|
}).returning();
|
|
const [validatePolicy] = await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Legacy validate",
|
|
policyType: "validate" as never,
|
|
selectors: { toolName: "send_email" },
|
|
config: { schema: { required: ["body"] } },
|
|
priority: 2,
|
|
}).returning();
|
|
|
|
const redactDecision = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
|
await db.update(toolPolicies).set({ enabled: false }).where(eq(toolPolicies.id, redactPolicy!.id));
|
|
const validateDecision = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
|
|
|
expect(redactDecision).toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_policy_block",
|
|
matchedPolicyIds: [redactPolicy!.id],
|
|
});
|
|
expect(validateDecision).toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_policy_block",
|
|
matchedPolicyIds: [validatePolicy!.id],
|
|
});
|
|
});
|
|
|
|
it("fails closed for legacy condition-bearing policies", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const profile = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `profile-${randomUUID()}`,
|
|
name: "Fallback allow",
|
|
defaultAction: "allow",
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
|
const [policy] = await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Legacy conditional allow",
|
|
policyType: "allow",
|
|
selectors: { toolName: "send_email" },
|
|
conditions: { args: { body: "safe" } },
|
|
priority: 1,
|
|
}).returning();
|
|
|
|
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_policy_block",
|
|
matchedPolicyIds: [policy!.id],
|
|
});
|
|
});
|
|
|
|
it("fails closed for legacy rate-limit policies with invalid config", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
const profile = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `profile-${randomUUID()}`,
|
|
name: "Fallback allow",
|
|
defaultAction: "allow",
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
|
const [policy] = await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Broken rate limit",
|
|
policyType: "rate_limit",
|
|
selectors: { toolName: "send_email" },
|
|
config: { rateLimit: { limit: 0, windowSeconds: 60 } },
|
|
priority: 1,
|
|
}).returning();
|
|
|
|
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
decision: "deny",
|
|
reasonCode: "deny_policy_block",
|
|
matchedPolicyIds: [policy!.id],
|
|
});
|
|
});
|
|
|
|
it("promotes repeated approved actions into a scoped trust rule with audited hits", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const issue = await createIssue(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review send_email",
|
|
policyType: "require_approval",
|
|
selectors: { toolName: "send_email" },
|
|
description: "Writes require review until promoted.",
|
|
});
|
|
const args = { to: "ops@example.com", body: "ship it" };
|
|
const first = await createApprovedToolAction({
|
|
db,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
issueId: issue.id,
|
|
argumentsValue: args,
|
|
status: "executed",
|
|
});
|
|
await createApprovedToolAction({
|
|
db,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
issueId: issue.id,
|
|
argumentsValue: args,
|
|
});
|
|
|
|
const trustRule = await toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: first.actionRequest.id,
|
|
body: {
|
|
approvalThreshold: 2,
|
|
scope: { includeIssue: true, includeCatalogEntry: true },
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
batchApproval: { enabled: true, maxBatchSize: 5, windowSeconds: 3600 },
|
|
},
|
|
});
|
|
const decision = await toolAccessPolicyService(db).decide({
|
|
...first.decisionInput,
|
|
consumeRateLimit: true,
|
|
});
|
|
const [updatedRule] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, trustRule.id));
|
|
const trustConfig = updatedRule.config as { trustRule?: { hitCount?: number; lastHitAt?: string | null } };
|
|
const trustEvents = await db.select().from(toolCallEvents);
|
|
|
|
expect(trustRule).toMatchObject({
|
|
policyType: "trust_rule",
|
|
enabled: true,
|
|
selectors: expect.objectContaining({
|
|
agentId: agent.id,
|
|
issueId: issue.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
toolName: "send_email",
|
|
}),
|
|
});
|
|
expect(decision).toMatchObject({
|
|
allowed: true,
|
|
decision: "allow",
|
|
reasonCode: "allow_trust_rule",
|
|
matchedPolicyIds: [trustRule.id],
|
|
});
|
|
expect(trustConfig.trustRule?.hitCount).toBe(1);
|
|
expect(trustConfig.trustRule?.lastHitAt).toEqual(expect.any(String));
|
|
expect(trustEvents.some((event) => event.eventType === "trust_rule_created")).toBe(true);
|
|
expect(trustEvents.some((event) => event.eventType === "trust_rule_used")).toBe(true);
|
|
});
|
|
|
|
it("does not count approved actions outside the final trust-rule agent scope", async () => {
|
|
const company = await createCompany(db);
|
|
const agentA = await createAgent(db, company.id);
|
|
const agentB = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review scoped sends"));
|
|
const args = { to: "ops@example.com", body: "same reviewed payload" };
|
|
await createApprovedToolAction({
|
|
db,
|
|
companyId: company.id,
|
|
agentId: agentA.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
argumentsValue: args,
|
|
status: "executed",
|
|
});
|
|
const agentBAction = await createApprovedToolAction({
|
|
db,
|
|
companyId: company.id,
|
|
agentId: agentB.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
argumentsValue: args,
|
|
status: "executed",
|
|
});
|
|
|
|
await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: agentBAction.actionRequest.id,
|
|
body: { approvalThreshold: 2 },
|
|
})).rejects.toThrow(/final rule scope; found 1/);
|
|
});
|
|
|
|
it("does not count approvals from stale catalog versions toward trust-rule promotion", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review versioned sends"));
|
|
const args = { to: "ops@example.com", body: "same payload after tool change" };
|
|
await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
|
await db
|
|
.update(toolCatalogEntries)
|
|
.set({ versionHash: randomUUID(), schemaHash: randomUUID(), updatedAt: new Date() })
|
|
.where(eq(toolCatalogEntries.id, catalogEntry.id));
|
|
const currentVersionAction = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
|
|
|
await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: currentVersionAction.actionRequest.id,
|
|
body: { approvalThreshold: 2, scope: { includeCatalogEntry: true } },
|
|
})).rejects.toThrow(/final rule scope; found 1/);
|
|
});
|
|
|
|
it("rejects company-wide trust-rule promotion bodies that drop the reviewed scope", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review scoped sends"));
|
|
const args = { to: "ops@example.com", body: "same reviewed payload" };
|
|
const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
|
await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
|
|
|
await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: first.actionRequest.id,
|
|
body: {
|
|
approvalThreshold: 2,
|
|
scope: {
|
|
includeAgent: false,
|
|
includeProject: false,
|
|
includeApplication: false,
|
|
includeConnection: false,
|
|
includeTool: false,
|
|
},
|
|
},
|
|
})).rejects.toThrow(/reviewed actor\/tool scope/);
|
|
});
|
|
|
|
it("rejects argument-broadening trust-rule promotion bodies", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review exact payload sends"));
|
|
const args = { to: "ops@example.com", body: "same reviewed payload" };
|
|
const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
|
await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
|
|
|
await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: first.actionRequest.id,
|
|
body: {
|
|
approvalThreshold: 2,
|
|
argumentFilters: { allowAny: true },
|
|
},
|
|
})).rejects.toThrow(/exact reviewed argument hash/);
|
|
});
|
|
|
|
it("falls back to review when a trusted catalog tool changes", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review changed sends"));
|
|
const args = { to: "ops@example.com", body: "repeatable" };
|
|
const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
|
await createApprovedToolAction({
|
|
db,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
argumentsValue: args,
|
|
});
|
|
const trustRule = await toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: first.actionRequest.id,
|
|
body: { approvalThreshold: 2, scope: { includeCatalogEntry: true } },
|
|
});
|
|
await db
|
|
.update(toolCatalogEntries)
|
|
.set({ status: "quarantined", versionHash: randomUUID(), updatedAt: new Date() })
|
|
.where(eq(toolCatalogEntries.id, catalogEntry.id));
|
|
|
|
const decision = await toolAccessPolicyService(db).decide(first.decisionInput);
|
|
|
|
expect(decision).toMatchObject({
|
|
allowed: false,
|
|
decision: "require_approval",
|
|
reasonCode: "requires_review_changed_tool",
|
|
matchedPolicyIds: [trustRule.id],
|
|
});
|
|
});
|
|
|
|
it("revokes trust rules so matching actions return to per-call approval", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection, catalogEntry } = await createTool(db, company.id);
|
|
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review revocable sends"));
|
|
const args = { to: "ops@example.com", body: "revocable" };
|
|
const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
|
await createApprovedToolAction({
|
|
db,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
argumentsValue: args,
|
|
});
|
|
const trustRule = await toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: first.actionRequest.id,
|
|
body: { approvalThreshold: 2 },
|
|
});
|
|
const revoked = await toolAccessPolicyService(db).revokeTrustRule({
|
|
companyId: company.id,
|
|
policyId: trustRule.id,
|
|
body: { reason: "Tool scope changed" },
|
|
});
|
|
const decision = await toolAccessPolicyService(db).decide(first.decisionInput);
|
|
const config = revoked.config as { trustRule?: { revokedAt?: string | null; revocationReason?: string | null } };
|
|
|
|
expect(revoked.enabled).toBe(false);
|
|
expect(config.trustRule?.revokedAt).toEqual(expect.any(String));
|
|
expect(config.trustRule?.revocationReason).toBe("Tool scope changed");
|
|
expect(decision).toMatchObject({
|
|
allowed: false,
|
|
decision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
});
|
|
});
|
|
|
|
it("routes gateway execution through policy decisions instead of legacy gateway permissions", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id, { toolGateway: { allowAll: true } });
|
|
const run = await createRun(db, company.id, agent.id);
|
|
const gateway = createToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
await expect(gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:echo",
|
|
parameters: { message: "hello" },
|
|
})).rejects.toMatchObject({
|
|
status: 403,
|
|
reasonCode: "deny_default",
|
|
} satisfies Partial<ToolGatewayHttpError>);
|
|
|
|
const [invocation] = await db.select().from(toolInvocations);
|
|
expect(invocation).toMatchObject({
|
|
toolName: "mcp-remote-fixture:echo",
|
|
policyDecision: "deny",
|
|
status: "denied",
|
|
});
|
|
});
|
|
|
|
it("replays idempotent side-effecting gateway calls without creating a second invocation", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const run = await createRun(db, company.id, agent.id);
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId: company.id,
|
|
principalType: "agent",
|
|
principalId: agent.id,
|
|
permissionKey: "tools:use",
|
|
scope: { toolName: "mcp-remote-fixture:update_note" },
|
|
});
|
|
const gateway = createToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:update_note",
|
|
parameters: { noteId: "n1", body: "ship" },
|
|
idempotencyKey: "note-update-1",
|
|
});
|
|
const replay = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:update_note",
|
|
parameters: { noteId: "n1", body: "ship" },
|
|
idempotencyKey: "note-update-1",
|
|
});
|
|
|
|
expect(first).toMatchObject({ status: "completed", tool: "mcp-remote-fixture:update_note" });
|
|
expect(replay).toMatchObject({ status: "replayed", invocationId: first.invocationId });
|
|
const invocations = await db.select().from(toolInvocations);
|
|
expect(invocations).toHaveLength(1);
|
|
expect(invocations[0]).toMatchObject({
|
|
idempotencyKey: "note-update-1",
|
|
status: "succeeded",
|
|
resultSummary: expect.objectContaining({ summary: expect.any(String) }),
|
|
});
|
|
});
|
|
|
|
it("rejects cross-company owner agents and credential secret refs before persisting tool access records", async () => {
|
|
const company = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const otherAgent = await createAgent(db, otherCompany.id);
|
|
const [otherSecret] = await db.insert(companySecrets).values({
|
|
companyId: otherCompany.id,
|
|
key: `secret-${randomUUID()}`,
|
|
name: `Secret ${randomUUID()}`,
|
|
}).returning();
|
|
const svc = toolAccessService(db);
|
|
|
|
await expect(svc.createApplication(company.id, {
|
|
name: "Wrong owner",
|
|
type: "mcp_http",
|
|
ownerAgentId: otherAgent.id,
|
|
})).rejects.toThrow(/same company/);
|
|
|
|
await expect(svc.createConnection(company.id, {
|
|
name: "Wrong secret",
|
|
transport: "mcp_remote",
|
|
transportConfig: { url: "https://8.8.8.8/mcp" },
|
|
credentialSecretRefs: [{
|
|
secretId: otherSecret.id,
|
|
configPath: "headers.Authorization",
|
|
}],
|
|
})).rejects.toThrow(/same company/);
|
|
});
|
|
});
|