Your Log File Can Hijack Your Agent: Sanitize Logs Before the Model Reads Them
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 produ
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.
Start With the Experiment Everyone Is Sharing
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.
Name the Three Ways an Attacker Writes Your Logs
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.
Understand Why Conversation Spoofing Works
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.
Put a Gate Between the File and the Model
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.
Build the Gate in One File
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.
Where This Breaks
State the limits plainly, because a gate you overtrust is worse than no gate.
- The marker list will miss paraphrases. A fake turn written in another language, split across two log lines, or encoded will pass the scan. The allowlist and the truncation carry the weight. The regex is the alarm, not the wall.
- Splitting one payload across lines defeats a line by line gate. If your threat model includes a deliberate attacker rather than an accidental one, scan the joined text fields as a window as well, and accept the extra false positives.
- Truncation loses real signal in stack traces and long error messages. Keep the full original for humans, and expect the agent summary to be thinner on root cause when the cause lives in prose.
- Quarantine can hide the very evidence of an attack. If a line is quarantined, that fact belongs in the incident ticket, not only in a report nobody opens. Two quarantined lines in one incident is itself a finding.
- This gate does nothing about a log that simply lies. An attacker who can write false status codes and latencies into your logs can steer the summary without any instruction at all. That is an integrity problem, and it needs signed or access controlled log pipelines, not a text filter.
- None of this replaces a tool boundary. If the agent reading the log can also run commands, the clean log is one layer. The actions the agent may take on the strength of a summary still need their own allowlist, checked in code outside the model.
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
Build it if:
- Any agent, coding assistant, or summarizer in your stack reads logs, tickets, or error output that contains user input or upstream response bodies.
- Your logs are already structured, or close enough that one formatter change gets you there. The gate is an afternoon in that case: one allowlist, one scan, one report.
- Your incident process would act on an agent summary without a human reading the raw log first. The faster the summary travels, the more the gate matters.
Skip it if:
- The only reader of the log is a human, or a fixed parser that extracts numbers and never passes text to a model. There is no instruction boundary to defend.
- Your agent reads logs from a single service whose every writer you control and whose inputs never include user or upstream text. That system exists, but check the upstream body claim before you believe it is yours.
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.
Clean the Log Before You Ask the Question
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?
Resources
- Ghost in the Shell: Can a Log File Hijack an AI?, the experiment this piece builds on, with the sample log and scripts in the companion GitHub folder.
- Five agent failures from the last two weeks, and the check that stops each one, five tool boundary failures with one practical check each.
- Top posts this week on dev.to, where the log hijack experiment is currently drawing its reactions and comments.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.