@0dai-dev/cli 4.3.5 → 4.3.7
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.
- package/README.md +12 -11
- package/bin/0dai.js +214 -40
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +55 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/detect.js +10 -4
- package/lib/commands/doctor.js +545 -26
- package/lib/commands/experience.js +40 -5
- package/lib/commands/export.js +73 -0
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +222 -30
- package/lib/commands/mcp.js +129 -21
- package/lib/commands/models.js +138 -41
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +18 -7
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +44 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +286 -0
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +46 -9
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +934 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +97 -14
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +230 -11
- package/lib/utils/constants.js +7 -1
- package/lib/utils/export-bundler.js +285 -0
- package/lib/utils/identity.js +198 -1
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- 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()
|