Skip to main content

toolpass

A pass for every tool call: secure-by-default tools for AI agents, under any agent framework.

The secure-tools API (Toolkit, Session) is alpha and may still change; the permission engine (Toolpass, guarded, the adapters) is stable.

The package also ships the MCP gateway: toolpass gateway on puts toolpass in front of every MCP server Claude Desktop and Claude Code already use, and toolpass dashboard shows every tool call they make, and, for secured tools, every request each call made inside GitHub, Jira, Slack and the other systems. See the gateway guide; python examples/demo/run.py shows it with five systems. The rest of this page is about securing the tools you write yourself.

Teams write custom tools for their own agents: open a pull request against the GitOps repository, scale a deployment, read customer records, post to Slack. Those tools usually run with one service credential for everyone who talks to the agent, and every team rebuilds the same safety checks by hand, or skips them. toolpass is a decorator on a plain Python function. The function declares nothing; toolpass watches what it does:

import toolpass
from toolpass import Session, Toolpass, as_agent, secured_tool
from github import Github                            # any SDK: PyGithub, slack_sdk, boto3, raw requests

toolpass.configure(permissions=Toolpass.from_config("toolpass.yaml"))   # the github-acme connection, among others


@secured_tool()
def propose_fix(repo: str, path: str, fix: str) -> str:
    gh = Github(auth=token)
    with as_agent():                                 # the agent may read on its own authority
        current = gh.get_repo(repo).get_contents(path)
    pr = gh.get_repo(repo).create_pull(title="fix", body=fix, head="fix", base="main")
    return pr.html_url                               # pr.create on repo:acme/x: checked against the user


with Session("dana@example.com").active():           # the user your app authenticated
    agent.run(prompt)

Every HTTP request the body makes is mapped to the action it is, checked against the session's user, and refused before it is sent when they may not do it; see Actions observed at the wire. A tool can also declare what it does, and the two compose:

from toolpass import permission_check

toolpass.configure(credentials={"github-bot": github_token}, limits={"destructive": 3})


@secured_tool(
    effect="write",
    scope={"repo": "acme/gitops-*"},                 # the tool itself can touch nothing else
    authorize=permission_check(tp, "github-acme", "pr.create", "repo:{repo}"),
    credential="github-bot",                          # injected below; the model never sees it
)
def open_gitops_pr(repo: str, title: str, change: str, *, credential: str) -> str:
    ...

The decorated function keeps its signature (minus the injected credential), so LangChain, Pydantic AI, CrewAI, the OpenAI Agents SDK or your own loop can put their own @tool on top. No adapter is needed. Or let secured_tool apply it: as_tool= takes the framework's decorator and returns that framework's tool, with every check inside.

from langchain_core.tools import tool

@secured_tool(effect="read", untrusted_output=True, as_tool=tool)
def read_email(id: str) -> str: ...

Some frameworks hide a tool's exception from the model or let it escape the run (the OpenAI Agents SDK's function_tool answers "An error occurred while running the tool"; LangChain's default ToolNode, Pydantic AI, Google ADK, CrewAI and the MCP SDK's server behave their own way). There, toolpass.configure(on_refuse=str) makes a refusal the tool's result, so the model can tell the user why; the guide lists which frameworks need it.

toolpass.configure(...) sets up the default behind secured_tool; tools declared earlier pick the settings up at call time. A program that needs two sets of settings makes Toolkit(...) objects and uses their secured_tool.

What a tool can declare

Protection Declared with What it guarantees
User from the session Session(user).active() The user comes from your app, never from the model's arguments
Argument validation type hints, validate={...} str is a str, Literal is one of its values; your validators run before anything else
Scope limits scope={"repo": "acme/gitops-*"} The tool can only touch what it declares, whoever asks
Action limits limit=, Toolkit(limits={"destructive": 3}) A loop or an injection cannot run 200 deletes in one session
User authorization authorize=permission_check(...) or any callable The call runs only if the person asking may do it
Untrusted-input check untrusted_output=True on readers An action whose arguments repeat untrusted content (an email, a web page) is refused
Fencing automatic for untrusted string output Untrusted text reaches the model inside a nonce-tagged block marked as data
Exfiltration guard reads_private=True, sends_out=True Once a session has read private data and seen untrusted content, nothing is sent out
Credential injection credential="name" The secret is fetched after every check and never enters the model's context; echoes of it in the output are redacted
Audit Toolkit(audit=...) One event per call, refused or run, with who asked and why it was decided
Observed actions Toolkit(permissions=tp), no effect Every request the body makes is mapped to an action, checked against the user and refused before it is sent; as_agent() marks reads the agent makes on its own authority
Recorded entitlements Toolkit(record=...), then entitlements= A run records what the tool did; the tool is then held to it, with the user's actions checked before the body runs

