Files
PaperClipAI/server/src/__tests__/tool-access-policy-service.test.ts
T
DottaandPaperclip 77295faebc fix(server): recover approved tool actions from committed changes (#17454)
## 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>
2026-10-27 16:10:44 -05:00

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/);
});
});