@0dai-dev/cli 4.3.6 → 4.3.8

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 (77) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +133 -33
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +707 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +298 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/play.js +20 -4
  18. package/lib/commands/provider.js +30 -59
  19. package/lib/commands/quota.js +1 -1
  20. package/lib/commands/receipt.js +1 -1
  21. package/lib/commands/run.js +14 -6
  22. package/lib/commands/runner.js +31 -1
  23. package/lib/commands/status.js +176 -11
  24. package/lib/commands/swarm.js +130 -12
  25. package/lib/commands/trust.js +1 -1
  26. package/lib/commands/update.js +184 -38
  27. package/lib/commands/usage.js +1 -1
  28. package/lib/commands/validate.js +32 -3
  29. package/lib/commands/vault.js +43 -8
  30. package/lib/python/__init__.py +0 -0
  31. package/lib/python/agent_quotas.py +525 -0
  32. package/lib/python/anomaly_alert.py +397 -0
  33. package/lib/python/anti_pattern_detector.py +799 -0
  34. package/lib/python/auth.py +443 -0
  35. package/lib/python/capi_profile_guard.py +477 -0
  36. package/lib/python/compliance_report.py +581 -0
  37. package/lib/python/drift_detector.py +388 -0
  38. package/lib/python/experience_pipeline.py +1130 -0
  39. package/lib/python/graph.py +19 -0
  40. package/lib/python/graph_core.py +293 -0
  41. package/lib/python/graph_io.py +179 -0
  42. package/lib/python/graph_legacy.py +2052 -0
  43. package/lib/python/graph_legacy_helpers.py +221 -0
  44. package/lib/python/graph_outcomes_core.py +85 -0
  45. package/lib/python/graph_queries.py +171 -0
  46. package/lib/python/graph_slice.py +198 -0
  47. package/lib/python/graph_slicer.py +576 -0
  48. package/lib/python/graph_slicer_cli.py +60 -0
  49. package/lib/python/graph_validation.py +64 -0
  50. package/lib/python/heatmap.py +943 -0
  51. package/lib/python/json_utils.py +193 -0
  52. package/lib/python/mcp_exposure_check.py +247 -0
  53. package/lib/python/model_router.py +1434 -0
  54. package/lib/python/project_manager.py +621 -0
  55. package/lib/python/provider_profiles.py +1618 -0
  56. package/lib/python/provider_registry.py +1211 -0
  57. package/lib/python/provider_registry_cli.py +125 -0
  58. package/lib/python/receipt_png.py +727 -0
  59. package/lib/python/structural_memory.py +325 -0
  60. package/lib/python/swarm_cost.py +177 -0
  61. package/lib/python/usage_ledger.py +569 -0
  62. package/lib/scripts/mcp_tier_config.py +240 -0
  63. package/lib/shared.js +96 -12
  64. package/lib/tui/index.mjs +35174 -0
  65. package/lib/utils/activation_telemetry.js +1 -4
  66. package/lib/utils/constants.js +7 -1
  67. package/lib/utils/identity.js +184 -0
  68. package/lib/utils/mcp-auth.js +81 -15
  69. package/lib/utils/plan.js +1 -1
  70. package/lib/vault/index.js +19 -3
  71. package/lib/vault/storage.js +21 -2
  72. package/lib/wizard.js +5 -2
  73. package/package.json +9 -3
  74. package/scripts/build-python-bundle.js +106 -0
  75. package/scripts/build-tui.js +14 -1
  76. package/scripts/harvest_experience.py +523 -0
  77. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,581 @@
