# Check what a tool does, not what it says A toolpass tool used to be safe to the extent its declaration was right: `effect="write"`, `authorize=permission_check(tp, "github-acme", "pr.create", "repo:{repo}")`. The declaration is a claim. If the body also deletes a branch, nothing notices; and writing it correctly means knowing GitHub's permission model. toolpass can instead watch the requests a tool's body makes. Every HTTP request that leaves the process while a toolpass tool runs is mapped to the integration action and resource the permission engine already answers for (`POST /repos/acme/x/pulls` is `pr.create` on `repo:acme/x`). The requests inside `as_user()` are checked and refused before they are sent when the session's user may not make them. The tool declares nothing: ```python import toolpass from toolpass import Session, Toolpass, as_user from github import Github tp = Toolpass.from_config("toolpass.yaml") # the github-acme connection, among others toolpass.configure(permissions=tp) @toolpass.tool() def propose_fix(repo: str, path: str, fix: str) -> str: r = Github(auth=token).get_repo(repo) current = r.get_contents(path) # the agent reads on its own authority with as_user(): # checked against the user pr = r.create_pull(title="fix", body=fix, head="fix", base="main") return f"opened #{pr.number}" with Session("dana@example.com").active(): propose_fix("acme/x", "README.md", "...") ``` When Dana may not open pull requests in `acme/x`, the `POST` never reaches GitHub: the hook raises `ActionRefused` (a `ToolRefused` with code `not_authorized`) inside PyGithub's call, the body unwinds, and the model reads the refusal. The audit event lists both requests as `actions`, the read allowed and the pull request refused. ## How a request is understood 1. **Which system.** The request's host is matched against the connections of the `permissions` engine (`Toolpass.from_config` or `Toolpass(connections=[...])`): `api.github.com` or a GitHub Enterprise Server URL is the `github-acme` connection, `slack.com` is `slack-main`. A remote engine knows no connections; name them with `connections=[{"id": ..., "integration": ..., "url": ...}]` then. 2. **Which action.** The integration's route table maps the method and path (and, where the path does not carry it, the body: Slack's `channel`) to an action, a resource and an effect. `toolpass routes github` prints the table. 3. **The checks**, in the toolkit's usual order: the tool's `resources=` scope, limits, the permission check of the session's user, the untrusted-input check on the outgoing URL and body, the exfiltration guard for routes that send data out. 4. **The verdict.** Allowed requests go out. A refused request raises from inside the client library; the call's outcome is the refusal even when the body catches the exception and returns something, so a half-result never reaches the model. A request the toolkit cannot place is handled by what it knows: | Request | Default | With `strict=True` | |---|---|---| | To a host that is not a configured connection | allowed, audited `unknown_host` | refused | | A read on a known host with no route | allowed as `read`, audited `unmapped` | the same | | A write on a known host with no route | refused `unmapped_action` | the same | | A route whose resource the request does not name (a Slack channel by name) | refused `unresolved_resource` | the same | | From a thread that lost the call's context | allowed, unchecked (see below) | the same | Requests about the agent's own identity (`GET /user`, `auth.test`), search and rate limits need no user permission: they are allowed and audited with no action. ## `as_user()` and `as_agent()` A body mostly runs on the agent's own authority: it reads the file it needs, looks up the default branch, and then opens a pull request the user must be allowed to open. `as_user()` marks that last part. Inside it, every request is checked against the session's user; outside it, the permission check against the user is skipped and nothing else: scope, limits, the untrusted-input and exfiltration checks and entitlements still apply, and each action is audited with `as_agent`. The block covers only requests made inside it, not a nested toolpass tool, which starts from the toolkit's setting again. `act_as` sets whose authority a request outside both blocks runs on. `"agent"`, the default, is the one above. `"user"` turns it around: every request is checked against the user, and `as_agent()` marks the ones that run on the agent's authority. ```python toolpass.configure(permissions=tp, act_as="user") @toolpass.tool() def propose_fix(repo: str, path: str, fix: str) -> str: r = Github(auth=token).get_repo(repo) with as_agent(): # the agent may read what the user cannot current = r.get_contents(path) pr = r.create_pull(title="fix", body=fix, head="fix", base="main") # checked against the user return f"opened #{pr.number}" ``` The innermost block decides, so `as_user()` inside `as_agent()` checks again. With the default, a request you forget to put inside `as_user()` is one no one checks against the user, so record the tool's entitlements and review the `as: agent` lines, or set `act_as="user"` where a forgotten `as_agent()` should fail closed instead. With entitlements, the file decides: a request that matches an `as: user` line is checked against the user even outside `as_user()`. ## Record what a tool does, then hold it to that A refusal at the wire lands mid-body: a tool that created a branch, pushed and then had its pull request refused leaves the branch behind. Recorded entitlements move the refusal before the body: ```python toolpass.configure(permissions=tp, record="toolpass-tools.yaml") # in development or tests ``` Every call writes the actions its body performed to the file, with argument values turned back into templates: ```yaml tools: propose_fix: actions: - action: github:repo.read resource: repo:{repo} as: agent effect: read - action: github:pr.create resource: repo:{repo} as: user effect: write ``` In production, the file is what the tool may do: ```python toolpass.configure(permissions=tp, entitlements="toolpass-tools.yaml") ``` Before the body runs, every `as: user` action whose resource fills from the arguments is checked against the engine, and limits are taken for the strongest recorded effect. The hook stays on as the backstop: a request that matches no recorded action is refused `unentitled_action`, a host the file does not list is refused `unknown_host`, and a tool that declares `effect="read"` while its recorded actions include a write is refused `effect_mismatch`. A resource known only at run time (an id fetched by an earlier request) is written as a glob (`repo:acme/*`) or a literal and checked at the wire. `strict=True` also refuses a tool that has no entry in the file. ## Through the frameworks The hook sits under the tool body, so it works wherever the toolkit works: the tests drive the same scenario (a tool that declares nothing, a read as the agent, a refused pull request) through each of the nine supported frameworks' own tool execution path, with a scripted model: the real agent loop for eight of them, and for the Claude Agent SDK its in-process MCP server, which is what runs a tool when the agent calls it. In every one the call's context reaches the body, the refused request never leaves the process, and the allowed one does. What differs is how each framework hands a tool's exception to the model. Where it masks or re-raises it, set `toolpass.configure(on_refuse=str)` so the refusal is the tool's result and the model reads why: | Framework | Without `on_refuse=str` | Needs `on_refuse=str` | |---|---|---| | Strands Agents | the model reads `Error: ActionRefused - ` | no | | LlamaIndex | the model reads the reason as an error tool output | no | | Claude Agent SDK (a tool on its in-process MCP server) | the server returns the reason as an error result | no | | LangChain `create_agent`, and LangGraph's prebuilt `ToolNode` with its default error handler | the exception escapes the run | yes | | OpenAI Agents SDK (`function_tool`) | "An error occurred while running the tool" | yes | | Pydantic AI | the exception escapes the run | yes | | Google ADK | the run ends on the exception | yes | | CrewAI | the model reads CrewAI's own wording of the error | yes | | MCP server (the `mcp` SDK) | "Error executing tool ", the message masked | yes | With `on_refuse=str` the refusal arrives as an ordinary tool result in every framework. ## What it cannot see - A client library the hook does not patch (urllib3 and everything on it, the standard library, httpx and aiohttp are covered; gRPC is not yet), or a custom httpx transport. - A request made from a thread the SDK started (boto3's managed transfers, pubsub's `publish()`, Azure's pollers): the call's context does not reach it, so it is not attributed to the call and not checked. toolpass cannot tell such a request from the application's own traffic in another thread, so it does not refuse it either. `TransferConfig(use_threads=False)` keeps boto3's transfers in the caller's thread; `contextvars.copy_context().run(...)` carries the context into a thread you start yourself. - The response: toolpass does not read what comes back. A read from an untrusted route marks the session as having seen untrusted content; the tool's return value is recorded and fenced as always. - Meaning in a body it cannot parse: a GraphQL mutation or an SQL statement is refused until a route reads it. The hook is on for a toolkit that has `permissions`, `connections`, `entitlements`, `record` or `strict` set, and for any tool that declares no `effect`. `Toolkit(intercept=False)` turns it off. A tool with a declared effect in a toolkit with none of those settings runs as before.