The order of checks

Every call goes through the same steps. A failed step refuses the call before the tool's body runs, and anything that cannot be evaluated (an authorizer that raises, a credential source that is down) refuses too.

  1. Session. No current session means no call.
  2. Arguments. Types, then validators, then scope rules.
  3. Limits. One slot is taken from each cap, and given back if the call never reaches its body.
  4. Authorization. Only an explicit allow lets the call through.
  5. Untrusted input. Arguments repeating six or more consecutive words of untrusted output are refused.
  6. Exfiltration. A sends_out tool in a session that has read private data and seen untrusted content is refused (exfiltration="off" turns the guard off).
  7. Credential. Fetched only now.
  8. The body. Its output is redacted, recorded if untrusted, and fenced.
  9. Audit. One event, whatever happened.

The exfiltration guard

An agent can be made to leak data only when one session has all three of what Simon Willison calls the lethal trifecta: access to private data, exposure to untrusted content, and a way to send data out. Tools already declare which of these they are, so the toolkit tracks the first two per session and guards the third:

@secured_tool(effect="read", reads_private=True)
def read_customers() -> str: ...

@secured_tool(effect="read", untrusted_output=True)
def read_email(id: str) -> str: ...

@secured_tool(effect="write", sends_out=True)
def post_slack(channel: str, text: str) -> str: ...

After read_customers and read_email have both run in a session, post_slack is refused. This does not depend on spotting injected words, so a model that paraphrases the injection does not get past it.

Hooks in async tools

In async tools, sync hooks (an authorizer, a credentials callable) run in a worker thread, so blocking I/O in them cannot stall the event loop; pass async def hooks to run them on the loop.

Watch it live

toolpass.watch() serves a page that shows every call the toolkit decides, as it happens: who asked, the arguments, ran or refused and why, each request the body made and the system it reached, with a map of the tools and those systems. It is read-only: it changes nothing in the toolkit.

import toolpass

page = toolpass.watch()                    # the default toolkit; toolpass.watch(kit) for another
print(page.url)                            # http://127.0.0.1:PORT/t/<token>/
...
page.close()                               # or use it as a context manager

page.events() returns what the page shows.

The page binds to the loopback interface only, and every path carries a random token, so another program or web page on the machine cannot read anything without the printed URL. It has no accounts: to show it to people elsewhere, put something that authenticates them in front of it.

Refusals

A refused call raises ToolRefused with a code (out_of_scope, not_authorized, untrusted_input, exfiltration_risk, ...) and a message written for the model. Most frameworks pass the exception's text back to the model. Where a framework hides it, Toolkit(on_refuse=str) returns the message as the tool's result instead. A check that itself fails unexpectedly (a scope predicate that crashes, a session source that is down) refuses with check_error or the check's own code.

An exception from the tool's own body passes through unchanged, with two exceptions. If its message contains the injected credential, or the tool is marked untrusted_output (its error text may carry what it fetched), it is raised as ToolError: the secret redacted, the untrusted text recorded and fenced, and the original exception kept on .original for your own logs.

A validator accepts by returning True (or any truthy value, such as a re.Match) and rejects by returning anything falsy (False, None) or raising. Generator tools are not supported: return the whole result. A returned iterator is turned into a list before redaction; an async iterator is refused.

Permission checks in tools

permission_check asks Toolpass, the permission engine in this package, whether the session's user may perform the action, live, in the system that owns the resource (GitHub, Jira, Kubernetes, AWS, and 17 more). It works with Toolpass.from_config(...) in-process or Toolpass.remote(...) against a toolpass server. Any callable that returns AuthDecision, True or False can be the authorizer instead. The engine also works on its own: permission checks on their own, below.

Actions observed at the wire

A declaration is a claim. toolpass can instead watch the requests a tool's body makes: every HTTP request that leaves the process while a secured tool runs (PyGithub, slack_sdk, boto3, raw requests, anything on urllib3, the standard library, httpx or aiohttp) is mapped to the action and resource the permission engine answers for, checked, and refused before it is sent. The tool declares nothing:

