<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Wieger van der Meulen - Elastic Security Labs]]></title>
    <description><![CDATA[Trusted security news & research from the team at Elastic.]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Wieger van der Meulen - Elastic Security Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte2c6b841aff36df4/6a88d9784acc96e3f324863d/security-labs-thumbnail.png</url>
      <link>https://www.elastic.co/security-labs/author/wieger-van-der-meulen</link>
    </image>
    <link>https://www.elastic.co/security-labs/author/wieger-van-der-meulen</link>
    <atom:link href="https://www.elastic.co/security-labs/rss/author/wieger-van-der-meulen.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Wed, 16 Sep 2026 22:13:16 GMT</lastBuildDate>
  <item>
    <title><![CDATA[13 million tool calls: auditing every AI coding agent action with Elastic Agent]]></title>
    <description><![CDATA[Cursor hooks and Elastic Agent capture every tool call, shell command, file read and MCP request as structured events you can hunt with ES|QL.]]></description>
    <content:encoded><![CDATA[<p>We gave hundreds of developers an AI agent that can run shell commands, edit files, and call <a href="https://modelcontextprotocol.io">Model Context Protocol (MCP)</a> 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 <a href="https://www.elastic.co/docs/reference/fleet">Elastic Agent</a> 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 <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> query. The worked example here is Cursor end to end, but the pattern works with any agent that offers lifecycle hooks.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4019dba27166ecb/6a85bb00078290658f3213c5/diagram.png" alt="" /></p>
<p>By the end of this post, you will have:</p>
<ul>
<li>A hook script that logs every Cursor tool call, from both the IDE and the CLI.  </li>
<li>The hook configuration, including the two deployment gotchas that cost us the most time.  </li>
<li>A way to deliver the collector to machines your device management cannot reach.  </li>
<li>an Elastic Agent filestream integration that parses the logs into structured fields  </li>
<li>ES|QL queries you can run to hunt across agent activity.  </li>
<li>The hardening and privacy decisions we made before rolling this out to the whole company.</li>
</ul>
<p>Everything here works on the current 9.x release of the Elastic Stack.</p>
<h2 id="whyaicodingagentactivityisablindspot">Why AI coding agent activity is a blind spot</h2>
<p>A coding agent with shell access is an automated operator on the endpoint. It runs <code>curl</code>, 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.</p>
<p>That ambiguity matters in three situations: </p>
<ol>
<li>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 <a href="https://attack.mitre.org/techniques/T1059/">T1059, Command and Scripting Interpreter</a>, with the model as the interpreter).  </li>
<li>uring threat hunting, you want to ask "which machines ran an agent that read a file matching <code>*.pem</code> last week?" and get an answer.  </li>
<li>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.</li>
</ol>
<p>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.</p>
<h2 id="whatareagenthooks">What are agent hooks?</h2>
<p>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.</p>
<p>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.</p>
<p>The events we capture are below:</p>
<p>| Hook | Fires when |
| :---- | :---- |
| <code>sessionStart</code> / <code>sessionEnd</code> | A conversation begins or ends |
| <code>beforeShellExecution</code> / <code>afterShellExecution</code> | A shell command runs |
| <code>beforeMCPExecution</code> / <code>afterMCPExecution</code> | An MCP tool is called |
| <code>postToolUse</code> / <code>postToolUseFailure</code> | Any tool call succeeds or fails |
| <code>afterFileEdit</code> / <code>beforeReadFile</code> | The agent edits or reads a file |
| <code>subagentStart</code> / <code>subagentStop</code> | A sub-agent spawns or completes |
| <code>stop</code> | The agent loop ends |</p>
<h2 id="howdoesthehookcollectorscriptwork">How does the hook collector script work?</h2>
<p>The full script is about 280 lines of bash with no dependencies, available in the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/security-labs/ai-hooks-collector-scripts">elastic/elasticsearch-labs repository</a> 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.</p>
<p><strong>Answer blocking hooks first.</strong> A sensor-only collector still registers the blocking events, because they carry the richest telemetry: <code>beforeShellExecution</code> captures the command before it runs, and `<code>beforeReadFil</code>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):</p>
<pre><code># 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
</code></pre>
<p>In Cursor's response schema, <code>allow</code> proceeds, <code>deny</code> blocks, and <code>ask</code> 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 <code>failClosed: true</code> instead. The combination means the worst case of a collector bug is a missing log line rather than a blocked engineer.</p>
<p><strong>Detect which surface fired the hook.</strong> 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)</p>
<pre><code>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
</code></pre>
<p><strong>Promote the fields you will query.</strong> Each log entry carries the original hook payload under a <code>raw</code> key, plus identity (<code>user</code>, <code>email</code>, <code>host</code>) and a set of top-level fields extracted from the payload: <code>hook_event_name</code>, <code>tool_name</code>, <code>command</code>, <code>file_path</code>, <code>mcp_server</code>, <code>model</code>, <code>session_id</code>, and duration. A finished entry looks like this:</p>
<pre><code>{
  "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" }
}
</code></pre>
<p>The <code>event</code> object follows <a href="https://www.elastic.co/docs/reference/ecs">Elastic Common Schema (ECS)</a> conventions (<code>event.category</code>, <code>event.type</code>, <code>event.outcome</code>), 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.</p>
<p>The script keeps logs readable only by the owner (<code>chmod 0600</code>), rotates by date, and opportunistically deletes files older than 30 days. Local retention is short on purpose; Elasticsearch is the system of record.</p>
<h2 id="configuringcursorhooks">Configuring Cursor hooks</h2>
<p>Cursor reads a <a href="https://cursor.com/docs/agent/hooks"><code>hooks.json</code></a> that maps each event to a command. Deployed system-wide on macOS, it lives at <code>/Library/Application Support/Cursor/hooks.json</code>, and both the IDE and the CLI pick it up, so one file covers both surfaces:</p>
<pre><code>{
  "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" }]
  }
}
</code></pre>
<p>Two gotchas cost us real time. The first: the script must not live under <code>/Library/Application Support/</code>. Cursor splits hook command paths on spaces, so a script under a path containing a space silently never runs. We keep the script at <code>/usr/local/share/ai-hooks/</code> and only the JSON config under the Cursor directory.</p>
<p>The second: Cursor reads <code>hooks.json</code> 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.</p>
<p>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:</p>
<pre><code>cursor-agent --print "say hello"
tail -1 ~/.config/ai-hooks/logs/tool-calls-$(date -u +%F).jsonl
</code></pre>
<p>The output shows <code>"agent":"cursor","ide":"cursor-cli"</code> instead of ide=cursor, confirming the CLI path is tagged and captured separately from the IDE.</p>
<h2 id="deployingcursorhookswithoutmdm">Deploying Cursor hooks without MDM</h2>
<p>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 <a href="https://cursor.com/docs/agent/hooks#cloud-distribution-enterprise-only">Cloud Distribution</a>. The console distributes a command rather than files, so a <code>hooks.json</code> that points at <code>/usr/local/share/ai-hooks/log-tool-calls.sh</code> is useless if nothing ever placed that script on the box.</p>
<p>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.</p>
<h2 id="sendinghooklogstoelasticsearchwithelasticagent">Sending hook logs to Elasticsearch with Elastic Agent</h2>
<p>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 <a href="https://www.elastic.co/docs/reference/integrations/filestream">Custom Logs (filestream)</a> input pointed at the hook log glob, with a <code>decode_json_fields</code> processor to parse each line. Configuring the integration was done quickly; getting the log format right took much longer.</p>
<pre><code># 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
</code></pre>
<p>Each line of the JSONL log file becomes one event in a <code>logs-ai_hooks-*</code> data stream, with every field from that line under the <code>ai_hooks.*</code> prefix.</p>
<p>Here is the lesson that reshaped the log format. Our first version logged only identity plus the raw payload, on the theory that <code>decode_json_fields</code> would expand everything and Kibana would sort it out. That theory was technically true: the data was all there, nested under <code>ai_hooks.raw.*</code>, three levels deep, with payload shapes that varied by hook type. Building a dashboard on <code>ai_hooks.raw.tool_input.command</code> for one event type and <code>ai_hooks.raw.command</code> for another was miserable, and our nested format turned Discover sessions into archaeology.</p>
<p>We extended the script to promote the queryable fields (<code>tool_name</code>, <code>command</code>, <code>file_path</code>, <code>mcp_server</code>) 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 <code>raw</code> as the escape hatch rather than the interface.</p>
<h2 id="huntingacrossagentactivitywithesql">Hunting across agent activity with ES|QL</h2>
<p>With promoted fields, the questions that motivated the project become ES|QL one-liners. Which tools do agents call most across the fleet:</p>
<pre><code>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
</code></pre>
<p>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.</p>
<p>Every shell command an agent ran on a given host, newest first:</p>
<pre><code>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
</code></pre>
<p>Agents that touched credential material (<a href="https://attack.mitre.org/techniques/T1552/001/">T1552.001, Credentials in Files</a>):</p>
<pre><code>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
</code></pre>
<p>Expect this one to be noisy in a good way: agents read <code>.env</code> files constantly because that is where connection settings live. The value is the baseline. One tuning note: a bare <code>*credentials*</code> wildcard also matches project and plan file names that happen to contain the word, so we anchor the pattern to the filename itself, <code>credentials</code> or <code>credentials.&lt;ext&gt;</code>, 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.</p>
<p>Which MCP servers are in use, and how widely:</p>
<pre><code>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
</code></pre>
<p>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.</p>
<p>Download-and-execute patterns worth reviewing (<a href="https://attack.mitre.org/techniques/T1105/">T1105, Ingress Tool Transfer</a>):</p>
<pre><code>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
</code></pre>
<p>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 <a href="https://www.elastic.co/docs/explore-analyze/visualize/lens">Lens</a> panels over the same data stream; nothing about the visualization layer is exotic, which is the point of normalizing early.</p>
<h2 id="hardeningandprivacyforagentauditlogs">Hardening and privacy for agent audit logs</h2>
<p>Once this data existed, the decisions that mattered most had little to do with the pipeline.</p>
<ul>
<li><strong>Restrict who can read it.</strong> 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 <code>ai_hooks.*</code> field namespace from general-purpose security roles using <a href="https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/controlling-access-at-document-field-level">field-level security</a>, leaving full access to the small team that owns the pipeline:</li>
</ul>
<pre><code>POST /_security/role/secops_general
{
  "indices": [
    {
      "names": ["logs-*"],
      "privileges": ["read"],
      "field_security": {
        "grant": ["*"],
        "except": ["ai_hooks.*"]
      }
    }
  ]
}
</code></pre>
<ul>
<li><p><strong>Collect metadata and leave content alone.</strong> 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.</p></li>
<li><p><strong>Say all of that out loud.</strong> 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.</p></li>
</ul>
<h2 id="what13milliontoolcallsrevealaboutaicodingagentbehavior">What 13 million tool calls reveal about AI coding agent behavior</h2>
<p>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 <code>postToolUse</code>, and <code>beforeReadFile</code> came second. That confirmed the read-heavy profile held across the whole fleet, not only on early-adopter machines.</p>
<p>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 <code>HOME</code> is unset; refuse to run without piped stdin so a stray manual invocation cannot hang on <code>cat</code>). Then management tooling pushed it to the whole fleet, and within a week the pipeline went from a trickle to nearly a million events.</p>
<p>Here are two operational notes for anyone repeating this. First, fail-open is the correct default and you should still measure it: <code>postToolUseFailure</code> events told us when hooks themselves misbehaved after agent updates. Second, agent vendors ship fast and hook payloads change; the <code>raw</code> field meant new payload fields were captured from day one even before we promoted them.</p>
<h2 id="whatarethelimitationsofhookbasedaiagentauditing">What are the limitations of hook-based AI agent auditing?</h2>
<p>This section exists because a defender will ask all of these questions anyway.</p>
<p>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 <a href="https://www.elastic.co/docs/reference/integrations/osquery">osquery</a>, to detect machines where the agent is installed, but no hook events arrive. Elastic has released Shadow AI detection packs with <a href="https://www.elastic.co/docs/reference/integrations/osquery_manager">OSquery Manager v1.3.3</a> 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.</p>
<p>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.</p>
<h2 id="gettingstartedtryitononelaptop">Getting started: try it on one laptop</h2>
<p>The collector script, the hooks configuration, the self-installing console command, and the platform installers are in the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/security-labs/ai-hooks-collector-scripts">supporting repo</a>. 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 <code>logs-ai_hooks-*</code>. 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.</p>]]></content:encoded>
    <link>https://www.elastic.co/security-labs/blog/ai-coding-agent-audit-cursor-hooks</link>
    <guid isPermaLink="false">ai-coding-agent-audit-cursor-hooks</guid>
    <category><![CDATA[AI & Automation]]></category>
    <dc:creator><![CDATA[Wieger van der Meulen]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta8dba8aab4b14d99/6a85bb0443c0b72e8a2f0266/image1.png" length="0" type="image/png"/>
    <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[The security signal log tailing can't see: tracking npm cooldown removals with Elastic Agent]]></title>
    <description><![CDATA[A 40-line CEL integration snapshots .npmrc files every 6 hours to catch cooldown removals. This post walks through the three ways we broke filestream before landing on snapshot semantics.]]></description>
    <content:encoded><![CDATA[<p>npm's <code>min-release-age</code> setting tells npm to ignore any package version published less than a set number of days ago, keeping freshly compromised releases out of <code>npm install</code> during the window when they do the most damage. Getting the setting onto developer workstations is straightforward. Knowing when someone quietly deletes it is a different problem entirely, and log-tailing inputs are no help because they only fire when lines are appended to a file. We built a ~40-line Common Expression Language (CEL) integration in <a href="https://www.elastic.co/elastic-agent">Elastic Agent</a> that snapshots every <code>.npmrc</code> on a 6-hour heartbeat. When <code>min-release-age</code> disappears from the next snapshot, the pipeline marks it <code>cooldown.absent = true</code>. This post walks through that pipeline, the filestream approach we tried first, and what we learned from three iterations before landing on the final design.</p>
<p>The full path from config file to dashboard looks like this:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteb5ec3f717e84e68/6a7d839133fa8a2eca1ff9b8/vertical_flowchart.png" alt="npm's cooldown pipeline" title="npm's cooldown pipeline" /></p>
<h2 id="whydeveloperworkstationsarethenpmsupplychaingap">Why developer workstations are the npm supply-chain  gap</h2>
<p>CI/CD pipelines have their own supply-chain guardrails that can be managed at the enterprise level. Workstations are where the gap usually sits. A developer running <code>npm install</code> on a freshly compromised package is a different surface from the build pipeline, and the package manager itself is the right place to apply a delay before a fresh release becomes installable. npm's <a href="https://docs.npmjs.com/cli/v11/using-npm/config"><code>min-release-age</code></a> setting, available since npm 11.10, does exactly that: the value is a number of days, and npm excludes any version published more recently from resolution. In MITRE ATT\&amp;CK terms, the technique it blunts is <a href="https://attack.mitre.org/techniques/T1195/001/">Compromise Software Dependencies and Development Tools (T1195.001)</a>.</p>
<p>Most other package managers have an equivalent flag; <a href="https://cooldowns.dev">cooldowns.dev</a> tracks which ones, what each flag is called, and recommended values. This post focuses on npm, and recent attacks have happened, but the technique applies to any of them.</p>
<p>We enforce the setting with Jamf: a script writes <code>min-release-age=7</code> to each user's <code>.npmrc</code> and the machine-global npmrc, and re-applies once a day. A removal therefore self-heals within 24 hours, and the telemetry is what makes the removal visible at all: the 6-hour heartbeat catches the gap window before Jamf closes it, and shows us which hosts keep removing the setting.</p>
<h2 id="whatdoesannpmcooldownconfigfilelooklike">What does an npm cooldown config file look like?</h2>
<p>The target is small: npm writes the setting to a user-level <code>.npmrc</code>, typically 10 to 50 bytes.</p>
<pre><code>~/.npmrc    →    min-release-age=7        # days
</code></pre>
<p>Machine-global settings live in a handful of well-known paths (<code>/opt/homebrew/etc/npmrc</code> and <code>/usr/local/etc/npmrc</code> on macOS, <code>/etc/npmrc</code> and <code>/usr/lib/node_modules/npm/.npmrc</code> on Linux), so a complete inventory has to include those paths too. While we haven’t deployed a Windows version yet, we did include all the details to make this work in the Linux and Windows variants section. These can be found in the section Linux and Windows variants.</p>
<p>Two ingredients made this look easy. First, as InfoSec is Customer Zero at Elastic, the Elastic Agent is already rolled out to every endpoint and runs with the privileges to read these files. Second, the schema is essentially flat: one key, one value, one config file. So in theory, you configure a file input, tail the cooldown lines, and ship them to <a href="https://www.elastic.co/elasticsearch">Elasticsearch</a>.</p>
<p>The complication is that auth tokens for private registries (<code>//registry.npmjs.org/:_authToken=...</code>) live in the same <code>.npmrc</code> file. None of that can leave the host. Any approach has to filter the file body before it transits anywhere.</p>
<h2 id="firstapproachmonitoringnpmrcwiththecustomlogsfilestreamintegration">First approach: Monitoring .npmrc with the Custom Logs Filestream integration</h2>
<p>The <a href="https://www.elastic.co/docs/reference/integrations/filestream">Custom Logs Filestream integration</a> in <a href="https://www.elastic.co/docs/reference/fleet">Fleet</a> wraps Filebeat's modern <code>filestream</code> input and lets you specify path globs and an ingest pipeline. Note: this is a distinct Fleet integration from the older "Custom Logs" integration, which wraps the legacy <code>log</code> input. The two have different configuration surfaces and file-identity models, so if you're migrating between them the reference linked above is worth reading before either direction.</p>
<p>We configured the integration across all users on macOS, plus the system-global npm paths:</p>
<pre><code>/Users/*/.npmrc
/opt/homebrew/etc/npmrc
/usr/local/etc/npmrc
/etc/npmrc
</code></pre>
<p>The ingest pipeline did four things:</p>
<ol>
<li><a href="https://www.elastic.co/docs/reference/enrich-processor/grok-processor">Grok</a> the <code>message</code> field to pull out the <code>min-release-age=value</code> pair.  </li>
<li>Drop any line whose key wasn't <code>min-release-age</code>, so registry auth tokens never made it into Elasticsearch.  </li>
<li>Convert the value to a <code>long</code>.  </li>
<li>Remove the raw <code>message</code> and <code>event.original</code> fields before indexing.</li>
</ol>
<p>It worked, with three caveats that changed our minds about whether it was the right integration for this job.</p>
<h3 id="threefilestreambehavioursthatdontfitconfigfilemonitoring">Three filestream behaviours that don't fit config file monitoring</h3>
<h4 id="nativefileidentitybeatsfingerprintforsmallfiles">Native file identity beats fingerprint for small files</h4>
<p>Filebeat's default file identity strategy is "fingerprint", which hashes the first N bytes of a file to give it a stable ID across rotations. The fingerprint length defaults to 1024 bytes, and a file shorter than that minimum is silently held back from ingestion until it grows. A 22-byte <code>.npmrc</code> will never grow, so the data never moves. This is in line with the description of the Filestream integration and also apparent in the agent logs:</p>
<pre><code>"ingestion from some files will be delayed, files need to be at least
 1024 in size for ingestion to start"
</code></pre>
<p>The fix is to switch the integration's advanced options to use native file identity, which uses inode plus device and has no size floor. In our deployment on Elastic Agent 9.x, disabling fingerprint alone did not fall back to native; we had to flip both toggles explicitly.</p>
<h4 id="clean_inactiveandignore_olderarecoupled">clean_inactive and ignore_older are coupled</h4>
<p>We wanted periodic re-emission of each config file so a stale event could be distinguished from a current one. filestream's <code>clean_inactive</code> is the right knob for that, but it requires <code>ignore_older</code> to also be set. Together they cleanly trim files that haven't moved recently, which is useful for rotating log files and not useful for static config files that may sit unchanged for months. We disabled <code>clean_inactive</code>.</p>
<h4 id="filestreamemitsonappendnotonremoval">Filestream emits on append, not on removal</h4>
<p>This third learning is structural rather than a config gotcha. filestream is designed for log tailing: when bytes arrive at the end of a file, an event is emitted. When content disappears (because npm rewrites <code>.npmrc</code> without the cooldown line, or because the file is deleted), filestream sees a modification, but every resulting line is dropped by our allowlist filter, and nothing reaches Elasticsearch. The last "set" event for that host stays in the index indefinitely.</p>
<p>For application logs that's correct behavior, since log lines aren't usually retracted. For config file state monitoring, removal is the signal the adoption campaign needs most, and it never arrives. That's why we switched to CEL.</p>
<h3 id="whynvminflatesnpmversioncountsforcooldownadoption">Why nvm inflates npm version counts for cooldown adoption</h3>
<p>Machines routinely report more than one npm version, because every Node.js version installed through nvm (Node Version Manager) ships its own npm binary. The distortion stood out as soon as we checked test pulls from our <a href="https://www.elastic.co/docs/reference/integrations/osquery_manager">osquery manager integration</a> package-version inventory: one machine reported 34 distinct npm versions.</p>
<p>The config side of this is forgiving. <code>npm config set min-release-age 7</code> writes to the user-level <code>~/.npmrc</code>, and every npm installation on the machine reads that file, including the per-Node copies nvm installs. Enforcement is the strict side: versions older than 11.10 ignore the key. A machine carrying npm 11.12 in one nvm environment and npm 10.9 in another is only protected when the newer npm is the one running <code>npm install</code>. A count of machines with any capable npm overstates who is protected, which is one more reason to track the config file directly rather than infer adoption from version counts.</p>
<h2 id="secondapproachmonitoringnpmrcwithcelsnapshotsemantics">Second approach: Monitoring .npmrc with CEL snapshot semantics</h2>
<p>The <a href="https://www.elastic.co/docs/reference/beats/filebeat/filebeat-input-cel">CEL input</a> in Elastic Agent was designed for HTTP API polling, but it also exposes a <code>file()</code> function and a <code>dir()</code> function for filesystem access. That enables a different model from filestream. filestream tails lines as they arrive; CEL takes a snapshot of the whole file each interval. For state monitoring, the snapshot is what we want: the unit of interest is the current contents of <code>.npmrc</code> as a whole.</p>
<p>Here is the complete script we run, trimmed to npm (our production version watches the other package managers' config files with the same pattern, and carries an extra guard for non-UTF-8 file content):</p>
<pre><code>(
  (
    try(dir("/Users")).as(entries, type(entries) != type("") ?
      entries.filter(u,
        u.is_dir
        &amp;&amp; !string(u.name).startsWith(".")
        &amp;&amp; string(u.name) != "Shared"
        &amp;&amp; string(u.name) != "Guest"
      ).map(u, "/Users/" + string(u.name) + "/.npmrc")
    : [])
  ) + (
    try(dir("/home")).as(entries, type(entries) != type("") ?
      entries.filter(u,
        u.is_dir
        &amp;&amp; !string(u.name).startsWith(".")
      ).map(u, "/home/" + string(u.name) + "/.npmrc")
    : [])
  ) + (has(state.files) ? state.files : [])
).map(f,
  try(file(f)).as(content,
    type(content) == type("") ?
      {"file": f, "exists": false}
    :
      {"file": f, "body": string(content),
       "hash": content.sha256().hex(), "exists": true}
  )
).as(file_data, {
  "events": file_data.filter(fd, fd.exists).map(fd, {
    "message": fd.body,
    "file": {"path": fd.file, "hash": {"sha256": fd.hash}},
  }),
  "cursor": {"hashes": file_data.filter(fd, fd.exists)
    .map(fd, {"file": fd.file, "hash": fd.hash})},
  "url": state.url,
  "files": has(state.files) ? state.files : [],
})
</code></pre>
<p>The first block enumerates user home directories at runtime, with filtering that path globs can't express, and appends the machine-global paths from <code>state.files</code>. <code>try(dir(...))</code> and <code>try(file(...))</code> return an error string when the path doesn't exist, so each block checks the type of the result and collapses a missing directory or file to an empty result. A macOS host has no <code>/home</code>, a Linux host has no <code>/Users</code>, and the same script runs on both.</p>
<p>The initial state supplies the global paths and the poll interval is 6 hours:</p>
<pre><code>files:
  - /opt/homebrew/etc/npmrc
  - /usr/local/etc/npmrc
  - /etc/npmrc
  - /usr/lib/node_modules/npm/.npmrc
</code></pre>
<p><strong>An aside on Fleet:</strong> the integration to add in the UI is called <a href="https://www.elastic.co/docs/reference/integrations/cel">"Custom API using Common Expression Language"</a>, not "CEL". It exposes the full CEL runtime including file system access. Set <code>resource.url: file:///dev/null</code> to satisfy the required URL field without making an HTTP request, and put the initial state in the "Custom request cursor" YAML at the bottom of the form.</p>
<h3 id="howthecelintegrationevolvedemitonchangetombstonesandheartbeat">How the CEL integration evolved: emit-on-change, tombstones and heartbeat</h3>
<p>The CEL snapshot integration shown above is the third version. The path there is the useful part.</p>
<p><strong>Version one emitted only on change.</strong> Each heartbeat hashed every file and emitted an event only when the hash differs from the cursor, the state the input persists between runs. Efficient, and it produced the removal signal cleanly: npm rewrites <code>.npmrc</code> in place when a setting changes, the hash moves, the new snapshot carries no <code>min-release-age</code> line, and the ingest pipeline marks the event <code>cooldown.absent = true</code>.</p>
<p><strong>Version two added a deletion tombstone.</strong> Hash comparison can't see a file that stopped existing (<code>npm config delete min-release-age</code> removes <code>.npmrc</code> entirely when it's the only setting), because version one filtered out non-existent files before emitting. A one-block extension emitted a stub event, once, for any file that was in the cursor but no longer on disk.</p>
<p><strong>Version three replaced both with a heartbeat, because the dashboard demanded it.</strong> The adoption dashboard counts hosts whose cooldown state falls inside the selected time window. Under emit-on-change, a host that sets a cooldown emits exactly one event and then goes silent. Once that single event ages past the dashboard's window (say, <code>now-7d</code>), the host disappears from the "adopted" count even though the cooldown is still in place. We watched adoption climb during rollout and then start to erode a few days later, purely as an artifact of one-shot events aging out of the window. The telemetry was correct; the time-windowed view of it was not.</p>
<p>So the final integration re-emits each existing file's current state on every 6-hour heartbeat. Every host re-reports its cooldown posture four times a day, the windowed dashboard reflects live state, and a host that stops appearing is genuinely offline rather than merely quiet. Removal of the cooldown line still surfaces as <code>cooldown.absent = true</code> in the next snapshot, and a deleted file drops out of subsequent snapshots, so the tombstone becomes unnecessary.</p>
<p>The cost is volume: a snapshot per file per host per interval instead of one event per change. At a 6-hour cadence that is a few thousand small documents a day across the fleet, which is immaterial. At a 60-second interval it stops being immaterial: always-emitting at 60s ships the same unchanged state every minute, which we measured at roughly a thousand-fold more documents for zero added signal. If you adopt the heartbeat, set the interval in hours.</p>
<p>The detection latency for a removal is the heartbeat interval, 6 hours in our case. For an adoption campaign measured over days and weeks against a 7-day cooldown, that is not a meaningful constraint. If you need lower-latency, discrete removal events for alerting, run the emit-on-change variant instead; the snapshot model supports both.</p>
<h3 id="howtofilternpmrcauthtokensbeforetheyleavethehost">How to filter .npmrc auth tokens before they leave the host</h3>
<p>A snapshot-based integration sends the whole file body across the wire to the ingest pipeline. That's fine for config keys; it is not fine for <code>.npmrc</code> registry auth tokens. Filtering at the ingest pipeline is too late, because by then the tokens have already transited the network.</p>
<p>The fix is an agent-side <a href="https://www.elastic.co/docs/reference/beats/filebeat/processor-script">script processor</a> in the integration's Advanced options → Processors field. It keeps only <code>min-release-age</code> lines and drops everything else before the event leaves the workstation:</p>
<pre><code>- script:
    lang: javascript
    source: &gt;
      function process(event) {
        var msg = event.Get("message");
        if (msg == null) return;
        var filtered = [];
        var lines = msg.split('\n');
        for (var i = 0; i &lt; lines.length; i++) {
          var line = lines[i].trim();
          if (line.indexOf('min-release-age') === 0) {
            filtered.push(line);
          }
        }
        event.Put("message", filtered.join('\n'));
      }
</code></pre>
<p>Tokens never reach the wire. We verified by adding a test token to a <code>.npmrc</code> on a managed host, running a cycle, and confirming zero search hits for the token string in Elasticsearch.</p>
<h3 id="thenpmcooldowningestpipeline">The npm cooldown ingest pipeline</h3>
<p>The pipeline below is the npm-only variant of the one we run in production, and it is short enough to show whole. Grok extracts the key and value, a Set processor turns "no key found" into the explicit removal signal, and the raw message is removed before indexing:</p>
<pre><code>PUT _ingest/pipeline/npm-cooldown-workstation
{
  "description": "Parses min-release-age from .npmrc snapshots",
  "processors": [
    {
      "grok": {
        "field": "message",
        "patterns": [
          "(?&lt;cooldown.key&gt;min-release-age)\\s*=\\s*(?&lt;cooldown.value&gt;[^\\n\\r]*)"
        ],
        "ignore_missing": true,
        "ignore_failure": true
      }
    },
    {
      "set": {
        "if": "ctx.cooldown?.key == null",
        "field": "cooldown.absent",
        "value": true
      }
    },
    {
      "set": {
        "if": "ctx.cooldown?.key != null",
        "field": "cooldown.unit",
        "value": "days"
      }
    },
    {
      "convert": {
        "field": "cooldown.value",
        "type": "long",
        "ignore_missing": true,
        "ignore_failure": true
      }
    },
    {
      "remove": {
        "field": ["message", "event.original"],
        "ignore_missing": true
      }
    }
  ]
}
</code></pre>
<p>Two details earned their place the hard way. The value capture is <code>[^\n\r]*</code>, which stops at both LF and CRLF line endings; an earlier revision used <code>[^ \r]*</code>, which truncated any value containing a space and could run across newlines. And <code>cooldown.unit</code> is set explicitly even though npm's unit is always days, because dashboard rows that read <code>7 days</code> stay unambiguous when the telemetry later grows beyond npm.</p>
<p>The <code>cooldown.absent = true</code> event is the payoff. The host was in the index six hours ago with <code>cooldown.key = min-release-age</code>; the next snapshot has no key; the dashboard shows the transition. That is the removal signal filestream could not produce.</p>
<h2 id="celvsfilestreamforconfigfilemonitoring">CEL vs. filestream for config file monitoring</h2>
<p>We ran both in parallel for a few weeks and then made a call.</p>
<p>The core reason is the shape of the data. filestream is built for append-only logs, where new lines arrive at the end of a file and the job is to harvest them. <code>.npmrc</code> is a state file: npm rewrites it in place when a setting changes and deletes it when the last setting is removed. The question the telemetry needs to answer, "what is the cooldown state of this host right now, and when did it change," is a question about the current contents of a file. Tail offsets can't answer it.</p>
<p>|  | CEL | Filestream |
| :---- | :---- | :---- |
| Designed for | State files (snapshot + hash) | Append-only logs (tail) |
| Cooldown-line removal detection | Yes (next snapshot marks <code>cooldown.absent</code>) | No |
| File deletion detection | Yes (host drops out of snapshots) | No |
| Snapshot semantics | Whole file | Per line |
| Auth-token posture | Filtered agent-side before transit | Same (agent-side allowlist) |
| Fleet integration | Custom API using Common Expression Language | Custom Logs Filestream |
| Config complexity | ~40-line CEL integration | Declarative path globs |
| State refresh / latency | 6h heartbeat | ~10s (tail) |</p>
<p>Running both meant every config change produced two events into two separate datasets, and we maintained two ingest pipelines with dashboards split across indices. The complexity cost outweighs any redundancy benefit, and the only thing filestream gives that CEL doesn't is faster detection latency, which is not a meaningful constraint for adoption tracking on a 7-day cooldown.</p>
<p>We also considered osquery and auditd before settling on CEL. osquery can read config file contents on a schedule through its <code>file</code> and <code>file_lines</code> tables, and auditd can fire on file writes, but neither produces a clean current-state signal across ingestion, and both would mean standing up a second collection path next to the agent already deployed for endpoint telemetry.</p>
<p>CEL won. End-to-end validation on a single macOS workstation:</p>
<p>| Scenario | Expected | Result |
| :---- | :---- | :---- |
| <code>npm config set min-release-age 5</code> | <code>cooldown.key = min-release-age</code>, <code>cooldown.value = 5</code> | Pass |
| <code>.npmrc</code> exists with no cooldown key | <code>cooldown.absent = true</code> | Pass |
| <code>npm config delete min-release-age</code> (deletes the file) | path absent from subsequent snapshots | Pass |
| Auth token line in <code>.npmrc</code> | No token in Elasticsearch | Pass |</p>
<h2 id="npmcooldownmonitoringonlinuxandwindows">npm cooldown monitoring on Linux and Windows</h2>
<p>macOS was the primary platform; the design generalizes cleanly.</p>
<p>Linux needed no integration change at all. The <code>/home</code> block in the integration above already covers it: on each platform, the directory that doesn't exist fails the type check and collapses to an empty list, and the other side fills in. The Linux variant runs the same integration through the same ingest pipeline into the same dataset, with <code>/etc/npmrc</code> and <code>/usr/lib/node_modules/npm/.npmrc</code> in the global watch list.</p>
<p>Windows is a separate integration on the same dataset, and it's the one variant we designed but haven't deployed: our own fleet has too few Windows machines to justify the rollout yet. We're documenting it anyway because Windows is the most common OS in corporate fleets, so for many readers this variant is the one that matters most. The integration enumerates <code>C:/Users/*</code> (forward slashes work on Windows under Go/CEL), excludes <code>Public</code>, <code>Default</code>, and <code>Default User</code>, and watches each user's <code>.npmrc</code> plus the system globals under <code>C:/ProgramData</code>. The Grok pattern's value capture, <code>[^\n\r]*</code>, stops at both LF and CRLF line endings, so it works unchanged on Windows without capturing the trailing <code>\r</code> in the value. The agent-side script processor is also unchanged.</p>
<h2 id="rolloutstatusandextendingbeyondnpm">Rollout status and extending beyond npm</h2>
<p>The pipeline is now reporting from several hundred macOS, each re-emitting its current npm cooldown state on the 6-hour heartbeat. We're sharing the technique at this stage so other security engineering teams can build on it; once the rollout reaches the full fleet we'll follow up with an org-wide adoption-rate post. The Windows variant stays on the shelf until our Windows population justifies the rollout; if your fleet is Windows-heavy, the design in this post is ready to adapt.</p>
<p>In production the same integration already watches the config files for pip, uv, pnpm, yarn Berry, and bun. That expansion deserves its own post, because cooldown values are not unit-comparable across package managers, and one manager's documentation and implementation disagree about the unit by three orders of magnitude. If you're extending this design beyond npm, check <a href="https://cooldowns.dev">cooldowns.dev</a> for each flag's unit before you compare values across tools.</p>
<h2 id="whatwelearnedaboutnpmcooldownmonitoringwithelasticagent">What we learned about npm cooldown monitoring with Elastic Agent</h2>
<ul>
<li>npm cooldown adoption telemetry is state monitoring. The signal that matters most is a host removing <code>min-release-age</code>, and append-driven log tailing cannot see that happen.  </li>
<li>A snapshot-based CEL integration produces the removal signal directly: re-emit each <code>.npmrc</code>'s current state on a 6-hour heartbeat, and a removal shows up as the key's absence in the next snapshot.  </li>
<li>A time-windowed adoption dashboard needs the heartbeat, not one-shot change events, which age out of the window and make adoption look like it's eroding when it isn't.  </li>
<li>Small config files (under 1024 bytes) need native file identity, not fingerprint, regardless of which integration you use.  </li>
<li>An agent-side script processor keeps <code>.npmrc</code> registry auth tokens on the workstation: tokens never reach the wire.  </li>
<li>nvm inflates capability counts: each Node.js version ships its own npm, all of them read the shared <code>~/.npmrc</code>, but only npm 11.10+ enforces the key. Track the config file, count enforcement-capable versions separately.</li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/security-labs/blog/npm-cooldown-removal-detection-elastic-agent</link>
    <guid isPermaLink="false">npm-cooldown-removal-detection-elastic-agent</guid>
    <category><![CDATA[Security Operations]]></category>
    <dc:creator><![CDATA[Wieger van der Meulen]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbd7f6e8ecebcfe29/6a7d8394a529e16d0759c958/cover.png" length="0" type="image/png"/>
    <pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>