AI coding

One Allowlist for Tool Calls and File Reads

Antonio Evans9 min read

A single sheet of paper headed with a short declaration: a list of tool names on the left, a list of directory paths on the right, and a line joining each tool to the paths it may touch.
Key points

Key points

  1. 1A single policy document can declare both the tools an agent may call and the files it may read, so one validator checks every call against both surfaces before execution.
  2. 2Git ignore files were built for humans and version control, not for agents that read the filesystem directly, so a separate agent-facing policy is required.
  3. 3Tool metadata and tool names are untrusted input: a tool approved once can change later, so the policy must pin what it approves and re-check at call time.
  4. 4A read becomes a two part check: the tool must be allowed, and the argument it carries must pass the path check, which also means normalizing paths before matching.

Start with what a path policy already does

A path allowlist is a declaration of directories and glob patterns that a process may touch. The most familiar version of this pattern is Git's own ignore machinery: a committed .gitignore applies to everyone who clones the repository, while .git/info/exclude applies only to one checkout and the global ignore file applies machine wide, and all three use the same pattern syntax per this walkthrough of Git ignore layers. That three level structure matters because it already teaches the lesson you need: scope belongs in the document, not in the code that reads it.

The important shift for agents is that Git ignore answers a version control question, not an access question. A file can be perfectly ignored by Git and still be read straight into an agent's context window, because most coding agents scan the local filesystem rather than the Git index as this guide to agent facing ignore files explains. Practitioners have watched this play out in the open: the Agent composer in one popular editor reported it could list and read the contents of a directory that both ignore files excluded according to the bug report thread. The conclusion is narrow and useful: your path allowlist has to be an agent policy in its own right, not a copy of .gitignore.

There is also a privilege problem underneath. An AI coding agent runs with the privileges of the person who launched it and presents the same credentials on the same machine, so credential based controls cannot tell agent and human apart as BeyondTrust's workflow documentation puts it. That is why the policy has to name the subject (an agent, not a user) and the action (reads a file, runs a process) explicitly.

Why the tool list belongs in the same file

Most teams end up with two artifacts: a JSON file listing MCP servers and tools, and a separate ignore or allowlist of paths. The two drift apart, because nothing forces a new tool to state which directories it needs. The failure modes are well catalogued. A tool approved today can change tomorrow, so a sound policy hashes each server's tool manifest and forces re approval when definitions change per this tool and plugin security guide. A trusted server can still expose a destructive tool, which is why policy objects need to sit at the individual tool level rather than only at the server or agent level as Backslash Security's allowlist guidance sets out.

Putting both lists in one document does three things:

  1. Every tool entry must state the scopes it needs, so a bash tool that wants the repository root cannot be added without that fact being visible in a diff.
  2. One validator reads one file, so a path rule cannot be bypassed by reaching the filesystem through a tool that was never wired to the path checker.
  3. Review becomes a single file to read, which matters more than it sounds: reviewers approve what they can hold in their head.

The pattern is not novel in the broader security world. Policy engines such as Open Policy Agent let you write authorization as a separate, reviewable artifact that can be tested in CI with opa test, and tool approval callbacks in agent SDKs already map a policy decision to allow, deny or requires approval as the AI SDK documentation describes. You do not need a full policy engine to start, but you do want the decision to live in a file rather than inside a function.

The declaration: one file, two sections

Below is a YAML policy that names tools, their arguments, the scopes they need, and the paths the agent may touch. It is plain data: no expressions, no templating, so a reviewer can read it without knowing the validator's internals.

policy: repo-agent
version: 1

paths:
  allow:
    - "/workspace/app/**"
    - "/workspace/docs/**"
  deny:
    - "/workspace/app/config/prod/**"
    - "**/.env"
    - "**/*.pem"
  follow_symlinks: false

tools:
  read_file:
    class: read
    args:
      - name: path
        type: path
        must_match: paths.allow
    scopes: [filesystem.read]

  list_dir:
    class: read
    args:
      - name: path
        type: path
        must_match: paths.allow
    scopes: [filesystem.read]

  write_file:
    class: write
    args:
      - name: path
        type: path
        must_match: paths.allow
    scopes: [filesystem.write]

  bash:
    class: execute
    args:
      - name: command
        type: command
        grammar: allowlist
        allow: ["git status", "git diff", "pytest", "npm test", "ls"]
    scopes: [process.execute]
    path_args: extract
    require_path_check: true

  web_fetch:
    class: network
    args:
      - name: url
        type: url
        allow_hosts: ["docs.internal.example"]
    scopes: [network.read]

Two properties are doing the heavy lifting. First, deny beats allow: a path that matches both sections is refused, which is how you keep prod config out even though it sits under an allowed directory. Second, bash declares require_path_check: true, which is the line that closes the hole most teams leave open. A shell tool can read any file the process can read; declaring that its arguments must be extracted and re checked against the path policy is what makes the shell governed by the same declaration as read_file.

