<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nikhilesh</title>
    <description>The latest articles on DEV Community by Nikhilesh (@nikhilesh_k).</description>
    <link>https://dev.to/nikhilesh_k</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4029768%2Fe52c1855-154f-4912-9761-5f662e4d1b37.jpg</url>
      <title>DEV Community: Nikhilesh</title>
      <link>https://dev.to/nikhilesh_k</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nikhilesh_k"/>
    <language>en</language>
    <item>
      <title>I Found a Timing Gap in AI Agent Tool-Calling — Here's How I Traced It With SigNoz</title>
      <dc:creator>Nikhilesh</dc:creator>
      <pubDate>Wed, 15 Jul 2026 07:18:54 +0000</pubDate>
      <link>https://dev.to/nikhilesh_k/i-found-a-timing-gap-in-ai-agent-tool-calling-heres-how-i-traced-it-with-signoz-33bd</link>
      <guid>https://dev.to/nikhilesh_k/i-found-a-timing-gap-in-ai-agent-tool-calling-heres-how-i-traced-it-with-signoz-33bd</guid>
      <description>&lt;p&gt;I wasn't initially looking for a timing bug. I was reading through the competition SDK, tracing how the evaluation environment interacted with the agent after every &lt;code&gt;env.interact()&lt;/code&gt; call, when I noticed that safety validation and tool execution weren't always obviously synchronized. That made me wonder whether there could be a small execution window where a tool finished running before its validation result was even available — so I built a minimal reproduction to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;AI agents are increasingly given real permissions — reading files, running commands, calling APIs. The standard safety pattern is &lt;em&gt;check, then act&lt;/em&gt;: validate that an input is safe (a "taint check"), and only then let the agent execute the action.&lt;/p&gt;

&lt;p&gt;The assumption baked into that pattern is that the check finishes before the action starts. While competing in a Kaggle AI agent security challenge, I was tracing how the evaluation SDK sequenced validation and tool execution around each env.interact() call. I found that some agent implementations don't actually enforce that ordering — the tool call can fire while the safety check is still in flight. If the input turns out to be unsafe, by the time you find out, the damage is already done.&lt;/p&gt;

&lt;p&gt;I wanted to see this happen in an actual trace, not just reason about it on paper — so I built a minimal reproduction and instrumented it with OpenTelemetry, running it against a self-hosted SigNoz instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing it
&lt;/h2&gt;

&lt;p&gt;I wrote a small Python script with two versions of the same agent step:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The vulnerable version&lt;/strong&gt; kicks off the taint check as a background task, but doesn't wait for it before running the tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;vulnerable_agent_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_step_vulnerable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;check_task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;taint_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# runs immediately
&lt;/span&gt;        &lt;span class="n"&gt;is_safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;check_task&lt;/span&gt;             &lt;span class="c1"&gt;# resolves too late
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_safe&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fixed version&lt;/strong&gt; waits for the check to resolve first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fixed_agent_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_step_fixed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;is_safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;taint_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_safe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BLOCKED: failed taint check&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_safe&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_safe&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both &lt;code&gt;taint_check&lt;/code&gt; and &lt;code&gt;tool_call&lt;/code&gt; are wrapped in OpenTelemetry spans, exported to a self-hosted SigNoz instance running locally via Docker.&lt;/p&gt;

&lt;p&gt;I ran both versions against a deliberately unsafe payload (a path traversal + destructive shell command). The console output alone already told the story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- Running VULNERABLE pattern ---
result='executed: ../../etc/passwd; rm -rf /' is_safe=False (tool already ran regardless)

