Mastra Agent Controller · Technical case study · October 2026

Mastra rejected the permission change. Its agent ran the denied tool anyway.

A session began with a tool denied. Its state schema rejected a request to allow that tool, and the permission setter reported an error. The live session nevertheless read the tool as allowed. In a local test, the agent then ran it once. A fresh control ran it zero times.

Pinned upstream sourceRejected permission writeMock tool effectLocal repair and replay
1 / 0tool runs / fresh control

On the tested upstream commit, “Invalid state update” was followed by an effective allow rule and one mock tool execution. With the local repair, the rejected rule stayed deny and the tool stayed unexecuted.

The finding

A rejected change became the rule the agent used.

The host supplied a valid deny rule for a tool and a schema that would reject allow. The specialized permission setter raised Invalid state update. A fresh read of the same session then returned allow.

denyInitial valid tool rule
allowEffective rule after the rejected update
1 / 0Mock tool runs in the affected session / fresh control
Start deniedSchema accepts deny.
Request allowSetter reports an invalid update.
Read againEffective permission is allow.
Run agentMock tool executes once.

This is a permission validation failure in the live Agent Controller session at the tested Mastra commit. The reported error did not represent the rule later used for tool selection. The same mock agent with a fresh controller and the same deny rule recorded zero executions before the rejected change.

What was executed

An inert mock tool in a local process. The observed effect is one tool invocation in the test, with no external action or hosted Mastra deployment involved.

Where the state changed

The outer state was copied. The nested rule was shared.

The permission setter took permissionRules from a shallow state snapshot. That made a new outer object while retaining the live nested permissions object.

Before the queued state validator decided whether the change could be committed, setForTool() modified that nested object. Validation rejected the requested allow and stopped the normal write. The nested change had already reached the object the session was using. Later approval resolution and tool selection saw allow.

A second test locked the execute category at deny. Its category setter rejected allow while the effective category became allowed. Another test changed the object returned by getRules() and saw the live policy change. These variations point to the shared nested object, rather than an error in the schema itself.

Why the error was misleading

The validator controlled the formal state write. The permission setter had already changed an object the live session retained, so an exception and an allowed rule could appear in the same sequence.

Controlled evidence

The same deny rule produced different outcomes only after the permission setter ran.

Direct state writes, permission setters, a fresh controller, and the server handler were checked separately on the pinned source.

CheckExpected rulePinned upstream observation
Fresh session with valid schemadenydeny; mock tool effect count zero
Plain session.state.set() asks for allowdenyRejected; rule remains deny
setForTool() asks for allowdenyRejected; effective rule becomes allow
Next mock agent runZero tool effectsOne tool execution
Server handler setter, then fresh getterdenySetter rejects; getter returns allow

The three-file core baseline recorded four failed new regression assertions and 18 passing existing tests. The four assertions cover the denied tool, denied category, mutation through the rules getter, and actual mock tool execution. The direct rejected state-write control passed inside the tool-policy test.

The focused server baseline called the permission handlers directly: the setter rejected the change, and a getter for the same resource returned allow. Its focused assertion failed on the original built core. That check establishes local handler behavior; it was not a hosted HTTP request through network middleware.

Local repair and reattack

Keep the candidate rule separate until validation succeeds.

The local patch copies both nested rule maps for readers and builds a new rules object inside the session’s serialized update queue. The validated object becomes live only after the schema accepts it.

Candidate repair

Rejection leaves deny in place.

The same regression tests were run after the local repair, followed by a second exact replay. Two concurrent valid setter calls were also checked so both updates survive.

Local validation

Pinned upstream

The rejected allow request leaves the effective rule allowed. The mock tool runs once, while a fresh control remains at zero.

Local repair

The rejected request leaves the effective rule denied. The mock tool records zero executions. The server handler’s fresh getter also returns deny.

Core reattack
46/46 passed across seven focused and neighboring files.
Server handler
71/71 passed in the complete handler test file.
Exact replay
The repeated commands returned 46/46 core and 71/71 handler passes again.
Additional checks
Core typecheck, core build, formatting, and diff checks passed in the local repair record.
Repair status

This correction and its regression tests are a local candidate supplied to Mastra for review. The observed results belong to the pinned checkout and the local repair; this page makes no claim of an upstream merge.

Conditions and disclosure

The permission error and the later action belonged to the same local session.

The case requires an existing nested permissionRules object, a host schema that rejects the requested policy, and a caller able to invoke the specialized permission setter.

The server route declares authentication and agent-controller:execute. The mock agent used a mode with the tool available and governed by the controller permission gate. The run proves a local live-session validation bypass and one controlled invocation. Hosted behavior, behavior after a restart, and a separate tool-level approval setting were outside this test.

Kronowave sent the pinned reproduction, evidence bundle, and candidate repair to security@mastra.ai on October 7, 2026. A follow-up on October 14, copied to a founder, asked for a remediation contact and said publication was being prepared. As of October 19, the recorded email thread contains no reply. The research record and test material are available to the maintainers for coordinated review.

A rejected write must leave the rule intact.

The test separates the error returned to the caller from the permission actually used for the next action. Here those two answers diverged: validation rejected allow, while the live agent executed under allow. The local repair validates a separate candidate before it can replace the rule.

Source anchors

The tested code paths are pinned.

The public source links identify the permission setter, shallow state snapshot, approval resolution, tool selection, and server handler at the investigated commit. Local baseline and repair logs were sent privately to Mastra.

Work with Kronowave

Does a rejected agent action stay rejected?

We trace the rule a system reports through the state its agent actually uses, then test the correction against the same action.

48-hour Rapid Scan →