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/__init__.py +0 -0
- iris_cli/assess.py +498 -0
- iris_cli/cedar_parser.py +454 -0
- iris_cli/compiler_config.py +54 -0
- iris_cli/evidence.py +822 -0
- iris_cli/main.py +542 -0
- iris_cli/mcp_server.py +567 -0
- iris_cli/policy_cache.py +116 -0
- iris_cli/policy_diff.py +467 -0
- iris_cli/scan_report.py +146 -0
- iris_security_cli-0.1.0.dist-info/METADATA +45 -0
- iris_security_cli-0.1.0.dist-info/RECORD +17 -0
- iris_security_cli-0.1.0.dist-info/WHEEL +5 -0
- iris_security_cli-0.1.0.dist-info/entry_points.txt +2 -0
- iris_security_cli-0.1.0.dist-info/top_level.txt +2 -0
- tests/test_evidence.py +296 -0
- tests/test_policy_diff.py +250 -0
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()
|
iris_cli/policy_cache.py
ADDED
|
@@ -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
|