1
+ #!/usr/bin/env python3
2
+ """0dai Compliance Report - generate SOC 2 / ISO 27001 evidence from audit logs.
3
+
4
+ Produces structured evidence showing: access controls, change management,
5
+ policy enforcement, and monitoring — derived from ai/ layer data.
6
+
7
+ Usage:
8
+ python3 scripts/compliance_report.py --target <path> # human-readable
9
+ python3 scripts/compliance_report.py --target <path> --json # JSON output
10
+ python3 scripts/compliance_report.py --target <path> --framework soc2 # SOC 2 focused
11
+ python3 scripts/compliance_report.py --target <path> --framework iso27001
12
+ 0dai compliance audit-trail --since=DATE [--until=DATE]
13
+ 0dai compliance --target <path>
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import datetime as dt
19
+ import gzip
20
+ import io
21
+ import json
22
+ import pathlib
23
+ import tarfile
24
+ from typing import Any
25
+
26
+ from json_utils import load_json as _ext_load_json, load_jsonl as _ext_load_jsonl
27
+
28
+
29
+ FRAMEWORKS = {
30
+ "soc2": {
31
+ "name": "SOC 2 Type II",
32
+ "controls": [
33
+ {"id": "CC6.1", "area": "Access Control", "description": "Logical access restrictions to information assets"},
34
+ {"id": "CC6.2", "area": "Access Control", "description": "User registration and authorization"},
35
+ {"id": "CC7.2", "area": "Change Management", "description": "Changes are authorized and tested before implementation"},
36
+ {"id": "CC7.3", "area": "Change Management", "description": "Changes to infrastructure and software are managed"},
37
+ {"id": "CC7.4", "area": "Monitoring", "description": "Changes are monitored and anomalies investigated"},
38
+ {"id": "CC8.1", "area": "Risk Assessment", "description": "Risk assessment processes identify threats and vulnerabilities"},
39
+ ],
40
+ },
41
+ "iso27001": {
42
+ "name": "ISO 27001:2022",
43
+ "controls": [
44
+ {"id": "A.5.1", "area": "Policies", "description": "Information security policies defined and approved"},
45
+ {"id": "A.8.3", "area": "Access Control", "description": "Access to information restricted per policy"},
46
+ {"id": "A.8.9", "area": "Change Management", "description": "Configuration management applied"},
47
+ {"id": "A.8.15", "area": "Logging", "description": "Activities, exceptions, and events logged"},
48
+ {"id": "A.8.25", "area": "Secure Development", "description": "Rules for secure development established"},
49
+ {"id": "A.8.32", "area": "Change Management", "description": "Changes to development subject to change management"},
50
+ ],
51
+ },
52
+ }
53
+ UTC = getattr(dt, "UTC", dt.timezone.utc)
54
+ ADR_SCHEMA = "0dai.adr.v1"
55
+ DATE_ONLY_LENGTH = 10
56
+ NON_CONCRETE_EVIDENCE_KEYS = {
57
+ "configured",
58
+ "evidence",
59
+ "evidence_gaps",
60
+ "schema_valid",
61
+ }
62
+ PLACEHOLDER_EVIDENCE_TEXT = {
63
+ "n/a",
64
+ "na",
65
+ "none",
66
+ "no evidence",
67
+ "not available",
68
+ "placeholder",
69
+ "tbd",
70
+ "todo",
71
+ }
72
+
73
+
74
+ def _load_jsonl(path: pathlib.Path) -> list[dict]:
75
+ return _ext_load_jsonl(path)
76
+
77
+
78
+ def _load_json(path: pathlib.Path) -> dict | None:
79
+ result = _ext_load_json(path)
80
+ return result or None
81
+
82
+
83
+ def _type_name(value: Any) -> str:
84
+ if value is None:
85
+ return "null"
86
+ return type(value).__name__
87
+
88
+
89
+ def _is_placeholder_text(value: str) -> bool:
90
+ normalized = " ".join(value.strip().lower().split())
91
+ return not normalized or normalized in PLACEHOLDER_EVIDENCE_TEXT
92
+
93
+
94
+ def _has_concrete_payload(value: Any) -> bool:
95
+ has_payload = False
96
+ if isinstance(value, bool):
97
+ has_payload = value
98
+ elif isinstance(value, (int, float)):
99
+ has_payload = value > 0
100
+ elif isinstance(value, str):
101
+ has_payload = not _is_placeholder_text(value)
102
+ elif isinstance(value, list):
103
+ has_payload = any(_has_concrete_payload(item) for item in value)
104
+ elif isinstance(value, dict):
105
+ has_payload = any(
106
+ _has_concrete_payload(item)
107
+ for key, item in value.items()
108
+ if key not in NON_CONCRETE_EVIDENCE_KEYS
109
+ )
110
+ return has_payload
111
+
112
+
113
+ def _positive_count(record: dict, key: str) -> bool:
114
+ try:
115
+ return int(record.get(key) or 0) > 0
116
+ except (TypeError, ValueError):
117
+ return False
118
+
119
+
120
+ def _string_list_field(raw: Any, field: str, gaps: list[str]) -> list[str]:
121
+ if raw is None:
122
+ return []
123
+ if not isinstance(raw, list):
124
+ gaps.append(f"permissions.{field} must be a list, got {_type_name(raw)}")
125
+ return []
126
+
127
+ values: list[str] = []
128
+ for index, item in enumerate(raw):
129
+ if not isinstance(item, str) or not item.strip():
130
+ gaps.append(f"permissions.{field}[{index}] must be a non-empty string")
131
+ continue
132
+ values.append(item)
133
+ return values
134
+
135
+
136
+ def _org_policy_pack_names(org_policy: dict) -> list[str]:
137
+ packs = org_policy.get("packs_applied", [])
138
+ if not isinstance(packs, list):
139
+ return []
140
+
141
+ names: list[str] = []
142
+ for item in packs:
143
+ if isinstance(item, dict):
144
+ name = str(item.get("name") or "").strip()
145
+ elif isinstance(item, str):
146
+ name = item.strip()
147
+ else:
148
+ name = ""
149
+ if name:
150
+ names.append(name)
151
+ return names
152
+
153
+
154
+ def _normalize_org_permissions(org_policy: dict) -> tuple[dict[str, list[str]], list[str]]:
155
+ permissions = org_policy.get("permissions", {})
156
+ if permissions is None:
157
+ return {"protected_paths": [], "denied_commands": []}, ["permissions must be an object, got null"]
158
+ if not isinstance(permissions, dict):
159
+ return (
160
+ {"protected_paths": [], "denied_commands": []},
161
+ [f"permissions must be an object, got {_type_name(permissions)}"],
162
+ )
163
+
164
+ gaps: list[str] = []
165
+ normalized = {
166
+ "protected_paths": _string_list_field(permissions.get("protected_paths", []), "protected_paths", gaps),
167
+ "denied_commands": _string_list_field(permissions.get("denied_commands", []), "denied_commands", gaps),
168
+ }
169
+ return normalized, gaps
170
+
171
+
172
+ def _source_has_failed_evidence(record: Any) -> bool:
173
+ return (
174
+ isinstance(record, dict)
175
+ and (record.get("schema_valid") is False or bool(record.get("evidence_gaps")))
176
+ )
177
+
178
+
179
+ def _source_has_concrete_evidence(source: str, record: Any) -> bool:
180
+ if not isinstance(record, dict):
181
+ return _has_concrete_payload(record)
182
+ if record.get("configured") is False or _source_has_failed_evidence(record):
183
+ return False
184
+
185
+ checks = {
186
+ "access_control": lambda item: bool(item.get("roles_defined")) and _positive_count(item, "users_assigned"),
187
+ "audit_log": lambda item: _positive_count(item, "total_entries"),
188
+ "knowledge_management": lambda item: any(
189
+ _positive_count(item, key)
190
+ for key in ("events_captured", "candidates_pending", "knowledge_accepted")
191
+ ),
192
+ "licensing": lambda item: item.get("licensed") is True,
193
+ "prompt_integrity": lambda item: _positive_count(item, "snapshots"),
194
+ "security_policy": lambda item: bool(item.get("protected_paths") or item.get("denied_commands")),
195
+ "version_control": lambda item: bool(item.get("ai_version")) and bool(item.get("checksums_present")),
196
+ "write_ahead_log": lambda item: _positive_count(item, "total_mutations"),
197
+ }
198
+
199
+ check = checks.get(source)
200
+ return check(record) if check else _has_concrete_payload(record)
201
+
202
+
203
+ def generate_evidence(target: pathlib.Path) -> dict:
204
+ """Collect evidence from all ai/ layer sources."""
205
+ evidence: dict = {}
206
+
207
+ # 1. Audit log — change tracking
208
+ audit = _load_jsonl(target / "ai" / "manifest" / "audit.jsonl")
209
+ evidence["audit_log"] = {
210
+ "total_entries": len(audit),
211
+ "unique_users": list({e.get("user", "unknown") for e in audit}),
212
+ "unique_actions": list({e.get("action", "") for e in audit}),
213
+ "date_range": {
214
+ "first": audit[0].get("timestamp", "")[:10] if audit else None,
215
+ "last": audit[-1].get("timestamp", "")[:10] if audit else None,
216
+ },
217
+ "evidence": (
218
+ "All CLI operations logged with timestamp, action, user, and version"
219
+ if audit
220
+ else "No audit log entries available"
221
+ ),
222
+ }
223
+
224
+ # 2. WAL — mutation tracking
225
+ wal = _load_jsonl(target / "ai" / "manifest" / "wal.jsonl")
226
+ evidence["write_ahead_log"] = {
227
+ "total_mutations": len(wal),
228
+ "undone": sum(1 for e in wal if e.get("undone")),
229
+ "evidence": (
230
+ "All MCP write operations recorded with before-state for rollback"
231
+ if wal
232
+ else "No MCP write operations recorded"
233
+ ),
234
+ }
235
+
236
+ # 3. Role policy — access control
237
+ role_policy = _load_json(target / "ai" / "manifest" / "role-policy.json")
238
+ if role_policy:
239
+ evidence["access_control"] = {
240
+ "roles_defined": list(role_policy.get("roles", {}).keys()),
241
+ "users_assigned": len(role_policy.get("assignments", {})),
242
+ "tier_restrictions": {
243
+ name: r.get("allowed_tiers", [])
244
+ for name, r in role_policy.get("roles", {}).items()
245
+ },
246
+ "evidence": "Role-based access with command tier restrictions enforced per user",
247
+ }
248
+ else:
249
+ evidence["access_control"] = {"configured": False, "evidence": "No role policy configured"}
250
+
251
+ # 4. Org policy — security policies
252
+ org_policy = _load_json(target / "ai" / "manifest" / "org-policy.json")
253
+ if org_policy:
254
+ permissions, permission_gaps = _normalize_org_permissions(org_policy)
255
+ has_enforced_permissions = bool(permissions["protected_paths"] or permissions["denied_commands"])
256
+ security_policy = {
257
+ "configured": not permission_gaps and has_enforced_permissions,
258
+ "schema_valid": not permission_gaps,
259
+ "packs_applied": _org_policy_pack_names(org_policy),
260
+ "protected_paths": permissions["protected_paths"],
261
+ "denied_commands": permissions["denied_commands"],
262
+ "evidence": (
263
+ "Organization security policies enforced across all AI agent configurations"
264
+ if not permission_gaps and has_enforced_permissions
265
+ else "Organization security policy evidence is incomplete or invalid"
266
+ ),
267
+ }
268
+ if permission_gaps:
269
+ security_policy["evidence_gaps"] = permission_gaps
270
+ evidence["security_policy"] = security_policy
271
+ else:
272
+ evidence["security_policy"] = {"configured": False, "evidence": "No org policy applied"}
273
+
274
+ # 5. License — authorization
275
+ license_data = _load_json(target / "ai" / "manifest" / "license.json")
276
+ evidence["licensing"] = {
277
+ "licensed": license_data is not None,
278
+ "tier": license_data.get("tier", "free") if license_data else "free",
279
+ "team": license_data.get("team", "") if license_data else "",
280
+ "evidence": "License tier controls access to team collaboration features",
281
+ }
282
+
283
+ # 6. Experience pipeline — knowledge management
284
+ events_dir = target / "ai" / "experience" / "events"
285
+ candidates_dir = target / "ai" / "experience" / "candidates"
286
+ accepted_dir = target / "ai" / "experience" / "accepted"
287
+ evidence["knowledge_management"] = {
288
+ "events_captured": len(list(events_dir.glob("*.json"))) if events_dir.is_dir() else 0,
289
+ "candidates_pending": len(list(candidates_dir.glob("*.md"))) if candidates_dir.is_dir() else 0,
290
+ "knowledge_accepted": sum(1 for _ in accepted_dir.rglob("*.md")) if accepted_dir.is_dir() else 0,
291
+ "evidence": "Structured knowledge lifecycle: capture → review → promote → distribute",
292
+ }
293
+
294
+ # 7. Version control
295
+ version_text = None
296
+ v_path = target / "ai" / "VERSION"
297
+ if v_path.is_file():
298
+ version_text = v_path.read_text(encoding="utf-8").strip()
299
+ lock = _load_json(target / "ai" / "manifest" / "applied-lock.json")
300
+ evidence["version_control"] = {
301
+ "ai_version": version_text,
302
+ "packs_installed": lock.get("packs", {}) if lock else {},
303
+ "checksums_present": bool(lock and lock.get("generated")),
304
+ "evidence": "Versioned AI layer with lock files and checksums for integrity verification",
305
+ }
306
+
307
+ # 8. Prompt versioning
308
+ prompt_history = _load_json(target / "ai" / "prompts" / ".history.json")
309
+ evidence["prompt_integrity"] = {
310
+ "snapshots": len(prompt_history.get("snapshots", [])) if prompt_history else 0,
311
+ "evidence": "System prompt changes tracked with hash-based versioning",
312
+ }
313
+
314
+ return evidence
315
+
316
+
317
+ def map_to_framework(evidence: dict, framework: str) -> list[dict]:
318
+ """Map collected evidence to compliance framework controls."""
319
+ fw = FRAMEWORKS.get(framework, FRAMEWORKS["soc2"])
320
+ mappings = []
321
+
322
+ evidence_map = {
323
+ "Access Control": ["access_control", "licensing"],
324
+ "Change Management": ["audit_log", "write_ahead_log", "version_control"],
325
+ "Monitoring": ["audit_log", "knowledge_management"],
326
+ "Logging": ["audit_log", "write_ahead_log"],
327
+ "Risk Assessment": ["security_policy", "knowledge_management"],
328
+ "Policies": ["security_policy", "access_control"],
329
+ "Secure Development": ["prompt_integrity", "version_control"],
330
+ }
331
+
332
+ for control in fw["controls"]:
333
+ area = control["area"]
334
+ relevant_keys = evidence_map.get(area, [])
335
+ relevant_evidence = {k: evidence[k] for k in relevant_keys if k in evidence}
336
+
337
+ has_failed_evidence = any(_source_has_failed_evidence(v) for v in relevant_evidence.values())
338
+ has_data = not has_failed_evidence and any(
339
+ _source_has_concrete_evidence(key, value)
340
+ for key, value in relevant_evidence.items()
341
+ )
342
+
343
+ mappings.append({
344
+ "control_id": control["id"],
345
+ "area": area,
346
+ "description": control["description"],
347
+ "status": "evidenced" if has_data else "gap",
348
+ "evidence_sources": list(relevant_evidence.keys()),
349
+ })
350
+
351
+ return mappings
352
+
353
+
354
+ def cmd_report(target: pathlib.Path, framework: str) -> None:
355
+ evidence = generate_evidence(target)
356
+ mappings = map_to_framework(evidence, framework)
357
+ fw = FRAMEWORKS.get(framework, FRAMEWORKS["soc2"])
358
+
359
+ print(f"0dai Compliance Report — {fw['name']}")
360
+ print(f"Project: {target.resolve().name}")
361
+ print(f"Generated: {dt.datetime.now(UTC).strftime('%Y-%m-%d %H:%M UTC')}")
362
+ print(f"AI Layer Version: {evidence.get('version_control', {}).get('ai_version', '?')}")
363
+ print()
364
+
365
+ # Summary
366
+ evidenced = sum(1 for m in mappings if m["status"] == "evidenced")
367
+ gaps = sum(1 for m in mappings if m["status"] == "gap")
368
+ print(f"Controls: {len(mappings)} total, {evidenced} evidenced, {gaps} gap(s)")
369
+ print()
370
+
371
+ # Control mapping
372
+ print(f"{'Control':<10} {'Area':<20} {'Status':<12} {'Sources':<30} Description")
373
+ print("-" * 110)
374
+ for m in mappings:
375
+ status = "PASS" if m["status"] == "evidenced" else "GAP"
376
+ sources = ", ".join(m["evidence_sources"][:3])
377
+ print(f"{m['control_id']:<10} {m['area']:<20} {status:<12} {sources:<30} {m['description'][:35]}")
378
+
379
+ # Evidence details
380
+ print("\nEvidence Details:")
381
+ for key, val in evidence.items():
382
+ if isinstance(val, dict) and "evidence" in val:
383
+ print(f" {key}: {val['evidence']}")
384
+
385
+
386
+ def cmd_json(target: pathlib.Path, framework: str) -> None:
387
+ evidence = generate_evidence(target)
388
+ mappings = map_to_framework(evidence, framework)
389
+ fw = FRAMEWORKS.get(framework, FRAMEWORKS["soc2"])
390
+ result = {
391
+ "framework": fw["name"],
392
+ "project": target.resolve().name,
393
+ "generated": dt.datetime.now(UTC).isoformat(),
394
+ "evidence": evidence,
395
+ "control_mappings": mappings,
396
+ "summary": {
397
+ "total_controls": len(mappings),
398
+ "evidenced": sum(1 for m in mappings if m["status"] == "evidenced"),
399
+ "gaps": sum(1 for m in mappings if m["status"] == "gap"),
400
+ },
401
+ }
402
+ print(json.dumps(result, indent=2, ensure_ascii=False))
403
+
404
+
405
+ def _parse_date(value: str) -> dt.datetime:
406
+ text = value.strip()
407
+ if not text:
408
+ raise ValueError("date must not be empty")
409
+ if len(text) == DATE_ONLY_LENGTH:
410
+ parsed = dt.datetime.strptime(text, "%Y-%m-%d")
411
+ return parsed.replace(tzinfo=UTC)
412
+ if text.endswith("Z"):
413
+ text = text[:-1] + "+00:00"
414
+ parsed = dt.datetime.fromisoformat(text)
415
+ if parsed.tzinfo is None:
416
+ parsed = parsed.replace(tzinfo=UTC)
417
+ return parsed.astimezone(UTC)
418
+
419
+
420
+ def _end_of_day(value: str) -> dt.datetime:
421
+ if len(value.strip()) == DATE_ONLY_LENGTH:
422
+ start = _parse_date(value)
423
+ return start + dt.timedelta(days=1) - dt.timedelta(microseconds=1)
424
+ return _parse_date(value)
425
+
426
+
427
+ def _record_timestamp(record: dict[str, Any]) -> dt.datetime | None:
428
+ for key in ("ts_completed", "ts_started"):
429
+ value = str(record.get(key) or "")
430
+ if not value:
431
+ continue
432
+ try:
433
+ return _parse_date(value)
434
+ except ValueError:
435
+ continue
436
+ return None
437
+
438
+
439
+ def _load_adr_records(target: pathlib.Path) -> list[tuple[pathlib.Path, dict[str, Any]]]:
440
+ adr_root = target / "ai" / "meta" / "adr"
441
+ if not adr_root.is_dir():
442
+ return []
443
+ records: list[tuple[pathlib.Path, dict[str, Any]]] = []
444
+ for path in sorted(adr_root.glob("*/*.json")):
445
+ try:
446
+ data = json.loads(path.read_text(encoding="utf-8"))
447
+ except (OSError, json.JSONDecodeError):
448
+ continue
449
+ if isinstance(data, dict) and data.get("schema") == ADR_SCHEMA:
450
+ records.append((path, data))
451
+ return records
452
+
453
+
454
+ def _tar_add_bytes(tar: tarfile.TarFile, name: str, payload: bytes) -> None:
455
+ info = tarfile.TarInfo(name)
456
+ info.size = len(payload)
457
+ info.mtime = 0
458
+ info.uid = 0
459
+ info.gid = 0
460
+ info.uname = ""
461
+ info.gname = ""
462
+ tar.addfile(info, io.BytesIO(payload))
463
+
464
+
465
+ def export_audit_trail(
466
+ target: pathlib.Path,
467
+ *,
468
+ since: str,
469
+ until: str | None = None,
470
+ output: pathlib.Path | None = None,
471
+ ) -> dict[str, Any]:
472
+ since_dt = _parse_date(since)
473
+ until_dt = _end_of_day(until) if until else dt.datetime.now(UTC)
474
+ if until_dt < since_dt:
475
+ raise ValueError("--until must be greater than or equal to --since")
476
+
477
+ selected: list[tuple[pathlib.Path, dict[str, Any], dt.datetime]] = []
478
+ for path, record in _load_adr_records(target):
479
+ record_ts = _record_timestamp(record)
480
+ if record_ts is None or record_ts < since_dt or record_ts > until_dt:
481
+ continue
482
+ selected.append((path, record, record_ts))
483
+
484
+ if output is None:
485
+ until_label = until or dt.datetime.now(UTC).strftime("%Y-%m-%d")
486
+ output = (
487
+ target
488
+ / "ai"
489
+ / "meta"
490
+ / "audit"
491
+ / f"adr-audit-trail-{since}-to-{until_label}.tar.gz"
492
+ )
493
+ output = output.resolve()
494
+ output.parent.mkdir(parents=True, exist_ok=True)
495
+
496
+ manifest = {
497
+ "schema": "0dai.adr.audit-trail.v1",
498
+ "generated_at": dt.datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
499
+ "target": str(target.resolve()),
500
+ "since": since,
501
+ "until": until,
502
+ "record_count": len(selected),
503
+ "records": [
504
+ {
505
+ "session_id": record.get("session_id"),
506
+ "task_id": record.get("task_id"),
507
+ "ts_completed": record.get("ts_completed"),
508
+ "path": str(path.relative_to(target).as_posix()),
509
+ }
510
+ for path, record, _record_ts in selected
511
+ ],
512
+ }
513
+
514
+ with output.open("wb") as raw:
515
+ with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as gz:
516
+ with tarfile.open(fileobj=gz, mode="w") as tar:
517
+ _tar_add_bytes(
518
+ tar,
519
+ "manifest.json",
520
+ json.dumps(manifest, indent=2, ensure_ascii=False).encode("utf-8") + b"\n",
521
+ )
522
+ for path, _record, _record_ts in selected:
523
+ arcname = path.relative_to(target).as_posix()
524
+ _tar_add_bytes(tar, arcname, path.read_bytes())
525
+
526
+ return {
527
+ "ok": True,
528
+ "tarball": str(output),
529
+ "record_count": len(selected),
530
+ "since": since,
531
+ "until": until,
532
+ }
533
+
534
+
535
+ def cmd_audit_trail(args: argparse.Namespace) -> None:
536
+ result = export_audit_trail(
537
+ pathlib.Path(args.target).resolve(),
538
+ since=args.since,
539
+ until=args.until,
540
+ output=pathlib.Path(args.output) if args.output else None,
541
+ )
542
+ if args.json:
543
+ print(json.dumps(result, indent=2, ensure_ascii=False))
544
+ return
545
+ print(f"0dai ADR audit trail: {result['tarball']}")
546
+ print(f"Records: {result['record_count']}")
547
+
548
+
549
+ def build_parser() -> argparse.ArgumentParser:
550
+ parser = argparse.ArgumentParser(description="Generate 0dai compliance evidence")
551
+ subparsers = parser.add_subparsers(dest="command")
552
+
553
+ audit_trail = subparsers.add_parser("audit-trail", help="export signed ADR records")
554
+ audit_trail.add_argument("--target", default=".", help="project root")
555
+ audit_trail.add_argument("--since", required=True, help="inclusive start date or timestamp")
556
+ audit_trail.add_argument("--until", default=None, help="inclusive end date or timestamp")
557
+ audit_trail.add_argument("--output", default="", help="tar.gz output path")
558
+ audit_trail.add_argument("--json", action="store_true", help="emit JSON")
559
+
560
+ parser.add_argument("--target", default=".", help="project root")
561
+ parser.add_argument("--framework", default="soc2", help="soc2 or iso27001")
562
+ parser.add_argument("--json", action="store_true", help="emit JSON")
563
+ return parser
564
+
565
+
566
+ def main(argv: list[str] | None = None) -> None:
567
+ parser = build_parser()
568
+ args = parser.parse_args(argv)
569
+ if args.command == "audit-trail":
570
+ cmd_audit_trail(args)
571
+ return
572
+
573
+ target = pathlib.Path(args.target).resolve()
574
+ if args.json:
575
+ cmd_json(target, args.framework)
576
+ else:
577
+ cmd_report(target, args.framework)
578
+
579
+
580
+ if __name__ == "__main__":
581
+ main()