toolpass.configure(permissions=Toolpass.from_config("toolpass.yaml"))

@secured_tool()
def propose_fix(repo: str, path: str, fix: str) -> str:
    gh = Github(auth=token)
    with as_agent():                                       # the agent may read on its own authority
        current = gh.get_repo(repo).get_contents(path)
    return gh.get_repo(repo).create_pull(title="fix", body=fix, head="fix", base="main").html_url   # checked against the user

When the session's user may not open pull requests in that repository, the POST never reaches GitHub: ActionRefused (a ToolRefused) is raised inside PyGithub's call, and the call is refused even if the body catches it. The audit event lists every request as actions. toolpass.configure(record="toolpass-tools.yaml") writes the actions a run performed to a file, and configure(entitlements="toolpass-tools.yaml") then checks the recorded user actions before the body runs and refuses anything beyond the list. The guide has the details; toolpass routes github prints what toolpass understands of an API.

Limits

  • The hook sees HTTP from the libraries it patches (urllib3 and everything on it, the standard library, httpx, aiohttp), in the thread or task the body runs in. gRPC, a custom httpx transport and a thread an SDK starts (boto3's managed transfers) are not seen. GitHub, Slack, Jira, Linear and Kubernetes have route tables today; a request to another integration is placed by host but not mapped to an action.
  • The untrusted-input check matches words, not meaning. A model that paraphrases injected text, or an argument shorter than six words, is not caught. The exfiltration guard catches a paraphrase only when the session also read private data and the tool sends data out; otherwise the call runs, within its scope and limits. Don't rely on this check alone.
  • Fencing helps, it does not guarantee. Models can still be talked past markers.
  • Only string output is fenced. Structured output is recorded as untrusted but returned as is.
  • Sessions live in memory. Counts and flags do not survive a restart or span processes yet.
  • Redaction matches the credential's literal text. An encoded or split echo of it (base64, a hex dump) is not caught, and strings shorter than 8 characters are never redacted.
  • permission_check puts argument values into the resource string as they are. Give such arguments a scope rule or validator, so a value carrying ?, @ or # cannot make the permission check look at a different resource from the one the tool acts on.
  • Check and action are not atomic. Authorization is checked, then the body runs; a permission revoked in between is not noticed.
  • Session state reaches worker threads only through context. Frameworks that run tools in threads must copy the context (LangChain and asyncio.to_thread do).

Run the example and the tests

pip install -e . pytest pytest-timeout hypothesis
python examples/ops_agent.py
python -m pytest -q

examples/ops_agent.py runs a scripted ops-agent session with no network: an injected email, a blocked exfiltration attempt, a permission denial, and the same pull request run for an admin.

Permission checks on their own

Permission checks for AI agents and bots, answered live by the system they act in.

Before your agent acts for a user (delete a Jira issue, push to a repository, scale a deployment), ask toolpass whether that user may do it. toolpass asks the system that owns the resource, live, with its own read-only credential, and answers allow, deny or unknown. It only checks; it never performs the action. Twenty-one systems are supported: Kubernetes, Argo CD, GitHub, GitLab, Bitbucket, Jira, Confluence, Slack, Datadog, PagerDuty, AWS, Google Workspace, Google Cloud, Microsoft 365, Databricks, Salesforce, Snowflake, Vault, Azure, Linear and Zendesk.

pip install toolpass

Python 3.10 or later. The core depends only on PyYAML. The engine runs in your process, so there is no service to deploy; the same package also runs it as a server (toolpass serve).

In-process

From a toolpass YAML file (example, reference):

from toolpass import Toolpass

tp = Toolpass.from_config("toolpass.yaml")

d = tp.check("dana@example.com", "jira-main", "DELETE_ISSUES", "issue:PAY-123")
d.decision  # "allow", "deny" or "unknown"
d.reason    # "denied: Dana Levi does not hold DELETE_ISSUES on issue PAY-123"
d.allowed   # True only for allow

tp.allowed(...)                # True or False
tp.require(..., fresh=True)    # the decision on allow; raises PermissionDenied otherwise
await tp.acheck(...)           # check and require for async code
await tp.arequire(...)

Or with the connections in code, as mappings with exactly the keys of the file. A secret key takes toolpass.env("NAME"), toolpass.file("/path"), toolpass.literal(value), or the file's "env:NAME" and "file:/path" strings:

import toolpass

tp = toolpass.Toolpass(connections=[
    {"id": "jira-main", "integration": "jira", "url": "https://acme.atlassian.net",
     "username": "toolpass-bot@acme.com", "credential": toolpass.env("JIRA_TOKEN")},
])

check never raises for a failed lookup: a timeout, a rejected credential or anything else toolpass cannot evaluate is unknown. Treat unknown exactly like deny.

Remote

To keep the lookup credentials out of the agent's process, run toolpass as a server (the ghcr.io/roee-hersh/toolpass image, the Helm chart, or toolpass serve -config toolpass.yaml) and ask it:

tp = Toolpass.remote("https://toolpass.internal", api_key)  # or TOOLPASS_URL and TOOLPASS_API_KEY

The methods are the same. The URL must be https://, or http:// on localhost; redirects are not followed; a server that cannot be reached is unknown.

guarded

Puts the check in front of one function, so its body runs only after toolpass said allow:

from contextvars import ContextVar
from toolpass import guarded

current_user: ContextVar[str] = ContextVar("current_user")  # set from your login, per request

@tool  # any framework's decorator
@guarded(tp, "jira-main", "DELETE_ISSUES", "issue:{key}", user=current_user, fresh=True)
def delete_issue(key: str) -> str:
    jira.delete_issue(key)  # the agent's own credential, only after allow
    return f"deleted {key}"

The user comes from user= (a string, a zero-argument callable or a ContextVar), never from the call's arguments, so the model cannot choose who it acts as. Anything but allow raises PermissionDenied before the body runs; deny= returns a message instead.

Framework adapters

Each adapter is configured once on the agent with rules, a tool name to Rule(connection, action, resource) or a (connection, action, resource) tuple, and checks every call to a tool with a rule before it runs. A refused call does not run; the model reads toolpass refused this call: <reason> and the run goes on. The user always comes from the application, never from the model. A tool without a rule runs unchecked, unless strict=True.

Strands Agents: an intervention handler; the user from invocation_state.

from toolpass.strands import ToolpassAuthorization, Rule

guard = ToolpassAuthorization(tp, {"delete_issue": Rule("jira-main", "DELETE_ISSUES", "issue:{key}", fresh=True)})
agent = Agent(tools=tools, interventions=[guard])
agent(prompt, invocation_state={"user_id": user.email})

LangChain and LangGraph: agent middleware; the user from the runtime context.

from toolpass.langchain import ToolpassMiddleware, Rule

guard = ToolpassMiddleware(tp, {"delete_issue": Rule("jira-main", "DELETE_ISSUES", "issue:{key}")})
agent = create_agent(model, tools=tools, middleware=[guard], context_schema=Context)
agent.invoke({"messages": [...]}, context=Context(user_id=user.email))
# a graph you build yourself: guard.tool_node(tools) is a checked ToolNode

MCP servers (the mcp SDK v2): server middleware; the user from the access token's email claim, or user= for stdio.

from toolpass.mcp import Rule, guard

guard(mcp, tp, {"delete_issue": Rule("jira-main", "DELETE_ISSUES", "issue:{key}")})

OpenAI Agents SDK: tool guardrails; the user from the run context's user_id.

from toolpass.openai_agents import ToolpassGuardrails, Rule

agent = ToolpassGuardrails(tp, {"delete_issue": Rule("jira-main", "DELETE_ISSUES", "issue:{key}")}).apply(agent)
await Runner.run(agent, prompt, context=RequestContext(user_id=user.email))

Claude Agent SDK: a PreToolUse hook; the user from user=. Rules use Claude Code's tool names.

from toolpass.claude_agent_sdk import ToolpassHooks, Rule

guard = ToolpassHooks(tp, {"mcp__ops__delete_issue": Rule("jira-main", "DELETE_ISSUES", "issue:{key}")}, user=current_user)
options = guard.apply(ClaudeAgentOptions(mcp_servers={"ops": server}))

Google ADK: tool callbacks (or .plugin() for an App); the user is the session's user_id.

from toolpass.google_adk import ToolpassCallbacks, Rule

agent = ToolpassCallbacks(tp, {"delete_issue": Rule("jira-main", "DELETE_ISSUES", "issue:{key}")}).apply(agent)

CrewAI: process-wide tool-call hooks; the user from the crew's kickoff inputs.

from toolpass.crewai import ToolpassHooks, Rule

with ToolpassHooks(tp, {"delete_issue": Rule("jira-main", "DELETE_ISSUES", "issue:{key}")}):
    crew.kickoff(inputs={"user_id": user.email})

Pydantic AI: a capability (or ToolpassToolset around one toolset); the user from deps.user.

from toolpass.pydantic_ai import ToolpassAuthorization, Rule

agent = Agent(model, deps_type=Deps, tools=tools,
              capabilities=[ToolpassAuthorization(tp, {"delete_issue": Rule("jira-main", "DELETE_ISSUES", "issue:{key}")})])
agent.run_sync(prompt, deps=Deps(user=user.email))

LlamaIndex: wrapped tools; the user from user=.

from toolpass.llamaindex import ToolpassAuthorization, Rule

guard = ToolpassAuthorization(tp, {"delete_issue": Rule("jira-main", "DELETE_ISSUES", "issue:{key}")}, user=current_user)
agent = FunctionAgent(tools=guard.wrap(tools), llm=llm)

Complete, runnable examples: toolpass-py/examples. Each adapter's docstring and the agent guide cover groups, strict, and what each framework does with a refusal.

Extras

Extra Adds
toolpass[crypto] cryptography, for the integrations that sign with a private key: GitHub App, Google service accounts, Snowflake key pair, Salesforce JWT, Microsoft 365 certificates
toolpass[strands] strands-agents, for toolpass.strands
toolpass[langchain] langchain and langgraph, for toolpass.langchain
toolpass[mcp] mcp, for toolpass.mcp
toolpass[openai-agents] openai-agents, for toolpass.openai_agents
toolpass[claude-agent-sdk] claude-agent-sdk, for toolpass.claude_agent_sdk
toolpass[google-adk] google-adk, for toolpass.google_adk
toolpass[pydantic-ai] pydantic-ai-slim, for toolpass.pydantic_ai
toolpass[crewai] crewai, for toolpass.crewai
toolpass[llamaindex] llama-index-core, for toolpass.llamaindex

The command

The package installs toolpass:

toolpass validate -config toolpass.yaml      # the file, references, certificates; no network
toolpass probe    -config toolpass.yaml      # each connection's credential, live
toolpass check    -config toolpass.yaml -connection jira-main \
  -user dana@example.com -action DELETE_ISSUES -resource issue:PAY-123
toolpass catalog  jira                       # an integration's keys and actions
toolpass serve    -config toolpass.yaml      # POST /check and GET /healthz on :8080

More

  • Documentation, including one page per integration with the credential to create.
  • Architecture and trust boundaries.
  • toolpass-client on PyPI, the old Python client, is replaced by this package and gets no new releases; installed versions keep working. To move, depend on toolpass and change from toolpass_client import Toolpass / Toolpass() to from toolpass import Toolpass / Toolpass.remote() (and toolpass_client.strands to toolpass.strands).

Apache-2.0.

Metadata

Release files for toolpass 0.8.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for toolpass 0.8.1
File Size Uploaded
toolpass-0.8.1.tar.gz 976.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for toolpass 0.8.1
File Interpreter ABI Platform
toolpass-0.8.1-py3-none-any.whl Python 3 none any Details

Total release size: 1.5 MB

Release files / toolpass-0.8.1.tar.gz

Download URL toolpass-0.8.1.tar.gz
Size 976.7 kB
Tags Source
SHA-256 checksum
How to use checksums
2a66eb1638bbd146d405747397cb5344d29a5426f64ba786479768ff4217805c
BLAKE2b-256 checksum
How to use checksums
d19535286aad3abec5413af530801f1a2247abf71c96f9679b66eea8c314b684
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release files / toolpass-0.8.1-py3-none-any.whl

Download URL toolpass-0.8.1-py3-none-any.whl
Size 550.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5526f237fcc03160ca02afb8dd5b8e694c977a61ea14dccff0008bace456767a
BLAKE2b-256 checksum
How to use checksums
3286bb2bc449036310f458811fb6395cd96e93fbb7a8e15cdc09a916d77589a8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release history Release notifications | RSS feed

0.11.1

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.0

2 release files

This release

0.8.1 This release

2 release files

0.8.0

2 release files

0.7.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page