shadowcat 2.0.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.
Files changed (95) hide show
  1. agent/__init__.py +17 -0
  2. agent/benchmark/__init__.py +11 -0
  3. agent/benchmark/cli.py +179 -0
  4. agent/benchmark/config.py +15 -0
  5. agent/benchmark/docker.py +192 -0
  6. agent/benchmark/registry.py +99 -0
  7. agent/core/__init__.py +0 -0
  8. agent/core/agent.py +362 -0
  9. agent/core/backend.py +1667 -0
  10. agent/core/config.py +106 -0
  11. agent/core/controller.py +638 -0
  12. agent/core/events.py +177 -0
  13. agent/core/langfuse.py +320 -0
  14. agent/core/phantom.py +2327 -0
  15. agent/core/planner.py +493 -0
  16. agent/core/profiling.py +58 -0
  17. agent/core/sanitizer.py +104 -0
  18. agent/core/session.py +228 -0
  19. agent/core/tracer.py +137 -0
  20. agent/interface/__init__.py +0 -0
  21. agent/interface/components/__init__.py +0 -0
  22. agent/interface/components/activity_feed.py +202 -0
  23. agent/interface/components/renderers.py +149 -0
  24. agent/interface/components/splash.py +112 -0
  25. agent/interface/main.py +1126 -0
  26. agent/interface/styles.tcss +421 -0
  27. agent/interface/tui.py +508 -0
  28. agent/parsing/html_distiller.py +230 -0
  29. agent/parsing/tool_parser.py +115 -0
  30. agent/prompts/__init__.py +0 -0
  31. agent/prompts/pentesting.py +238 -0
  32. agent/rag_module/knowledge_base.py +116 -0
  33. agent/tests/benchmark_phantom.py +455 -0
  34. agent/tools/__init__.py +14 -0
  35. agent/tools/base.py +99 -0
  36. agent/tools/executor.py +345 -0
  37. agent/tools/registry.py +47 -0
  38. backend/README.md +244 -0
  39. backend/__init__.py +10 -0
  40. backend/api/__init__.py +1 -0
  41. backend/api/routes_scan.py +932 -0
  42. backend/authz.py +75 -0
  43. backend/cli.py +261 -0
  44. backend/compliance/__init__.py +1 -0
  45. backend/compliance/pdpa_mapping.py +16 -0
  46. backend/core/__init__.py +1 -0
  47. backend/core/coverage.py +93 -0
  48. backend/core/events.py +166 -0
  49. backend/core/llm_client.py +614 -0
  50. backend/core/orchestrator.py +402 -0
  51. backend/core/scan_memory.py +236 -0
  52. backend/crawler/__init__.py +1 -0
  53. backend/crawler/csrf.py +75 -0
  54. backend/crawler/headless.py +232 -0
  55. backend/crawler/js_analyzer.py +133 -0
  56. backend/crawler/spider.py +550 -0
  57. backend/crawler/subdomains.py +279 -0
  58. backend/daemon.py +252 -0
  59. backend/db/README.md +86 -0
  60. backend/db/__init__.py +17 -0
  61. backend/db/engine.py +87 -0
  62. backend/db/models.py +206 -0
  63. backend/db/repository.py +316 -0
  64. backend/db/schema.sql +247 -0
  65. backend/modes/__init__.py +5 -0
  66. backend/modes/base.py +178 -0
  67. backend/modes/ctf.py +39 -0
  68. backend/modes/enterprise.py +210 -0
  69. backend/modes/general.py +226 -0
  70. backend/modes/registry.py +34 -0
  71. backend/reporting/__init__.py +0 -0
  72. backend/reporting/generator.py +285 -0
  73. backend/schemas/__init__.py +1 -0
  74. backend/schemas/api.py +423 -0
  75. backend/tools/__init__.py +62 -0
  76. backend/tools/dirbrute_tool.py +304 -0
  77. backend/tools/general_report_tool.py +135 -0
  78. backend/tools/http_tool.py +351 -0
  79. backend/tools/nuclei_tool.py +20 -0
  80. backend/tools/report_tool.py +145 -0
  81. backend/tools/shell_tools.py +23 -0
  82. backend/verification/__init__.py +1 -0
  83. backend/verification/evidence_store.py +125 -0
  84. backend/verification/general_oracle.py +369 -0
  85. backend/verification/idor_oracle.py +131 -0
  86. backend/waf/__init__.py +0 -0
  87. backend/waf/detector.py +147 -0
  88. backend/waf/evasion.py +117 -0
  89. backend/webui/index.html +713 -0
  90. backend/workspace.py +182 -0
  91. shadowcat-2.0.0.dist-info/METADATA +360 -0
  92. shadowcat-2.0.0.dist-info/RECORD +95 -0
  93. shadowcat-2.0.0.dist-info/WHEEL +4 -0
  94. shadowcat-2.0.0.dist-info/entry_points.txt +4 -0
  95. shadowcat-2.0.0.dist-info/licenses/LICENSE.md +21 -0
