cd /news/ai-agents/deep-agents-filesystem-permissions-a… · home topics ai-agents article
[ARTICLE · art-76750] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Deep Agents Filesystem Permissions: A Beginner-Friendly Guide with 3 Python Examples

Deep Agents introduces FilesystemPermission rules that let developers control which paths an AI agent's built-in filesystem tools can access and how. A guide with three Python examples demonstrates how to set up allow, deny, and interrupt modes to restrict agent file operations, such as blocking access to .env files while allowing edits to project code.

read10 min views1 publishedJul 28, 2026

An AI agent that can read and write files is only as safe as the boundaries you put around it. A coding agent might need full access to a project folder, but it should never be able to open .env

, read a shared credentials file, or overwrite a policy document it was only supposed to consult.

Deep Agents solves this with FilesystemPermission

rules: small, declarative statements that say which paths an agent's built-in filesystem tools can touch, and how.

This guide builds three examples, each adding one new idea:

Each example is a complete, runnable script. If you want to follow along, you'll need deepagents

installed and a chat model configured — the full scripts (linked at the end) include that setup so you can just run them.

Permission rules need deepagents>=0.5.2

. If you want to use mode="interrupt"

(Example 2), you need deepagents>=0.6.8

. If your examples don't behave as described below, check your installed version first.

A rule has three parts:

FilesystemPermission(
    operations=["read", "write"],
    paths=["/workspace/**"],
    mode="allow",
)

** operations** — which kind of filesystem call the rule watches:

"read"

covers ls

, read_file

, glob

, grep

"write"

covers write_file

, edit_file

, delete

** paths** — glob patterns matched against the agent's

**

