AI coding

A File-Access Policy That Survives a Repo Move

Antonio Evans8 min read

A single policy file at the root of a repository tree, with three thin lines running from it to a terminal command, a git hook icon on a commit node, and a CI pipeline stage.
Key points

Key points

  1. 1Put every allowed and denied path in one versioned policy file at the repository root, and have each consumer read it at run time rather than embedding its own copy.
  2. 2Treat agent file access as an access-control problem: agents run with the launching user's privileges, so the boundary has to be enforced by the hook or sandbox that actually opens the file.
  3. 3When a repository moves, the failure mode is silent widening: an old absolute path stops matching, a fallback branch engages, and the agent can read more, not less.
  4. 4Make the policy self-checking: a CI job that fails on hardcoded consumer paths and on unapproved allow additions keeps the file honest after every move.

Why a repo move quietly widens agent access

Renaming or relocating a repository looks cosmetic. For a coding agent it is a permission change. Agents resolve the path you give them against the working directory and fall back to something permissive when resolution fails. An allowlist written as an absolute path stops matching the moment the checkout moves, and the comparison code usually has an except branch that logs a warning and proceeds. The agent now reads the whole tree instead of one directory, and nothing says "access expanded."

The root cause is delegation. An AI coding agent runs with the privileges of the person who launched it, presenting the same credentials on the same machine, so credential-based controls cannot tell the agent and the human apart. A developer allowed to read a production config file hands that same access to any agent they run (BeyondTrust). That is why the boundary belongs in the tool call: a pre-execution hook or sandboxed file reader that consults a policy at the moment of access.

This is a familiar governance problem in new clothing. Policy drift is the general term for systems, controls and practices moving away from approved expectations, and the canonical example is identity and access policies edited during sprints and hotfixes that end up granting broader access than intended (Sprinto). A repo move is a hotfix to your directory layout. If the access policy does not live in the same file as the layout, it drifts.

There is a second, tool-specific reason to care. Git-ignored is not the same as agent-invisible. Many agents scan the filesystem directly rather than the git index, so .gitignore alone does not stop an agent from reading .env files, lockfiles or vendor folders (Qabash). Your policy must describe the filesystem, not the commit history.

The decision rule: one policy file, no local exceptions

Adopt this rule before writing any configuration. If a path appears in more than one place, one of those places is wrong. Concretely:

  • Allowed and denied paths live in exactly one file, agent-policy.yaml, at the repository root, committed and reviewed.
  • Every consumer (agent settings, pre-commit hook, CI job, sandbox launcher) reads that file at run time and stores no paths of its own.
  • Paths are written relative to the repository root, never absolute.
  • The policy fails closed: a missing file, an unparseable entry or an unresolved relative path means deny.

The relative-path rule carries the weight. Relative paths survive a rename because they resolve against the checkout root, which is whatever the working directory happens to be. Absolute paths encode the environment into the policy, and the environment is the thing that moves.

A worked example shows the cost. Suppose the policy allows two directories and denies four, and five consumers each hardcode those paths: the agent settings file, a pre-commit hook, a CI lint job, a sandbox profile and a reviewer checklist. That is 30 path literals (6 paths times 5 consumers). Moving the repo needs 30 edits, and one missed edit leaves a stale allow rule behind. With a single policy file it is one edit, plus one CI check proving no consumer carries a literal path.

ApproachEdits per moveFailure modeWho notices
Paths copied into each toolOne per consumer per pathStale allowlist keeps old scope, or fallback opens a full readNobody until an incident review
Absolute paths in a central fileOne per pathEvery entry stops matching; fail-open code widens accessSomeone, if logs are watched
Relative paths in a central fileOne line, plus directories that truly movedCI flags the diff and reviewers see the scope changeCI and the reviewer, before merge

The middle row is the trap most teams fall into: centralized but not relativized. Centralization fixes the edit count, not the silent widening. Both properties are required.

Writing the policy file

Keep the schema small enough that a reviewer can read the whole diff. The version below separates three things people usually conflate: what may be read, what may never be read, and why each entry exists.

# agent-policy.yaml
version: 1
root: "."                      # resolved against the checkout root, never a user path

allow:
  - path: src/
    reason: "agent edits application code"
    approved_by: "eng-platform"
  - path: tests/
    reason: "agent adds and fixes tests"
    approved_by: "eng-platform"

deny:
  - path: .env*
    reason: "secrets"
  - path: infra/prod/
    reason: "production config, read by humans only"
  - path: secrets/
    reason: "key material"
  - path: "*.pem"
    reason: "private keys"

# Consumers must declare themselves here; an undeclared consumer fails CI.
consumers:
  - id: agent-settings
    reads: config
  - id: pre-commit-hook
    reads: config
  - id: ci-lint
    reads: config