backend/schemas/api.py ADDED
@@ -0,0 +1,423 @@
1
+ """Shared request / response / event models for the Enterprise API.
2
+
3
+ These Pydantic models are the single source of truth for the wire contract:
4
+ FastAPI generates ``/openapi.json`` from them so the frontend can codegen a
5
+ fully typed TS client with zero hand-syncing.
6
+
7
+ The run envelope (``scan_id``, ``mode``, ``status``, timing, ``summary``) is
8
+ identical across modes; mode-specific shapes live inside ``findings`` / ``extra``
9
+ so the frontend branches on ``mode`` once, never on field types.
10
+
11
+ Skeleton only — fields sketch the contract from ``ENTERPRISE_ARCH.md``; refine
12
+ as the orchestrator and modes are implemented.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime
19
+ from enum import Enum
20
+ from typing import Any
21
+
22
+ from pydantic import BaseModel, ConfigDict, Field
23
+
24
+
25
+ class ScanMode(str, Enum):
26
+ """Operating mode requested for a scan. Resolved to an ``AgentMode`` via the registry."""
27
+
28
+ CTF = "ctf"
29
+ ENTERPRISE = "enterprise"
30
+ GENERAL = "general"
31
+
32
+
33
+ class RunStatus(str, Enum):
34
+ """Lifecycle status shared across modes (mirrors the ``run.status`` SSE event)."""
35
+
36
+ QUEUED = "queued"
37
+ RUNNING = "running"
38
+ VERIFYING = "verifying"
39
+ COMPLETED = "completed"
40
+ FAILED = "failed"
41
+ CANCELLED = "cancelled"
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Request / response models
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ class IdentityConfig(BaseModel):
50
+ """Credentials for one seeded identity (userA / userB / custom).
51
+
52
+ The agent only ever names an identity (e.g. ``"userA"``); the server injects
53
+ these credentials server-side. The model never sees or handles secrets.
54
+
55
+ Credential priority (first that yields cookies wins):
56
+ 1. ``cookie`` / ``cookies`` — paste a raw session string directly.
57
+ 2. ``username`` + ``password`` — server POSTs to ``login_url`` (or
58
+ auto-discovers common login paths) and extracts ``Set-Cookie``.
59
+ """
60
+
61
+ name: str = Field(..., description="Identity label used in http_request calls (e.g. 'userA').")
62
+ cookie: str | None = Field(
63
+ None,
64
+ description="Raw Cookie header value (e.g. 'session=abc123; user_id=1'). "
65
+ "Parsed into individual cookies and merged with the `cookies` dict.",
66
+ )
67
+ headers: dict[str, str] = Field(
68
+ default_factory=dict,
69
+ description="Extra request headers injected for this identity (e.g. Authorization).",
70
+ )
71
+ cookies: dict[str, str] = Field(
72
+ default_factory=dict,
73
+ description="Explicit cookie key/value pairs (supplement or override `cookie`).",
74
+ )
75
+ username: str | None = Field(
76
+ None,
77
+ description="Login username / e-mail for auto-login. Requires `password`.",
78
+ )
79
+ password: str | None = Field(
80
+ None,
81
+ description="Login password for auto-login. Requires `username`.",
82
+ )
83
+ login_url: str | None = Field(
84
+ None,
85
+ description="Explicit login endpoint URL. When omitted the server probes "
86
+ "common paths (/login, /api/login, /auth/login, …) under the scan target.",
87
+ )
88
+ csrf_field: str | None = Field(
89
+ None,
90
+ description="Name of the CSRF token hidden input on the login form (e.g. '_token', "
91
+ "'csrfmiddlewaretoken'). When set the server fetches the login page first, extracts "
92
+ "the token, and includes it in the login POST. When omitted, common names are tried.",
93
+ )
94
+
95
+
96
+ class ScanConfig(BaseModel):
97
+ """Per-run tuning knobs — controls how deep and how fast the scan runs.
98
+
99
+ Sensible defaults allow small targets to work without any configuration;
100
+ increase ``max_turns`` / ``max_requests`` / ``max_crawl_pages`` for large
101
+ sites such as universities or enterprise portals.
102
+ """
103
+
104
+ crawl_first: bool = Field(
105
+ True,
106
+ description="Run the automated spider before the AI agent to build an attack "
107
+ "surface map. The agent receives a structured summary and focuses on testing.",
108
+ )
109
+ max_turns: int = Field(
110
+ 0,
111
+ ge=0,
112
+ le=400,
113
+ description="Max LLM ReAct turns. 0 = unlimited: runs until the agent finishes "
114
+ "on its own, the target goes unreachable, or you press Stop (Stop still "
115
+ "verifies and produces a report). Set 1-400 to cap it.",
116
+ )
117
+ max_requests: int = Field(
118
+ 300,
119
+ ge=10,
120
+ le=2000,
121
+ description="Total HTTP request budget shared between the spider and the agent.",
122
+ )
123
+ rate_limit_ms: int = Field(
124
+ 300,
125
+ ge=0,
126
+ le=10000,
127
+ description="Milliseconds to wait between outgoing HTTP requests (politeness delay). "
128
+ "Increase to avoid triggering WAFs on production sites.",
129
+ )
130
+ max_depth: int = Field(
131
+ 3,
132
+ ge=1,
133
+ le=6,
134
+ description="How many link-hops deep the spider follows from the start URL.",
135
+ )
136
+ max_crawl_pages: int = Field(
137
+ 150,
138
+ ge=10,
139
+ le=1000,
140
+ description="Maximum pages the spider visits before handing off to the agent.",
141
+ )
142
+ spider_workers: int = Field(
143
+ 10,
144
+ ge=1,
145
+ le=50,
146
+ description="Number of concurrent spider worker coroutines. More workers "
147
+ "improve throughput on slow targets without changing the per-second rate.",
148
+ )
149
+ enable_js_analysis: bool = Field(
150
+ True,
151
+ description="After the HTML crawl, fetch linked .js files and scan for API "
152
+ "endpoint paths not visible in HTML (fetch/axios calls, route definitions).",
153
+ )
154
+ render_js: bool = Field(
155
+ False,
156
+ description="Render the target's root page in a real headless browser "
157
+ "(Playwright) so JavaScript-rendered SPAs are crawled properly and the API "
158
+ "endpoints they call are captured. Requires Playwright + a browser to be "
159
+ "installed; falls back to the plain HTTP crawl if unavailable.",
160
+ )
161
+ enable_subdomains: bool = Field(
162
+ False,
163
+ description="Enumerate and HTTP-confirm subdomains of the target domain before "
164
+ "crawling, then add live subdomains to the authorized scope.",
165
+ )
166
+ enable_waf_detection: bool = Field(
167
+ True,
168
+ description="Send a known-bad probe to fingerprint any WAF before the main "
169
+ "scan so the agent knows what evasion variants may be needed.",
170
+ )
171
+ prune_every_n_turns: int = Field(
172
+ 8,
173
+ ge=0,
174
+ le=200,
175
+ description="Compress old conversation turns every N LLM completions to keep "
176
+ "context bounded. On a non-cached model the whole history is re-billed every "
177
+ "turn, so frequent pruning is the main cost lever. Set 0 to disable pruning.",
178
+ )
179
+ prune_keep_recent: int = Field(
180
+ 5,
181
+ ge=2,
182
+ le=40,
183
+ description="How many of the most recent agent turns to keep verbatim when "
184
+ "pruning. Older turns collapse into a one-line summary (with reported finding "
185
+ "ids preserved). Lower = cheaper, higher = more short-term context.",
186
+ )
187
+ max_completion_tokens: int | None = Field(
188
+ 1536,
189
+ ge=256,
190
+ le=8192,
191
+ description="Cap on output tokens per LLM turn. A single tool call + rationale "
192
+ "is small, so this bounds runaway generations without hurting normal steps. "
193
+ "Set null to leave the provider default uncapped.",
194
+ )
195
+
196
+
197
+ class ScanRequest(BaseModel):
198
+ """Body of ``POST /api/scans``."""
199
+
200
+ mode: ScanMode
201
+ target: str = Field(..., min_length=1, description="Authorized target URL or host.")
202
+ instruction: str | None = Field(None, description="Optional operator hint / focus.")
203
+ scope: list[str] = Field(
204
+ default_factory=list,
205
+ description="Authorized host / CIDR / URL allowlist for this run.",
206
+ )
207
+ model: str | None = Field(
208
+ None,
209
+ description="LLM model slug to drive this run (e.g. 'anthropic/claude-sonnet-4-5'). "
210
+ "Falls back to the server's MODEL env var, then a built-in default.",
211
+ )
212
+ openrouter_api_key: str | None = Field(
213
+ None,
214
+ description="Caller-supplied API key. Works with OpenRouter (sk-or-...) and PSU Blue "
215
+ "(sk-user-...). When set, overrides the server's OPENROUTER_API_KEY for this run only.",
216
+ )
217
+ api_base_url: str | None = Field(
218
+ None,
219
+ description="Custom OpenAI-compatible base URL for this run "
220
+ "(e.g. 'https://ai.psu.blue/v1'). Auto-detected from key prefix when omitted.",
221
+ )
222
+ identities: list[IdentityConfig] = Field(
223
+ default_factory=list,
224
+ description="Seeded user sessions for IDOR testing. Provide at least 'userA' and "
225
+ "'userB' with their session cookies so the agent can test cross-user access.",
226
+ )
227
+ victim_markers: list[str] = Field(
228
+ default_factory=list,
229
+ description="Strings that uniquely identify the victim identity's private data "
230
+ "(e.g. userB's email, account number, order id). The IDOR oracle confirms a finding "
231
+ "only when one of these appears in a cross-access response and is absent from the "
232
+ "attacker's own baseline — without them a successful cross-access stays 'inconclusive'. "
233
+ "Required for deterministic IDOR/BOLA verification.",
234
+ )
235
+ config: ScanConfig = Field(
236
+ default_factory=ScanConfig,
237
+ description="Scan depth and rate-limiting configuration.",
238
+ )
239
+
240
+
241
+ class ScanCreatedResponse(BaseModel):
242
+ """``202`` response from ``POST /api/scans``."""
243
+
244
+ scan_id: str
245
+ status: RunStatus = RunStatus.QUEUED
246
+
247
+
248
+ class RunResult(BaseModel):
249
+ """Mode-polymorphic final result inside a common envelope.
250
+
251
+ Returned by ``AgentMode.finalize``. Keep the envelope identical across
252
+ modes; let each mode populate ``findings`` / ``extra`` with its own shape.
253
+ """
254
+
255
+ scan_id: str
256
+ mode: ScanMode
257
+ status: RunStatus
258
+ started_at: datetime | None = None
259
+ finished_at: datetime | None = None
260
+ summary: dict[str, Any] = Field(default_factory=dict)
261
+ findings: list[dict[str, Any]] = Field(default_factory=list)
262
+ extra: dict[str, Any] = Field(default_factory=dict)
263
+
264
+
265
+ class ScanStatusResponse(BaseModel):
266
+ """``GET /api/scans/{id}`` — status plus a poll-able result snapshot."""
267
+
268
+ scan_id: str
269
+ mode: ScanMode
270
+ status: RunStatus
271
+ result: RunResult | None = None
272
+
273
+
274
+ class SSEEvent(BaseModel):
275
+ """One Server-Sent Event envelope, discriminated by ``type``.
276
+
277
+ Same shape for both modes so the frontend has exactly one parser and can
278
+ build the Task Tree directly from ``node_id`` / ``parent_id``. The
279
+ monotonic ``id`` lets the browser resume via ``Last-Event-ID``.
280
+ """
281
+
282
+ id: int
283
+ scan_id: str
284
+ ts: datetime
285
+ type: str
286
+ node_id: str | None = None
287
+ parent_id: str | None = None
288
+ payload: dict[str, Any] = Field(default_factory=dict)
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Finding report (agent-supplied) — strictly an evidence bundle, never a verdict
293
+ # ---------------------------------------------------------------------------
294
+ #
295
+ # This is the schema the agent MUST use to report a candidate finding. It is
296
+ # deliberately constrained:
297
+ # * ``extra="forbid"`` rejects any field the agent invents.
298
+ # * There is NO status / severity / "confirmed" / "is_vulnerable" field — the
299
+ # agent is structurally incapable of declaring a verdict. The deterministic
300
+ # oracle assigns those downstream.
301
+ # * Evidence is carried only as ``request_id`` strings; the agent cannot paste
302
+ # request/response content here, so it cannot fabricate evidence.
303
+
304
+
305
+ class VulnClass(BaseModel):
306
+ """Vulnerability classification for a candidate finding."""
307
+
308
+ model_config = ConfigDict(extra="forbid")
309
+
310
+ cwe: str = Field(..., description="CWE identifier, e.g. 'CWE-639'.")
311
+ owasp: str | None = Field(None, description="OWASP category, e.g. 'A01:2021'.")
312
+
313
+
314
+ class FindingEvidence(BaseModel):
315
+ """Evidence for a finding — ``request_id`` references only, never content."""
316
+
317
+ model_config = ConfigDict(extra="forbid")
318
+
319
+ baseline: str = Field(
320
+ ..., description="request_id: identity A reading A's OWN object (legitimate behavior)."
321
+ )
322
+ cross_access: str = Field(
323
+ ..., description="request_id: identity A requesting identity B's object."
324
+ )
325
+ controls: list[str] = Field(
326
+ default_factory=list,
327
+ description="request_ids of negative controls (e.g. unauthenticated, non-existent reference).",
328
+ )
329
+
330
+
331
+ class FindingReport(BaseModel):
332
+ """Agent-supplied candidate finding. An evidence bundle, not a verdict."""
333
+
334
+ model_config = ConfigDict(extra="forbid")
335
+
336
+ title: str = Field(..., description="Short finding title, e.g. 'IDOR on GET /api/orders/{id}'.")
337
+ hypothesis: str = Field(..., description="The specific access-control hypothesis tested.")
338
+ vuln_class: VulnClass
339
+ evidence: FindingEvidence
340
+ rationale: str | None = Field(
341
+ None, description="Concise factual reasoning. Shown to operators; NOT a verdict."
342
+ )
343
+
344
+ def cited_request_ids(self) -> list[str]:
345
+ """Every ``request_id`` referenced, in (baseline, cross_access, controls) order."""
346
+ return [self.evidence.baseline, self.evidence.cross_access, *self.evidence.controls]
347
+
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # General DAST finding (OWASP Top 10 — any vulnerability class)
351
+ # ---------------------------------------------------------------------------
352
+
353
+
354
+ class GeneralFindingEvidence(BaseModel):
355
+ """Evidence for a general-purpose DAST finding.
356
+
357
+ Simpler than IDOR evidence — a single probe request is the minimum; an
358
+ optional baseline makes the comparison machine-verifiable.
359
+ """
360
+
361
+ model_config = ConfigDict(extra="forbid")
362
+
363
+ probe: str = Field(
364
+ ...,
365
+ description="request_id of the test request that elicited the anomalous response.",
366
+ )
367
+ baseline: str | None = Field(
368
+ None,
369
+ description="request_id of a benign baseline request (same endpoint, safe input).",
370
+ )
371
+ controls: list[str] = Field(
372
+ default_factory=list,
373
+ description="request_ids of additional variant requests (e.g. different payloads).",
374
+ )
375
+
376
+
377
+ class GeneralFindingReport(BaseModel):
378
+ """Agent-supplied candidate finding for General DAST mode."""
379
+
380
+ model_config = ConfigDict(extra="forbid")
381
+
382
+ title: str = Field(..., description="Short title, e.g. 'Reflected XSS on GET /search?q='.")
383
+ hypothesis: str = Field(..., description="The specific vulnerability hypothesis tested.")
384
+ vuln_class: VulnClass
385
+ evidence: GeneralFindingEvidence
386
+ payload: str | None = Field(
387
+ None,
388
+ description="The test payload used (e.g. the SQLi string or XSS vector).",
389
+ )
390
+ rationale: str | None = Field(None, description="Concise factual reasoning. NOT a verdict.")
391
+
392
+ def cited_request_ids(self) -> list[str]:
393
+ ids = [self.evidence.probe]
394
+ if self.evidence.baseline:
395
+ ids.append(self.evidence.baseline)
396
+ ids.extend(self.evidence.controls)
397
+ return ids
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Orchestrator run state (in-memory, not part of the wire contract)
402
+ # ---------------------------------------------------------------------------
403
+
404
+
405
+ @dataclass
406
+ class RunState:
407
+ """Mutable per-run state threaded through the orchestrator loop.
408
+
409
+ Passed to the ``AgentMode`` hooks (``is_complete`` / ``on_tool_result`` /
410
+ ``finalize``). Held in memory for the life of a run.
411
+ """
412
+
413
+ scan_id: str
414
+ mode: ScanMode
415
+ target: str
416
+ status: RunStatus = RunStatus.QUEUED
417
+ turn: int = 0
418
+ actions_used: int = 0
419
+ transcript: list[dict[str, Any]] = field(default_factory=list)
420
+ scratch: dict[str, Any] = field(default_factory=dict)
421
+ # Cooperative stop flag: set by the cancel endpoint; the orchestrator checks it
422
+ # at each turn boundary and stops *gracefully* (verify + report), not hard-killed.
423
+ cancelled: bool = False
@@ -0,0 +1,62 @@
1
+ """Tool framework: per-mode tool registries and individual tool implementations.
2
+
3
+ A ``ToolRegistry`` is built per mode (see ``AgentMode.tool_registry``) so a
4
+ mode's capabilities are fixed *by construction*: a tool absent from the
5
+ registry is absent both from what the LLM sees and from what the executor can
6
+ dispatch. The dangerous mode's capabilities physically cannot leak into the
7
+ strict mode.
8
+
9
+ Skeleton only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+
17
+ @runtime_checkable
18
+ class Tool(Protocol):
19
+ """A single dispatchable capability exposed to the LLM."""
20
+
21
+ name: str
22
+
23
+ def schema(self) -> dict[str, Any]:
24
+ """OpenAI-style function schema advertised to the model."""
25
+ ...
26
+
27
+ async def run(self, **kwargs: Any) -> Any:
28
+ """Execute the tool and return its result."""
29
+ ...
30
+
31
+
32
+ class ToolRegistry:
33
+ """The set of tools that EXIST for a given mode.
34
+
35
+ Tools are added per mode; lookups for an unregistered name return ``None``
36
+ so the executor can refuse to dispatch a capability the mode never granted.
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._tools: dict[str, Tool] = {}
41
+
42
+ def register(self, tool: Tool) -> None:
43
+ """Add a tool to the registry (last registration of a name wins)."""
44
+ self._tools[tool.name] = tool
45
+
46
+ def get(self, name: str) -> Tool | None:
47
+ """Look up a tool by name; ``None`` if not registered for this mode."""
48
+ return self._tools.get(name)
49
+
50
+ def schemas(self) -> list[dict[str, Any]]:
51
+ """Return the function schemas for every registered tool."""
52
+ return [tool.schema() for tool in self._tools.values()]
53
+
54
+ def names(self) -> list[str]:
55
+ """Names of all registered tools."""
56
+ return list(self._tools)
57
+
58
+ def __contains__(self, name: object) -> bool:
59
+ return name in self._tools
60
+
61
+ def __len__(self) -> int:
62
+ return len(self._tools)