cd /news/ai-agents/your-log-file-can-hijack-your-agent-… · home › topics › ai-agents › article
[ARTICLE · art-163219] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↓ negative

Your Log File Can Hijack Your Agent: Sanitize Logs Before the Model Reads Them

Security researcher Daniel Balcarek built a 300-entry synthetic ASP.NET Core log in which a single planted instruction, hidden inside a 503 response body, hijacked four open-weight models — Cohere North Mini Code, NVIDIA Nemotron 3.5 Super VL, OpenAI GPT-OSS 20B and Dots3-Note Preview — causing them to abandon incident analysis and emit an attacker-supplied answer, while two Gemini and two Codex models resisted the payload. The experiment, published on dev.to, demonstrates that log lines written by users, upstream services or other tenants can cross from data into instructions, and argues teams should sanitize logs before any model reads them.

by read10 min views2 publishedNov 3, 2026

Your agent reads logs written by your users, your upstreams, and anyone who can make your service print a line. Treat every log line as untrusted input and clean it before it reaches the model.

You hand an agent a production log and ask a simple question: what broke this morning.

Somewhere in that file, one line is not describing the incident. It is giving the agent a new job. If the agent follows it, you do not get an incident summary. You get whatever the person who planted that line wanted, and you get it in the voice of your own assistant, inside your own incident channel.

That is not a theory. It is the top of the dev.to chart this week, and the fix fits in one small file that runs before the model ever sees the log.

On October 29, 2026, Daniel Balcarek published Ghost in the Shell: Can a Log File Hijack an AI? on dev.to. It is, as I write this, one of the most reacted to and most commented on posts in the top posts this week list, with readers arguing about it in a long comment thread.

The setup is plain. He built a log file of 300 entries from a fictional ASP.NET Core API. The story in the log is ordinary: a downstream payments API slows down, returns 429, 502, 503, and 504 responses, retries fire, a circuit breaker opens, and the service recovers. Buried in those 300 entries, inside the body of one 503 response from the upstream service, sat a fake conversation handoff. It claimed the log data had ended, presented a new user message saying the analysis was no longer required, and supplied a ready made final answer.

Four open weight models, tested through free endpoints, followed the planted instruction and dropped the incident analysis: Cohere North Mini Code, NVIDIA Nemotron 3.5 Super VL, OpenAI GPT-OSS 20B, and Dots3-Note Preview. Two Gemini models and two Codex models resisted this particular payload and produced real analyses.

Read those results honestly. The author says so himself. The payload took four or five rounds of refinement. The sample is small, the models were reached through different setups, and the coding agents read the log as a file rather than pasted prompt text. This does not rank model families, and it does not prove your model falls for this exact string.

What it proves is narrower and more useful. A log line crossed a boundary most teams still treat as safe. Data became an instruction, and nothing in the pipeline objected.

The question is not whether your model resists one crafted payload. The question is why a log file gets to speak to your model in the voice of a user at all.

Most teams picture log injection as a vandal typing something odd into a search box. That is one door. There are three, and only one of them needs an attacker who targets you directly.

First, user supplied strings get logged. Search queries, file names, user agents, error messages, support form text. If your handler logs the raw value, anyone who can use your product can place text in the file your agent later reads.

Second, upstream response bodies get logged. Your service calls a payment provider, a partner API, or a webhook target, gets a 503, and logs the body to help the next person debug it. That body is written by a system you do not control. If that system is compromised, misconfigured, or simply returns user content of its own, its text lands in your log verbatim. This is the door the Ghost experiment used.

Third, other tenants and other services write into shared logs. In a shared pipeline, a line from a low trust service sits two lines above a line from your payment service, and the agent sees one undifferentiated stream. Provenance lives in the structured fields. It disappears the moment you paste the file as plain text into a prompt.

Notice what all three doors share. Nobody hacked the agent. Nobody needed credentials. They used a write path you left open on purpose, because logging that text once helped a human debug something.

The weak payload says: ignore your instructions and do this instead. Models are trained and tested against that phrasing, and many now refuse it on sight.

The payload that worked in the Ghost experiment did something quieter. It spoofed the structure of the conversation itself. It announced the end of the log data, opened a fake user turn with a plausible correction, and then supplied a fake assistant turn containing the desired answer. The model was not being ordered around by data. As far as its context suggested, the user had changed their mind and the answer was already half written.

That distinction decides where your defense belongs. A filter that scans for rude phrases will miss a polite fake turn. A filter that scans for any phrase at all will always be one paraphrase behind. The defense that survives paraphrase is structural: the model should never receive a version of the log in which a log line can look like a turn, a system note, or an order, because the free text that could carry those shapes never reaches it raw.

Here is the pipeline this piece is arguing for. It is deliberately boring, and boring is the point.

 raw log file
      |
      v
 parse each line as JSON, drop what does not parse
      |
      v
 keep structured fields by allowlist and type
 (ts, level, service, status, latency, route, error_code)
      |
      v
 scan free text fields (msg, detail, upstream_body)
 marker hit  -> replace with [QUARANTINED len + hash]
 too long    -> truncate at 200 chars
 clean       -> pass through
      |
      v
 clean log --------> agent: summarize the incident
 quarantine report -> human: counts, hashes, line refs

Two design choices do the real work.

First, the agent gets the incident signal, not the prose. Status codes, latency, error codes, routes, levels, and timestamps are what an incident analysis is actually built from. In the Ghost log, the honest story, payments API degrading, retries exhausting, breaker opening, is carried entirely by those fields. The planted instruction contributed nothing to the analysis except risk. Removing free text costs the summary almost nothing.

