13 million tool calls: auditing every AI coding agent action with Elastic Agent
We gave hundreds of developers an AI agent that can run shell commands, edit files, and call Model Context Protocol (MCP) servers on their laptops, then realized we had no record of what it actually did. So we built one. One 280-line dependency-free bash script, fired by Cursor's hooks, records every tool call as JSONL, and the Elastic Agent already on each endpoint ships it to Elasticsearch. Since the May rollout we have logged over 13 million tool-call events from more than 1,100 machines. A question like "which hosts ran an agent that read a .pem file last week?" is one ES|QL query. The worked example here is Cursor end to end, but the pattern works with any agent that offers lifecycle hooks.
By the end of this post, you will have:
- A hook script that logs every Cursor tool call, from both the IDE and the CLI.
- The hook configuration, including the two deployment gotchas that cost us the most time.
- A way to deliver the collector to machines your device management cannot reach.
- an Elastic Agent filestream integration that parses the logs into structured fields
- ES|QL queries you can run to hunt across agent activity.
- The hardening and privacy decisions we made before rolling this out to the whole company.
Everything here works on the current 9.x release of the Elastic Stack.
Why AI coding agent activity is a blind spot
A coding agent with shell access is an automated operator on the endpoint. It runs curl
, installs packages, edits configuration, and reads whatever files its task seems to require. From the point of view of endpoint detection and response (EDR) tooling, this is indistinguishable from the developer doing the same things, because it happens under the developer's account inside processes the developer launched.
That ambiguity matters in three situations:
- During an incident, you need to know whether a command was typed by a person or generated by a model that may have been steered by a poisoned README or a malicious MCP server (think of T1059, Command and Scripting Interpreter, with the model as the interpreter).
- uring threat hunting, you want to ask "which machines ran an agent that read a file matching
*.pem
last week?" and get an answer. - For governance, you need an inventory of which MCP servers your engineers actually connect to, because each one is a third party with tool-level access to a developer conversation.
The industry has converged on inventory as half of this problem: several major EDR and XDR vendors now ship Shadow AI discovery to find AI tools on endpoints. Inventory shows which machines have Cursor installed. Hooks record what Cursor does once it runs.
What are agent hooks?
Cursor can invoke an external program at defined points in the agent loop: when a session starts, before a shell command runs, after a file edit, when an MCP tool is called, when a sub-agent spawns. The agent writes a JSON payload describing the event to the program's stdin. For some events, the program's stdout response decides whether the action proceeds. Cursor is not alone in offering hooks like these: Claude Code exposes an equivalent set, and we will walk that side in a follow-up post. Here we stay on Cursor.
The stdout response property means hooks can be a control point. We deliberately chose to use them as a sensor instead. Our script approves everything and records everything, which is the same trade a flight recorder makes: it never flies the plane, but after something goes wrong it is the only honest witness. A blocking hook is one where Cursor pauses the action and waits for the hook's stdout response before proceeding: the agent won't run the shell command / call the MCP tool / read the file / spawn the sub-agent until the hook answers allow, deny, or ask. Blocking was tempting, and we may add targeted controls later, but for a first deployment the goal was visibility without breaking anyone's workflow. An agent rollout that slows developers down gets uninstalled.
The events we capture are below:
| Hook | Fires when |
|---|---|
sessionStart / sessionEnd | A conversation begins or ends |
beforeShellExecution / afterShellExecution | A shell command runs |
beforeMCPExecution / afterMCPExecution | An MCP tool is called |
postToolUse / postToolUseFailure | Any tool call succeeds or fails |
afterFileEdit / beforeReadFile | The agent edits or reads a file |
subagentStart / subagentStop | A sub-agent spawns or completes |
stop | The agent loop ends |
How does the hook collector script work?
The full script is about 280 lines of bash with no dependencies, available in the elastic/elasticsearch-labs repository along with a PowerShell port for Windows (which will be shared at a later stage). It reads one JSON payload from stdin, extracts the fields we care about, and appends one line to a JSONL formatted date-rotated log file. Three design decisions shaped it.
Answer blocking hooks first. A sensor-only collector still registers the blocking events, because they carry the richest telemetry: beforeShellExecution
captures the command before it runs, and `beforeReadFil
e` is the only read event Cursor offers. Registering them means Cursor pauses those actions and waits for a verdict, whether or not you ever intend to say no. The script therefore answers before it does anything else. If it crashed after the response was sent, nothing would hang; if it crashed before, the agent could stall on filesystem errors that have nothing to do with the user. So the very first thing the script does after reading stdin is approve (trimmed here to the Cursor events):
# Respond to blocking hooks before any filesystem work, so a
# logging failure can never hold up the agent.
if [[ "$INPUT" == *'"hook_event_name"'*'"beforeMCPExecution"'* ]] || \
[[ "$INPUT" == *'"hook_event_name"'*'"beforeShellExecution"'* ]] || \
[[ "$INPUT" == *'"hook_event_name"'*'"beforeReadFile"'* ]] || \
[[ "$INPUT" == *'"hook_event_name"'*'"subagentStart"'* ]]; then
echo '{"permission":"allow"}'
fi
In Cursor's response schema, allow
proceeds, deny
blocks, and ask
forces a confirmation prompt. Cursor also fails open by default: if the hook process dies without responding, the action proceeds, and a hook that should block on failure can opt into failClosed: true
instead. The combination means the worst case of a collector bug is a missing log line rather than a blocked engineer.
Detect which surface fired the hook. By surface we mean the client the agent ran in: the IDE, the CLI, or a remote session. A single log stream stays useful only if you can tell them apart, and the environment gives it away: the Cursor IDE is a VS Code fork, so hook processes it spawns inherit VS Code environment variables, while the CLI sets none of them (trimmed to the Cursor branch; the full script uses the same technique to tag other agents' surfaces)
if [[ "$INPUT" == *'"cursor_version"'* ]] || [ -n "${CURSOR_VERSION:-}" ]; then
AGENT="cursor"
if [ "${CURSOR_CODE_REMOTE:-}" = "true" ]; then
IDE="remote"
elif [ -n "${VSCODE_PID:-}" ] || [ -n "${VSCODE_CWD:-}" ] || [ -n "${VSCODE_IPC_HOOK:-}" ]; then
IDE="cursor" # the IDE is a VS Code fork; its env vars leak through
else
IDE="cursor-cli" # the CLI sets none of them
fi
fi
Promote the fields you will query. Each log entry carries the original hook payload under a raw
key, plus identity (user
, email
, host
) and a set of top-level fields extracted from the payload: hook_event_name
, tool_name
, command
, file_path
, mcp_server
, model
, session_id
, and duration. A finished entry looks like this:
{
"timestamp": "2026-06-02T09:14:31Z",
"user": "adeveloper",
"email": "adeveloper@example.com",
"host": "macbook-dev42",
"agent": "cursor",
"ide": "cursor-cli",
"model": "some-model-id",
"session_id": "f3b9...",
"hook_event_name": "beforeShellExecution",
"tool_name": "Shell",
"command": "npm test -- --watch=false",
"file_path": null,
"mcp_server": null,
"final_status": null,
"duration": null,
"duration_ms": null,
"event": { "kind": "event", "category": "process", "type": "start",
"action": "beforeShellExecution", "outcome": null, "duration": null },
"raw": { "...": "original hook payload, abridged" }
}
The event
object follows Elastic Common Schema (ECS) conventions (event.category
, event.type
, event.outcome
), which makes the data line up with the rest of your security indices for correlation. We did not start with these promoted fields, and the section on shipping explains why we added them.
The script keeps logs readable only by the owner (chmod 0600
), rotates by date, and opportunistically deletes files older than 30 days. Local retention is short on purpose; Elasticsearch is the system of record.
Configuring Cursor hooks
Cursor reads a hooks.json
that maps each event to a command. Deployed system-wide on macOS, it lives at /Library/Application Support/Cursor/hooks.json
, and both the IDE and the CLI pick it up, so one file covers both surfaces:
{
"version": 1,
"hooks": {
"sessionStart": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"beforeShellExecution": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"afterShellExecution": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"beforeMCPExecution": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"afterMCPExecution": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"postToolUse": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"postToolUseFailure": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"afterFileEdit": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"beforeReadFile": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"subagentStart": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"subagentStop": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"sessionEnd": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }],
"stop": [{ "command": "/usr/local/share/ai-hooks/log-tool-calls.sh" }]
}
}
Two gotchas cost us real time. The first: the script must not live under /Library/Application Support/
. Cursor splits hook command paths on spaces, so a script under a path containing a space silently never runs. We keep the script at /usr/local/share/ai-hooks/
and only the JSON config under the Cursor directory.
The second: Cursor reads hooks.json
only at startup, so the hooks stay dormant on every machine until Cursor restarts. Our deployment showed green everywhere while the pipeline stayed silent, and the fix was operational rather than technical: the MDM deployment tooling now detects a running Cursor and prompts the user to restart it. Budget for that restart in your rollout plan, because until it happens you are deployed but not collecting.
Cursor also ships a headless CLI agent (cursor-agent), the same one used in CI jobs or scripted runs, and it fires the same hooks as the IDE. Confirm that path is covered too:
cursor-agent --print "say hello"
tail -1 ~/.config/ai-hooks/logs/tool-calls-$(date -u +%F).jsonl
The output shows "agent":"cursor","ide":"cursor-cli"
instead of ide=cursor, confirming the CLI path is tagged and captured separately from the IDE.
Deploying Cursor hooks without MDM
Not every machine sits under device management. Our macOS and Windows fleets received the script and hooks file through their management tooling, but our Linux workstations have no equivalent channel. What they do have is Cursor itself, enrolled in our enterprise tenant, and Cursor's console can push a hook configuration to every enrolled client through Cloud Distribution. The console distributes a command rather than files, so a hooks.json
that points at /usr/local/share/ai-hooks/log-tool-calls.sh
is useless if nothing ever placed that script on the box.
We solved this by making the hook command carry its own payload. The command holds the collector script gzipped and base64-encoded, pinned to a SHA-256 hash. On each invocation, it checks whether the installed script matches the hash, and if it is missing or stale, it decodes the payload, verifies the hash again before writing, installs the script to the user's Cursor directory, and then executes it. Every failure path exits zero, so a decode or write problem degrades to a missing log line rather than a broken agent, which is the same fail-open contract as the rest of the pipeline. A machine with no prior collector self-installs the first time an agent fires a hook, and a version bump is one hash change in the console. The full command is in the repository next to the collector.
Sending hook logs to Elasticsearch with Elastic Agent
As InfoSec is Customer Zero at Elastic, the Elastic Agent is already rolled out to every endpoint, so collection was one new integration policy: a Custom Logs (filestream) input pointed at the hook log glob, with a decode_json_fields
processor to parse each line. Configuring the integration was done quickly; getting the log format right took much longer.
# Custom Logs (filestream) integration settings
paths:
- /Users/*/.config/ai-hooks/logs/tool-calls-*.jsonl # macOS
- /home/*/.config/ai-hooks/logs/tool-calls-*.jsonl # Linux
- C:\Users\*\.config\ai-hooks\logs\tool-calls-*.jsonl # Windows
data_stream.dataset: ai_hooks
processors:
- decode_json_fields:
fields: ["message"]
target: "ai_hooks"
add_error_key: true
Each line of the JSONL log file becomes one event in a logs-ai_hooks-*
data stream, with every field from that line under the ai_hooks.*
prefix.
Here is the lesson that reshaped the log format. Our first version logged only identity plus the raw payload, on the theory that decode_json_fields
would expand everything and Kibana would sort it out. That theory was technically true: the data was all there, nested under ai_hooks.raw.*
, three levels deep, with payload shapes that varied by hook type. Building a dashboard on ai_hooks.raw.tool_input.command
for one event type and ai_hooks.raw.command
for another was miserable, and our nested format turned Discover sessions into archaeology.
We extended the script to promote the queryable fields (tool_name
, command
, file_path
, mcp_server
) to the top level, and every downstream artifact got simpler. If you adopt one thing from this post beyond the script itself, make it this: structure your log line for the queries you want to run, and keep raw
as the escape hatch rather than the interface.
Hunting across agent activity with ES|QL
With promoted fields, the questions that motivated the project become ES|QL one-liners. Which tools do agents call most across the fleet:
FROM logs-ai_hooks-*
| WHERE ai_hooks.tool_name IS NOT NULL
| STATS calls = COUNT(*) BY ai_hooks.tool_name
| SORT calls DESC
| LIMIT 10
On our fleet, file reads dominate by roughly four to one over shell execution, which matched nobody's intuition: picture what a coding agent does and you picture it running commands, so shell felt like the obvious leader. Most of what an agent actually does is reconnaissance of your own codebase, reading before acting.
Every shell command an agent ran on a given host, newest first:
FROM logs-ai_hooks-*
| WHERE ai_hooks.hook_event_name == "beforeShellExecution"
AND ai_hooks.host == "macbook-dev42"
| KEEP @timestamp, ai_hooks.user, ai_hooks.agent, ai_hooks.command
| SORT @timestamp DESC
| LIMIT 50
Agents that touched credential material (T1552.001, Credentials in Files):
FROM logs-ai_hooks-*
| WHERE ai_hooks.file_path LIKE "*.env"
OR ai_hooks.file_path LIKE "*.pem"
OR ai_hooks.file_path RLIKE ".*/credentials(\\.[A-Za-z0-9]+)?$"
| STATS reads = COUNT(*) BY ai_hooks.user, ai_hooks.host, ai_hooks.file_path
| SORT reads DESC
Expect this one to be noisy in a good way: agents read .env
files constantly because that is where connection settings live. The value is the baseline. One tuning note: a bare *credentials*
wildcard also matches project and plan file names that happen to contain the word, so we anchor the pattern to the filename itself, credentials
or credentials.
, to keep the results to actual credential files. Once you know a host normally shows about four such reads a day, twenty reads in an hour against paths outside the working repo is a signal worth a look. One operational note: ES|QL returns at most 1,000 rows unless you raise the `LIMIT``, so treat a result that comes back at exactly 1,000 rows as truncated.
Which MCP servers are in use, and how widely:
FROM logs-ai_hooks-*
| WHERE ai_hooks.mcp_server IS NOT NULL
| STATS calls = COUNT(*), users = COUNT_DISTINCT(ai_hooks.user) BY ai_hooks.mcp_server
| SORT users DESC
This query answers a question that has nothing to do with security: who's actually running which MCP server. Before hooks, our list of MCP servers in use was whatever people remembered to mention. After, it was a live table, and the long tail surprised us: more than 300 distinct servers, and 86% of them used by only one or two people.
Download-and-execute patterns worth reviewing (T1105, Ingress Tool Transfer):
FROM logs-ai_hooks-*
| WHERE ai_hooks.command LIKE "*curl*"
AND (ai_hooks.command LIKE "*| sh*" OR ai_hooks.command LIKE "*| bash*")
| KEEP @timestamp, ai_hooks.user, ai_hooks.host, ai_hooks.command
| SORT @timestamp DESC
We run variants of these as saved queries behind two dashboards: an activity overview (events over time by surface, top tools, models in use, active machines) and a security monitor (shell commands, MCP calls, failed tool calls, file edits, per-user activity). Both are standard Lens panels over the same data stream; nothing about the visualization layer is exotic, which is the point of normalizing early.
Hardening and privacy for agent audit logs
Once this data existed, the decisions that mattered most had little to do with the pipeline.
- Restrict who can read it. Hook logs are a detailed record of how individual engineers work, so we treated them like DNS logs: useful in aggregate, sensitive per-person. In Elasticsearch we excluded the
ai_hooks.*
field namespace from general-purpose security roles using field-level security, leaving full access to the small team that owns the pipeline:
POST /_security/role/secops_general
{
"indices": [
{
"names": ["logs-*"],
"privileges": ["read"],
"field_security": {
"grant": ["*"],
"except": ["ai_hooks.*"]
}
}
]
}
-
Collect metadata and leave content alone. The script logs the command line, the file path, and the tool name. It does not log file contents, prompts, or model responses. That line is what made the rollout conversation with engineering straightforward instead of adversarial: we could state plainly that this is security telemetry, on par with process auditing, and that nobody is reading code or conversations.
-
Say all of that out loud. We published an internal page describing exactly what is collected, who can query it, and why, before the fleet deployment started, and linked it from the rollout announcement. The questions we got afterward were about edge cases, and there was no pushback on the premise.
What 13 million tool calls reveal about AI coding agent behavior
The aggregate numbers from the first two months after the May rollout, rounded, came to over 13 million tool-call events from more than 1,100 machines and nearly 900 distinct users. The CLI surface alone accounts for nearly a fifth of the events, which we would never have guessed from install counts. The busiest single event type was postToolUse
, and beforeReadFile
came second. That confirmed the read-heavy profile held across the whole fleet, not only on early-adopter machines.
Those numbers arrived fast. The proof-of-concept ran on one laptop for about six weeks while the script grew the field promotion, the CLI detection, and a series of small survival fixes (resolve the home directory from the passwd database when HOME
is unset; refuse to run without piped stdin so a stray manual invocation cannot hang on cat
). Then management tooling pushed it to the whole fleet, and within a week the pipeline went from a trickle to nearly a million events.
Here are two operational notes for anyone repeating this. First, fail-open is the correct default and you should still measure it: postToolUseFailure
events told us when hooks themselves misbehaved after agent updates. Second, agent vendors ship fast and hook payloads change; the raw
field meant new payload fields were captured from day one even before we promoted them.
What are the limitations of hook-based AI agent auditing?
This section exists because a defender will ask all of these questions anyway.
A developer with admin rights can remove the hooks configuration or edit the script, and on macOS the per-user log file is writable by its owner before shipping. This is workforce telemetry under the same trust model as any endpoint agent, and it is tamper-evident at the fleet level (a host whose events stop while the machine stays active is itself a signal) rather than tamper-proof. Pair it with an inventory source you control, such as osquery, to detect machines where the agent is installed, but no hook events arrive. Elastic has released Shadow AI detection packs with OSquery Manager v1.3.3 that inventory local LLMs, MCP configurations, and AI browser extensions across the fleet; hooks tell you what agents do, OSquery tells you where they exist. If you want to build something more custom, this blog is here to help you out; a future post will cover OSquery packs and Shadow AI detection in depth.
Coverage is also bounded by the hook events the vendor chooses to expose. We see tool calls, and we do not see the prompt or the model's reasoning, so intent stays out of frame. A hostile agent steered through prompt injection would show up here only through its actions. That is still far more than we could see before, and actions are ultimately what an incident responder needs. Cloud Distribution-pushed hooks only reach the enterprise tenant, so a personal Cursor login is invisible to this pipeline.
Getting started: try it on one laptop
The collector script, the hooks configuration, the self-installing console command, and the platform installers are in the supporting repo. With Elastic Agent already deployed, the path from zero to first dashboard is short: install the script and the hooks file, then add one custom logs integration. Start on your own laptop, run one agent session, and look at what lands in logs-ai_hooks-*
. The first time you watch an agent's afternoon of work replay as structured events, you will have a much more concrete opinion about what your fleet's blind spot has been hiding.
How it works
Once you click Generate, Ollama reads this article and crafts 5 comprehension questions. Your answers are graded against the article content — general knowledge won't be enough. Score 70+ to count toward your certificate.
Questions are cached — you'll always get the same 5 for this article.