iris-security-cli 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.
iris_cli/mcp_server.py ADDED
@@ -0,0 +1,567 @@
1
+ """
2
+ IRIS MCP Server for Cursor IDE.
3
+
4
+ This is the shift-left engine. It runs as a local MCP (Model Context Protocol)
5
+ server that Cursor connects to. When a developer writes agent code, Cursor
6
+ asks IRIS to evaluate it for compliance violations. IRIS responds with:
7
+ 1. What the violation is in plain English
8
+ 2. The exact fix (corrected code)
9
+ 3. Which compliance rule it satisfies
10
+
11
+ The developer sees the fix inline and approves it with one click.
12
+ Think of it like ESLint but for the Colorado AI Act.
13
+
14
+ Setup:
15
+ pip install iris-sdk
16
+ iris mcp start ← starts this server on localhost:7779
17
+ Then add to Cursor settings (see .cursor/mcp.json)
18
+ """
19
+
20
+ import json
21
+ import asyncio
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from datetime import datetime
25
+
26
+
27
+ # ── MCP tool definitions ──────────────────────────────────────────────────────
28
+ # These are the tools Cursor calls when it wants IRIS feedback.
29
+
30
+ MCP_TOOLS = [
31
+ {
32
+ "name": "iris_check_agent_code",
33
+ "description": (
34
+ "Check Python or TypeScript agent code for IRIS governance violations. "
35
+ "Returns violations with plain-English explanations and suggested fixes. "
36
+ "Call this whenever a developer writes code that creates or calls an AI agent."
37
+ ),
38
+ "inputSchema": {
39
+ "type": "object",
40
+ "properties": {
41
+ "code": {
42
+ "type": "string",
43
+ "description": "The agent code to check",
44
+ },
45
+ "file_path": {
46
+ "type": "string",
47
+ "description": "Path to the file being edited",
48
+ },
49
+ "framework": {
50
+ "type": "string",
51
+ "description": "Compliance framework to check against",
52
+ "default": "colorado-ai-act",
53
+ },
54
+ },
55
+ "required": ["code"],
56
+ },
57
+ },
58
+ {
59
+ "name": "iris_get_passport_status",
60
+ "description": (
61
+ "Get the governance status of a specific agent. "
62
+ "Returns passport details, compliance check results, and next steps."
63
+ ),
64
+ "inputSchema": {
65
+ "type": "object",
66
+ "properties": {
67
+ "agent_name": {
68
+ "type": "string",
69
+ "description": "Name of the agent to check",
70
+ },
71
+ },
72
+ "required": ["agent_name"],
73
+ },
74
+ },
75
+ {
76
+ "name": "iris_suggest_policy",
77
+ "description": (
78
+ "Generate a suggested IRIS policy for agent code. "
79
+ "Returns a policy-intent.md draft and the compiled Cedar policy. "
80
+ "Call this when a developer has written agent code but has no policy file."
81
+ ),
82
+ "inputSchema": {
83
+ "type": "object",
84
+ "properties": {
85
+ "code": {
86
+ "type": "string",
87
+ "description": "The agent code to generate policy for",
88
+ },
89
+ "agent_name": {
90
+ "type": "string",
91
+ "description": "Name for the agent",
92
+ },
93
+ },
94
+ "required": ["code", "agent_name"],
95
+ },
96
+ },
97
+ {
98
+ "name": "iris_fix_violation",
99
+ "description": (
100
+ "Given a specific IRIS violation, return the corrected code. "
101
+ "The developer can approve the fix with one click in Cursor."
102
+ ),
103
+ "inputSchema": {
104
+ "type": "object",
105
+ "properties": {
106
+ "violation_rule_id": {
107
+ "type": "string",
108
+ "description": "The IRIS rule ID (e.g. CO-002, IRIS-XR-001)",
109
+ },
110
+ "code": {
111
+ "type": "string",
112
+ "description": "The code that triggered the violation",
113
+ },
114
+ "agent_name": {
115
+ "type": "string",
116
+ "description": "Agent name for context",
117
+ },
118
+ },
119
+ "required": ["violation_rule_id", "code"],
120
+ },
121
+ },
122
+ {
123
+ "name": "iris_scan_workspace",
124
+ "description": (
125
+ "Scan the entire workspace for ungoverned agents and policy gaps. "
126
+ "Returns a summary of all agents found, their compliance status, "
127
+ "and prioritized list of actions to take."
128
+ ),
129
+ "inputSchema": {
130
+ "type": "object",
131
+ "properties": {
132
+ "workspace_path": {
133
+ "type": "string",
134
+ "description": "Path to the workspace root",
135
+ },
136
+ },
137
+ "required": [],
138
+ },
139
+ },
140
+ ]
141
+
142
+
143
+ # ── Tool handlers ─────────────────────────────────────────────────────────────
144
+
145
+ def handle_check_agent_code(params: dict) -> dict:
146
+ """
147
+ Analyze agent code for compliance violations.
148
+ This is the core feedback loop for Cursor.
149
+ """
150
+ code = params.get("code", "")
151
+ file_path = params.get("file_path", "unknown")
152
+ framework = params.get("framework", "colorado-ai-act")
153
+
154
+ violations = []
155
+ suggestions = []
156
+
157
+ # Pattern: agent making LLM calls without IRIS decorator
158
+ if any(kw in code for kw in ["openai", "anthropic", "ChatOpenAI", "claude"]):
159
+ if "@agent.guard" not in code and "IrisAgent" not in code:
160
+ violations.append({
161
+ "rule_id": "IRIS-SDK-001",
162
+ "severity": "HIGH",
163
+ "message": (
164
+ "This code makes LLM API calls without IRIS governance. "
165
+ "Any agent calling an LLM must be registered with an AgentPassport "
166
+ "and have its tool calls wrapped with @agent.guard()."
167
+ ),
168
+ "compliance_refs": ["colorado-ai-act:CO-003", "iris:tool-permission"],
169
+ "line_hint": next(
170
+ (i + 1 for i, l in enumerate(code.splitlines())
171
+ if any(kw in l for kw in ["openai", "anthropic", "ChatOpenAI", "claude"])),
172
+ None,
173
+ ),
174
+ "fix": _generate_iris_wrapper(code),
175
+ })
176
+
177
+ # Pattern: PII access without data_classification declared
178
+ if any(kw in code for kw in ["pii", "personal_data", "ssn", "email", "phone", "address"]):
179
+ if "data_classification" not in code:
180
+ violations.append({
181
+ "rule_id": "IRIS-DATA-001",
182
+ "severity": "CRITICAL",
183
+ "message": (
184
+ "This code appears to access personal data but does not declare "
185
+ "a data_classification. Under the Colorado AI Act, agents handling "
186
+ "PII must explicitly declare and restrict their data access."
187
+ ),
188
+ "compliance_refs": ["colorado-ai-act:CO-004", "iris:data-classification"],
189
+ "fix": "Add data_classification=DataClassification.PII to your IrisAgent declaration.",
190
+ })
191
+
192
+ # Pattern: cross-region data movement
193
+ if any(kw in code for kw in ["cn-north", "china", "cn-northwest", "beijing"]):
194
+ violations.append({
195
+ "rule_id": "IRIS-XR-001",
196
+ "severity": "CRITICAL",
197
+ "message": (
198
+ "This code references a Chinese AWS region. Transferring data "
199
+ "between China and the US violates China PIPL. "
200
+ "IRIS will block this call at runtime in production."
201
+ ),
202
+ "compliance_refs": ["china-pipl:cross-border-transfer"],
203
+ "fix": (
204
+ "Add destination_region to your @agent.guard() decorator. "
205
+ "IRIS will enforce the cross-region policy automatically."
206
+ ),
207
+ })
208
+
209
+ # Pattern: high-risk domain with no consent gate
210
+ high_risk_keywords = [
211
+ "loan", "credit", "insurance", "medical", "diagnosis",
212
+ "hiring", "employment", "eviction", "mortgage",
213
+ ]
214
+ if any(kw in code.lower() for kw in high_risk_keywords):
215
+ if "user_consent" not in code and "consent" not in code:
216
+ violations.append({
217
+ "rule_id": "CO-004",
218
+ "severity": "HIGH",
219
+ "message": (
220
+ "This agent appears to make consequential decisions "
221
+ "(financial, medical, or employment-related) without a "
222
+ "consent gate. The Colorado AI Act requires consumers to "
223
+ "be able to opt out of consequential AI decisions."
224
+ ),
225
+ "compliance_refs": ["colorado-ai-act:CO-004"],
226
+ "fix": (
227
+ "Add user_consent_logged=True to your @agent.guard() context "
228
+ "and ensure your application captures explicit user consent "
229
+ "before calling this agent."
230
+ ),
231
+ })
232
+
233
+ # Pattern: no passport registered
234
+ if "def " in code and any(kw in code for kw in ["agent", "llm", "chain", "crew"]):
235
+ if "IrisAgent" not in code and "AgentPassport" not in code:
236
+ suggestions.append({
237
+ "type": "registration",
238
+ "message": (
239
+ "No IRIS AgentPassport detected. Run the following command "
240
+ "to register this agent and generate a Colorado AI Act compliant passport:"
241
+ ),
242
+ "command": f"iris register --name my-agent --owner you@company.com --compliance colorado-ai-act",
243
+ })
244
+
245
+ return {
246
+ "file": file_path,
247
+ "framework": framework,
248
+ "violations_found": len(violations),
249
+ "violations": violations,
250
+ "suggestions": suggestions,
251
+ "status": "FAIL" if violations else "PASS",
252
+ "timestamp": datetime.utcnow().isoformat(),
253
+ }
254
+
255
+
256
+ def handle_get_passport_status(params: dict) -> dict:
257
+ """Get full governance status for an agent."""
258
+ agent_name = params.get("agent_name", "")
259
+ gov_dir = Path.cwd() / "governance" / "agents" / agent_name
260
+ passport_file = gov_dir / "passport.yaml"
261
+
262
+ if not passport_file.exists():
263
+ return {
264
+ "agent": agent_name,
265
+ "status": "NOT_REGISTERED",
266
+ "message": f"No passport found for '{agent_name}'.",
267
+ "next_step": f"iris register --name {agent_name} --compliance colorado-ai-act",
268
+ }
269
+
270
+ try:
271
+ import yaml as _yaml
272
+ passport_data = _yaml.safe_load(passport_file.read_text())
273
+ spec = passport_data.get("spec", {})
274
+
275
+ violations = []
276
+ if spec.get("is_high_risk_ai") and not spec.get("evidence_vault_id"):
277
+ violations.append({
278
+ "rule_id": "CO-002",
279
+ "severity": "CRITICAL",
280
+ "message": "No impact assessment on file.",
281
+ "fix_command": f"iris compliance assess --agent {agent_name}",
282
+ })
283
+ if not spec.get("intent_ref"):
284
+ violations.append({
285
+ "rule_id": "CO-003",
286
+ "severity": "HIGH",
287
+ "message": "No transparency disclosure (policy-intent.md).",
288
+ "fix_command": f"iris policy compile --agent {agent_name}",
289
+ })
290
+
291
+ return {
292
+ "agent": agent_name,
293
+ "status": "COMPLIANT" if not violations else "NON_COMPLIANT",
294
+ "passport": spec,
295
+ "violations": violations,
296
+ "files": {
297
+ "passport": str(passport_file),
298
+ "policy": str(gov_dir / "policy.cedar"),
299
+ "intent": str(gov_dir / "policy-intent.md"),
300
+ "assessment": str(gov_dir / "impact-assessment.md"),
301
+ },
302
+ }
303
+ except Exception as e:
304
+ return {"agent": agent_name, "status": "ERROR", "error": str(e)}
305
+
306
+
307
+ def handle_suggest_policy(params: dict) -> dict:
308
+ """Generate a policy suggestion from agent code."""
309
+ code = params.get("code", "")
310
+ agent_name = params.get("agent_name", "my-agent")
311
+
312
+ tools_detected = []
313
+ if "openai" in code or "ChatOpenAI" in code:
314
+ tools_detected.append("openai-api")
315
+ if "anthropic" in code or "claude" in code:
316
+ tools_detected.append("anthropic-api")
317
+ if "requests" in code or "httpx" in code:
318
+ tools_detected.append("external-http")
319
+ if "boto3" in code or "s3" in code.lower():
320
+ tools_detected.append("aws-s3")
321
+ if "postgres" in code.lower() or "mysql" in code.lower() or "database" in code.lower():
322
+ tools_detected.append("database")
323
+
324
+ intent_draft = f"""# Policy Intent — {agent_name}
325
+
326
+ ## What this agent does
327
+ [Auto-detected from code — edit this description]
328
+
329
+ ## What it is allowed to access
330
+ {chr(10).join(f'- {t}' for t in tools_detected) or '- [No tools auto-detected — add them here]'}
331
+
332
+ ## What it must never do
333
+ - Access personal data outside approved regions
334
+ - Make consequential decisions without user consent logged
335
+ - Call any API not listed above
336
+
337
+ ## Compliance notes
338
+ This agent operates under the Colorado AI Act (SB 24-205).
339
+ User consent must be logged before any consequential decision.
340
+ """
341
+
342
+ return {
343
+ "agent_name": agent_name,
344
+ "tools_detected": tools_detected,
345
+ "intent_draft": intent_draft,
346
+ "next_steps": [
347
+ f"Save the intent draft to: governance/agents/{agent_name}/policy-intent.md",
348
+ f"Then run: iris policy compile --agent {agent_name}",
349
+ f"Then run: iris compliance assess --agent {agent_name}",
350
+ ],
351
+ }
352
+
353
+
354
+ def handle_fix_violation(params: dict) -> dict:
355
+ """Return corrected code for a specific violation."""
356
+ rule_id = params.get("violation_rule_id", "")
357
+ code = params.get("code", "")
358
+ agent_name = params.get("agent_name", "my-agent")
359
+
360
+ fixes = {
361
+ "IRIS-SDK-001": {
362
+ "explanation": "Wrap your agent in an IrisAgent and decorate tool calls with @agent.guard()",
363
+ "code_prefix": f"""from iris import IrisAgent, DataClassification
364
+
365
+ agent = IrisAgent(
366
+ name="{agent_name}",
367
+ owner="your-email@company.com",
368
+ compliance=["colorado-ai-act"],
369
+ )
370
+
371
+ """,
372
+ },
373
+ "CO-002": {
374
+ "explanation": "Run the impact assessment to generate a CO-002-compliant document",
375
+ "command": f"iris compliance assess --agent {agent_name}",
376
+ },
377
+ "CO-003": {
378
+ "explanation": "Compile your policy intent to generate the transparency disclosure",
379
+ "command": f"iris policy compile --agent {agent_name}",
380
+ },
381
+ "CO-004": {
382
+ "explanation": "Add user_consent_logged=True to your guard decorator context",
383
+ "code_snippet": "@agent.guard(tool=\"your-tool\", action=\"call\", user_consent_logged=True)",
384
+ },
385
+ "IRIS-XR-001": {
386
+ "explanation": "Declare the destination region so IRIS can enforce cross-region policy",
387
+ "code_snippet": "@agent.guard(tool=\"storage\", action=\"write\", data_region=\"us-east-1\", destination_region=\"us-east-1\")",
388
+ },
389
+ }
390
+
391
+ fix = fixes.get(rule_id, {
392
+ "explanation": f"No auto-fix available for {rule_id}. Run: iris compliance check for guidance.",
393
+ })
394
+
395
+ return {
396
+ "rule_id": rule_id,
397
+ "fix": fix,
398
+ "approved": False,
399
+ }
400
+
401
+
402
+ def handle_scan_workspace(params: dict) -> dict:
403
+ """Scan workspace for ungoverned agents."""
404
+ workspace = Path(params.get("workspace_path", "."))
405
+ gov_dir = workspace / "governance" / "agents"
406
+
407
+ agents_found = []
408
+ ungoverned = []
409
+
410
+ if gov_dir.exists():
411
+ for passport_file in gov_dir.rglob("passport.yaml"):
412
+ agent_name = passport_file.parent.name
413
+ has_policy = (passport_file.parent / "policy.cedar").exists()
414
+ has_assessment = (passport_file.parent / "impact-assessment.md").exists()
415
+ agents_found.append({
416
+ "name": agent_name,
417
+ "has_passport": True,
418
+ "has_policy": has_policy,
419
+ "has_assessment": has_assessment,
420
+ "compliant": has_policy and has_assessment,
421
+ })
422
+
423
+ py_files = list(workspace.rglob("*.py"))
424
+ for f in py_files:
425
+ try:
426
+ content = f.read_text()
427
+ if any(kw in content for kw in ["openai", "anthropic", "LLM", "ChatOpenAI"]):
428
+ if "IrisAgent" not in content:
429
+ ungoverned.append(str(f.relative_to(workspace)))
430
+ except Exception:
431
+ pass
432
+
433
+ return {
434
+ "agents_registered": len(agents_found),
435
+ "agents": agents_found,
436
+ "ungoverned_files": ungoverned,
437
+ "priority_actions": [
438
+ f"Register {len(ungoverned)} ungoverned agent file(s)" if ungoverned else None,
439
+ "Run iris compliance assess for agents missing impact assessments",
440
+ "Run iris policy compile for agents missing Cedar policies",
441
+ ],
442
+ }
443
+
444
+
445
+ def _generate_iris_wrapper(code: str) -> str:
446
+ return '''from iris import IrisAgent, DataClassification
447
+
448
+ # Add IRIS governance to this agent
449
+ agent = IrisAgent(
450
+ name="my-agent",
451
+ owner="your-email@company.com",
452
+ compliance=["colorado-ai-act"],
453
+ is_high_risk_ai=True, # set True if making consequential decisions
454
+ )
455
+
456
+ # Wrap your tool calls with @agent.guard()
457
+ @agent.guard(tool="llm-api", action="call")
458
+ def your_agent_function():
459
+ # your existing code here
460
+ pass
461
+ '''
462
+
463
+
464
+ # ── MCP Server (stdio transport) ──────────────────────────────────────────────
465
+
466
+ HANDLERS = {
467
+ "iris_check_agent_code": handle_check_agent_code,
468
+ "iris_get_passport_status": handle_get_passport_status,
469
+ "iris_suggest_policy": handle_suggest_policy,
470
+ "iris_fix_violation": handle_fix_violation,
471
+ "iris_scan_workspace": handle_scan_workspace,
472
+ }
473
+
474
+
475
+ async def handle_request(request: dict) -> dict:
476
+ """Handle a single MCP JSON-RPC request."""
477
+ method = request.get("method", "")
478
+ req_id = request.get("id")
479
+ params = request.get("params", {})
480
+
481
+ if method == "initialize":
482
+ return {
483
+ "jsonrpc": "2.0",
484
+ "id": req_id,
485
+ "result": {
486
+ "protocolVersion": "2024-11-05",
487
+ "capabilities": {"tools": {}},
488
+ "serverInfo": {
489
+ "name": "iris-mcp",
490
+ "version": "0.1.0",
491
+ "description": "IRIS AI Agent Governance — Colorado AI Act compliance in your IDE",
492
+ },
493
+ },
494
+ }
495
+
496
+ if method == "tools/list":
497
+ return {
498
+ "jsonrpc": "2.0",
499
+ "id": req_id,
500
+ "result": {"tools": MCP_TOOLS},
501
+ }
502
+
503
+ if method == "tools/call":
504
+ tool_name = params.get("name")
505
+ tool_args = params.get("arguments", {})
506
+ handler = HANDLERS.get(tool_name)
507
+
508
+ if not handler:
509
+ return {
510
+ "jsonrpc": "2.0",
511
+ "id": req_id,
512
+ "error": {"code": -32601, "message": f"Unknown tool: {tool_name}"},
513
+ }
514
+
515
+ try:
516
+ result = handler(tool_args)
517
+ return {
518
+ "jsonrpc": "2.0",
519
+ "id": req_id,
520
+ "result": {
521
+ "content": [{"type": "text", "text": json.dumps(result, indent=2)}]
522
+ },
523
+ }
524
+ except Exception as e:
525
+ return {
526
+ "jsonrpc": "2.0",
527
+ "id": req_id,
528
+ "error": {"code": -32603, "message": str(e)},
529
+ }
530
+
531
+ return {
532
+ "jsonrpc": "2.0",
533
+ "id": req_id,
534
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
535
+ }
536
+
537
+
538
+ async def run_stdio_server():
539
+ """Run the MCP server over stdio (Cursor's transport)."""
540
+ import sys
541
+ reader = asyncio.StreamReader()
542
+ protocol = asyncio.StreamReaderProtocol(reader)
543
+ await asyncio.get_event_loop().connect_read_pipe(lambda: protocol, sys.stdin)
544
+ writer_transport, writer_protocol = await asyncio.get_event_loop().connect_write_pipe(
545
+ asyncio.BaseProtocol, sys.stdout
546
+ )
547
+
548
+ while True:
549
+ try:
550
+ line = await reader.readline()
551
+ if not line:
552
+ break
553
+ request = json.loads(line.decode())
554
+ response = await handle_request(request)
555
+ sys.stdout.write(json.dumps(response) + "\n")
556
+ sys.stdout.flush()
557
+ except Exception:
558
+ break
559
+
560
+
561
+ def start():
562
+ """Entry point for: iris mcp start"""
563
+ asyncio.run(run_stdio_server())
564
+
565
+
566
+ if __name__ == "__main__":
567
+ start()
@@ -0,0 +1,116 @@
1
+ """
2
+ Local policy draft cache — zero API cost for iris policy diff.
3
+
4
+ After `iris policy compile` (or `--dry-run`), the generated Cedar is cached
5
+ alongside the agent's governance files. `iris policy diff` reads that cache
6
+ offline and compares it to policy.cedar on disk.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+
19
+ DRAFT_FILENAME = "policy-draft.cedar"
20
+ META_FILENAME = "policy-draft.meta.json"
21
+
22
+
23
+ @dataclass
24
+ class DraftMeta:
25
+ intent_sha256: str
26
+ compiled_at: str
27
+ compiler_backend: str
28
+ compiler_model: str
29
+
30
+ @classmethod
31
+ def from_dict(cls, data: dict) -> "DraftMeta":
32
+ return cls(
33
+ intent_sha256=data["intent_sha256"],
34
+ compiled_at=data["compiled_at"],
35
+ compiler_backend=data.get("compiler_backend", "unknown"),
36
+ compiler_model=data.get("compiler_model", "unknown"),
37
+ )
38
+
39
+
40
+ @dataclass
41
+ class DraftCacheStatus:
42
+ draft_path: Path
43
+ meta_path: Path
44
+ meta: Optional[DraftMeta]
45
+ intent_sha256: str
46
+ draft_exists: bool
47
+ is_stale: bool
48
+
49
+
50
+ def intent_hash(intent_text: str) -> str:
51
+ return hashlib.sha256(intent_text.encode("utf-8")).hexdigest()
52
+
53
+
54
+ def draft_paths(gov_dir: Path) -> tuple[Path, Path]:
55
+ return gov_dir / DRAFT_FILENAME, gov_dir / META_FILENAME
56
+
57
+
58
+ def save_policy_draft(
59
+ gov_dir: Path,
60
+ intent_text: str,
61
+ cedar_policy: str,
62
+ compiler_backend: str,
63
+ compiler_model: str,
64
+ ) -> Path:
65
+ """Write Cedar draft + metadata after compile or dry-run."""
66
+ draft_file, meta_file = draft_paths(gov_dir)
67
+ draft_file.write_text(cedar_policy)
68
+ meta = DraftMeta(
69
+ intent_sha256=intent_hash(intent_text),
70
+ compiled_at=datetime.now(timezone.utc).isoformat(),
71
+ compiler_backend=compiler_backend,
72
+ compiler_model=compiler_model,
73
+ )
74
+ meta_file.write_text(json.dumps({
75
+ "intent_sha256": meta.intent_sha256,
76
+ "compiled_at": meta.compiled_at,
77
+ "compiler_backend": meta.compiler_backend,
78
+ "compiler_model": meta.compiler_model,
79
+ }, indent=2))
80
+ return draft_file
81
+
82
+
83
+ def load_draft_meta(gov_dir: Path) -> Optional[DraftMeta]:
84
+ _, meta_file = draft_paths(gov_dir)
85
+ if not meta_file.exists():
86
+ return None
87
+ try:
88
+ return DraftMeta.from_dict(json.loads(meta_file.read_text()))
89
+ except (json.JSONDecodeError, KeyError):
90
+ return None
91
+
92
+
93
+ def check_draft_cache(gov_dir: Path, intent_text: str) -> DraftCacheStatus:
94
+ draft_file, meta_file = draft_paths(gov_dir)
95
+ current_hash = intent_hash(intent_text)
96
+ meta = load_draft_meta(gov_dir)
97
+ is_stale = meta is None or meta.intent_sha256 != current_hash
98
+ return DraftCacheStatus(
99
+ draft_path=draft_file,
100
+ meta_path=meta_file,
101
+ meta=meta,
102
+ intent_sha256=current_hash,
103
+ draft_exists=draft_file.exists(),
104
+ is_stale=is_stale,
105
+ )
106
+
107
+
108
+ def load_cached_draft(gov_dir: Path, intent_text: str) -> tuple[str, DraftCacheStatus]:
109
+ status = check_draft_cache(gov_dir, intent_text)
110
+ if not status.draft_exists:
111
+ raise FileNotFoundError(
112
+ f"No cached policy draft found at {status.draft_path}\n"
113
+ f"Run: iris policy compile --agent <name> --dry-run\n"
114
+ f"Uses your LLM key from ANTHROPIC_API_KEY or ~/.iris/config.yaml"
115
+ )
116
+ return status.draft_path.read_text(), status