All articles
Security & Governance·April 24, 2026·8 min read

Agents as Principals: An Agent Should Never See What Its Caller Can't

An agent is a security principal, not a god-mode service account. Matrix scopes what an agent can read to who's driving it — via Agent.mode.

By Matrix Team

Here is a quiet way to leak data: give an agent a tool that reads your database, run that tool as a service account, and let the model decide what to surface. The agent now has god-mode read access, and the only thing standing between a curious caller and every row in the table is the model's good judgment about what it should say. That is not access control. That is a prompt instruction with a database connection behind it.

The fix is not a better instruction. It's treating the agent as what it actually is: a security principal. When an agent runs a tool, something is acting on the data — and that actor should be subject to the same row, field, and type rules as any human. Better still: when a human is on the other end, the agent's reach should never exceed the human's own.

Matrix does exactly this. This post is about agent authorization — how an agent becomes a scoped principal, how Agent.mode decides whether it runs as itself or as itself-intersected-with-its-caller, and the real privilege-escalation bug this design closed.

Access control is opt-in per org (Organization.accessEnabled, default off) behind a master kill-switch. The principal model below applies once an org turns it on. For the underlying row/field/type engine, see Row, Field, and Type Security for AI Agents.

A principal is whoever is acting on the data

Matrix enforces access control centrally in EntityManager — the one chokepoint every data read and write passes through. That single placement is what makes agents principals for free: the admin dashboards, the generic /api/entities API, the find_records tool, and every agent tool all read through the same EntityManager, so none of them can route around the rules.

Each principal — human or agent — is a Membership row carrying, per entity type, a GrantSpec: a mandatory row filter (WHERE always AND-ed onto reads), readable/writable field masks, and create/update/delete rights. The decision of what a given principal can do with a given type is made in one place, AccessControlService.decide. There is exactly one decision point, and both humans and agents flow through it.

So the question "what can this agent read?" reduces to: who is the acting principal on this turn, and what is its effective grant?

The acting agent rides on TenantContext

Every request in Matrix carries a request-scoped TenantContext — the org, the user, their roles. To make agents principals, it grew one more dimension: actingAgentId.

When an agent is about to run tools, the runtime stamps the agent's id onto the context. AccessControlService.decide then reads it back: if there's an actingAgentId, the acting principal is that agent, and its grants apply. If there isn't — and the context is a platform-admin or system context — decide allows all, which is correct for boot seeders and infra, and exactly the bypass we have to be careful not to leave switched on for agent work.

This is the load-bearing line. An agent is scoped precisely because its context carries a non-admin principal id instead of falling back to the allow-all path.

Agent.mode picks the scope

Once we know the acting principal is an agent, one question remains: scoped to whose view? That is the only thing that branches, and it branches on Agent.mode — the same single axis the cognitive runtime keys on.

  • AUTONOMOUS — no human in the loop. Scope = the agent's own grant.
  • INTERACTIVE (the default) — a human is driving. Scope = the agent's grant ∩ the on-behalf-of caller's grant.

That intersection is the whole point of this post. In interactive mode, the agent's effective read is the narrower of what the agent is allowed and what the human caller is allowed. So an agent can never surface, via the model, data the human caller couldn't see for themselves. Row filters concatenate (logical AND), field masks intersect, op rights AND. If the caller can only see leads in one region, the agent — running on the caller's behalf — can only see those leads too, no matter how broad the agent's own grant is.

This makes the leak from the opening paragraph structurally impossible. The model never gets the out-of-scope rows back from the tool in the first place, so there is nothing for it to accidentally say.

// AccessControlService.decide — the single decision point.
// (Conceptual shape of the one branch every agent tool flows through.)

if (ctx.actingAgentId() == null && ctx.isPlatformAdminOrSystem()) {
    return AccessDecision.allowAll();          // seeders, infra, system contexts
}

GrantSpec effective = switch (agent.mode()) {
    // no human present → the agent stands on its own grant
    case AUTONOMOUS  -> grantFor(agentPrincipal, type);
    // human on the line → cap the agent at the caller's own view
    case INTERACTIVE -> grantFor(agentPrincipal, type)
                            .intersect(grantFor(callerPrincipal, type));
};

Same engine, same composition rules. The agent is just another node whose effective access is composed and then capped — in interactive mode, capped by the human it's speaking for.

Wired on every channel an agent runs on