matches any depth (/workspace/**

matches /workspace/app.py

and /workspace/sub/dir/file.txt

). You can also use {a,b}

to match alternatives, e.g. /workspace/{src,tests}/**

.** mode** — what happens when a call matches:

"allow"

— let it through"deny"

— reject it"interrupt"

— the whole agent graph and wait for a human to approve, edit, or reject the callThree rules govern how a list of FilesystemPermission

objects behaves, and all three matter more than any single rule on its own:

deny

rule on /**

.ls

, read_file

, glob

, grep

, write_file

, edit_file

, delete

). They do execute

tool — that's a separate concern, covered at the end of this guide.With that model in place, the examples below should read less like magic incantations and more like straightforward consequences of these three rules.

Goal: the agent can freely read and edit files in /workspace

, except a .env

file sitting inside it.

The script maps a real folder to the agent's virtual root using FilesystemBackend(root_dir=..., virtual_mode=True)

. Concretely, if root_dir

is secure_workspace_data/

, then the real file secure_workspace_data/workspace/app.py

appears to the agent simply as /workspace/app.py

. The agent never sees your actual disk paths — only the virtual ones you've exposed.

import os
from pathlib import Path

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from deepagents import FilesystemPermission, create_deep_agent
from deepagents.backends import FilesystemBackend

load_dotenv()

ROOT = Path(__file__).parent / "secure_workspace_data"
WORKSPACE = ROOT / "workspace"
WORKSPACE.mkdir(parents=True, exist_ok=True)
(WORKSPACE / "app.py").write_text("print('demo application')\n", encoding="utf-8")
(WORKSPACE / ".env").write_text("DEMO_SECRET=do-not-read\n", encoding="utf-8")

model = init_chat_model("nvidia:meta/llama-3.1-70b-instruct")
backend = FilesystemBackend(root_dir=ROOT, virtual_mode=True)

permissions = [
    FilesystemPermission(
        operations=["read", "write"],
        paths=["/workspace/.env", "/workspace/secrets/**"],
        mode="deny",
    ),
    FilesystemPermission(
        operations=["read", "write"],
        paths=["/workspace/**"],
        mode="allow",
    ),
    FilesystemPermission(
        operations=["read", "write"],
        paths=["/**"],
        mode="deny",
    ),
]

agent = create_deep_agent(model=model, backend=backend, permissions=permissions)
result = agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": "Read /workspace/.env.",
            }
        ]
    }
)
print(result["messages"][-1].content)

result = agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": "Read /workspace/app.py.",
            }
        ]
    }
)
print(result["messages"][-1].content)

this will generate output something like:

I cannot read or list the contents of /workspace or /workspace/.env due to a permission error.
The file `/workspace/app.py` contains only one line of code: `print('demo application')`.

Now lets see why the order matters here: /workspace/.env

matches both Rule 1 and Rule 2. Because Rule 1 is checked first, .env

is denied before Rule 2's broad allow ever gets a chance to apply. Swap the two, and the allow rule would win instead — the deny rule would still exist in your code, but it would never fire, because a match was already found above it.

Agent action Result Which rule decided it
Read/edit /workspace/app.py
Allowed Rule 2
Read/edit /workspace/.env
Denied Rule 1
Read /anything-else.txt
Denied Rule 3 (nothing else matched)

Goal: the agent can read a shared policy document but never edit it, and any write under /secrets

s for a human to approve.

This introduces mode="interrupt"

, which behaves differently from allow

/deny

: instead of resolving the tool call immediately, it freezes the agent's execution graph at that point and returns control to your application. Your application decides what happens next — approve the call as-is, edit its arguments, or reject it — and then resumes the same run.

Because the graph has to be d and later resumed, it needs somewhere to store its state in between. That's what the checkpointer

and thread_id

are for: without a checkpointer, there's no state to resume from, so mode="interrupt"

requires one.

"""Example 2: read-only memory and human approval for sensitive writes."""

import os
from pathlib import Path

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from deepagents import FilesystemPermission, create_deep_agent
from deepagents.backends import FilesystemBackend

load_dotenv()

if not os.environ.get("NVIDIA_API_KEY", "").startswith("nvapi-"):
    raise RuntimeError(
        "Set NVIDIA_API_KEY to a valid NVIDIA API key (starting with 'nvapi-') "
        "in your environment or a .env file before running this example."
    )

ROOT = Path(__file__).parent / "approval_memory_data"
for directory in ("workspace", "memories", "secrets"):
    (ROOT / directory).mkdir(parents=True, exist_ok=True)
(ROOT / "workspace" / "report.md").write_text("# Draft report\n", encoding="utf-8")
(ROOT / "memories" / "company-policy.md").write_text(
    "Never commit credentials to source control.\n", encoding="utf-8"
)


model = init_chat_model(
    "nvidia:meta/llama-3.1-70b-instruct",
    model_kwargs={"parallel_tool_calls": False},
)
backend = FilesystemBackend(root_dir=ROOT, virtual_mode=True)
checkpointer = InMemorySaver()

permissions = [
    FilesystemPermission(
        operations=["write"],
        paths=["/memories/**", "/policies/**"],
        mode="deny",
    ),
    FilesystemPermission(
        operations=["read"],
        paths=["/memories/**", "/policies/**"],
        mode="allow",
    ),
    FilesystemPermission(
        operations=["write"],
        paths=["/secrets/**"],
        mode="interrupt",
    ),
    FilesystemPermission(
        operations=["read", "write"],
        paths=["/workspace/**"],
        mode="allow",
    ),
    FilesystemPermission(
        operations=["read", "write"],
        paths=["/**"],
        mode="deny",
    ),
]

agent = create_deep_agent(
    model=model,
    backend=backend,
    permissions=permissions,
    checkpointer=checkpointer,
)

config = {"configurable": {"thread_id": "approval-memory-demo"}}
result = agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": (
                    "Read /memories/company-policy.md, then write a short note to "
                    "/secrets/review.txt saying that the policy was reviewed. "
                    "The secret write requires human approval."
                ),
            }
        ]
    },
    config=config,
)

while True:
    interrupts = result.get("__interrupt__", ())
    if not interrupts:
        print(result["messages"][-1].content)
        break

    interrupt_value = interrupts[0].value
    action_requests = interrupt_value["action_requests"]
    decisions = []

    for action in action_requests:
        print("\nApproval required")
        print(f"Tool: {action['name']}")
        print(f"Arguments: {action['args']}")

        while True:
            decision = input("Approve this operation? [a]pprove/[r]eject: ").strip().lower()
            if decision in {"a", "approve"}:
                decisions.append({"type": "approve"})
                break
            if decision in {"r", "reject"}:
                decisions.append(
                    {
                        "type": "reject",
                        "message": "The user rejected this filesystem operation. Do not retry it.",
                    }
                )
                break
            print("Please enter 'a' to approve or 'r' to reject.")

    result = agent.invoke(Command(resume={"decisions": decisions}), config=config)

A couple of details that are easy to miss:

deny

and interrupt

mean different things.deny

is for actions that should interrupt

is for actions that are legitimate but need a person in the loop before they happen. Mixing them up either blocks something you actually wanted to allow-with-review, or lets something through that should have been reviewed.delete

on a folder, permissions are checked against /secrets/**

, not a wildcard-first pattern like /**/secrets

. Bulk operations (ls

, glob

, grep

, or delete

on a directory) will trigger the interrupt any time their search Goal: a parent agent can edit code inside a project. It delegates review work to an auditor

subagent that must never write anything, only read.

This example uses a CompositeBackend

instead of a plain FilesystemBackend

. A composite backend lets you route different virtual path prefixes to different underlying storage — here, everything under /workspace/

is mapped to a real project folder on disk, while anything else falls back to StateBackend()

(in-memory, scratch state that isn't persisted to disk).

import os
from pathlib import Path

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from deepagents import FilesystemPermission, create_deep_agent
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend

load_dotenv()

ROOT = Path(__file__).parent / "multi_agent_data"
PROJECT = ROOT / "project"
(PROJECT / "src").mkdir(parents=True, exist_ok=True)
(PROJECT / "src" / "app.py").write_text(
    "def greet(name):\n    return f'Hello, {name}'\n", encoding="utf-8"
)

model = init_chat_model("nvidia:meta/llama-3.1-70b-instruct")
backend = CompositeBackend(
    default=StateBackend(),
    routes={
        "/workspace/": FilesystemBackend(
            root_dir=PROJECT,
            virtual_mode=True,
        ),
    },
)

parent_permissions = [
    FilesystemPermission(
        operations=["read", "write"],
        paths=["/workspace/**"],
        mode="allow",
    ),
    FilesystemPermission(
        operations=["read", "write"],
        paths=["/**"],
        mode="deny",
    ),
]

auditor_permissions = [
    FilesystemPermission(
        operations=["write"],
        paths=["/**"],
        mode="deny",
    ),
    FilesystemPermission(
        operations=["read"],
        paths=["/workspace/**"],
        mode="allow",
    ),
    FilesystemPermission(
        operations=["read"],
        paths=["/**"],
        mode="deny",
    ),
]

agent = create_deep_agent(
    model=model,
    backend=backend,
    permissions=parent_permissions,
    subagents=[
        {
            "name": "auditor",
            "description": "Reviews workspace code but must never edit it.",
            "system_prompt": "Review code and report issues. Never modify files.",
            "permissions": auditor_permissions,
        }
    ],
)

result = agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": "Ask the auditor to inspect /workspace/src/app.py.",
            }
        ]
    },
    config={"configurable": {"thread_id": "multi-agent-auditor-demo"}},
)
print(result["messages"][-1].content)

