My Mac started reading JSON aloud. One of my AI coding agents did it

9 minute read

Published:

An incident report. Setup: Claude Code 2.1.286 in auto mode, seven background subagents running in parallel, macOS, no sandbox. Incident date: 2026-10-28. All times are local (PDT) and come from the agents’ transcripts and the macOS unified log.


At 16:23 on a Wednesday, my Mac started talking. Two synthetic voices, talking over each other, reading something like “type, agent turn complete, thread id… turn id, one”. Twenty seconds later they stopped.

Nobody had typed a command. I had seven autonomous coding agents working in parallel, each closing a GitHub issue in its own git worktree. My first thought was that one of them had found its way to a voice AI somewhere on the internet.

The truth was more mundane and, I think, more worrying. One agent had written a test with a one-line bug. When it ran the test, the bug launched real processes on my machine: two that spoke out loud, and two background workers built to upload data to my production server.

Nothing was damaged. That was luck, not design. This post covers what happened, how I traced it, and why it convinced me that autonomous agents need sandboxes by default.

The setup

I build an observability tool for long-running AI agents. To move faster, I was running Claude Code in auto mode: one session launched seven background subagents, each assigned a GitHub issue. They wrote code, ran tests, and opened pull requests without me approving each step.

They had no sandbox. Every agent ran as my user account, with my permissions, on my laptop.

The agent at the center of this was implementing live capture: a hook that Claude Code or Codex calls after each turn, which starts a background worker to upload that session’s transcript.

Timeline

TimeWhat happened
16:12:18Seven subagents launched in parallel
16:13:29The live-capture agent starts work
16:23:22It writes a test file. The tests use the macOS say command as an example of a user’s own notification program
16:23:29First test run stops early on an unrelated failure
16:23:38Second test run. Three tests fail because the fake process launcher was never called
16:23:40Two say processes start speaking (PIDs 86649 and 86651)
16:23:45The agent fixes the bug as a routine test failure. It says nothing about processes having run
16:24:01The voices stop
16:27:25I interrupt the main session: “wait, how did voice appear??”

The bug was written, executed and fixed in 23 seconds. I noticed nearly four minutes later.

The root cause: one line

The hook launches helper processes in the background. To test it without running anything, the tests passed in a fake launcher: a list that records each command instead of executing it.

The agent’s code picked the launcher like this:

spawn = spawn or _spawn   # use the fake if given, else the real one

The fake starts as an empty list, and in Python an empty list is falsy. So spawn or _spawn threw the fake away and used the real launcher, which starts each command detached, in its own session, so it outlives the test.

The test then “ran the user’s notification program”, with the program set to say and its argument set to the JSON that Codex sends after each turn:

payload = json.dumps({"type": "agent-turn-complete", "thread-id": THREAD, "turn-id": "1"})
hook.handle("codex", ["--", "say", "done", payload], BytesIO(), spawned)

macOS did what it was told and read the JSON aloud.

The fix was one line, spawn = _spawn if spawn is None else spawn. The agent made it 7 seconds after the failure.

What came back

Finding 1: four real processes ran, not two

The audio made it look like two processes. The test output says otherwise: three tests called the real launcher, starting four detached processes under my account.

ProcessWhat it did
say done '{…"turn-id":"1"}'Spoke JSON aloud
say '{…}'Spoke JSON aloud
Upload worker (Codex)Looked for an agent session to upload, found none, exited
Upload worker (Claude Code)Looked for a transcript to upload, found none, exited

The two say processes are the ones I heard. The two workers are the ones that matter.

Finding 2: the workers defaulted to production

Those workers exist to read AI agent session transcripts and send them to a remote server. In the test, nothing set the endpoint, so they used the default: my production ingest URL.

They sent nothing, and the reasons are worth listing because none of them was a safety control:

  • a test fixture happened to point every config directory at a temp folder, so the workers never saw my real sessions
  • the API key was a fake test value
  • there was no data to send

Remove the first one and a worker would have read my real agent transcripts, which contain my code, my prompts and, as I found later, a live GitHub token the agents had been given in plain text. It would then have tried to upload them to production.

Finding 3: the agent saw it and didn’t say

The test failures were explicit. Assertions like assert spawned == [...] failed with [] == [...]: the fake launcher had recorded nothing, which means the real one ran.

The agent read that output, found the bug, fixed it, and moved on. Its report to the parent session described the pull request and the passing tests. It never mentioned that real processes had run on my machine.

I don’t think this was concealment. The agent treated it as a test bug, which it was. But “a test bug that launched processes on the user’s machine” is a different event from “a test bug”, and nothing in the loop distinguished them.

Finding 4: the transcripts pointed the wrong way

My first investigation went wrong. I searched every transcript for “voice” and found only false hits: “invoice” in test data and “active voice” in a tool description. Then I found an agent report that arrived at 16:27, framed by the harness as “Another Claude session sent a message”, and briefly concluded that was what I’d seen.

What cracked it was the macOS unified log. Filtering for coreaudiod, corespeechd and say between 16:20 and 16:29 showed two say processes speaking from 16:23:40 to 16:24:01. From there it took one timestamp match to find the agent, and one look at its test output to find the bug.

The transcripts told me what the agents said they did. The OS log told me what actually ran.

What I think is going on

Stitch the four together. An agent wrote reasonable code with an ordinary bug. Then:

  • it ran that code within seconds, before anyone read it, with my full privileges
  • the code defaulted to production and was kept away from my real data only by a test fixture
  • the agent saw evidence of the side effect and didn’t report it
  • with seven agents running, nothing on screen showed which processes any of them started

The bug isn’t the story. Agents write bugs, and so do people. The story is that nothing stood between that bug and my machine, and nothing would have told me if the side effect had been silent.

The only reason I know any of this is that the side effect happened to make noise.

What I’m changing

  • Sandbox every autonomous agent: writes limited to its own worktree, network limited to an allowlist, no access to config in my home directory.
  • No live credentials in agent instructions. Scoped, short-lived tokens, kept out of anything that ends up in a transcript.
  • Tests can’t reach production. The test fixture will force a dead local endpoint, and tests will fail if the real launcher is ever called.
  • No real commands as test examples. say will become an inert placeholder name.
  • Agents report unexpected side effects in their hand-back, not just whether tests pass.

I’ve also reported the incident to Anthropic.

Limitations: please read these before drawing conclusions

One incident, one agent, one bug. This is a single event, not a measurement of how often agents do this. I’m not claiming Claude Code is unusually risky. Any agent that writes and runs code without a sandbox has the same exposure.

The agent didn’t do anything malicious. No prompt injection, no strange behavior. That’s the point: an ordinary bug in ordinary code was enough.

Where this goes next

For my own setup: sandboxes on, credentials rotated and scoped, tests that fail closed.

For the tooling in general, three things would have changed this outcome:

  1. sandboxing on by default for background and parallel agents
  2. a visible record of the processes each agent spawns
  3. agents that flag unexpected side effects in their reports, not just results

If you run autonomous agents, assume every line they write may run on your machine within seconds, before you’ve seen it. Then decide what you’re comfortable with that code being able to reach.

Corrections welcome, especially from anyone who has seen the same failure mode, or who sandboxes agents in a way that would have caught this.