toolwall 0.2.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.
- toolwall-0.2.0/.gitignore +43 -0
- toolwall-0.2.0/LICENSE +21 -0
- toolwall-0.2.0/PKG-INFO +41 -0
- toolwall-0.2.0/README.md +19 -0
- toolwall-0.2.0/pyproject.toml +44 -0
- toolwall-0.2.0/src/toolwall/__init__.py +56 -0
- toolwall-0.2.0/src/toolwall/cli.py +44 -0
- toolwall-0.2.0/src/toolwall/gate.py +381 -0
- toolwall-0.2.0/src/toolwall/intake.py +177 -0
- toolwall-0.2.0/src/toolwall/mcp_guard.py +100 -0
- toolwall-0.2.0/src/toolwall/meter.py +305 -0
- toolwall-0.2.0/src/toolwall/policy.py +115 -0
- toolwall-0.2.0/src/toolwall/schema.py +56 -0
- toolwall-0.2.0/src/toolwall/shield.py +187 -0
- toolwall-0.2.0/src/toolwall/suggest.py +96 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
.env
|
|
3
|
+
.env.local
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
.pytest_cache/
|
|
7
|
+
.coverage
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
dist/
|
|
14
|
+
build/
|
|
15
|
+
|
|
16
|
+
# Benchmark results (keep structure, ignore data)
|
|
17
|
+
toap-bench/results/*.json
|
|
18
|
+
toap-bench/results/*.csv
|
|
19
|
+
!toap-bench/results/.gitkeep
|
|
20
|
+
|
|
21
|
+
# Local working docs / pitch (not published to GitHub)
|
|
22
|
+
memory.md
|
|
23
|
+
agents.md
|
|
24
|
+
AGENTS.md
|
|
25
|
+
decisions.md
|
|
26
|
+
prd.md
|
|
27
|
+
plan.md
|
|
28
|
+
SHARE.md
|
|
29
|
+
OUTREACH.md
|
|
30
|
+
EXECUTION_FLOW.md
|
|
31
|
+
PARTNER_INSERT.md
|
|
32
|
+
critiques/
|
|
33
|
+
toap_architecture_strategy.pdf
|
|
34
|
+
toap_executive_pitch.pdf
|
|
35
|
+
|
|
36
|
+
# IDE
|
|
37
|
+
.idea/
|
|
38
|
+
.vscode/
|
|
39
|
+
*.swp
|
|
40
|
+
|
|
41
|
+
# OS
|
|
42
|
+
.DS_Store
|
|
43
|
+
Thumbs.db
|
toolwall-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammad Safwan Athar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
toolwall-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: toolwall
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Fail-closed firewall for AI agent tool calls: schema + policy gate before any tool executes
|
|
5
|
+
Project-URL: Homepage, https://github.com/Dev-Saif-Ops/Project_TOAP
|
|
6
|
+
Author: Mohammad Safwan Athar
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: agents,ai,guardrails,llm,mcp,security,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
|
+
# toolwall (package)
|
|
24
|
+
|
|
25
|
+
Fail-closed firewall for AI agent tool calls. See the [repo README](../README.md) for the full story.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e .
|
|
29
|
+
python examples/quickstart.py # no API key needed
|
|
30
|
+
pytest # run the suite
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Modules
|
|
34
|
+
|
|
35
|
+
| Module | Role |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `intake.py` | Normalize OpenAI / Anthropic / Gemini / plain-dict tool calls |
|
|
38
|
+
| `gate.py` | Fail-closed check → verdict → (optional) execute |
|
|
39
|
+
| `schema.py` | Required args + type validation before `tool(**args)` |
|
|
40
|
+
| `meter.py` | Audit events, token/cost accounting, JSON/CSV export |
|
|
41
|
+
| `cli.py` | `toolwall report <audit.json>` |
|
toolwall-0.2.0/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# toolwall (package)
|
|
2
|
+
|
|
3
|
+
Fail-closed firewall for AI agent tool calls. See the [repo README](../README.md) for the full story.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install -e .
|
|
7
|
+
python examples/quickstart.py # no API key needed
|
|
8
|
+
pytest # run the suite
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Modules
|
|
12
|
+
|
|
13
|
+
| Module | Role |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `intake.py` | Normalize OpenAI / Anthropic / Gemini / plain-dict tool calls |
|
|
16
|
+
| `gate.py` | Fail-closed check → verdict → (optional) execute |
|
|
17
|
+
| `schema.py` | Required args + type validation before `tool(**args)` |
|
|
18
|
+
| `meter.py` | Audit events, token/cost accounting, JSON/CSV export |
|
|
19
|
+
| `cli.py` | `toolwall report <audit.json>` |
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# Pin hatchling below the release that emits Metadata-Version 2.4/2.5.
|
|
3
|
+
# PyPI's upload endpoint accepts up to 2.3; newer metadata is rejected with a 400.
|
|
4
|
+
requires = ["hatchling<1.27"]
|
|
5
|
+
build-backend = "hatchling.build"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "toolwall"
|
|
9
|
+
version = "0.2.0"
|
|
10
|
+
description = "Fail-closed firewall for AI agent tool calls: schema + policy gate before any tool executes"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
authors = [{ name = "Mohammad Safwan Athar" }]
|
|
15
|
+
keywords = ["ai", "agents", "llm", "tool-calling", "guardrails", "security", "mcp"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
mcp = ["mcp>=1.0.0"]
|
|
28
|
+
dev = ["pytest>=8.0", "pytest-cov>=4.0"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
toolwall = "toolwall.cli:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/Dev-Saif-Ops/Project_TOAP"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/toolwall"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src/toolwall", "README.md", "LICENSE"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""toolwall: fail-closed firewall for AI agent tool calls.
|
|
2
|
+
|
|
3
|
+
Structured outputs guarantee your agent's tool calls are well-formed.
|
|
4
|
+
toolwall guarantees they're allowed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from toolwall.gate import Gate, GateResult, ToolRegistry, Verdict
|
|
8
|
+
from toolwall.intake import IntakeError, ToolCall, parse_tool_calls
|
|
9
|
+
from toolwall.meter import Meter, RunEvent, RunReport, extract_usage
|
|
10
|
+
from toolwall.policy import (
|
|
11
|
+
Policy,
|
|
12
|
+
ends_with,
|
|
13
|
+
in_range,
|
|
14
|
+
matches,
|
|
15
|
+
max_len,
|
|
16
|
+
not_empty,
|
|
17
|
+
one_of,
|
|
18
|
+
starts_with,
|
|
19
|
+
)
|
|
20
|
+
from toolwall.schema import ToolSchema, schema_from_signature
|
|
21
|
+
from toolwall.shield import Finding, Shield
|
|
22
|
+
from toolwall.suggest import suggest_policies
|
|
23
|
+
from toolwall.mcp_guard import GuardedCall, MCPGuard, to_mcp_error
|
|
24
|
+
|
|
25
|
+
__version__ = "0.2.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Gate",
|
|
29
|
+
"GateResult",
|
|
30
|
+
"Verdict",
|
|
31
|
+
"ToolRegistry",
|
|
32
|
+
"ToolCall",
|
|
33
|
+
"IntakeError",
|
|
34
|
+
"parse_tool_calls",
|
|
35
|
+
"Meter",
|
|
36
|
+
"RunEvent",
|
|
37
|
+
"RunReport",
|
|
38
|
+
"extract_usage",
|
|
39
|
+
"Policy",
|
|
40
|
+
"in_range",
|
|
41
|
+
"one_of",
|
|
42
|
+
"matches",
|
|
43
|
+
"max_len",
|
|
44
|
+
"ends_with",
|
|
45
|
+
"starts_with",
|
|
46
|
+
"not_empty",
|
|
47
|
+
"ToolSchema",
|
|
48
|
+
"schema_from_signature",
|
|
49
|
+
"Shield",
|
|
50
|
+
"Finding",
|
|
51
|
+
"suggest_policies",
|
|
52
|
+
"MCPGuard",
|
|
53
|
+
"GuardedCall",
|
|
54
|
+
"to_mcp_error",
|
|
55
|
+
"__version__",
|
|
56
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""toolwall CLI — inspect meter/audit exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
12
|
+
data = json.loads(Path(args.file).read_text(encoding="utf-8"))
|
|
13
|
+
summary = data.get("summary") or {}
|
|
14
|
+
print(f"Model: {data.get('model', 'unknown')}")
|
|
15
|
+
print(f"Events: {summary.get('event_count', len(data.get('events', [])))}")
|
|
16
|
+
for lane, stats in (summary.get("lanes") or {}).items():
|
|
17
|
+
print(f"\n[{lane}]")
|
|
18
|
+
for key in (
|
|
19
|
+
"prompt_tokens",
|
|
20
|
+
"completion_tokens",
|
|
21
|
+
"total_tokens",
|
|
22
|
+
"estimated_cost_usd",
|
|
23
|
+
"intercept_success_rate",
|
|
24
|
+
):
|
|
25
|
+
print(f" {key}: {stats.get(key)}")
|
|
26
|
+
if summary.get("note"):
|
|
27
|
+
print(f"\nNote: {summary['note']}")
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
ap = argparse.ArgumentParser(prog="toolwall", description="toolwall dev tools")
|
|
33
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
34
|
+
|
|
35
|
+
report = sub.add_parser("report", help="Print summary from a Meter JSON export")
|
|
36
|
+
report.add_argument("file", help="Path to meter/audit JSON")
|
|
37
|
+
report.set_defaults(func=cmd_report)
|
|
38
|
+
|
|
39
|
+
args = ap.parse_args()
|
|
40
|
+
sys.exit(args.func(args))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""toolwall: fail-closed checkpoint between LLM tool calls and execution.
|
|
2
|
+
|
|
3
|
+
gate = Gate(default="deny", meter=meter, shield=Shield(mode="block"))
|
|
4
|
+
gate.register("db_query", db_query,
|
|
5
|
+
schema=ToolSchema(required=["q"]),
|
|
6
|
+
policy=Policy(constraints={"limit": in_range(1, 100)}))
|
|
7
|
+
gate.budget(max_calls=20)
|
|
8
|
+
result = gate.run(openai_response) # check + execute only if allowed
|
|
9
|
+
|
|
10
|
+
Check order (first failure blocks, fail-closed):
|
|
11
|
+
intake -> known tool -> budget -> schema -> policy -> shield -> approval
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
from toolwall.intake import IntakeError, ToolCall, parse_tool_calls
|
|
22
|
+
from toolwall.meter import Meter, RunEvent
|
|
23
|
+
from toolwall.policy import Policy
|
|
24
|
+
from toolwall.schema import ToolSchema, schema_from_signature
|
|
25
|
+
from toolwall.shield import Finding, Shield
|
|
26
|
+
|
|
27
|
+
ToolFn = Callable[..., Any]
|
|
28
|
+
DefaultMode = Literal["deny", "allow"]
|
|
29
|
+
ApprovalHandler = Callable[["GateResult"], bool]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Verdict(str, Enum):
|
|
33
|
+
ALLOW = "allow"
|
|
34
|
+
BLOCK = "block"
|
|
35
|
+
NEEDS_APPROVAL = "needs_approval"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ToolRegistry:
|
|
40
|
+
"""Maps tool names to callables (+ optional schema and policy).
|
|
41
|
+
|
|
42
|
+
Registration is the allowlist: unregistered tools always block.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_tools: dict[str, ToolFn] = field(default_factory=dict)
|
|
46
|
+
_schemas: dict[str, ToolSchema] = field(default_factory=dict)
|
|
47
|
+
_policies: dict[str, Policy] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def register(
|
|
50
|
+
self,
|
|
51
|
+
name: str,
|
|
52
|
+
fn: ToolFn,
|
|
53
|
+
schema: ToolSchema | None = None,
|
|
54
|
+
*,
|
|
55
|
+
policy: Policy | None = None,
|
|
56
|
+
infer_schema: bool = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._tools[name] = fn
|
|
59
|
+
if schema is not None:
|
|
60
|
+
self._schemas[name] = schema
|
|
61
|
+
elif infer_schema:
|
|
62
|
+
self._schemas[name] = schema_from_signature(fn)
|
|
63
|
+
if policy is not None:
|
|
64
|
+
self._policies[name] = policy
|
|
65
|
+
|
|
66
|
+
def get(self, name: str) -> ToolFn | None:
|
|
67
|
+
return self._tools.get(name)
|
|
68
|
+
|
|
69
|
+
def get_schema(self, name: str) -> ToolSchema | None:
|
|
70
|
+
return self._schemas.get(name)
|
|
71
|
+
|
|
72
|
+
def get_policy(self, name: str) -> Policy | None:
|
|
73
|
+
return self._policies.get(name)
|
|
74
|
+
|
|
75
|
+
def names(self) -> list[str]:
|
|
76
|
+
return list(self._tools.keys())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class GateResult:
|
|
81
|
+
verdict: Verdict
|
|
82
|
+
call: ToolCall | None = None
|
|
83
|
+
reasons: list[str] = field(default_factory=list)
|
|
84
|
+
executed: bool = False
|
|
85
|
+
return_value: Any = None
|
|
86
|
+
error: str | None = None
|
|
87
|
+
findings: list[Finding] = field(default_factory=list) # shield hits (value-free)
|
|
88
|
+
dry_run: bool = False # True when the gate simulated execution
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def allowed(self) -> bool:
|
|
92
|
+
return self.verdict is Verdict.ALLOW
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Gate:
|
|
96
|
+
"""Fail-closed gate.
|
|
97
|
+
|
|
98
|
+
default="deny" -> a registered tool with NO schema is still blocked
|
|
99
|
+
default="allow" -> schema optional; registered tool without schema passes
|
|
100
|
+
|
|
101
|
+
approval: optional handler called by run()/run_all() when a policy demands
|
|
102
|
+
approval. Returns True to execute. Without a handler, NEEDS_APPROVAL calls
|
|
103
|
+
are never executed (fail closed).
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
registry: ToolRegistry | None = None,
|
|
109
|
+
*,
|
|
110
|
+
default: DefaultMode = "deny",
|
|
111
|
+
meter: Meter | None = None,
|
|
112
|
+
shield: Shield | None = None,
|
|
113
|
+
approval: ApprovalHandler | None = None,
|
|
114
|
+
dry_run: bool = False,
|
|
115
|
+
lane: str = "gate",
|
|
116
|
+
):
|
|
117
|
+
if default not in ("deny", "allow"):
|
|
118
|
+
raise ValueError(f"default must be 'deny' or 'allow', got {default!r}")
|
|
119
|
+
self.registry = registry or ToolRegistry()
|
|
120
|
+
self.default = default
|
|
121
|
+
self.meter = meter
|
|
122
|
+
self.shield = shield
|
|
123
|
+
self.approval = approval
|
|
124
|
+
self.dry_run = dry_run
|
|
125
|
+
self.lane = lane
|
|
126
|
+
self.history: list[GateResult] = [] # every checked result, for reports/suggest
|
|
127
|
+
self._budget: dict[str, Any] = {}
|
|
128
|
+
self._executed_calls = 0
|
|
129
|
+
self._executed_per_tool: dict[str, int] = {}
|
|
130
|
+
|
|
131
|
+
def register(
|
|
132
|
+
self,
|
|
133
|
+
name: str,
|
|
134
|
+
fn: ToolFn,
|
|
135
|
+
schema: ToolSchema | None = None,
|
|
136
|
+
*,
|
|
137
|
+
policy: Policy | None = None,
|
|
138
|
+
infer_schema: bool = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
self.registry.register(name, fn, schema, policy=policy, infer_schema=infer_schema)
|
|
141
|
+
|
|
142
|
+
# -- budget ---------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def budget(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
max_calls: int | None = None,
|
|
148
|
+
max_calls_per_tool: int | None = None,
|
|
149
|
+
max_usd: float | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
if max_usd is not None and self.meter is None:
|
|
152
|
+
raise ValueError("max_usd budget requires a meter (cost is meter-derived)")
|
|
153
|
+
self._budget = {
|
|
154
|
+
"max_calls": max_calls,
|
|
155
|
+
"max_calls_per_tool": max_calls_per_tool,
|
|
156
|
+
"max_usd": max_usd,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def _budget_violation(self, call: ToolCall) -> str | None:
|
|
160
|
+
if not self._budget:
|
|
161
|
+
return None
|
|
162
|
+
max_calls = self._budget.get("max_calls")
|
|
163
|
+
if max_calls is not None and self._executed_calls >= max_calls:
|
|
164
|
+
return f"budget exceeded: max_calls={max_calls} already executed"
|
|
165
|
+
per_tool = self._budget.get("max_calls_per_tool")
|
|
166
|
+
if per_tool is not None and self._executed_per_tool.get(call.name, 0) >= per_tool:
|
|
167
|
+
return f"budget exceeded: max_calls_per_tool={per_tool} for {call.name!r}"
|
|
168
|
+
max_usd = self._budget.get("max_usd")
|
|
169
|
+
if max_usd is not None and self.meter is not None:
|
|
170
|
+
spent = self.meter.report.estimate_cost_usd(self.lane)
|
|
171
|
+
if spent >= max_usd:
|
|
172
|
+
return f"budget exceeded: estimated ${spent:.4f} >= max_usd={max_usd}"
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
# -- checking ---------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def check_all(self, payload: Any) -> list[GateResult]:
|
|
178
|
+
try:
|
|
179
|
+
calls = parse_tool_calls(payload)
|
|
180
|
+
except IntakeError as exc:
|
|
181
|
+
return [self._record(GateResult(Verdict.BLOCK, reasons=[f"intake: {exc}"]))]
|
|
182
|
+
if not calls:
|
|
183
|
+
return [self._record(GateResult(Verdict.BLOCK, reasons=["intake: no tool calls in payload"]))]
|
|
184
|
+
return [self._check_one(call) for call in calls]
|
|
185
|
+
|
|
186
|
+
def check(self, payload: Any) -> GateResult:
|
|
187
|
+
results = self.check_all(payload)
|
|
188
|
+
if len(results) != 1:
|
|
189
|
+
raise ValueError(f"expected exactly 1 tool call, got {len(results)}; use check_all()")
|
|
190
|
+
return results[0]
|
|
191
|
+
|
|
192
|
+
def _check_one(self, call: ToolCall) -> GateResult:
|
|
193
|
+
if self.registry.get(call.name) is None:
|
|
194
|
+
return self._record(GateResult(Verdict.BLOCK, call, [f"unknown tool: {call.name!r}"]))
|
|
195
|
+
|
|
196
|
+
budget_reason = self._budget_violation(call)
|
|
197
|
+
if budget_reason is not None:
|
|
198
|
+
return self._record(GateResult(Verdict.BLOCK, call, [budget_reason]))
|
|
199
|
+
|
|
200
|
+
schema = self.registry.get_schema(call.name)
|
|
201
|
+
if schema is None:
|
|
202
|
+
if self.default == "deny":
|
|
203
|
+
return self._record(
|
|
204
|
+
GateResult(
|
|
205
|
+
Verdict.BLOCK,
|
|
206
|
+
call,
|
|
207
|
+
[f"no schema registered for {call.name!r} and gate default is deny"],
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
errors = schema.validate(call.args)
|
|
212
|
+
if errors:
|
|
213
|
+
return self._record(GateResult(Verdict.BLOCK, call, errors))
|
|
214
|
+
|
|
215
|
+
policy = self.registry.get_policy(call.name)
|
|
216
|
+
if policy is not None:
|
|
217
|
+
errors = policy.validate(call.args)
|
|
218
|
+
if errors:
|
|
219
|
+
return self._record(GateResult(Verdict.BLOCK, call, errors))
|
|
220
|
+
|
|
221
|
+
findings: list[Finding] = []
|
|
222
|
+
if self.shield is not None:
|
|
223
|
+
if self.shield.mode == "redact":
|
|
224
|
+
call.args, findings = self.shield.redact_args(call.args)
|
|
225
|
+
else:
|
|
226
|
+
findings = self.shield.scan_args(call.args)
|
|
227
|
+
if findings and self.shield.mode == "block":
|
|
228
|
+
reasons = [
|
|
229
|
+
f"secret detected ({f.kind}) in arg {f.arg!r}" for f in findings
|
|
230
|
+
]
|
|
231
|
+
return self._record(
|
|
232
|
+
GateResult(Verdict.BLOCK, call, reasons, findings=findings)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if policy is not None and policy.require_approval:
|
|
236
|
+
return self._record(
|
|
237
|
+
GateResult(
|
|
238
|
+
Verdict.NEEDS_APPROVAL,
|
|
239
|
+
call,
|
|
240
|
+
[f"approval required for {call.name!r}"],
|
|
241
|
+
findings=findings,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return self._record(GateResult(Verdict.ALLOW, call, findings=findings))
|
|
246
|
+
|
|
247
|
+
# -- execution ----------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def execute(self, result: GateResult) -> GateResult:
|
|
250
|
+
if not result.allowed or result.call is None:
|
|
251
|
+
joined = "; ".join(result.reasons) or "no call"
|
|
252
|
+
raise PermissionError(f"refusing to execute a {result.verdict.value} result: {joined}")
|
|
253
|
+
self._executed_calls += 1
|
|
254
|
+
self._executed_per_tool[result.call.name] = (
|
|
255
|
+
self._executed_per_tool.get(result.call.name, 0) + 1
|
|
256
|
+
)
|
|
257
|
+
if self.dry_run:
|
|
258
|
+
result.dry_run = True
|
|
259
|
+
if self.meter is not None:
|
|
260
|
+
self.meter.record(
|
|
261
|
+
RunEvent(
|
|
262
|
+
lane=self.lane,
|
|
263
|
+
kind="tool",
|
|
264
|
+
ok=True,
|
|
265
|
+
namespace=result.call.name,
|
|
266
|
+
meta={"dry_run": True, "would_execute": True},
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
return result
|
|
270
|
+
tool = self.registry.get(result.call.name)
|
|
271
|
+
if tool is None: # registry mutated between check and execute
|
|
272
|
+
result.error = f"unknown tool at execute time: {result.call.name!r}"
|
|
273
|
+
else:
|
|
274
|
+
try:
|
|
275
|
+
result.return_value = tool(**result.call.args)
|
|
276
|
+
result.executed = True
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
result.error = f"{type(exc).__name__}: {exc}"
|
|
279
|
+
if self.meter is not None:
|
|
280
|
+
self.meter.record(
|
|
281
|
+
RunEvent(
|
|
282
|
+
lane=self.lane,
|
|
283
|
+
kind="tool",
|
|
284
|
+
ok=result.executed,
|
|
285
|
+
namespace=result.call.name,
|
|
286
|
+
error=result.error,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
def _resolve_approval(self, result: GateResult) -> GateResult:
|
|
292
|
+
"""Called by run/run_all on NEEDS_APPROVAL. Fail closed without a handler."""
|
|
293
|
+
if self.approval is None:
|
|
294
|
+
return result
|
|
295
|
+
try:
|
|
296
|
+
granted = bool(self.approval(result))
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
result.verdict = Verdict.BLOCK
|
|
299
|
+
result.reasons.append(f"approval handler raised {type(exc).__name__}: fails closed")
|
|
300
|
+
return self._record_transition(result, "approval-error")
|
|
301
|
+
if granted:
|
|
302
|
+
result.verdict = Verdict.ALLOW
|
|
303
|
+
result.reasons = []
|
|
304
|
+
return self._record_transition(result, "approval-granted")
|
|
305
|
+
result.verdict = Verdict.BLOCK
|
|
306
|
+
result.reasons.append("approval denied")
|
|
307
|
+
return self._record_transition(result, "approval-denied")
|
|
308
|
+
|
|
309
|
+
def run(self, payload: Any) -> GateResult:
|
|
310
|
+
result = self.check(payload)
|
|
311
|
+
if result.verdict is Verdict.NEEDS_APPROVAL:
|
|
312
|
+
result = self._resolve_approval(result)
|
|
313
|
+
if result.allowed:
|
|
314
|
+
return self.execute(result)
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
def run_all(self, payload: Any) -> list[GateResult]:
|
|
318
|
+
out: list[GateResult] = []
|
|
319
|
+
for result in self.check_all(payload):
|
|
320
|
+
if result.verdict is Verdict.NEEDS_APPROVAL:
|
|
321
|
+
result = self._resolve_approval(result)
|
|
322
|
+
out.append(self.execute(result) if result.allowed else result)
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
# -- reporting ----------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def report(self) -> dict[str, Any]:
|
|
328
|
+
"""Summary of everything this gate has seen. Useful after a dry run."""
|
|
329
|
+
by_verdict: dict[str, int] = {}
|
|
330
|
+
by_tool: dict[str, dict[str, int]] = {}
|
|
331
|
+
blocked_reasons: list[str] = []
|
|
332
|
+
finding_kinds: dict[str, int] = {}
|
|
333
|
+
for r in self.history:
|
|
334
|
+
by_verdict[r.verdict.value] = by_verdict.get(r.verdict.value, 0) + 1
|
|
335
|
+
name = r.call.name if r.call else "(unparsed)"
|
|
336
|
+
tool_row = by_tool.setdefault(name, {"allow": 0, "block": 0, "needs_approval": 0})
|
|
337
|
+
tool_row[r.verdict.value] = tool_row.get(r.verdict.value, 0) + 1
|
|
338
|
+
if r.verdict is Verdict.BLOCK and r.reasons:
|
|
339
|
+
blocked_reasons.append(f"{name}: {r.reasons[0]}")
|
|
340
|
+
for f in r.findings:
|
|
341
|
+
finding_kinds[f.kind] = finding_kinds.get(f.kind, 0) + 1
|
|
342
|
+
return {
|
|
343
|
+
"dry_run": self.dry_run,
|
|
344
|
+
"calls_checked": len(self.history),
|
|
345
|
+
"verdicts": by_verdict,
|
|
346
|
+
"would_execute" if self.dry_run else "executed": self._executed_calls,
|
|
347
|
+
"by_tool": by_tool,
|
|
348
|
+
"blocked_reasons": blocked_reasons,
|
|
349
|
+
"secret_findings_by_kind": finding_kinds,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# -- internals -------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def _record(self, result: GateResult) -> GateResult:
|
|
355
|
+
self.history.append(result)
|
|
356
|
+
if self.meter is not None:
|
|
357
|
+
self.meter.record_intercept(
|
|
358
|
+
lane=self.lane,
|
|
359
|
+
ok=result.verdict is Verdict.ALLOW,
|
|
360
|
+
namespace=result.call.name if result.call else None,
|
|
361
|
+
error="; ".join(result.reasons) or None,
|
|
362
|
+
meta={
|
|
363
|
+
"verdict": result.verdict.value,
|
|
364
|
+
"default": self.default,
|
|
365
|
+
"findings": [f.kind for f in result.findings],
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
def _record_transition(self, result: GateResult, event: str) -> GateResult:
|
|
371
|
+
if self.meter is not None:
|
|
372
|
+
self.meter.record(
|
|
373
|
+
RunEvent(
|
|
374
|
+
lane=self.lane,
|
|
375
|
+
kind="note",
|
|
376
|
+
ok=result.verdict is Verdict.ALLOW,
|
|
377
|
+
namespace=result.call.name if result.call else None,
|
|
378
|
+
meta={"approval": event},
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
return result
|