adaptersentry 1.0.1__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.
- adaptersentry/__init__.py +14 -0
- adaptersentry/__main__.py +4 -0
- adaptersentry/analyzer.py +554 -0
- adaptersentry/cli/__init__.py +1 -0
- adaptersentry/cli/batch.py +353 -0
- adaptersentry/cli/main.py +68 -0
- adaptersentry/cli/scan.py +302 -0
- adaptersentry/detectors/__init__.py +27 -0
- adaptersentry/detectors/cross_layer.py +112 -0
- adaptersentry/detectors/entropy.py +93 -0
- adaptersentry/detectors/init_detector.py +111 -0
- adaptersentry/detectors/outlier.py +211 -0
- adaptersentry/detectors/wasserstein.py +175 -0
- adaptersentry/engine/__init__.py +17 -0
- adaptersentry/engine/cache.py +270 -0
- adaptersentry/engine/config.py +137 -0
- adaptersentry/engine/feature_extractor.py +561 -0
- adaptersentry/engine/identity.py +144 -0
- adaptersentry/engine/manifest.py +293 -0
- adaptersentry/engine/orchestrator.py +318 -0
- adaptersentry/engine/orchestrator_ray.py +353 -0
- adaptersentry/engine/result_sink.py +221 -0
- adaptersentry/engine/schemas/__init__.py +27 -0
- adaptersentry/engine/schemas/cache.py +69 -0
- adaptersentry/engine/schemas/combined_report.py +93 -0
- adaptersentry/engine/schemas/identity.py +82 -0
- adaptersentry/engine/schemas/requests.py +85 -0
- adaptersentry/engine/schemas/scan_result.py +148 -0
- adaptersentry/engine/schemas/scoring.py +104 -0
- adaptersentry/engine/schemas/signals.py +90 -0
- adaptersentry/engine/worker.py +363 -0
- adaptersentry/features/__init__.py +6 -0
- adaptersentry/features/delta_norm.py +232 -0
- adaptersentry/features/distribution.py +234 -0
- adaptersentry/features/entropy_compression.py +174 -0
- adaptersentry/features/inter_layer_similarity.py +195 -0
- adaptersentry/features/layer_stats.py +74 -0
- adaptersentry/features/tensor_stats.py +139 -0
- adaptersentry/integrations/__init__.py +1 -0
- adaptersentry/integrations/github_action.py +97 -0
- adaptersentry/parsers/__init__.py +24 -0
- adaptersentry/parsers/metadata.py +172 -0
- adaptersentry/parsers/safetensors.py +375 -0
- adaptersentry/reporters/__init__.py +7 -0
- adaptersentry/reporters/json.py +51 -0
- adaptersentry/reporters/sarif.py +223 -0
- adaptersentry/reporters/text.py +171 -0
- adaptersentry/reporting/__init__.py +0 -0
- adaptersentry/reporting/human_summary.py +293 -0
- adaptersentry/reporting/per_layer.py +198 -0
- adaptersentry/schemas/__init__.py +33 -0
- adaptersentry/schemas/adapter_metadata.py +78 -0
- adaptersentry/schemas/adapter_report.py +188 -0
- adaptersentry/schemas/confidence_score.py +146 -0
- adaptersentry/schemas/distribution_features.py +110 -0
- adaptersentry/schemas/entropy_compression_features.py +117 -0
- adaptersentry/schemas/errors.py +154 -0
- adaptersentry/schemas/finding.py +252 -0
- adaptersentry/schemas/inter_layer_similarity_features.py +80 -0
- adaptersentry/schemas/norm_features.py +64 -0
- adaptersentry/schemas/per_layer_finding.py +73 -0
- adaptersentry/schemas/score_breakdown.py +108 -0
- adaptersentry/schemas/scoring_policy.py +99 -0
- adaptersentry/schemas/tensor_record.py +125 -0
- adaptersentry/scoring/__init__.py +6 -0
- adaptersentry/scoring/confidence.py +231 -0
- adaptersentry/scoring/ensemble.py +399 -0
- adaptersentry/scoring/risk_scorer.py +128 -0
- adaptersentry/scoring/score_breakdown.py +558 -0
- adaptersentry/version.py +1 -0
- adaptersentry-1.0.1.dist-info/METADATA +750 -0
- adaptersentry-1.0.1.dist-info/RECORD +83 -0
- adaptersentry-1.0.1.dist-info/WHEEL +5 -0
- adaptersentry-1.0.1.dist-info/entry_points.txt +4 -0
- adaptersentry-1.0.1.dist-info/licenses/LICENSE +202 -0
- adaptersentry-1.0.1.dist-info/top_level.txt +2 -0
- benchmarks/__init__.py +1 -0
- benchmarks/check_regression.py +161 -0
- benchmarks/corpus.py +135 -0
- benchmarks/harness.py +497 -0
- benchmarks/hub_scanner.py +936 -0
- benchmarks/report.py +389 -0
- benchmarks/run_real.py +107 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""AdapterSentry — static security scanner for LoRA adapters.
|
|
2
|
+
|
|
3
|
+
Quick start
|
|
4
|
+
-----------
|
|
5
|
+
>>> from adaptersentry import analyze, scan
|
|
6
|
+
>>> report_dict = analyze(Path("adapter.safetensors")) # legacy dict API
|
|
7
|
+
>>> report = scan(Path("adapter.safetensors")) # typed AdapterReport API
|
|
8
|
+
>>> print(report.risk_summary.ensemble_risk_level)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from adaptersentry.version import __version__
|
|
12
|
+
from adaptersentry.analyzer import analyze, load_adapter, scan
|
|
13
|
+
|
|
14
|
+
__all__ = ["analyze", "load_adapter", "scan", "__version__"]
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""M1 Static Analyzer — weight tensor inspection for LoRA adapter detection.
|
|
2
|
+
|
|
3
|
+
Treats each LoRA adapter like a delta firmware patch: we inspect the patch
|
|
4
|
+
itself (static analysis) without running the model, looking for anomalous
|
|
5
|
+
weight distributions that indicate backdoors or alignment bypass.
|
|
6
|
+
|
|
7
|
+
Public API
|
|
8
|
+
----------
|
|
9
|
+
analyze(path, claimed_rank) → dict Legacy dict format (stable)
|
|
10
|
+
scan(path, claimed_rank) → AdapterReport New typed API (stable from v1.0.0)
|
|
11
|
+
load_adapter(path) → (tensors, metadata)
|
|
12
|
+
|
|
13
|
+
All private symbols re-exported here are for backward compatibility with
|
|
14
|
+
tests that were written before the src-layout refactor.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from adaptersentry.detectors.cross_layer import detect_cross_layer_anomalies
|
|
28
|
+
from adaptersentry.detectors.entropy import compute_entropy, detect_entropy_anomalies
|
|
29
|
+
from adaptersentry.detectors.init_detector import (
|
|
30
|
+
get_adapter_training_status,
|
|
31
|
+
suppress_init_flags,
|
|
32
|
+
)
|
|
33
|
+
from adaptersentry.detectors.outlier import detect_outlier_anomalies
|
|
34
|
+
from adaptersentry.detectors.wasserstein import compute_wasserstein_distance
|
|
35
|
+
from adaptersentry.features.delta_norm import compute_norm_features
|
|
36
|
+
from adaptersentry.features.distribution import compute_distribution_features
|
|
37
|
+
from adaptersentry.features.layer_stats import detect_layer_anomalies
|
|
38
|
+
from adaptersentry.features.tensor_stats import compute_svd_stats, compute_tensor_stats
|
|
39
|
+
from adaptersentry.parsers.metadata import _metadata_depth, parse_adapter_metadata
|
|
40
|
+
from adaptersentry.parsers.safetensors import _group_lora_layers, load_adapter
|
|
41
|
+
from adaptersentry.scoring.risk_scorer import RiskScorer
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Module-level scorer for the backward-compat wrappers
|
|
46
|
+
_default_scorer = RiskScorer()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Backward-compat thin wrappers (used by tests via `from adaptersentry.analyzer import …`)
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _score_from_flags(flags: list[str]) -> int:
|
|
55
|
+
"""Derive a 0–100 risk score from accumulated anomaly flags."""
|
|
56
|
+
return _default_scorer.score_flags(flags)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _risk_level(score: int) -> str:
|
|
60
|
+
"""Map a numeric risk score to a severity label."""
|
|
61
|
+
return _default_scorer.risk_level(score)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Internal analysis pipeline
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
_MAX_SAFE_METADATA_DEPTH = 5
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run_analysis(
|
|
72
|
+
adapter_path: Path,
|
|
73
|
+
claimed_rank: int | None = None,
|
|
74
|
+
*,
|
|
75
|
+
fast: bool = False,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
"""Core M1 analysis pipeline — returns the full dict report."""
|
|
78
|
+
tensors, metadata = load_adapter(adapter_path)
|
|
79
|
+
layers = _group_lora_layers(tensors)
|
|
80
|
+
|
|
81
|
+
all_flags: list[str] = []
|
|
82
|
+
layer_reports: dict[str, Any] = {}
|
|
83
|
+
_il_pairs: list[tuple[str, int, Any, Any]] = [] # (name, idx, tensor_A, tensor_B)
|
|
84
|
+
|
|
85
|
+
depth = _metadata_depth(metadata)
|
|
86
|
+
if depth > _MAX_SAFE_METADATA_DEPTH:
|
|
87
|
+
all_flags.append(
|
|
88
|
+
f"METADATA_DEPTH: nesting={depth} > {_MAX_SAFE_METADATA_DEPTH}"
|
|
89
|
+
" (possible metadata evasion attempt)"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if not metadata:
|
|
93
|
+
all_flags.append(
|
|
94
|
+
"MISSING_ADAPTER_METADATA: safetensors header contains no metadata"
|
|
95
|
+
" — adapter provenance unknown (legitimate distributed adapters usually"
|
|
96
|
+
" carry rank, base model, and peft_type)"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if claimed_rank is None:
|
|
100
|
+
for key in ("r", "rank", "lora_r"):
|
|
101
|
+
raw = metadata.get(key)
|
|
102
|
+
if raw is not None:
|
|
103
|
+
try:
|
|
104
|
+
claimed_rank = int(raw)
|
|
105
|
+
logger.debug("Inferred claimed_rank=%d from metadata[%r]", claimed_rank, key)
|
|
106
|
+
break
|
|
107
|
+
except (ValueError, TypeError):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
if not layers:
|
|
111
|
+
logger.warning("No lora_A/lora_B tensor pairs found in %s", adapter_path)
|
|
112
|
+
|
|
113
|
+
for _il_idx, (layer_name, pair) in enumerate(layers.items()):
|
|
114
|
+
tensor_A = pair.get("A")
|
|
115
|
+
tensor_B = pair.get("B")
|
|
116
|
+
|
|
117
|
+
if tensor_A is None or tensor_B is None:
|
|
118
|
+
logger.debug("Skipping incomplete pair for layer: %s", layer_name)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
_il_pairs.append((layer_name, _il_idx, tensor_A, tensor_B))
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
stats_A = compute_tensor_stats(tensor_A, fast=fast)
|
|
125
|
+
stats_B = compute_tensor_stats(tensor_B, fast=fast)
|
|
126
|
+
svd = compute_svd_stats(tensor_A, fast=fast)
|
|
127
|
+
layer_flags = detect_layer_anomalies(layer_name, stats_A, stats_B, svd, claimed_rank)
|
|
128
|
+
|
|
129
|
+
entropy_A = compute_entropy(tensor_A)
|
|
130
|
+
entropy_B = compute_entropy(tensor_B)
|
|
131
|
+
layer_flags += detect_entropy_anomalies(entropy_A, layer_name, "A")
|
|
132
|
+
layer_flags += detect_entropy_anomalies(entropy_B, layer_name, "B")
|
|
133
|
+
|
|
134
|
+
zs_A, iso_A, outlier_flags_A = detect_outlier_anomalies(
|
|
135
|
+
tensor_A, layer_name, "A", fast=fast
|
|
136
|
+
)
|
|
137
|
+
zs_B, _iso_B, outlier_flags_B = detect_outlier_anomalies(
|
|
138
|
+
tensor_B, layer_name, "B", run_isolation_forest=False, fast=fast
|
|
139
|
+
)
|
|
140
|
+
layer_flags += outlier_flags_A + outlier_flags_B
|
|
141
|
+
|
|
142
|
+
norm_feats = compute_norm_features(tensor_A, tensor_B, fast=fast)
|
|
143
|
+
dist_feats = compute_distribution_features(tensor_A, tensor_B, fast=fast)
|
|
144
|
+
|
|
145
|
+
all_flags.extend(layer_flags)
|
|
146
|
+
layer_reports[layer_name] = {
|
|
147
|
+
"shape_A": list(tensor_A.shape),
|
|
148
|
+
"shape_B": list(tensor_B.shape),
|
|
149
|
+
"rank": svd["effective_rank"],
|
|
150
|
+
"energy_concentration": svd["energy_concentration"],
|
|
151
|
+
"kurtosis_A": stats_A["kurtosis"],
|
|
152
|
+
"kurtosis_B": stats_B["kurtosis"],
|
|
153
|
+
"mean_A": stats_A["mean"],
|
|
154
|
+
"std_A": stats_A["std"],
|
|
155
|
+
"mean_B": stats_B["mean"],
|
|
156
|
+
"std_B": stats_B["std"],
|
|
157
|
+
"skewness_A": stats_A["skewness"],
|
|
158
|
+
"entropy_A": entropy_A,
|
|
159
|
+
"entropy_B": entropy_B,
|
|
160
|
+
"zscore_outlier_rate_A": zs_A["outlier_rate"],
|
|
161
|
+
"zscore_outlier_rate_B": zs_B["outlier_rate"],
|
|
162
|
+
"isolation_score_A": iso_A.get("mean_score"),
|
|
163
|
+
"flags": layer_flags,
|
|
164
|
+
"norm_features": norm_feats.model_dump() if norm_feats else None,
|
|
165
|
+
"distribution_features": dist_feats.model_dump() if dist_feats else None,
|
|
166
|
+
}
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
# Preserve a minimal degraded record so the layer is visible in the
|
|
169
|
+
# report and downstream cross-layer detectors account for it.
|
|
170
|
+
logger.warning("Layer %r: analysis failed — marking degraded: %s", layer_name, exc)
|
|
171
|
+
degraded_flag = f"DEGRADED_LAYER: analysis failed — {exc}"
|
|
172
|
+
all_flags.append(degraded_flag)
|
|
173
|
+
layer_reports[layer_name] = {
|
|
174
|
+
"shape_A": list(tensor_A.shape),
|
|
175
|
+
"shape_B": list(tensor_B.shape),
|
|
176
|
+
"rank": 0,
|
|
177
|
+
"energy_concentration": 0.0,
|
|
178
|
+
"kurtosis_A": 0.0,
|
|
179
|
+
"kurtosis_B": 0.0,
|
|
180
|
+
"mean_A": 0.0,
|
|
181
|
+
"std_A": 0.0,
|
|
182
|
+
"mean_B": 0.0,
|
|
183
|
+
"std_B": 0.0,
|
|
184
|
+
"skewness_A": 0.0,
|
|
185
|
+
"entropy_A": 0.0,
|
|
186
|
+
"entropy_B": 0.0,
|
|
187
|
+
"zscore_outlier_rate_A": 0.0,
|
|
188
|
+
"zscore_outlier_rate_B": 0.0,
|
|
189
|
+
"isolation_score_A": None,
|
|
190
|
+
"flags": [degraded_flag],
|
|
191
|
+
"parse_error": "degraded",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Init-only detection and flag suppression
|
|
195
|
+
training_status = get_adapter_training_status(layer_reports)
|
|
196
|
+
false_positive_suppressed = 0
|
|
197
|
+
|
|
198
|
+
if training_status == "INIT_ONLY":
|
|
199
|
+
all_flags, false_positive_suppressed = suppress_init_flags(all_flags)
|
|
200
|
+
for lname in layer_reports:
|
|
201
|
+
clean_layer_flags, _ = suppress_init_flags(layer_reports[lname].get("flags", []))
|
|
202
|
+
layer_reports[lname]["flags"] = clean_layer_flags
|
|
203
|
+
all_flags.append(
|
|
204
|
+
"INIT_ONLY_ADAPTER: all layers are at LoRA zero-init state"
|
|
205
|
+
" (B=0, A=uniform-random); init artifact flags suppressed"
|
|
206
|
+
)
|
|
207
|
+
logger.info(
|
|
208
|
+
"%s: INIT_ONLY — suppressed %d init-artifact flag(s)",
|
|
209
|
+
adapter_path.name, false_positive_suppressed,
|
|
210
|
+
)
|
|
211
|
+
elif training_status == "PARTIALLY_TRAINED":
|
|
212
|
+
all_flags.append(
|
|
213
|
+
"SUSPICIOUS_PARTIAL_TRAINING: some layers trained, others still at"
|
|
214
|
+
" LoRA init state — possible targeted-layer injection"
|
|
215
|
+
)
|
|
216
|
+
logger.warning(
|
|
217
|
+
"%s: PARTIALLY_TRAINED — possible targeted-layer injection", adapter_path.name
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
cross_consistency, cross_flags = detect_cross_layer_anomalies(layer_reports)
|
|
221
|
+
all_flags.extend(cross_flags)
|
|
222
|
+
|
|
223
|
+
wasserstein_distances: dict[str, float] = {}
|
|
224
|
+
for layer_name, pair in layers.items():
|
|
225
|
+
tensor_A = pair.get("A")
|
|
226
|
+
tensor_B = pair.get("B")
|
|
227
|
+
if tensor_A is not None and tensor_B is not None:
|
|
228
|
+
try:
|
|
229
|
+
w2 = compute_wasserstein_distance(tensor_A, tensor_B)
|
|
230
|
+
wasserstein_distances[layer_name] = w2
|
|
231
|
+
except Exception: # noqa: BLE001
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
if wasserstein_distances:
|
|
235
|
+
wasserstein_distances["_mean"] = float(
|
|
236
|
+
sum(wasserstein_distances.values()) / len(wasserstein_distances)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Inter-layer similarity (M1-ANAL-03)
|
|
240
|
+
_il_feats = None
|
|
241
|
+
if len(_il_pairs) >= 2:
|
|
242
|
+
try:
|
|
243
|
+
from adaptersentry.features.inter_layer_similarity import (
|
|
244
|
+
compute_inter_layer_similarity,
|
|
245
|
+
)
|
|
246
|
+
_il_feats = compute_inter_layer_similarity(_il_pairs, fast=fast)
|
|
247
|
+
except Exception as _il_exc:
|
|
248
|
+
logger.warning("Inter-layer similarity failed: %s", _il_exc)
|
|
249
|
+
|
|
250
|
+
partial_report = {
|
|
251
|
+
"flags": all_flags,
|
|
252
|
+
"layers": layer_reports,
|
|
253
|
+
"cross_layer_consistency": cross_consistency,
|
|
254
|
+
"wasserstein_distances": wasserstein_distances,
|
|
255
|
+
}
|
|
256
|
+
overall_risk, risk_level = RiskScorer().score_report(partial_report)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
from adaptersentry.scoring.ensemble import EnsembleDetector
|
|
260
|
+
detector = EnsembleDetector()
|
|
261
|
+
w2_mean = wasserstein_distances.get("_mean", 0.0)
|
|
262
|
+
ensemble_score = float(detector.score(
|
|
263
|
+
layer_reports,
|
|
264
|
+
wasserstein_score=w2_mean,
|
|
265
|
+
cross_layer_consistency=cross_consistency,
|
|
266
|
+
))
|
|
267
|
+
ensemble_risk_level = detector.risk_level(int(ensemble_score))
|
|
268
|
+
ensemble_explanation = detector.explain(
|
|
269
|
+
layer_reports,
|
|
270
|
+
wasserstein_score=w2_mean,
|
|
271
|
+
cross_layer_consistency=cross_consistency,
|
|
272
|
+
)
|
|
273
|
+
except Exception: # noqa: BLE001
|
|
274
|
+
ensemble_score = float(overall_risk)
|
|
275
|
+
ensemble_risk_level = risk_level
|
|
276
|
+
ensemble_explanation = []
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
"adapter_path": str(adapter_path.resolve()),
|
|
280
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
281
|
+
"overall_risk": overall_risk,
|
|
282
|
+
"risk_level": risk_level,
|
|
283
|
+
"flags": all_flags,
|
|
284
|
+
"layers": layer_reports,
|
|
285
|
+
"metadata": metadata,
|
|
286
|
+
"ensemble_score": ensemble_score,
|
|
287
|
+
"ensemble_risk_level": ensemble_risk_level,
|
|
288
|
+
"ensemble_explanation": ensemble_explanation,
|
|
289
|
+
"cross_layer_consistency": cross_consistency,
|
|
290
|
+
"wasserstein_distances": wasserstein_distances,
|
|
291
|
+
"training_status": training_status,
|
|
292
|
+
"false_positive_suppressed": false_positive_suppressed,
|
|
293
|
+
"inter_layer_similarity_features": _il_feats.model_dump() if _il_feats else None,
|
|
294
|
+
"summary": (
|
|
295
|
+
f"Analyzed {len(layer_reports)} LoRA layer(s). "
|
|
296
|
+
f"Training status: {training_status}. "
|
|
297
|
+
f"Risk: {risk_level} ({overall_risk}/100) | "
|
|
298
|
+
f"Ensemble: {ensemble_risk_level} ({ensemble_score:.1f}/100). "
|
|
299
|
+
f"{len(all_flags)} flag(s) ({false_positive_suppressed} init artifact(s) suppressed)."
|
|
300
|
+
),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# Public API
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def analyze(adapter_path: Path, claimed_rank: int | None = None) -> dict[str, Any]:
|
|
310
|
+
"""Run M1 static analysis on a LoRA adapter file.
|
|
311
|
+
|
|
312
|
+
Loads the adapter without executing the model, computes per-layer weight
|
|
313
|
+
statistics, applies anomaly detection rules, and returns a structured JSON
|
|
314
|
+
risk report conforming to the M1 output schema defined in README.md.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
adapter_path: Path to the .safetensors file.
|
|
318
|
+
claimed_rank: Expected LoRA rank r from the adapter config, if known.
|
|
319
|
+
Inferred from metadata keys "r", "rank", or "lora_r"
|
|
320
|
+
when not provided.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Risk report dict conforming to the M1 JSON schema.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
FileNotFoundError: If the adapter file does not exist.
|
|
327
|
+
ValueError: If the file cannot be parsed as safetensors.
|
|
328
|
+
Exception: Other unexpected failures from downstream detectors may
|
|
329
|
+
propagate. Use scan() for a fully exception-safe public API.
|
|
330
|
+
"""
|
|
331
|
+
return _run_analysis(adapter_path, claimed_rank)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def scan(
|
|
335
|
+
adapter_path: Path,
|
|
336
|
+
claimed_rank: int | None = None,
|
|
337
|
+
*,
|
|
338
|
+
fast: bool = False,
|
|
339
|
+
) -> "Any": # AdapterReport — imported lazily to keep analyze() fast
|
|
340
|
+
"""Run M1 static analysis and return a typed AdapterReport.
|
|
341
|
+
|
|
342
|
+
This is the preferred API for new callers. The legacy ``analyze()``
|
|
343
|
+
function is preserved for backward compatibility with existing tools
|
|
344
|
+
and benchmarks.
|
|
345
|
+
|
|
346
|
+
Unlike analyze(), scan() never raises — all failure modes are captured in
|
|
347
|
+
AdapterReport.parse_status / .errors so the caller always receives a
|
|
348
|
+
schema-stable result. Prefer scan() over analyze() for new integrations.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
adapter_path: Path to the .safetensors file.
|
|
352
|
+
claimed_rank: Expected LoRA rank r, inferred from metadata if omitted.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
AdapterReport — versioned schema-stable result. Check parse_status
|
|
356
|
+
and analysis_mode before consuming findings or tensor_records.
|
|
357
|
+
"""
|
|
358
|
+
from adaptersentry.schemas.adapter_report import (
|
|
359
|
+
AdapterReport, AnalysisMode, ParseStatus, RiskSummary, ScanTarget, ToolInfo, TrainingStatus,
|
|
360
|
+
)
|
|
361
|
+
from adaptersentry.schemas.adapter_metadata import AdapterMetadata
|
|
362
|
+
from adaptersentry.schemas.errors import ErrorCategory, ScanError
|
|
363
|
+
from adaptersentry.schemas.finding import flags_to_findings, Severity
|
|
364
|
+
from adaptersentry.schemas.inter_layer_similarity_features import InterLayerSimilarityFeatures
|
|
365
|
+
from adaptersentry.schemas.tensor_record import TensorRecord
|
|
366
|
+
from adaptersentry.version import __version__
|
|
367
|
+
|
|
368
|
+
started_at = datetime.now(timezone.utc).isoformat()
|
|
369
|
+
|
|
370
|
+
# ── File-level failure path ───────────────────────────────────────────────
|
|
371
|
+
try:
|
|
372
|
+
raw = _run_analysis(adapter_path, claimed_rank, fast=fast)
|
|
373
|
+
except Exception as exc:
|
|
374
|
+
completed_at = datetime.now(timezone.utc).isoformat()
|
|
375
|
+
try:
|
|
376
|
+
file_size: int | None = adapter_path.stat().st_size
|
|
377
|
+
except OSError:
|
|
378
|
+
file_size = None
|
|
379
|
+
return AdapterReport(
|
|
380
|
+
tool=ToolInfo(version=__version__),
|
|
381
|
+
scan_target=ScanTarget(path=str(adapter_path.resolve()), file_size_bytes=file_size),
|
|
382
|
+
adapter_metadata=AdapterMetadata.from_parsed({}),
|
|
383
|
+
tensor_records=[],
|
|
384
|
+
findings=[],
|
|
385
|
+
errors=[ScanError.malformed(
|
|
386
|
+
code="INVALID_SAFETENSORS",
|
|
387
|
+
message=str(exc),
|
|
388
|
+
detail=type(exc).__name__,
|
|
389
|
+
)],
|
|
390
|
+
risk_summary=RiskSummary(
|
|
391
|
+
overall_risk=0,
|
|
392
|
+
risk_level=Severity.LOW,
|
|
393
|
+
ensemble_score=0.0,
|
|
394
|
+
ensemble_risk_level=Severity.LOW,
|
|
395
|
+
training_status=TrainingStatus.UNKNOWN,
|
|
396
|
+
n_layers=0,
|
|
397
|
+
n_findings=0,
|
|
398
|
+
cross_layer_consistency=1.0,
|
|
399
|
+
),
|
|
400
|
+
analysis_mode=AnalysisMode.FAILED,
|
|
401
|
+
parse_status=ParseStatus.FAILED,
|
|
402
|
+
started_at=started_at,
|
|
403
|
+
completed_at=completed_at,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
completed_at = datetime.now(timezone.utc).isoformat()
|
|
407
|
+
|
|
408
|
+
# Parse metadata
|
|
409
|
+
parsed_meta = parse_adapter_metadata(raw.get("metadata", {}))
|
|
410
|
+
adapter_meta = AdapterMetadata.from_parsed(parsed_meta)
|
|
411
|
+
|
|
412
|
+
# Tensor records
|
|
413
|
+
tensor_records = [
|
|
414
|
+
TensorRecord.from_layer_dict(lname, ldata)
|
|
415
|
+
for lname, ldata in raw.get("layers", {}).items()
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
# Findings
|
|
419
|
+
findings = flags_to_findings(raw.get("flags", []), raw.get("layers", {}))
|
|
420
|
+
|
|
421
|
+
# Errors — promote degraded layer flags to ScanError entries
|
|
422
|
+
errors: list[ScanError] = []
|
|
423
|
+
for tr in tensor_records:
|
|
424
|
+
if tr.parse_error is not None:
|
|
425
|
+
errors.append(ScanError.degraded(
|
|
426
|
+
code="PARTIAL_LAYER_ANALYSIS",
|
|
427
|
+
message=f"Layer {tr.layer_name!r} parse error: {tr.parse_error.value}",
|
|
428
|
+
detail=next(
|
|
429
|
+
(f for f in tr.flags if f.startswith("DEGRADED_LAYER")), None
|
|
430
|
+
),
|
|
431
|
+
))
|
|
432
|
+
|
|
433
|
+
# Derive parse_status from tensor records
|
|
434
|
+
if any(tr.parse_error == ErrorCategory.MALFORMED for tr in tensor_records):
|
|
435
|
+
parse_status = ParseStatus.DEGRADED
|
|
436
|
+
elif any(tr.parse_error is not None for tr in tensor_records):
|
|
437
|
+
parse_status = ParseStatus.DEGRADED
|
|
438
|
+
else:
|
|
439
|
+
parse_status = ParseStatus.OK
|
|
440
|
+
|
|
441
|
+
# Risk summary
|
|
442
|
+
ts_raw = raw.get("training_status", "TRAINED")
|
|
443
|
+
try:
|
|
444
|
+
training_status = TrainingStatus(ts_raw)
|
|
445
|
+
except ValueError:
|
|
446
|
+
training_status = TrainingStatus.UNKNOWN
|
|
447
|
+
|
|
448
|
+
w2_distances = raw.get("wasserstein_distances", {})
|
|
449
|
+
w2_mean = w2_distances.get("_mean")
|
|
450
|
+
|
|
451
|
+
risk_summary = RiskSummary(
|
|
452
|
+
overall_risk=raw["overall_risk"],
|
|
453
|
+
risk_level=Severity(raw["risk_level"]),
|
|
454
|
+
ensemble_score=raw["ensemble_score"],
|
|
455
|
+
ensemble_risk_level=Severity(raw["ensemble_risk_level"]),
|
|
456
|
+
training_status=training_status,
|
|
457
|
+
false_positive_suppressed=raw.get("false_positive_suppressed", 0),
|
|
458
|
+
n_layers=len(raw.get("layers", {})),
|
|
459
|
+
n_findings=len(findings),
|
|
460
|
+
cross_layer_consistency=raw.get("cross_layer_consistency", 1.0),
|
|
461
|
+
wasserstein_mean=w2_mean if isinstance(w2_mean, float) else None,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
il_dict = raw.get("inter_layer_similarity_features")
|
|
465
|
+
try:
|
|
466
|
+
il_feats = InterLayerSimilarityFeatures(**il_dict) if il_dict else None
|
|
467
|
+
except Exception:
|
|
468
|
+
il_feats = None
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
file_size = adapter_path.stat().st_size
|
|
472
|
+
except OSError:
|
|
473
|
+
file_size = None
|
|
474
|
+
|
|
475
|
+
return AdapterReport(
|
|
476
|
+
tool=ToolInfo(version=__version__),
|
|
477
|
+
scan_target=ScanTarget(
|
|
478
|
+
path=str(adapter_path.resolve()),
|
|
479
|
+
file_size_bytes=file_size,
|
|
480
|
+
),
|
|
481
|
+
adapter_metadata=adapter_meta,
|
|
482
|
+
tensor_records=tensor_records,
|
|
483
|
+
findings=findings,
|
|
484
|
+
errors=errors,
|
|
485
|
+
risk_summary=risk_summary,
|
|
486
|
+
analysis_mode=AnalysisMode.FULL if not errors else AnalysisMode.DEGRADED,
|
|
487
|
+
parse_status=parse_status,
|
|
488
|
+
started_at=started_at,
|
|
489
|
+
completed_at=completed_at,
|
|
490
|
+
inter_layer_similarity_features=il_feats,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
# Legacy CLI (kept for backward compatibility — new CLI is `adaptersentry scan`)
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
500
|
+
parser = argparse.ArgumentParser(
|
|
501
|
+
prog="adaptersentry-m1",
|
|
502
|
+
description="AdapterSentry M1 — Static LoRA adapter analyzer",
|
|
503
|
+
)
|
|
504
|
+
parser.add_argument(
|
|
505
|
+
"--adapter", required=True, type=Path, metavar="FILE",
|
|
506
|
+
help="Path to .safetensors adapter file",
|
|
507
|
+
)
|
|
508
|
+
parser.add_argument(
|
|
509
|
+
"--output", type=Path, default=None, metavar="FILE",
|
|
510
|
+
help="Write JSON report to this file (default: stdout)",
|
|
511
|
+
)
|
|
512
|
+
parser.add_argument(
|
|
513
|
+
"--rank", type=int, default=None, metavar="R",
|
|
514
|
+
help="Expected LoRA rank r from adapter config (overrides metadata)",
|
|
515
|
+
)
|
|
516
|
+
parser.add_argument(
|
|
517
|
+
"--verbose", action="store_true",
|
|
518
|
+
help="Enable debug logging",
|
|
519
|
+
)
|
|
520
|
+
return parser
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def main() -> None:
|
|
524
|
+
"""CLI entry point for the legacy adaptersentry-m1 command."""
|
|
525
|
+
parser = _build_arg_parser()
|
|
526
|
+
args = parser.parse_args()
|
|
527
|
+
|
|
528
|
+
logging.basicConfig(
|
|
529
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
530
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
report = analyze(args.adapter, claimed_rank=args.rank)
|
|
535
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
536
|
+
logger.error("%s", exc)
|
|
537
|
+
sys.exit(1)
|
|
538
|
+
|
|
539
|
+
json_out = json.dumps(report, indent=2)
|
|
540
|
+
|
|
541
|
+
if args.output:
|
|
542
|
+
args.output.write_text(json_out)
|
|
543
|
+
logger.info("Report written to %s", args.output)
|
|
544
|
+
else:
|
|
545
|
+
try:
|
|
546
|
+
from rich.console import Console
|
|
547
|
+
from rich.syntax import Syntax
|
|
548
|
+
Console().print(Syntax(json_out, "json", theme="monokai"))
|
|
549
|
+
except ImportError:
|
|
550
|
+
sys.stdout.write(json_out + "\n")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
if __name__ == "__main__":
|
|
554
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI subpackage — argument parsing and output routing."""
|