skill-sentry-cli 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 (96) hide show
  1. skill_sentry_cli/__init__.py +5 -0
  2. skill_sentry_cli/__main__.py +4 -0
  3. skill_sentry_cli/cli.py +918 -0
  4. skill_sentry_cli/core/__init__.py +1 -0
  5. skill_sentry_cli/core/anchors/__init__.py +5 -0
  6. skill_sentry_cli/core/anchors/credential_flow.py +440 -0
  7. skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
  8. skill_sentry_cli/core/cache/__init__.py +35 -0
  9. skill_sentry_cli/core/cache/hash_utils.py +206 -0
  10. skill_sentry_cli/core/cache/sqlite_store.py +925 -0
  11. skill_sentry_cli/core/config.py +299 -0
  12. skill_sentry_cli/core/contracts.py +551 -0
  13. skill_sentry_cli/core/decisions/__init__.py +44 -0
  14. skill_sentry_cli/core/decisions/base.py +174 -0
  15. skill_sentry_cli/core/decisions/calibration.py +325 -0
  16. skill_sentry_cli/core/decisions/laya.py +439 -0
  17. skill_sentry_cli/core/decisions/mapping.py +218 -0
  18. skill_sentry_cli/core/decisions/mock.py +118 -0
  19. skill_sentry_cli/core/decisions/questions.py +118 -0
  20. skill_sentry_cli/core/decisions/report.py +65 -0
  21. skill_sentry_cli/core/decisions/selector.py +151 -0
  22. skill_sentry_cli/core/engine.py +1412 -0
  23. skill_sentry_cli/core/evasion/__init__.py +27 -0
  24. skill_sentry_cli/core/evasion/detector.py +210 -0
  25. skill_sentry_cli/core/exit_codes.py +13 -0
  26. skill_sentry_cli/core/models/__init__.py +20 -0
  27. skill_sentry_cli/core/models/_json.py +147 -0
  28. skill_sentry_cli/core/models/base.py +50 -0
  29. skill_sentry_cli/core/models/gemma_cloud.py +240 -0
  30. skill_sentry_cli/core/models/gemma_local.py +273 -0
  31. skill_sentry_cli/core/models/image_utils.py +112 -0
  32. skill_sentry_cli/core/models/mock.py +152 -0
  33. skill_sentry_cli/core/models/selector.py +123 -0
  34. skill_sentry_cli/core/parser/__init__.py +29 -0
  35. skill_sentry_cli/core/parser/skill_parser.py +520 -0
  36. skill_sentry_cli/core/parser/typosquat.py +167 -0
  37. skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
  38. skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
  39. skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
  40. skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
  41. skill_sentry_cli/core/reports.py +114 -0
  42. skill_sentry_cli/core/sandbox/__init__.py +74 -0
  43. skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
  44. skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
  45. skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
  46. skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
  47. skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
  48. skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
  49. skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
  50. skill_sentry_cli/core/sandbox/fake_http.py +194 -0
  51. skill_sentry_cli/core/sandbox/instructions.py +170 -0
  52. skill_sentry_cli/core/sandbox/masquerade.py +265 -0
  53. skill_sentry_cli/core/sandbox/monitor.py +278 -0
  54. skill_sentry_cli/core/sandbox/runner.py +776 -0
  55. skill_sentry_cli/core/sandbox/sidecar.py +327 -0
  56. skill_sentry_cli/core/semantic/__init__.py +9 -0
  57. skill_sentry_cli/core/semantic/analyzer.py +443 -0
  58. skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
  59. skill_sentry_cli/core/semantic/ast_python.py +156 -0
  60. skill_sentry_cli/core/semantic/candidates.py +78 -0
  61. skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
  62. skill_sentry_cli/core/semantic/prompts.py +61 -0
  63. skill_sentry_cli/core/verdict/__init__.py +24 -0
  64. skill_sentry_cli/core/verdict/fusion.py +392 -0
  65. skill_sentry_cli/core/verdict/markdown_report.py +211 -0
  66. skill_sentry_cli/core/verdict/sarif_report.py +139 -0
  67. skill_sentry_cli/core/verdict/status.py +35 -0
  68. skill_sentry_cli/core/verdict/stix_report.py +132 -0
  69. skill_sentry_cli/core/version.py +38 -0
  70. skill_sentry_cli/core/vision/__init__.py +8 -0
  71. skill_sentry_cli/core/vision/image_extractor.py +91 -0
  72. skill_sentry_cli/core/vision/vision_scanner.py +274 -0
  73. skill_sentry_cli/data/__init__.py +1 -0
  74. skill_sentry_cli/data/known_skills_v1.json +276 -0
  75. skill_sentry_cli/data/sandbox_image.json +4 -0
  76. skill_sentry_cli/rules/credential_rules.toml +94 -0
  77. skill_sentry_cli/server/__init__.py +5 -0
  78. skill_sentry_cli/server/app.py +129 -0
  79. skill_sentry_cli/server/backend.py +63 -0
  80. skill_sentry_cli/server/demo_assets.py +62 -0
  81. skill_sentry_cli/server/demo_backend.py +475 -0
  82. skill_sentry_cli/server/demo_data.py +814 -0
  83. skill_sentry_cli/server/demo_mode.py +40 -0
  84. skill_sentry_cli/server/errors.py +48 -0
  85. skill_sentry_cli/server/live_backend.py +576 -0
  86. skill_sentry_cli/server/routes.py +191 -0
  87. skill_sentry_cli/skill/SKILL.md +67 -0
  88. skill_sentry_cli/skill/scripts/audit.sh +48 -0
  89. skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
  90. skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
  91. skill_sentry_cli/ui/dist/index.html +14 -0
  92. skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
  93. skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
  94. skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
  95. skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
  96. skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,139 @@
