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, approvals) is alpha and may still change; the permission engine (Toolpass, guarded, the adapters) is stable.

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 that adds those checks to a plain Python function:

import toolpass
from toolpass import ApprovalQueue, Session, Toolpass, permission_check, secured_tool

tp = Toolpass.from_config("toolpass.yaml")          # permission checks; see below
approvals = ApprovalQueue()
toolpass.configure(approver=approvals, 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", "pull_request.create", "repo:{repo}"),
    approve=True,                                     # a person confirms, out of band
    credential="github-bot",                          # injected below; the model never sees it
)
def open_gitops_pr(repo: str, title: str, change: str, *, credential: str) -> str:
    ...


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

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 (the OpenAI Agents SDK's function_tool answers "An error occurred while running the tool"). There, toolpass.configure(on_refuse=str) makes a refusal the tool's result, so the model can tell the user why.

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 without approval
Human approval approve=True or a predicate, preview= A person confirms, out of band, the exact call; the approval is used once
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

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, an approver 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 (or sent for approval, with on_untrusted_input="approve").
  6. Exfiltration. A sends_out tool in a session that has read private data and seen untrusted content needs approval (or is refused, with exfiltration="refuse").
  7. Approval. When the tool, step 5 or step 6 asks for it.
  8. Credential. Fetched only now.
  9. The body. Its output is redacted, recorded if untrusted, and fenced.
  10. 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 waits for a person. This does not depend on spotting injected words, so a model that paraphrases the injection does not get past it.

Approval, out of band

ApprovalQueue keeps a call that needs approval until a person decides. The tool raises ApprovalPending, whose message tells the model to ask the user and retry. Your app shows the request to a person and approves it; the same call from the same session then runs once.

approvals.on_request(lambda req: slack.post("#approvals", req.describe()))   # who, what, why, preview

# later, from the Slack button handler:
approvals.approve(request_id, by="dana@example.com")

An approval is bound to the session, the tool, the exact arguments, the reasons it was asked for and the preview the person saw. It is spent only when the call reaches its body, so a credential that was briefly unavailable does not cost another approval, and it expires after ttl seconds (15 minutes by default). Any callable that returns True, False or None can be the approver instead, for example a prompt in a CLI.

In async tools, sync hooks (an authorizer, an approval rule, a preview, the approver and its listeners, 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.

Refusals

A refused call raises ToolRefused with a code (out_of_scope, not_authorized, untrusted_input, approval_pending, ...) 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.

Limits

  • 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 and approval on writes cover that gap; 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, flags and the approval queue 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 pull request that waits for approval, and a permission denial.

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.7.0

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.7.0
File Size Uploaded
toolpass-0.7.0.tar.gz 848.7 kB Details

Built distribution (wheel)

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

Total release size: 1.3 MB

Release files / toolpass-0.7.0.tar.gz

Download URL toolpass-0.7.0.tar.gz
Size 848.7 kB
Tags Source
SHA-256 checksum
How to use checksums
d6bafa3322b89e966d3923e3ecc0cad6d50a90c633e37d8bdd845d9d3e94abe9
BLAKE2b-256 checksum
How to use checksums
c1e686a167a3944dd86940bd6856b728d4ac13b2441b99c5f2cd4d66394c8673
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 2, 2026.

Transparency log

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

Download URL toolpass-0.7.0-py3-none-any.whl
Size 477.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
10bc15d723430333bcc7ed67127d770e492fd29fa6fa0e1c9aa5cf383f3a8a2e
BLAKE2b-256 checksum
How to use checksums
555bda1f8aceded9dd0799ea74fe3ceea50e810bd1f567fca474267e83fdedc1
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 2, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.7.0 This release

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