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.
Files changed (83) hide show
  1. adaptersentry/__init__.py +14 -0
  2. adaptersentry/__main__.py +4 -0
  3. adaptersentry/analyzer.py +554 -0
  4. adaptersentry/cli/__init__.py +1 -0
  5. adaptersentry/cli/batch.py +353 -0
  6. adaptersentry/cli/main.py +68 -0
  7. adaptersentry/cli/scan.py +302 -0
  8. adaptersentry/detectors/__init__.py +27 -0
  9. adaptersentry/detectors/cross_layer.py +112 -0
  10. adaptersentry/detectors/entropy.py +93 -0
  11. adaptersentry/detectors/init_detector.py +111 -0
  12. adaptersentry/detectors/outlier.py +211 -0
  13. adaptersentry/detectors/wasserstein.py +175 -0
  14. adaptersentry/engine/__init__.py +17 -0
  15. adaptersentry/engine/cache.py +270 -0
  16. adaptersentry/engine/config.py +137 -0
  17. adaptersentry/engine/feature_extractor.py +561 -0
  18. adaptersentry/engine/identity.py +144 -0
  19. adaptersentry/engine/manifest.py +293 -0
  20. adaptersentry/engine/orchestrator.py +318 -0
  21. adaptersentry/engine/orchestrator_ray.py +353 -0
  22. adaptersentry/engine/result_sink.py +221 -0
  23. adaptersentry/engine/schemas/__init__.py +27 -0
  24. adaptersentry/engine/schemas/cache.py +69 -0
  25. adaptersentry/engine/schemas/combined_report.py +93 -0
  26. adaptersentry/engine/schemas/identity.py +82 -0
  27. adaptersentry/engine/schemas/requests.py +85 -0
  28. adaptersentry/engine/schemas/scan_result.py +148 -0
  29. adaptersentry/engine/schemas/scoring.py +104 -0
  30. adaptersentry/engine/schemas/signals.py +90 -0
  31. adaptersentry/engine/worker.py +363 -0
  32. adaptersentry/features/__init__.py +6 -0
  33. adaptersentry/features/delta_norm.py +232 -0
  34. adaptersentry/features/distribution.py +234 -0
  35. adaptersentry/features/entropy_compression.py +174 -0
  36. adaptersentry/features/inter_layer_similarity.py +195 -0
  37. adaptersentry/features/layer_stats.py +74 -0
  38. adaptersentry/features/tensor_stats.py +139 -0
  39. adaptersentry/integrations/__init__.py +1 -0
  40. adaptersentry/integrations/github_action.py +97 -0
  41. adaptersentry/parsers/__init__.py +24 -0
  42. adaptersentry/parsers/metadata.py +172 -0
  43. adaptersentry/parsers/safetensors.py +375 -0
  44. adaptersentry/reporters/__init__.py +7 -0
  45. adaptersentry/reporters/json.py +51 -0
  46. adaptersentry/reporters/sarif.py +223 -0
  47. adaptersentry/reporters/text.py +171 -0
  48. adaptersentry/reporting/__init__.py +0 -0
  49. adaptersentry/reporting/human_summary.py +293 -0
  50. adaptersentry/reporting/per_layer.py +198 -0
  51. adaptersentry/schemas/__init__.py +33 -0
  52. adaptersentry/schemas/adapter_metadata.py +78 -0
  53. adaptersentry/schemas/adapter_report.py +188 -0
  54. adaptersentry/schemas/confidence_score.py +146 -0
  55. adaptersentry/schemas/distribution_features.py +110 -0
  56. adaptersentry/schemas/entropy_compression_features.py +117 -0
  57. adaptersentry/schemas/errors.py +154 -0
  58. adaptersentry/schemas/finding.py +252 -0
  59. adaptersentry/schemas/inter_layer_similarity_features.py +80 -0
  60. adaptersentry/schemas/norm_features.py +64 -0
  61. adaptersentry/schemas/per_layer_finding.py +73 -0
  62. adaptersentry/schemas/score_breakdown.py +108 -0
  63. adaptersentry/schemas/scoring_policy.py +99 -0
  64. adaptersentry/schemas/tensor_record.py +125 -0
  65. adaptersentry/scoring/__init__.py +6 -0
  66. adaptersentry/scoring/confidence.py +231 -0
  67. adaptersentry/scoring/ensemble.py +399 -0
  68. adaptersentry/scoring/risk_scorer.py +128 -0
  69. adaptersentry/scoring/score_breakdown.py +558 -0
  70. adaptersentry/version.py +1 -0
  71. adaptersentry-1.0.1.dist-info/METADATA +750 -0
  72. adaptersentry-1.0.1.dist-info/RECORD +83 -0
  73. adaptersentry-1.0.1.dist-info/WHEEL +5 -0
  74. adaptersentry-1.0.1.dist-info/entry_points.txt +4 -0
  75. adaptersentry-1.0.1.dist-info/licenses/LICENSE +202 -0
  76. adaptersentry-1.0.1.dist-info/top_level.txt +2 -0
  77. benchmarks/__init__.py +1 -0
  78. benchmarks/check_regression.py +161 -0
  79. benchmarks/corpus.py +135 -0
  80. benchmarks/harness.py +497 -0
  81. benchmarks/hub_scanner.py +936 -0
  82. benchmarks/report.py +389 -0
  83. 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,4 @@
1
+ """Entry point for ``python -m adaptersentry``."""
2
+ from adaptersentry.cli.main import main
3
+
4
+ main()
@@ -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."""