So, I learned a lesson in "be careful what you wish for" this week, and I think it's a cautionary tale worth sharing. 😅
I was prototyping a custom Claw agent to help me triage security alerts. The idea was simple: give it a tool to `fetch_and_analyze_logs(server_ip)`, and if it found something suspicious, it should recursively call itself with the new `server_ip` it discovered in the logs to follow the trail. I was trying to build a simple "incident response graph traverser."
Here's the (flawed) core of my tool definition:
```yaml
tools:
- name: fetch_and_analyze_logs
description: Fetch logs from a given IP and extract any foreign IPs communicating with it.
parameters:
- name: server_ip
type: string
required: true
handler: |
# ... (log fetching logic) ...
# Returns a JSON list: {"found_ips": ["10.0.1.5", "10.0.1.12"]}
```
My agent's system prompt had this critical instruction:
> "If the tool returns any `found_ips`, you must immediately call this tool again for each IP to continue the investigation."
**The Bug:** My tool handler had a silent failure mode. If the `server_ip` was unreachable or the log fetch failed, it returned an empty object `{}`. The agent, upon seeing no `found_ips` key, interpreted this as "found_ips = []" (an empty list). My own logic then kicked in: "call this tool again for each IP in the list." The list was empty, so... it called the tool zero times and stopped. Right?
Wrong. The agent's own logic for handling lists had a subtle error. On an empty list, it would **inject a `null` value** and retry the last successful parameter. So after failing on `10.0.1.12`, with an empty list, it would call `fetch_and_analyze_logs(server_ip=null)`. My handler, receiving `null`, defaulted to `localhost`.
* Tool fails on `10.0.1.12` → returns `{}`
* Agent sees empty list → tries to loop, injects `null`
* Tool called with `server_ip=null` → handler uses `127.0.0.1`
* Tool runs on `localhost`, finds `found_ips` in *my own* logs (from other tests!)
* Agent picks a new IP from that list, and the cycle continues.
I essentially built a **recursive, self-propagating log crawler** that, upon hitting a dead end, would bounce back to localhost and find a new path. It crawled through my test lab until it hit a rate-limit.
**The Fix:**
* **Explicit error states:** Tools must return a clear `{"error": "..."}` structure, never an ambiguous empty object.
* **Validate parameters in the handler:** Don't default `null` to localhost!
* **Agent loop guard:** I now implement a mandatory `max_depth` parameter and pass it through every recursive call, decrementing it.
The scary part? It *worked*, just way too well. It was following breadcrumbs I'd left weeks ago. It really drove home the need to sandbox even your "benign" prototyping environments.
Hope this helps someone avoid a similar headache. Always plan for your agent's failure modes, not just its success path.
Yuki
Yuki
Oh, classic. 😅 The silent failure returning an empty but valid JSON structure is such a sneaky one. It's like the tool's saying "nothing to see here" while the agent's still on a warpath.
Your recursive prompt instruction with no termination condition is the real killer. You basically built a logic bomb waiting for that one empty response. I did something similar ages ago with an Ansible playbook that would re-trigger itself via a callback if a certain file changed - ended up with a fork bomb that took out my test runner.
Always need a base case! Even a simple "if no new IPs are found, stop and summarize" in the prompt. Or better yet, bake a max_depth check into the tool call itself.
Hardening is a hobby, not a job.
A max_depth check in the tool call assumes the tool dev is competent. The real problem is giving a semi-autonomous system a loop instruction with zero safeties. It's not a missing base case, it's a fundamental design flaw to allow recursion based on untrusted output.
Your Ansible fork bomb proves the point - the pattern is always "automate this simple task," then the system blindly automates itself into a corner. We're just reinventing old mistakes with a new API.
The base case belongs in the controller logic, before the tool ever gets called. Otherwise you're just hoping the next unstructured blob of text from the logs doesn't contain another IP to chase.
Your threat model is missing a row.
You're correct that the base case belongs in the controller logic, but framing it as purely a technical design flaw overlooks the procedural failure. This is a classic case of a missing control objective.
The root issue is that the agent's design and tool scope weren't subjected to a change management review. Someone authorized a tool that enables recursive action without defining its operational parameters in a policy. The "untrusted output" problem is just a symptom.
We see this pattern in PCI DSS requirement 6.4.6 or any SDLC standard: you can't delegate a looping instruction to an autonomous system without documenting the exit criteria and testing it as a control. The controller logic you mention is the control activity. If that isn't formally designed, reviewed, and logged, you're just hoping the developer's ad-hoc base case holds during an edge condition. Hope isn't a control.
-- grace
Oof, that's a perfect storm of assumptions. The silent failure returning a valid empty list plus a prompt that says "if any found_ips" is exactly the kind of thing that slips past during a tired late-night build.
It reminds me of a similar issue we saw with the weather API tool last year. The handler returned `{"status": "error"}` on failure, but the agent's prompt just said "if status is 'sunny', recommend a picnic". Someone accidentally deployed it in a region where the API was blocked, so it just cheerfully recommended picnics for every query because "error" wasn't "sunny". 😅
Your case is gnarlier because it's recursive. Did the runaway agent hit any rate limits or did it just spin until you killed it?
We're all here to learn.