QA How-To
Validate MCP Tool Permission Boundaries
Learn to validate MCP tool permission boundaries with deny-by-default policies, scoped approvals, tenant tests, audit evidence, and CI-ready TypeScript.
20 min read | 2,402 words
TL;DR
Put a policy enforcement point immediately before MCP tool execution. Test it with a deny-by-default permission matrix, argument-bound approvals, tenant isolation checks, and negative assertions proving that rejected calls never reach a handler.
Key Takeaways
- Test effective permissions at the tool execution boundary, not only the tool list shown to a model.
- Deny unknown tools, missing identities, cross-tenant targets, and expired approvals by default.
- Bind approval to the exact principal, tool, arguments, tenant, and expiration time.
- Use a table-driven matrix to cover roles, resources, actions, and expected denial reasons.
- Assert that denied requests cause no handler call or external side effect.
- Run deterministic permission tests in CI with synthetic identities and no production credentials.
To validate mcp tool permission boundaries, send allowed and forbidden calls through the same policy gateway used in production, then assert the decision, reason, audit record, and absence of side effects. Listing the right tools is not enough because a caller may still forge a tool name, change sensitive arguments, reuse approval, or target another tenant.
This tutorial builds a runnable TypeScript permission lab for an MCP client and server. Use the MCP security testing complete guide for 2026 for the larger threat model, then use this guide to prove authorization behavior at one precise enforcement boundary.
You will model identities and grants, connect an MCP server through an in-memory transport, enforce policy before execution, and turn role, tenant, approval, and time constraints into Vitest regression tests. Every account and side effect is synthetic, so the suite is safe for local development and CI.
What You Will Build
You will build a small but realistic authorization harness with these parts:
- An MCP server with a read-only tool and a sensitive mutation tool.
- A deny-by-default policy gateway in front of every tool call.
- Exact grants for principal, tenant, tool, resource, and action.
- One-time, argument-bound approval for sensitive mutations.
- Table-driven Vitest cases for horizontal and vertical privilege escalation.
- An audit trail that records allow and deny decisions without invoking rejected handlers.
The suite sends requests directly to the execution boundary, including calls a normal model should never propose. This catches server-side authorization gaps before a compromised agent finds them.
Prerequisites
Use Node.js 22 or a newer supported LTS release. Create an isolated ESM project and install the official Model Context Protocol TypeScript SDK, Zod, TypeScript, and Vitest:
mkdir mcp-permission-lab
cd mcp-permission-lab
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install --save-dev typescript vitest @types/node
mkdir -p src test
Add ESM and test scripts to package.json:
{
"type": "module",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
}
}
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node", "vitest/globals"]
},
"include": ["src", "test"]
}
Run npm run typecheck. It should exit without errors. Keep the lab independent of production credentials, customer identifiers, and external APIs.
Step 1: How to validate mcp tool permission boundaries
Start by naming what the policy must decide. A useful authorization request includes the authenticated principal, active tenant, tool name, action, target resource, arguments, and approval evidence. Do not let a tool argument choose the caller identity or trusted tenant. Those values must come from authenticated session context.
Create src/types.ts:
export type Action = "read" | "write";
export type Principal = {
id: string;
tenantId: string;
roles: readonly string[];
};
export type ToolRequest = {
principal: Principal | null;
tool: string;
action: Action;
resource: string;
arguments: Record<string, unknown>;
approvalId?: string;
};
export type Decision = {
allowed: boolean;
reason: string;
};
export type Grant = {
principalId: string;
tenantId: string;
tool: string;
action: Action;
resourcePattern: string;
};
Use resource names that carry a trusted tenant prefix, such as tenant-a/report-7. In a real service, resolve that ownership from storage rather than trusting a string supplied by the caller. The lab keeps the format visible so the isolation assertion is easy to understand.
Map expected behavior before writing policy code:
| Scenario | Identity | Target | Expected result | Reason |
|---|---|---|---|---|
| Own report read | analyst-a | tenant-a/report-7 | Allow | Matching grant |
| Other tenant read | analyst-a | tenant-b/report-2 | Deny | Tenant mismatch |
| Delete without approval | admin-a | tenant-a/report-7 | Deny | Approval required |
| Unknown tool | admin-a | tenant-a/report-7 | Deny | Tool not granted |
| Missing identity | none | tenant-a/report-7 | Deny | Unauthenticated |
Verification: run npm run typecheck. Confirm every row specifies both an outcome and a stable reason. If a scenario has no expected result, it is not yet an executable test case.
Step 2: Create a Synthetic MCP Server
Create a server whose handlers only update in-memory state. The read_report tool represents a low-risk read. The delete_report tool represents an irreversible operation that needs both a grant and explicit approval.
Create src/server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export const effects: string[] = [];
export const resetEffects = () => effects.splice(0, effects.length);
export function createServer() {
const server = new McpServer({
name: "permission-fixture",
version: "1.0.0"
});
server.registerTool(
"read_report",
{
description: "Read one synthetic report",
inputSchema: { resource: z.string() }
},
async ({ resource }) => {
effects.push(`read:${resource}`);
return { content: [{ type: "text", text: `Report ${resource}` }] };
}
);
server.registerTool(
"delete_report",
{
description: "Delete one synthetic report",
inputSchema: { resource: z.string() }
},
async ({ resource }) => {
effects.push(`delete:${resource}`);
return { content: [{ type: "text", text: `Deleted ${resource}` }] };
}
);
return server;
}
The handler records an observable effect rather than touching a database. A permission test needs this second oracle because a gateway could return a denial after the handler already ran. Such a test must fail even if the final response says access was denied.
Tool descriptions are discovery metadata, not authorization. Hiding delete_report from a model can reduce accidental calls, but a hostile client can submit its name directly. The server-side gateway still has to reject it.
Verification: run npm run typecheck. Inspect both handlers and confirm that they have no filesystem, network, database, or subprocess access. The only mutation should be an append to effects.
Step 3: Connect the Client Through the Real Protocol
Use the SDK's linked in-memory transports. This exercises MCP request serialization and dispatch without opening a port or starting a subprocess.
Create src/client.ts:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "./server.js";
export async function connectLab() {
const server = createServer();
const client = new Client({
name: "permission-test-client",
version: "1.0.0"
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
server.connect(serverTransport),
client.connect(clientTransport)
]);
return {
client,
close: async () => {
await client.close();
await server.close();
}
};
}
Add test/protocol.test.ts:
import { describe, expect, it } from "vitest";
import { connectLab } from "../src/client.js";
describe("MCP permission fixture", () => {
it("publishes the expected tools", async () => {
const lab = await connectLab();
try {
const result = await lab.client.listTools();
expect(result.tools.map((tool) => tool.name).sort()).toEqual([
"delete_report",
"read_report"
]);
} finally {
await lab.close();
}
});
});
This smoke test proves that later calls cross the protocol boundary. It deliberately does not call a tool because unguarded execution would teach the wrong architecture. Production code should expose one authorized call path rather than giving application features direct access to the raw client.
Verification: run npm test -- test/protocol.test.ts. Expect one passing test and the two exact tool names. A timeout usually means one side of the linked pair was not connected or the cleanup path did not close both endpoints.
Step 4: Enforce Deny-by-Default Tool Access
Implement the policy as a pure function first. Pure decisions are fast to test and cannot accidentally cause a side effect. Evaluate authentication, known tool and action, tenant ownership, and exact grant in that order.
Create src/policy.ts:
import type { Action, Decision, Grant, ToolRequest } from "./types.js";
const knownTools = new Map<string, Action>([
["read_report", "read"],
["delete_report", "write"]
] as const);
function tenantOf(resource: string) {
return resource.split("/", 1)[0];
}
export function authorize(
request: ToolRequest,
grants: readonly Grant[]
): Decision {
if (!request.principal) return { allowed: false, reason: "unauthenticated" };
const expectedAction = knownTools.get(request.tool);
if (!expectedAction || expectedAction !== request.action) {
return { allowed: false, reason: "unknown tool or action" };
}
if (request.arguments.resource !== request.resource) {
return { allowed: false, reason: "resource argument mismatch" };
}
if (tenantOf(request.resource) !== request.principal.tenantId) {
return { allowed: false, reason: "tenant mismatch" };
}
const granted = grants.some((grant) =>
grant.principalId === request.principal!.id &&
grant.tenantId === request.principal!.tenantId &&
grant.tool === request.tool &&
grant.action === request.action &&
(grant.resourcePattern === "*" ||
grant.resourcePattern === request.resource)
);
return granted
? { allowed: true, reason: "matching grant" }
: { allowed: false, reason: "no matching grant" };
}
The resource equality check closes a confused-deputy gap: the gateway must not authorize one target and pass a different target to the handler. Exact matching is intentionally simple. Never use startsWith for tenant authorization because tenant-a would also match tenant-attacker.
Keep roles outside this core evaluator unless they are converted into grants by a trusted identity service. A role name inside tool arguments must never grant access. Cache policy decisions only when the cache key includes every security-relevant dimension and revocation requirements permit it.
Verification: run npm run typecheck. Confirm unknown tools, cross-tenant resources, and mismatched argument targets return before grant lookup.
Step 5: Bind Approval to Sensitive Tool Arguments
A broad session approval is easy to replay. Bind consent to the principal, tenant, tool, resource, canonical arguments, and expiration. Consume one-time approval before dispatch so two concurrent calls cannot use it.
Create src/approval.ts:
import { createHash } from "node:crypto";
import type { ToolRequest } from "./types.js";
export type Approval = {
id: string;
principalId: string;
tenantId: string;
tool: string;
resource: string;
argumentsHash: string;
expiresAt: number;
consumed: boolean;
};
export const hashArguments = (value: Record<string, unknown>) =>
createHash("sha256").update(JSON.stringify(value)).digest("hex");
export function consumeApproval(
request: ToolRequest,
approvals: Approval[],
now: number
) {
const approval = approvals.find((item) => item.id === request.approvalId);
if (!approval) return { allowed: false, reason: "approval required" };
if (approval.consumed) return { allowed: false, reason: "approval consumed" };
if (approval.expiresAt <= now) return { allowed: false, reason: "approval expired" };
const matches = request.principal &&
approval.principalId === request.principal.id &&
approval.tenantId === request.principal.tenantId &&
approval.tool === request.tool &&
approval.resource === request.resource &&
approval.argumentsHash === hashArguments(request.arguments);
if (!matches) return { allowed: false, reason: "approval scope mismatch" };
approval.consumed = true;
return { allowed: true, reason: "approval consumed" };
}
For production, use a deterministic canonical JSON serializer before hashing. Plain JSON.stringify preserves insertion order but semantically identical objects constructed in a different key order can hash differently. A false denial is acceptable in this lab, while a production workflow needs a documented canonicalization contract.
Only writes require approval in this example. Authentication and grants still apply first. Approval confirms user intent but never creates a permission the user lacks.
Verification: run npm run typecheck. Confirm that changing the resource, tool, principal, tenant, or arguments produces a scope mismatch, and that an expired or consumed record cannot succeed.
Step 6: Put the Policy Gateway Before Execution
Now combine authorization, approval, audit evidence, and MCP execution. The gateway must be the only application path to client.callTool.
Create src/gateway.ts:
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { Approval } from "./approval.js";
import { consumeApproval } from "./approval.js";
import { authorize } from "./policy.js";
import type { Decision, Grant, ToolRequest } from "./types.js";
export const audit: Array<Decision & { tool: string; resource: string }> = [];
export const resetAudit = () => audit.splice(0, audit.length);
export async function guardedCall(
client: Client,
request: ToolRequest,
grants: readonly Grant[],
approvals: Approval[],
now: number
) {
let decision = authorize(request, grants);
if (decision.allowed && request.action === "write") {
decision = consumeApproval(request, approvals, now);
}
audit.push({ ...decision, tool: request.tool, resource: request.resource });
if (!decision.allowed) throw new Error(`Denied: ${decision.reason}`);
return client.callTool({
name: request.tool,
arguments: request.arguments
});
}
Notice the order. The gateway records the decision and throws before protocol dispatch. An approval is evaluated only after the caller has a grant. Audit records omit full arguments because those may contain credentials or personal data. In production, include a correlation ID, policy version, principal identifier, tenant, approval identifier, and sanitized argument fingerprint.
Do not log authentication tokens or raw secrets. Protect authorization logs from modification and restrict who can read them. Evidence that creates a second data leak is not useful evidence.
Verification: run npm run typecheck. Search the lab for callTool(. The only execution call outside tests should be inside guardedCall; the protocol smoke test uses only listTools.
Step 7: Validate MCP Tool Permission Boundaries With a Matrix
Create table-driven tests that exercise positive access, horizontal escalation, vertical escalation, missing identity, tool forgery, and approval replay. Negative assertions must check both the rejection and the absence of effects.
Create test/permissions.test.ts:
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { connectLab } from "../src/client.js";
import { hashArguments, type Approval } from "../src/approval.js";
import { audit, guardedCall, resetAudit } from "../src/gateway.js";
import { effects, resetEffects } from "../src/server.js";
import type { Grant, Principal, ToolRequest } from "../src/types.js";
const alice: Principal = { id: "alice", tenantId: "tenant-a", roles: ["analyst"] };
const grants: Grant[] = [
{ principalId: "alice", tenantId: "tenant-a", tool: "read_report", action: "read", resourcePattern: "*" },
{ principalId: "alice", tenantId: "tenant-a", tool: "delete_report", action: "write", resourcePattern: "tenant-a/report-7" }
];
let close: undefined | (() => Promise<void>);
beforeEach(() => { resetEffects(); resetAudit(); close = undefined; });
afterEach(async () => close?.());
async function run(request: ToolRequest, approvals: Approval[] = []) {
const lab = await connectLab();
close = lab.close;
return guardedCall(lab.client, request, grants, approvals, 1_000);
}
describe("MCP tool permission boundary", () => {
it.each([
["missing identity", null, "tenant-a/report-7", "unauthenticated"],
["other tenant", alice, "tenant-b/report-2", "tenant mismatch"]
])("denies %s without effects", async (_, principal, resource, reason) => {
await expect(run({
principal, tool: "read_report", action: "read", resource,
arguments: { resource }
})).rejects.toThrow(reason);
expect(effects).toEqual([]);
expect(audit.at(-1)).toMatchObject({ allowed: false, reason });
});
it("denies a mismatched dispatched resource", async () => {
await expect(run({ principal: alice, tool: "read_report", action: "read",
resource: "tenant-a/report-7", arguments: { resource: "tenant-b/report-2" }
})).rejects.toThrow("resource argument mismatch");
expect(effects).toEqual([]);
});
it("allows a granted read", async () => {
await run({ principal: alice, tool: "read_report", action: "read",
resource: "tenant-a/report-7", arguments: { resource: "tenant-a/report-7" } });
expect(effects).toEqual(["read:tenant-a/report-7"]);
});
it("requires exact approval and prevents replay", async () => {
const args = { resource: "tenant-a/report-7" };
const approvals: Approval[] = [{ id: "approval-1", principalId: "alice",
tenantId: "tenant-a", tool: "delete_report", resource: args.resource,
argumentsHash: hashArguments(args), expiresAt: 2_000, consumed: false }];
const request: ToolRequest = { principal: alice, tool: "delete_report",
action: "write", resource: args.resource, arguments: args, approvalId: "approval-1" };
await run(request, approvals);
expect(effects).toEqual(["delete:tenant-a/report-7"]);
await expect(run(request, approvals)).rejects.toThrow("approval consumed");
expect(effects).toEqual(["delete:tenant-a/report-7"]);
});
});
Extend the matrix for every production role and sensitive tool. Include other-tenant resources, disabled users, revoked grants, malformed resources, mismatched actions, expired approvals, changed arguments, and concurrent replay.
Verification: run npm test -- test/permissions.test.ts. Expect five passing cases because it.each expands into two tests. Confirm that every denial leaves effects unchanged and emits one denial reason.
Step 8: Run Permission Boundary Tests in CI
Keep deterministic authorization tests on every pull request. They are cheap, stable, and do not need a language model. Add .github/workflows/mcp-permissions.yml in the lab project:
name: MCP permission tests
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
permission-boundary:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
Use synthetic grants and approvals in CI. Do not connect the job to production identity providers or databases. If integration coverage requires a real authorization service, use an isolated test tenant, least-privileged credentials, restricted egress, and automatic cleanup.
Make the permission matrix reviewable. Store policy fixtures next to tests, require code owners for changes to expected allow results, and fail when a tool lacks a declared action or sensitivity class. A new tool should begin denied until its grants, approval rule, audit behavior, and tests are explicit.
Authorization changes deserve both positive and negative review. A new allowed case can create more risk than a new denial, so treat snapshot updates carefully. Never accept a broad permissions snapshot merely because it is difficult to inspect.
Verification: run npm ci, npm run typecheck, and npm test locally. In CI, confirm the job has read-only repository permission and no production environment. Change one expected denial to allow, verify the suite turns red, then restore it.
Troubleshooting
Problem: a denied call still changes state -> Move authorization before client.callTool and before any queue publication, cache write, or audit hook with side effects. Assert the handler spy, database fixture, and outbound request recorder remain unchanged.
Problem: cross-tenant tests unexpectedly pass -> Derive ownership from trusted storage, compare exact identifiers, and ensure the authorized resource equals the handler argument. Do not use prefix matching or caller-provided ownership.
Problem: an approved call works after its arguments change -> Hash canonical arguments and bind the approval to principal, tenant, tool, resource, and expiration. Consume approval atomically before dispatch.
Problem: tests pass through the UI but direct calls bypass policy -> Send raw MCP calls or invoke the gateway integration directly. Tool visibility and confirmation dialogs are useful controls, but server-side authorization must withstand a hostile client.
Problem: policy tests are flaky -> Inject a fixed clock, use in-memory transports, remove live identity calls, and isolate shared state in beforeEach. Test production adapters separately with controlled fixtures.
Problem: audit logs reveal sensitive arguments -> Record stable reason codes and sanitized fingerprints instead of raw payloads. Apply redaction before persistence and test the redactor with synthetic canaries.
Where To Go Next
Return to the complete MCP security testing guide to place authorization evidence within transport, authentication, input validation, prompt injection, and secret-management coverage. Permission tests prove who may act, but they do not prove that permitted input is structurally safe or that the agent cannot be manipulated.
Next, test MCP prompt injection attacks to verify that untrusted resources and tool results cannot obtain authority. Then fuzz MCP tool arguments with JSON Schema to probe boundary values, malformed objects, and parser differences. Finish with MCP server secret leakage testing across responses, errors, logs, and transports.
Repeat the matrix at the pure policy evaluator, MCP gateway, and sandboxed end-to-end agent. Reuse the same principal, tenant, resource, and reason vocabulary.
Interview Questions and Answers
Q: Why is hiding an MCP tool not an authorization control?
Tool discovery affects what a normal client or model sees, but a hostile client can submit a tool name directly. Authorization must run at the execution boundary using authenticated identity and trusted resource context. Hidden tools should still reject unauthorized direct calls.
Q: What dimensions belong in an MCP permission matrix?
Include principal, role or grant, tenant, tool, action, resource, argument scope, approval state, and expected reason. Add time and environment where they affect policy. Test both allowed combinations and near-neighbor denials.
Q: How do you prove a denied MCP call caused no side effect?
Instrument the handler and every external boundary, such as databases, queues, files, and HTTP clients. Assert that the denial occurs before dispatch and that all effect recorders remain unchanged. A denied response alone is insufficient.
Q: What is horizontal privilege escalation?
It occurs when a user accesses another user's or tenant's resource at the same privilege level. Test it by keeping the tool and action valid while changing only the target ownership. Exact tenant and resource checks should deny the call.
Q: What is vertical privilege escalation?
It occurs when a lower-privileged identity performs an administrator or sensitive action. Call write and administrative tools directly with a read-only principal, then assert denial and zero effects. Do not rely on the UI hiding those operations.
Q: How should approval interact with authorization?
Authorization determines whether the principal is eligible to perform an action. Approval confirms intent for one eligible sensitive operation. Approval must not grant missing permission, and it should be narrowly scoped, short-lived, and resistant to replay.
Best Practices
- Deny missing identities, unknown tools, unknown actions, and unmatched grants.
- Derive principal and tenant from authenticated context, never tool arguments.
- Put one policy gateway immediately before every MCP tool execution.
- Bind sensitive approval to exact scope and consume it atomically.
- Test direct calls even when tools are hidden from discovery.
- Assert decision, reason, audit evidence, and absence of side effects.
- Give server processes minimum filesystem, network, token, and database access.
- Review new allow rules and wildcard grants as security-sensitive changes.
- Keep production secrets and customer records out of test fixtures.
- Fail closed when policy, identity, approval, or ownership data is unavailable.
Common mistakes include testing only happy paths, trusting a role supplied in arguments, checking authorization after handler execution, using broad session approval, matching tenants by prefix, and considering a denial message sufficient evidence. Another frequent mistake is granting an entire MCP server rather than evaluating each tool, action, resource, and argument scope.
Conclusion
The reliable way to validate mcp tool permission boundaries is to make authorization unavoidable and test it adversarially. Send forged, cross-tenant, over-privileged, expired, altered, and replayed calls through the real gateway. For every denial, prove that the handler and downstream systems remained untouched.
Start with the deterministic matrix in this tutorial and run it on every change. Then combine it with injection, argument fuzzing, and secret leakage tests so an authorized tool call is also well-formed, intentional, and safe.
Interview Questions and Answers
Where should MCP tool authorization be enforced?
Enforce it immediately before tool execution and before any downstream side effect. The decision should use authenticated identity, trusted tenant and resource context, the requested action, and current grants. Client-side visibility can complement this boundary but cannot replace it.
How would you test horizontal privilege escalation in MCP?
Use a valid principal, tool, and action, then change only the resource to one owned by another user or tenant. Assert a tenant or ownership denial, an audit record, and no handler or external effect. Repeat the test across tenant pairs.
How would you test vertical privilege escalation in MCP?
Call sensitive write or administrative tools directly with a read-only identity. Do not rely on the client hiding those tools. The gateway should deny the call because no matching grant exists and should never dispatch it.
Why bind approval to MCP tool arguments?
Without argument binding, consent for one target can be reused for another target or a more dangerous value. Bind approval to principal, tenant, tool, resource, canonical arguments, and expiration. Consume one-time approvals atomically to stop replay.
What is a strong oracle for an MCP permission denial?
Check the denied decision, stable reason code, sanitized audit event, and absence of every observable side effect. A response that says denied is weak evidence if the handler already executed.
Why should unknown MCP tools be denied by default?
A new or misspelled tool has no reviewed authorization contract. Allowing it through a fallback can expose capabilities as the server evolves. Require an explicit action classification, grant rule, approval rule, and tests before enabling it.
Frequently Asked Questions
How do you validate MCP tool permission boundaries?
Send allowed and forbidden tool calls through the production policy enforcement path. Assert the decision and reason, then verify that rejected calls never invoke a handler or create an external side effect.
Is hiding an MCP tool enough to prevent unauthorized access?
No. Discovery controls what a cooperative client sees, but a hostile client can call a known tool name directly. Enforce authorization immediately before execution.
What should an MCP permission test matrix include?
Include principal, tenant, tool, action, target resource, argument scope, grant, approval state, and expected decision reason. Cover valid access plus cross-tenant, over-privileged, unauthenticated, expired, altered, and replayed requests.
Should MCP tool approval replace authorization?
No. Authorization establishes whether the caller is eligible to perform the action, while approval confirms intent for a sensitive eligible action. Approval must never create a permission that the principal lacks.
How can MCP permission tests prove there was no side effect?
Instrument tool handlers and downstream adapters for databases, files, queues, and HTTP calls. After a denial, assert that every recorder is unchanged and that dispatch never occurred.
Can MCP authorization tests run in CI?
Yes. Pure policy and in-memory protocol tests are deterministic and suitable for every pull request. Use synthetic identities and data, fixed clocks, restricted credentials, and no connection to production services.