1
+ """SARIF 2.1.0 report writer (docs/05-api-reference.md §5.6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ from skill_sentry_cli.core.contracts import Finding, Severity, Verdict, format_utc, utc_now
10
+ from skill_sentry_cli.core.version import ENGINE_VERSION
11
+
12
+ SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
13
+ LEVEL_BY_SEVERITY: dict[Severity, str] = {
14
+ Severity.CRITICAL: "error",
15
+ Severity.HIGH: "error",
16
+ Severity.MEDIUM: "warning",
17
+ Severity.LOW: "note",
18
+ Severity.NOTE: "note",
19
+ }
20
+
21
+
22
+ def _slug(value: str, *, fallback: str = "finding", max_length: int = 64) -> str:
23
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
24
+ return (slug or fallback)[:max_length].strip("-") or fallback
25
+
26
+
27
+ def rule_id(finding: Finding) -> str:
28
+ """``skillsentry/<stage>/<type>`` — §5.6."""
29
+ kind = finding.finding_type or _slug(finding.message)
30
+ return f"skillsentry/{finding.stage.value}/{_slug(kind)}"
31
+
32
+
33
+ def _artifact_uri(path: str | None) -> str | None:
34
+ if not path:
35
+ return None
36
+ normalised = path.replace("\\", "/").lstrip("/")
37
+ while normalised.startswith("./"):
38
+ normalised = normalised[2:]
39
+ return quote(normalised, safe="/-._~")
40
+
41
+
42
+ def _result(finding: Finding, rule_index: int) -> dict[str, Any]:
43
+ result: dict[str, Any] = {
44
+ "ruleId": rule_id(finding),
45
+ "ruleIndex": rule_index,
46
+ "level": LEVEL_BY_SEVERITY[finding.severity],
47
+ "message": {"text": finding.message},
48
+ "properties": {
49
+ "stage": finding.stage.value,
50
+ "severity": finding.severity.value,
51
+ "evidence": finding.evidence.model_dump(by_alias=True, mode="json", exclude_none=True),
52
+ },
53
+ }
54
+ uri = _artifact_uri(finding.evidence.file)
55
+ if uri:
56
+ physical: dict[str, Any] = {"artifactLocation": {"uri": uri}}
57
+ if finding.evidence.line:
58
+ physical["region"] = {"startLine": finding.evidence.line}
59
+ result["locations"] = [{"physicalLocation": physical}]
60
+ return result
61
+
62
+
63
+ def build_sarif(
64
+ verdict: Verdict,
65
+ *,
66
+ generated_at=None,
67
+ decision: dict[str, Any] | None = None,
68
+ ) -> dict[str, Any]:
69
+ """Build a schema-shaped SARIF 2.1.0 document for one verdict."""
70
+ rules: dict[str, dict[str, Any]] = {}
71
+ results: list[dict[str, Any]] = []
72
+
73
+ for finding in verdict.findings:
74
+ rid = rule_id(finding)
75
+ if rid not in rules:
76
+ rules[rid] = {
77
+ "id": rid,
78
+ "name": _slug(rid.rsplit("/", 1)[-1], fallback="finding"),
79
+ "shortDescription": {"text": finding.message},
80
+ "helpUri": "https://github.com/skillsentry/skillsentry#readme",
81
+ "defaultConfiguration": {"level": LEVEL_BY_SEVERITY[finding.severity]},
82
+ "properties": {
83
+ "stage": finding.stage.value,
84
+ "severity": finding.severity.value,
85
+ "tags": [finding.stage.value, finding.severity.value],
86
+ },
87
+ }
88
+ results.append(_result(finding, list(rules).index(rid)))
89
+
90
+ run_properties: dict[str, Any] = {
91
+ "verdict": verdict.verdict.value,
92
+ "riskScore": verdict.risk_score,
93
+ "confidence": verdict.confidence,
94
+ "cached": verdict.cached,
95
+ "skill": verdict.skill,
96
+ "skillId": str(verdict.skill_id),
97
+ "scanId": str(verdict.scan_id),
98
+ "engineVersion": ENGINE_VERSION,
99
+ "generatedAt": format_utc(generated_at or utc_now()),
100
+ }
101
+ if verdict.active_defense is not None:
102
+ run_properties["activeDefense"] = verdict.active_defense.model_dump(
103
+ by_alias=True, mode="json", exclude_none=False
104
+ )
105
+ if decision is not None:
106
+ # Report-safe summary only; see skill_sentry_cli.core.decisions.report.
107
+ run_properties["decisionLayer"] = decision
108
+
109
+ return {
110
+ "$schema": SARIF_SCHEMA,
111
+ "version": "2.1.0",
112
+ "runs": [
113
+ {
114
+ "tool": {
115
+ "driver": {
116
+ "name": "SkillSentry",
117
+ "informationUri": "https://github.com/skillsentry/skillsentry",
118
+ "version": ENGINE_VERSION,
119
+ "semanticVersion": ENGINE_VERSION,
120
+ "rules": list(rules.values()),
121
+ }
122
+ },
123
+ "automationDetails": {"id": f"skillsentry/{verdict.scan_id}"},
124
+ "invocations": [{"executionSuccessful": True}],
125
+ "results": results,
126
+ "properties": run_properties,
127
+ "columnKind": "utf16CodeUnits",
128
+ }
129
+ ],
130
+ }
131
+
132
+
133
+ def build_sarif_text(verdict: Verdict) -> str:
134
+ import json
135
+
136
+ return json.dumps(build_sarif(verdict, generated_at=utc_now()), indent=2) + "\n"
137
+
138
+
139
+ __all__ = ["LEVEL_BY_SEVERITY", "SARIF_SCHEMA", "build_sarif", "build_sarif_text", "rule_id"]
@@ -0,0 +1,35 @@
1
+ """What a scan's result says about completeness, shared by every renderer.
2
+
3
+ A verdict is only as complete as its stages. When a stage degraded, the scan did
4
+ not read everything it should have, so a SAFE or SUSPICIOUS result is shown as
5
+ ``UNSCANNED`` with the reason. A MALICIOUS verdict is conclusive evidence and is
6
+ never downgraded to "unscanned" by another stage's failure.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+
13
+ from skill_sentry_cli.core.contracts import StageResult, VerdictTier
14
+
15
+ UNSCANNED = "UNSCANNED"
16
+
17
+
18
+ def is_unscanned(verdict: VerdictTier, stage_results: Sequence[StageResult] | None) -> bool:
19
+ if verdict == VerdictTier.MALICIOUS:
20
+ return False
21
+ return any(result.degraded for result in stage_results or ())
22
+
23
+
24
+ def degraded_reason(stage_results: Sequence[StageResult] | None) -> str:
25
+ for result in stage_results or ():
26
+ if result.degraded:
27
+ return f"{result.stage.value} degraded: {result.error or 'not fully analysed'}"
28
+ return "not fully analysed"
29
+
30
+
31
+ def shown_status(verdict: VerdictTier, stage_results: Sequence[StageResult] | None) -> str:
32
+ return UNSCANNED if is_unscanned(verdict, stage_results) else verdict.value
33
+
34
+
35
+ __all__ = ["UNSCANNED", "degraded_reason", "is_unscanned", "shown_status"]
@@ -0,0 +1,132 @@
1
+ """STIX 2.1 IOC bundle writer (docs/05-api-reference.md §5.7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import datetime
8
+ from typing import Any
9
+ from uuid import uuid4
10
+
11
+ from skill_sentry_cli.core.contracts import Verdict, format_utc, utc_now
12
+ from skill_sentry_cli.core.version import ENGINE_VERSION
13
+
14
+ _IPV4_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
15
+
16
+
17
+ class NoIOCsError(RuntimeError):
18
+ """Raised when a STIX export is requested for a scan without IOCs."""
19
+
20
+
21
+ def _pattern_for(value: str) -> tuple[str, str]:
22
+ if value.startswith(("http://", "https://")):
23
+ return "url", f"[url:value = '{value}']"
24
+ if _IPV4_RE.match(value):
25
+ return "ipv4-addr", f"[ipv4-addr:value = '{value}']"
26
+ return "domain-name", f"[domain-name:value = '{value}']"
27
+
28
+
29
+ def _sdo(object_type: str, **fields: Any) -> dict[str, Any]:
30
+ return {"type": object_type, "spec_version": "2.1", "id": f"{object_type}--{uuid4()}", **fields}
31
+
32
+
33
+ def build_stix(verdict: Verdict, *, generated_at: datetime | None = None) -> dict[str, Any]:
34
+ """Build a STIX 2.1 bundle, or raise when active defense did not trigger."""
35
+ active = verdict.active_defense
36
+ if active is None or not active.triggered:
37
+ raise NoIOCsError("no_iocs")
38
+ indicators = [value for value in active.c2_indicators if value]
39
+ if not indicators:
40
+ raise NoIOCsError("no_iocs")
41
+
42
+ created = generated_at or utc_now()
43
+ stamp = format_utc(created)
44
+ identity_id = f"identity--{uuid4()}"
45
+ malware_id = f"malware--{uuid4()}"
46
+
47
+ objects: list[dict[str, Any]] = [
48
+ _sdo(
49
+ "identity",
50
+ created=stamp,
51
+ modified=stamp,
52
+ name="SkillSentry 2.0",
53
+ description="Local-first Agent Skill supply-chain auditor.",
54
+ identity_class="system",
55
+ x_skillsentry_engine_version=ENGINE_VERSION,
56
+ ),
57
+ _sdo(
58
+ "malware",
59
+ created=stamp,
60
+ modified=stamp,
61
+ created_by_ref=identity_id,
62
+ name=verdict.skill,
63
+ description=(
64
+ f"Agent Skill flagged {verdict.verdict.value} with risk "
65
+ f"{verdict.risk_score}/100. {active.actual_behavior or ''}".strip()
66
+ ),
67
+ is_family=False,
68
+ labels=["malicious-activity"],
69
+ confidence=max(0, min(100, round(verdict.confidence * 100))),
70
+ x_skillsentry_scan_id=str(verdict.scan_id),
71
+ x_skillsentry_skill_id=str(verdict.skill_id),
72
+ x_skillsentry_canary_marker=active.canary_marker,
73
+ x_skillsentry_exfil_channel=(
74
+ active.exfil_channel.value if active.exfil_channel else None
75
+ ),
76
+ x_skillsentry_encoding=active.encoding,
77
+ ),
78
+ ]
79
+
80
+ for value in indicators:
81
+ ioc_type, pattern = _pattern_for(value)
82
+ indicator_id = f"indicator--{uuid4()}"
83
+ objects.append(
84
+ _sdo(
85
+ "indicator",
86
+ created=stamp,
87
+ modified=stamp,
88
+ created_by_ref=identity_id,
89
+ name=f"C2 {ioc_type} {value}",
90
+ description=(
91
+ f"Observed in sandboxed {active.exfil_channel.value if active.exfil_channel else 'egress'} "
92
+ f"traffic while serving canary {active.canary_marker or 'material'}."
93
+ ),
94
+ indicator_types=["malicious-activity"],
95
+ pattern=pattern,
96
+ pattern_type="stix",
97
+ valid_from=stamp,
98
+ labels=["malicious-activity"],
99
+ confidence=100 if active.triggered else 50,
100
+ x_skillsentry_channel=(
101
+ active.exfil_channel.value if active.exfil_channel else None
102
+ ),
103
+ )
104
+ )
105
+ objects.append(
106
+ _sdo(
107
+ "relationship",
108
+ created=stamp,
109
+ modified=stamp,
110
+ created_by_ref=identity_id,
111
+ relationship_type="indicates",
112
+ source_ref=malware_id,
113
+ target_ref=indicator_id,
114
+ description=f"{verdict.skill} exfiltrates to {value}",
115
+ )
116
+ )
117
+
118
+ return {
119
+ "type": "bundle",
120
+ "id": f"bundle--{uuid4()}",
121
+ "spec_version": "2.1",
122
+ "objects": objects,
123
+ "x_skillsentry_verdict": verdict.verdict.value,
124
+ "x_skillsentry_risk_score": verdict.risk_score,
125
+ }
126
+
127
+
128
+ def build_stix_text(verdict: Verdict, *, generated_at: datetime | None = None) -> str:
129
+ return json.dumps(build_stix(verdict, generated_at=generated_at), indent=2) + "\n"
130
+
131
+
132
+ __all__ = ["NoIOCsError", "build_stix", "build_stix_text"]
@@ -0,0 +1,38 @@
1
+ """Package metadata and engine version helpers.
2
+
3
+ The engine version participates in the Stage-0 cache key. Any change to the
4
+ fusion weights is folded into the version string so cached verdicts produced
5
+ under different scoring rules are never reused (docs/03-database.md §3.5).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from collections.abc import Mapping
13
+
14
+ __version__ = "2.0.0"
15
+ ENGINE_VERSION = __version__
16
+ CONTRACT_VERSION = "1"
17
+ GEMMA_BADGE = "Fully local analysis · open weights"
18
+
19
+ DEFAULT_WEIGHTS: dict[str, float] = {
20
+ "anchors": 0.15,
21
+ "semantic": 0.20,
22
+ "vision": 0.20,
23
+ "sandbox": 0.25,
24
+ }
25
+
26
+
27
+ def weights_fingerprint(weights: Mapping[str, float] | None = None) -> str:
28
+ """Return a stable short fingerprint for a fusion-weight configuration."""
29
+ payload = json.dumps(dict(weights or DEFAULT_WEIGHTS), sort_keys=True, separators=(",", ":"))
30
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:8]
31
+
32
+
33
+ def engine_version(weights: Mapping[str, float] | None = None) -> str:
34
+ """Engine version including the scoring-weight fingerprint.
35
+
36
+ Example: ``2.0.0+a1b2c3d4``.
37
+ """
38
+ return f"{ENGINE_VERSION}+{weights_fingerprint(weights)}"
@@ -0,0 +1,8 @@
1
+ """Bundled image extraction and vision analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from skill_sentry_cli.core.vision.image_extractor import ImageAsset, extract_images
6
+ from skill_sentry_cli.core.vision.vision_scanner import scan_vision
7
+
8
+ __all__ = ["ImageAsset", "extract_images", "scan_vision"]
@@ -0,0 +1,91 @@
1
+ """Bounded image discovery and safe preview construction for vision analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import mimetypes
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from skill_sentry_cli.core.parser import SkillPackage
11
+
12
+ IMAGE_SUFFIXES = frozenset(
13
+ {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".svg"}
14
+ )
15
+ MAX_IMAGES = 20
16
+ MAX_IMAGE_BYTES = 20 * 1024 * 1024
17
+ MAX_PREVIEW_BYTES = 2 * 1024 * 1024
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ImageAsset:
22
+ path: Path
23
+ relative: str
24
+ suffix: str
25
+ size: int
26
+ data: bytes
27
+ mime: str
28
+
29
+ @property
30
+ def is_svg(self) -> bool:
31
+ return self.suffix == ".svg"
32
+
33
+ def preview_uri(self) -> str:
34
+ payload = self.data
35
+ if len(payload) > MAX_PREVIEW_BYTES:
36
+ # The API schema needs a displayable string. Do not inline
37
+ # arbitrarily large blobs into a verdict; give the UI a stable
38
+ # relative reference instead.
39
+ return f"skill-file:{self.relative}"
40
+ encoded = base64.b64encode(payload).decode("ascii")
41
+ return f"data:{self.mime};base64,{encoded}"
42
+
43
+
44
+ def extract_images(package: SkillPackage) -> tuple[list[ImageAsset], list[str]]:
45
+ """Return image assets (bounded) and human-readable skip notes."""
46
+
47
+ assets: list[ImageAsset] = []
48
+ notes: list[str] = []
49
+ for path in package.files:
50
+ if len(assets) >= MAX_IMAGES:
51
+ notes.append(f"image limit reached ({MAX_IMAGES}); remaining images not scanned")
52
+ break
53
+ suffix = path.suffix.lower()
54
+ if suffix not in IMAGE_SUFFIXES:
55
+ continue
56
+ try:
57
+ size = path.stat().st_size
58
+ except OSError as exc:
59
+ notes.append(f"could not stat {path.name}: {exc}")
60
+ continue
61
+ if size > MAX_IMAGE_BYTES:
62
+ notes.append(f"{path.name} exceeds 20 MiB and was not scanned")
63
+ continue
64
+ try:
65
+ data = path.read_bytes()
66
+ except OSError as exc:
67
+ notes.append(f"could not read {path.name}: {exc}")
68
+ continue
69
+ mime = mimetypes.guess_type(path.name)[0] or (
70
+ "image/svg+xml" if suffix == ".svg" else "application/octet-stream"
71
+ )
72
+ assets.append(
73
+ ImageAsset(
74
+ path=path,
75
+ relative=path.relative_to(package.root).as_posix(),
76
+ suffix=suffix,
77
+ size=size,
78
+ data=data,
79
+ mime=mime,
80
+ )
81
+ )
82
+ return assets, notes
83
+
84
+
85
+ __all__ = [
86
+ "IMAGE_SUFFIXES",
87
+ "MAX_IMAGES",
88
+ "MAX_IMAGE_BYTES",
89
+ "ImageAsset",
90
+ "extract_images",
91
+ ]