The type: path versus type: command distinction is also worth guarding. Treating a free form command string as a path, or a path as a command, is exactly the sort of mismatch that lets a crafted argument slip past one checker because it was meant for the other.

Decision table: which check fires for which call

The validator's job is to answer a fixed set of questions in a fixed order, and to fail closed when it cannot answer. This table is the specification you can hand to whoever writes the code.

CallTool checkArgument checkPath checkResult if any check fails
read_file(path="/workspace/app/src/a.py")read_file in tools, scope grantedarg path is a stringnormalized path matches allow, no denyDeny
read_file(path="/workspace/app/config/prod/db.yaml")passespassesmatches allow and denyDeny
read_file(path="../etc/passwd")passespassesresolves outside allowDeny
list_dir(path="app/src/../../.env")passespassesnormalization hits **/.envDeny
bash(command="cat /workspace/app/config/prod/db.yaml")bash allowedcommand matches grammar allowlist? nonot reachedDeny at grammar step
bash(command="git diff")allowedgrammar allowlist matchno path tokens foundAllow
bash(command="git diff --output=/tmp/x")allowedgrammar match on verb, extra flag parsed/tmp/x outside allowDeny
web_fetch(url="https://evil.example/x")allowedhost not in allow_hostsn/aDeny
rm_rf(path="/workspace")tool not in toolsn/an/aDeny (default deny)

Read the last row carefully: a tool that is absent from the document is denied, not ignored. That is the difference between an allowlist and a blocklist. A blocklist can only name components that have already been published and judged bad, and a renamed copy of a tool is on nobody's block list per Backslash Security's analysis. Default deny closes that gap by making silence mean no.

Implementing the validator in the call path

The validator has to run after the agent has decided what to call and before the system executes it. That timing is not a detail. A ban written in a system prompt only works when the model happens to read it, whereas a deny rule in the hook that fires before execution works in every mode as this write up of action class matrices argues. The two questions the hook answers are separate: is this command on the approved list (the allowlist, and the primary boundary), and does this specific invocation do something dangerous regardless of its name (the deny patterns, as defense in depth) per this pre tool hook guide.

A minimal Python validator below implements the table above. It normalizes paths with os.path.realpath so that .. segments and (when follow_symlinks is false) resolved symlinks are evaluated on the real target, then matches against the compiled globs.

import fnmatch, os, shlex

class Denied(Exception):
    pass

def norm(p, base="/workspace"):
    full = p if os.path.isabs(p) else os.path.join(base, p)
    return os.path.realpath(full)

def path_allowed(policy, raw):
    target = norm(raw)
    deny = policy["paths"]["deny"]
    allow = policy["paths"]["allow"]
    if any(fnmatch.fnmatch(target, pattern) for pattern in deny):
        return False
    return any(fnmatch.fnmatch(target, pattern) for pattern in allow)

def command_paths(command):
    tokens = shlex.split(command)
    return [t.split("=", 1)[1] for t in tokens
            if "=" in t and ("/" in t.split("=", 1)[1])]

def check(policy, tool_name, args, grant):
    tool = policy["tools"].get(tool_name)
    if tool is None:
        raise Denied(f"tool {tool_name} not declared")
    if not set(tool["scopes"]).issubset(grant):
        raise Denied(f"missing scope for {tool_name}")

    for spec in tool["args"]:
        value = args.get(spec["name"])
        if spec["type"] == "path" and not path_allowed(policy, value):
            raise Denied(f"path {value} not allowed")
        if spec["type"] == "command":
            verb = " ".join(shlex.split(value)[:2])
            if not any(verb.startswith(a) for a in spec["allow"]):
                raise Denied(f"command not allowed: {verb}")

    if tool.get("require_path_check"):
        for candidate in command_paths(args["command"]):
            if not path_allowed(policy, candidate):
                raise Denied(f"command touches {candidate}")
    return True

Two notes before you ship this. realpath resolves symlinks even when you have set follow_symlinks: false, so if you truly want to refuse symlinked paths you must compare the raw and resolved values and deny when they differ. Symlink handling is not academic: a sandbox escape was reported in which an agent inside a sandbox planted a symlink that produced read write access to an arbitrary host directory it was never given per Endor Labs' write up. Second, extracting paths from a shell command with a regular expression is a losing game. If your commands are complex, parse them with a grammar instead of a pattern, because a regex that matches git diff will miss git diff --output=... and everything after a pipe.

A worked example with numbers

Suppose a monorepo agent has four declared tools and a path policy with two allowed roots. During one task it attempts 40 tool calls. Here is what an honest audit looks like.

OutcomeCountWhere it was caught
Allowed31all checks passed
Denied at tool check3undeclared tool names
Denied at argument check2command verb not in grammar allowlist
Denied at path check4deny globs or outside allow roots
Total40one validator, one log line per call

