ladex 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.
Files changed (50) hide show
  1. ladex/__init__.py +8 -0
  2. ladex/cli/__init__.py +394 -0
  3. ladex/cli/report.py +306 -0
  4. ladex/engine/__init__.py +1 -0
  5. ladex/engine/attest/__init__.py +31 -0
  6. ladex/engine/attest/service.py +69 -0
  7. ladex/engine/attest/signer.py +107 -0
  8. ladex/engine/attest/statement.py +61 -0
  9. ladex/engine/attest/store.py +100 -0
  10. ladex/engine/bom/__init__.py +7 -0
  11. ladex/engine/bom/cyclonedx.py +215 -0
  12. ladex/engine/detect/__init__.py +8 -0
  13. ladex/engine/detect/ast_walker.py +263 -0
  14. ladex/engine/detect/iac.py +346 -0
  15. ladex/engine/detect/records.py +47 -0
  16. ladex/engine/enrich/__init__.py +34 -0
  17. ladex/engine/enrich/cache.py +98 -0
  18. ladex/engine/enrich/hfhub.py +74 -0
  19. ladex/engine/enrich/http.py +84 -0
  20. ladex/engine/enrich/models.py +104 -0
  21. ladex/engine/enrich/osv.py +53 -0
  22. ladex/engine/enrich/pypi.py +59 -0
  23. ladex/engine/enrich/service.py +73 -0
  24. ladex/engine/policy/__init__.py +41 -0
  25. ladex/engine/policy/context.py +71 -0
  26. ladex/engine/policy/evaluate.py +93 -0
  27. ladex/engine/policy/loader.py +91 -0
  28. ladex/engine/policy/models.py +108 -0
  29. ladex/engine/policy/report.py +62 -0
  30. ladex/engine/policy/service.py +16 -0
  31. ladex/engine/scan.py +116 -0
  32. ladex/engine/server/__init__.py +8 -0
  33. ladex/engine/server/diagnostics.py +55 -0
  34. ladex/engine/server/server.py +51 -0
  35. ladex/engine/taxonomy/__init__.py +41 -0
  36. ladex/engine/taxonomy/loader.py +128 -0
  37. ladex/engine/taxonomy/models.py +146 -0
  38. ladex/packs/__init__.py +1 -0
  39. ladex/packs/iac/iac.yaml +59 -0
  40. ladex/packs/policy/eu_ai_act/art50.yaml +42 -0
  41. ladex/packs/taxonomy/frameworks.yaml +46 -0
  42. ladex/packs/taxonomy/inference_apis.yaml +73 -0
  43. ladex/packs/taxonomy/model_loaders.yaml +66 -0
  44. ladex/packs/taxonomy/vector_stores.yaml +37 -0
  45. ladex/py.typed +0 -0
  46. ladex-0.1.0.dist-info/METADATA +164 -0
  47. ladex-0.1.0.dist-info/RECORD +50 -0
  48. ladex-0.1.0.dist-info/WHEEL +4 -0
  49. ladex-0.1.0.dist-info/entry_points.txt +2 -0
  50. ladex-0.1.0.dist-info/licenses/LICENSE +21 -0
