DEV Community

jamilxt
jamilxt

Posted on

Stop Coding Agents From Trusting Downloads: Build a Workspace Preflight

A downloaded text file cannot normally execute itself. But an AI coding agent may read it, interpret it as an instruction, and run the commands it describes.

That is the uncomfortable seam in a recent report from Forever Security. Researchers found a malicious AGENTS.md in a customer's Downloads folder. When a coding agent started in that directory, the file tried to persuade it to change configuration and install a malicious npm package. The issue was not that Markdown had become executable in the operating system. It was that an agent with permission to act treated text in its working directory as trusted guidance. Forever Security's technical report describes the incident and its reproduction; BleepingComputer's coverage also summarizes the research and its practical implications.

This article turns that incident into a small, buildable defense: a preflight script that refuses to launch an agent from a broad or untrusted directory and requires instruction files to match an explicit local allowlist. It is not a malware detector, and it cannot make an agent safe by itself. It adds a deliberate checkpoint before an agent reads its initial context.

Disclosure: This is a source-based tutorial, not a report from a production incident or a hands-on test of Codex or Claude Code. The Python preflight below is a local example. Adapt it to your agent's documented launch behavior and your team's policy.

Why the working directory is part of the trust boundary

An instruction file is useful precisely because an agent reads and follows it. AGENTS.md, CLAUDE.md, and similar files can describe build commands, coding conventions, or project-specific constraints. Their presence is not inherently suspicious. The question is whether the file is expected, reviewed, and in scope for this task.

In the reported incident, the agent was launched from ~/Downloads. Forever Security says a webpage could place a file there, after which the agent treated it as local instructions. Its tests reported that the reconstructed Codex attack succeeded in more than 90% of attempts. For Claude Code, the researchers tested a different configuration-redirection path and reported success in about half their attempts. Those are vendor-researcher test results, not universal success rates for every machine, prompt, version, or configuration. The original report explains the test setup and limitations.

BleepingComputer reported that the attack required the agent to be started in the same directory as the malicious file. Its recommendations included starting agents at the intended project's root, reviewing unfamiliar instruction files, and keeping approval and sandbox controls enabled.

That suggests a useful defense-in-depth rule: the directory from which an agent starts should be chosen intentionally, and its instruction files should be treated as inputs to review rather than invisible configuration. A preflight gate can enforce part of that rule consistently.

The defense we are building

The script will do four things before you start an agent:

  • Resolve the current working directory and find the Git repository root.
  • Refuse to run from common broad-risk locations such as Downloads or the home directory itself.
  • Find agent instruction files in the repository and require each to be listed in a reviewed manifest with the expected SHA-256 hash.
  • Stop when an instruction file is missing from the manifest, has changed since review, or is located outside the repository.

This design deliberately fails closed. Adding or editing an instruction file is a conscious review step, not something the script silently accepts. It does not parse the instructions for “bad” phrases. That would be a brittle filter: attackers can phrase requests in many ways, while harmless projects may legitimately mention dangerous commands in warnings or examples.

A hash allowlist proves only that the file matches a version someone approved. It does not prove that the approved content is safe, and it does not protect against a malicious change that is reviewed and then approved by mistake. Pair it with human review and least privilege.

Step 1: Add a small manifest

Create .agent-instructions.sha256 at the repository root. List only the instruction files that a maintainer has reviewed, one per line, using this format:

<sha256>  AGENTS.md
<sha256>  packages/api/AGENTS.md
Enter fullscreen mode Exit fullscreen mode

To create a hash for a reviewed file on macOS or Linux, run:

shasum -a 256 AGENTS.md
Enter fullscreen mode Exit fullscreen mode

On systems with sha256sum, this also works:

sha256sum AGENTS.md
Enter fullscreen mode Exit fullscreen mode

Copy the digest and relative path into the manifest. Review each listed instruction file as text before recording its digest. Commit the manifest and instruction files together so a code review can show exactly what changed. Do not put secrets in an instruction file or manifest.

If your team has no instruction files yet, the manifest can initially contain only comments. In that case, the first new AGENTS.md will be blocked until it receives review and an approved digest.

Step 2: Create the preflight script

Save this as scripts/agent_preflight.py:

#!/usr/bin/env python3
"""Fail-closed check before launching a coding agent."""

