Skip to content

Commit 2644254

Browse files
feat(sdk): scope permissions to routes for composite backends with sandbox default
1 parent 41dc759 commit 2644254

2 files changed

Lines changed: 246 additions & 3 deletions

File tree

libs/deepagents/deepagents/middleware/permissions.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from langchain_core.messages import ToolMessage
1919
from langgraph.types import Command
2020

21+
from deepagents.backends.composite import CompositeBackend
2122
from deepagents.backends.protocol import BACKEND_TYPES, BackendProtocol, GlobResult, GrepResult, LsResult
2223
from deepagents.backends.utils import (
2324
format_grep_matches,
@@ -143,6 +144,31 @@ def _filter_paths_by_permission(
143144
return [p for p in paths if _check_fs_permission(rules, operation, p) == "allow"]
144145

145146

147+
def _all_paths_scoped_to_routes(
148+
rules: list[FilesystemPermission],
149+
backend: BackendProtocol,
150+
) -> bool:
151+
"""Check if every permission path is scoped under a CompositeBackend route.
152+
153+
Returns ``True`` only when *backend* is a ``CompositeBackend`` and every
154+
path pattern in *rules* starts with one of its route prefixes. This means
155+
the permissions only govern file operations on route-specific backends and
156+
never touch the (sandbox-capable) default backend.
157+
"""
158+
if not isinstance(backend, CompositeBackend):
159+
return False
160+
161+
route_prefixes = list(backend.routes.keys())
162+
if not route_prefixes:
163+
return False
164+
165+
for rule in rules:
166+
for path in rule.paths:
167+
if not any(path.startswith(prefix) for prefix in route_prefixes):
168+
return False
169+
return True
170+
171+
146172
class _PermissionMiddleware(AgentMiddleware[Any, ContextT, ResponseT]):
147173
"""Middleware enforcing filesystem permission rules.
148174
@@ -190,10 +216,18 @@ def __init__(self, *, rules: list[FilesystemPermission], backend: BACKEND_TYPES)
190216
raised because tool-level permissions for the ``execute``
191217
tool are not yet implemented.
192218
219+
**Exception for CompositeBackend**: If the backend is a
220+
``CompositeBackend`` whose default supports execution but
221+
*every* permission path is scoped under a known route prefix,
222+
the middleware is allowed. Filesystem permissions only govern
223+
file operations on route backends, so the sandbox default's
224+
execution capability is irrelevant.
225+
193226
Raises:
194-
NotImplementedError: If the backend supports command execution.
227+
NotImplementedError: If the backend supports command execution
228+
and any permission path is not scoped to a route.
195229
"""
196-
if isinstance(backend, BackendProtocol) and supports_execution(backend):
230+
if isinstance(backend, BackendProtocol) and supports_execution(backend) and not _all_paths_scoped_to_routes(rules, backend):
197231
msg = (
198232
"_PermissionMiddleware does not yet support backends with command "
199233
"execution (SandboxBackendProtocol). Tool-level permissions for "

libs/deepagents/tests/unit_tests/test_end_to_end.py

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from langgraph.store.memory import InMemoryStore
2222

2323
from deepagents.backends import CompositeBackend, FilesystemBackend
24-
from deepagents.backends.protocol import BackendProtocol
24+
from deepagents.backends.protocol import BackendProtocol, ExecuteResponse, SandboxBackendProtocol
2525
from deepagents.backends.state import StateBackend
2626
from deepagents.backends.store import StoreBackend
2727
from deepagents.backends.utils import TOOL_RESULT_TOKEN_LIMIT, create_file_data
@@ -1567,6 +1567,215 @@ async def test_filesystem_permission_deny_write_async(self) -> None:
15671567
assert "permission denied" in tool_messages[0].content
15681568

15691569

1570+
class TestCompositeBackendPermissionsEndToEnd:
1571+
"""End-to-end tests for permissions with CompositeBackend + sandbox default.
1572+
1573+
When a CompositeBackend has a sandbox default (supports execution), permissions
1574+
should still be allowed if they only scope to route paths. Permissions that
1575+
include paths outside any route should still raise NotImplementedError.
1576+
"""
1577+
1578+
@staticmethod
1579+
def _make_sandbox_store() -> "SandboxBackendProtocol":
1580+
"""Create a mock sandbox backend based on StoreBackend."""
1581+
1582+
class MockSandbox(SandboxBackendProtocol, StoreBackend):
1583+
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
1584+
return ExecuteResponse(output="", exit_code=0, truncated=False)
1585+
1586+
async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: # noqa: ASYNC109
1587+
return ExecuteResponse(output="", exit_code=0, truncated=False)
1588+
1589+
@property
1590+
def id(self) -> str:
1591+
return "mock-sandbox"
1592+
1593+
return MockSandbox(store=InMemoryStore(), namespace=lambda _ctx: ("filesystem",))
1594+
1595+
def test_permissions_scoped_to_route_with_sandbox_default(self) -> None:
1596+
"""Permissions scoped entirely to a route should work even when default is sandbox.
1597+
1598+
When a CompositeBackend's default supports execution but the permission
1599+
rules only target paths under a known route, the agent should be created
1600+
successfully and the permissions should be enforced on the route.
1601+
"""
1602+
sandbox = self._make_sandbox_store()
1603+
route_store = StoreBackend(store=InMemoryStore(), namespace=lambda _ctx: ("route",))
1604+
composite = CompositeBackend(default=sandbox, routes={"/memories/": route_store})
1605+
1606+
model = FixedGenericFakeChatModel(
1607+
messages=iter(
1608+
[
1609+
AIMessage(
1610+
content="",
1611+
tool_calls=[
1612+
{
1613+
"name": "write_file",
1614+
"args": {"file_path": "/memories/secret.txt", "content": "data"},
1615+
"id": "call_1",
1616+
"type": "tool_call",
1617+
}
1618+
],
1619+
),
1620+
AIMessage(content="Done."),
1621+
]
1622+
)
1623+
)
1624+
1625+
# This should NOT raise NotImplementedError — permissions are route-scoped
1626+
agent = create_deep_agent(
1627+
model=model,
1628+
backend=composite,
1629+
permissions=[
1630+
FilesystemPermission(operations=["write"], paths=["/memories/**"], mode="deny"),
1631+
],
1632+
)
1633+
result = agent.invoke({"messages": [HumanMessage(content="Write to memories")]})
1634+
1635+
tool_messages = [msg for msg in result["messages"] if msg.type == "tool"]
1636+
assert len(tool_messages) == 1
1637+
assert "permission denied" in tool_messages[0].content
1638+
1639+
def test_permissions_allow_route_write_with_sandbox_default(self) -> None:
1640+
"""Permissions that allow a route path should let writes through."""
1641+
sandbox = self._make_sandbox_store()
1642+
route_store = StoreBackend(store=InMemoryStore(), namespace=lambda _ctx: ("route",))
1643+
composite = CompositeBackend(default=sandbox, routes={"/memories/": route_store})
1644+
1645+
model = FixedGenericFakeChatModel(
1646+
messages=iter(
1647+
[
1648+
AIMessage(
1649+
content="",
1650+
tool_calls=[
1651+
{
1652+
"name": "write_file",
1653+
"args": {"file_path": "/memories/note.txt", "content": "hello"},
1654+
"id": "call_1",
1655+
"type": "tool_call",
1656+
}
1657+
],
1658+
),
1659+
AIMessage(content="Done."),
1660+
]
1661+
)
1662+
)
1663+
1664+
# Allow writes under /memories/ — should succeed
1665+
agent = create_deep_agent(
1666+
model=model,
1667+
backend=composite,
1668+
permissions=[
1669+
FilesystemPermission(operations=["write"], paths=["/memories/**"], mode="allow"),
1670+
],
1671+
)
1672+
result = agent.invoke({"messages": [HumanMessage(content="Write a note")]})
1673+
1674+
tool_messages = [msg for msg in result["messages"] if msg.type == "tool"]
1675+
assert len(tool_messages) == 1
1676+
assert "permission denied" not in tool_messages[0].content
1677+
1678+
def test_permissions_outside_routes_still_raises_with_sandbox_default(self) -> None:
1679+
"""Permissions that target paths outside routes should still raise NotImplementedError.
1680+
1681+
If any permission rule covers paths that could hit the sandbox default backend,
1682+
we must still reject — execute tool permissions are not implemented.
1683+
"""
1684+
sandbox = self._make_sandbox_store()
1685+
route_store = StoreBackend(store=InMemoryStore(), namespace=lambda _ctx: ("route",))
1686+
composite = CompositeBackend(default=sandbox, routes={"/memories/": route_store})
1687+
1688+
with pytest.raises(NotImplementedError, match="execute"):
1689+
create_deep_agent(
1690+
model=FixedGenericFakeChatModel(messages=iter([AIMessage(content="Done.")])),
1691+
backend=composite,
1692+
permissions=[
1693+
# This path is NOT under any route — it hits the sandbox default
1694+
FilesystemPermission(operations=["write"], paths=["/workspace/**"], mode="deny"),
1695+
],
1696+
)
1697+
1698+
def test_wildcard_permissions_raises_with_sandbox_default(self) -> None:
1699+
"""Wildcard permissions (/**) that cover default backend paths should raise.
1700+
1701+
A blanket rule like /** covers both route and non-route paths, so it
1702+
cannot be safely scoped to just routes.
1703+
"""
1704+
sandbox = self._make_sandbox_store()
1705+
route_store = StoreBackend(store=InMemoryStore(), namespace=lambda _ctx: ("route",))
1706+
composite = CompositeBackend(default=sandbox, routes={"/memories/": route_store})
1707+
1708+
with pytest.raises(NotImplementedError, match="execute"):
1709+
create_deep_agent(
1710+
model=FixedGenericFakeChatModel(messages=iter([AIMessage(content="Done.")])),
1711+
backend=composite,
1712+
permissions=[
1713+
FilesystemPermission(operations=["write"], paths=["/**"], mode="deny"),
1714+
],
1715+
)
1716+
1717+
def test_mixed_permissions_some_outside_routes_raises(self) -> None:
1718+
"""If any permission rule has paths outside routes, raise NotImplementedError."""
1719+
sandbox = self._make_sandbox_store()
1720+
route_store = StoreBackend(store=InMemoryStore(), namespace=lambda _ctx: ("route",))
1721+
composite = CompositeBackend(default=sandbox, routes={"/memories/": route_store})
1722+
1723+
with pytest.raises(NotImplementedError, match="execute"):
1724+
create_deep_agent(
1725+
model=FixedGenericFakeChatModel(messages=iter([AIMessage(content="Done.")])),
1726+
backend=composite,
1727+
permissions=[
1728+
# This one is route-scoped (fine)
1729+
FilesystemPermission(operations=["read"], paths=["/memories/**"], mode="deny"),
1730+
# This one is NOT route-scoped (should trigger error)
1731+
FilesystemPermission(operations=["write"], paths=["/etc/**"], mode="deny"),
1732+
],
1733+
)
1734+
1735+
def test_multiple_routes_all_scoped(self) -> None:
1736+
"""Permissions scoped to multiple routes should all work with sandbox default."""
1737+
sandbox = self._make_sandbox_store()
1738+
memories_store = StoreBackend(store=InMemoryStore(), namespace=lambda _ctx: ("memories",))
1739+
archive_store = StoreBackend(store=InMemoryStore(), namespace=lambda _ctx: ("archive",))
1740+
composite = CompositeBackend(
1741+
default=sandbox,
1742+
routes={"/memories/": memories_store, "/archive/": archive_store},
1743+
)
1744+
1745+
model = FixedGenericFakeChatModel(
1746+
messages=iter(
1747+
[
1748+
AIMessage(
1749+
content="",
1750+
tool_calls=[
1751+
{
1752+
"name": "write_file",
1753+
"args": {"file_path": "/archive/doc.txt", "content": "data"},
1754+
"id": "call_1",
1755+
"type": "tool_call",
1756+
}
1757+
],
1758+
),
1759+
AIMessage(content="Done."),
1760+
]
1761+
)
1762+
)
1763+
1764+
agent = create_deep_agent(
1765+
model=model,
1766+
backend=composite,
1767+
permissions=[
1768+
FilesystemPermission(operations=["write"], paths=["/memories/**"], mode="deny"),
1769+
FilesystemPermission(operations=["write"], paths=["/archive/**"], mode="deny"),
1770+
],
1771+
)
1772+
result = agent.invoke({"messages": [HumanMessage(content="Write to archive")]})
1773+
1774+
tool_messages = [msg for msg in result["messages"] if msg.type == "tool"]
1775+
assert len(tool_messages) == 1
1776+
assert "permission denied" in tool_messages[0].content
1777+
1778+
15701779
class TestSubAgentPermissionsEndToEnd:
15711780
"""End-to-end tests for subagent permission inheritance and override.
15721781

0 commit comments

Comments
 (0)