DEV Community

Cover image for I took SSH away from my agent. Then my own tool started lying to it.
SangheeSon
SangheeSon

Posted on Edited on

I took SSH away from my agent. Then my own tool started lying to it.

This spring I wrote twice about not giving Claude SSH access to my home server. The argument was simple: a shell is too sharp, so give the agent a narrow tool instead. Structured answers, bounded scope, nothing it can rm -rf.

I still believe that. But six months of running it taught me the half I'd skipped.

A narrow tool that says something false is just as dangerous as a shell. Maybe more, because nobody double-checks it.

When I read my own tool's output, I bring context. I know I ran docker kill ten minutes ago. I know that port has always belonged to that container. An agent has none of that. It reads the words and acts on them. Every sentence the tool prints becomes a decision somebody else makes.

Eleven days on a Raspberry Pi

Before calling HomeButler 1.0, I put it on a Raspberry Pi with three apps, a watch service and a daily report --json, and left it for eleven days. I wasn't looking for crashes. I was reading every line it said and asking: is this true?

The first day was humbling:

  • I ran docker kill on a container. HomeButler filed it as an OOM, confidence: high.
  • It recorded a restart that never happened.
  • It suggested a command that doesn't exist.

None of these were crashes. The code ran fine. It just said things nobody had told it. It saw exit code 137 and guessed. It saw a timestamp field and filled it in. Every guess was plausible, and plausible is the problem.

The fixes were mostly deletions. oom only when Docker actually sent an oom event. A start time only when something actually started. I ended up with one rule for the whole project:

Say only what the machine reported.

The quiet day

The worst one was the most boring. On the first day nothing changed, report --json said the comparison could not be made.

"No significant changes" had been filed under the same kind as "I couldn't compare." For a person, that's a typo. For an agent, it's the difference between "all good" and "go investigate." Empty lists were also coming back as null, which a script reads as "missing," not "nothing."

So now there's a word for each case, and they never overlap:

skipped   the comparison could not be made
[]        the comparison was made and found nothing
Enter fullscreen mode Exit fullscreen mode

An all-clear the tool can't stand behind is worse than no answer at all.

"Completely removed"

After the soak, I was cleaning up the demo machine and ran install purge filebrowser. It said Completely removed.

filebrowser's database and settings were still sitting in two Docker volumes. They'd been left behind on the soak machine before that too, and I'd never noticed, because the tool told me it was done.

If an agent had run that purge for me, it would have reported back "done, everything's gone," and it would have been sincerely, confidently wrong. Now the answer lists what was actually left, under Removed, except:, with the volume names. A purge that really removed everything answers the same as before.

Read-only doesn't mean safe to read

Earlier this month I added homebutler mcp --max-risk read. An agent can look at the machine and change nothing. That felt like the finish line of the "no SSH" idea.

Then I found that one read tool, docker_top, returned each container's full command line. If you started something with --token=..., the agent got your token, in read-only mode.

"Read" described what a call does to the machine. I had been using it as if it also described what a call reveals. Those are different promises. docker_top now returns the process name, not its arguments, and the docs for every read tool say what it returns, not just that it changes nothing.

Year one

One more, because it's my favourite. For a while, every alert watch sent was dated:

0001-01-01 00:00:00
Enter fullscreen mode Exit fullscreen mode

On every provider. Discord, Slack, all of them. It went unnoticed until I pointed a real webhook at the Pi.

Freezing the words

Once I started seeing output as something an agent acts on, versioning looked different too. An agent branches on gone, new, replaced. If one of those words quietly changes meaning, its decisions change without anyone touching a prompt.

So 1.0 came with a promise: a tool, a field, or a word an agent branches on doesn't change before 2.0. A golden test checks every tool's answer field by field, and the build fails if something drifts.

That's also why so many of the soak fixes shipped before 1.0. Freezing a shape that said the wrong thing would have frozen the lie.

What I'd tell myself in April

Taking the shell away was the right first step. It shrinks what an agent can do. It doesn't shrink what it can be told.

If you're building tools for agents, I'd start with three questions about every line your tool prints:

  1. Did the machine actually say this, or did my code infer it?
  2. Can "nothing happened" be told apart from "I couldn't check"?
  3. Would I be fine if the agent repeated this to me as fact?

Most of the bugs I fixed in the last six months failed one of those. None of them showed up as a failing test.

Try it

HomeButler is still a single Go binary. No daemon, no database.

brew install Higangssh/homebutler/homebutler
homebutler report
homebutler mcp --max-risk read   # for your agent
Enter fullscreen mode Exit fullscreen mode

Repo: https://github.com/Higangssh/homebutler


Genuine question: what's the most confidently wrong thing a tool has told you about your own server? I'm collecting them, partly for test cases, partly for comfort.

This post was written with the help of AI. The incidents, the decisions and the numbers come from my own soak runs and the project's changelog.

Top comments (2)

Collapse
 
reidmarlow profile image
Reid Marlow •

Separating what a call changes from what it reveals is where read-only tooling usually fails. I ran into the same problem wrapping systemd inspection when the status output printed environment variables from the unit definition. The command changed nothing on the host, but the model got live credentials in its context and pasted them into the next shell call.

Collapse
 
higangssh profile image
SangheeSon •

Exactly. I changed docker_top to return only the process name too, and inspect never read env values in the first place. Thanks for taking an interest in the post!