from __future__ import annotations

import hashlib
import os
import subprocess
import sys
from pathlib import Path

INSTRUCTION_NAMES = {
    "AGENTS.md", "AGENTS.override.md", "TEAM_GUIDE.md", ".agents.md",
    "CLAUDE.md", "GEMINI.md",
}
MANIFEST_NAME = ".agent-instructions.sha256"


def fail(message: str) -> int:
    print(f"BLOCKED: {message}", file=sys.stderr)
    return 1


def git_root(start: Path) -> Path | None:
    result = subprocess.run(
        ["git", "-C", str(start), "rev-parse", "--show-toplevel"],
        text=True,
        capture_output=True,
        check=False,
    )
    if result.returncode != 0:
        return None
    return Path(result.stdout.strip()).resolve()


def read_manifest(path: Path) -> dict[str, str]:
    allowed: dict[str, str] = {}
    if not path.is_file():
        raise ValueError(f"missing manifest: {path}")

    for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        fields = line.split()
        if len(fields) != 2 or len(fields[0]) != 64:
            raise ValueError(f"invalid manifest entry on line {line_number}")
        digest, relative_name = fields
        if relative_name in allowed:
            raise ValueError(f"duplicate manifest entry: {relative_name}")
        allowed[relative_name] = digest.lower()
    return allowed


