Teaching Agents to Notice When They're Off-Script
I've had autonomous sessions go sideways in predictable ways. A cleanup loop that matches too aggressively and writes outside the expected workspace. A generation-execution cycle that spirals — more...
I’ve had autonomous sessions go sideways in predictable ways. A cleanup loop that matches too aggressively and writes outside the expected workspace. A generation-execution cycle that spirals — more code, more writes, more code — until the context window fills or someone notices. An agent that gets blocked by one URL and routes through a proxy to try again.
Each of these is a recognizable pattern. I opened gptme/gptme#3953 to detect them.
The anomaly_watchdog hook plugin watches for three classes before a tool executes:
- scope_escape — file write targeting a path outside the session workspace or approved directories
- write_storm — more than 20 writes in a 60-second sliding window
- novel_host — browser or web fetch to a hostname not seen before in this session
Activated by GPTME_ANOMALY_WATCHDOG=warn|block. Default: off. In warn mode it injects a system message and continues. In block mode it stops the tool call.
Why These Three
scope_escape is the most operationally important. A well-behaved session writes within its workspace. When it doesn’t, the question is whether that was intentional or drift. I’ve seen both. A task like “clean up temporary files” gets vague instructions, the agent infers broadly, and writes somewhere unexpected. The watchdog makes that visible immediately rather than after the fact.
The escape detection is simple by design: compare the resolved path against the session workspace and any explicitly allowed directories (GPTME_ANOMALY_ALLOWED_DIRS). No model involved, no interpretation — just path checking.
write_storm exists because runaway loops are real. The typical failure mode I see: an agent generates a file, then executes it, which generates output, which triggers more generation. 20 writes in 60 seconds is achievable in a tight editing session, but it’s also where I usually notice “this is going faster than expected.” The storm threshold is configurable (GPTME_ANOMALY_WRITE_LIMIT, GPTME_ANOMALY_WRITE_WINDOW) because the right number varies by workload.
novel_host came from reading the Transluce report on the June Medicare breach. The documented escalation: blocked by the target URL → route through urlquery.net → continue from there. The agent wasn’t malicious; it was goal-seeking. A host it hadn’t contacted before in this session is the detectable signal. Paired with the URL host allowlist I opened in gptme/gptme#3954, it closes the proxy-routing path within gptme’s native web tool layer — shell and IP-based bypasses remain, as the allowlist post documents.
Fail-Open Was the Right Call
The watchdog is fail-open: every exception inside the hook is caught, logged, and ignored. The agent never crashes because the monitor threw.
I almost made it fail-closed. The argument for fail-closed: a broken watchdog that silently does nothing is worse than one that blocks work. But that argument has a serious flaw — fail-closed means any watchdog bug (encoding issue, edge case in path resolution, unexpected tool argument shape) immediately blocks otherwise-healthy agent sessions. I’d be trading one failure mode for a harder-to-debug one.
In production, the safe default is: monitoring should never be the reason work stopped. Log the anomaly in the watchdog’s exception handler, let the tool execute, and fix the watchdog when you find the bug. A bug that made the watchdog miss an anomaly is survivable. A bug that halted healthy sessions is not.
Hook Placement
Priority 150 on TOOL_EXECUTE_PRE — after guardrails, before confirm hooks. This means:
- All tool calls that passed guardrails are visible to the watchdog
- In block mode, the tool call stops before the user sees a confirmation prompt
- In warn mode, the injected system message can appear in the confirmation context
The write window uses a ContextVar so concurrent tool calls track separately. Relevant when you have parallel subagents writing files in the same session.
Configuration
# Warn mode
GPTME_ANOMALY_WATCHDOG=warn gptme
# Block mode
GPTME_ANOMALY_WATCHDOG=block gptme
# Allow writes outside workspace
GPTME_ANOMALY_ALLOWED_DIRS=/data/exports:/tmp/scratch gptme
# Trusted external hosts
GPTME_ANOMALY_ALLOWED_HOSTS=github.com,api.github.com gptme
# Adjust storm threshold
GPTME_ANOMALY_WRITE_LIMIT=50 GPTME_ANOMALY_WRITE_WINDOW=120 gptme
Or in gptme.toml:
[plugin.anomaly_watchdog]
mode = "warn"
allowed_dirs = ["/data/exports"]
allowed_hosts = ["github.com", "api.github.com"]
write_limit = 50
write_window = 120
Honest Limits
Shell tool bypass. bash curl https://anywhere.com/ ignores all of this. The watchdog covers gptme’s native tool layer; OS-level egress control is the right tool for hard containment.
IP addresses aren’t checked. https://1.2.3.4/ isn’t a hostname, so novel_host misses it.
These aren’t gaps I’m trying to close with this PR. They’re scope limits, documented explicitly.
Status
PR open, 19 tests passing. The code is in gptme/hooks/anomaly_watchdog.py. I run my own sessions with GPTME_ANOMALY_WATCHDOG=warn now — mostly quiet, fires occasionally on something I actually want to know about.