The single most important thing to understand here: ** permissions on a subagent spec replaces the parent's rules, it does not add to them.** A subagent doesn't inherit a stricter version of its parent's policy by default — if you give it

permissions

at all, that list is now the entire policy for that subagent, evaluated in isolation.That's exactly why auditor_permissions

is a complete three-rule list on its own — deny all writes, allow reads inside /workspace

, deny all other reads — rather than just the single write-deny rule. Remember rule #2 from earlier: unmatched calls are allowed by default. If the auditor only had the write-deny rule, it would correctly be blocked from writing, but it would also be free to read absolutely anything, since nothing would deny it.

One more thing worth knowing if you reach for CompositeBackend with a sandbox: if the backend's

default

route is a sandbox (one that can execute arbitrary shell commands), every permission path you declare must be scoped under one of the composite's named route prefixes — you can't write a permission like /**

or /workspace/**

that reaches into the sandbox's default route, and doing so raises NotImplementedError

. This example avoids that because its default is StateBackend()

, not a sandbox — but it's the first thing to check if you swap in a sandbox default and your permissions suddenly stop working.It's worth restating plainly: FilesystemPermission

rules only govern Deep Agents' own built-in filesystem tools. They say nothing about:

execute

If your agent can run shell commands, filesystem permission globs cannot contain that — a shell command can read or write anything the process itself has access to, permission rules or not. That's a separate problem, solved with an actual sandboxed execution environment, not with path patterns.

/workspace/**

, rather than trying to enumerate everything to deny.deny

on /**

— remember, no match means allowed.interrupt

(with a checkpointer) when a human should sign off on an action, and deny

when an action should never happen at all.CompositeBackend

with a sandbox as the default route, scope every permission path to a named route prefix.The full runnable versions are available in this project as secure_workspace_agent.py, approval_memory_agent.py, and multi_agent_auditor.py.

For the current API reference and additional examples, see the Deep Agents permissions documentation.

── more in #ai-agents 4 stories · sorted by recency
── more on @deep agents 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/deep-agents-filesyst…] indexed:0 read:10min 2026-07-28 ·