skillplus 0.1.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.
- skillplus/__init__.py +49 -0
- skillplus/client.py +378 -0
- skillplus/errors.py +17 -0
- skillplus/py.typed +0 -0
- skillplus/types.py +176 -0
- skillplus-0.1.0.dist-info/METADATA +102 -0
- skillplus-0.1.0.dist-info/RECORD +9 -0
- skillplus-0.1.0.dist-info/WHEEL +4 -0
- skillplus-0.1.0.dist-info/licenses/LICENSE +21 -0
skillplus/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from .client import SkillPlus
|
|
2
|
+
from .errors import SkillPlusError
|
|
3
|
+
from .types import (
|
|
4
|
+
AgentRun,
|
|
5
|
+
AiCategoryResult,
|
|
6
|
+
AiFalsePositive,
|
|
7
|
+
AiReport,
|
|
8
|
+
AiReportStatus,
|
|
9
|
+
IssueCategory,
|
|
10
|
+
PlatformListing,
|
|
11
|
+
QueryResult,
|
|
12
|
+
QueryStatus,
|
|
13
|
+
Rating,
|
|
14
|
+
ScanIssue,
|
|
15
|
+
ScanReport,
|
|
16
|
+
ScanResult,
|
|
17
|
+
ScanStatus,
|
|
18
|
+
ScanSummary,
|
|
19
|
+
Severity,
|
|
20
|
+
SeverityNormalized,
|
|
21
|
+
SupplyChainHit,
|
|
22
|
+
SupplyChainReport,
|
|
23
|
+
Verdict,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AgentRun",
|
|
28
|
+
"AiCategoryResult",
|
|
29
|
+
"AiFalsePositive",
|
|
30
|
+
"AiReport",
|
|
31
|
+
"AiReportStatus",
|
|
32
|
+
"IssueCategory",
|
|
33
|
+
"PlatformListing",
|
|
34
|
+
"QueryResult",
|
|
35
|
+
"QueryStatus",
|
|
36
|
+
"Rating",
|
|
37
|
+
"ScanIssue",
|
|
38
|
+
"ScanReport",
|
|
39
|
+
"ScanResult",
|
|
40
|
+
"ScanStatus",
|
|
41
|
+
"ScanSummary",
|
|
42
|
+
"Severity",
|
|
43
|
+
"SeverityNormalized",
|
|
44
|
+
"SupplyChainHit",
|
|
45
|
+
"SupplyChainReport",
|
|
46
|
+
"SkillPlus",
|
|
47
|
+
"SkillPlusError",
|
|
48
|
+
"Verdict",
|
|
49
|
+
]
|
skillplus/client.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .errors import SkillPlusError
|
|
9
|
+
from .types import (
|
|
10
|
+
AgentRun,
|
|
11
|
+
AiCategoryResult,
|
|
12
|
+
AiFalsePositive,
|
|
13
|
+
AiReport,
|
|
14
|
+
PlatformListing,
|
|
15
|
+
QueryResult,
|
|
16
|
+
ScanIssue,
|
|
17
|
+
ScanReport,
|
|
18
|
+
ScanResult,
|
|
19
|
+
ScanSummary,
|
|
20
|
+
SupplyChainHit,
|
|
21
|
+
SupplyChainReport,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://skillplus.xyz"
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0
|
|
26
|
+
DEFAULT_MAX_RETRIES = 2
|
|
27
|
+
_RETRY_STATUSES = frozenset({429, 502, 503})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SkillPlus:
|
|
31
|
+
"""Client for the hosted SkillPlus API."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str,
|
|
36
|
+
*,
|
|
37
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
38
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
39
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
40
|
+
) -> None:
|
|
41
|
+
if not api_key:
|
|
42
|
+
raise ValueError("api_key is required")
|
|
43
|
+
|
|
44
|
+
self._api_key = api_key
|
|
45
|
+
self._base_url = base_url.rstrip("/")
|
|
46
|
+
self._max_retries = max(0, max_retries)
|
|
47
|
+
self._client = httpx.Client(
|
|
48
|
+
base_url=self._base_url,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
headers={
|
|
51
|
+
"Authorization": f"Bearer {api_key}",
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
"User-Agent": "skillplus-python/0.1.0",
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def __enter__(self) -> SkillPlus:
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
61
|
+
self.close()
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
"""Close the underlying HTTP client."""
|
|
65
|
+
|
|
66
|
+
self._client.close()
|
|
67
|
+
|
|
68
|
+
def query(
|
|
69
|
+
self,
|
|
70
|
+
repo_url: str,
|
|
71
|
+
*,
|
|
72
|
+
skill_path: str | None = None,
|
|
73
|
+
scan_if_missing: bool = False,
|
|
74
|
+
preferred_provider: str | None = None,
|
|
75
|
+
) -> QueryResult:
|
|
76
|
+
"""Query an existing report, optionally queueing a scan if no report exists."""
|
|
77
|
+
|
|
78
|
+
payload = self._request_json(
|
|
79
|
+
"POST",
|
|
80
|
+
"/api/sdk/query",
|
|
81
|
+
json={
|
|
82
|
+
"repo_url": repo_url,
|
|
83
|
+
"skill_path": skill_path,
|
|
84
|
+
"scan_if_missing": scan_if_missing,
|
|
85
|
+
"preferred_provider": preferred_provider,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
return _parse_query_result(payload)
|
|
89
|
+
|
|
90
|
+
def scan(
|
|
91
|
+
self,
|
|
92
|
+
repo_url: str,
|
|
93
|
+
*,
|
|
94
|
+
skill_path: str | None = None,
|
|
95
|
+
preferred_provider: str | None = None,
|
|
96
|
+
force: bool = False,
|
|
97
|
+
) -> ScanResult:
|
|
98
|
+
"""Request a scan for a skill."""
|
|
99
|
+
|
|
100
|
+
payload = self._request_json(
|
|
101
|
+
"POST",
|
|
102
|
+
"/api/sdk/scan",
|
|
103
|
+
json={
|
|
104
|
+
"repo_url": repo_url,
|
|
105
|
+
"skill_path": skill_path,
|
|
106
|
+
"preferred_provider": preferred_provider,
|
|
107
|
+
"force": force,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
return _parse_scan_result(payload)
|
|
111
|
+
|
|
112
|
+
def wait_for_report(
|
|
113
|
+
self,
|
|
114
|
+
repo_url: str,
|
|
115
|
+
*,
|
|
116
|
+
skill_path: str | None = None,
|
|
117
|
+
preferred_provider: str | None = None,
|
|
118
|
+
interval: float = 5.0,
|
|
119
|
+
timeout: float = 600.0,
|
|
120
|
+
) -> ScanReport:
|
|
121
|
+
"""Query-and-wait: poll until a completed report exists (scanning if
|
|
122
|
+
missing). This is the reliable path after a ``queued`` result, which
|
|
123
|
+
may carry no scan_id to poll."""
|
|
124
|
+
|
|
125
|
+
deadline = time.monotonic() + timeout
|
|
126
|
+
while True:
|
|
127
|
+
result = self.query(
|
|
128
|
+
repo_url,
|
|
129
|
+
skill_path=skill_path,
|
|
130
|
+
scan_if_missing=True,
|
|
131
|
+
preferred_provider=preferred_provider,
|
|
132
|
+
)
|
|
133
|
+
if result.status == "found" and result.report and result.report.status == "completed":
|
|
134
|
+
return result.report
|
|
135
|
+
if result.status in ("failed", "not_found"):
|
|
136
|
+
raise SkillPlusError(f"Scan {result.status}: {result.message}", detail=result.status)
|
|
137
|
+
if time.monotonic() + interval > deadline:
|
|
138
|
+
raise SkillPlusError(
|
|
139
|
+
f"Timed out waiting for report (last status: {result.status})",
|
|
140
|
+
detail=result.status,
|
|
141
|
+
)
|
|
142
|
+
time.sleep(interval)
|
|
143
|
+
|
|
144
|
+
def get_report(self, scan_id: str) -> ScanReport:
|
|
145
|
+
"""Retrieve structured report data by scan ID."""
|
|
146
|
+
|
|
147
|
+
payload = self._request_json("GET", f"/api/report/{scan_id}")
|
|
148
|
+
return _parse_report(payload)
|
|
149
|
+
|
|
150
|
+
def get_badge(self, scan_id: str) -> str:
|
|
151
|
+
"""Fetch the badge SVG for a report."""
|
|
152
|
+
|
|
153
|
+
return self._request_text("GET", f"/api/report/{scan_id}/badge.svg")
|
|
154
|
+
|
|
155
|
+
def get_badge_url(self, scan_id: str) -> str:
|
|
156
|
+
"""Return the public badge URL for a report."""
|
|
157
|
+
|
|
158
|
+
return f"{self._base_url}/api/report/{scan_id}/badge.svg"
|
|
159
|
+
|
|
160
|
+
def _request_json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
161
|
+
response = self._request(method, path, **kwargs)
|
|
162
|
+
try:
|
|
163
|
+
payload = response.json()
|
|
164
|
+
except ValueError as exc:
|
|
165
|
+
raise SkillPlusError("SkillPlus returned invalid JSON", status_code=response.status_code) from exc
|
|
166
|
+
|
|
167
|
+
if not isinstance(payload, dict):
|
|
168
|
+
raise SkillPlusError(
|
|
169
|
+
"SkillPlus returned an unexpected response",
|
|
170
|
+
status_code=response.status_code,
|
|
171
|
+
detail=payload,
|
|
172
|
+
)
|
|
173
|
+
return payload
|
|
174
|
+
|
|
175
|
+
def _request_text(self, method: str, path: str, **kwargs: Any) -> str:
|
|
176
|
+
return self._request(method, path, **kwargs).text
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _retry_delay(response: httpx.Response | None, attempt: int) -> float:
|
|
180
|
+
if response is not None:
|
|
181
|
+
retry_after = response.headers.get("Retry-After")
|
|
182
|
+
if retry_after:
|
|
183
|
+
try:
|
|
184
|
+
return min(max(float(retry_after), 0.0), 60.0)
|
|
185
|
+
except ValueError:
|
|
186
|
+
pass
|
|
187
|
+
return min(2.0**attempt, 10.0)
|
|
188
|
+
|
|
189
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
190
|
+
response: httpx.Response | None = None
|
|
191
|
+
for attempt in range(self._max_retries + 1):
|
|
192
|
+
try:
|
|
193
|
+
response = self._client.request(method, path, **kwargs)
|
|
194
|
+
except httpx.RequestError as exc:
|
|
195
|
+
if attempt < self._max_retries:
|
|
196
|
+
time.sleep(self._retry_delay(None, attempt))
|
|
197
|
+
continue
|
|
198
|
+
raise SkillPlusError(str(exc), detail=exc) from exc
|
|
199
|
+
if response.status_code in _RETRY_STATUSES and attempt < self._max_retries:
|
|
200
|
+
time.sleep(self._retry_delay(response, attempt))
|
|
201
|
+
continue
|
|
202
|
+
break
|
|
203
|
+
assert response is not None
|
|
204
|
+
|
|
205
|
+
if response.is_error:
|
|
206
|
+
detail: object | None = None
|
|
207
|
+
message = response.reason_phrase
|
|
208
|
+
try:
|
|
209
|
+
detail = response.json()
|
|
210
|
+
if isinstance(detail, dict):
|
|
211
|
+
message = str(detail.get("detail") or detail.get("message") or message)
|
|
212
|
+
except ValueError:
|
|
213
|
+
detail = response.text or None
|
|
214
|
+
if response.text:
|
|
215
|
+
message = response.text
|
|
216
|
+
|
|
217
|
+
raise SkillPlusError(message, status_code=response.status_code, detail=detail)
|
|
218
|
+
|
|
219
|
+
return response
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _parse_query_result(payload: dict[str, Any]) -> QueryResult:
|
|
223
|
+
return QueryResult(
|
|
224
|
+
status=payload["status"],
|
|
225
|
+
message=payload["message"],
|
|
226
|
+
scan_id=payload.get("scan_id"),
|
|
227
|
+
poll_url=payload.get("poll_url"),
|
|
228
|
+
report=_parse_report(payload["report"]) if payload.get("report") else None,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_scan_result(payload: dict[str, Any]) -> ScanResult:
|
|
233
|
+
return ScanResult(
|
|
234
|
+
status=payload["status"],
|
|
235
|
+
message=payload["message"],
|
|
236
|
+
scan_id=payload.get("scan_id"),
|
|
237
|
+
poll_url=payload.get("poll_url"),
|
|
238
|
+
report=_parse_report(payload["report"]) if payload.get("report") else None,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_report(payload: dict[str, Any]) -> ScanReport:
|
|
243
|
+
return ScanReport(
|
|
244
|
+
scan_id=payload["scan_id"],
|
|
245
|
+
skill_name=payload["skill_name"],
|
|
246
|
+
source=payload["source"],
|
|
247
|
+
skill_path=payload["skill_path"],
|
|
248
|
+
tree_sha=payload["tree_sha"],
|
|
249
|
+
rule_version=payload["rule_version"],
|
|
250
|
+
valid_skill=payload["valid_skill"],
|
|
251
|
+
rating=payload["rating"],
|
|
252
|
+
verdict=payload.get("verdict")
|
|
253
|
+
or {"low": "safe", "critical": "high"}.get(payload["rating"], payload["rating"]),
|
|
254
|
+
scanned_at=payload["scanned_at"],
|
|
255
|
+
cached=payload["cached"],
|
|
256
|
+
scan_duration_ms=payload["scan_duration_ms"],
|
|
257
|
+
summary=_parse_summary(payload["summary"]),
|
|
258
|
+
issues=[_parse_issue(issue) for issue in payload.get("issues", [])],
|
|
259
|
+
report_url=payload["report_url"],
|
|
260
|
+
badge_url=payload["badge_url"],
|
|
261
|
+
blacklisted=bool(payload.get("blacklisted")),
|
|
262
|
+
whitelisted=bool(payload.get("whitelisted")),
|
|
263
|
+
status=payload["status"],
|
|
264
|
+
ai_report=_parse_ai_report(payload["ai_report"]) if payload.get("ai_report") else None,
|
|
265
|
+
external_links=payload.get("external_links") or {},
|
|
266
|
+
platform_listings=[
|
|
267
|
+
PlatformListing(
|
|
268
|
+
platform=listing["platform"],
|
|
269
|
+
url=listing["url"],
|
|
270
|
+
installs=listing["installs"],
|
|
271
|
+
)
|
|
272
|
+
for listing in payload.get("platform_listings", [])
|
|
273
|
+
],
|
|
274
|
+
supply_chain=_parse_supply_chain(payload.get("supply_chain")),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_supply_chain(payload: dict[str, Any] | None) -> SupplyChainReport | None:
|
|
279
|
+
if not payload:
|
|
280
|
+
return None
|
|
281
|
+
return SupplyChainReport(
|
|
282
|
+
dependency_count=payload.get("dependency_count") or 0,
|
|
283
|
+
endpoint_count=payload.get("endpoint_count") or 0,
|
|
284
|
+
blacklist_hits=[
|
|
285
|
+
SupplyChainHit(
|
|
286
|
+
kind=hit["kind"],
|
|
287
|
+
ecosystem=hit.get("ecosystem"),
|
|
288
|
+
name=hit["name"],
|
|
289
|
+
version_spec=hit.get("version_spec") or {},
|
|
290
|
+
reason=hit["reason"],
|
|
291
|
+
severity=hit["severity"],
|
|
292
|
+
reference_url=hit.get("reference_url"),
|
|
293
|
+
match_strength=hit["match_strength"],
|
|
294
|
+
matched_version=hit.get("matched_version"),
|
|
295
|
+
)
|
|
296
|
+
for hit in payload.get("blacklist_hits", [])
|
|
297
|
+
],
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _parse_summary(payload: dict[str, Any]) -> ScanSummary:
|
|
302
|
+
return ScanSummary(
|
|
303
|
+
total_issues=payload["total_issues"],
|
|
304
|
+
danger=payload["danger"],
|
|
305
|
+
warning=payload["warning"],
|
|
306
|
+
info=payload["info"],
|
|
307
|
+
files_scanned=payload["files_scanned"],
|
|
308
|
+
by_category=payload.get("by_category") or {},
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _parse_issue(payload: dict[str, Any]) -> ScanIssue:
|
|
313
|
+
return ScanIssue(
|
|
314
|
+
rule_id=payload["rule_id"],
|
|
315
|
+
severity=payload["severity"],
|
|
316
|
+
severity_normalized=payload.get("severity_normalized")
|
|
317
|
+
or {"danger": "high", "warning": "medium", "info": "low"}.get(
|
|
318
|
+
payload["severity"], payload["severity"]
|
|
319
|
+
),
|
|
320
|
+
category=payload["category"],
|
|
321
|
+
message=payload["message"],
|
|
322
|
+
file=payload.get("file"),
|
|
323
|
+
line=payload.get("line"),
|
|
324
|
+
snippet=payload.get("snippet"),
|
|
325
|
+
domain=payload.get("domain"),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _parse_ai_report(payload: dict[str, Any]) -> AiReport:
|
|
330
|
+
return AiReport(
|
|
331
|
+
status=payload["status"],
|
|
332
|
+
risk_level=payload.get("risk_level"),
|
|
333
|
+
refined_rating=payload.get("refined_rating"),
|
|
334
|
+
false_positives=[
|
|
335
|
+
AiFalsePositive(
|
|
336
|
+
rule_id=false_positive["rule_id"],
|
|
337
|
+
file=false_positive["file"],
|
|
338
|
+
reason=false_positive["reason"],
|
|
339
|
+
)
|
|
340
|
+
for false_positive in payload.get("false_positives", [])
|
|
341
|
+
],
|
|
342
|
+
analysis=[_parse_ai_category(item) for item in payload.get("analysis", [])],
|
|
343
|
+
summary=payload.get("summary"),
|
|
344
|
+
categories_checked=[
|
|
345
|
+
_parse_ai_category(item) for item in payload.get("categories_checked", [])
|
|
346
|
+
],
|
|
347
|
+
tokens_used=payload.get("tokens_used"),
|
|
348
|
+
duration_ms=payload.get("duration_ms"),
|
|
349
|
+
error_message=payload.get("error_message"),
|
|
350
|
+
agents=(
|
|
351
|
+
[
|
|
352
|
+
AgentRun(
|
|
353
|
+
agent=run["agent"],
|
|
354
|
+
status=run["status"],
|
|
355
|
+
findings=run.get("findings") or [],
|
|
356
|
+
summary=run.get("summary"),
|
|
357
|
+
tokens_used=run.get("tokens_used"),
|
|
358
|
+
duration_ms=run.get("duration_ms"),
|
|
359
|
+
error_message=run.get("error_message"),
|
|
360
|
+
)
|
|
361
|
+
for run in payload["agents"]
|
|
362
|
+
]
|
|
363
|
+
if payload.get("agents") is not None
|
|
364
|
+
else None
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _parse_ai_category(payload: dict[str, Any]) -> AiCategoryResult:
|
|
370
|
+
return AiCategoryResult(
|
|
371
|
+
category=payload["category"],
|
|
372
|
+
status=payload["status"],
|
|
373
|
+
severity=payload["severity"],
|
|
374
|
+
title=payload["title"],
|
|
375
|
+
description=payload["description"],
|
|
376
|
+
evidence=payload.get("evidence") or [],
|
|
377
|
+
confidence=payload.get("confidence") or 0.0,
|
|
378
|
+
)
|
skillplus/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SkillPlusError(Exception):
|
|
5
|
+
"""Error raised by the SkillPlus SDK."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status_code: int | None = None,
|
|
12
|
+
detail: object | None = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.detail = detail
|
skillplus/py.typed
ADDED
|
File without changes
|
skillplus/types.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal, TypeAlias
|
|
5
|
+
|
|
6
|
+
Rating: TypeAlias = Literal["safe", "low", "medium", "high", "unknown"]
|
|
7
|
+
# Rating system v3: three-tier verdict (low→safe, critical→high). Prefer over Rating.
|
|
8
|
+
Verdict: TypeAlias = Literal["safe", "medium", "high", "unknown"]
|
|
9
|
+
# Raw rule-issue severity. v3 scans emit high/medium/low; reports scanned
|
|
10
|
+
# before v3 may still carry danger/warning/info. Prefer SeverityNormalized.
|
|
11
|
+
Severity: TypeAlias = Literal[
|
|
12
|
+
"high", "medium", "low", "critical", "danger", "warning", "info", "safe"
|
|
13
|
+
]
|
|
14
|
+
# Rating system v3: unified finding severity. Prefer over Severity.
|
|
15
|
+
SeverityNormalized: TypeAlias = Literal["high", "medium", "low", "safe"]
|
|
16
|
+
IssueCategory: TypeAlias = Literal[
|
|
17
|
+
"url",
|
|
18
|
+
"code",
|
|
19
|
+
"prompt",
|
|
20
|
+
"structure",
|
|
21
|
+
"persistence",
|
|
22
|
+
"credential",
|
|
23
|
+
"supply_chain",
|
|
24
|
+
"propagation",
|
|
25
|
+
]
|
|
26
|
+
QueryStatus: TypeAlias = Literal["found", "queued", "running", "not_found", "failed"]
|
|
27
|
+
ScanStatus: TypeAlias = Literal["found", "queued", "running", "failed"]
|
|
28
|
+
AiReportStatus: TypeAlias = Literal["pending", "processing", "completed", "failed"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class ScanIssue:
|
|
33
|
+
rule_id: str
|
|
34
|
+
severity: Severity # deprecated: prefer severity_normalized
|
|
35
|
+
severity_normalized: SeverityNormalized
|
|
36
|
+
category: IssueCategory
|
|
37
|
+
message: str
|
|
38
|
+
file: str | None = None
|
|
39
|
+
line: int | None = None
|
|
40
|
+
snippet: str | None = None
|
|
41
|
+
domain: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class ScanSummary:
|
|
46
|
+
total_issues: int
|
|
47
|
+
danger: int
|
|
48
|
+
warning: int
|
|
49
|
+
info: int
|
|
50
|
+
files_scanned: int
|
|
51
|
+
by_category: dict[str, int] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class AiFalsePositive:
|
|
56
|
+
rule_id: str
|
|
57
|
+
file: str
|
|
58
|
+
reason: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class AiCategoryResult:
|
|
63
|
+
category: str
|
|
64
|
+
status: str
|
|
65
|
+
severity: str
|
|
66
|
+
title: str
|
|
67
|
+
description: str
|
|
68
|
+
evidence: list[str] = field(default_factory=list)
|
|
69
|
+
confidence: float = 0.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class AgentRun:
|
|
74
|
+
"""One agent's run in the v2 multi-agent pipeline (present on get_report)."""
|
|
75
|
+
|
|
76
|
+
agent: str
|
|
77
|
+
status: str
|
|
78
|
+
findings: list[dict] = field(default_factory=list)
|
|
79
|
+
summary: str | None = None
|
|
80
|
+
tokens_used: int | None = None
|
|
81
|
+
duration_ms: int | None = None
|
|
82
|
+
error_message: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(slots=True)
|
|
86
|
+
class AiReport:
|
|
87
|
+
status: AiReportStatus
|
|
88
|
+
risk_level: str | None
|
|
89
|
+
refined_rating: Rating | None
|
|
90
|
+
false_positives: list[AiFalsePositive] = field(default_factory=list)
|
|
91
|
+
analysis: list[AiCategoryResult] = field(default_factory=list)
|
|
92
|
+
summary: str | None = None
|
|
93
|
+
categories_checked: list[AiCategoryResult] = field(default_factory=list)
|
|
94
|
+
tokens_used: int | None = None
|
|
95
|
+
duration_ms: int | None = None
|
|
96
|
+
error_message: str | None = None
|
|
97
|
+
# Per-agent detail (multi-agent pipeline); None when not exposed.
|
|
98
|
+
agents: list[AgentRun] | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class SupplyChainHit:
|
|
103
|
+
"""One supply-chain blacklist hit (a dependency/domain known to be poisoned)."""
|
|
104
|
+
|
|
105
|
+
kind: str
|
|
106
|
+
ecosystem: str | None
|
|
107
|
+
name: str
|
|
108
|
+
version_spec: dict
|
|
109
|
+
reason: str
|
|
110
|
+
severity: str
|
|
111
|
+
reference_url: str | None
|
|
112
|
+
match_strength: str
|
|
113
|
+
matched_version: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(slots=True)
|
|
117
|
+
class SupplyChainReport:
|
|
118
|
+
"""Supply-chain section of a report. Any blacklist hit floors the verdict
|
|
119
|
+
to high unless the skill is admin-whitelisted."""
|
|
120
|
+
|
|
121
|
+
dependency_count: int = 0
|
|
122
|
+
endpoint_count: int = 0
|
|
123
|
+
blacklist_hits: list[SupplyChainHit] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(slots=True)
|
|
127
|
+
class PlatformListing:
|
|
128
|
+
platform: str
|
|
129
|
+
url: str
|
|
130
|
+
installs: int
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(slots=True)
|
|
134
|
+
class ScanReport:
|
|
135
|
+
scan_id: str
|
|
136
|
+
skill_name: str
|
|
137
|
+
source: str
|
|
138
|
+
skill_path: str
|
|
139
|
+
tree_sha: str
|
|
140
|
+
rule_version: str
|
|
141
|
+
valid_skill: bool
|
|
142
|
+
rating: Rating # deprecated: prefer verdict
|
|
143
|
+
verdict: Verdict
|
|
144
|
+
scanned_at: str
|
|
145
|
+
cached: bool
|
|
146
|
+
scan_duration_ms: int
|
|
147
|
+
summary: ScanSummary
|
|
148
|
+
issues: list[ScanIssue]
|
|
149
|
+
report_url: str
|
|
150
|
+
badge_url: str
|
|
151
|
+
blacklisted: bool
|
|
152
|
+
whitelisted: bool
|
|
153
|
+
status: str
|
|
154
|
+
ai_report: AiReport | None = None
|
|
155
|
+
external_links: dict[str, str] = field(default_factory=dict)
|
|
156
|
+
platform_listings: list[PlatformListing] = field(default_factory=list)
|
|
157
|
+
# Supply-chain snapshot; None when the section is unavailable.
|
|
158
|
+
supply_chain: SupplyChainReport | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(slots=True)
|
|
162
|
+
class QueryResult:
|
|
163
|
+
status: QueryStatus
|
|
164
|
+
message: str
|
|
165
|
+
scan_id: str | None = None
|
|
166
|
+
poll_url: str | None = None
|
|
167
|
+
report: ScanReport | None = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(slots=True)
|
|
171
|
+
class ScanResult:
|
|
172
|
+
status: ScanStatus
|
|
173
|
+
message: str
|
|
174
|
+
scan_id: str | None = None
|
|
175
|
+
poll_url: str | None = None
|
|
176
|
+
report: ScanReport | None = None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skillplus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for SkillPlus
|
|
5
|
+
Project-URL: Homepage, https://skillplus.xyz
|
|
6
|
+
Project-URL: Documentation, https://docs.skillplus.xyz
|
|
7
|
+
Author: SkillPlus
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,sdk,security,skillplus
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx>=0.28
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: respx>=0.22; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# SkillPlus Python SDK
|
|
20
|
+
|
|
21
|
+
Official Python SDK for the hosted SkillPlus API.
|
|
22
|
+
|
|
23
|
+
SkillPlus provides security intelligence for AI skills and agent-facing software, helping developers, teams, and platforms scan, understand, and trust AI skills before installation, approval, or integration.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install skillplus
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Basic usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from skillplus import SkillPlus
|
|
35
|
+
|
|
36
|
+
client = SkillPlus(api_key="skp_...")
|
|
37
|
+
|
|
38
|
+
result = client.query("https://github.com/owner/repo")
|
|
39
|
+
|
|
40
|
+
if result.status == "found" and result.report:
|
|
41
|
+
print(result.report.rating) # "safe" | "medium" | "high"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Advanced options
|
|
45
|
+
|
|
46
|
+
For repositories containing many skills, or to queue a scan automatically when
|
|
47
|
+
no report exists yet:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
result = client.query(
|
|
51
|
+
"https://github.com/owner/repo",
|
|
52
|
+
skill_path="skills/example", # pick one skill in a multi-skill repo
|
|
53
|
+
scan_if_missing=True, # queue a scan if no report exists
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The SDK talks to the official SkillPlus service at `https://skillplus.xyz`.
|
|
58
|
+
|
|
59
|
+
## Context manager
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from skillplus import SkillPlus
|
|
63
|
+
|
|
64
|
+
with SkillPlus(api_key="skp_...") as client:
|
|
65
|
+
result = client.scan(
|
|
66
|
+
"https://github.com/owner/repo",
|
|
67
|
+
skill_path="skills/example",
|
|
68
|
+
)
|
|
69
|
+
print(result.status)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Methods
|
|
73
|
+
|
|
74
|
+
| Method | Use case |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `query(...)` | Check whether SkillPlus already has a report for a skill. |
|
|
77
|
+
| `scan(...)` | Request a scan when a report is missing or when a fresh check is needed. |
|
|
78
|
+
| `get_report(scan_id)` | Retrieve structured report data for dashboards, registries, or automation. |
|
|
79
|
+
| `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
|
|
80
|
+
| `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
|
|
81
|
+
|
|
82
|
+
## Error handling
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from skillplus import SkillPlus, SkillPlusError
|
|
86
|
+
|
|
87
|
+
client = SkillPlus(api_key="skp_...")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
result = client.query("https://github.com/owner/repo")
|
|
91
|
+
except SkillPlusError as error:
|
|
92
|
+
print(error.status_code)
|
|
93
|
+
print(error.message)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv sync --extra dev
|
|
100
|
+
uv run pytest
|
|
101
|
+
uv run python -m build
|
|
102
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
skillplus/__init__.py,sha256=cZd1LkSGu_SD7yFKoXruz45PIBucVFApH7581QQvwkQ,884
|
|
2
|
+
skillplus/client.py,sha256=bHdr_3QGhB2rFnyjkQxlnmukEj8I3LJVonWXqedb-Tk,12937
|
|
3
|
+
skillplus/errors.py,sha256=sBKaAhsdSvbD6NAJvgcm5Znz_4yzH_GgCPoZ8scN3Z8,407
|
|
4
|
+
skillplus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
skillplus/types.py,sha256=F2QSa1xLYqxTnKuUoRJFnb1cMy6aL3msUtH4zpPUZVE,4812
|
|
6
|
+
skillplus-0.1.0.dist-info/METADATA,sha256=GOIXYCc83oVoCxYhgzf4Rd3T8XH9IAlmJp7bTDZthHk,2627
|
|
7
|
+
skillplus-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
skillplus-0.1.0.dist-info/licenses/LICENSE,sha256=inrw9S8ekrm5HZC828XJGvq4XcZ7OU9OFghnU1HWAcg,1066
|
|
9
|
+
skillplus-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SkillPlus
|
|
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.
|