AI coding

A .agentignore for Your Agent: What to Hide and Leave Visible

Antonio Evans8 min read

A cutaway side view of a repository as a building with labeled rooms: a bright open reading room for src and tests, a locked cabinet room for .env and secrets, a sealed basement for node_modules and build output.
Key points

Key points

  1. 1A .agentignore is a repo-root file read by the tool wrapper, not the prompt, that declares which paths a coding agent may and may not touch.
  2. 2Split it into an allowlist (source, tests, docs, config templates) and a denylist (secrets, build output, vendor code, VCS internals, scratch notes).
  3. 3Prompts only shape what a model tries to do; they do not stop a tool call, so enforcement belongs in the wrapper that resolves paths to their real absolute form.
  4. 4Log every refusal and run escape tests (traversal, symlinks, case tricks) before trusting the file.

Why prompts cannot enforce file access

A prompt is a request. A tool wrapper is a gate. When you write "do not read .env" in a system prompt, you are asking a probabilistic system to respect a boundary that has no mechanical check behind it. Prompting guidance is explicit that instructions steer behavior, but it never claims to guarantee it: the Claude prompting best practices page frames clear instructions as a way to improve results, and the OpenAI prompt engineering guide does the same. Neither promises a hard boundary.

The practical consequence shows up in the field. Users of at least one editor reported that files listed in ignore files were still reachable by the agent in some modes, and that a previously viewed or indexed file could enter context anyway (Cursor forum thread). Git's own ignore files were never designed for this: they control what gets committed, not what an agent reads. A file can be perfectly ignored by Git and still be slurped into a model's context window, which is the mental shift the AI-era ignore writeup describes.

There is a second reason to put the rule in code. Agents run with the privileges of the person who launched them. A tool that only checks credentials cannot tell the developer from the agent, because they present the same identity on the same machine (BeyondTrust workflow docs). Path policy attached to the tool call is one of the few controls that distinguishes the two.

Finally, agents take shortcuts when a task gets hard. A guardrail that inspects the outgoing tool call, after the agent decides but before execution, is the last moment a bad action can be stopped (writeup on pre-tool guards). A prompt has no such moment.

What belongs in the allowlist and what belongs in the denylist

Design the file as two sections that are read together by the wrapper. The allowlist is what the agent is hired to do. The denylist is everything that must never enter context or get written, even if it sits under an allowed path.