Three details matter more than they look. reason and approved_by turn the file into an audit trail, the same discipline compliance tooling applies when it demands evidence for policy drift (Sprinto). Deny beats allow: the resolver checks deny patterns first and returns on the first match. And the consumers list is the registry CI uses later to hunt for hardcoded paths.

Build the deny list from patterns your tools' ignore syntaxes already understand, because you will generate tool-specific ignore files from it. Gitignore-style patterns are the shared vocabulary here, and all three Git ignore mechanisms (committed .gitignore, local .git/info/exclude, machine-wide config) use the same syntax (Developers Digest). That compatibility is what lets one policy drive several tools.

Note what the file does not contain: no model names, no API keys, no user home directories. If you want a user path in the policy, that is a signal it belongs in a managed settings layer instead, of the kind where managed configuration sits above anything a developer can set locally (Unblocked).

Wiring hooks and CI to read it at run time

The policy file is inert until something consults it. Start with the pre-execution hook, the last point where a bad read can still be stopped. The pattern is two independent questions at the outgoing evaluation point: is this path on the approved list, and does this specific request do something dangerous regardless of the list (Josphine Job). The policy file answers both.

A minimal resolver and hook:

# policy.py
import fnmatch, os, sys, yaml

def load(root="."):
    with open(os.path.join(root, "agent-policy.yaml")) as f:
        return yaml.safe_load(f)

def resolve(request_path, policy, root="."):
    rel = os.path.relpath(os.path.abspath(request_path), os.path.abspath(root))
    if rel.startswith(".."):
        return False, "outside repository root"
    for rule in policy.get("deny", []):
        if fnmatch.fnmatch(rel, rule["path"].rstrip("/") + "*"):
            return False, f"denied by {rule['path']} ({rule['reason']})"
    for rule in policy.get("allow", []):
        if fnmatch.fnmatch(rel, rule["path"].rstrip("/") + "*"):
            return True, f"allowed by {rule['path']}"
    return False, "not on allowlist"   # fail closed

if __name__ == "__main__":
    policy = load()
    ok, why = resolve(sys.argv[1], policy)
    print(("allow: " if ok else "deny: ") + why)
    sys.exit(0 if ok else 1)

Two properties are worth naming. os.path.relpath collapses any absolute path back to a repo-relative one, so the hook behaves identically on a laptop, in a container and on a build agent. And the default return is False, the fail-closed behavior that stops a malformed policy from becoming an open door.

The same resolver, called with the repo root, generates the tool-specific ignore files. Generate rather than hand-write them, because agents differ: Cursor reads .cursorignore, other tools have their own conventions, and plain .gitignore constrains neither (Qabash). Put a one-line header in each output file ("generated from agent-policy.yaml, do not edit") so a hand-edit in a pull request stands out.

For the git side, run the resolver against every path in the staged diff from a pre-commit hook. Reject commits that add files inside a denied directory, which is the cheapest moment to catch an agent that "helpfully" wrote a config file into infra/prod/. And remember that ignore rules only stop untracked files from appearing in status; a tracked file needs an index operation or removal (Developers Digest).

The CI check that catches drift after a move

Move-time correctness is not enough, because the next person to touch the repo will not know the rule. Add a CI job with three assertions, run on every push:

  1. No hardcoded paths. Scan the files named in consumers for string literals matching an allow or deny entry. Fail with file and line.
  2. No scope widening. Diff the policy against the merge base. Any addition to allow, or removal from deny, needs an approved_by entry matching a reviewer on the pull request.
  3. Resolution smoke test. Run the resolver against one path from each allow entry and each deny entry, asserting allow and deny respectively. This catches a renamed directory nobody updated.

The second assertion pays for itself. It makes the policy a reviewed artifact in the same sense as an infrastructure template: the diff shows exactly which read scopes changed, and a widening appears in review rather than in an incident. Infrastructure tooling already works this way. A resource whose configuration drifts from its intended state is either accepted deliberately by updating the template or reverted, and the template, not the live environment, is the source of truth (AWS CloudFormation docs).

A shell sketch of assertion 3, enough to catch most renames:

set -e
for p in $(python -c "import yaml;print(' '.join(r['path'] for r in yaml.safe_load(open('agent-policy.yaml'))['allow']))"); do
  test -e "$p" || { echo "allow entry missing on disk: $p"; exit 1; }
done
for p in $(python -c "import yaml;print(' '.join(r['path'] for r in yaml.safe_load(open('agent-policy.yaml'))['deny']))"); do
  ls -d $p >/dev/null 2>&1 && echo "warning: denied path exists and is readable: $p"
done
echo "policy resolves cleanly"

