|
21 | 21 | from langgraph.store.memory import InMemoryStore |
22 | 22 |
|
23 | 23 | from deepagents.backends import CompositeBackend, FilesystemBackend |
24 | | -from deepagents.backends.protocol import BackendProtocol |
| 24 | +from deepagents.backends.protocol import BackendProtocol, ExecuteResponse, SandboxBackendProtocol |
25 | 25 | from deepagents.backends.state import StateBackend |
26 | 26 | from deepagents.backends.store import StoreBackend |
27 | 27 | 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: |
1567 | 1567 | assert "permission denied" in tool_messages[0].content |
1568 | 1568 |
|
1569 | 1569 |
|
| 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 | + |
1570 | 1779 | class TestSubAgentPermissionsEndToEnd: |
1571 | 1780 | """End-to-end tests for subagent permission inheritance and override. |
1572 | 1781 |
|
|
0 commit comments