CategoryAllowlist (visible)Denylist (hidden)Reason
Sourcesrc/, lib/, app/(none)The agent needs code to change code
Teststests/, spec/(none)Verification is how the agent closes its loop
Docsdocs/, README.md, AGENTS.mdPersonal notes, journalsShared docs help; private notes leak context and intent
Config.env.example, config/*.example.*.env, .env.*, *.pem, *.key, secrets/, *credentials*.jsonTemplates teach shape; real values expose secrets
Dependencies(none)node_modules/, vendor/, third_party/Vendor code is large and rarely the edit target
Build output(none)dist/, build/, .next/, coverage/, *.min.js, lockfiles when hugeGenerated files burn context and add no signal
VCS internals(none).git/, .git/info/excludeRewriting Git internals detonates history
Scratch(none)scratch/, tmp/, *.log, logs/Work-in-progress notes are the symptom that started this

Two cautions are worth folding into this table. First, a lockfile can be committed but still excluded from agent context, because it is large and mostly noise; the AI-era ignore guide recommends exactly this split. Second, ignore mechanisms are not interchangeable. Git has three layers (.gitignore, .git/info/exclude, and a global exclude file), each with different scope, and there is a diagnostic (git check-ignore -v) that tells you which rule fired (Git ignore methods). Reuse that thinking for .agentignore: one file for the team, plus a local override for paths only you want hidden.

An example file:

# .agentignore
[allow]
src/
lib/
tests/
docs/
README.md
AGENTS.md
.env.example
config/*.example.*

[deny]
.env
.env.*
!.env.example
*.pem
*.key
*credentials*.json
secrets/
node_modules/
vendor/
dist/
build/
coverage/
.git/
scratch/
tmp/
*.log

The subtle part is !.env.example under deny, which un-hides the template that the allow list also names. Section order and negation rules must be written down, not inferred. Better yet, make the wrapper refuse to start when the two sections disagree rather than silently picking a winner.

Enforce it in the tool wrapper, not the model

Every agent exposes a small set of tools: read a file, write a file, glob, grep, run a shell command. The wrapper sits between the model and those tools and has one job: take the requested path or command, turn it into a real absolute path, apply the lists, and return a refusal if the check fails. Sibling tools need the same treatment as read_file; a grep with a --path flag is a read in disguise, and a shell string with cat ../.env is a read with extra steps.

The skeleton looks like this:

from pathlib import Path, PurePosixPath
import os, re, shlex, logging

ROOT = Path("/workspace/project").resolve()
ALLOW = ("src", "lib", "tests", "docs", "README.md", "AGENTS.md", ".env.example")
DENY  = (".env", ".env.local", "secrets", "node_modules", "vendor", "dist",
         "build", "coverage", ".git", "scratch", "tmp")

def real(path: str) -> Path:
    # Join to root first, then resolve: this collapses "..", symlinks and case.
    return (ROOT / path).resolve()

def rel(path: Path) -> PurePosixPath:
    return PurePosixPath(path.relative_to(ROOT).as_posix())

def is_denied(relpath):
    parts = relpath.parts
    return any(p in DENY or relpath.name == p or relpath.name.endswith(".log")
               or relpath.suffix in (".pem", ".key") for p in parts)

def check(path: str, write: bool = False):
    target = real(path)
    if ROOT not in target.parents and target != ROOT:
        return refuse(path, "outside repository root")
    r = rel(target)
    if is_denied(r):
        return refuse(path, "denylist")
    first = r.parts[0] if r.parts else ""
    if not (first in ALLOW or str(r) in ALLOW):
        return refuse(path, "not in allowlist")
    return target

Two details make this robust. Resolve before you compare, because src/../.env looks like a child of src and a symlink named src/config.json may point at ~/.ssh/id_rsa; a wrapper that checks the string has already lost. And write a refusal path that returns a structured error, not an exception, so the agent sees "denied by policy for path X" instead of a stack trace and learns to abandon that route rather than retry variants forever.

The shell tool is the leaky end. You cannot reliably parse arbitrary shell into path sets. Sensible options are: run shell inside a container that only has allowed paths mounted, or refuse shell for tasks the code path policy is meant to protect and expose a small typed command set (run_tests, run_build) that internally calls the same check function. The tool-level allowlist and denylist design is the same pattern described for endpoint agents, where approval applies to the specific tool action, not just the agent's name (Backslash Security on allowlist and denylist policy).

If you run more than one model behind the same agent, the policy should live in the wrapper, not in each model's prompt. Quanta Inference (https://quantaintelligence.ai/inference) exposes text, image, video, audio and embedding models behind one OpenAI-compatible key, which means the wrapper's path check stays identical while you swap models underneath.

A worked example: same task, three policies

Imagine an agent asked to fix a failing test in tests/checkout.test.ts. It reads src/checkout.ts, runs the test suite, sees a red assertion, and tries three paths to make it pass. The table shows what each policy permits.

Agent attemptPrompt-onlyNaive allowlist (src/, tests/)Path check + resolve + denyOutcome
read src/checkout.tsokokoknormal edit
grep -R "STRIPE_" .often happensreads .envrefused at .envno secret in context
cat tests/../../.envsometimesstring compare passesrefused after resolvetraversal blocked
ln -s ~/.aws/credentials tests/k then readpossibletests/ allowedrefused: symlink target outside rootsymlink escape blocked
rm tests/checkout.test.ts to "fix" the testpossibletests allowlist permits writerefused if write to tests needs a flagtest deleted only by human

Take the third row in detail. If the wrapper compares request strings against prefixes, tests/../../.env starts with tests/, so a naive check says yes. Once you resolve it, the real path is /workspace/project/.env, the denylist fires, and the model gets a refusal. The fourth row is the one most teams miss: if the agent has write access to tests/, it can create a symlink in an allowed directory that points at anything, then read through it. Resolving the final target catches this because Path.resolve() follows symlinks before your rules are applied.

The fifth row is the honest one. Path policy cannot decide intent; deleting or skipping a failing test to get green is a behavioral failure that looks like a normal allowed write. Path policy narrows the blast radius. It does not replace a review gate before merge. Keep agent work on a branch and require human review of the diff, which is the same pattern used for Git-native agent workflows where agent changes open a pull request, not a push to main (GitAgent patterns).

Logging refusals and reading the log

A refusal that vanishes is a refusal you will debug twice. Write one structured log line per check with: timestamp, tool name, requested path, resolved path (relative to root), decision, and matched rule. Keep it append-only, and rotate it like any other sensitive log because the requested path itself can reveal intent (an agent probing .env.production tells you something).

What to actually read in the log, week one:

  • Denied reads on .env* and key files. One denial is the agent hitting noise. Repeated denials across sessions mean your task instruction is under-specified. Tell it where config comes from (.env.example) in the rules file, but do not remove the check.
  • Traversal patterns. A rise in ../ denials is a hint a tool is handing the agent absolute paths it did not expect, or a task is failing silently.
  • Refused writes to tests or CI files. If the deny list is over-broad, the agent will report "cannot fix test." Widen deliberately, on a branch, with a reason. If the agent tries to weaken a test, the deny list is doing its job.
  • Volume, not just errors. If thousands of files under node_modules are still being resolved before the deny fires, your glob tool is doing the walk. Refuse the directory prefix in glob before it expands, not after.

Pair the log with an audit posture. Regulated teams already track whether an action was blocked before execution or merely logged, and a policy left in "audit" that records but never blocks is just documentation that happens to be verbose (BeyondTrust). Treat the first week as audit; treat the second as enforcement.

Escape tests you can run before trusting the file

Write tests for the wrapper, not the model. The model is not the unit under test; the check function is. A minimal suite:

import pytest
from guard import check, Refused

@pytest.mark.parametrize("path", [
    "src/index.ts", "tests/a.test.ts", "docs/setup.md", ".env.example",
])
def test_allowed(path):
    check(path)

@pytest.mark.parametrize("path", [
    ".env", "src/../.env", "tests/../../etc/passwd",
    ".git/config", "node_modules/x/index.js", "dist/app.js",
    "/etc/passwd", "\\..\\..\\.env", "src/%2e%2e/.env",
])
def test_refused(path):
    with pytest.raises(Refused):
        check(path)

def test_symlink_escape(tmp_path):
    link = tmp_path / "tests" / "k"
    link.symlink_to("/etc/passwd")
    with pytest.raises(Refused):
        check(str(link.relative_to(tmp_path)))

Add these beyond the obvious:

TestWhat it proves
A symlink inside src pointing to ~/.sshresolution happens before comparison
A path with unicode or percent-encoded ..decoding happens once, before check
Trailing slash and duplicated slashesnormalization is consistent
An absolute path supplied by the agentjoin-to-root or refuse, never pass through
Case-only variants on a case-insensitive filesystemthe compare is either both normalized or both exact
A file under src that is a symlinked directorydirectory symlinks do not smuggle the deny target
Shell command cat .envshell path goes through the same gate or is not offered

Run the suite in CI. If someone adds a new tool (patch_file, apply_diff, fetch_url) it must call check; a test that enumerates registered tools and asserts each one calls the guard catches the next tool someone forgets.

When the agent needs a line of business or a whole product

Repository scoping is the minimum, not the ceiling. Once paths are controlled, the next questions are usually "who owns this agent" and "what does it produce." A guard belongs to a tool wrapper you control; the model route belongs to whoever runs the agent. Quanta Inference (https://quantaintelligence.ai/inference) is one option for the model side: a single key across text, image, video, audio and embedding models, priced in US dollars, so a wrapper can switch models without rewriting policy. Teams that want the work delivered rather than glued together can put a Quanta Expert (https://quantaintelligence.ai/agents) on the task and hand the agent a job description instead of a prompt file.

The decision rule this article argues for: if a boundary matters when the wrong thing happens, put it in code that resolves the path, checks the lists and logs the refusal. If it only matters when the work is wrong, put it in the prompt. Very few boundaries belong in the second bucket.

FAQ

FAQ

Does a .agentignore replace .gitignore?

No. They have different audiences. .gitignore controls what Git commits and tracks, and .agentignore controls what the agent may read or write. A file can be committed and still hidden from the agent (large lockfiles) or ignored by Git and still reachable by the agent unless a path check blocks it, which is why the two files have to be written and reviewed separately (Qabash).

Why not just tell the model not to read .env in the system prompt?

Because a prompt is a request and a tool wrapper is a gate. Prompting guidance improves outcomes but makes no guarantee about tool calls, and field reports show ignored files can still reach an agent in some modes (Cursor forum). The prompt can still say "do not read secrets," but the refusal has to come from the code path that calls open().

What path tricks do I need to handle before trusting the check?

At minimum: .. traversal, absolute paths, symlinks pointing outside the repo, percent-encoded dots, duplicated slashes, and case variants. The rule is to resolve first, then compare, because every one of those tricks defeats a string prefix check but not a real absolute path. Add one test per trick to the wrapper's CI suite so regressions surface immediately.

Should the denylist block writes to tests?

Usually no by default, but block destructive writes. Blocking all test writes stops the agent from adding the assertion it needs, so a better split is: allow writes to new test files and assertions, deny deleting or skipping an existing test file unless a human marks it. The residue (an agent weakening a test) is a review problem, so keep agent work on a branch with human sign-off (GitAgent patterns).

How do I roll this out without breaking the team's workflows?

Run in audit for one week: log every decision, block nothing. Read the log, widen the allowlist for legitimate denials, and add the files you forgot (config templates, schema files, generated types). Then flip to block, and keep the audit log for a monthly review. A policy saved but never attached, or left in audit, applies to nothing, which is the most common failure mode of a well-written allowlist (BeyondTrust).

Sources

Sources

  1. Prompting best practices - Claude Platform Docs
  2. Prompt engineering | OpenAI API
  3. Questions on .gitignore, .cursorignore, .cursorban - Feedback
  4. The Ultimate .gitignore for the AI Era
  5. Block access while retaining user privileges | AI Agent Security
  6. How to Stop an AI Agent from Running Dangerous Commands - Medium
  7. Three Ways to Ignore Files in Git (Beyond .gitignore)
  8. How to Build Allowlist and Denylist Policies for AI Agents on Employee ...
  9. GitAgent: 14 patterns all AI agents should follow.
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles