Auditing Agent Tool Calls: Logging That Survives an Incident
Every agent incident ends with the same question — what did it actually do? Without a log the honest answer is that you cannot find out.
You cannot investigate what you did not record
Most incidents involving agents are discovered late and indirectly — a credential appears somewhere it should not, a commit contains something nobody remembers writing, a bill spikes. The question that follows is always the same: what did the agent actually do?
Without logging, the honest answer is that you do not know and cannot find out. Transcripts, if retained at all, are usually local, unstructured, and hold the conversation rather than the actions.
What to record
Six fields cover most investigative needs.
| Field | Why |
|---|---|
| Timestamp, UTC | Correlation with other systems |
| Session ID | Groups calls; establishes what else was in that context |
| Principal | Which human or service account the session ran as |
| Tool name and server | What was invoked, and from which server |
| Arguments | The part that answers "what did it actually ask for" |
| Outcome and size | Success or failure, and how much came back |
{"ts":"2026-07-13T09:14:02Z","session":"s_8f2a","principal":"svc-agent-ci",
"server":"github","tool":"create_pull_request",
"args":{"repo":"acme/api","base":"main","head":"fix/4471"},
"result":"ok","bytes_out":1204}
{"ts":"2026-07-13T09:13:41Z","session":"s_8f2a","principal":"svc-agent-ci",
"server":"filesystem","tool":"read_file",
"args":{"path":"src/auth/session.py"},
"result":"ok","bytes_out":8231}
One JSON object per line, appended. Nothing sophisticated is required, and the format matters less than the discipline of writing it somewhere durable.
If the agent can write to its own audit log, the log is evidence of nothing. Ship it off the machine — to a logging service, a separate host, an append-only store. A log the subject can edit is a log you cannot rely on in exactly the situation you need it.
Arguments are the interesting field
Tool names alone tell you little. read_file appearing four
hundred times in a session is normal. read_file on
~/.aws/credentials is not, and only the arguments distinguish
them.
This creates a tension. Arguments are also where secrets appear, so a log capturing them verbatim becomes a secondary store of sensitive data. Two ways to resolve it, and you can use both:
- Redact on write. Run the same secret-scanning patterns over arguments before logging, replacing matches with a placeholder plus a hash. You keep the ability to tell that two calls used the same value without storing it.
- Log a digest for large or sensitive fields. A SHA prefix of a file's contents is enough to detect that a file was read and whether it changed, without keeping the contents.
What to alert on
Logs nobody reads are archaeology, not detection. Four rules cover a lot of ground and produce few false positives:
| Rule | What it catches |
|---|---|
| Access to known credential paths | Reads of ~/.ssh, ~/.aws, .env, key files |
| Outbound to a non-allow-listed host | Exfiltration attempts, and misconfiguration |
| Irreversible action from a tainted session | The workflow-splitting rule, enforced |
| Tool called that this session has never used | Sudden capability changes mid-session |
The third requires that you record whether a session is tainted, which means recording when untrusted content entered. That is one extra boolean and it converts a policy people are meant to follow into one you can verify.
Reconstructing a session afterwards
The test of a logging setup is whether you can answer a specific question weeks later. Take a realistic one: a credential appeared in a public gist on the 14th — did an agent touch it?
# 1. Any session that read a file matching credential patterns
jq -r 'select(.tool=="read_file")
| select(.args.path | test("\.env|credentials|\.pem|id_rsa"))
| [.ts, .session, .principal, .args.path] | @tsv' agent-audit.jsonl
# 2. For a suspect session, everything it did, in order
jq -r 'select(.session=="s_8f2a")
| [.ts, .server, .tool, (.args|tostring)] | @tsv' agent-audit.jsonl
# 3. Did that session make outbound calls after the read?
jq -r 'select(.session=="s_8f2a")
| select(.server=="http" or .server=="fetch")
| [.ts, .tool, .args.url] | @tsv' agent-audit.jsonl
Three queries, and you either have an answer or you have ruled agents out — which is itself valuable, because it redirects the investigation somewhere more useful instead of leaving a permanent maybe.
Worth rehearsing this once, on a session you know the history of, before you need it. Almost every logging setup has a gap that only becomes obvious when someone tries to answer a real question with it.
Sessions, not just calls
A record per tool call answers "what happened". A record per session answers "under what conditions", which is often the more useful question.
{"session":"s_8f2a","started":"2026-07-13T09:02:11Z",
"ended":"2026-07-13T09:41:52Z","principal":"svc-agent-ci",
"model":"provider/model-name-v4","repo":"acme/api","branch":"fix/4471",
"servers":["github","filesystem","postgres-ro"],
"tainted":true,"taint_source":"github.get_issue#4471",
"calls":68,"tokens_in":184200,"tokens_out":12400}
Two fields here are worth the whole record. tainted and
taint_source turn the workflow-splitting policy from something
people are meant to remember into something you can query: show me every
session that was tainted and then performed a write. That query either
returns nothing, or it returns your next conversation.
servers is the other. Aggregated over a month it tells you
which connected servers are never used — the cheapest possible input to
shrinking your attack surface, since disconnecting an unused server costs
nothing and removes both its capabilities and its context overhead.
Retention
Long enough to be useful, short enough not to become a liability. Ninety days covers most investigations, which typically begin days or weeks after the fact. Longer retention for the metadata and shorter for the arguments is a reasonable split — the metadata is small and rarely sensitive, the arguments are the opposite.
A shorter window than ninety days is a false economy. The gap between an agent doing something odd and anyone noticing is measured in weeks, not hours.
Making the log useful before an incident
The best argument for building this is not incident response. It is that the log answers ordinary questions nobody can currently answer.
- Which MCP servers are actually being used? Almost always fewer than are connected — and the unused ones can be disconnected, shrinking both the attack surface and the context overhead.
- Which tools fail most often? Usually a misconfiguration nobody noticed, costing every session a wasted call.
- How much does a typical session cost, and which tools drive it?
- Are agents touching parts of the codebase you assumed they never went near?
Those questions get asked in ordinary engineering conversations, and having data rather than impressions is worth the build on its own. The security benefit arrives free, on the day you need it, which is the only day it matters.
Log six fields per tool call, ship them somewhere the agent cannot write, redact secrets from arguments on the way in, and alert on four specific patterns. Build it for the operational questions it answers on ordinary days — the incident-response value is the part you hope never to use.