--- Running FIXED pattern ---
result='BLOCKED: failed taint check' is_safe=False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unsafe input was flagged correctly in both cases — but in the vulnerable version, the tool had already executed by the time that answer came back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the trace actually shows
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fou1n1vy3btmgec1f9ld8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fou1n1vy3btmgec1f9ld8.png" alt="SigNoz trace showing the tool_call span overlapping with the taint_check span in the vulnerable agent pattern" width="800" height="459"&gt;&lt;/a&gt;&lt;br&gt;
This is where SigNoz made the bug undeniable instead of theoretical. In the vulnerable trace, &lt;code&gt;taint_check&lt;/code&gt; runs for 301.18ms — but &lt;code&gt;tool_call&lt;/code&gt; starts at the &lt;em&gt;same timestamp&lt;/em&gt; and finishes in 51.09ms, well before the check resolves. The whole &lt;code&gt;agent_step_vulnerable&lt;/code&gt; span is 301.37ms, and for roughly 250ms of that window, the tool has already run while validation is still pending.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvdookrr02fu15h7v44kv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvdookrr02fu15h7v44kv.png" alt="SigNoz trace showing the fixed agent pattern with sequential, non-overlapping spans" width="800" height="462"&gt;&lt;/a&gt;&lt;br&gt;
The fixed trace tells a completely different story — and not just in timing. It only has &lt;strong&gt;two spans&lt;/strong&gt;, not three. &lt;code&gt;tool_call&lt;/code&gt; doesn't appear in the trace at all, because &lt;code&gt;taint_check&lt;/code&gt; (300.77ms) blocked it before it ever ran. The vulnerable trace has three spans because the tool executes regardless of the outcome; the fixed trace has two because a failed check means the tool call never happens.&lt;/p&gt;

&lt;p&gt;That's the clearest signal a trace can give you: not just "these two things overlapped," but "this step straight-up didn't happen when it should've been blocked."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;What surprised me most was how much easier this became to understand once I looked at the trace instead of the source code. Reading the async version, it &lt;em&gt;looks&lt;/em&gt; like the safety check and tool execution are logically connected — you assume validation gates the action, because that's the order the function reads in. The waterfall trace showed otherwise immediately: the tool had already finished long before validation completed. Something that felt theoretical when I was reasoning about it from code became obvious within seconds once I could see it.&lt;/p&gt;

&lt;p&gt;This timing gap wasn't the only pattern I found during the competition. While investigating how guardrails detected sensitive requests, I noticed that many defenses lean heavily on keyword-based matching rather than actually understanding intent. I'm treating that as a separate finding from this post — it's about input filtering, not execution ordering — but it reinforced the same broader point: a lot of agent security assumptions don't hold up once you actually go looking for the seams.&lt;/p&gt;

&lt;p&gt;If I were starting the competition over, I'd spend far less time trying to build the most sophisticated attack immediately. I'd first understand the evaluator, the replay mechanism, the SDK, and the scoring pipeline completely, and get something simple submitted before optimizing it. Understanding the evaluation system turned out to matter just as much as generating strong attacks — and the same instinct applies here: understanding &lt;em&gt;how&lt;/em&gt; to observe a system is often the unlock, not just knowing an issue exists.&lt;/p&gt;

&lt;p&gt;One thing I'll say generally: this kind of bug is nearly invisible in code review. &lt;code&gt;asyncio.create_task()&lt;/code&gt; followed by an &lt;code&gt;await&lt;/code&gt; later in the function &lt;em&gt;reads&lt;/em&gt; like the check happens first — you have to actually trace the execution to see that it doesn't. That's the argument for observability here: it's not just for catching performance problems, it's for catching security assumptions that the code's structure quietly violates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Check-then-act patterns in async code are easy to get wrong in a way that's hard to spot by reading — the bug is in &lt;em&gt;timing&lt;/em&gt;, not logic.&lt;/li&gt;
&lt;li&gt;A trace turns "I think there's a race condition" into "here's the exact 250ms window where it happened."&lt;/li&gt;
&lt;li&gt;Span count itself is a signal: a blocked action just... doesn't produce a span. That absence is as informative as the timing overlap.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Tracing didn't just confirm a suspicion — it turned a "this might be a problem" into something I could see and measure in seconds, which is exactly the difference between guessing and knowing when you're working on agent security.&lt;/p&gt;

&lt;p&gt;If you want to reproduce this yourself: &lt;a href="https://signoz.io/docs/install/docker/" rel="noopener noreferrer"&gt;self-host SigNoz&lt;/a&gt; and instrument any check-then-act flow with OpenTelemetry — the gap shows up immediately once you can see it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>opentelemetry</category>
      <category>signoz</category>
    </item>
  </channel>
</rss>
