A Python library for AI-agent runtime security, LLM tool-call authorization, and policy enforcement — without a separate service.
ToolBoundary enforces tool permissions, operation allow-lists, rate limits, approval rules, emergency stops, audit trails, optional authorization/evidence providers, and token-gated HTTP proxying for AI agents and LLM applications.
Current development version: v1.0.4 (pending publication) · Latest published release: v1.0.3 · Install from PyPI
ToolBoundary answers one question, fast and locally, every time your agent tries to call a tool: "is this exact call allowed, right now?"
No separate web app or database is required. Your policy is plain Python, version-controlled with the rest of your code.
pip install toolboundaryToolBoundary v1.0.4 adds an optional CrewAI integration, allowing authorization to run inside CrewAI's actual tool-execution path rather than relying on an agent to remember a separate policy check.
- Guard individual or multiple CrewAI tools with
guard_toolandguard_tools. - Authorize synchronous and asynchronous execution in the wrapped tool's
_run/_arunpaths, including structured-tool invocation. - Apply per-tool policies such as access mode and numeric value limits, including positional arguments normalized against the original tool signature.
- Keep framework dependencies optional — install
toolboundary[crewai]to use the adapter; the core package remains dependency-free. - Provider-aware execution uses ToolBoundary's existing authorization and execution-evidence lifecycle when an external provider is configured.
Install the optional integration with pip install 'toolboundary[crewai]'.
ToolBoundary v1.0.3 is a security and correctness release. It prevents client paths from changing a network proxy's configured upstream host, rejects traversal paths, validates numeric limits, requires named operations for configured allow-lists, and redacts sensitive arguments from decorator-generated audit metadata.
- Network proxy target validation — request paths cannot change the configured upstream authority or traverse above its base path; oversized request bodies are rejected.
- Operation allow-lists — a call must provide an operation when a tool has an
allowed_operationsallow-list. - Numeric limits — malformed and non-finite values are denied, optional minimum
values are supported, and
strict_limits=Truerequires values for configured ceilings. - Audit argument redaction — common secret-like argument names are redacted by default, with options for custom sensitive argument names and value-free logging.
- Execution evidence — a tool that successfully returns
Noneis recorded as a successful execution.
This release also includes the provider-neutral authorization/evidence architecture, ToolBoundary-side AgentKey reference integration, and optional Redis shared backends introduced in v1.0.2.
ToolBoundary v1.0.2 consolidates the provider-neutral authorization/evidence architecture introduced in v1.0.0 with the ToolBoundary-side AgentKey reference integration and an optional Redis backend for shared rate limiting and token storage, while keeping ToolBoundary as the local enforcement authority.
- Optional Redis shared backends —
RedisSlidingWindowRateLimiterandRedisTokenStorefor multi-process and multi-replica deployments, available throughtoolboundary[redis]without changing the in-memory default EvidenceProviderprotocol — a clean interface any external provider can implement to add authorization and execution evidence without coupling to a specific vendor- Provider-aware execution lifecycle —
authorize_call()/record_execution()freeze the exact tool call, compute the current ToolBoundary call digest, consult an optional provider, and record execution evidence - Observe / Enforce modes —
ProviderMode.OBSERVElogs provider decisions without blocking;ProviderMode.ENFORCEfails closed on provider denial or unavailability evaluate()method — returns a structuredLocalDecisioninstead of raise-on-deny, enabling richer programmatic integration- Deterministic call binding — the current Python implementation uses canonical JSON key ordering to produce stable SHA-256 call digests; future cross-language/native signed evidence will use a standardized canonicalization scheme (RFC 8785 / JCS) rather than relying on Python-specific JSON serialization
- Replay prevention — consumed authorizations cannot be reused for a second dispatch
- Post-dispatch resilience — if a provider fails to record execution evidence, the local record is preserved
ToolBoundary remains the local enforcement authority. An external provider can add a stricter gate or external evidence, but it can never turn a local deny into an allow.
Enterprise AI-governance platforms (agent registries, policy engines, approval dashboards) make sense when a large organization has dozens of AI agents built by different teams and needs a compliance layer to track all of them. That's real infrastructure for a real problem — but it's disproportionate for the much more common case: one developer or a small team building one to a handful of agents, who just need to make sure a tool-calling agent can't do something catastrophic.
ToolBoundary is built for that second case. It costs nothing, requires no infrastructure, and takes minutes to add to an existing agent.
from toolboundary import Boundary, ToolPermission, AutonomyLevel, AccessMode
boundary = Boundary(
agent_name="support-agent",
autonomy=AutonomyLevel.LIMITED_AUTONOMOUS,
permissions=[
ToolPermission("read_ticket_db", access_mode=AccessMode.READ_ONLY),
ToolPermission(
"send_reply_email",
access_mode=AccessMode.EXECUTE,
max_calls_per_hour=30,
),
],
blocked_operations=frozenset({"delete_ticket"}),
max_actions_per_hour=100,
kill_switch_env="TOOLBOUNDARY_KILL_SWITCH",
)
# Somewhere in your agent's tool-calling code:
boundary.check("read_ticket_db", access_mode=AccessMode.READ_ONLY) # passes silently
boundary.check("delete_ticket", operation="delete_ticket") # raises BoundaryViolationIf a call is denied, boundary.check(...) raises BoundaryViolation (or a more
specific subclass like KillSwitchActive or RateLimitExceeded). If a call needs a
human before it can proceed, it raises ApprovalRequired. Every decision — allow,
deny, or approval-required — is written to a structured audit log automatically.
export TOOLBOUNDARY_KILL_SWITCH=1Set the environment variable your Boundary was configured with, and every future
call for that agent is denied immediately — no restart required, no code change,
no separate dashboard to log into.
from toolboundary import guarded_tool, AccessMode
@guarded_tool(boundary, access_mode=AccessMode.EXECUTE, value_arg="amount")
def wire_transfer(account_id: str, amount: float) -> str:
return f"transferred {amount} to {account_id}"
wire_transfer(account_id="acct_1", amount=250_000)
# raises BoundaryViolation if 250_000 exceeds the permission's max_value —
# the function body never executes.Once a function is decorated, calling it is calling through ToolBoundary. There is no code path to the real implementation that skips the check.
from toolboundary.integrations.langchain import guard_tools
from toolboundary import AccessMode
guarded_tools = guard_tools(
[read_db_tool, send_email_tool, wire_transfer_tool],
boundary,
default_access_mode=AccessMode.READ_ONLY,
overrides={
"send_email_tool": {"access_mode": AccessMode.EXECUTE},
"wire_transfer_tool": {"access_mode": AccessMode.EXECUTE, "value_arg": "amount"},
},
)
agent_executor = AgentExecutor(agent=agent, tools=guarded_tools)This wraps the LangChain BaseTool objects themselves — the objects your
AgentExecutor actually invokes when the LLM decides to call a tool — so the boundary
check runs inside LangChain's own tool-execution path, not as a step the agent's
reasoning loop has to remember to call.
Install with the LangChain extra: pip install toolboundary[langchain]
from toolboundary.integrations.crewai import guard_tools
from crewai import Crew
from toolboundary import AccessMode
guarded_tools = guard_tools(
[read_db_tool, wire_transfer_tool],
boundary,
access_mode=AccessMode.READ_ONLY,
overrides={
"wire_transfer_tool": {
"access_mode": AccessMode.EXECUTE,
"value_arg": "amount",
},
},
)
crew = Crew(
agents=[agent],
tasks=[task],
)ToolBoundary can optionally consult an external authorization provider before dispatching a tool call. The provider adds a second gate — it can never weaken a local policy decision.
boundary = Boundary(
agent_name="support-agent",
...
)
# Works exactly as before. No external service needed.from toolboundary import Boundary, ProviderMode
boundary = Boundary(
agent_name="support-agent",
...,
provider=my_provider,
provider_mode=ProviderMode.OBSERVE,
)
# Provider decisions are logged but don't block locally-allowed actions.
# Provider unavailability is gracefully degraded.boundary = Boundary(
agent_name="support-agent",
...,
provider=my_provider,
provider_mode=ProviderMode.ENFORCE,
)
# Provider must explicitly allow the action.
# Provider denial or unavailability blocks execution before dispatch.Any class that implements the EvidenceProvider protocol can serve as a provider:
from toolboundary import EvidenceProvider, FrozenToolCall, LocalDecision, ProviderGrant
from toolboundary import ExecutionRecord, ProviderReceipt
class MyProvider:
def authorize(self, call: FrozenToolCall, local: LocalDecision) -> ProviderGrant:
# Your authorization logic here
return ProviderGrant(allowed=True, provider="my-provider")
def record(self, grant: ProviderGrant, execution: ExecutionRecord) -> ProviderReceipt:
# Your evidence recording logic here
return ProviderReceipt(recorded=True, provider="my-provider")AgentKey is the first external provider reference integration built against ToolBoundary's provider-neutral contract.
The ToolBoundary repository contains the ToolBoundary-side reference integration,
example, and end-to-end test coverage. The AgentKey-specific provider implementation
is maintained on the AgentKey side and plugs into the existing EvidenceProvider
interface.
from toolboundary import Boundary, ToolPermission, AutonomyLevel, AccessMode, ProviderMode
from toolboundary.integrations.agentkey import AgentKeyProvider
provider = AgentKeyProvider(client=your_agentkey_client)
boundary = Boundary(
agent_name="support-agent",
autonomy=AutonomyLevel.AUTONOMOUS,
permissions=[ToolPermission("read_ticket", access_mode=AccessMode.READ_ONLY)],
provider=provider,
provider_mode=ProviderMode.ENFORCE,
)Key points:
- AgentKey is optional. The core
pip install toolboundaryremains independent. - ToolBoundary remains the local enforcement authority. A local deny is final.
- The provider contract is vendor-neutral. Other authorization/evidence providers can implement the same interface.
- Cross-language canonical evidence is not defined by Python
json.dumps(). The native signed-evidence work will use RFC 8785 (JCS) as the canonicalization target. - The reference integration is intentionally small. It covers pre-dispatch authorization and post-dispatch evidence without moving security authority out of ToolBoundary.
See examples/agentkey_integration.py for the ToolBoundary-side example, tests/test_agentkey_integration.py for the integration coverage, and src/toolboundary/redis_backend.py for the optional Redis backends.
Local policy check → DENY? → stop (provider never consulted)
→ ALLOW? → freeze call → compute digest → provider.authorize()
→ execute exact call
→ provider.record()
| Control | Example |
|---|---|
| Which tools an agent may use at all | permissions=[ToolPermission("read_db", ...)] |
| Operation-level allow/block lists | blocked_operations=frozenset({"delete_customer"}) |
| Access mode (READ_ONLY / WRITE / EXECUTE / ADMIN) | access_mode=AccessMode.EXECUTE |
| Transaction value ceilings | ToolPermission(..., max_value=500_000) |
| Record-count ceilings | ToolPermission(..., max_records=100) |
| Rate limits (global or per-tool) | max_actions_per_hour=60 |
| Autonomy level | AutonomyLevel.RECOMMEND_ONLY / HUMAN_APPROVAL_REQUIRED / LIMITED_AUTONOMOUS / AUTONOMOUS / QUARANTINED |
| Time-bounded validity | valid_from=, valid_to= |
| Environment restriction | allowed_environments=frozenset({"DEV", "TEST"}) |
| Emergency kill switch | in-process flag or environment variable |
| Custom policy logic | policy_hooks=[my_custom_check] |
| External authorization provider | provider=my_provider, provider_mode=ProviderMode.ENFORCE |
| Exact-call binding with cryptographic digest | Automatic when a provider is configured |
Full field reference: see docs/API.md.
Every decision produces a structured event. By default it goes to Python's standard
logging module under the logger name toolboundary.audit, so it flows into whatever
logging pipeline you already have (stdout, a file, CloudWatch, Datadog, etc.) with zero
extra code.
from toolboundary.audit import AuditTrail, JSONLFileSink
boundary = Boundary(
agent_name="support-agent",
...,
audit=AuditTrail(sinks=[JSONLFileSink("toolboundary-audit.jsonl")]),
)For native or cross-language verifiable evidence, the canonicalization and signing format must be specified independently of Python's JSON serializer. See the RFC 8785/JCS evidence work planned for the native evidence roadmap.
When a provider is configured, audit events automatically include evidence metadata: call digests, provider decisions, authorization IDs, and result digests.
A WebhookSink is also included if you want to forward events to a self-hosted
dashboard or a centralized governance platform. Audit delivery is always best-effort —
a network hiccup in your audit pipeline can never block or crash your agent, because
the ALLOW/DENY decision has already been enforced locally before the sink is invoked.
- Fail closed. Anything ambiguous, misconfigured, or erroring is treated as denied
by default. See
fail_closed_on_hook_errorfor the one place this is configurable. - No infrastructure required. No database, no server, no login. The whole thing is a Python object you construct alongside your agent code.
- Version-controlled policy. Your boundary is code, reviewed in the same pull requests as everything else — not a setting buried in a web UI that drifts silently out of sync with what the agent actually does.
- Loud by default. Denials raise exceptions, not silent
Falsereturns that are easy to accidentally ignore. - Framework-agnostic core, framework-specific adapters. The core
Boundaryhas zero dependencies. Framework integrations for LangChain and CrewAI are optional extras. - Local authority, optional extension. External providers add evidence and stricter gates but never override local policy. ToolBoundary works identically with or without a provider configured.
ToolBoundary is an in-process, application-layer library. Being explicit about what it does not do is more important than what it does:
- It cannot stop an agent that bypasses it entirely. If your agent's code has any
path that calls a tool's real implementation directly — instead of through a
@guarded_tool-wrapped function or aguard_tool-wrapped LangChain / CrewAI tool — that call is not evaluated. ToolBoundary governs the doors you route through it; it is not a network firewall. - It is not a substitute for credential scoping. If the underlying API key or database credential your tool uses has broader permissions than ToolBoundary's policy allows, a determined attacker who obtains that credential directly bypasses ToolBoundary entirely. Scope your actual credentials as tightly as you can — ToolBoundary is a second layer, not a replacement for the first.
- It is not a compliance/audit system of record for large organizations. If you have dozens of agents across multiple teams and need human governance workflows, cross-team registries, and formal approval routing, look at enterprise AI governance platforms — ToolBoundary is intentionally not trying to be that.
- The in-memory rate limiter is per-process. If you run multiple replicas of your
agent, each process has its own rate-limit counters unless you supply a shared
backend. Install
toolboundary[redis]and passRedisSlidingWindowRateLimiter/RedisTokenStorefromtoolboundary.redis_backendintoBoundary(rate_limiter=...)andNetworkEnforcer(token_store=...). - Provider evidence is only as trustworthy as the provider. An SDK-reported result does not itself prove that an external side effect occurred — it proves the SDK reported it. See the provider documentation for what guarantees each provider makes.
- Numeric ceilings need supplied values by default. If the caller omits
valueorrecord_count, its configured maximum is not evaluated unlessBoundary(strict_limits=True)is enabled.
If your threat model requires guaranteeing that a compromised agent physically cannot reach a tool's network endpoint except through an approved path, you need a network-layer control (a sidecar proxy, egress firewall rule, or service mesh policy) in addition to ToolBoundary, not instead of it.
pip install toolboundary # core, zero dependencies
pip install 'toolboundary[langchain]' # + LangChain integration
pip install 'toolboundary[crewai]' # + CrewAI integration
pip install 'toolboundary[redis]' # + shared Redis rate limiter / token storeIssues and PRs are welcome. See CONTRIBUTING.md.
Ideas that would make great first contributions:
- Custom
EvidenceProviderimplementations for popular platforms - AutoGen / LangGraph integrations (mirroring the existing optional framework adapters)
- A minimal read-only local dashboard that tails a
JSONLFileSinklog
MIT — see LICENSE.