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.
@@ -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
  [![PyPI version](https://img.shields.io/pypi/v/toolwall.svg?cacheSeconds=300)](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
  [![PyPI version](https://img.shields.io/pypi/v/toolwall.svg?cacheSeconds=300)](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.2"
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", "guardrails",
18
- "security", "ai-safety", "mcp", "mcp-security", "mcp-firewall", "mcp-proxy",
19
- "prompt-injection", "secret-detection", "agent-sandbox", "firewall",
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://github.com/Dev-Saif-Ops/toolwall"
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"]
@@ -25,7 +25,7 @@ from toolwall.shield import Finding, Shield
25
25
  from toolwall.suggest import suggest_policies
26
26
  from toolwall.mcp_guard import GuardedCall, MCPGuard, to_mcp_error
27
27
 
28
- __version__ = "0.4.2"
28
+ __version__ = "0.4.3"
29
29
 
30
30
  __all__ = [
31
31
  "ToolWall",
@@ -0,0 +1,5 @@
1
+ """`python -m toolwall ...`: same as the `toolwall` command."""
2
+
3
+ from toolwall.cli import main
4
+
5
+ main()
@@ -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 — inspect meter/audit exports."""
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
- else:
471
- try:
472
- if invoke is not None:
473
- result.return_value = invoke(execute_args)
474
- else:
475
- result.return_value = tool(**execute_args)
476
- result.executed = True
477
- except Exception as exc:
478
- result.error = self._scan_error_text(f"{type(exc).__name__}: {exc}", type(exc).__name__)
479
- else:
480
- try:
481
- self._scan_output(result)
482
- except Exception as exc:
483
- # Output we could not scan is output we cannot vouch for.
484
- result.return_value = None
485
- detail = str(exc) if isinstance(exc, UnscannableError) else type(exc).__name__
486
- result.error = self._scrub(
487
- f"tool output withheld: output could not be scanned ({detail})"
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
- # Held for good: there is no approve-later path, so nothing may run it.
569
- self._issued.pop(id(result), None)
570
- return result
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 = bool(self.approval(result))
666
+ granted = self.approval(result)
667
+ if inspect.isawaitable(granted):
668
+ granted = await granted
573
669
  except Exception as exc:
574
- self._issued.pop(id(result), None)
575
- result.verdict = Verdict.BLOCK
576
- result.reasons.append(f"approval handler raised {type(exc).__name__}: fails closed")
577
- return self._record_transition(result, "approval-error")
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
- reached.append(True)
74
- return self.forward(name, verified_args)
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 = self.gate.execute(result, invoke=invoke)
87
+ if result.verdict is Verdict.NEEDS_APPROVAL:
88
+ result = await self.gate._aresolve_approval(result)
77
89
 
78
- if not result.allowed: # refused at execute time (receipt mismatch, budget)
90
+ if not result.allowed:
79
91
  return _refused(result)
80
- if result.dry_run:
81
- return GuardedCall(verdict="allow", forwarded=False, reason="dry run: not forwarded")
82
- if result.error is not None: # downstream raised, or its output was withheld
83
- return GuardedCall(
84
- verdict="allow",
85
- forwarded=bool(reached),
86
- reason=result.error,
87
- result={
88
- "isError": True,
89
- "content": [{"type": "text", "text": f"toolwall: {result.error}"}],
90
- },
91
- )
92
- return GuardedCall(verdict="allow", forwarded=True, result=result.return_value)
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
- ok = bool(rule(args[key]))
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
- errors.extend(verdict)
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
- if isinstance(item, Iterator):
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 tools still need a schema) or "allow".
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 = False,
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