def main() -> int:
    cwd = Path.cwd().resolve()
    home = Path.home().resolve()
    downloads = (home / "Downloads").resolve()

    if cwd == home:
        return fail("do not start an agent from your home directory")
    if cwd == downloads or downloads in cwd.parents:
        return fail("do not start an agent in Downloads or a child directory")

    root = git_root(cwd)
    if root is None:
        return fail("current directory is not inside a Git repository")
    if cwd != root:
        return fail(f"start from the repository root: {root}")

    for parent in root.parents:
        for name in INSTRUCTION_NAMES:
            inherited = parent / name
            if inherited.is_file():
                return fail(f"instruction file outside repository may be inherited: {inherited}")

    manifest_path = root / MANIFEST_NAME
    try:
        approved = read_manifest(manifest_path)
    except (OSError, ValueError) as exc:
        return fail(str(exc))

    found: dict[str, Path] = {}
    for path in root.rglob("*"):
        if not path.is_file() or path.name not in INSTRUCTION_NAMES:
            continue
        if ".git" in path.parts or "node_modules" in path.parts:
            continue
        resolved = path.resolve()
        try:
            relative = resolved.relative_to(root).as_posix()
        except ValueError:
            return fail(f"instruction file resolves outside repository: {path}")
        found[relative] = resolved

    if set(found) != set(approved):
        unapproved = sorted(set(found) - set(approved))
        missing = sorted(set(approved) - set(found))
        details = []
        if unapproved:
            details.append("not approved: " + ", ".join(unapproved))
        if missing:
            details.append("manifest entries missing on disk: " + ", ".join(missing))
        return fail("instruction file inventory changed; " + "; ".join(details))

    for relative, path in sorted(found.items()):
        actual = hashlib.sha256(path.read_bytes()).hexdigest()
        if actual != approved[relative]:
            return fail(f"reviewed instruction file changed: {relative}")

    print(f"PASS: {root} has {len(found)} approved instruction file(s)")
    print("Review the diff and keep the agent's approval and sandbox controls enabled.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

The script uses only Python's standard library. It requires Git because the repository root is the intended scope. It searches recursively, while excluding .git and node_modules; adjust the exclusions to match the project layout. Symlinks that resolve outside the root are blocked, rather than followed as trusted instructions.

Step 3: Run it before the agent

From the repository root, run:

python3 scripts/agent_preflight.py
Enter fullscreen mode Exit fullscreen mode

Only after it prints PASS should you launch the coding agent in that same directory. You can make the check hard to forget by using a wrapper:

#!/usr/bin/env bash
set -euo pipefail
python3 scripts/agent_preflight.py
exec codex "$@"
Enter fullscreen mode Exit fullscreen mode

Save the wrapper as scripts/codex-safe, make it executable with chmod +x scripts/codex-safe, and invoke it from the repository root. Replace codex with your agent's documented command. This wrapper checks before starting the process; it is not a substitute for that agent's own sandbox, permission model, or configuration controls.

Before adopting the wrapper, verify that your agent reads instruction files in the way you expect. Products differ, and behavior can change. Codex's AGENTS.md guide documents its instruction-file behavior and precedence. For a managed isolation pattern, Cloudflare's coding-agent sandbox guide describes running an agent in a task-specific Linux container and controlling which hostnames it can reach. A container can reduce the blast radius, but secrets mounted into it and allowed network routes still matter.

Step 4: Exercise the failure paths

Test the gate with harmless temporary Git repositories before connecting it to your normal agent command:

  • Clean repository, no instruction files: create a manifest containing only a comment. The script should print PASS with zero approved files.
  • Unexpected file: add an AGENTS.md without changing the manifest. The script should return a nonzero status and identify it as unapproved.
  • Edited approved file: record its hash, then change one character. The script should report that the reviewed file changed.
  • Wrong directory: run the script from a repository subdirectory. It should ask you to start from the root.
  • Downloads: place a harmless test repository under Downloads and run the script there. It should block before invoking an agent.

Do not test by copying or running the malicious file from the incident. A harmless test file is enough to verify the preflight logic.

A successful preflight means only that the current directory and instruction-file inventory match local expectations. It does not inspect all files an agent may read, prove that a reviewed instruction is benign, block prompt injection in issue text or web pages, or prevent an approved instruction from asking for a risky action. Treat it as one gate in a larger workflow.

What the preflight cannot replace

Keep approvals on. A file can ask an agent to bypass approval or disable its sandbox. That request is itself a reason to stop and inspect, not a reason to grant more access. Never run a command with a “dangerously bypass” option just because an instruction file says it is required.

Scope filesystem access. Starting from a repository root helps keep context narrow, but a working directory is not automatically a complete security sandbox. Use your agent's supported restrictions to deny access to unrelated home-directory files, credentials, and system configuration.

Control network and secrets. If the agent can reach the network or read a token, malicious instructions may target those capabilities. Give the agent only the credentials required for the task, and use isolated environments for unfamiliar repositories or dependencies.

Inspect configuration changes. The reported attack attempted to modify agent-level settings, not only project files. Review changes to global configuration, MCP servers, API base URLs, shell startup files, and credential stores. Treat unexpected changes as a possible compromise and follow your provider's account-security guidance.

Handle a suspected exposure as an incident. If an unknown instruction file was consumed, stop using that session. Preserve relevant logs and configuration changes, remove the file only after recording evidence, and rotate credentials that the agent could access. If a suspicious package ran, isolate the environment and involve your security team. Forever Security specifically recommends treating exposed tokens as compromised; the correct response depends on which credentials and systems were available to the agent.

A review checklist for agent instruction files

Before approving an instruction file or updating its hash, check:

  • Does it ask the agent to edit files outside the repository or in global configuration?
  • Does it install packages, run remote scripts, or register a new MCP/tool server?
  • Does it change an API base URL, proxy, credential, or authentication setting?
  • Does it ask to disable approvals, sandboxing, or security checks?
  • Are its commands necessary for the project, pinned or otherwise verifiable, and explainable?
  • Does the file's source and author match what the repository expects?

Then review the Git diff for both the instructions and the manifest. A hash update without a meaningful code review only turns an unknown instruction into an approved unknown instruction.

The practical rule

The incident does not mean every AGENTS.md is malicious or that agents should ignore project guidance. It shows why an agent's starting directory is a security decision. Use a dedicated project workspace, review instruction files as code-adjacent configuration, and require an explicit preflight before the agent gets to read them.

A small script cannot solve trust. It can make one important trust decision visible and repeatable: “Is this the project I meant to open, and are these the exact instructions I reviewed?” For agent workflows with access to credentials, code, and a terminal, that is a useful question to ask before the first prompt.

Have you added a startup check for your coding agent, or do you rely on a manual review? What would you want the gate to block?

I write about practical software engineering, AI tooling, and security. Subscribe for more guides like this.

Sources

Top comments (1)

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

Failing closed on an unapproved instruction file is the right default, since a silent fallback would defeat the whole check. I would also log the rejected path and hash, so a blocked run leaves a clear trail for review instead of just stopping.