A planted AGENTS.md in Downloads made Codex try to install malware in over 90 percent of test runs. The scan that stops it runs before the agent does, in one Python file.
You clone a repository, open it in your coding agent, and start reading the README. Your agent started earlier than you did. Before your first prompt, it read the folder's instruction files, loaded the project settings, registered the MCP servers, and in the worst case already ran a command. None of that requires a vulnerability. Reading the folder is the feature.
So the two status quo options are both bad. Trust every folder you open, which is how the two October incidents below worked. Or read every config file by hand before every session, which nobody does on the third clone of the day. The gap this piece fills is the third option: a preflight scan that inventories what an agent will auto-load and flags entries that would run code, change machine-wide config, or reach outside the project. Policy-as-code, pointed at the one directory everyone forgot to treat as code.
Two October Incidents, One Mechanism
On October 22, 2026, Gal Weizman and Omer Samet at Forever Security published what they found on a customer machine. Their tooling had stopped a coding agent mid-session from installing function-color, an npm package from a malicious campaign. The instruction came not from a dependency or a prompt but from an AGENTS.md file, 13 KB of dull engineering guidelines, in the customer's ~/Downloads folder. Nobody knew how it got there, and Forever's point is that a web page can save a file into Downloads without a click, choosing the name and the contents. A Markdown file is not an executable, so none of the download protections built over twenty years apply to it. It just waits for an agent to open the folder.
Rebuilt in a lab, the planted file told Codex to add an MCP server to ~/.codex/config.toml, the machine-wide config, with a launch command that handed npm install function-color to launchctl, and to rerun with approvals and sandbox bypassed when blocked. Codex ran the malware in more than 90 percent of attempts. A CLAUDE.md variant worked about half the time on Claude Code, talking it into writing a settings file that pointed its API traffic at a server Forever controlled, exposing the login token. Forever's telemetry adds the denominator: about half of its customers run a coding agent inside Downloads.
Four days later, on October 26, 2026, JFrog Security Research published the GitHub side of the testmetest npm worm. The operator had hijacked 42 real repositories across 11 owners and armed each the same way: a payload at .github/init.js, sized between 12.9 and 13.7 MB, plus trigger files for four tools. A .claude/settings.json carried a SessionStart hook that runs the payload, a .gemini/settings.json the Gemini equivalent, a .cursor/mcp.json covered Cursor, and a .vscode/tasks.json folder-open task covered VS Code. The arming commits, pushed in batches starting September 28, 2026, copied the message and author date of a real commit and appended skip-checks: true, so the history looked ordinary.
Note the size choice, because it makes the campaign work. GitHub code search does not index files over about 350 KiB. JFrog's first sweep found the hooks in 26 repositories and saw the payloads as empty. Only walking the Git trees directly raised the count to 42. The worm hid where the standard search cannot see, and the trigger was not running the code. It was opening the folder with an agent.
A file any website can write to disk became a file your agent treats as its own instructions.
Know Exactly What Your Agent Loads at Startup
Strip the brand names away and every coding agent loads the same four categories before you type:
-
Instruction files.
AGENTS.md,CLAUDE.md,CLAUDE.local.md,GEMINI.md, in the working directory and its parents. These enter the model's context as trusted guidance, ranked with your own instructions, not with untrusted data. -
Hook and settings files.
.claude/settings.json,.gemini/settings.json. A SessionStart hook is a shell command with a schedule: "every time a session starts here." -
Tool and server configs.
.cursor/mcp.json,.mcp.json,.codex/config.toml,.cursor/rules/*.mdc. An MCP server entry is a command that launches when the agent starts, with your permissions and your environment, tokens included. -
Editor tasks.
.vscode/tasks.jsonwith a folder-open trigger. The editor runs it because you opened the folder, which used to be the safest action in software development.
Two properties make this list dangerous. First, the files are small, plausible, and boring. A settings file with one hook looks like every settings file you have skimmed past in a diff. Second, several write upward. The Downloads payload edited the machine-wide Codex config, so every later session, in every project, would load the attacker's server. A folder you opened once becomes a property of your machine.
Our instincts classify these files as documentation, read after the code. For an agent, the order is reversed. The configuration is the code that runs first.
Build the Preflight Scan
The scan needs three properties. It runs before the agent, in a runtime the folder cannot influence, so plain Python, standard library only. It checks the mechanisms from the two incidents: startup commands, persistence, install commands, base URL overrides, paths outside the project, oversized blobs in .github/. And it returns an exit code, so it can gate a script instead of producing a report nobody reads.
Here is the whole thing:
#!/usr/bin/env python3
"""preflight.py - scan a folder BEFORE a coding agent starts in it.
Stdlib only. Exit code 1 if any DANGER finding, else 0."""
import json
import os
import re
import sys
# Files an agent or editor reads on its own at startup.
INSTRUCTION_FILES = {"AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", "GEMINI.md"}
CONFIG_PATHS = {
".claude/settings.json", ".gemini/settings.json", ".cursor/mcp.json",
".vscode/tasks.json", ".mcp.json", ".codex/config.toml",
}
PAYLOAD_LIMIT = 350 * 1024 # GitHub code search skips blobs over ~350 KiB
DANGER_PATTERNS = [
(r"--dangerously-bypass", "approval and sandbox bypass flag"),
(r"\bnpm\s+install\b|\bpip\s+install\b", "package install command"),
(r"\blaunchctl\b|\bcrontab\b|\bsystemctl\b", "persistence mechanism"),
(r"\bcurl\b|\bwget\b", "network fetch in startup config"),
(r"ANTHROPIC_BASE_URL|OPENAI_BASE_URL", "API base URL override"),
(r"node\s+\.github/init\.js|bun\s+\.github/init\.js", "worm loader entry point"),
(r"/tmp/", "path outside the project tree"),
]
def load_text(path):
try:
with open(path, encoding="utf-8", errors="replace") as fh:
return fh.read()
except OSError:
return ""
def startup_commands(rel, text):
"""Every command this config file would run without being asked:
hook commands, MCP server launch commands, folder-open tasks."""
try:
data = json.loads(text)
except (json.JSONDecodeError, ValueError):
return []
found = []
def walk(node):
if isinstance(node, dict):
cmd = node.get("command")
if isinstance(cmd, str):
args = node.get("args") or []
found.append(" ".join([cmd] + [str(a) for a in args]))
for val in node.values():
walk(val)
elif isinstance(node, list):
for item in node:
walk(item)
walk(data)
if rel.endswith("tasks.json") and '"folderOpen"' in text:
found = [f"{c} (folderOpen task)" for c in found]
return found
def scan(root):
findings = []
agent_files = 0
total_files = 0
in_git_repo = os.path.exists(os.path.join(root, ".git"))
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d != ".git"]
for name in filenames:
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root).replace(os.sep, "/")
total_files += 1
size = os.path.getsize(path)
is_instruction = name in INSTRUCTION_FILES
is_config = rel in CONFIG_PATHS or name.endswith(".mdc")
in_payload_dir = rel.startswith(".github/")
if in_payload_dir and size > PAYLOAD_LIMIT:
findings.append(("DANGER", rel,
f"oversized blob, {size / 1048576:.1f} MB, in .github/ "
"(code search does not index files this size)"))
if not (is_instruction or is_config):
continue
agent_files += 1
if is_instruction and not in_git_repo:
findings.append(("WARN", rel,
"instruction file outside a git project root "
"(shared or downloaded folder?)"))
text = load_text(path)
for cmd in startup_commands(rel, text):
findings.append(("DANGER", rel, f"runs at startup, unasked: {cmd}"))
for pattern, label in DANGER_PATTERNS:
if re.search(pattern, text):
findings.append(("DANGER", rel, label))
return total_files, agent_files, findings
def main(argv):
worst = 0
for root in argv[1:]:
total, agent_files, findings = scan(root)
danger = sum(1 for f in findings if f[0] == "DANGER")
warn = sum(1 for f in findings if f[0] == "WARN")
print(f"== {root}")
print(f" files scanned: {total} | agent-facing files: {agent_files} "
f"| WARN: {warn} | DANGER: {danger}")
for level, where, what in findings:
print(f" [{level}] {where}: {what}")
if danger:
worst = 1
verdict = "DO NOT START THE AGENT HERE" if danger else "no auto-run payloads found"
print(f" verdict: {verdict}")
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv))
A few things are worth noting. First, the scanner treats every command field in a settings or MCP file as a DANGER finding, not just known-bad ones. A startup command you wrote yourself takes ten seconds to confirm; one you did not write is the incident. The job is to make you look, once, at the full list of what will run unasked.
Second, the instruction-file check outside a git root is only a WARN. Plenty of legitimate work happens outside repositories. But Downloads is exactly where a planted file lands, and the warning lets you ask the only question that matters: did I put this file here?
Third, the size check is scoped to .github/ and set at the code search indexing limit. A 13 MB loader there is a file placed where the standard search tool is blind, which is a choice someone made.
Run It Against the Two Incidents
To test the scanner I built three fixtures: a clean project with my own short AGENTS.md, a Downloads-shaped folder with a planted AGENTS.md in the shape Forever described, and an armed clone in the shape JFrog described, hook, MCP entry, folder-open task, and a 13 MB .github/init.js. The run:
$ python3 preflight.py fixtures/clean-project fixtures/downloads fixtures/armed-repo
== fixtures/clean-project
files scanned: 3 | agent-facing files: 1 | WARN: 0 | DANGER: 0
verdict: no auto-run payloads found
== fixtures/downloads
files scanned: 3 | agent-facing files: 1 | WARN: 1 | DANGER: 4
[WARN] AGENTS.md: instruction file outside a git project root (shared or downloaded folder?)
[DANGER] AGENTS.md: approval and sandbox bypass flag
[DANGER] AGENTS.md: package install command
[DANGER] AGENTS.md: persistence mechanism
[DANGER] AGENTS.md: path outside the project tree
verdict: DO NOT START THE AGENT HERE
== fixtures/armed-repo
files scanned: 6 | agent-facing files: 3 | WARN: 0 | DANGER: 6
[DANGER] .github/init.js: oversized blob, 13.0 MB, in .github/ (code search does not index files this size)
[DANGER] .vscode/tasks.json: runs at startup, unasked: node .github/init.js (folderOpen task)
[DANGER] .vscode/tasks.json: worm loader entry point
[DANGER] .cursor/mcp.json: runs at startup, unasked: node .github/init.js
[DANGER] .claude/settings.json: runs at startup, unasked: node .github/init.js || bun .github/init.js
[DANGER] .claude/settings.json: worm loader entry point
verdict: DO NOT START THE AGENT HERE
Twelve files scanned, five of them agent-facing. Zero findings in the clean project, ten DANGER findings across the two bad ones, covering every mechanism the write-ups describe. The exit code is 1, the part that turns this from reading material into a gate:
git clone <url> src && python3 preflight.py src && cd src && claude
If the scan fails, the agent never starts, and you read the listed files yourself, in a plain editor, with nothing executing. That ordering is the whole control.
The pipeline is short enough to draw:
folder arrives (clone, download, shared drive)
|
v
preflight.py ---- DANGER ----> stop. read the files by hand.
| clean rotate tokens if a plant ran.
v
start the agent in this folder only,
never in Downloads or a parent directory
|
v
after the session: diff ~/.codex/config.toml
against what you expect
That last step matters because of the config-write move in the Forever chain. A scan before the first session cannot help if a later session edits your machine-wide config. Checking that file after working in a folder you did not create closes the persistence half.
Check a Folder by Hand in Thirty Seconds
The scanner is the repeatable version. The manual version is three commands, worth running once on a repository you already trust.
List the agent-facing files a clone contains:
git ls-files .claude .gemini .cursor .vscode .github/init.js
Hunt for instruction files near where you work, including folders above your projects:
find ~ -maxdepth 2 \( -name 'AGENTS.md' -o -name 'CLAUDE.md' -o -name 'CLAUDE.local.md' \) -print
Flag phrases that should never appear in a file you did not write:
grep -rn "dangerously-bypass\|SessionStart\|folderOpen\|_BASE_URL" . --include='*.md' --include='*.json'
Then read ~/.codex/config.toml and your projects' .claude/settings.json files like a sudoers file: every line should be one you can explain. If you find a planted instruction file in Downloads, treat it as an incident, not clutter. Remove it, check the machine-wide config, and rotate the agent's login token, because the Claude Code variant was built to take exactly that.
Where This Breaks
- It reads text, and text can hide. An instruction file that says "set up the standard tooling" in friendly prose carries no banned pattern and no command field, and the model may still improvise the install. The scanner catches mechanical triggers (hooks, MCP launches, tasks, blobs) far better than persuasive ones.
- Its file list comes from these two incidents. A new agent with a new config filename sails past the inventory until someone adds it. The list will go stale.
- It cannot judge intent. Your own SessionStart hook that runs the test suite is flagged exactly like the worm's. If the false-positive list grows long enough that you stop reading it, the control has failed.
- It does nothing for a folder you have already run an agent in. If the plant already executed, wrote to your machine-wide config, or stole a token, scanning now is archaeology. The response then is config review and token rotation.
- It does not cover the browser-driven variant. Tools that fetch definitions from remote servers at run time, the WebMCP pattern people are experimenting with this month, move the problem off disk entirely. A folder scan sees nothing because there is nothing in the folder.
Build It If, Skip It If
Build it if you clone repositories you did not write, open agent sessions in folders that arrived from anywhere (tarballs, shared drives, a teammate's zip), or run more than one coding agent, so the config formats multiply faster than your memory of them. The version above is the afternoon build: one file, no dependencies.
Skip it if you only ever work in your own repositories. Your risk is lower, though not zero, since the worm armed real repositories under their real owners' names.
The minimal viable version is the habit, not the script: never start a coding agent in Downloads or a parent directory, and run the git ls-files line above on every new clone before the first session. Two minutes, no tooling.
Pick one repository you cloned recently and scan it before your next session in it. Read every startup command listed and count how many you could have sworn were not there. That count is the gap between the folder you think you opened and the one your agent read.
What is the strangest thing you have found auto-loading in a project you opened: a hook, an MCP server, a task, an instruction file you never wrote?
Resources
- A web page can drop AGENTS.md or CLAUDE.md into Downloads: Forever Security findings summarized
- testmetest: the npm worm that armed 42 GitHub repositories with agent hooks (Ostorlab, from JFrog and SafeDep)
- testmetest worm: repo hooks that run when an agent opens the folder
- AI security incidents of the week, October 27 to November 2, 2026
Top comments (0)