ladex/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Ladex — a bill of lading for AI.
2
+
3
+ Ladex records what AI is aboard a codebase — models, datasets, agent frameworks,
4
+ vector stores, inference APIs — captured at the moment the code is written, and emits a
5
+ CycloneDX ML-BOM with signed human attestations for the fields no scanner can derive.
6
+ """
7
+
8
+ __version__ = "0.1.0"
ladex/cli/__init__.py ADDED
@@ -0,0 +1,394 @@
1
+ """Ladex command-line interface.
2
+
3
+ Kept intentionally dependency-free (stdlib ``argparse``) until Step 3, when ``ladex scan``
4
+ and a richer CLI (typer/rich) land. For now it exposes taxonomy inspection so Step 1 is
5
+ runnable and verifiable:
6
+
7
+ ladex --version
8
+ ladex scan [PATH] [--json]
9
+ ladex taxonomy validate [PACK.yaml ...]
10
+ ladex taxonomy list
11
+ ladex detect FILE.py
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from ladex import __version__
22
+ from ladex.engine.detect import PythonDetector
23
+ from ladex.engine.scan import scan_path
24
+ from ladex.engine.taxonomy import (
25
+ AttributeMatch,
26
+ CallMatch,
27
+ ImportMatch,
28
+ Rule,
29
+ StringMatch,
30
+ TaxonomyError,
31
+ aggregate,
32
+ load_builtin_packs,
33
+ load_pack_file,
34
+ )
35
+
36
+ _VERSION_FLAGS = {"--version", "-V", "version"}
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ """Entry point for the ``ladex`` console script."""
41
+ args = sys.argv[1:] if argv is None else argv
42
+ if not args:
43
+ _print_banner()
44
+ return 0
45
+ if args[0] in _VERSION_FLAGS:
46
+ print(f"ladex {__version__}")
47
+ return 0
48
+ if args[0] == "scan":
49
+ return _scan_command(args[1:])
50
+ if args[0] == "policy":
51
+ return _policy_command(args[1:])
52
+ if args[0] == "attest":
53
+ return _attest_command(args[1:])
54
+ if args[0] == "verify":
55
+ return _verify_command(args[1:])
56
+ if args[0] == "serve":
57
+ return _serve_command(args[1:])
58
+ if args[0] == "taxonomy":
59
+ return _taxonomy_command(args[1:])
60
+ if args[0] == "detect":
61
+ return _detect_command(args[1:])
62
+ print(f"ladex {__version__} - a bill of lading for AI")
63
+ print(f"unknown command: {args[0]!r}. Try 'ladex taxonomy list'.", file=sys.stderr)
64
+ return 2
65
+
66
+
67
+ def _print_banner() -> None:
68
+ print(f"ladex {__version__} - a bill of lading for AI")
69
+ print("Commands: `ladex scan [PATH]`, `ladex detect FILE`, `ladex taxonomy list`.")
70
+
71
+
72
+ def _scan_command(args: list[str]) -> int:
73
+ if args and args[0] in {"-h", "--help"}:
74
+ print(
75
+ "usage: ladex scan [PATH] [--json] [--enrich] [--offline] [--hf-token TOKEN]\n"
76
+ " [--write-bom [FILE]]",
77
+ file=sys.stderr,
78
+ )
79
+ return 0
80
+ as_json = "--json" in args
81
+ write_bom = "--write-bom" in args
82
+ do_enrich = "--enrich" in args or "--offline" in args
83
+ offline = "--offline" in args
84
+ hf_token = _flag_value(args, "--hf-token") or os.environ.get("HF_TOKEN")
85
+ bom_path = Path(_flag_value(args, "--write-bom") or "aibom.cdx.json")
86
+ consumed = {v for v in (hf_token, str(bom_path)) if v}
87
+ positional = [a for a in args if not a.startswith("-") and a not in consumed]
88
+ root = Path(positional[0]) if positional else Path(".")
89
+ if not root.exists():
90
+ print(f"path does not exist: {root}", file=sys.stderr)
91
+ return 2
92
+
93
+ # Import rendering lazily so `--json` needs no rich formatting path.
94
+ from ladex.cli.report import (
95
+ enrichment_to_dict,
96
+ render_enrichment,
97
+ render_scan,
98
+ scan_to_dict,
99
+ )
100
+
101
+ result = scan_path(root)
102
+ enrichment = None
103
+ if do_enrich:
104
+ from ladex.engine.enrich import enrich_scan
105
+
106
+ enrichment = enrich_scan(result, offline=offline, hf_token=hf_token)
107
+
108
+ if write_bom:
109
+ from ladex.engine.attest import AttestationStore
110
+ from ladex.engine.bom import build_bom, render_json
111
+ from ladex.engine.policy import check_scan
112
+
113
+ policy = check_scan(result)
114
+ attestations = AttestationStore.for_root(root).load()
115
+ bom = build_bom(result, enrichment=enrichment, policy=policy, attestations=attestations)
116
+ bom_path.write_text(render_json(bom), encoding="utf-8")
117
+ print(f"wrote {bom_path} ({len(result.detections)} detection(s))")
118
+ return 0
119
+
120
+ if as_json:
121
+ payload = scan_to_dict(result)
122
+ if enrichment is not None:
123
+ payload["enrichment"] = enrichment_to_dict(enrichment)
124
+ print(json.dumps(payload, indent=2))
125
+ else:
126
+ render_scan(result)
127
+ if enrichment is not None:
128
+ render_enrichment(enrichment)
129
+ # `scan` is informational: presence of AI is not a failure. Gating on obligations
130
+ # and gaps is the policy layer's job (Step 5).
131
+ return 0
132
+
133
+
134
+ def _flag_value(args: list[str], flag: str) -> str | None:
135
+ if flag in args:
136
+ idx = args.index(flag)
137
+ if idx + 1 < len(args):
138
+ return args[idx + 1]
139
+ return None
140
+
141
+
142
+ def _tristate(args: list[str], yes: str, no: str) -> bool | None:
143
+ if yes in args:
144
+ return True
145
+ if no in args:
146
+ return False
147
+ return None
148
+
149
+
150
+ def _policy_command(args: list[str]) -> int:
151
+ if not args or args[0] in {"-h", "--help"}:
152
+ print(
153
+ "usage: ladex policy {check|list} [PATH] [--json]\n"
154
+ " project facts: --user-facing/--not-user-facing, "
155
+ "--synthetic-content/--no-synthetic-content",
156
+ file=sys.stderr,
157
+ )
158
+ return 0 if args else 2
159
+ sub, rest = args[0], args[1:]
160
+ if sub == "list":
161
+ return _policy_list()
162
+ if sub == "check":
163
+ return _policy_check(rest)
164
+ print(f"unknown policy subcommand: {sub!r}", file=sys.stderr)
165
+ return 2
166
+
167
+
168
+ def _policy_list() -> int:
169
+ from ladex.engine.policy import PolicyError, load_builtin_bundles
170
+
171
+ try:
172
+ bundles = load_builtin_bundles()
173
+ except PolicyError as exc:
174
+ print(f"INVALID: {exc}", file=sys.stderr)
175
+ return 1
176
+ for bundle in bundles:
177
+ print(f"{bundle.id} ({bundle.regulation}) v{bundle.version}: {len(bundle.rules)} rule(s)")
178
+ for rule in bundle.rules:
179
+ print(f" - {rule.citation:<10} {rule.id} [{rule.verification.value}]")
180
+ return 0
181
+
182
+
183
+ def _policy_check(args: list[str]) -> int:
184
+ from ladex.cli.report import policy_to_dict, render_policy
185
+ from ladex.engine.policy import PolicyError, ProjectContext, check_scan
186
+
187
+ as_json = "--json" in args
188
+ project = ProjectContext(
189
+ user_facing=_tristate(args, "--user-facing", "--not-user-facing"),
190
+ generates_synthetic_content=_tristate(
191
+ args, "--synthetic-content", "--no-synthetic-content"
192
+ ),
193
+ )
194
+ positional = [a for a in args if not a.startswith("-")]
195
+ root = Path(positional[0]) if positional else Path(".")
196
+ if not root.exists():
197
+ print(f"path does not exist: {root}", file=sys.stderr)
198
+ return 2
199
+
200
+ try:
201
+ result = scan_path(root)
202
+ report = check_scan(result, project)
203
+ except PolicyError as exc:
204
+ print(f"INVALID: {exc}", file=sys.stderr)
205
+ return 1
206
+
207
+ if as_json:
208
+ print(json.dumps(policy_to_dict(report), indent=2))
209
+ else:
210
+ render_policy(report)
211
+ # Informational for now; the CI gate on open gaps turns on with attestation (Step 7).
212
+ return 0
213
+
214
+
215
+ def _attest_command(args: list[str]) -> int:
216
+ if not args or args[0] in {"-h", "--help"}:
217
+ print(
218
+ "usage: ladex attest SUBJECT --claim provenance --value TEXT [--attester WHO]\n"
219
+ " [--path DIR]\n"
220
+ " SUBJECT is a model id (e.g. sentence-transformers/all-MiniLM-L6-v2).",
221
+ file=sys.stderr,
222
+ )
223
+ return 0 if args else 2
224
+
225
+ from ladex.engine.attest import (
226
+ ATTESTABLE_CLAIMS,
227
+ AttestationStore,
228
+ create_attestation,
229
+ get_signer,
230
+ )
231
+
232
+ positional = [a for a in args if not a.startswith("-")]
233
+ consumed = {
234
+ v
235
+ for v in (
236
+ _flag_value(args, "--claim"),
237
+ _flag_value(args, "--value"),
238
+ _flag_value(args, "--attester"),
239
+ _flag_value(args, "--path"),
240
+ )
241
+ if v
242
+ }
243
+ positional = [p for p in positional if p not in consumed]
244
+ if not positional:
245
+ print("attest requires a SUBJECT (model id)", file=sys.stderr)
246
+ return 2
247
+ subject = positional[0]
248
+ claim = _flag_value(args, "--claim") or "provenance"
249
+ if claim not in ATTESTABLE_CLAIMS:
250
+ print(f"unknown claim {claim!r}; choose from {list(ATTESTABLE_CLAIMS)}", file=sys.stderr)
251
+ return 2
252
+ value = _flag_value(args, "--value")
253
+ if value is None:
254
+ print("attest requires --value (the declaration text)", file=sys.stderr)
255
+ return 2
256
+ attester = _flag_value(args, "--attester") or _git_email() or "unknown"
257
+ root = Path(_flag_value(args, "--path") or ".")
258
+
259
+ signer = get_signer("local")
260
+ attestation = create_attestation(subject, claim, value, attester, signer)
261
+ AttestationStore.for_root(root).add(attestation)
262
+ print(
263
+ f"attested {claim} for {subject}\n"
264
+ f" signed by {attester} (keyid {attestation.keyid}, {signer.__class__.__name__})\n"
265
+ f" stored in {root / '.ladex' / 'attestations.json'}"
266
+ )
267
+ return 0
268
+
269
+
270
+ def _verify_command(args: list[str]) -> int:
271
+ if args and args[0] in {"-h", "--help"}:
272
+ print("usage: ladex verify [PATH]", file=sys.stderr)
273
+ return 0
274
+
275
+ from ladex.engine.attest import AttestationStore, verify_attestation
276
+
277
+ positional = [a for a in args if not a.startswith("-")]
278
+ root = Path(positional[0]) if positional else Path(".")
279
+ attestations = AttestationStore.for_root(root).load()
280
+ if not attestations:
281
+ print("no attestations found.")
282
+ return 0
283
+
284
+ ok = 0
285
+ for att in attestations:
286
+ valid = verify_attestation(att)
287
+ ok += valid
288
+ mark = "OK " if valid else "FAIL"
289
+ print(f" [{mark}] {att.subject} {att.claim} <- {att.attester}")
290
+ bad = len(attestations) - ok
291
+ print(f"\n{ok} valid, {bad} invalid attestation(s).")
292
+ return 0 if bad == 0 else 1
293
+
294
+
295
+ def _serve_command(args: list[str]) -> int:
296
+ if args and args[0] in {"-h", "--help"}:
297
+ print("usage: ladex serve (runs the LSP server over stdio)", file=sys.stderr)
298
+ return 0
299
+ from ladex.engine.server import start_stdio
300
+
301
+ start_stdio()
302
+ return 0
303
+
304
+
305
+ def _git_email() -> str | None:
306
+ import subprocess
307
+
308
+ try:
309
+ out = subprocess.run(
310
+ ["git", "config", "user.email"],
311
+ capture_output=True,
312
+ text=True,
313
+ timeout=5,
314
+ check=False,
315
+ )
316
+ except (OSError, subprocess.SubprocessError):
317
+ return None
318
+ email = out.stdout.strip()
319
+ return email or None
320
+
321
+
322
+ def _detect_command(args: list[str]) -> int:
323
+ if not args or args[0] in {"-h", "--help"}:
324
+ print("usage: ladex detect FILE.py", file=sys.stderr)
325
+ return 0 if args else 2
326
+ target = Path(args[0])
327
+ if not target.is_file():
328
+ print(f"not a file: {target}", file=sys.stderr)
329
+ return 2
330
+ detections = PythonDetector().detect_file(target)
331
+ if not detections:
332
+ return 0 # ruthless silence: nothing AI-relevant, say nothing
333
+ for d in detections:
334
+ print(f"{d.location():<28} {d.component_type.value:<15} {d.rule_id:<28} {d.evidence}")
335
+ print(f"\n{len(detections)} detection(s).")
336
+ return 0
337
+
338
+
339
+ def _taxonomy_command(args: list[str]) -> int:
340
+ if not args or args[0] in {"-h", "--help"}:
341
+ print("usage: ladex taxonomy {validate|list} [PACK.yaml ...]", file=sys.stderr)
342
+ return 0 if args else 2
343
+ sub, rest = args[0], args[1:]
344
+ if sub == "validate":
345
+ return _taxonomy_validate([Path(p) for p in rest])
346
+ if sub == "list":
347
+ return _taxonomy_list()
348
+ print(f"unknown taxonomy subcommand: {sub!r}", file=sys.stderr)
349
+ return 2
350
+
351
+
352
+ def _taxonomy_validate(extra_paths: list[Path]) -> int:
353
+ try:
354
+ packs = load_builtin_packs()
355
+ packs.extend(load_pack_file(p) for p in extra_paths)
356
+ taxonomy = aggregate(packs)
357
+ except TaxonomyError as exc:
358
+ print(f"INVALID: {exc}", file=sys.stderr)
359
+ return 1
360
+ print(f"OK: {len(taxonomy.packs)} pack(s), {len(taxonomy)} rule(s) validated.")
361
+ for pack in taxonomy.packs:
362
+ print(f" - {pack.name} v{pack.version}: {len(pack.rules)} rule(s)")
363
+ return 0
364
+
365
+
366
+ def _taxonomy_list() -> int:
367
+ try:
368
+ taxonomy = aggregate(load_builtin_packs())
369
+ except TaxonomyError as exc:
370
+ print(f"INVALID: {exc}", file=sys.stderr)
371
+ return 1
372
+ rules = sorted(taxonomy.rules, key=lambda r: (r.component_type.value, r.id))
373
+ width = max((len(r.id) for r in rules), default=3)
374
+ for rule in rules:
375
+ print(f"{rule.id:<{width}} {rule.component_type.value:<15} {_match_summary(rule)}")
376
+ print(f"\n{len(rules)} rule(s).")
377
+ return 0
378
+
379
+
380
+ def _match_summary(rule: Rule) -> str:
381
+ m = rule.match
382
+ if isinstance(m, ImportMatch):
383
+ return f"import {m.module}" + (f".{m.symbol}" if m.symbol else "")
384
+ if isinstance(m, CallMatch):
385
+ return f"call {m.target}()"
386
+ if isinstance(m, AttributeMatch):
387
+ return f"attr {m.target}"
388
+ if isinstance(m, StringMatch):
389
+ return f"string /{m.pattern}/"
390
+ return "?" # pragma: no cover - exhaustive above
391
+
392
+
393
+ if __name__ == "__main__":
394
+ raise SystemExit(main())