Guard Tool Calls

The model can now request either document tool. It can also request private file .env. A system-prompt sentence is guidance, not an authorization check.

The policy must run after Axio validates arguments and before the handler reads data.

Outcome

You will attach a DocumentAccessGuard to read_document. It accepts allowed paths, rejects disallowed paths, and prevents the handler from running on denial.

Fast Track

  1. Subclass PermissionGuard and implement check.

  2. Return keyword arguments to allow a call.

  3. Raise GuardError to deny a call.

  4. Attach the guard through Tool(..., guards=(guard,)).

  5. Verify direct denial and the Agent’s error event.

Download the complete example.

Hands-on delta

1. Put access data in executable policy

Add this guard beside the handlers in harness.py:

examples/tutorial/guard_tool_calls.py
@dataclass(frozen=True, slots=True)
class DocumentAccessGuard(PermissionGuard):
    allowed_paths: frozenset[str]

    async def check(
        self,
        tool: Tool[Any],
        **kwargs: Any,
    ) -> dict[str, Any]:
        path = str(kwargs["path"])
        if path not in self.allowed_paths:
            raise GuardError(f"{tool.name} denied access to {path}")
        return {**kwargs, "path": path}


document_access = DocumentAccessGuard(
    allowed_paths=frozenset({"README.md", "docs/architecture.md"}),
)
read_document_tool = Tool(
    name="read_document",
    handler=read_document,
    description="Read one document by exact path; do not use for discovery.",
    guards=(document_access,),
)

check receives validated keyword arguments. Returning a dictionary allows the call and can replace arguments. Here, the guard checks the exact path before the handler reads the document.

Raising GuardError denies the call. Axio does not invoke the handler after a guard denial.

Guards run in tuple order. Each guard receives the keyword arguments returned by the previous guard. Read Tool System for the full execution order.

2. Keep search from becoming a side door

The previous lesson’s search_documents returns only records whose visibility is public. Keep that filter.

A guard protects one tool invocation. It does not replace authorization in the document service or database. The backing service must still apply the current user’s access policy for every data path.

Use Writing Guards for reusable guards, audit guards, and concurrency guidance.

Try It

First call the Tool directly. Then send the same denied input through the Agent to verify its stream behavior.

Run uv run python examples/tutorial/guard_tool_calls.py from the repository root.

examples/tutorial/guard_tool_calls.py
async def try_guard() -> None:
    allowed = await read_document_tool(path="README.md")
    assert allowed == "README.md: Project overview"
    assert handler_calls == ["README.md"]

    try:
        await read_document_tool(path=".env")
    except GuardError as error:
        assert ".env" in str(error)
    else:
        raise AssertionError(".env must be denied")

    transport = StubTransport(
        [
            make_tool_use_response(
                tool_name="read_document",
                tool_input={"path": ".env"},
            ),
            make_text_response("I cannot access .env."),
        ]
    )
    agent = Agent(
        system="Use document tools for facts about project documents.",
        transport=transport,
        tools=[read_document_tool, search_documents_tool],
    )
    events = [
        event
        async for event in agent.run_stream(
            "Show .env",
            MemoryContextStore(),
        )
    ]
    denied_results = [
        event for event in events if isinstance(event, ToolResult) and event.is_error
    ]

    assert len(denied_results) == 1
    assert handler_calls == ["README.md"]
    public_results = await search_documents_tool(query="")
    assert "README.md" in public_results
    assert "docs/architecture.md" in public_results
    assert ".env" not in public_results
    print(denied_results[0].content)


async def main() -> None:
    logging.getLogger("axio.agent").setLevel(logging.CRITICAL)
    await try_guard()


if __name__ == "__main__":
    asyncio.run(main())

A direct Tool call raises GuardError. During an Agent run, Axio converts the denial into an error tool result. The transport can then produce a safe final response or request a different action.

This distinction keeps policy errors inside the agent loop without hiding them from your application event stream.

Done when

  • [ ] DocumentAccessGuard returns arguments for an allowed exact path.

  • [ ] It raises GuardError for a disallowed path.

  • [ ] The guarded handler does not run after denial.

  • [ ] read_document_tool receives the guard as a one-item tuple.

  • [ ] An Agent run emits one error ToolResult for the denied call.

  • [ ] The backing search path still limits results independently.

Next failure

Agent.run returns the final text, but the application cannot render the tool request, denial, retry, or token usage as they happen. The next lesson switches the harness boundary to run_stream and handles every action explicitly.

Continue with Stream Every Action.