ToolLeash
Synthesize and enforce least-privilege tool-call policies from OpenTelemetry GenAI traces.
ToolLeash ingests OTel GenAI execute_tool spans emitted by a real agent run, synthesizes a
deny-by-default policy that allows only observed behaviour (with inferred per-argument
constraints), emits it in multiple formats (Cedar, OPA/Rego, an MCP allow-list, or its own
canonical policy-ir JSON), and enforces it deterministically at the tool-call boundary. Mental
model: iamlive / AWS Access Analyzer, but for AI-agent tool calls.
Status: pre-1.0, under active construction. Every claim in this README is backed by a test or a command you can run yourself — see
STATUS.mdfor the exact test IDs and CI run per capability,PLAN.mdfor the roadmap, andCLAUDE.mdfor the engineering rules this project holds itself to. Nothing is claimed here that a test does not prove.
Install
pip install . # from a clone; no PyPI release yet
toolleash --help
Zero API keys, zero external binaries (no Node, no Docker) — every dependency is pip-installable.
Quickstart
python examples/run_quickstart.py
One command, no traces to record by hand. It runs a real Python MCP server, records a handful of
real execute_tool spans, synthesizes a policy from them in-process, then enforces that policy on
the same live session: an allowed call proceeds, a policy-violating call (never seen during
training) is denied. See examples/README.md for what it does and why. This exact script is run
as a real CI integration test (tests/integration/test_examples_quickstart.py).
CLI
toolleash record --listen 127.0.0.1:4318 # OTLP/HTTP receiver: capture a real staging run
toolleash inspect ./traces # sessions, tools, calls, argument-capture coverage
toolleash synthesize --traces ./traces --format cedar -o policy --strictness balanced
toolleash diff --policy policy-ir.json --traces ./new-traces # day-2 drift report (always exits 0)
toolleash validate --policy policy-ir.json --traces ./ci-run # CI gate: exits 1 on any denial
toolleash evaluate ./traces # self-graded / de-biased holdout table
Every command accepts --json for machine-readable output (all but record, which is a blocking
server, not a value-returning command). Formats available from synthesize: cedar, rego,
mcp-allowlist, policy-ir (Progent-JSON is not implemented — see Limitations).
Library
from toolleash.enforce.engine import PolicyEngine, ToolCallRequest
from toolleash.enforce.adapters import guarded_tool
engine = PolicyEngine(policy) # a PolicyIR from toolleash.policy.ir.build_policy_ir
decision = engine.decide(ToolCallRequest(agent="my-agent", tool="write_file", arguments={...}))
@guarded_tool(engine) # framework-agnostic: no framework imports
def write_file(path: str, content: str) -> None: ...
PolicyEngine.guard/enforce raise PolicyViolation on a policy-denied call in enforce mode;
shadow mode records what would have been blocked without blocking it — the safe rollout path.
See src/toolleash/enforce/engine.py and examples/run_quickstart.py for the full contract.
Limitations
These are not boilerplate — read them before you rely on a synthesized policy.
- Every enforcement mechanism here has only ever been probed by this project's own
adversarial-reviewer, never an independent security researcher. The traversal and
injection bypasses fixed this phase (see
docs/THREAT_MODEL.md's summary table) were each found by this project deliberately widening its own search after the previous one — not by an external red team. "No known bypass" and "no bypass" are different claims; today this project can only make the first one. Treat every "Yes" indocs/THREAT_MODEL.md's summary table as "this project's own tests say yes," not as an independently audited guarantee. - Progent-JSON is not implemented. Its real DSL can't be pinned from code without cloning
and running the actual Progent repo — outside this project's zero-asset guarantee, and
inferring the schema from the paper instead was explicitly ruled out (honesty over feature
count). So it is absent from
--formatentirely, not stubbed, not silently degraded.cedar,rego,mcp-allowlist, andpolicy-irare the only real formats; seePLAN.mdT-3.5 for the full locked decision. - Policies reflect observed behaviour, nothing more. An under-exercised staging run yields an over-restrictive policy (legitimate calls the agent will make in production were never observed, so they're denied); a staging run that's too broad — say, one that happens to touch paths or hosts wider than production ever will — yields an over-permissive policy. ToolLeash cannot tell which case it's in from the traces alone; the recorded window's coverage is on you.
- False-deny is a measured floor, not a product guarantee. On the two available free-tier
model-driven corpora (
gemini-flash-lite-latest,tests/fixtures/recorded/model_driven/), the held-out envelope false-deny rate at ToolLeash's default (balanced) strictness measures 11.1%–14.9%; at the tightest (exact/tight) presets it measures 24.6%–35.6%. Reproduce it yourself:toolleash evaluate tests/fixtures/recorded/model_driven/open(or.../shared). These numbers come from three toy agent personas over two toy tool suites — they are expected to improve with richer, more diverse training data, not a bound on what ToolLeash can achieve in your environment. - Permissiveness figures are lower bounds.
toolleash evaluate's "security-vacuous" / permissiveness column measures how much of a sampled foreign corpus a constraint admits; a thin corpus makes a wide-open constraint look artificially tight. A low permissiveness score is "not yet shown to be permissive," never "shown to be tight" — see the CLI's ownLOWER BOUNDcaption on everyevaluaterun. - Cedar output is a conservative under-approximation, not equivalent to the IR. The Cedar
emitter cannot express
string_length,char_class_regex, orurl_constraintfamilies; a rule containing one of these is omitted entirely (fail-closed — deny-by-default then denies those calls, never silently rendered as accept-any). A Cedar user therefore gets a strictly stricter policy than the canonical IR and MUST check which rules were dropped:synthesizeprintsOMITTED: ...to stderr and the emitted.cedarfile carries// OMITTEDheader comments; reproduce withtoolleash synthesize --traces tests/fixtures/recorded/filesystem --format cedar -o /tmp/policy --strictness balanced. Rego is more expressive (onlyurl_constraintis its own gap) but is not equivalent to the IR either — always checkomissions()/ the CLI output, for either format, before trusting the emitted policy's coverage. - Tool-argument capture is opt-in, and inference is only as good as what it's given. If a
traced agent's instrumentation does not capture call arguments (
arguments=Noneon the recorded span), ToolLeash has nothing to constrain — the synthesized policy degrades to a tool-name-only allow-list (any arguments accepted for an observed tool).toolleash inspectprints a loudWARNING: ... content capture appears OFFwhen this is detected; check that warning before trusting a policy's argument-level constraints. - Dependency upper bounds cap majors, not minors — the versions below are what was actually
verified, not a guarantee every version in range behaves identically. Every dependency is
capped at its next major (ADR-0020) after a real TestPyPI round-trip resolved
mcp>=1.28(no ceiling) to a genuinely breakingmcp==2.0.0on a fresh install — a class of bug no gate run ever exercises, since CI installs from the pinneduv.lock, never a fresh resolve. Last verified against:cedarpy4.8.7,regopy1.5.2,mcp1.28.1/1.29.0,pydantic2.13.4,typer0.26.8/0.27.0,opentelemetry-*1.43.0/1.44.0.regopyis the sharpest risk of this group: its RE2 regex-matching semantics are load-bearing for the char-class containment guarantee (ADR-0015), and a future major changing that would not crash the waymcpdid — it could silently change which policies validate. A ceiling is only ever raised after re-running the full round-trip against the new major (ADR-0020), never preemptively. - The OTel GenAI semantic conventions this project reads are Development/experimental status
(the
_incubatingnamespace) — attribute names may still change upstream. ToolLeash pins the exact version it targets indocs/specs/otel_genai_semconv.mdandsrc/toolleash/semconv.py; a future semconv release could require a re-pin.
See docs/THREAT_MODEL.md for what ToolLeash defends against and — just as important — what it
explicitly does not.
Requirements
Python ≥ 3.11. Zero API keys, zero external binaries, zero Node, zero Docker — every dependency
is pip-installable (see PLAN.md "Zero-asset guarantee").
Developing
uv sync # create the environment from pyproject + uv.lock
uv run python scripts/check.py # run the full quality gate (the definition of "green")
make check is equivalent where make is available; both just invoke scripts/check.py.
License
Apache-2.0. See LICENSE.
Release files for toolleash 0.1.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 | |
|---|---|---|---|
| toolleash-0.1.1.tar.gz | 625.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| toolleash-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 763.9 kB
Release files / toolleash-0.1.1.tar.gz
| Download URL | toolleash-0.1.1.tar.gz |
|---|---|
| Size | 625.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
55c20c305c2ba601a3079aaf56d65eec737312da53a6b8777e0606b889cc80e8
|
|
BLAKE2b-256 checksum How to use checksums |
1a87690e070dea52fd3280b74134445e47efe84a1ae8355732f84e431e733dd2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.10.0
|
Release files / toolleash-0.1.1-py3-none-any.whl
| Download URL | toolleash-0.1.1-py3-none-any.whl |
|---|---|
| Size | 138.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3b06c27830556e84fa0e3dc98496665471bc9ebccd3b9aabcd042f7f896c4a42
|
|
BLAKE2b-256 checksum How to use checksums |
adc42e2c189ebed038a54980adc7f5a4e65823ae8ceab9324f6860d0fb97983b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.10.0
|