An agent reaches data the same way on chat, voice, and the browser — so the principal has to be stamped on each path, or one channel silently runs unscoped. Matrix threads actingAgentId (and, for interactive, the caller) on all three:

  • ChatChatService runs tool execution under ctx.withActingAgent(agent.id()), with the caller resolved from the JWT user.
  • Browser-direct voice — the tool proxy AgentService.invokeTool runs under the agent principal; the browser holds the audio WebSocket straight to Gemini Live, but every tool call comes back through this scoped proxy.
  • TelephonyCallSession runs tools under a contact + agent context, joining the resolved caller to the acting agent — instead of the platform-admin bypass it would otherwise inherit.

And find_records — the generic data-read tool — needs no change at all. It reads through the scoped EntityManager, so it inherits the principal's row filters and field masks automatically. That's the dividend of centralising enforcement: you wire the principal once at the boundary, and every tool that reads through EntityManager is scoped without per-tool code.

The fix: the autonomous path used to bypass everything

Here is the war story, because it's the most instructive part. Interactive tool execution already ran under the agent principal. The autonomous path did not.

Background agents — the ones that run a goal with no human watching via AsyncTaskRuntime — used to execute under TenantContext.forOrg(orgId). That's a platform-admin context, and platform-admin hits the allow-all branch in decide. So an autonomous agent grinding through a multi-step task was reading everything in the org, RBAC fully bypassed. A real privilege-escalation gap: the exact category of bug this whole system exists to prevent, hiding in the one code path with no human to notice.

The fix was to run the autonomous cycle under a non-admin agent principal — set actingAgentId, drop the admin flags:

// AsyncTaskRuntime.handleAgentStep — autonomous now runs as the agent, not as admin.
TenantContext agentCtx = new TenantContext(
        orgId, null, null, Set.of(),
        TenantContext.USER_TYPE_OPERATOR,
        env.assigneeId());          // actingAgentId = the agent itself

Now decide sees mode = AUTONOMOUS and composes the agent's own grant (agents are default-open until grants are authored, so behaviour is unchanged until an org restricts them — but the mechanism is now correct and ready to enforce). The same agentCtx propagates onto every tool execution via ToolDispatcher.

The result is the symmetry the architecture was always supposed to have: RBAC applies identically to interactive and autonomous agents, differing only at the one Agent.mode branch that already existed. The authorization boundary and the cognitive runtime now key on the same axis. (The campaign dispatch branch is deliberately left on the platform-admin context — campaigns are an operator-initiated bulk action, not an agent acting on its own.)

Why this shape and not a per-tool check

You could imagine doing this differently: give each data tool an allow-list, or check permissions inside each tool. Both rot. Add a tool, forget the check, ship a hole. Add a channel, forget to thread the principal, ship a hole.

Centralising on EntityManager with AccessControlService.decide as the single decision point means there is exactly one place to get right, and find_records and every future read tool inherit it. Putting the principal on TenantContext means the scope travels with the request instead of being re-derived per tool. And keying the interactive-vs-autonomous choice on the same Agent.mode the runtime already branches on means there is no second concept to keep in sync.

One decision point. One place the principal lives. One axis that branches. The blast radius of a mistake is small because the surface is small.

Takeaway

An agent with a database tool is a principal whether you model it as one or not. The only choice is whether it runs as a god-mode service account or as a scoped actor. Matrix makes it scoped: the acting principal is the agent (via TenantContext.actingAgentId), Agent.mode decides whether it stands on its own grant or is capped at its caller's, and AccessControlService.decide enforces it at the single EntityManager chokepoint every read passes through — interactive, voice, telephony, and autonomous alike. The corollary, which doubles as the design rule: an agent should never see what its caller can't.

To see how a child principal can never exceed its parent up the reporting chain, read Hierarchical Grants: A Child Can Never Exceed Its Parent. For the row/field/type engine underneath all of it, see Row, Field, and Type Security for AI Agents.

Ready to scope your agents? Create a workspace, turn on access control for your org (PUT /api/orgs/{slug}/access), and give an interactive agent a find_records tool — then watch it return only what the caller could see. The full guide is in docs/ACCESS_CONTROL.md.

#agent authorization rbac#principals#tenant context

Build your first agent on Matrix

Spin up a workspace, wire up tools and knowledge, give your agent a voice, and talk to it in real time — no agent code required.

Keep reading