toolwall 0.4.2__tar.gz → 0.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolwall-0.4.2/README.md → toolwall-0.4.3/PKG-INFO +96 -1
- toolwall-0.4.2/PKG-INFO → toolwall-0.4.3/README.md +61 -23
- {toolwall-0.4.2 → toolwall-0.4.3}/pyproject.toml +18 -5
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/__init__.py +1 -1
- toolwall-0.4.3/src/toolwall/__main__.py +5 -0
- toolwall-0.4.3/src/toolwall/_aio.py +33 -0
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/cli.py +11 -1
- toolwall-0.4.3/src/toolwall/demo.py +113 -0
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/gate.py +168 -29
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/mcp_guard.py +42 -17
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/policy.py +20 -2
- toolwall-0.4.3/src/toolwall/py.typed +0 -0
- toolwall-0.4.3/src/toolwall/schema.py +183 -0
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/shield.py +13 -1
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/wall.py +29 -3
- toolwall-0.4.2/src/toolwall/schema.py +0 -81
- {toolwall-0.4.2 → toolwall-0.4.3}/.gitignore +0 -0
- {toolwall-0.4.2 → toolwall-0.4.3}/LICENSE +0 -0
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/intake.py +0 -0
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/meter.py +0 -0
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/receipt.py +0 -0
- {toolwall-0.4.2 → toolwall-0.4.3}/src/toolwall/suggest.py +0 -0
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: toolwall
|
|
3
|
+
Version: 0.4.3
|
|
4
|
+
Summary: Security gateway for AI agent tool calls: fail-closed schema + policy + secret-detection gate before any tool executes
|
|
5
|
+
Project-URL: Homepage, https://toolwall.aya-ai.xyz
|
|
6
|
+
Project-URL: Documentation, https://github.com/Dev-Saif-Ops/toolwall#readme
|
|
7
|
+
Project-URL: Source, https://github.com/Dev-Saif-Ops/toolwall
|
|
8
|
+
Project-URL: Changelog, https://github.com/Dev-Saif-Ops/toolwall/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/Dev-Saif-Ops/toolwall/issues
|
|
10
|
+
Project-URL: Security policy, https://github.com/Dev-Saif-Ops/toolwall/blob/main/SECURITY.md
|
|
11
|
+
Author: Mohammad Safwan Athar
|
|
12
|
+
License: MIT
|
|
13
|
+
Keywords: agent-security,agents,ai,ai-agent-security,ai-agents,ai-safety,approval,asyncio,function-calling,guardrails,human-in-the-loop,llm,llm-security,mcp,mcp-security,policy,prompt-injection,secret-detection,security,tool-call-validation,tool-calling
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Security
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Provides-Extra: mcp
|
|
33
|
+
Requires-Dist: mcp>=1.0.0; extra == 'mcp'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
1
36
|
# toolwall
|
|
2
37
|
|
|
3
38
|
[](https://pypi.org/project/toolwall/)
|
|
@@ -29,7 +64,36 @@ pip install toolwall
|
|
|
29
64
|
```
|
|
30
65
|
|
|
31
66
|
Zero required dependencies. Works with OpenAI, Anthropic, and Gemini native tool
|
|
32
|
-
calling (and plain dicts). Python 3.10
|
|
67
|
+
calling (and plain dicts). Python 3.10+, sync and async tools.
|
|
68
|
+
|
|
69
|
+
**See it in 30 seconds, no API key:**
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m toolwall demo
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
toolwall demo: a support agent serving customer c_42
|
|
77
|
+
Every verdict below comes from the real gate. No model or API key.
|
|
78
|
+
|
|
79
|
+
ALLOW issue_refund(order_id='o_9', amount=40.0)
|
|
80
|
+
an honest refund: ran: refunded 40.00 on o_9
|
|
81
|
+
BLOCK issue_refund(order_id='o_9', amount=5000.0)
|
|
82
|
+
a refund over the cap: policy violation: arg 'amount' rejected by in_range(0.01, 500)
|
|
83
|
+
BLOCK issue_refund(order_id='o_77', amount=40.0)
|
|
84
|
+
someone else's order: order does not belong to the signed-in customer
|
|
85
|
+
BLOCK send_email(to='x@evil.com,ops@ourco.com', body='export')
|
|
86
|
+
a smuggled recipient: policy violation: arg 'to' rejected by customer_or_ourco
|
|
87
|
+
ALLOW lookup_customer(customer_id='c_42')
|
|
88
|
+
a lookup whose record holds a key: ran, but tool output withheld: secret detected (stripe-key) in 'return.billing_api_key'
|
|
89
|
+
HELD close_account(customer_id='c_42')
|
|
90
|
+
a destructive action: waiting for a human to approve
|
|
91
|
+
BLOCK drop_all_tables()
|
|
92
|
+
a tool nobody registered: unknown tool: 'drop_all_tables'
|
|
93
|
+
|
|
94
|
+
1 ran · 5 stopped · 1 waiting for a human. Every decision is in the audit log (wall.export).
|
|
95
|
+
```
|
|
96
|
+
|
|
33
97
|
|
|
34
98
|
## Quickstart
|
|
35
99
|
|
|
@@ -55,6 +119,31 @@ results = wall.guard(openai_response) # or gate a raw OpenAI/Anthropic/Gemini
|
|
|
55
119
|
|
|
56
120
|
Only an `ALLOW` verdict runs the tool. `Gate` is the lower-level primitive underneath.
|
|
57
121
|
|
|
122
|
+
**Async tools.** Agent frameworks are mostly async. Use `acall` and `aguard`: they
|
|
123
|
+
await the tool, then scan what it returned, with every check `call` makes.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
async def lookup_customer(customer_id: str) -> dict: ...
|
|
127
|
+
|
|
128
|
+
wall.register("lookup_customer", lookup_customer) # schema from the annotations
|
|
129
|
+
r = await wall.acall("lookup_customer", {"customer_id": "c_42"})
|
|
130
|
+
results = await wall.aguard(openai_response)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The sync `call` refuses an async tool instead of reporting a coroutine as a run.
|
|
134
|
+
A tool that streams (an async generator) has its output withheld on both paths:
|
|
135
|
+
return a materialised list instead.
|
|
136
|
+
|
|
137
|
+
A function whose parameters and return value are all annotated gets its schema
|
|
138
|
+
from its signature: classes, `X | None`, `Optional`, `Union`, generic containers
|
|
139
|
+
(`list[str]` checks for a list), `Any` and `Annotated[...]`; `float` also accepts an
|
|
140
|
+
integer. Anything else (`Literal`, `TypedDict`, a non-runtime `Protocol`, a lambda,
|
|
141
|
+
a partly annotated function) still needs `schema=`, for example
|
|
142
|
+
`types={"filters": dict}`. A parameter annotated with a class JSON can't produce (a
|
|
143
|
+
dataclass, an Enum, a pydantic model) is inferred but blocks every call; give it an
|
|
144
|
+
explicit `schema=` with `dict` or `str`.
|
|
145
|
+
|
|
146
|
+
|
|
58
147
|
## What it stops
|
|
59
148
|
|
|
60
149
|
```python
|
|
@@ -166,6 +255,12 @@ changed. Argument integrity is not resource integrity. For resources that can ch
|
|
|
166
255
|
owner or meaning, re-verify inside the tool's own transaction (for example
|
|
167
256
|
compare-and-swap on a version column); the gate cannot see your datastore.
|
|
168
257
|
|
|
258
|
+
## Security
|
|
259
|
+
|
|
260
|
+
Found a way past the gate? Please report it privately through
|
|
261
|
+
[GitHub's vulnerability reporting](https://github.com/Dev-Saif-Ops/toolwall/security/advisories/new),
|
|
262
|
+
not as a public issue. Details in [SECURITY.md](https://github.com/Dev-Saif-Ops/toolwall/blob/main/SECURITY.md).
|
|
263
|
+
|
|
169
264
|
## Links
|
|
170
265
|
|
|
171
266
|
- **Source, full docs, and the failure suite:** https://github.com/Dev-Saif-Ops/toolwall
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: toolwall
|
|
3
|
-
Version: 0.4.2
|
|
4
|
-
Summary: Security gateway for AI agent tool calls: fail-closed schema + policy + secret-detection gate before any tool executes
|
|
5
|
-
Project-URL: Homepage, https://github.com/Dev-Saif-Ops/toolwall
|
|
6
|
-
Author: Mohammad Safwan Athar
|
|
7
|
-
License: MIT
|
|
8
|
-
Keywords: agent-sandbox,agent-security,agents,ai,ai-agent-security,ai-agents,ai-safety,firewall,guardrails,llm,llm-security,mcp,mcp-firewall,mcp-proxy,mcp-security,prompt-injection,secret-detection,security,tool-call-validation,tool-calling
|
|
9
|
-
Classifier: Development Status :: 3 - Alpha
|
|
10
|
-
Classifier: Intended Audience :: Developers
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Topic :: Security
|
|
14
|
-
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
-
Requires-Python: >=3.10
|
|
16
|
-
Provides-Extra: dev
|
|
17
|
-
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
18
|
-
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
-
Provides-Extra: mcp
|
|
20
|
-
Requires-Dist: mcp>=1.0.0; extra == 'mcp'
|
|
21
|
-
Description-Content-Type: text/markdown
|
|
22
|
-
|
|
23
1
|
# toolwall
|
|
24
2
|
|
|
25
3
|
[](https://pypi.org/project/toolwall/)
|
|
@@ -51,7 +29,36 @@ pip install toolwall
|
|
|
51
29
|
```
|
|
52
30
|
|
|
53
31
|
Zero required dependencies. Works with OpenAI, Anthropic, and Gemini native tool
|
|
54
|
-
calling (and plain dicts). Python 3.10
|
|
32
|
+
calling (and plain dicts). Python 3.10+, sync and async tools.
|
|
33
|
+
|
|
34
|
+
**See it in 30 seconds, no API key:**
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python -m toolwall demo
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
toolwall demo: a support agent serving customer c_42
|
|
42
|
+
Every verdict below comes from the real gate. No model or API key.
|
|
43
|
+
|
|
44
|
+
ALLOW issue_refund(order_id='o_9', amount=40.0)
|
|
45
|
+
an honest refund: ran: refunded 40.00 on o_9
|
|
46
|
+
BLOCK issue_refund(order_id='o_9', amount=5000.0)
|
|
47
|
+
a refund over the cap: policy violation: arg 'amount' rejected by in_range(0.01, 500)
|
|
48
|
+
BLOCK issue_refund(order_id='o_77', amount=40.0)
|
|
49
|
+
someone else's order: order does not belong to the signed-in customer
|
|
50
|
+
BLOCK send_email(to='x@evil.com,ops@ourco.com', body='export')
|
|
51
|
+
a smuggled recipient: policy violation: arg 'to' rejected by customer_or_ourco
|
|
52
|
+
ALLOW lookup_customer(customer_id='c_42')
|
|
53
|
+
a lookup whose record holds a key: ran, but tool output withheld: secret detected (stripe-key) in 'return.billing_api_key'
|
|
54
|
+
HELD close_account(customer_id='c_42')
|
|
55
|
+
a destructive action: waiting for a human to approve
|
|
56
|
+
BLOCK drop_all_tables()
|
|
57
|
+
a tool nobody registered: unknown tool: 'drop_all_tables'
|
|
58
|
+
|
|
59
|
+
1 ran · 5 stopped · 1 waiting for a human. Every decision is in the audit log (wall.export).
|
|
60
|
+
```
|
|
61
|
+
|
|
55
62
|
|
|
56
63
|
## Quickstart
|
|
57
64
|
|
|
@@ -77,6 +84,31 @@ results = wall.guard(openai_response) # or gate a raw OpenAI/Anthropic/Gemini
|
|
|
77
84
|
|
|
78
85
|
Only an `ALLOW` verdict runs the tool. `Gate` is the lower-level primitive underneath.
|
|
79
86
|
|
|
87
|
+
**Async tools.** Agent frameworks are mostly async. Use `acall` and `aguard`: they
|
|
88
|
+
await the tool, then scan what it returned, with every check `call` makes.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
async def lookup_customer(customer_id: str) -> dict: ...
|
|
92
|
+
|
|
93
|
+
wall.register("lookup_customer", lookup_customer) # schema from the annotations
|
|
94
|
+
r = await wall.acall("lookup_customer", {"customer_id": "c_42"})
|
|
95
|
+
results = await wall.aguard(openai_response)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The sync `call` refuses an async tool instead of reporting a coroutine as a run.
|
|
99
|
+
A tool that streams (an async generator) has its output withheld on both paths:
|
|
100
|
+
return a materialised list instead.
|
|
101
|
+
|
|
102
|
+
A function whose parameters and return value are all annotated gets its schema
|
|
103
|
+
from its signature: classes, `X | None`, `Optional`, `Union`, generic containers
|
|
104
|
+
(`list[str]` checks for a list), `Any` and `Annotated[...]`; `float` also accepts an
|
|
105
|
+
integer. Anything else (`Literal`, `TypedDict`, a non-runtime `Protocol`, a lambda,
|
|
106
|
+
a partly annotated function) still needs `schema=`, for example
|
|
107
|
+
`types={"filters": dict}`. A parameter annotated with a class JSON can't produce (a
|
|
108
|
+
dataclass, an Enum, a pydantic model) is inferred but blocks every call; give it an
|
|
109
|
+
explicit `schema=` with `dict` or `str`.
|
|
110
|
+
|
|
111
|
+
|
|
80
112
|
## What it stops
|
|
81
113
|
|
|
82
114
|
```python
|
|
@@ -188,6 +220,12 @@ changed. Argument integrity is not resource integrity. For resources that can ch
|
|
|
188
220
|
owner or meaning, re-verify inside the tool's own transaction (for example
|
|
189
221
|
compare-and-swap on a version column); the gate cannot see your datastore.
|
|
190
222
|
|
|
223
|
+
## Security
|
|
224
|
+
|
|
225
|
+
Found a way past the gate? Please report it privately through
|
|
226
|
+
[GitHub's vulnerability reporting](https://github.com/Dev-Saif-Ops/toolwall/security/advisories/new),
|
|
227
|
+
not as a public issue. Details in [SECURITY.md](https://github.com/Dev-Saif-Ops/toolwall/blob/main/SECURITY.md).
|
|
228
|
+
|
|
191
229
|
## Links
|
|
192
230
|
|
|
193
231
|
- **Source, full docs, and the failure suite:** https://github.com/Dev-Saif-Ops/toolwall
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "toolwall"
|
|
9
|
-
version = "0.4.
|
|
9
|
+
version = "0.4.3"
|
|
10
10
|
description = "Security gateway for AI agent tool calls: fail-closed schema + policy + secret-detection gate before any tool executes"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
license = { text = "MIT" }
|
|
@@ -14,15 +14,23 @@ requires-python = ">=3.10"
|
|
|
14
14
|
authors = [{ name = "Mohammad Safwan Athar" }]
|
|
15
15
|
keywords = [
|
|
16
16
|
"ai", "agents", "ai-agents", "llm", "llm-security", "ai-agent-security",
|
|
17
|
-
"agent-security", "tool-calling", "tool-call-validation",
|
|
18
|
-
"
|
|
19
|
-
"
|
|
17
|
+
"agent-security", "tool-calling", "function-calling", "tool-call-validation",
|
|
18
|
+
"guardrails", "human-in-the-loop", "approval", "policy", "security", "ai-safety",
|
|
19
|
+
"mcp", "mcp-security", "prompt-injection", "secret-detection", "asyncio",
|
|
20
20
|
]
|
|
21
21
|
classifiers = [
|
|
22
22
|
"Development Status :: 3 - Alpha",
|
|
23
23
|
"Intended Audience :: Developers",
|
|
24
24
|
"License :: OSI Approved :: MIT License",
|
|
25
25
|
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Operating System :: OS Independent",
|
|
32
|
+
"Framework :: AsyncIO",
|
|
33
|
+
"Typing :: Typed",
|
|
26
34
|
"Topic :: Software Development :: Libraries",
|
|
27
35
|
"Topic :: Security",
|
|
28
36
|
]
|
|
@@ -36,7 +44,12 @@ dev = ["pytest>=8.0", "pytest-cov>=4.0"]
|
|
|
36
44
|
toolwall = "toolwall.cli:main"
|
|
37
45
|
|
|
38
46
|
[project.urls]
|
|
39
|
-
Homepage = "https://
|
|
47
|
+
Homepage = "https://toolwall.aya-ai.xyz"
|
|
48
|
+
Documentation = "https://github.com/Dev-Saif-Ops/toolwall#readme"
|
|
49
|
+
Source = "https://github.com/Dev-Saif-Ops/toolwall"
|
|
50
|
+
Changelog = "https://github.com/Dev-Saif-Ops/toolwall/blob/main/CHANGELOG.md"
|
|
51
|
+
Issues = "https://github.com/Dev-Saif-Ops/toolwall/issues"
|
|
52
|
+
"Security policy" = "https://github.com/Dev-Saif-Ops/toolwall/blob/main/SECURITY.md"
|
|
40
53
|
|
|
41
54
|
[tool.hatch.build.targets.wheel]
|
|
42
55
|
packages = ["src/toolwall"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Small helpers for telling async callables and awaitables apart (stdlib only).
|
|
2
|
+
|
|
3
|
+
An awaitable is truthy and is not the value it will produce. Anywhere the gate
|
|
4
|
+
reads a callable's result as a decision or as output, an awaitable must either
|
|
5
|
+
be awaited (async path) or refused (sync path); reading it as-is approves or
|
|
6
|
+
leaks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import functools
|
|
13
|
+
import inspect
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_async_callable(fn: Any) -> bool:
|
|
18
|
+
"""True for `async def` functions, partials of them, and objects whose
|
|
19
|
+
`__call__` is `async def`."""
|
|
20
|
+
while isinstance(fn, functools.partial):
|
|
21
|
+
fn = fn.func
|
|
22
|
+
if inspect.iscoroutinefunction(fn):
|
|
23
|
+
return True
|
|
24
|
+
call = getattr(fn, "__call__", None)
|
|
25
|
+
return call is not None and inspect.iscoroutinefunction(call)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def discard_awaitable(value: Any) -> None:
|
|
29
|
+
"""Drop an awaitable we refuse to await, without a 'never awaited' warning."""
|
|
30
|
+
if inspect.iscoroutine(value):
|
|
31
|
+
value.close()
|
|
32
|
+
elif isinstance(value, asyncio.Future):
|
|
33
|
+
value.cancel()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""toolwall CLI
|
|
1
|
+
"""toolwall CLI: a no-key demo, and inspecting meter/audit exports."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -28,10 +28,20 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
28
28
|
return 0
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def cmd_demo(args: argparse.Namespace) -> int:
|
|
32
|
+
from toolwall.demo import run_demo
|
|
33
|
+
|
|
34
|
+
return run_demo(color=False if args.no_color else None)
|
|
35
|
+
|
|
36
|
+
|
|
31
37
|
def main() -> None:
|
|
32
38
|
ap = argparse.ArgumentParser(prog="toolwall", description="toolwall dev tools")
|
|
33
39
|
sub = ap.add_subparsers(dest="command", required=True)
|
|
34
40
|
|
|
41
|
+
demo = sub.add_parser("demo", help="Watch the gate stop a support agent's bad tool calls (no API key)")
|
|
42
|
+
demo.add_argument("--no-color", action="store_true", help="Plain output")
|
|
43
|
+
demo.set_defaults(func=cmd_demo)
|
|
44
|
+
|
|
35
45
|
report = sub.add_parser("report", help="Print summary from a Meter JSON export")
|
|
36
46
|
report.add_argument("file", help="Path to meter/audit JSON")
|
|
37
47
|
report.set_defaults(func=cmd_report)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""`python -m toolwall demo`: a support agent's tool calls, gated. No API key.
|
|
2
|
+
|
|
3
|
+
A scripted "model" makes the calls a prompt-injected support agent might make
|
|
4
|
+
on behalf of one signed-in customer. Every verdict printed is the real gate's.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from toolwall.policy import Policy, email_domain, in_range
|
|
14
|
+
from toolwall.schema import ToolSchema
|
|
15
|
+
from toolwall.wall import ToolWall
|
|
16
|
+
|
|
17
|
+
CUSTOMER = {"id": "c_42", "email": "amal@example.com"}
|
|
18
|
+
ORDER_OWNER = {"o_9": "c_42", "o_10": "c_42", "o_77": "c_7"}
|
|
19
|
+
# Built by concatenation so repository secret scanners don't flag the fixture.
|
|
20
|
+
FAKE_KEY = "sk_live_" + "4eC39HqLyjWDarjtT1zdp7dc"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def issue_refund(order_id: str, amount: float) -> str:
|
|
24
|
+
return f"refunded {amount:.2f} on {order_id}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def send_email(to: str, body: str) -> str:
|
|
28
|
+
return f"sent to {to}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def lookup_customer(customer_id: str) -> dict:
|
|
32
|
+
return {"id": customer_id, "plan": "pro", "billing_api_key": FAKE_KEY}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def close_account(customer_id: str) -> str:
|
|
36
|
+
return f"closed {customer_id}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def own_order(args: dict[str, Any]) -> str | None:
|
|
40
|
+
owner = ORDER_OWNER.get(args.get("order_id"))
|
|
41
|
+
if owner != CUSTOMER["id"]:
|
|
42
|
+
return "order does not belong to the signed-in customer"
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def customer_or_ourco(value: Any) -> bool:
|
|
47
|
+
return value == CUSTOMER["email"] or email_domain("ourco.com")(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_wall() -> ToolWall:
|
|
51
|
+
wall = ToolWall()
|
|
52
|
+
# Fully annotated functions get their schema from the signature.
|
|
53
|
+
wall.register("issue_refund", issue_refund,
|
|
54
|
+
policy=Policy(constraints={"amount": in_range(0.01, 500)}, cross=own_order))
|
|
55
|
+
wall.register("send_email", send_email, schema=ToolSchema(required=["to", "body"]),
|
|
56
|
+
policy=Policy(constraints={"to": customer_or_ourco}))
|
|
57
|
+
wall.register("lookup_customer", lookup_customer)
|
|
58
|
+
wall.register("close_account", close_account, policy=Policy(require_approval=True))
|
|
59
|
+
return wall
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
SCRIPT = [
|
|
63
|
+
("issue_refund", {"order_id": "o_9", "amount": 40.0}, "an honest refund"),
|
|
64
|
+
("issue_refund", {"order_id": "o_9", "amount": 5000.0}, "a refund over the cap"),
|
|
65
|
+
("issue_refund", {"order_id": "o_77", "amount": 40.0}, "someone else's order"),
|
|
66
|
+
("send_email", {"to": "x@evil.com,ops@ourco.com", "body": "export"}, "a smuggled recipient"),
|
|
67
|
+
("lookup_customer", {"customer_id": "c_42"}, "a lookup whose record holds a key"),
|
|
68
|
+
("close_account", {"customer_id": "c_42"}, "a destructive action"),
|
|
69
|
+
("drop_all_tables", {}, "a tool nobody registered"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _paint(color: bool):
|
|
74
|
+
def paint(text: str, code: str) -> str:
|
|
75
|
+
return f"\033[{code}m{text}\033[0m" if color else text
|
|
76
|
+
return paint
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _render_call(name: str, args: dict[str, Any]) -> str:
|
|
80
|
+
inner = ", ".join(f"{k}={v!r}" for k, v in args.items())
|
|
81
|
+
return f"{name}({inner})"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_demo(color: bool | None = None, out: Any = None) -> int:
|
|
85
|
+
out = out or sys.stdout
|
|
86
|
+
if color is None:
|
|
87
|
+
color = getattr(out, "isatty", lambda: False)() and "NO_COLOR" not in os.environ
|
|
88
|
+
paint = _paint(color)
|
|
89
|
+
wall = build_wall()
|
|
90
|
+
print(paint(f"toolwall demo: a support agent serving customer {CUSTOMER['id']}", "1"), file=out)
|
|
91
|
+
print(paint("Every verdict below comes from the real gate. No model or API key.\n", "2"), file=out)
|
|
92
|
+
|
|
93
|
+
tally = {"ran": 0, "stopped": 0, "held": 0}
|
|
94
|
+
for name, args, label in SCRIPT:
|
|
95
|
+
r = wall.call(name, args)
|
|
96
|
+
call = _render_call(name, args)
|
|
97
|
+
if r.needs_approval:
|
|
98
|
+
tag, note, key = paint("HELD ", "33;1"), "waiting for a human to approve", "held"
|
|
99
|
+
elif r.blocked:
|
|
100
|
+
tag, note, key = paint("BLOCK", "31;1"), r.reason, "stopped"
|
|
101
|
+
elif r.error:
|
|
102
|
+
tag, note, key = paint("ALLOW", "32;1"), f"ran, but {r.error}", "stopped"
|
|
103
|
+
else:
|
|
104
|
+
tag, note, key = paint("ALLOW", "32;1"), f"ran: {r.return_value}", "ran"
|
|
105
|
+
tally[key] += 1
|
|
106
|
+
print(f" {tag} {call}", file=out)
|
|
107
|
+
print(paint(f" {label}: {note}", "2"), file=out)
|
|
108
|
+
|
|
109
|
+
print(file=out)
|
|
110
|
+
print(f"{tally['ran']} ran · {tally['stopped']} stopped · {tally['held']} waiting for a human. "
|
|
111
|
+
"Every decision is in the audit log (wall.export).", file=out)
|
|
112
|
+
print(paint("Source of this demo: python -c \"import toolwall.demo as d; print(d.__file__)\"", "2"), file=out)
|
|
113
|
+
return 0
|
|
@@ -14,6 +14,7 @@ Check order (first failure blocks, fail-closed):
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import copy
|
|
17
|
+
import inspect
|
|
17
18
|
import threading
|
|
18
19
|
import weakref
|
|
19
20
|
from collections.abc import Callable
|
|
@@ -21,6 +22,7 @@ from dataclasses import dataclass, field
|
|
|
21
22
|
from enum import Enum
|
|
22
23
|
from typing import Any, Literal
|
|
23
24
|
|
|
25
|
+
from toolwall._aio import discard_awaitable, is_async_callable
|
|
24
26
|
from toolwall.intake import IntakeError, ToolCall, parse_tool_calls
|
|
25
27
|
from toolwall.meter import Meter, RunEvent
|
|
26
28
|
from toolwall.policy import Policy
|
|
@@ -379,7 +381,71 @@ class Gate:
|
|
|
379
381
|
the registered tool being called with them (MCPGuard forwards to a
|
|
380
382
|
downstream server this way). Everything else applies unchanged; the tool
|
|
381
383
|
must still be registered, because registration is the allowlist.
|
|
384
|
+
|
|
385
|
+
Synchronous. An async tool is refused here, before any budget is spent, and
|
|
386
|
+
an awaitable returned by a sync callable is withheld: neither has run, and
|
|
387
|
+
neither has an output the shield could scan. Use aexecute()/arun() for them.
|
|
388
|
+
"""
|
|
389
|
+
admitted = self._admit(result, invoke, sync=True)
|
|
390
|
+
if isinstance(admitted, GateResult):
|
|
391
|
+
return admitted
|
|
392
|
+
target, args = admitted
|
|
393
|
+
try:
|
|
394
|
+
value = target(args)
|
|
395
|
+
except Exception as exc:
|
|
396
|
+
result.error = self._scan_error_text(f"{type(exc).__name__}: {exc}", type(exc).__name__)
|
|
397
|
+
except BaseException as exc: # KeyboardInterrupt and friends: audit, then re-raise
|
|
398
|
+
result.error = f"cancelled ({type(exc).__name__}) after the tool started"
|
|
399
|
+
self._record_tool(result, invoke)
|
|
400
|
+
raise
|
|
401
|
+
else:
|
|
402
|
+
if inspect.isawaitable(value):
|
|
403
|
+
discard_awaitable(value)
|
|
404
|
+
result.error = (
|
|
405
|
+
"tool returned an awaitable that was not awaited, so it did not run; "
|
|
406
|
+
"call it with acall() / arun()"
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
self._accept_output(result, value)
|
|
410
|
+
return self._record_tool(result, invoke)
|
|
411
|
+
|
|
412
|
+
async def aexecute(
|
|
413
|
+
self, result: GateResult, invoke: Callable[[dict[str, Any]], Any] | None = None
|
|
414
|
+
) -> GateResult:
|
|
415
|
+
"""execute() for async tools and forwards: awaits, then scans the output.
|
|
416
|
+
|
|
417
|
+
Every check is shared with execute(); only the call itself differs. Sync
|
|
418
|
+
tools run inline (not in a thread), exactly as they do in execute().
|
|
382
419
|
"""
|
|
420
|
+
admitted = self._admit(result, invoke, sync=False)
|
|
421
|
+
if isinstance(admitted, GateResult):
|
|
422
|
+
return admitted
|
|
423
|
+
target, args = admitted
|
|
424
|
+
try:
|
|
425
|
+
value = target(args)
|
|
426
|
+
if inspect.isawaitable(value):
|
|
427
|
+
value = await value
|
|
428
|
+
except Exception as exc:
|
|
429
|
+
result.error = self._scan_error_text(f"{type(exc).__name__}: {exc}", type(exc).__name__)
|
|
430
|
+
except BaseException as exc:
|
|
431
|
+
# Cancelled (or interrupted) after the tool started: its side effects may
|
|
432
|
+
# have happened. Record that in the audit, keep the spent budget, re-raise.
|
|
433
|
+
result.error = f"cancelled ({type(exc).__name__}) after the tool started"
|
|
434
|
+
self._record_tool(result, invoke)
|
|
435
|
+
raise
|
|
436
|
+
else:
|
|
437
|
+
if inspect.isawaitable(value): # an awaitable that resolved to another one
|
|
438
|
+
discard_awaitable(value)
|
|
439
|
+
result.error = "tool output withheld: output could not be scanned (awaitable)"
|
|
440
|
+
else:
|
|
441
|
+
self._accept_output(result, value)
|
|
442
|
+
return self._record_tool(result, invoke)
|
|
443
|
+
|
|
444
|
+
def _admit(
|
|
445
|
+
self, result: GateResult, invoke: Callable[[dict[str, Any]], Any] | None, *, sync: bool
|
|
446
|
+
) -> GateResult | tuple[Callable[[dict[str, Any]], Any], dict[str, Any]]:
|
|
447
|
+
"""Every check between an ALLOW and the call. Returns the call to make, or
|
|
448
|
+
the final result when the call must not happen (refused, dry run)."""
|
|
383
449
|
if not result.allowed or result.call is None:
|
|
384
450
|
joined = "; ".join(result.reasons) or "no call"
|
|
385
451
|
raise PermissionError(f"refusing to execute a {result.verdict.value} result: {joined}")
|
|
@@ -438,6 +504,16 @@ class Gate:
|
|
|
438
504
|
# result keeps the checked args; the hash just proved the two identical.
|
|
439
505
|
execute_args = frozen_args
|
|
440
506
|
|
|
507
|
+
tool = self.registry.get(result.call.name)
|
|
508
|
+
# Calling an async tool synchronously only creates a coroutine: the tool would
|
|
509
|
+
# not run and its output could not be scanned. Refuse before spending budget.
|
|
510
|
+
if sync and invoke is None and tool is not None and is_async_callable(tool):
|
|
511
|
+
return self._refuse(
|
|
512
|
+
result,
|
|
513
|
+
f"{result.call.name!r} is an async tool; call it with acall() / arun() "
|
|
514
|
+
"so it is awaited and its output scanned",
|
|
515
|
+
)
|
|
516
|
+
|
|
441
517
|
# Re-check the budget and reserve the slot in one critical section. The check
|
|
442
518
|
# in check() is advisory: between it and here other threads sharing this Gate
|
|
443
519
|
# may have executed, and without this a burst of parallel calls all pass a
|
|
@@ -464,28 +540,29 @@ class Gate:
|
|
|
464
540
|
)
|
|
465
541
|
)
|
|
466
542
|
return result
|
|
467
|
-
tool = self.registry.get(result.call.name)
|
|
468
543
|
if tool is None: # registry mutated between check and execute
|
|
469
544
|
result.error = f"unknown tool at execute time: {result.call.name!r}"
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
545
|
+
return self._record_tool(result, invoke)
|
|
546
|
+
if invoke is not None:
|
|
547
|
+
return invoke, execute_args
|
|
548
|
+
return (lambda args: tool(**args)), execute_args
|
|
549
|
+
|
|
550
|
+
def _accept_output(self, result: GateResult, value: Any) -> None:
|
|
551
|
+
result.return_value = value
|
|
552
|
+
result.executed = True
|
|
553
|
+
try:
|
|
554
|
+
self._scan_output(result)
|
|
555
|
+
except Exception as exc:
|
|
556
|
+
# Output we could not scan is output we cannot vouch for.
|
|
557
|
+
result.return_value = None
|
|
558
|
+
detail = str(exc) if isinstance(exc, UnscannableError) else type(exc).__name__
|
|
559
|
+
result.error = self._scrub(
|
|
560
|
+
f"tool output withheld: output could not be scanned ({detail})"
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def _record_tool(
|
|
564
|
+
self, result: GateResult, invoke: Callable[[dict[str, Any]], Any] | None
|
|
565
|
+
) -> GateResult:
|
|
489
566
|
if self.meter is not None:
|
|
490
567
|
self.meter.record(
|
|
491
568
|
RunEvent(
|
|
@@ -563,18 +640,54 @@ class Gate:
|
|
|
563
640
|
return message
|
|
564
641
|
|
|
565
642
|
def _resolve_approval(self, result: GateResult) -> GateResult:
|
|
566
|
-
"""Called by run/run_all on NEEDS_APPROVAL. Fail closed without a handler.
|
|
643
|
+
"""Called by run/run_all on NEEDS_APPROVAL. Fail closed without a handler.
|
|
644
|
+
|
|
645
|
+
An async handler cannot be asked from here: its coroutine is truthy, and
|
|
646
|
+
reading it as "granted" would approve every call. It is refused, closed.
|
|
647
|
+
"""
|
|
567
648
|
if self.approval is None:
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
649
|
+
return self._approval_unavailable(result)
|
|
650
|
+
try:
|
|
651
|
+
granted = self.approval(result)
|
|
652
|
+
except Exception as exc:
|
|
653
|
+
return self._approval_failed(result, f"approval handler raised {type(exc).__name__}: fails closed")
|
|
654
|
+
if inspect.isawaitable(granted):
|
|
655
|
+
discard_awaitable(granted)
|
|
656
|
+
return self._approval_failed(
|
|
657
|
+
result, "approval handler is async; use acall() / arun() so it is awaited: fails closed"
|
|
658
|
+
)
|
|
659
|
+
return self._approval_decided(result, bool(granted))
|
|
660
|
+
|
|
661
|
+
async def _aresolve_approval(self, result: GateResult) -> GateResult:
|
|
662
|
+
"""_resolve_approval() for the async path: an async handler is awaited."""
|
|
663
|
+
if self.approval is None:
|
|
664
|
+
return self._approval_unavailable(result)
|
|
571
665
|
try:
|
|
572
|
-
granted =
|
|
666
|
+
granted = self.approval(result)
|
|
667
|
+
if inspect.isawaitable(granted):
|
|
668
|
+
granted = await granted
|
|
573
669
|
except Exception as exc:
|
|
574
|
-
self.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
670
|
+
return self._approval_failed(result, f"approval handler raised {type(exc).__name__}: fails closed")
|
|
671
|
+
except BaseException:
|
|
672
|
+
self._issued.pop(id(result), None) # cancelled mid-approval: never runnable
|
|
673
|
+
raise
|
|
674
|
+
if inspect.isawaitable(granted):
|
|
675
|
+
discard_awaitable(granted)
|
|
676
|
+
return self._approval_failed(result, "approval handler returned an awaitable: fails closed")
|
|
677
|
+
return self._approval_decided(result, bool(granted))
|
|
678
|
+
|
|
679
|
+
def _approval_unavailable(self, result: GateResult) -> GateResult:
|
|
680
|
+
# Held for good: there is no approve-later path, so nothing may run it.
|
|
681
|
+
self._issued.pop(id(result), None)
|
|
682
|
+
return result
|
|
683
|
+
|
|
684
|
+
def _approval_failed(self, result: GateResult, reason: str) -> GateResult:
|
|
685
|
+
self._issued.pop(id(result), None)
|
|
686
|
+
result.verdict = Verdict.BLOCK
|
|
687
|
+
result.reasons.append(reason)
|
|
688
|
+
return self._record_transition(result, "approval-error")
|
|
689
|
+
|
|
690
|
+
def _approval_decided(self, result: GateResult, granted: bool) -> GateResult:
|
|
578
691
|
if granted:
|
|
579
692
|
with self._lock:
|
|
580
693
|
entry = self._issued.get(id(result))
|
|
@@ -615,6 +728,32 @@ class Gate:
|
|
|
615
728
|
out.append(self.execute(result) if result.allowed else result)
|
|
616
729
|
return out
|
|
617
730
|
|
|
731
|
+
async def arun(self, payload: Any) -> GateResult:
|
|
732
|
+
"""run() for async tools and async approval handlers."""
|
|
733
|
+
result = self.check(payload)
|
|
734
|
+
if result.verdict is Verdict.NEEDS_APPROVAL:
|
|
735
|
+
result = await self._aresolve_approval(result)
|
|
736
|
+
if result.allowed:
|
|
737
|
+
return await self.aexecute(result)
|
|
738
|
+
return result
|
|
739
|
+
|
|
740
|
+
async def arun_all(self, payload: Any) -> list[GateResult]:
|
|
741
|
+
"""run_all() for async tools: calls run one after another, never at once,
|
|
742
|
+
so each check sees the budget the previous call spent."""
|
|
743
|
+
try:
|
|
744
|
+
calls = parse_tool_calls(payload)
|
|
745
|
+
except IntakeError as exc:
|
|
746
|
+
return [self._record(GateResult(Verdict.BLOCK, reasons=[f"intake: {exc}"]))]
|
|
747
|
+
if not calls:
|
|
748
|
+
return [self._record(GateResult(Verdict.BLOCK, reasons=["intake: no tool calls in payload"]))]
|
|
749
|
+
out: list[GateResult] = []
|
|
750
|
+
for call in calls:
|
|
751
|
+
result = self._check_one(call)
|
|
752
|
+
if result.verdict is Verdict.NEEDS_APPROVAL:
|
|
753
|
+
result = await self._aresolve_approval(result)
|
|
754
|
+
out.append(await self.aexecute(result) if result.allowed else result)
|
|
755
|
+
return out
|
|
756
|
+
|
|
618
757
|
# -- reporting ----------------------------------------------------------------
|
|
619
758
|
|
|
620
759
|
def report(self) -> dict[str, Any]:
|
|
@@ -13,6 +13,7 @@ module imports it lazily so toolwall core stays stdlib-only. The logic below
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
from dataclasses import dataclass
|
|
16
|
+
import inspect
|
|
16
17
|
from typing import Any, Callable
|
|
17
18
|
|
|
18
19
|
from toolwall.gate import Gate, GateResult, Verdict
|
|
@@ -55,6 +56,8 @@ class MCPGuard:
|
|
|
55
56
|
self.forward = forward
|
|
56
57
|
|
|
57
58
|
def handle(self, name: str, args: dict[str, Any]) -> GuardedCall:
|
|
59
|
+
"""Check and forward synchronously. An async `forward` is refused here
|
|
60
|
+
(its coroutine would never run); use ahandle() for it."""
|
|
58
61
|
result = self.gate.check({"name": name, "args": args})
|
|
59
62
|
|
|
60
63
|
if result.verdict is Verdict.NEEDS_APPROVAL:
|
|
@@ -70,26 +73,48 @@ class MCPGuard:
|
|
|
70
73
|
reached: list[bool] = []
|
|
71
74
|
|
|
72
75
|
def invoke(verified_args: dict[str, Any]) -> Any:
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
value = self.forward(name, verified_args)
|
|
77
|
+
if not inspect.isawaitable(value): # an un-awaited forward reached nothing
|
|
78
|
+
reached.append(True)
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
return _finish(self.gate.execute(result, invoke=invoke), reached)
|
|
82
|
+
|
|
83
|
+
async def ahandle(self, name: str, args: dict[str, Any]) -> GuardedCall:
|
|
84
|
+
"""handle() for async forwards and async approval handlers."""
|
|
85
|
+
result = self.gate.check({"name": name, "args": args})
|
|
75
86
|
|
|
76
|
-
result
|
|
87
|
+
if result.verdict is Verdict.NEEDS_APPROVAL:
|
|
88
|
+
result = await self.gate._aresolve_approval(result)
|
|
77
89
|
|
|
78
|
-
if not result.allowed:
|
|
90
|
+
if not result.allowed:
|
|
79
91
|
return _refused(result)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return
|
|
92
|
+
|
|
93
|
+
reached: list[bool] = []
|
|
94
|
+
|
|
95
|
+
def invoke(verified_args: dict[str, Any]) -> Any:
|
|
96
|
+
reached.append(True)
|
|
97
|
+
return self.forward(name, verified_args) # aexecute awaits it
|
|
98
|
+
|
|
99
|
+
return _finish(await self.gate.aexecute(result, invoke=invoke), reached)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _finish(result: GateResult, reached: list[bool]) -> GuardedCall:
|
|
103
|
+
if not result.allowed: # refused at execute time (receipt mismatch, budget)
|
|
104
|
+
return _refused(result)
|
|
105
|
+
if result.dry_run:
|
|
106
|
+
return GuardedCall(verdict="allow", forwarded=False, reason="dry run: not forwarded")
|
|
107
|
+
if result.error is not None: # downstream raised, or its output was withheld
|
|
108
|
+
return GuardedCall(
|
|
109
|
+
verdict="allow",
|
|
110
|
+
forwarded=bool(reached),
|
|
111
|
+
reason=result.error,
|
|
112
|
+
result={
|
|
113
|
+
"isError": True,
|
|
114
|
+
"content": [{"type": "text", "text": f"toolwall: {result.error}"}],
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
return GuardedCall(verdict="allow", forwarded=True, result=result.return_value)
|
|
93
118
|
|
|
94
119
|
|
|
95
120
|
def _refused(result: GateResult) -> GuardedCall:
|
|
@@ -6,12 +6,15 @@ Rules are plain callables returning truthy/falsy. A raising rule fails closed.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import inspect
|
|
9
10
|
import math
|
|
10
11
|
import re
|
|
11
12
|
from collections.abc import Callable
|
|
12
13
|
from dataclasses import dataclass, field
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
16
|
+
from toolwall._aio import discard_awaitable
|
|
17
|
+
|
|
15
18
|
Rule = Callable[[Any], bool]
|
|
16
19
|
CrossRule = Callable[[dict[str, Any]], "str | list[str] | None"]
|
|
17
20
|
|
|
@@ -136,7 +139,13 @@ class Policy:
|
|
|
136
139
|
continue
|
|
137
140
|
name = getattr(rule, "__name__", "rule")
|
|
138
141
|
try:
|
|
139
|
-
|
|
142
|
+
verdict = rule(args[key])
|
|
143
|
+
if inspect.isawaitable(verdict):
|
|
144
|
+
# A coroutine is truthy: reading it would pass every value.
|
|
145
|
+
discard_awaitable(verdict)
|
|
146
|
+
errors.append(f"policy rule {name} for arg {key!r} is async; rules must be synchronous: fails closed")
|
|
147
|
+
continue
|
|
148
|
+
ok = bool(verdict)
|
|
140
149
|
except Exception as exc:
|
|
141
150
|
errors.append(
|
|
142
151
|
f"policy rule {name} for arg {key!r} raised {type(exc).__name__}: fails closed"
|
|
@@ -150,8 +159,17 @@ class Policy:
|
|
|
150
159
|
except Exception as exc:
|
|
151
160
|
errors.append(f"policy cross-rule raised {type(exc).__name__}: fails closed")
|
|
152
161
|
else:
|
|
162
|
+
if inspect.isawaitable(verdict):
|
|
163
|
+
discard_awaitable(verdict)
|
|
164
|
+
errors.append("policy cross-rule is async; rules must be synchronous: fails closed")
|
|
165
|
+
return errors
|
|
153
166
|
if isinstance(verdict, str):
|
|
154
167
|
errors.append(verdict)
|
|
155
168
|
elif verdict:
|
|
156
|
-
|
|
169
|
+
for item in verdict:
|
|
170
|
+
if inspect.isawaitable(item):
|
|
171
|
+
discard_awaitable(item)
|
|
172
|
+
errors.append("policy cross-rule returned an awaitable: fails closed")
|
|
173
|
+
else:
|
|
174
|
+
errors.append(item)
|
|
157
175
|
return errors
|
|
File without changes
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Tool schema validation, run before a tool is allowed to execute."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import types
|
|
7
|
+
import typing
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ToolSchema:
|
|
14
|
+
"""Minimal schema: required keys + optional crude types."""
|
|
15
|
+
|
|
16
|
+
required: list[str] = field(default_factory=list)
|
|
17
|
+
types: dict[str, type | tuple[type, ...]] = field(default_factory=dict)
|
|
18
|
+
allow_extra: bool = True
|
|
19
|
+
# Known-but-not-required arg names. Only consulted when allow_extra is False,
|
|
20
|
+
# so that an optional parameter carrying no type annotation (and therefore
|
|
21
|
+
# absent from `types`) is not mistaken for an unexpected argument.
|
|
22
|
+
optional: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def validate(self, args: dict[str, Any]) -> list[str]:
|
|
25
|
+
errors: list[str] = []
|
|
26
|
+
for key in self.required:
|
|
27
|
+
if key not in args:
|
|
28
|
+
errors.append(f"Missing required arg: {key}")
|
|
29
|
+
if not self.allow_extra:
|
|
30
|
+
allowed = set(self.required) | set(self.types) | set(self.optional)
|
|
31
|
+
for key in args:
|
|
32
|
+
if key not in allowed:
|
|
33
|
+
errors.append(f"Unexpected arg: {key}")
|
|
34
|
+
for key, expected in self.types.items():
|
|
35
|
+
if key not in args:
|
|
36
|
+
continue
|
|
37
|
+
if not isinstance(args[key], expected):
|
|
38
|
+
errors.append(
|
|
39
|
+
f"Arg {key!r} expected {expected}, got {type(args[key]).__name__}"
|
|
40
|
+
)
|
|
41
|
+
return errors
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _signature(fn: Any) -> inspect.Signature:
|
|
45
|
+
# eval_str resolves string annotations (modules using `from __future__ import
|
|
46
|
+
# annotations`), so types are enforced there too. If any annotation can't be
|
|
47
|
+
# resolved, fall back to the raw signature: those annotations stay strings and
|
|
48
|
+
# are treated as unsupported, which means no inference rather than an error.
|
|
49
|
+
try:
|
|
50
|
+
return inspect.signature(fn, eval_str=True)
|
|
51
|
+
except Exception:
|
|
52
|
+
return inspect.signature(fn)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_ANY = object() # an annotation that constrains nothing (typing.Any)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_annotation(ann: Any) -> "tuple[type, ...] | object | None":
|
|
59
|
+
"""Map an annotation to the classes isinstance() should accept.
|
|
60
|
+
|
|
61
|
+
Returns a tuple of classes, _ANY for typing.Any, or None when the annotation
|
|
62
|
+
can't be checked with isinstance (Literal, TypedDict, a string, a TypeVar...).
|
|
63
|
+
Generic containers check the container only: list[str] accepts any list.
|
|
64
|
+
float also accepts int, because JSON has one number type.
|
|
65
|
+
"""
|
|
66
|
+
if ann is typing.Any:
|
|
67
|
+
return _ANY
|
|
68
|
+
origin = typing.get_origin(ann)
|
|
69
|
+
if origin is typing.Annotated:
|
|
70
|
+
return _resolve_annotation(typing.get_args(ann)[0])
|
|
71
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
72
|
+
accepted: list[type] = []
|
|
73
|
+
for member in typing.get_args(ann):
|
|
74
|
+
resolved = _resolve_annotation(member)
|
|
75
|
+
if resolved is None:
|
|
76
|
+
return None
|
|
77
|
+
if resolved is _ANY:
|
|
78
|
+
return _ANY
|
|
79
|
+
accepted.extend(resolved) # type: ignore[arg-type]
|
|
80
|
+
return tuple(dict.fromkeys(accepted))
|
|
81
|
+
if origin is not None:
|
|
82
|
+
# list[str] -> list, typing.List[str] -> list; Literal's origin is not a class.
|
|
83
|
+
return (origin,) if isinstance(origin, type) else None
|
|
84
|
+
if ann is None or ann is type(None):
|
|
85
|
+
return (type(None),)
|
|
86
|
+
# On 3.10, isinstance(list[str], type) is True; GenericAlias is handled above.
|
|
87
|
+
if isinstance(ann, type) and not isinstance(ann, types.GenericAlias):
|
|
88
|
+
# Some classes refuse isinstance() altogether (TypedDict, a Protocol that is
|
|
89
|
+
# not runtime_checkable). Inferring them would make a tool no call can pass.
|
|
90
|
+
try:
|
|
91
|
+
isinstance(None, ann)
|
|
92
|
+
except TypeError:
|
|
93
|
+
return None
|
|
94
|
+
return (int, float) if ann is float else (ann,)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _named_params(sig: inspect.Signature) -> list[inspect.Parameter]:
|
|
99
|
+
return [
|
|
100
|
+
p for name, p in sig.parameters.items()
|
|
101
|
+
if name not in ("self", "cls") and p.kind is not p.VAR_POSITIONAL
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fully_annotated(sig: inspect.Signature) -> bool:
|
|
106
|
+
named = _named_params(sig)
|
|
107
|
+
if any(p.kind is p.VAR_KEYWORD for p in named):
|
|
108
|
+
return False
|
|
109
|
+
if sig.return_annotation is inspect.Signature.empty:
|
|
110
|
+
return False
|
|
111
|
+
# Parameter.empty is itself a class, so it must be excluded explicitly.
|
|
112
|
+
return all(
|
|
113
|
+
p.annotation is not p.empty and _resolve_annotation(p.annotation) is not None
|
|
114
|
+
for p in named
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fully_annotated(fn: Any) -> bool:
|
|
119
|
+
"""True when every named parameter has an annotation toolwall can check (a
|
|
120
|
+
class, X | None, Union, Optional, a generic container, Any, Annotated[...]),
|
|
121
|
+
the return value is annotated, and there is no **kwargs: the author declared
|
|
122
|
+
the whole signature, so it can stand in for a schema. A lambda never does."""
|
|
123
|
+
try:
|
|
124
|
+
return _fully_annotated(_signature(fn))
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def infer_schema_if_annotated(fn: Any) -> "ToolSchema | None":
|
|
130
|
+
"""One signature read: the inferred schema, or None when fn isn't fully
|
|
131
|
+
annotated. Annotation expressions are evaluated once."""
|
|
132
|
+
try:
|
|
133
|
+
sig = _signature(fn)
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
return None
|
|
136
|
+
if not _fully_annotated(sig):
|
|
137
|
+
return None
|
|
138
|
+
return schema_from_signature(fn, _sig=sig)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def schema_from_signature(
|
|
142
|
+
fn: Any, *, required: list[str] | None = None, _sig: inspect.Signature | None = None
|
|
143
|
+
) -> ToolSchema:
|
|
144
|
+
"""Best-effort schema from a callable signature (stdlib inspect).
|
|
145
|
+
|
|
146
|
+
Extra arguments are rejected, because the signature already tells us exactly
|
|
147
|
+
what the callable accepts. Passing one would raise TypeError inside the tool,
|
|
148
|
+
which surfaces as an executed=False result carrying an error rather than as a
|
|
149
|
+
verdict; a clean BLOCK is both more honest and fails closed.
|
|
150
|
+
|
|
151
|
+
The exception is a callable that declares **kwargs: it genuinely accepts
|
|
152
|
+
arguments we cannot enumerate, so extras stay allowed there.
|
|
153
|
+
"""
|
|
154
|
+
sig = _sig if _sig is not None else _signature(fn)
|
|
155
|
+
req: list[str] = []
|
|
156
|
+
opt: list[str] = []
|
|
157
|
+
types: dict[str, type | tuple[type, ...]] = {}
|
|
158
|
+
takes_var_kwargs = False
|
|
159
|
+
for name, param in sig.parameters.items():
|
|
160
|
+
if name in ("self", "cls"):
|
|
161
|
+
continue
|
|
162
|
+
if param.kind is param.VAR_KEYWORD:
|
|
163
|
+
takes_var_kwargs = True
|
|
164
|
+
continue
|
|
165
|
+
if param.kind is param.VAR_POSITIONAL:
|
|
166
|
+
continue
|
|
167
|
+
if param.default is inspect.Parameter.empty:
|
|
168
|
+
req.append(name)
|
|
169
|
+
else:
|
|
170
|
+
opt.append(name)
|
|
171
|
+
ann = param.annotation
|
|
172
|
+
if ann is not inspect.Parameter.empty:
|
|
173
|
+
resolved = _resolve_annotation(ann)
|
|
174
|
+
if isinstance(resolved, tuple):
|
|
175
|
+
types[name] = resolved[0] if len(resolved) == 1 else resolved
|
|
176
|
+
if required is not None:
|
|
177
|
+
# A caller-supplied required list overrides which args are mandatory, but
|
|
178
|
+
# every name from the signature is still a known argument.
|
|
179
|
+
opt = [n for n in req + opt if n not in required]
|
|
180
|
+
req = list(required)
|
|
181
|
+
return ToolSchema(
|
|
182
|
+
required=req, types=types, allow_extra=takes_var_kwargs, optional=opt
|
|
183
|
+
)
|
|
@@ -16,6 +16,7 @@ import enum
|
|
|
16
16
|
import math
|
|
17
17
|
import numbers
|
|
18
18
|
import email.message
|
|
19
|
+
import inspect
|
|
19
20
|
import re
|
|
20
21
|
import sqlite3
|
|
21
22
|
import types
|
|
@@ -383,7 +384,18 @@ def _walk_strings(value: Any, path: str = "") -> Iterator[tuple[str, str]]:
|
|
|
383
384
|
continue
|
|
384
385
|
seen.add(id(item))
|
|
385
386
|
keep.append(item)
|
|
386
|
-
|
|
387
|
+
# Lazy values: iterating, awaiting or async-iterating them is the only way to
|
|
388
|
+
# see what they hold, and that would consume them. An async generator is none
|
|
389
|
+
# of Iterator, awaitable or iter()-able, so it needs its own test.
|
|
390
|
+
if (
|
|
391
|
+
isinstance(item, Iterator)
|
|
392
|
+
or inspect.isawaitable(item)
|
|
393
|
+
or inspect.isasyncgen(item)
|
|
394
|
+
# On the type, as `async for` looks it up: probing the instance would run
|
|
395
|
+
# __getattr__ on attr-dicts and withhold plain data.
|
|
396
|
+
or hasattr(type(item), "__aiter__")
|
|
397
|
+
or hasattr(type(item), "__anext__")
|
|
398
|
+
):
|
|
387
399
|
raise _unscannable(item)
|
|
388
400
|
if isinstance(item, array.array):
|
|
389
401
|
# Element-wise each entry is one char or one int; scan the text it holds.
|
|
@@ -25,7 +25,7 @@ from typing import Any
|
|
|
25
25
|
from toolwall.gate import ApprovalHandler, DefaultMode, Gate, GateResult
|
|
26
26
|
from toolwall.meter import Meter
|
|
27
27
|
from toolwall.policy import Policy
|
|
28
|
-
from toolwall.schema import ToolSchema
|
|
28
|
+
from toolwall.schema import ToolSchema, infer_schema_if_annotated
|
|
29
29
|
from toolwall.shield import Shield
|
|
30
30
|
|
|
31
31
|
ToolFn = Callable[..., Any]
|
|
@@ -35,7 +35,8 @@ class ToolWall:
|
|
|
35
35
|
"""Fail-closed security gateway for AI agent tool calls.
|
|
36
36
|
|
|
37
37
|
Args:
|
|
38
|
-
default: "deny" (registered
|
|
38
|
+
default: "deny" (a registered tool needs a schema, given or inferred from a
|
|
39
|
+
fully annotated signature) or "allow".
|
|
39
40
|
detect_secrets: attach a Shield. True blocks secrets in tool args;
|
|
40
41
|
pass redact=True to replace them with placeholders instead.
|
|
41
42
|
redact: if detecting secrets, redact instead of block.
|
|
@@ -65,9 +66,23 @@ class ToolWall:
|
|
|
65
66
|
*,
|
|
66
67
|
schema: ToolSchema | None = None,
|
|
67
68
|
policy: Policy | None = None,
|
|
68
|
-
infer_schema: bool =
|
|
69
|
+
infer_schema: bool | None = None,
|
|
69
70
|
replace: bool = False,
|
|
70
71
|
) -> "ToolWall":
|
|
72
|
+
"""Register a tool. Registration is the allowlist.
|
|
73
|
+
|
|
74
|
+
With no schema, a function whose every parameter and return value are
|
|
75
|
+
annotated gets its schema from its signature: required args, types, no
|
|
76
|
+
unknown args. Supported annotations are classes, X | None, Optional and
|
|
77
|
+
Union of classes, generic containers (list[str] checks for a list), Any and
|
|
78
|
+
Annotated[...]; float also accepts int. Anything less (a lambda, a partly
|
|
79
|
+
annotated function, Literal) still needs a schema: under default deny it is
|
|
80
|
+
blocked until it has one. infer_schema=False opts out.
|
|
81
|
+
"""
|
|
82
|
+
if infer_schema is None:
|
|
83
|
+
infer_schema = False
|
|
84
|
+
if schema is None:
|
|
85
|
+
schema = infer_schema_if_annotated(fn) # None unless fully annotated
|
|
71
86
|
self.gate.register(
|
|
72
87
|
name, fn, schema, policy=policy, infer_schema=infer_schema, replace=replace
|
|
73
88
|
)
|
|
@@ -95,6 +110,13 @@ class ToolWall:
|
|
|
95
110
|
"""Check the call and execute it only if allowed."""
|
|
96
111
|
return self.gate.run({"name": name, "args": args or {}})
|
|
97
112
|
|
|
113
|
+
async def acall(self, name: str, args: dict[str, Any] | None = None) -> GateResult:
|
|
114
|
+
"""call() for async tools: awaits the tool, then scans what it returned.
|
|
115
|
+
|
|
116
|
+
Works for sync tools too. Async approval handlers are awaited here.
|
|
117
|
+
"""
|
|
118
|
+
return await self.gate.arun({"name": name, "args": args or {}})
|
|
119
|
+
|
|
98
120
|
def check(self, name: str, args: dict[str, Any] | None = None) -> GateResult:
|
|
99
121
|
"""Return the verdict without executing anything."""
|
|
100
122
|
return self.gate.check({"name": name, "args": args or {}})
|
|
@@ -103,6 +125,10 @@ class ToolWall:
|
|
|
103
125
|
"""Gate a raw LLM response (OpenAI/Anthropic/Gemini), possibly many calls."""
|
|
104
126
|
return self.gate.run_all(response)
|
|
105
127
|
|
|
128
|
+
async def aguard(self, response: Any) -> list[GateResult]:
|
|
129
|
+
"""guard() for async tools. Calls run one at a time, in order."""
|
|
130
|
+
return await self.gate.arun_all(response)
|
|
131
|
+
|
|
106
132
|
# -- dry-run + reporting -------------------------------------------------
|
|
107
133
|
|
|
108
134
|
@property
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""Tool schema validation, run before a tool is allowed to execute."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass, field
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@dataclass
|
|
10
|
-
class ToolSchema:
|
|
11
|
-
"""Minimal schema: required keys + optional crude types."""
|
|
12
|
-
|
|
13
|
-
required: list[str] = field(default_factory=list)
|
|
14
|
-
types: dict[str, type | tuple[type, ...]] = field(default_factory=dict)
|
|
15
|
-
allow_extra: bool = True
|
|
16
|
-
# Known-but-not-required arg names. Only consulted when allow_extra is False,
|
|
17
|
-
# so that an optional parameter carrying no type annotation (and therefore
|
|
18
|
-
# absent from `types`) is not mistaken for an unexpected argument.
|
|
19
|
-
optional: list[str] = field(default_factory=list)
|
|
20
|
-
|
|
21
|
-
def validate(self, args: dict[str, Any]) -> list[str]:
|
|
22
|
-
errors: list[str] = []
|
|
23
|
-
for key in self.required:
|
|
24
|
-
if key not in args:
|
|
25
|
-
errors.append(f"Missing required arg: {key}")
|
|
26
|
-
if not self.allow_extra:
|
|
27
|
-
allowed = set(self.required) | set(self.types) | set(self.optional)
|
|
28
|
-
for key in args:
|
|
29
|
-
if key not in allowed:
|
|
30
|
-
errors.append(f"Unexpected arg: {key}")
|
|
31
|
-
for key, expected in self.types.items():
|
|
32
|
-
if key not in args:
|
|
33
|
-
continue
|
|
34
|
-
if not isinstance(args[key], expected):
|
|
35
|
-
errors.append(
|
|
36
|
-
f"Arg {key!r} expected {expected}, got {type(args[key]).__name__}"
|
|
37
|
-
)
|
|
38
|
-
return errors
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def schema_from_signature(fn: Any, *, required: list[str] | None = None) -> ToolSchema:
|
|
42
|
-
"""Best-effort schema from a callable signature (stdlib inspect).
|
|
43
|
-
|
|
44
|
-
Extra arguments are rejected, because the signature already tells us exactly
|
|
45
|
-
what the callable accepts. Passing one would raise TypeError inside the tool,
|
|
46
|
-
which surfaces as an executed=False result carrying an error rather than as a
|
|
47
|
-
verdict; a clean BLOCK is both more honest and fails closed.
|
|
48
|
-
|
|
49
|
-
The exception is a callable that declares **kwargs: it genuinely accepts
|
|
50
|
-
arguments we cannot enumerate, so extras stay allowed there.
|
|
51
|
-
"""
|
|
52
|
-
import inspect
|
|
53
|
-
|
|
54
|
-
sig = inspect.signature(fn)
|
|
55
|
-
req: list[str] = []
|
|
56
|
-
opt: list[str] = []
|
|
57
|
-
types: dict[str, type | tuple[type, ...]] = {}
|
|
58
|
-
takes_var_kwargs = False
|
|
59
|
-
for name, param in sig.parameters.items():
|
|
60
|
-
if name in ("self", "cls"):
|
|
61
|
-
continue
|
|
62
|
-
if param.kind is param.VAR_KEYWORD:
|
|
63
|
-
takes_var_kwargs = True
|
|
64
|
-
continue
|
|
65
|
-
if param.kind is param.VAR_POSITIONAL:
|
|
66
|
-
continue
|
|
67
|
-
if param.default is inspect.Parameter.empty:
|
|
68
|
-
req.append(name)
|
|
69
|
-
else:
|
|
70
|
-
opt.append(name)
|
|
71
|
-
ann = param.annotation
|
|
72
|
-
if ann is not inspect.Parameter.empty and isinstance(ann, type):
|
|
73
|
-
types[name] = ann
|
|
74
|
-
if required is not None:
|
|
75
|
-
# A caller-supplied required list overrides which args are mandatory, but
|
|
76
|
-
# every name from the signature is still a known argument.
|
|
77
|
-
opt = [n for n in req + opt if n not in required]
|
|
78
|
-
req = list(required)
|
|
79
|
-
return ToolSchema(
|
|
80
|
-
required=req, types=types, allow_extra=takes_var_kwargs, optional=opt
|
|
81
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|