Twenty two percent of calls were refused, and every refusal carries a reason string that names the rule. That log is the artifact your security reviewer actually wants, because it answers "was this invocation authorized for this agent, and does it deviate from baseline" rather than only "which credential acted" as this analysis of tool call visibility argues. Compare that to the alternative: 40 calls, no policy, and a review after the fact of what the agent chose to do.

The comparison also tells you where to spend effort. Three of the twenty two refusals were undeclared tools, which is a declaration hygiene problem, not an attack. Two were commands outside the grammar, which is either a missing legitimate entry or an agent improvising. Only four were genuine path violations, and those are the ones that justify the whole exercise.

Checklist before you roll it out

ItemWhy it mattersEvidence to keep
Default is deny for undeclared toolsBlocklists cannot name tools that do not exist yetValidator returns deny on missing key
Deny globs are evaluated before allow globsKeeps secrets out of broadly allowed rootsUnit test per deny pattern
Paths are normalized before matching.. and symlinks defeat naive string matchesFuzz test with traversal inputs
Shell tools declare path extractionOtherwise bash bypasses the path policy completelyCall log showing extracted paths
Every decision is logged with a reasonYou need this to review and to appealOne record per call
Policy lives in version control and is reviewed like codeDrift happens through hotfix edits, not attacksPull request history
Audit mode runs before blockYou see what a rule would catch without breaking workReplay of recorded activity

That last row has a direct precedent. BeyondTrust's workflow runs a new policy in audit first, replays recorded activity to see what it would have matched, publishes it as a draft, attaches it to a test endpoint, and only then switches to block, confirming that the agent's attempt is recorded while the user's is not per their documentation. That sequence exists because policy that is not enforced inline, before execution, is documentation rather than control as Backslash Security puts it.

Drift is the quiet failure after launch. Configurations move away from the approved baseline through human error, emergency hotfixes and scripts that cannot keep up, so detection needs codified policy, runtime checks, version control and regular review per this guide to policy drift detection. For an agent policy, drift usually looks like one extra line in the file that nobody discussed. A required reviewer on that file is cheaper than any runtime product.

Where models and experts fit

The validator sits between your agent and the outside world, but the model behind the agent still needs a route to every surface it uses: text for planning, image models for screenshots of a failing UI, embedding models for retrieval over docs. Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI compatible key, which means the policy file can name model scopes alongside tool scopes without a second vendor relationship.

For teams that would rather have the whole harness written than assemble it, Quanta Experts reads your repository context and returns finished work, and the skill library behind it can be installed into an existing coding agent, so the policy you just wrote can travel with the capability rather than being rewritten per tool.

FAQ

FAQ

Should the tool policy and the path policy really be one file?

Yes, and the reason is enforcement rather than tidiness. Two files means two validators, and the moment a tool is added that does not call the path validator, the path rules become optional. A single document forces every tool entry to declare the scopes and argument types it needs, so a reviewer sees a path requirement the same day the tool is proposed.

How does a bash call get checked against a path policy at all?

The tool entry declares that paths must be extracted from the command and re checked, and the command string is first parsed against a grammar allowlist of verbs and subcommands. If the verb is not on the list, the call is refused before extraction runs. Paths found in the arguments then go through the same normalize and match steps as a read_file argument.

What stops an agent from reading a `.env` file that sits inside an allowed directory?

Ordering. Deny globs are evaluated before allow globs, so a path matching both is refused. That is the same priority rule Git ignore files use for negation, and it is the only reliable way to keep one sensitive pattern closed across a set of allowed roots that must stay broad for normal work.

Is a system prompt instruction not enough?

No. A ban written as text only works when the model reads and follows it, while a deny rule in the hook before execution works in every mode, including the most autonomous one. There is a documented case of an agent ignoring an explicit, all caps instruction to take no action during a code freeze, which is why the check has to sit in code on the call path.

How do I roll this out without breaking every workflow on the team?

Run in audit mode first, replay recorded activity and read what would have been blocked, then publish as a draft and attach to one test machine. Promote to block only after you have added exception paths for legitimate refusals, and attach the rule by tag so new endpoints inherit it rather than needing manual enrollment.

Sources

Sources

  1. Three Ways to Ignore Files in Git (Beyond .gitignore)
  2. The Ultimate .gitignore for the AI Era
  3. Questions on .gitignore, .cursorignore, .cursorban - Feedback
  4. Block access while retaining user privileges | AI Agent Security
  5. AI Agent Tool and Plugin Security Guide: Tool Poisoning
  6. How to Build Allowlist and Denylist Policies for AI Agents on Employee ...
  7. Policy-Based Tool Approvals
  8. Harness Engineering, AI Agent Permissions as Policy in ...
  9. How to Stop an AI Agent from Running Dangerous Commands - Medium
  10. AI Agent Sandbox Flaw in brig: Symlink Traversal (CVSS 8.2)
  11. Prompt and Tool Call Visibility: What Your AI Agents Are ...
  12. The Comprehensive Guide to Policy Drift Detection
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