After anything goes wrong with an AI agent, the first question is always the same: what did it actually read?
You cannot answer that later if nothing was written down at the time. So you add logging, and the obvious implementation is to log what the tool returned.
That is where it goes wrong.
The log becomes the softer target
An audit trail that stores the rows holds, after a month of operation, a copy of every record any agent has ever read. Reconstructed from tool responses, sitting in a table or a log aggregator, and nobody has written a policy for it.
Your tickets table has a policy. Every read goes through it, per record. The audit log has whoever has log access, which is usually a much longer list including anyone who can search your observability platform.
You built a careful exposure layer and then created an unguarded mirror of everything that passed through it.
Identifiers and field names, never values
The design I settled on records what was reached, not what it contained:
{
"at": "2026-09-09T14:02:11+00:00",
"resource": "ticket",
"operation": "get",
"outcome": "ok",
"actor": "41",
"arguments": { "id": 7 },
"ids": [7],
"fields": ["id", "subject", "status", "user_id"],
"denied": 0,
"truncated": false
}
From that you can answer the question completely. Which records were exposed, to whom, through which tool, when, and which fields of them. To see the values you go back and read those ids from the source, where the policy still applies and the read is itself audited.
fields is the piece people leave out. Without it you know record 7 was read but not whether that included internal_notes, and the field list changes when the attribute changes.
There is one deliberate exception. arguments records what the agent supplied, including a search term, because "what was it looking for" is half of any incident. That is the agent's input, and it should be read back as untrusted text since a prompt can put anything there.
The distinction the agent must not see
get returns null for both a missing record and a denied one, so ids cannot be enumerated.
The operator needs to know which it was. A run of denials is somebody probing; a run of misses is a broken integration. Those need different responses, and the difference is invisible if both are logged the same way.
So the trail records them apart while the agent still cannot tell:
if ($model === null) {
$this->record($user, 'get', AuditEvent::MISSING, ['id' => $id]);
return null;
}
if (! $this->gate->allows($user, $model)) {
$this->record($user, 'get', AuditEvent::DENIED, ['id' => $id], [], 1);
return null;
}
Identical to the caller, different in the record the caller never sees. That asymmetry is the point of having an audit log at all, and it was not expressible before, because the old code could not tell the two cases apart internally either.
Refused calls are recorded too. One filter on an unexposed field is a typo. A run of them is probing, and that pattern only exists if failures are written down.
Failing closed, including here
The uncomfortable decision: by default a sink that refuses takes the read down with it.
AuditFailedException: The audit sink refused to record ticket.get, so the
result was withheld rather than served unrecorded.
This is the least popular thing in the package and I will make the argument for it. A trail with silent gaps cannot answer the question it exists for, and the moment a gap opens is exactly the moment it is worth the most to whoever caused it. Serving data you cannot account for is the failure the whole package exists to prevent, so the audit trail should not be the one component that degrades quietly.
It is configurable, because availability is a legitimate thing to want more:
'audit' => ['strict' => false],
That is a real trade and it is yours to make. What matters is that it is a decision and not an accident.
Where it goes
The default writes to a Laravel log channel, so it works on a fresh install with no migration and no table to forget. That is a starting point. Log files rotate, and "what did the agent read three months ago" is eventually a query.
So the sink is an interface. Bind your own and the package uses it:
$this->app->bind(AuditSink::class, YourDatabaseSink::class);
There is also an ArraySink shipped for your own test suite, because the useful thing to assert in an application test is that a tool call produced the record you expected.
The one line version
Log which records were exposed and to whom, and not what was in them. The source of truth already has a policy; your audit log does not.
catidegla/laravel-agent-kit, and there is a test asserting that no field value ever reaches an audit record.
Top comments (0)