The deny loop warns rather than fails, because a denied path may legitimately exist on disk; denying it is the point. The allow loop must fail, because an allow entry pointing at nothing means the resolver matches less than the author intended, and someone will "fix" it by adding a broader pattern.

The move checklist

When the repository actually moves, run this sequence in order. Every step exists because skipping it ends the same way: an allowlist that no longer matches reality.

StepActionWhat it prevents
1Confirm every path in the policy is repo-relativeEnvironment-specific paths surviving the move
2Grep consumer configs for the old root stringStale absolute paths in tool settings
3Regenerate tool ignore files from the policy.cursorignore and similar files diverging from .gitignore
4Run the resolver smoke test against the new checkoutAllow entries matching nothing
5Diff the policy; confirm no allow additions, no deny removalsSilent widening disguised as a move
6Re-run one real agent task and inspect which files it openedFallback branches opening the whole tree
7Check logs for "outside repository root" denialsPaths resolving above the checkout

Step 6 is the one teams skip, and it is the only step that observes actual behavior rather than intended behavior. Pick a task the agent performs often, run it, and read the list of files it opened. If anything from your deny patterns appears, the enforcement point is not where you think it is.

One caveat on layers. Hooks in the agent tool cover agent-initiated reads, but a shell command the agent runs may bypass the hook entirely. If the command surface is open, pair the policy hook with an endpoint control whose subject is the agent rather than the human, so a developer who may read a production config does not hand that access onward. Such policies usually run in audit mode first to show what they would catch, then switch to blocking, and the subject-versus-target distinction matters: a rule scoped to "any AI agent" leaves the person's own processes alone, while a rule scoped to "anyone" stops the user, the backup software and the installers too (BeyondTrust).

Where this pattern generalizes

The single-source policy file is not only about paths. The same shape covers which MCP servers an agent may connect to, which rules files it may load, and which tools it may call. Backslash Security's analysis of agent allowlists argues that the governed objects are the parts an agent assembles at run time (agent, model, server, tool, skill, hook, rules file), each with its own identity and permissions, and that a denylist alone cannot cover a component whose name says nothing about what it does at run time (Backslash Security). The path policy here is the narrowest instance of that object model, and narrow is the right place to start: paths first, then tools.

If the team grows to several agents, several policy files and a handful of model providers, the plumbing starts to cost more than the policy. At that point one endpoint for every model class, one key, one bill, removes a category of per-provider configuration that would otherwise need its own drifted copy of the same rules. That is what Quanta Inference (https://quantaintelligence.ai/inference) is for, and it is the natural next step once the policy file is stable and the remaining work is routing requests cheaply instead of writing more configuration.

Teams that would rather adopt a tested hook-and-policy bundle than write a resolver can install one from Quanta Skills (https://quantaintelligence.ai/skills) into their coding agent, which is a reasonable way to get the fail-closed default without reinventing it.

FAQ

FAQ

Does `.gitignore` protect files from an agent?

No. .gitignore controls what Git commits, not what a process can read on disk, and many agents scan the filesystem directly rather than the git index (Qabash). You need a separate, explicit deny list that the enforcement point checks before a file is opened.

What is the difference between configuration drift and policy drift here?

Configuration drift is the technical state moving away from its configured baseline, while policy drift is any deviation from the rules the system is meant to follow, including access rules (Sprinto). A repo move that leaves an old allowlist in place is policy drift even when every file on disk is exactly as intended.

Should allowed paths be absolute or relative?

Relative to the repository root, always. Absolute paths encode the environment into the policy, so a rename or a different container layout stops matching, and fail-open code then widens access. Relative paths resolve against whichever checkout the consumer runs in and survive the move unchanged.

How do I stop a developer from editing their local policy copy?

Treat local copies as untrusted and keep the authoritative policy in a managed settings layer, where managed configuration outranks anything a user sets (Unblocked). For file access specifically, an endpoint control whose subject is the agent keeps the person's own access intact while blocking the agent (BeyondTrust).

What is the smallest useful version of this setup?

One agent-policy.yaml at the repository root with an allow list, a deny list and relative paths, plus a pre-execution hook calling a fail-closed resolver. Add two CI assertions next: every allow entry exists on disk, and any allow addition or deny removal needs an approval. That combination catches a botched move before it merges, which is the entire point.

Sources

Sources

  1. Block access while retaining user privileges | AI Agent Security
  2. The Comprehensive Guide to Policy Drift Detection
  3. The Ultimate .gitignore for the AI Era
  4. Three Ways to Ignore Files in Git (Beyond .gitignore)
  5. How to Centralize Context for Coding Agents (2026) | Unblocked
  6. How to Stop an AI Agent from Running Dangerous Commands - Medium
  7. Resolve drift with an import operation - AWS CloudFormation
  8. How to Build Allowlist and Denylist Policies for AI Agents on Employee ...
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