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.
@@ -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.
@@ -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>` |
@@ -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