toolwall 0.2.0__py3-none-any.whl
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/__init__.py +56 -0
- toolwall/cli.py +44 -0
- toolwall/gate.py +381 -0
- toolwall/intake.py +177 -0
- toolwall/mcp_guard.py +100 -0
- toolwall/meter.py +305 -0
- toolwall/policy.py +115 -0
- toolwall/schema.py +56 -0
- toolwall/shield.py +187 -0
- toolwall/suggest.py +96 -0
- toolwall-0.2.0.dist-info/METADATA +41 -0
- toolwall-0.2.0.dist-info/RECORD +15 -0
- toolwall-0.2.0.dist-info/WHEEL +4 -0
- toolwall-0.2.0.dist-info/entry_points.txt +2 -0
- toolwall-0.2.0.dist-info/licenses/LICENSE +21 -0
toolwall/__init__.py
ADDED
|
@@ -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
|
+
]
|
toolwall/cli.py
ADDED
|
@@ -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()
|
toolwall/gate.py
ADDED
|
@@ -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
|
toolwall/intake.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Normalize LLM provider tool-call outputs into ToolCall records.
|
|
2
|
+
|
|
3
|
+
Accepted shapes (dicts, or SDK objects exposing model_dump()/to_dict()):
|
|
4
|
+
|
|
5
|
+
- Plain call: {"name"|"tool"|"action": str, "args"|"params"|"arguments"|"input": {...}}
|
|
6
|
+
- OpenAI chat: choices[*].message.tool_calls[*].function{name, arguments(JSON str)}
|
|
7
|
+
- OpenAI responses: output[*] where type == "function_call" {name, arguments(JSON str)}
|
|
8
|
+
- Anthropic: content[*] where type == "tool_use" {name, input}
|
|
9
|
+
- Gemini: candidates[*].content.parts[*].functionCall|function_call {name, args}
|
|
10
|
+
|
|
11
|
+
No provider SDK is imported here — toolwall core stays stdlib-only.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IntakeError(ValueError):
|
|
22
|
+
"""Payload could not be interpreted as tool call(s)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ToolCall:
|
|
27
|
+
name: str
|
|
28
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
id: str | None = None
|
|
30
|
+
source: str = "dict"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_NAME_KEYS = ("name", "tool", "tool_name", "action", "namespace")
|
|
34
|
+
_ARG_KEYS = ("args", "params", "arguments", "parameters", "input", "inputs")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _as_data(obj: Any) -> Any:
|
|
38
|
+
if isinstance(obj, (dict, list)):
|
|
39
|
+
return obj
|
|
40
|
+
for attr in ("model_dump", "to_dict", "to_json_dict"):
|
|
41
|
+
fn = getattr(obj, attr, None)
|
|
42
|
+
if callable(fn):
|
|
43
|
+
try:
|
|
44
|
+
data = fn()
|
|
45
|
+
except TypeError:
|
|
46
|
+
continue
|
|
47
|
+
if isinstance(data, (dict, list)):
|
|
48
|
+
return data
|
|
49
|
+
raise IntakeError(f"unsupported payload type: {type(obj).__name__}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_args(value: Any) -> dict[str, Any]:
|
|
53
|
+
if value is None:
|
|
54
|
+
return {}
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
try:
|
|
57
|
+
value = json.loads(value or "{}")
|
|
58
|
+
except json.JSONDecodeError as exc:
|
|
59
|
+
raise IntakeError(f"tool arguments are not valid JSON: {exc}") from exc
|
|
60
|
+
if not isinstance(value, dict):
|
|
61
|
+
raise IntakeError(f"tool args must be an object, got {type(value).__name__}")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _from_openai_chat(data: dict) -> list[ToolCall] | None:
|
|
66
|
+
choices = data.get("choices")
|
|
67
|
+
if not isinstance(choices, list):
|
|
68
|
+
return None
|
|
69
|
+
calls: list[ToolCall] = []
|
|
70
|
+
for choice in choices:
|
|
71
|
+
message = (choice or {}).get("message") or {}
|
|
72
|
+
for tc in message.get("tool_calls") or []:
|
|
73
|
+
fn = tc.get("function") or {}
|
|
74
|
+
name = fn.get("name")
|
|
75
|
+
if not name:
|
|
76
|
+
raise IntakeError("OpenAI tool_call missing function.name")
|
|
77
|
+
calls.append(
|
|
78
|
+
ToolCall(name=name, args=_parse_args(fn.get("arguments")), id=tc.get("id"), source="openai-chat")
|
|
79
|
+
)
|
|
80
|
+
return calls
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _from_openai_responses(data: dict) -> list[ToolCall] | None:
|
|
84
|
+
output = data.get("output")
|
|
85
|
+
if not isinstance(output, list):
|
|
86
|
+
return None
|
|
87
|
+
calls: list[ToolCall] = []
|
|
88
|
+
for item in output:
|
|
89
|
+
if isinstance(item, dict) and item.get("type") == "function_call":
|
|
90
|
+
name = item.get("name")
|
|
91
|
+
if not name:
|
|
92
|
+
raise IntakeError("OpenAI responses function_call missing name")
|
|
93
|
+
calls.append(
|
|
94
|
+
ToolCall(
|
|
95
|
+
name=name,
|
|
96
|
+
args=_parse_args(item.get("arguments")),
|
|
97
|
+
id=item.get("call_id") or item.get("id"),
|
|
98
|
+
source="openai-responses",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return calls
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _from_anthropic(data: dict) -> list[ToolCall] | None:
|
|
105
|
+
content = data.get("content")
|
|
106
|
+
if not isinstance(content, list):
|
|
107
|
+
return None
|
|
108
|
+
calls: list[ToolCall] = []
|
|
109
|
+
for block in content:
|
|
110
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
111
|
+
name = block.get("name")
|
|
112
|
+
if not name:
|
|
113
|
+
raise IntakeError("Anthropic tool_use block missing name")
|
|
114
|
+
calls.append(
|
|
115
|
+
ToolCall(name=name, args=_parse_args(block.get("input")), id=block.get("id"), source="anthropic")
|
|
116
|
+
)
|
|
117
|
+
return calls
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _from_gemini(data: dict) -> list[ToolCall] | None:
|
|
121
|
+
candidates = data.get("candidates")
|
|
122
|
+
if not isinstance(candidates, list):
|
|
123
|
+
return None
|
|
124
|
+
calls: list[ToolCall] = []
|
|
125
|
+
for cand in candidates:
|
|
126
|
+
parts = ((cand or {}).get("content") or {}).get("parts") or []
|
|
127
|
+
for part in parts:
|
|
128
|
+
if not isinstance(part, dict):
|
|
129
|
+
continue
|
|
130
|
+
fc = part.get("functionCall") or part.get("function_call")
|
|
131
|
+
if fc:
|
|
132
|
+
name = fc.get("name")
|
|
133
|
+
if not name:
|
|
134
|
+
raise IntakeError("Gemini functionCall missing name")
|
|
135
|
+
calls.append(ToolCall(name=name, args=_parse_args(fc.get("args")), source="gemini"))
|
|
136
|
+
return calls
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _from_plain(data: dict) -> ToolCall | None:
|
|
140
|
+
name = next((data[k] for k in _NAME_KEYS if isinstance(data.get(k), str)), None)
|
|
141
|
+
if name is None:
|
|
142
|
+
return None
|
|
143
|
+
raw_args = next((data[k] for k in _ARG_KEYS if k in data), None)
|
|
144
|
+
return ToolCall(name=name, args=_parse_args(raw_args), id=data.get("id"), source="dict")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_ENVELOPE_EXTRACTORS = (_from_openai_chat, _from_openai_responses, _from_anthropic, _from_gemini)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_tool_calls(payload: Any) -> list[ToolCall]:
|
|
151
|
+
"""Extract every tool call from a provider response or plain dict.
|
|
152
|
+
|
|
153
|
+
Returns [] when a recognized envelope contains no tool calls
|
|
154
|
+
(e.g. the model answered with plain text). Raises IntakeError when
|
|
155
|
+
the payload shape is not recognizable at all.
|
|
156
|
+
"""
|
|
157
|
+
data = _as_data(payload)
|
|
158
|
+
|
|
159
|
+
if isinstance(data, list):
|
|
160
|
+
calls: list[ToolCall] = []
|
|
161
|
+
for item in data:
|
|
162
|
+
calls.extend(parse_tool_calls(item))
|
|
163
|
+
return calls
|
|
164
|
+
|
|
165
|
+
for extract in _ENVELOPE_EXTRACTORS:
|
|
166
|
+
calls = extract(data)
|
|
167
|
+
if calls is not None:
|
|
168
|
+
return calls
|
|
169
|
+
|
|
170
|
+
plain = _from_plain(data)
|
|
171
|
+
if plain is not None:
|
|
172
|
+
return [plain]
|
|
173
|
+
|
|
174
|
+
raise IntakeError(
|
|
175
|
+
"no recognizable tool-call shape (expected OpenAI/Anthropic/Gemini response or "
|
|
176
|
+
f"a dict with one of {_NAME_KEYS})"
|
|
177
|
+
)
|