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 toolpass server's dashboard (/dashboard/) shows every run of your toolpass tools, from every
agent that reports to it: who it ran for, and every request each run made inside GitHub, Jira,
Slack and the other systems, allowed or refused. A toolkit whose permissions are remote reports
with nothing else configured; toolpass.otel exports each run as a span. See
the dashboard guide, and python examples/demo/run.py for five
systems at once.
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
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
@toolpass.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})
@toolpass.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 toolpass.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
@toolpass.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 toolpass.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 toolpass.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.
- Session. No current session means no call.
- Arguments. Types, then validators, then scope rules.
- Limits. One slot is taken from each cap, and given back if the call never reaches its body.
- Authorization. Only an explicit allow lets the call through.
- Untrusted input. Arguments repeating six or more consecutive words of untrusted output are refused.
- Exfiltration. A
sends_outtool in a session that has read private data and seen untrusted content is refused (exfiltration="off"turns the guard off). - Credential. Fetched only now.
- The body. Its output is redacted, recorded if untrusted, and fenced.
- 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:
@toolpass.tool(effect="read", reads_private=True)
def read_customers() -> str: ...
@toolpass.tool(effect="read", untrusted_output=True)
def read_email(id: str) -> str: ...
@toolpass.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 toolpass 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"))
@toolpass.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. Every integration has a route table (
toolpass routes); a write a table cannot map exactly is refused; such a read goes through unchecked, marked untrusted. - 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_checkputs 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_threaddo).
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. If toolpass is not found afterwards (pip's
scripts folder is not on PATH, common on Windows), run python -m toolpass (py -m toolpass). 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 (any SDK): no adapter. toolpass.tool on the tool, and the user from the
X-Toolpass-User header the agent sends, which session_from_header reads on the server's
Streamable HTTP app (this machine only, unless given key=); for stdio, a Session around run()
for the user the host names. A server people sign in to with their own token takes the user from
that token: toolpass.configure(session=toolpass.mcp.token_session()).
import toolpass
from toolpass import session_from_header
@mcp.tool()
@toolpass.tool()
def delete_issue(key: str) -> str: ...
app = session_from_header(mcp.streamable_http_app()) # key=... when the agent runs elsewhere
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[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-clienton PyPI, the old Python client, is replaced by this package and gets no new releases; installed versions keep working. To move, depend ontoolpassand changefrom toolpass_client import Toolpass/Toolpass()tofrom toolpass import Toolpass/Toolpass.remote()(andtoolpass_client.strandstotoolpass.strands).
Apache-2.0.
Metadata
Release files for toolpass 0.11.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| toolpass-0.11.1.tar.gz | 1.2 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| toolpass-0.11.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.9 MB
Release files / toolpass-0.11.1.tar.gz
| Download URL | toolpass-0.11.1.tar.gz |
|---|---|
| Size | 1.2 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c9db05b26974beb68d869d7020135885272dc29c0c4a0f07095396d6e1cd8006
|
|
BLAKE2b-256 checksum How to use checksums |
aef6f7408595050c85ccae9ef46ea1f2452cba87445f27405a43b61f07b32781
|
| 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 17, 2026.
Transparency logRelease files / toolpass-0.11.1-py3-none-any.whl
| Download URL | toolpass-0.11.1-py3-none-any.whl |
|---|---|
| Size | 682.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4a6984bad0cbe15ad9f63ac9a03b10f240329722e9a0cbbc6fe8d6b2a6118629
|
|
BLAKE2b-256 checksum How to use checksums |
d3211db812efa0b4de773b7a4d9d4578b2debf28dd7fd3153fa8f0e62a060192
|
| 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 17, 2026.
Transparency log