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,459 @@
1
+ """
2
+ mcp_server.py — SAGE MCP Server
3
+ ═════════════════════════════════
4
+ Exposes 5 tools to any MCP-compatible coding agent:
5
+
6
+ 1. sage_evaluate — classify intent + full ethics evaluation
7
+ 2. security_scan — deterministic code security scan
8
+ 3. intercept_file_write — PRE-WRITE interception (the key differentiator)
9
+ 4. audit_write — append-only audit trail entry
10
+ 5. report_generate — model card generation from audit trail
11
+
12
+ TRANSPORT: stdio (persistent process — NOT respawned per tool call)
13
+ DISTRIBUTION: npm install -g sage-governance (see bin/sage.js)
14
+ COMPATIBLE WITH: OpenCode, Cline, Claude Code, Continue, Cursor, Zed
15
+
16
+ ARGUMENT 2 CLARIFICATION (for team reference)
17
+ ──────────────────────────────────────────────
18
+ The concern "FastMCP spawns a new process per call" is INCORRECT for
19
+ stdio transport. The host (e.g. OpenCode) launches this process ONCE
20
+ when the MCP connection is established. It stays alive for the session.
21
+ startup.py preloading is still correct practice — it eliminates first-
22
+ call latency from heavy imports inside tool functions.
23
+
24
+ Author: SAGE Team — Beunec Technologies, Inc. / Team SAGE (Hackathon)
25
+ License: MIT
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import sys
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+
36
+ # ── startup MUST be the first import — preloads all globals ──────────────────
37
+ from startup import AUDIT_FILE, LOGS_FILE, LOCAL_MEMORY, write_audit_entry
38
+
39
+ import sage_agent
40
+ import security_agent
41
+ import report_gen
42
+
43
+ try:
44
+ from mcp.server.fastmcp import FastMCP
45
+ except ImportError:
46
+ print(
47
+ "[SAGE] FATAL: 'mcp' package not found.\n"
48
+ " Run: pip install mcp\n"
49
+ " Or: pip install 'mcp[cli]'",
50
+ file=sys.stderr,
51
+ )
52
+ sys.exit(1)
53
+
54
+ # ══════════════════════════════════════════════════════════════════════════════
55
+ # SERVER INITIALISATION
56
+ # ══════════════════════════════════════════════════════════════════════════════
57
+
58
+ mcp = FastMCP(
59
+ "sage-governance",
60
+ instructions=(
61
+ "SAGE (Supervisory Agentic Governance Engine) — governance layer for agentic coding. "
62
+ "Always call sage_evaluate BEFORE acting on any request involving data, ML models, "
63
+ "or automated decisions. Always call intercept_file_write BEFORE writing any file. "
64
+ "Call audit_write to record developer decisions. Call report_generate to produce "
65
+ "a human-readable governance report."
66
+ ),
67
+ )
68
+
69
+ # ══════════════════════════════════════════════════════════════════════════════
70
+ # AUDIT TRAIL STATE (module-level, persists for the process lifetime)
71
+ # ══════════════════════════════════════════════════════════════════════════════
72
+
73
+ _session_id: str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
74
+
75
+
76
+ def _write_audit(entry: dict) -> str:
77
+ """
78
+ Append one JSON line to decisions.jsonl with SHA-256 chain link.
79
+ Delegates to write_audit_entry in startup.py to support multi-process chaining.
80
+ """
81
+ if "session_id" not in entry:
82
+ entry["session_id"] = _session_id
83
+ return write_audit_entry(entry)
84
+
85
+
86
+ def _write_log(summary: str) -> None:
87
+ """Append human-readable summary to LOGS.md (append-only)."""
88
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
89
+ with open(LOGS_FILE, "a", encoding="utf-8") as fh:
90
+ fh.write(f"\n## {ts}\n\n{summary}\n\n---\n")
91
+
92
+
93
+ # ══════════════════════════════════════════════════════════════════════════════
94
+ # TOOL 1 — sage_evaluate
95
+ # ══════════════════════════════════════════════════════════════════════════════
96
+
97
+
98
+ @mcp.tool()
99
+ async def sage_evaluate(
100
+ prompt: str,
101
+ code: str = "",
102
+ context: str = "",
103
+ ) -> dict:
104
+ """
105
+ SAGE primary evaluation tool.
106
+
107
+ Call this BEFORE the coding agent acts on ANY request involving:
108
+ • machine learning models or classifiers
109
+ • predictions about people (recidivism, credit, hiring, health)
110
+ • datasets with demographic or behavioral signals
111
+ • automated decision-making systems
112
+
113
+ Returns a Pydantic-validated response (always parseable):
114
+ • risk_level : LOW | MEDIUM | HIGH | CRITICAL
115
+ • eu_ai_act_annex : applicable EU AI Act classification or null
116
+ • protected_attributes: attributes detected directly
117
+ • proxy_attributes : known proxy features detected
118
+ • fairness_options : 3 concrete options with pros/cons/API
119
+ • compliance_flags : actionable regulatory issues
120
+ • immediate_actions : ordered list of required next steps
121
+ • requires_human_review: true when risk is HIGH or CRITICAL
122
+ • sage_reasoning : 2-3 sentence explanation (LLM or fallback)
123
+
124
+ Args:
125
+ prompt: The developer's original request (required)
126
+ code: Optional code snippet already written
127
+ context: Optional dataset description or additional context
128
+ """
129
+ result = sage_agent.evaluate(prompt=prompt, code=code, context=context)
130
+ result_dict = result.model_dump()
131
+
132
+ entry_hash = _write_audit({
133
+ "event_type": "sage_evaluate",
134
+ "intent_summary": result.intent_summary,
135
+ "domain": result.detected_domain,
136
+ "risk_level": result.risk_level,
137
+ "eu_ai_act_annex": result.eu_ai_act_annex,
138
+ "protected_attributes": result.protected_attributes,
139
+ "proxy_attributes": result.proxy_attributes,
140
+ "compliance_flags": result.compliance_flags,
141
+ "regulations": result.regulations,
142
+ "udhr_articles": result.udhr_articles,
143
+ "requires_human_review": result.requires_human_review,
144
+ "fairness_impossibility": result.fairness_impossibility,
145
+ })
146
+ result_dict["audit_entry_hash"] = entry_hash
147
+
148
+ _write_log(
149
+ f"**SAGE Evaluate** — Risk: `{result.risk_level}` | "
150
+ f"Domain: `{result.detected_domain}`\n\n"
151
+ f"**Prompt:** {prompt[:200]}\n\n"
152
+ f"**EU AI Act:** {result.eu_ai_act_annex or 'Not classified as high-risk'}\n\n"
153
+ f"**Protected attributes:** "
154
+ f"{', '.join(f'`{a}`' for a in result.protected_attributes) or 'none detected'}\n\n"
155
+ f"**SAGE reasoning:** {result.sage_reasoning}"
156
+ )
157
+
158
+ return result_dict
159
+
160
+
161
+ # ══════════════════════════════════════════════════════════════════════════════
162
+ # TOOL 2 — security_scan
163
+ # ══════════════════════════════════════════════════════════════════════════════
164
+
165
+
166
+ @mcp.tool()
167
+ async def security_scan(
168
+ code: str,
169
+ filepath: str = "",
170
+ context: str = "",
171
+ ) -> dict:
172
+ """
173
+ Full deterministic security scan of generated code.
174
+
175
+ Detects (in severity order P0–P4):
176
+ P0 — API keys / secrets hardcoded; biometric/medical PII; protected attrs
177
+ P1 — Government ID PII; geolocation; major compliance gaps
178
+ P2 — Proxy discrimination risk; black-box model; missing fairness metrics
179
+ P3 — Encoding choices; data quality; model serialization gaps
180
+
181
+ Returns:
182
+ • passed : true only when zero P0/P1 findings
183
+ • highest_severity: P0|P1|P2|P3|P4|PASS
184
+ • findings : list of findings sorted by severity
185
+ • summary : human-readable verdict string
186
+
187
+ Args:
188
+ code: Code content to scan
189
+ filepath: Optional path for audit trail reference
190
+ context: Optional context (framework, dataset, purpose)
191
+ """
192
+ report = security_agent.scan(code)
193
+
194
+ findings_dicts = [f.to_dict() for f in report.findings]
195
+ top = report.top_finding()
196
+
197
+ _write_audit({
198
+ "event_type": "security_scan",
199
+ "filepath": filepath,
200
+ "total_findings": report.total_findings,
201
+ "highest_severity": report.highest_severity,
202
+ "protected_attributes_found": report.protected_attributes_found,
203
+ "secrets_found_count": len(report.secrets_found),
204
+ "passed": report.passed,
205
+ "highest_risk_finding": {
206
+ "severity": top.severity,
207
+ "category": top.category,
208
+ "description": top.description,
209
+ } if top else None,
210
+ })
211
+
212
+ verdict = "✅ PASSED" if report.passed else "❌ BLOCKED"
213
+ return {
214
+ "passed": report.passed,
215
+ "total_findings": report.total_findings,
216
+ "highest_severity": report.highest_severity,
217
+ "protected_attributes_found": report.protected_attributes_found,
218
+ "findings": findings_dicts,
219
+ "summary": (
220
+ f"{verdict} — {report.total_findings} finding(s), "
221
+ f"highest severity: {report.highest_severity}"
222
+ ),
223
+ }
224
+
225
+
226
+ # ══════════════════════════════════════════════════════════════════════════════
227
+ # TOOL 3 — intercept_file_write (THE KEY DIFFERENTIATOR)
228
+ # ══════════════════════════════════════════════════════════════════════════════
229
+
230
+
231
+ @mcp.tool()
232
+ async def intercept_file_write(
233
+ filepath: str,
234
+ code: str,
235
+ context: str = "",
236
+ ) -> dict:
237
+ """
238
+ CRITICAL — Call this BEFORE writing ANY file to disk.
239
+
240
+ SAGE scans the code before it touches the filesystem.
241
+ This is the only governance tool that blocks at the point of action,
242
+ not after the fact.
243
+
244
+ Behaviour:
245
+ • Zero P0/P1 findings → auto-approves, logs silently, returns approved=true
246
+ • P0/P1 found → BLOCKS write, surfaces the single highest-risk finding,
247
+ returns approved=false with 3 explicit developer choices
248
+
249
+ Developer choices (pass back via audit_write):
250
+ • "accept_as_is" — write file unchanged; risk accepted and logged
251
+ • "apply_suggestion" — apply SAGE's recommended fix before writing
252
+ • "reject" — discard this code; revise the approach
253
+
254
+ Args:
255
+ filepath: Target file path being written
256
+ code: Complete code content about to be written
257
+ context: Optional context (framework, dataset purpose)
258
+ """
259
+ report = security_agent.scan(code)
260
+
261
+ # ── Auto-approve path ────────────────────────────────────────────────────
262
+ if report.passed:
263
+ _write_audit({
264
+ "event_type": "file_write_intercepted",
265
+ "filepath": filepath,
266
+ "decision": "auto_approved",
267
+ "total_findings": report.total_findings,
268
+ "highest_severity": report.highest_severity,
269
+ })
270
+ return {
271
+ "approved": True,
272
+ "decision": "auto_approved",
273
+ "findings": [f.to_dict() for f in report.findings],
274
+ "message": f"✅ SAGE approved write to `{filepath}` — no P0/P1 issues detected.",
275
+ }
276
+
277
+ # ── Block path ───────────────────────────────────────────────────────────
278
+ top = report.top_finding()
279
+
280
+ audit_data = {
281
+ "event_type": "file_write_intercepted",
282
+ "filepath": filepath,
283
+ "decision": "blocked_pending_developer_action",
284
+ "total_findings": report.total_findings,
285
+ "highest_risk_finding": {
286
+ "severity": top.severity,
287
+ "category": top.category,
288
+ "line_number": top.line_number,
289
+ "snippet": top.snippet,
290
+ "description": top.description,
291
+ "fix": top.fix,
292
+ "regulation": top.regulation,
293
+ } if top else None,
294
+ "audit_pending": True,
295
+ }
296
+ _write_audit(audit_data)
297
+
298
+ _write_log(
299
+ f"**⛔ File Write Intercepted:** `{filepath}`\n\n"
300
+ f"- Severity: `{top.severity}`\n"
301
+ f"- Category: `{top.category}`\n"
302
+ f"- Line {top.line_number}: `{top.snippet[:80]}`\n"
303
+ f"- Issue: {top.description}\n"
304
+ f"- Regulation: {top.regulation}"
305
+ )
306
+
307
+ return {
308
+ "approved": False,
309
+ "requires_action": True,
310
+ "filepath": filepath,
311
+ "total_findings": report.total_findings,
312
+ "highest_risk": {
313
+ "severity": top.severity,
314
+ "category": top.category,
315
+ "line_number": top.line_number,
316
+ "code_snippet": top.snippet,
317
+ "risk_description": top.description,
318
+ "suggested_fix": top.fix,
319
+ "regulation": top.regulation,
320
+ },
321
+ "developer_choices": [
322
+ "accept_as_is — write file unchanged (risk accepted and logged in audit trail)",
323
+ "apply_suggestion — apply SAGE's suggested fix before writing",
324
+ "reject — discard this code and revise the approach",
325
+ ],
326
+ "instruction": (
327
+ f"⛔ SAGE has BLOCKED write to `{filepath}`. "
328
+ f"Severity: {top.severity}. "
329
+ f"You MUST call audit_write with your developer_choice before proceeding. "
330
+ f"Total findings: {report.total_findings}."
331
+ ),
332
+ "audit_pending": True,
333
+ }
334
+
335
+
336
+ # ══════════════════════════════════════════════════════════════════════════════
337
+ # TOOL 4 — audit_write
338
+ # ══════════════════════════════════════════════════════════════════════════════
339
+
340
+
341
+ @mcp.tool()
342
+ async def audit_write(
343
+ event_type: str,
344
+ developer_choice: str = "",
345
+ choice_reasoning: str = "",
346
+ filepath: str = "",
347
+ extra_data: dict = {},
348
+ ) -> dict:
349
+ """
350
+ Records a developer decision in the append-only audit trail.
351
+
352
+ Required after intercept_file_write when approved=false.
353
+ Also use to record any governance decision (fairness option selection,
354
+ DPIA acknowledgement, human review escalation).
355
+
356
+ The audit trail is append-only — entries can be added but never deleted
357
+ or modified via this tool.
358
+
359
+ Args:
360
+ event_type: Type of event (e.g. "fairness_option_selected",
361
+ "file_write_decision", "human_review_escalated")
362
+ developer_choice: What the developer chose
363
+ choice_reasoning: Optional explanation of the choice
364
+ filepath: Optional file path reference
365
+ extra_data: Optional additional structured data
366
+ """
367
+ entry = {
368
+ "event_type": event_type,
369
+ "developer_choice": developer_choice,
370
+ "choice_reasoning": choice_reasoning,
371
+ "filepath": filepath,
372
+ **extra_data,
373
+ }
374
+ entry_hash = _write_audit(entry)
375
+
376
+ _write_log(
377
+ f"**Developer Decision** — `{event_type}`\n\n"
378
+ f"- Choice: `{developer_choice or 'N/A'}`\n"
379
+ f"- File: {filepath or 'N/A'}\n"
380
+ f"- Reasoning: {choice_reasoning or '_Not provided_'}"
381
+ )
382
+
383
+ return {
384
+ "recorded": True,
385
+ "event_type": event_type,
386
+ "developer_choice": developer_choice,
387
+ "entry_hash": entry_hash,
388
+ "session_id": _session_id,
389
+ "message": (
390
+ f"✅ Decision recorded in audit trail "
391
+ f"(hash: {entry_hash[:12]}...)"
392
+ ),
393
+ }
394
+
395
+
396
+ # ══════════════════════════════════════════════════════════════════════════════
397
+ # TOOL 5 — report_generate
398
+ # ══════════════════════════════════════════════════════════════════════════════
399
+
400
+
401
+ @mcp.tool()
402
+ async def report_generate(
403
+ session_id: str = "",
404
+ output_format: str = "markdown",
405
+ ) -> dict:
406
+ """
407
+ Generates a human-readable governance report from the audit trail.
408
+
409
+ Output formats:
410
+ • "markdown" (default) — full model card written to reports/governance_report_*.md
411
+ • "summary" — short terminal-friendly summary (no file written)
412
+
413
+ The model card follows Google Model Cards spec (Mitchell et al., 2019)
414
+ and covers: intended use, regulatory classification, fairness analysis,
415
+ security findings, compliance flags, audit integrity, and limitations.
416
+
417
+ Args:
418
+ session_id: Filter to a specific session (empty = current session)
419
+ output_format: "markdown" or "summary"
420
+ """
421
+ sid = session_id or _session_id
422
+
423
+ if output_format == "summary":
424
+ content = report_gen.generate_terminal_summary(sid)
425
+ report_path = None
426
+ else:
427
+ content = report_gen.generate_model_card(sid)
428
+ saved_path = report_gen.save_report(content, sid)
429
+ report_path = str(saved_path)
430
+
431
+ _write_audit({
432
+ "event_type": "report_generated",
433
+ "output_format": output_format,
434
+ "report_path": report_path,
435
+ "session_id": sid,
436
+ })
437
+
438
+ return {
439
+ "generated": True,
440
+ "session_id": sid,
441
+ "report_path": report_path,
442
+ "content": content,
443
+ "message": (
444
+ f"✅ Governance report generated ({output_format})"
445
+ + (f" → {report_path}" if report_path else "")
446
+ ),
447
+ }
448
+
449
+
450
+ # ══════════════════════════════════════════════════════════════════════════════
451
+ # ENTRY POINT
452
+ # ══════════════════════════════════════════════════════════════════════════════
453
+
454
+ if __name__ == "__main__":
455
+ print(
456
+ f"[SAGE] MCP server starting — session {_session_id}",
457
+ file=sys.stderr,
458
+ )
459
+ mcp.run(transport="stdio")