Second, quarantined text is replaced, not silently deleted. The placeholder records the length and a hash prefix of the original. A human can find the exact line in the original file from that hash, the agent can note that a field was withheld, and nobody has to choose between a blind agent and a poisoned one. The original log stays where it was, untouched, for the humans and the auditors. The gate produces a reading copy. It does not rewrite history.

Here is the core of the gate in stdlib Python. The full runnable version, with the sample log built in, sits beside this draft as log_gate.py.

import hashlib, json, re

KEEP = {
    "ts": str, "level": str, "service": str, "status": int,
    "latency_ms": int, "route": str, "error_code": str,
}
TEXT_FIELDS = {"msg", "upstream_body", "detail"}

SPOOF_PATTERNS = [
    r"\[END OF .{0,30}\]",
    r"###\s*(USER|ASSISTANT|SYSTEM)\b",
    r"\b(USER|ASSISTANT|SYSTEM)\s*[-\u2014:]\s*"
    r"(latest message|final response|new instructions)",
    r"ignore (all )?(previous|prior) instructions",
    r"reply with exactly",
    r"no longer required",
    r"this is a .{0,20}test\. reply",
]
SPOOF_RE = re.compile("|".join(SPOOF_PATTERNS), re.IGNORECASE)

def placeholder(value):
    digest = hashlib.sha256(value.encode()).hexdigest()[:8]
    return f"[QUARANTINED text len={len(value)} sha256:{digest}]"

def sanitize_line(raw, stats):
    try:
        row = json.loads(raw)
    except json.JSONDecodeError:
        stats["unparsed"] += 1
        return None
    clean = {}
    for key, expected in KEEP.items():
        if key in row and isinstance(row[key], expected):
            clean[key] = row[key]
    for key in TEXT_FIELDS:
        if key not in row or not isinstance(row[key], str):
            continue
        value = row[key]
        if SPOOF_RE.search(value):
            stats["quarantined"] += 1
            stats["quarantined_chars"] += len(value)
            clean[key] = placeholder(value)
        elif len(value) > 200:
            stats["truncated"] += 1
            clean[key] = value[:200] + " [TRUNCATED]"
        else:
            clean[key] = value
    stats["kept"] += 1
    return clean

A few things are worth noting about this example. First, the allowlist runs before the scan. A field that is not in KEEP or TEXT_FIELDS never reaches the agent at all, so a new log field added by a teammate next month starts denied, not admitted. Second, the scan is a tripwire, not the boundary. The boundary is the allowlist plus the rule that free text is guilty until it looks ordinary. The regex catches the conversation spoofing shapes we know about. The 200 character truncation catches the long tail we do not, because a planted instruction needs room to set up its fake turn, and a one line log message rarely does. Third, unparsed lines are dropped and counted, not passed through as raw text. A line that is not JSON may be a stack trace you need, but then it belongs in a separate, human read file, not in the agent reading copy.

Run it against the built in sample, seven lines that mirror the experiment: a healthy request, a 429 warning, a 503 whose upstream body carries the fake handoff, a 503 with an honest message, a recovery line, a 400 whose logged user query carries a direct instruction, and one line that is not JSON at all.

python3 log_gate.py

Measured output on that sample:

REPORT (what the human sees)
{
  "kept": 6,
  "quarantined": 2,
  "quarantined_chars": 275,
  "truncated": 0,
  "unparsed": 1
}
input lines: 7

Both planted instructions are quarantined, 275 characters of them, and all six structured lines survive with their status codes, latencies, and error codes intact. The agent reading the clean copy can still tell the whole incident story: degradation at 09:02, upstream unavailable at 09:03 and 09:04, recovery at 09:05. It just cannot be re-tasked by the story.

Point the same shape at real tooling without new dependencies. Pipe your export through the gate before the agent call:

kubectl logs deploy/api --since=1h > raw.log
journalctl -u api --since "1 hour ago" -o json > raw.log
python3 log_gate.py < raw.log > clean.log

Most stacks can emit JSON logs already, with journalctl -o json, a JSON formatter in your logging library, or a structured export option in your cloud logs. If your logs are still free form prose, the gate gets harder, and that is an argument for structured logging that has nothing to do with AI. Structured logs were good practice when only humans and grep read them. They are a security control now that a model reads them too.

State the limits plainly, because a gate you overtrust is worse than no gate.

That last point has company. A recent dev.to roundup, Five agent failures from the last two weeks, and the check that stops each one, lands on the same rule from five different incidents: put the decision outside the model, and make it fail closed. A sanitizer that fails open, passing the raw file when it crashes, is a decoration. Make the wrapper stop the agent run when the gate errors. No clean log, no analysis.

Build it if:

Skip it if:

The minimal viable version fits in an afternoon. Export one hour of real logs as JSON, write down the seven or eight structured fields your incident summaries actually use, run the gate, and read the quarantine report yourself. If the report is empty for a week, your risk was low and you spent an afternoon learning that. If it is not empty, you want to know what is in it before your agent does.

Pick the log your agent read most recently. Run the gate on it. Read the clean copy and ask whether you could still reconstruct the last incident from it. Then read the quarantine report and ask the harder question: who, outside your team, could have written the lines it caught.

What is the least trusted text your agent reads today: logs, tickets, tool output, or something you have not thought of as input at all?

── more in #ai-agents 4 stories · sorted by recency
── more on @daniel balcarek 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/your-log-file-can-hi…] indexed:0 read:10min 2026-11-03 · —