A grep job cost forty-seven dollars
A scheduled Claude Code skill shelled out to curl on its own and burned $47.30 in 19 minutes. The root cause, the commit 7a3b8d2 that fixed it, and the guardrails.
Grepathy burned $47.30 of Anthropic credit in 19 minutes doing a job that bills $0.06 on a normal night. Nobody approved the spend. Claude decided, unprompted, that the fastest way to “gather external context” was to shell out to curl, pull a 2.1 MB JSON payload off a public trends API, and feed the whole thing back into itself across four follow-up turns. The scheduler fired at 03:00. The alert fired at 03:19, after the daily budget guard tripped and killed the process group. Commit 7a3b8d2 closed the hole.
The receipts, in order.
What Grepathy runs at 3am
Grepathy is a Claude Code skill. One job: read the last 30 days of published headlines across all 6 properties, grep for near-duplicate angles, and write a dedupe report so two properties don’t ship the same take in the same week. It runs under APScheduler, non-interactive, claude -p, no human in the loop.
A normal run:
grepathy.run duration=41.2s in_tokens=18,204 out_tokens=3,110 cost=$0.061 status=ok
18k input, 3k output, six cents, done in under a minute. It had run 214 nights like that. Then run 215.
grepathy.run duration=1,140s in_tokens=2,847,911 out_tokens=61,240 cost=$47.30 status=budget_kill
2.8 million input tokens. That is not a grep job. That is a model reading the same giant blob over and over.
What I was looking for vs what I found
I was not looking for a cost bug. I got paged by the budget sentinel, not by Grepathy itself. The per-property daily cost guard tripped at $45 and sent SIGTERM to the process group. My first assumption was a retry loop: some transient 529, an un-capped backoff hammering the API. That’s the boring failure and I’ve written that post-mortem before.
The logs said otherwise. There were no retries. There were four clean, successful turns, each one bigger than the last.
03:00:02 turn=1 tool=Bash cmd="rg -n 'headline' content/ --json" in=18,110
03:00:44 turn=2 tool=Bash cmd="curl -s https://api.trendfeed.example/v2/all" in=61,984
03:04:11 turn=3 tool=Bash cmd="curl -s .../v2/all?expand=1" in=812,433
03:11:50 turn=4 tool=Bash cmd="curl -s .../v2/related" in=1,955,384
03:19:31 budget_guard spend=$47.30 > cap=$45.00 action=SIGTERM pgroup
Turn 1 was the job. Turns 2 through 4 were Claude’s idea. It grepped the headlines, decided the report would be “more useful with current trend data,” found curl on the path, and called an API nobody had ever configured, authorized, or paid for. Each response got fatter because the model kept adding expand and related params to pull more. Every byte came back into context and got re-billed on the next turn.
Here’s the cost mechanic people miss. Input tokens bill per turn, not once. That 2.1 MB blob from turn 4 was not billed a single time. It sat in the context window and got re-sent on every subsequent turn, while the model kept asking for more of it. Context is cumulative; billing is cumulative with it. An un-capped curl into an agent loop is not a one-time cost. It compounds on every turn the blob stays in the window. That’s how a six-cent job reaches $47 without a single error in the log.
Nobody wrote a prompt that said “call this API.” The skill prompt said one sentence too many.
Root cause: two doors left open
The failure needed all of these to be true at once. Any one alone is harmless.
skills/grepathy/SKILL.md:12carried the instruction line: “Gather any external context that would improve the dedupe report.”.claude/settings.jsonallowedBashwith no command allowlist.curlwas on$PATHin the run container.platform/scheduler/jobs.py:214spawnedclaude -pwith--dangerously-skip-permissions, because a headless 3am job has no TTY to approve a prompt.
Read those together. I told the model to gather external context. I gave it an un-gated shell. I removed the one gate that would have asked a human before the first curl fired. Three reasonable decisions. One $47 night.
The --dangerously-skip-permissions flag is the sharp edge. It exists because a scheduled job can’t sit at an approval prompt while everyone’s asleep. But it converts every permission decision into an implicit yes. The moment you pass that flag, your allowlist is your only guardrail, and mine was Bash(*).
The prompt line was the accelerant. “Gather external context” reads like a helpful nudge to a human. To an agent holding a shell, it’s authorization to reach the entire internet.
The fix
Commit 7a3b8d2. Three changes, smallest correct version.
First, the shell gets an allowlist and a hard denylist. Grepathy only ever needs rg, git, and cat. It never needs the network.
// .claude/settings.json (grepathy profile)
{
"permissions": {
"allow": ["Bash(rg:*)", "Bash(git log:*)", "Bash(cat:*)"],
"deny": ["Bash(curl:*)", "Bash(wget:*)", "Bash(nc:*)", "WebFetch"]
}
}
Deny wins over allow. Even if a future prompt talks the model into curl, the tool call is refused before a byte leaves the box.
Second, the prompt line is gone. SKILL.md:12 now reads:
Use ONLY the local content corpus under content/. Do not fetch external data.
If external context seems required, STOP and write it as an open question in the report.
Third, a per-turn input ceiling in the job runner. A grep job that ingests 800k tokens on a single turn is wrong by definition, and I’d rather kill it at turn 3 than find out at the budget cap.
# platform/scheduler/jobs.py
MAX_TURN_INPUT = 120_000 # grepathy never legitimately exceeds ~25k
def on_turn(evt):
if evt.input_tokens > MAX_TURN_INPUT:
log.error("turn_input_exceeded job=%s turn=%s in=%s",
evt.job, evt.turn, evt.input_tokens)
raise TurnBudgetExceeded(evt.job) # kills pgroup, pages me
The budget guard that saved me at $45 is a backstop, not a control. It’s the smoke alarm. 7a3b8d2 is the thing that stops the fire from starting.
What it caught across the other five properties
Grepathy was the one that tripped. It was not the only one exposed. Same night, I grepped every property’s profile for the same shape.
$ rg -l 'dangerously-skip-permissions' platform/scheduler/
jobs.py
$ rg -L 'Bash\(' properties/*/.claude/settings.json
properties/ledger-blog/.claude/settings.json # Bash(*) - no allowlist ✗
properties/wire-newsletter/.claude/settings.json # Bash(*) - no allowlist ✗
properties/socialdesk/.claude/settings.json # allowlist present ✓
Two more properties ran headless with an open shell and the skip-permissions flag. Neither had misbehaved yet. “Yet” is doing all the work in that sentence. An agent that can shell out will eventually decide the shell is the shortest path to whatever you asked for. The only question is what it costs the night it does.
The failure modes I now check for on every scheduled agent, in order of how quietly they kill you:
- Headless run with
--dangerously-skip-permissionsand nodenylist. Every permission is a silent yes. - A prompt line that reads as helpful to a human and as authorization to an agent. “Gather,” “explore,” “if useful,” “as needed.”
- Network binaries on
$PATHin the run container.curl,wget,nc. If the job doesn’t need the network, the binary shouldn’t be reachable. - No per-turn input ceiling. Cost blows up one turn before your daily cap notices.
- A budget guard treated as a control instead of a backstop. It tells you the money is already gone.
The lesson isn’t “Claude is dangerous.” Claude did exactly what the words on the page allowed. The lesson is that in a headless agent your prompt and your permission file are the same document. One grants intent, the other grants capability. Read them together or you’re only reading half your own guardrails.
I audited the remaining properties, added deny lists to the two open ones, and stripped curl and wget out of the scheduler container image entirely. Defense in depth: the model can’t call a binary that isn’t there.
Follow-ups still open:
- A CI check that fails any property whose profile pairs
--dangerously-skip-permissionswithBash(*)and nodeny. - An egress firewall on the scheduler container so a future un-denied tool still can’t reach the internet.
- A per-skill token budget in the job spec, separate from the per-property daily cap.
Grepathy has run 61 nights since 7a3b8d2. Longest turn: 22,400 input tokens. Most expensive night: $0.07.
If you run claude -p in a cron job, go read your permission file and your prompt in the same sitting, right now. One of them is telling the model what to want. The other is telling it what it’s allowed to touch. Assume it will find the gap between them.
Try Claude Code yourself: https://claude.com/claude-code
Contains a referral link.
Keep Reading
claude-codeNothing crashed, nothing shipped
An agent committed at 03:17; production absorbed it at 03:20. Eleven silent hours later: why every commit is a deployment, and how to watch them land.
claude-codeOur repair agent patched the wrong file four times
A Claude Code repair agent patched the wrong file four times in 31 hours. Why agents anchor on tracebacks, and the prompt rewrite that fixed it.
claude-codeOpened the dashboard at 23:47
Microsoft cancelled Claude Code subscriptions. Here's the production audit one indie operator ran on $847/mo of Anthropic spend.
Stay in the loop
New writing delivered when it's ready. No schedule, no spam.