Per-user auth, policy & audit for Mastra agents
TL;DR. npm i @agenticcontrolplane/governance, wrap each tool’s execute with governed(...), bind the end user’s identity via withContext — and every Mastra tool call gets per-user identity, policy enforcement (allow / deny / redact), PII detection, and an audit trail. Same control model as Claude Code.
Mastra is a TypeScript framework for building agents with first-class tools, workflows, and multi-provider model routing. Out of the box, a production deployment shares one backend API key across every end user’s request — no per-user policy enforcement, no per-user audit trail, no way to tell downstream systems which human triggered which action.
@agenticcontrolplane/governance closes that gap. Wrap each tool’s execute callback with governed(...); bind the end user’s identity per request via withContext. Same control model as Claude Code — same /govern/tool-use endpoint, same workspace policies.
Starter · 5-minute install. No framework-specific adapter needed — the base
governed()from@agenticcontrolplane/governancecomposes cleanly with Mastra’screateTool(). See the runnable starter, how a call is decided, or the frameworks index.
Install
npm install @agenticcontrolplane/governance @mastra/core zod
Minimal policy-enforced agent
import { Mastra } from "@mastra/core";
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import {
configure,
governed,
withContext,
} from "@agenticcontrolplane/governance";
configure({ baseUrl: "https://api.agenticcontrolplane.com" });
// Wrap the execute callback with governed(name, fn). Mastra calls the
// wrapped version transparently; governance runs on every dispatch.
const lookupRecord = createTool({
id: "lookup_record",
description: "Look up a record by ID.",
inputSchema: z.object({ id: z.string() }),
outputSchema: z.object({ id: z.string(), data: z.any() }),
execute: governed("lookup_record", async ({ context }) => {
return { id: context.id, data: await db.records.findOne({ id: context.id }) };
}),
});
const agent = new Agent({
id: "my-mastra-agent",
name: "My Mastra Agent",
instructions: "You are an ACP-governed agent. Use the tools available.",
model: "openai/gpt-4o-mini",
tools: { lookupRecord },
});
const mastra = new Mastra({ agents: { agent } });
app.post("/run", async (req, res) => {
const userToken = req.header("authorization")!.replace(/^Bearer /, "").trim();
await withContext(
{ userToken, agentName: "my-mastra-agent", agentTier: "interactive" },
async () => {
const result = await mastra.getAgentById("my-mastra-agent")!.generate(req.body.prompt);
res.json({ result: result.text });
},
);
});
What governed does
Wraps any async function with ACP’s pre/post hook protocol:
- POSTs to
/govern/tool-usewith the tool name, input, and the user JWT bound bywithContext. - If ACP denies, returns
"tool_error: <reason>"— the model sees this as a tool result and adapts. - If ACP allows, runs your function.
- POSTs the output to
/govern/tool-outputfor audit logging and PII scanning. - If ACP redacts, replaces the output with the redacted version.
- If ACP blocks the output post-hoc (a leaked secret pattern, for example), returns
"tool_error: <reason>".
Mastra’s createTool({...execute}) receives the wrapped function — control is invisible to Mastra.
Per-tier policy
withContext binds an agentTier to the request scope:
interactive— human at the keyboard, permissive default.subagent— invoked by another agent, no human in the immediate loop.background— autonomous, no human anywhere — most restrictive.api— programmatic call from your backend.
A destructive tool denied in background can be allowed in interactive. Match the tier to actual deployment reality.
Mastra-specific notes
- No framework-specific ACP adapter needed. The base
@agenticcontrolplane/governancepackage composes with Mastra directly. Noacp-mastrashim required. - Mastra’s
requireApproval: trueon a tool gates with a stream-level approval event — orthogonal to ACP. Use for human-in-the-loop on sensitive tools; complementary to per-call ACP policy. - Mastra processors (
inputProcessors/outputProcessors) handle message-content guardrails (PII detection, prompt-injection scanning, moderation) — complementary to tool-layer control, not a replacement. - No tool-dispatch middleware in
@mastra/core1.28. Inlinegoverned(execute)is the documented way to add per-tool control.
Adding more tools
const sendEmail = createTool({
id: "send_email",
description: "Send an email.",
inputSchema: z.object({
to: z.string(),
subject: z.string(),
body: z.string(),
}),
execute: governed("send_email", async ({ context }) => {
return await mailer.send({ to: context.to, subject: context.subject, body: context.body });
}),
});
const agent = new Agent({
...,
tools: { lookupRecord, sendEmail },
});
Wrap each execute with governed("..."). Tools outside this pattern bypass control.
Price and meter the model calls
governed is the interception plane — what your agent does. The proxy
plane covers what it spends. Both run against the same gateway, and init()
wires them together:
import { init } from "@agenticcontrolplane/governance";
init(); // call before you construct any model client
Model calls now land on the same trail as tool calls, carrying real cost,
tokens, and cache economics. Constructing clients explicitly instead? Pass the
options rather than calling init():
import { modelClientOptions } from "@agenticcontrolplane/governance";
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(modelClientOptions("anthropic"));
Shapes are "anthropic", "openai" (chat completions), and
"openai-responses". The last two are not interchangeable — /v1 serves
chat completions, /openai/v1 serves responses.
init() is all-or-nothing per provider: it sets the base URL and the key, or
it leaves that provider completely alone and tells you which one it skipped. If
OPENAI_BASE_URL already points at your own gateway, ACP won’t silently
reroute you.
Your coverage: interception ✓ (decorated tools) · proxy ✓ (model calls).
Limitations
- Only tools wrapped with
governedare covered. Plainexecutecallbacks bypass control. - The two planes are wired separately. The decorator covers tool calls; the proxy covers model calls. Both ship, but each is its own edit — see Price and meter the model calls above. Decorating tools without repointing the model client gives you control with no cost data.
- Pre-release.
@agenticcontrolplane/governanceis on 0.x. Pin exact versions.