sage-governance 1.0.0

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.
@@ -0,0 +1,408 @@
1
+ """
2
+ report_gen.py — SAGE Governance Report Generator
3
+ ═════════════════════════════════════════════════
4
+ Reads audit-trail/decisions.jsonl and produces a human-readable
5
+ model card in Markdown, aligned with:
6
+
7
+ • Google Model Cards for Model Reporting (Mitchell et al., 2019)
8
+ • EU AI Act Article 13 (transparency obligations)
9
+ • UNESCO AI Ethics Recommendation (2021)
10
+
11
+ IMPORTANT LIMITATION (document this honestly)
12
+ ─────────────────────────────────────────────
13
+ The SHA-256 hash chain in decisions.jsonl proves sequential integrity
14
+ WITHIN a session — entries were not reordered or modified in place.
15
+ It does NOT prevent local file deletion. This is a session-level
16
+ tamper-detection mechanism, not an immutable external audit log.
17
+
18
+ Author: SAGE Team / Team SAGE (Hackathon)
19
+ License: MIT
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ from startup import AUDIT_FILE, REPORTS_DIR
30
+
31
+
32
+ # ══════════════════════════════════════════════════════════════════════════════
33
+ # AUDIT READER
34
+ # ══════════════════════════════════════════════════════════════════════════════
35
+
36
+
37
+ def load_audit_entries(session_id: Optional[str] = None) -> list[dict]:
38
+ """Load all audit entries, optionally filtered by session_id."""
39
+ if not AUDIT_FILE.exists():
40
+ return []
41
+ entries: list[dict] = []
42
+ for raw_line in AUDIT_FILE.read_text(encoding="utf-8").splitlines():
43
+ line = raw_line.strip()
44
+ if not line:
45
+ continue
46
+ try:
47
+ entry = json.loads(line)
48
+ if session_id is None or entry.get("session_id") == session_id:
49
+ entries.append(entry)
50
+ except json.JSONDecodeError:
51
+ continue
52
+ return entries
53
+
54
+
55
+ # ══════════════════════════════════════════════════════════════════════════════
56
+ # REPORT BUILDERS
57
+ # ══════════════════════════════════════════════════════════════════════════════
58
+
59
+
60
+ def generate_model_card(session_id: Optional[str] = None) -> str:
61
+ """
62
+ Produces a full Markdown model card from the audit trail.
63
+ Sections follow Mitchell et al. (2019) structure.
64
+ """
65
+ entries = load_audit_entries(session_id)
66
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
67
+
68
+ if not entries:
69
+ return (
70
+ f"# SAGE Governance Report\n\n"
71
+ f"_No audit entries found for session `{session_id or 'all'}`. "
72
+ f"Generated: {now}_\n"
73
+ )
74
+
75
+ # ── Aggregate ────────────────────────────────────────────────────────────
76
+ domains = list({e.get("domain", "unknown") for e in entries})
77
+ risk_levels = [e.get("risk_level", "UNKNOWN") for e in entries if e.get("risk_level")]
78
+ _severity_rank = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3, "UNKNOWN": -1}
79
+ highest_risk = (
80
+ max(risk_levels, key=lambda r: _severity_rank.get(r, -1))
81
+ if risk_levels else "N/A"
82
+ )
83
+ eval_entries = [e for e in entries if e.get("event_type") == "sage_evaluate"]
84
+ intercept_entries = [e for e in entries if e.get("event_type") == "file_write_intercepted"]
85
+ decision_entries = [e for e in entries if e.get("developer_choice")]
86
+ auto_approved = sum(1 for e in entries if e.get("decision") == "auto_approved")
87
+ model_trained_entries = [e for e in entries if e.get("event_type") == "model_trained"]
88
+
89
+ all_flags = [f for e in entries for f in e.get("compliance_flags", [])]
90
+ all_protected = list({a for e in entries for a in e.get("protected_attributes", [])})
91
+ all_proxy = list({a for e in entries for a in e.get("proxy_attributes", [])})
92
+ all_regs = list({r for e in entries for r in e.get("regulations", [])})
93
+ all_udhr = list({a for e in entries for a in e.get("udhr_articles", [])})
94
+
95
+ # ── Render ───────────────────────────────────────────────────────────────
96
+ r = f"""# SAGE Governance Report — Model Card
97
+
98
+ > **Generated by** SAGE (Supervisory Agentic Governance Engine)
99
+ > **Report date:** {now}
100
+ > **Session:** `{session_id or 'all'}`
101
+ > **Audit entries:** {len(entries)}
102
+
103
+ ---
104
+
105
+ ## 1. Model / System Overview
106
+
107
+ | Field | Value |
108
+ |-------|-------|
109
+ | Detected domain(s) | {', '.join(f'`{d}`' for d in domains)} |
110
+ | Highest risk level | **{highest_risk}** |
111
+ | Total audit entries | {len(entries)} |
112
+ | Prompts evaluated | {len(eval_entries)} |
113
+ | File writes intercepted | {len(intercept_entries)} |
114
+ | Auto-approved (low risk) | {auto_approved} |
115
+ | Developer decisions recorded | {len(decision_entries)} |
116
+
117
+ ---
118
+
119
+ ## 2. Intended Use
120
+
121
+ """
122
+ for entry in eval_entries:
123
+ r += f"### Prompt\n> {entry.get('intent_summary', 'N/A')[:250]}\n\n"
124
+ r += f"| Field | Value |\n|-------|-------|\n"
125
+ r += f"| Domain | `{entry.get('domain', 'N/A')}` |\n"
126
+ r += f"| Risk level | **{entry.get('risk_level', 'N/A')}** |\n"
127
+ r += f"| EU AI Act | `{entry.get('eu_ai_act_annex') or 'Not classified as high-risk'}` |\n"
128
+ r += f"| Timestamp | {entry.get('timestamp', 'N/A')} |\n"
129
+ r += f"| Audit hash | `{entry.get('entry_hash', 'N/A')[:16]}...` |\n\n"
130
+
131
+ r += """---
132
+
133
+ ## 3. Regulatory Classification
134
+
135
+ """
136
+ if all_regs:
137
+ for reg in all_regs:
138
+ r += f"- {reg}\n"
139
+ else:
140
+ r += "_No high-risk regulatory classification triggered in this session._\n"
141
+
142
+ if all_udhr:
143
+ r += "\n**UDHR Articles implicated:**\n"
144
+ for art in all_udhr:
145
+ r += f"- {art}\n"
146
+
147
+ r += """
148
+ ---
149
+
150
+ ## 4. Fairness Analysis
151
+
152
+ ### 4.1 Protected Attributes
153
+
154
+ """
155
+ if all_protected:
156
+ r += "_The following protected attributes were detected in developer prompts or code:_\n\n"
157
+ for attr in all_protected:
158
+ r += f"- `{attr}`\n"
159
+ else:
160
+ r += "_No protected attributes detected directly in this session._\n"
161
+
162
+ r += "\n### 4.2 Proxy Attributes\n\n"
163
+ if all_proxy:
164
+ r += "_The following features are documented proxies for protected characteristics:_\n\n"
165
+ for attr in all_proxy:
166
+ r += f"- `{attr}`\n"
167
+ else:
168
+ r += "_No proxy attributes detected in this session._\n"
169
+
170
+ r += "\n### 4.3 Developer Fairness Decisions\n\n"
171
+ if decision_entries:
172
+ for entry in decision_entries:
173
+ r += f"**Choice:** `{entry.get('developer_choice', 'N/A')}`\n\n"
174
+ r += f"- Event type: `{entry.get('event_type', 'N/A')}`\n"
175
+ r += f"- Reasoning: {entry.get('choice_reasoning') or '_Not provided_'}\n"
176
+ r += f"- Timestamp: {entry.get('timestamp', 'N/A')}\n"
177
+ r += f"- Audit hash: `{entry.get('entry_hash', 'N/A')[:16]}...`\n\n"
178
+ else:
179
+ r += "_No fairness option selections recorded in this session._\n"
180
+
181
+ r += "\n### 4.4 Fairness Impossibility Note\n\n"
182
+ any_impossible = any(e.get("fairness_impossibility") for e in entries)
183
+ if any_impossible:
184
+ r += (
185
+ "> ⚠️ **Fairness Impossibility Theorem applies to this session.** "
186
+ "When base rates differ across groups, Demographic Parity, Equalized Odds, "
187
+ "and Predictive Parity cannot all be satisfied simultaneously. "
188
+ "The developer's fairness choice above reflects a values judgment, "
189
+ "not a technical oversight. "
190
+ "_(Barocas, Hardt & Narayanan, 2023; Chouldechova, 2016)_\n"
191
+ )
192
+ else:
193
+ r += "_Fairness impossibility theorem not triggered in this session._\n"
194
+
195
+ r += "\n"
196
+
197
+ # Section 5: Training Data
198
+ r += "---\n\n## 5. Training Data\n\n"
199
+ if model_trained_entries:
200
+ for idx, entry in enumerate(model_trained_entries, 1):
201
+ dataset = entry.get("dataset_info", {})
202
+ r += f"### Model training instance {idx}\n"
203
+ r += "| Field | Value |\n|---|---|\n"
204
+ r += f"| Dataset Name | `{dataset.get('name', 'N/A')}` |\n"
205
+ r += f"| Sample Count | {dataset.get('samples', 'N/A')} |\n"
206
+ r += f"| Features | {', '.join(f'`{f}`' for f in dataset.get('features', [])) or 'N/A'} |\n"
207
+ r += f"| Sensitive features | {', '.join(f'`{f}`' for f in dataset.get('sensitive_features', [])) or 'None'} |\n"
208
+ r += f"| Timestamp | {entry.get('timestamp', 'N/A')} |\n\n"
209
+ else:
210
+ r += "_No model training or dataset profiling recorded in this session._\n\n"
211
+
212
+ # Section 6: Performance & Test Metrics
213
+ r += "---\n\n## 6. Performance & Test Metrics\n\n"
214
+ if model_trained_entries:
215
+ for idx, entry in enumerate(model_trained_entries, 1):
216
+ metrics = entry.get("metrics", {})
217
+ r += f"### Model evaluation metrics {idx}\n"
218
+ r += "| Metric | Value |\n|---|---|\n"
219
+ for k, v in metrics.items():
220
+ if isinstance(v, float):
221
+ r += f"| {k} | {v:.4f} |\n"
222
+ else:
223
+ r += f"| {k} | {v} |\n"
224
+ r += "\n"
225
+ else:
226
+ r += "_No model performance metrics or evaluation test results recorded in this session._\n\n"
227
+
228
+ # Section 7: Child Safety & Safeguarding
229
+ r += "---\n\n## 7. Child Safety & Safeguarding\n\n"
230
+ safeguarding_choices = [
231
+ e for e in entries
232
+ if e.get("event_type") == "fairness_option_selected"
233
+ and (
234
+ e.get("developer_choice") in ("human_in_loop_escalation", "metadata_only_retention", "recall_first_detection")
235
+ or "human" in str(e.get("developer_choice")).lower()
236
+ or "metadata" in str(e.get("developer_choice")).lower()
237
+ or "recall" in str(e.get("developer_choice")).lower()
238
+ )
239
+ ]
240
+ safeguarding_violations = [
241
+ e for e in entries
242
+ if e.get("event_type") == "file_write_intercepted"
243
+ and e.get("highest_risk_finding", {}).get("category") == "SAFEGUARDING_VIOLATION"
244
+ ]
245
+
246
+ r += "### 7.1 Safeguarding Decisions\n\n"
247
+ if safeguarding_choices:
248
+ for choice in safeguarding_choices:
249
+ r += f"- **Decision:** `{choice.get('developer_choice')}`\n"
250
+ r += f" - Reasoning: {choice.get('choice_reasoning') or '_Not provided_'}\n"
251
+ r += f" - Timestamp: {choice.get('timestamp')}\n"
252
+ else:
253
+ r += "_No child safeguarding decisions recorded in this session._\n\n"
254
+
255
+ r += "### 7.2 Safeguarding Scanner Warnings & Violations\n\n"
256
+ if safeguarding_violations:
257
+ r += "| File | Violation Details | Action taken | Timestamp |\n|---|---|---|---|\n"
258
+ for v in safeguarding_violations:
259
+ finding = v.get("highest_risk_finding", {})
260
+ r += f"| `{v.get('filepath')}` | {finding.get('description')} (Severity: {finding.get('severity')}) | `{v.get('decision')}` | {v.get('timestamp')} |\n"
261
+ r += "\n"
262
+ else:
263
+ r += "_No safeguarding violations or blocked auto-escalation/chat logging patterns encountered._\n\n"
264
+
265
+ r += """---
266
+
267
+ ## 8. Security Findings
268
+
269
+ """
270
+ if intercept_entries:
271
+ for entry in intercept_entries:
272
+ r += f"### `{entry.get('filepath', 'Code scan')}` — {entry.get('timestamp', '')}\n\n"
273
+ r += f"- **SAGE decision:** `{entry.get('decision', 'pending')}`\n"
274
+ r += f"- **Total findings:** {entry.get('total_findings', 0)}\n"
275
+ finding = entry.get("highest_risk_finding")
276
+ if finding:
277
+ r += f"- **Highest severity:** `{finding.get('severity', 'N/A')}`\n"
278
+ r += f"- **Category:** {finding.get('category', 'N/A')}\n"
279
+ r += f"- **Description:** {finding.get('description', 'N/A')}\n"
280
+ dev_decision = entry.get("developer_decision")
281
+ if dev_decision:
282
+ r += f"- **Developer action:** `{dev_decision}`\n"
283
+ r += "\n"
284
+ else:
285
+ r += "_No file write interceptions recorded in this session._\n"
286
+
287
+ r += """
288
+ ---
289
+
290
+ ## 9. Compliance Flags
291
+
292
+ """
293
+ if all_flags:
294
+ unique_flags = list(dict.fromkeys(all_flags)) # preserve order, deduplicate
295
+ for flag in unique_flags:
296
+ r += f"⚠️ {flag}\n\n"
297
+ else:
298
+ r += "_No compliance flags raised in this session._\n"
299
+
300
+ r += f"""
301
+ ---
302
+
303
+ ## 10. Audit Trail Integrity
304
+
305
+ | Property | Value |
306
+ |----------|-------|
307
+ | Audit file | `audit-trail/decisions.jsonl` |
308
+ | Entry count | {len(entries)} |
309
+ | Hash algorithm | SHA-256 |
310
+ | Chain type | Sequential (prev_hash → entry_hash) |
311
+ | Storage | Local filesystem |
312
+
313
+ > **Limitation (be honest):** The SHA-256 chain proves entries were not modified
314
+ > or reordered within the session. It does NOT prevent local file deletion.
315
+ > For an immutable external audit log, forward entries to a remote append-only
316
+ > service (e.g. AWS CloudTrail, Azure Monitor, or a Git commit per entry).
317
+
318
+ ---
319
+
320
+ ## 11. Human Oversight
321
+
322
+ """
323
+ hitl = [e for e in entries if e.get("requires_human_review")]
324
+ if hitl:
325
+ r += f"**{len(hitl)} item(s) flagged for mandatory human review:**\n\n"
326
+ for entry in hitl:
327
+ r += (
328
+ f"- {entry.get('intent_summary', 'N/A')[:120]} "
329
+ f"_(Risk: {entry.get('risk_level')})_\n"
330
+ )
331
+ else:
332
+ r += "_No items required human review escalation in this session._\n"
333
+
334
+ r += """
335
+ ---
336
+
337
+ ## 12. Known Limitations
338
+
339
+ - **Policy retrieval is keyword-based** — obfuscated variable names or indirect
340
+ descriptions of protected categories may evade detection.
341
+ - **SHA-256 chain is session-scoped** — not an immutable external audit record.
342
+ - **Fairness impossibility** — no single configuration satisfies all fairness
343
+ metrics simultaneously when base rates differ across groups.
344
+ - **LLM reasoning is probabilistic** — the sage_reasoning field is enriched by
345
+ an LLM and may vary across runs. Deterministic fields (risk_level, protected_attributes,
346
+ eu_ai_act_annex) are rule-based and stable.
347
+
348
+ ---
349
+
350
+ ## 13. References
351
+
352
+ - EU AI Act — Regulation (EU) 2024/1689
353
+ - UNESCO Recommendation on the Ethics of AI (2021)
354
+ - OECD AI Principles (2019, updated 2024)
355
+ - Universal Declaration of Human Rights (1948)
356
+ - UN Convention on the Rights of the Child (1989)
357
+ - Mitchell et al. (2019). "Model Cards for Model Reporting." *FAccT 2019.*
358
+ - Chouldechova, A. (2016). "Fair Prediction with Disparate Impact."
359
+ - Barocas, Hardt & Narayanan (2023). *Fairness and Machine Learning.*
360
+ - ProPublica (2016). "Machine Bias." COMPAS analysis.
361
+ - Ali et al. (2019). "Discrimination through Optimization." Facebook ad delivery audit.
362
+
363
+ ---
364
+
365
+ _Report generated by SAGE · Beunec Technologies, Inc. · MIT License_
366
+ _Open source: github.com/[your-org]/sage-governance_
367
+ """
368
+ return r
369
+
370
+
371
+ def generate_terminal_summary(session_id: Optional[str] = None) -> str:
372
+ """Short summary for CLI display after a session completes."""
373
+ entries = load_audit_entries(session_id)
374
+ if not entries:
375
+ return "No audit events recorded in this session."
376
+
377
+ risk_counts: dict[str, int] = {}
378
+ for e in entries:
379
+ r = e.get("risk_level")
380
+ if r:
381
+ risk_counts[r] = risk_counts.get(r, 0) + 1
382
+
383
+ choices = [e for e in entries if e.get("developer_choice")]
384
+ intercepts = [e for e in entries if e.get("event_type") == "file_write_intercepted"]
385
+
386
+ lines = [
387
+ "┌─────────────────────────────────────────────┐",
388
+ "│ SAGE Session Summary │",
389
+ "└─────────────────────────────────────────────┘",
390
+ f" Total audit events : {len(entries)}",
391
+ ]
392
+ order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
393
+ for level in order:
394
+ if level in risk_counts:
395
+ lines.append(f" {level:<10} risk(s) : {risk_counts[level]}")
396
+ lines.append(f" File writes blocked : {len(intercepts)}")
397
+ lines.append(f" Developer choices : {len(choices)}")
398
+ lines.append(f" Audit file : audit-trail/decisions.jsonl")
399
+ lines.append(f" Full report : sage report (generates governance_report_*.md)")
400
+ return "\n".join(lines)
401
+
402
+
403
+ def save_report(content: str, session_id: str) -> Path:
404
+ """Write report to reports/ directory and return the path."""
405
+ filename = f"governance_report_{session_id}.md"
406
+ output_path = REPORTS_DIR / filename
407
+ output_path.write_text(content, encoding="utf-8")
408
+ return output_path