toolwall 0.2.2__tar.gz → 0.3.0__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,11 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: toolwall
3
- Version: 0.2.2
4
- Summary: Fail-closed firewall for AI agent tool calls: schema + policy gate before any tool executes
3
+ Version: 0.3.0
4
+ Summary: Security gateway for AI agent tool calls: fail-closed schema + policy + secret-detection gate before any tool executes
5
5
  Project-URL: Homepage, https://github.com/Dev-Saif-Ops/toolwall
6
6
  Author: Mohammad Safwan Athar
7
7
  License: MIT
8
- Keywords: agents,ai,guardrails,llm,mcp,security,tool-calling
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
9
  Classifier: Development Status :: 3 - Alpha
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
@@ -27,20 +27,20 @@ Description-Content-Type: text/markdown
27
27
  [![License: MIT](https://img.shields.io/pypi/l/toolwall.svg)](https://github.com/Dev-Saif-Ops/toolwall/blob/main/toolwall/LICENSE)
28
28
  [![Zero dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen.svg)](https://github.com/Dev-Saif-Ops/toolwall)
29
29
 
30
- **A fail-closed firewall for AI agent tool calls.**
30
+ **The security gateway for AI agent tool calls.**
31
31
 
32
- Structured outputs already guarantee your agent's tool calls are *well-formed*.
33
- Nothing guarantees they're *allowed*. `toolwall` is the checkpoint that sits between
34
- the LLM's tool call and execution, and blocks the schema-valid-but-wrong ones.
32
+ Your LLM can generate a *valid* tool call. That doesn't mean it's *safe* to execute.
35
33
 
36
34
  ```python
37
35
  delete_records(filter={}) # perfectly valid JSON. whole table gone.
38
36
  send_email(to="attacker@evil.com") # recipient injected via a poisoned web page
39
- db_query(limit=10_000_000) # schema-valid. production melts.
37
+ transfer_money(amount=999999999) # schema-valid. every field the right type.
38
+ db_query(limit=10_000_000) # production melts.
40
39
  ```
41
40
 
42
- Every one of these passes JSON schema validation. Nothing in the platform stack stops
43
- them. `toolwall` does.
41
+ Every one of these passes JSON schema validation. Structured outputs, schemas, and
42
+ content moderation all wave them through. `toolwall` is the fail-closed checkpoint
43
+ between the LLM's tool call and execution that blocks them.
44
44
 
45
45
  ---
46
46
 
@@ -56,26 +56,45 @@ calling (and plain dicts). Python 3.10+.
56
56
  ## Quickstart
57
57
 
58
58
  ```python
59
- from toolwall import Gate, Meter, Policy, Shield, ToolSchema, in_range, not_empty
60
-
61
- gate = Gate(default="deny", meter=Meter(), shield=Shield(mode="block"))
62
-
63
- gate.register(
64
- "db_query", db_query,
65
- schema=ToolSchema(required=["q"], types={"q": str, "limit": int}),
66
- policy=Policy(constraints={"limit": in_range(1, 100)}),
67
- )
68
- gate.register(
69
- "delete_records", delete_records,
70
- schema=ToolSchema(required=["filter"], types={"filter": dict}),
71
- policy=Policy(constraints={"filter": not_empty}, require_approval=True),
72
- )
73
- gate.budget(max_calls=20)
74
-
75
- result = gate.run(openai_response) # any provider shape, or a plain dict
76
- # result.verdict: ALLOW | BLOCK | NEEDS_APPROVAL. Only ALLOW executes.
59
+ from toolwall import ToolWall, Policy, ToolSchema, in_range, not_empty
60
+
61
+ wall = ToolWall() # default-deny, secret detection + audit on
62
+
63
+ wall.register("db_query", db_query,
64
+ schema=ToolSchema(required=["q"], types={"q": str, "limit": int}),
65
+ policy=Policy(constraints={"limit": in_range(1, 100)}))
66
+ wall.register("delete_records", delete_records,
67
+ schema=ToolSchema(required=["filter"], types={"filter": dict}),
68
+ policy=Policy(constraints={"filter": not_empty}, require_approval=True))
69
+ wall.budget(max_calls=20)
70
+
71
+ result = wall.call("delete_records", {"filter": {}})
72
+ # result.blocked -> True
73
+ # result.reason -> "policy violation: arg 'filter' rejected by not_empty"
74
+
75
+ results = wall.guard(openai_response) # or gate a raw OpenAI/Anthropic/Gemini response
77
76
  ```
78
77
 
78
+ Only an `ALLOW` verdict runs the tool. `Gate` is the lower-level primitive underneath.
79
+
80
+ ## What it stops
81
+
82
+ ```python
83
+ wall.call("delete_records", {"filter": {}})
84
+ # BLOCKED: policy violation: arg 'filter' rejected by not_empty
85
+
86
+ wall.call("send_email", {"to": "ops@ourco.com", "body": "aws key AKIA..."})
87
+ # BLOCKED: secret detected (aws-access-key) in arg 'body'
88
+
89
+ wall.call("db_query", {"q": "everything", "limit": 10_000_000})
90
+ # BLOCKED: policy violation: arg 'limit' rejected by in_range(1, 100)
91
+
92
+ wall.call("run_shell", {"cmd": "..."})
93
+ # BLOCKED: unknown tool: 'run_shell'
94
+ ```
95
+
96
+ Everything not explicitly allowed is blocked. That is the whole idea.
97
+
79
98
  ## What it does
80
99
 
81
100
  - **Fail-closed gate**: unknown tool, schema violation, policy violation, budget hit,
@@ -5,20 +5,20 @@
5
5
  [![License: MIT](https://img.shields.io/pypi/l/toolwall.svg)](https://github.com/Dev-Saif-Ops/toolwall/blob/main/toolwall/LICENSE)
6
6
  [![Zero dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen.svg)](https://github.com/Dev-Saif-Ops/toolwall)
7
7
 
8
- **A fail-closed firewall for AI agent tool calls.**
8
+ **The security gateway for AI agent tool calls.**
9
9
 
10
- Structured outputs already guarantee your agent's tool calls are *well-formed*.
11
- Nothing guarantees they're *allowed*. `toolwall` is the checkpoint that sits between
12
- the LLM's tool call and execution, and blocks the schema-valid-but-wrong ones.
10
+ Your LLM can generate a *valid* tool call. That doesn't mean it's *safe* to execute.
13
11
 
14
12
  ```python
15
13
  delete_records(filter={}) # perfectly valid JSON. whole table gone.
16
14
  send_email(to="attacker@evil.com") # recipient injected via a poisoned web page
17
- db_query(limit=10_000_000) # schema-valid. production melts.
15
+ transfer_money(amount=999999999) # schema-valid. every field the right type.
16
+ db_query(limit=10_000_000) # production melts.
18
17
  ```
19
18
 
20
- Every one of these passes JSON schema validation. Nothing in the platform stack stops
21
- them. `toolwall` does.
19
+ Every one of these passes JSON schema validation. Structured outputs, schemas, and
20
+ content moderation all wave them through. `toolwall` is the fail-closed checkpoint
21
+ between the LLM's tool call and execution that blocks them.
22
22
 
23
23
  ---
24
24
 
@@ -34,26 +34,45 @@ calling (and plain dicts). Python 3.10+.
34
34
  ## Quickstart
35
35
 
36
36
  ```python
37
- from toolwall import Gate, Meter, Policy, Shield, ToolSchema, in_range, not_empty
38
-
39
- gate = Gate(default="deny", meter=Meter(), shield=Shield(mode="block"))
40
-
41
- gate.register(
42
- "db_query", db_query,
43
- schema=ToolSchema(required=["q"], types={"q": str, "limit": int}),
44
- policy=Policy(constraints={"limit": in_range(1, 100)}),
45
- )
46
- gate.register(
47
- "delete_records", delete_records,
48
- schema=ToolSchema(required=["filter"], types={"filter": dict}),
49
- policy=Policy(constraints={"filter": not_empty}, require_approval=True),
50
- )
51
- gate.budget(max_calls=20)
52
-
53
- result = gate.run(openai_response) # any provider shape, or a plain dict
54
- # result.verdict: ALLOW | BLOCK | NEEDS_APPROVAL. Only ALLOW executes.
37
+ from toolwall import ToolWall, Policy, ToolSchema, in_range, not_empty
38
+
39
+ wall = ToolWall() # default-deny, secret detection + audit on
40
+
41
+ wall.register("db_query", db_query,
42
+ schema=ToolSchema(required=["q"], types={"q": str, "limit": int}),
43
+ policy=Policy(constraints={"limit": in_range(1, 100)}))
44
+ wall.register("delete_records", delete_records,
45
+ schema=ToolSchema(required=["filter"], types={"filter": dict}),
46
+ policy=Policy(constraints={"filter": not_empty}, require_approval=True))
47
+ wall.budget(max_calls=20)
48
+
49
+ result = wall.call("delete_records", {"filter": {}})
50
+ # result.blocked -> True
51
+ # result.reason -> "policy violation: arg 'filter' rejected by not_empty"
52
+
53
+ results = wall.guard(openai_response) # or gate a raw OpenAI/Anthropic/Gemini response
55
54
  ```
56
55
 
56
+ Only an `ALLOW` verdict runs the tool. `Gate` is the lower-level primitive underneath.
57
+
58
+ ## What it stops
59
+
60
+ ```python
61
+ wall.call("delete_records", {"filter": {}})
62
+ # BLOCKED: policy violation: arg 'filter' rejected by not_empty
63
+
64
+ wall.call("send_email", {"to": "ops@ourco.com", "body": "aws key AKIA..."})
65
+ # BLOCKED: secret detected (aws-access-key) in arg 'body'
66
+
67
+ wall.call("db_query", {"q": "everything", "limit": 10_000_000})
68
+ # BLOCKED: policy violation: arg 'limit' rejected by in_range(1, 100)
69
+
70
+ wall.call("run_shell", {"cmd": "..."})
71
+ # BLOCKED: unknown tool: 'run_shell'
72
+ ```
73
+
74
+ Everything not explicitly allowed is blocked. That is the whole idea.
75
+
57
76
  ## What it does
58
77
 
59
78
  - **Fail-closed gate**: unknown tool, schema violation, policy violation, budget hit,
@@ -6,13 +6,18 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "toolwall"
9
- version = "0.2.2"
10
- description = "Fail-closed firewall for AI agent tool calls: schema + policy gate before any tool executes"
9
+ version = "0.3.0"
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" }
13
13
  requires-python = ">=3.10"
14
14
  authors = [{ name = "Mohammad Safwan Athar" }]
15
- keywords = ["ai", "agents", "llm", "tool-calling", "guardrails", "security", "mcp"]
15
+ keywords = [
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",
20
+ ]
16
21
  classifiers = [
17
22
  "Development Status :: 3 - Alpha",
18
23
  "Intended Audience :: Developers",
@@ -5,6 +5,7 @@ toolwall guarantees they're allowed.
5
5
  """
6
6
 
7
7
  from toolwall.gate import Gate, GateResult, ToolRegistry, Verdict
8
+ from toolwall.wall import ToolWall
8
9
  from toolwall.intake import IntakeError, ToolCall, parse_tool_calls
9
10
  from toolwall.meter import Meter, RunEvent, RunReport, extract_usage
10
11
  from toolwall.policy import (
@@ -22,9 +23,10 @@ from toolwall.shield import Finding, Shield
22
23
  from toolwall.suggest import suggest_policies
23
24
  from toolwall.mcp_guard import GuardedCall, MCPGuard, to_mcp_error
24
25
 
25
- __version__ = "0.2.2"
26
+ __version__ = "0.3.0"
26
27
 
27
28
  __all__ = [
29
+ "ToolWall",
28
30
  "Gate",
29
31
  "GateResult",
30
32
  "Verdict",
@@ -91,6 +91,19 @@ class GateResult:
91
91
  def allowed(self) -> bool:
92
92
  return self.verdict is Verdict.ALLOW
93
93
 
94
+ @property
95
+ def blocked(self) -> bool:
96
+ return self.verdict is Verdict.BLOCK
97
+
98
+ @property
99
+ def needs_approval(self) -> bool:
100
+ return self.verdict is Verdict.NEEDS_APPROVAL
101
+
102
+ @property
103
+ def reason(self) -> str | None:
104
+ """First human-readable reason for a block/hold, or None when allowed."""
105
+ return self.reasons[0] if self.reasons else None
106
+
94
107
 
95
108
  class Gate:
96
109
  """Fail-closed gate.
@@ -0,0 +1,122 @@
1
+ """ToolWall: the one-line entry point.
2
+
3
+ `Gate` is the low-level primitive (verdicts, execution split, meter wiring).
4
+ `ToolWall` is the ergonomic facade most users want: it wires a Gate with a
5
+ Shield and a Meter already attached, and gives you `register` + `call`.
6
+
7
+ from toolwall import ToolWall, Policy, ToolSchema, not_empty
8
+
9
+ wall = ToolWall() # default-deny, secret detection + audit on
10
+ wall.register("get_user", get_user, schema=ToolSchema(required=["id"]))
11
+ wall.register("delete_user", delete_user, policy=Policy(require_approval=True))
12
+
13
+ result = wall.call("get_user", {"id": "123"}) # ALLOW -> runs
14
+ result = wall.call("delete_user", {"id": "123"}) # held for approval
15
+ if result.blocked:
16
+ print(result.reason)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable
22
+ from typing import Any
23
+
24
+ from toolwall.gate import ApprovalHandler, DefaultMode, Gate, GateResult
25
+ from toolwall.meter import Meter
26
+ from toolwall.policy import Policy
27
+ from toolwall.schema import ToolSchema
28
+ from toolwall.shield import Shield
29
+
30
+ ToolFn = Callable[..., Any]
31
+
32
+
33
+ class ToolWall:
34
+ """Fail-closed security gateway for AI agent tool calls.
35
+
36
+ Args:
37
+ default: "deny" (registered tools still need a schema) or "allow".
38
+ detect_secrets: attach a Shield. True blocks secrets in tool args;
39
+ pass redact=True to replace them with placeholders instead.
40
+ redact: if detecting secrets, redact instead of block.
41
+ audit: attach a Meter so every verdict is recorded and exportable.
42
+ approval: handler called when a tool needs approval; return True to run.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ default: DefaultMode = "deny",
49
+ detect_secrets: bool = True,
50
+ redact: bool = False,
51
+ audit: bool = True,
52
+ approval: ApprovalHandler | None = None,
53
+ ):
54
+ shield = Shield(mode="redact" if redact else "block") if detect_secrets else None
55
+ meter = Meter() if audit else None
56
+ self.gate = Gate(default=default, meter=meter, shield=shield, approval=approval)
57
+
58
+ # -- setup (chainable) ---------------------------------------------------
59
+
60
+ def register(
61
+ self,
62
+ name: str,
63
+ fn: ToolFn,
64
+ *,
65
+ schema: ToolSchema | None = None,
66
+ policy: Policy | None = None,
67
+ infer_schema: bool = False,
68
+ ) -> "ToolWall":
69
+ self.gate.register(name, fn, schema, policy=policy, infer_schema=infer_schema)
70
+ return self
71
+
72
+ def budget(
73
+ self,
74
+ *,
75
+ max_calls: int | None = None,
76
+ max_calls_per_tool: int | None = None,
77
+ max_usd: float | None = None,
78
+ ) -> "ToolWall":
79
+ self.gate.budget(
80
+ max_calls=max_calls, max_calls_per_tool=max_calls_per_tool, max_usd=max_usd
81
+ )
82
+ return self
83
+
84
+ def approve_with(self, handler: ApprovalHandler) -> "ToolWall":
85
+ self.gate.approval = handler
86
+ return self
87
+
88
+ # -- use -----------------------------------------------------------------
89
+
90
+ def call(self, name: str, args: dict[str, Any] | None = None) -> GateResult:
91
+ """Check the call and execute it only if allowed."""
92
+ return self.gate.run({"name": name, "args": args or {}})
93
+
94
+ def check(self, name: str, args: dict[str, Any] | None = None) -> GateResult:
95
+ """Return the verdict without executing anything."""
96
+ return self.gate.check({"name": name, "args": args or {}})
97
+
98
+ def guard(self, response: Any) -> list[GateResult]:
99
+ """Gate a raw LLM response (OpenAI/Anthropic/Gemini), possibly many calls."""
100
+ return self.gate.run_all(response)
101
+
102
+ # -- dry-run + reporting -------------------------------------------------
103
+
104
+ @property
105
+ def dry_run(self) -> bool:
106
+ return self.gate.dry_run
107
+
108
+ @dry_run.setter
109
+ def dry_run(self, value: bool) -> None:
110
+ self.gate.dry_run = value
111
+
112
+ def report(self) -> dict[str, Any]:
113
+ return self.gate.report()
114
+
115
+ def export(self, json_path: str, csv_path: str | None = None) -> dict[str, Any]:
116
+ if self.gate.meter is None:
117
+ raise ValueError("no audit meter attached (ToolWall was built with audit=False)")
118
+ return self.gate.meter.export(json_path, csv_path)
119
+
120
+ @property
121
+ def meter(self) -> Meter | None:
122
+ return self.gate.meter
File without changes
File without changes
File without changes
File without changes