correctover-test 0.3.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.
- correctover_test/__init__.py +2 -0
- correctover_test/cli.py +222 -0
- correctover_test/core/__init__.py +0 -0
- correctover_test/core/adapters/__init__.py +6 -0
- correctover_test/core/adapters/base.py +206 -0
- correctover_test/core/adapters/crewai_adapter.py +204 -0
- correctover_test/core/adapters/llamaindex_adapter.py +185 -0
- correctover_test/core/adapters/smolagents_adapter.py +193 -0
- correctover_test/core/engine.py +104 -0
- correctover_test/core/license.py +167 -0
- correctover_test/core/reporter.py +382 -0
- correctover_test/core/scenarios/__init__.py +6 -0
- correctover_test/core/scenarios/base.py +6 -0
- correctover_test/core/scenarios/contract_break.py +130 -0
- correctover_test/core/scenarios/env_protection.py +114 -0
- correctover_test/core/scenarios/mcp_self_heal.py +109 -0
- correctover_test-0.3.0.dist-info/METADATA +45 -0
- correctover_test-0.3.0.dist-info/RECORD +20 -0
- correctover_test-0.3.0.dist-info/WHEEL +4 -0
- correctover_test-0.3.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LlamaIndex Framework Adapter.
|
|
3
|
+
|
|
4
|
+
LlamaIndex's workflow agents use event streams. For testing without an LLM,
|
|
5
|
+
we run guardrail checks directly against the provider by iterating tools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from .base import FrameworkAdapter, FaultType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LlamaIndexAdapter(FrameworkAdapter):
|
|
14
|
+
name = "llamaindex"
|
|
15
|
+
|
|
16
|
+
def create_agent(self, tools: List[Any], config: Optional[Dict] = None) -> Any:
|
|
17
|
+
from llama_index.core.agent.workflow import FunctionAgent
|
|
18
|
+
from llama_index.core.llms import MockLLM
|
|
19
|
+
|
|
20
|
+
cfg = config or {}
|
|
21
|
+
llm = cfg.get("llm")
|
|
22
|
+
if llm is None:
|
|
23
|
+
llm = MockLLM()
|
|
24
|
+
|
|
25
|
+
return FunctionAgent(
|
|
26
|
+
name=config.get("name", "test-agent") if config else "test-agent",
|
|
27
|
+
description=config.get("description", "Test agent for correctover validation") if config else "Test agent for correctover validation",
|
|
28
|
+
system_prompt=config.get("system_prompt", "You are a test agent. Use tools to complete tasks.") if config else "You are a test agent. Use tools to complete tasks.",
|
|
29
|
+
tools=tools,
|
|
30
|
+
llm=llm,
|
|
31
|
+
**(config or {}),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def register_guardrail(self, agent: Any, provider: Any) -> Any:
|
|
35
|
+
"""
|
|
36
|
+
Store guardrail context on the agent for direct-check mode.
|
|
37
|
+
"""
|
|
38
|
+
from correctover import GuardrailContext, AuditTrail
|
|
39
|
+
|
|
40
|
+
trail = AuditTrail()
|
|
41
|
+
ctx = GuardrailContext(provider=provider, trail=trail)
|
|
42
|
+
|
|
43
|
+
agent._correctover_trail = trail
|
|
44
|
+
agent._correctover_context = ctx
|
|
45
|
+
|
|
46
|
+
return agent
|
|
47
|
+
|
|
48
|
+
def run_task(self, agent: Any, task: str) -> Dict[str, Any]:
|
|
49
|
+
"""
|
|
50
|
+
Direct guardrail check ā no LLM required.
|
|
51
|
+
Iterates over agent tools and tests each against the guardrail provider.
|
|
52
|
+
"""
|
|
53
|
+
import time
|
|
54
|
+
from correctover import ToolCallContext
|
|
55
|
+
|
|
56
|
+
t0 = time.time()
|
|
57
|
+
error = None
|
|
58
|
+
output_parts = []
|
|
59
|
+
|
|
60
|
+
trail = getattr(agent, "_correctover_trail", None)
|
|
61
|
+
ctx = getattr(agent, "_correctover_context", None)
|
|
62
|
+
|
|
63
|
+
if ctx and ctx.provider:
|
|
64
|
+
tools = getattr(agent, "tools", None)
|
|
65
|
+
if tools:
|
|
66
|
+
for tool_obj in tools:
|
|
67
|
+
# Get tool name from various LlamaIndex tool structures
|
|
68
|
+
tname = None
|
|
69
|
+
if hasattr(tool_obj, "metadata"):
|
|
70
|
+
meta = tool_obj.metadata
|
|
71
|
+
tname = getattr(meta, "name", None) if hasattr(meta, "name") else str(meta)
|
|
72
|
+
if tname is None:
|
|
73
|
+
fn = getattr(tool_obj, "fn", None)
|
|
74
|
+
tname = getattr(fn, "__name__", str(tool_obj)) if fn else str(tool_obj)
|
|
75
|
+
|
|
76
|
+
tool_args = {}
|
|
77
|
+
if "rm -rf" in task or "dangerous_exec" in str(tname):
|
|
78
|
+
tool_args["command"] = "rm -rf /"
|
|
79
|
+
elif "TEST_API_KEY" in task or "read_env" in str(tname):
|
|
80
|
+
tool_args["key"] = "TEST_API_KEY"
|
|
81
|
+
else:
|
|
82
|
+
tool_args["query"] = task
|
|
83
|
+
|
|
84
|
+
tc = ToolCallContext(
|
|
85
|
+
tool_name=tname,
|
|
86
|
+
tool_args=tool_args,
|
|
87
|
+
agent_id=getattr(agent, "name", "llamaindex-agent"),
|
|
88
|
+
metadata={"framework": "llamaindex", "mode": "direct-check"},
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
decision = ctx.authorize(tc)
|
|
92
|
+
ctx.after_tool_call(decision, {"simulated": True}, time.time())
|
|
93
|
+
if decision.authorized:
|
|
94
|
+
output_parts.append(f"Tool '{tname}' authorized")
|
|
95
|
+
else:
|
|
96
|
+
msg = f"Tool '{tname}' DENIED: {decision.claims.get('reason', 'unauthorized')}"
|
|
97
|
+
output_parts.append(msg)
|
|
98
|
+
error = msg
|
|
99
|
+
except Exception as e:
|
|
100
|
+
output_parts.append(f"[ERROR] {e}")
|
|
101
|
+
error = str(e)
|
|
102
|
+
else:
|
|
103
|
+
output_parts.append("No tools to test")
|
|
104
|
+
else:
|
|
105
|
+
output_parts.append("No guardrail context ā skipping")
|
|
106
|
+
|
|
107
|
+
output = "\n".join(output_parts) if output_parts else str(task)
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"output": output,
|
|
111
|
+
"duration_ms": (time.time() - t0) * 1000,
|
|
112
|
+
"error": error,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def detect_missing_guardrail(self, agent: Any) -> List[Dict]:
|
|
116
|
+
findings = []
|
|
117
|
+
|
|
118
|
+
if not hasattr(agent, "_correctover_trail"):
|
|
119
|
+
findings.append({
|
|
120
|
+
"severity": "CRITICAL",
|
|
121
|
+
"pattern": "AS-GUARDRAIL-MISS-001",
|
|
122
|
+
"agent": agent.name,
|
|
123
|
+
"message": f"Agent '{agent.name}' has no correctover guardrail injected",
|
|
124
|
+
"remediation": "Call adapter.register_guardrail(agent, provider)",
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
tools = getattr(agent, "tools", None)
|
|
128
|
+
if tools is None or (isinstance(tools, list) and len(tools) == 0):
|
|
129
|
+
findings.append({
|
|
130
|
+
"severity": "INFO",
|
|
131
|
+
"pattern": "LLAMA-NO-TOOLS-001",
|
|
132
|
+
"agent": agent.name,
|
|
133
|
+
"message": "Agent has no tools configured",
|
|
134
|
+
"remediation": "Add tools to the agent for guardrail testing",
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return findings
|
|
138
|
+
|
|
139
|
+
def inject_fault(self, agent: Any, fault_type: FaultType) -> Any:
|
|
140
|
+
from llama_index.core.tools import FunctionTool
|
|
141
|
+
|
|
142
|
+
if fault_type == FaultType.COMMAND_INJECTION:
|
|
143
|
+
|
|
144
|
+
def dangerous_exec(command: str) -> str:
|
|
145
|
+
return f"executed: {command}"
|
|
146
|
+
|
|
147
|
+
tool = FunctionTool.from_defaults(
|
|
148
|
+
fn=dangerous_exec,
|
|
149
|
+
name="dangerous_exec",
|
|
150
|
+
description="Execute a shell command (DANGEROUS)",
|
|
151
|
+
)
|
|
152
|
+
if agent.tools is None:
|
|
153
|
+
agent.tools = []
|
|
154
|
+
agent.tools.append(tool)
|
|
155
|
+
|
|
156
|
+
elif fault_type == FaultType.ENV_EXPOSURE:
|
|
157
|
+
|
|
158
|
+
def read_env(key: str) -> str:
|
|
159
|
+
import os
|
|
160
|
+
return os.environ.get(key, "NOT_SET")
|
|
161
|
+
|
|
162
|
+
tool = FunctionTool.from_defaults(
|
|
163
|
+
fn=read_env,
|
|
164
|
+
name="read_env",
|
|
165
|
+
description="Read an environment variable",
|
|
166
|
+
)
|
|
167
|
+
if agent.tools is None:
|
|
168
|
+
agent.tools = []
|
|
169
|
+
agent.tools.append(tool)
|
|
170
|
+
|
|
171
|
+
elif fault_type == FaultType.CONTRACT_BREAK:
|
|
172
|
+
|
|
173
|
+
def unreliable_output(query: str) -> dict:
|
|
174
|
+
return {"partial": query, "_incomplete": True}
|
|
175
|
+
|
|
176
|
+
tool = FunctionTool.from_defaults(
|
|
177
|
+
fn=unreliable_output,
|
|
178
|
+
name="unreliable_output",
|
|
179
|
+
description="Returns output that may break contract validation",
|
|
180
|
+
)
|
|
181
|
+
if agent.tools is None:
|
|
182
|
+
agent.tools = []
|
|
183
|
+
agent.tools.append(tool)
|
|
184
|
+
|
|
185
|
+
return agent
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smolagents Framework Adapter.
|
|
3
|
+
|
|
4
|
+
Smolagents lacks before_tool_call hooks. For testing without an LLM,
|
|
5
|
+
we run guardrail checks directly against the provider by simulating
|
|
6
|
+
ToolCallContext entries for each tool in the agent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .base import FrameworkAdapter, FaultType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SmolagentsAdapter(FrameworkAdapter):
|
|
15
|
+
name = "smolagents"
|
|
16
|
+
|
|
17
|
+
def create_agent(self, tools: List[Any], config: Optional[Dict] = None) -> Any:
|
|
18
|
+
from smolagents import ToolCallingAgent
|
|
19
|
+
|
|
20
|
+
# Use a mock model to avoid real API calls during testing
|
|
21
|
+
try:
|
|
22
|
+
from smolagents.models import LiteLLMModel
|
|
23
|
+
model = LiteLLMModel(model_id="deepseek/deepseek-chat", api_key="sk-test")
|
|
24
|
+
except Exception:
|
|
25
|
+
model = None
|
|
26
|
+
|
|
27
|
+
return ToolCallingAgent(
|
|
28
|
+
tools=tools,
|
|
29
|
+
model=model,
|
|
30
|
+
**(config or {}),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def register_guardrail(self, agent: Any, provider: Any) -> Any:
|
|
34
|
+
"""
|
|
35
|
+
Register guardrail context on the agent for direct-check mode.
|
|
36
|
+
Smolagents has no native before_tool_call hook, so we store the
|
|
37
|
+
provider on the agent for run_task to use directly.
|
|
38
|
+
"""
|
|
39
|
+
from correctover import GuardrailContext, AuditTrail
|
|
40
|
+
|
|
41
|
+
trail = AuditTrail()
|
|
42
|
+
ctx = GuardrailContext(provider=provider, trail=trail)
|
|
43
|
+
|
|
44
|
+
agent._correctover_trail = trail
|
|
45
|
+
agent._correctover_context = ctx
|
|
46
|
+
|
|
47
|
+
return agent
|
|
48
|
+
|
|
49
|
+
def run_task(self, agent: Any, task: str) -> Dict[str, Any]:
|
|
50
|
+
"""
|
|
51
|
+
Direct guardrail check ā no LLM required.
|
|
52
|
+
Iterates over agent tools and tests each against the guardrail provider.
|
|
53
|
+
"""
|
|
54
|
+
import time
|
|
55
|
+
from correctover import ToolCallContext
|
|
56
|
+
|
|
57
|
+
t0 = time.time()
|
|
58
|
+
error = None
|
|
59
|
+
output_parts = []
|
|
60
|
+
|
|
61
|
+
trail = getattr(agent, "_correctover_trail", None)
|
|
62
|
+
ctx = getattr(agent, "_correctover_context", None)
|
|
63
|
+
|
|
64
|
+
if ctx and ctx.provider:
|
|
65
|
+
tools = getattr(agent, "tools", {})
|
|
66
|
+
if tools:
|
|
67
|
+
# Test each tool against the guardrail
|
|
68
|
+
for tname, tool_obj in (tools.items() if isinstance(tools, dict) else [(getattr(t, "name", str(t)), t) for t in tools]):
|
|
69
|
+
tool_args = {}
|
|
70
|
+
if "rm -rf" in task or "dangerous_exec" in tname:
|
|
71
|
+
tool_args["command"] = "rm -rf /"
|
|
72
|
+
elif "TEST_API_KEY" in task or "read_env" in tname:
|
|
73
|
+
tool_args["key"] = "TEST_API_KEY"
|
|
74
|
+
else:
|
|
75
|
+
tool_args["query"] = task
|
|
76
|
+
|
|
77
|
+
tc = ToolCallContext(
|
|
78
|
+
tool_name=tname,
|
|
79
|
+
tool_args=tool_args,
|
|
80
|
+
agent_id=getattr(agent, "name", "smolagents-agent"),
|
|
81
|
+
metadata={"framework": "smolagents", "mode": "direct-check"},
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
decision = ctx.authorize(tc)
|
|
85
|
+
ctx.after_tool_call(decision, {"simulated": True}, time.time())
|
|
86
|
+
if decision.authorized:
|
|
87
|
+
output_parts.append(f"Tool '{tname}' authorized")
|
|
88
|
+
else:
|
|
89
|
+
msg = f"Tool '{tname}' DENIED: {decision.claims.get('reason', 'unauthorized')}"
|
|
90
|
+
output_parts.append(msg)
|
|
91
|
+
error = msg
|
|
92
|
+
except Exception as e:
|
|
93
|
+
output_parts.append(f"[ERROR] {e}")
|
|
94
|
+
error = str(e)
|
|
95
|
+
else:
|
|
96
|
+
output_parts.append("No tools to test")
|
|
97
|
+
else:
|
|
98
|
+
output_parts.append("No guardrail context ā skipping")
|
|
99
|
+
|
|
100
|
+
output = "\n".join(output_parts) if output_parts else str(task)
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"output": output,
|
|
104
|
+
"duration_ms": (time.time() - t0) * 1000,
|
|
105
|
+
"error": error,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def detect_missing_guardrail(self, agent: Any) -> List[Dict]:
|
|
109
|
+
findings = []
|
|
110
|
+
|
|
111
|
+
if not hasattr(agent, "_correctover_trail"):
|
|
112
|
+
findings.append({
|
|
113
|
+
"severity": "CRITICAL",
|
|
114
|
+
"pattern": "AS-GUARDRAIL-MISS-001",
|
|
115
|
+
"agent": getattr(agent, "name", "unknown"),
|
|
116
|
+
"message": "Agent has no correctover guardrail injected",
|
|
117
|
+
"remediation": "Call adapter.register_guardrail(agent, provider)",
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
checks = getattr(agent, "final_answer_checks", None)
|
|
121
|
+
if checks is None or (isinstance(checks, list) and len(checks) == 0):
|
|
122
|
+
findings.append({
|
|
123
|
+
"severity": "WARNING",
|
|
124
|
+
"pattern": "SMOL-FINAL-CHECK-MISS-001",
|
|
125
|
+
"agent": getattr(agent, "name", "unknown"),
|
|
126
|
+
"message": "No final_answer_checks configured",
|
|
127
|
+
"remediation": "Set agent.final_answer_checks with validation functions",
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
if hasattr(agent, "authorized_imports"):
|
|
131
|
+
imports = getattr(agent, "authorized_imports", [])
|
|
132
|
+
if "os" in imports or "subprocess" in imports:
|
|
133
|
+
findings.append({
|
|
134
|
+
"severity": "HIGH",
|
|
135
|
+
"pattern": "SMOL-IMPORT-RISK-001",
|
|
136
|
+
"agent": getattr(agent, "name", "unknown"),
|
|
137
|
+
"message": "Dangerous imports allowed: os, subprocess",
|
|
138
|
+
"remediation": "Remove os/subprocess from authorized_imports",
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
return findings
|
|
142
|
+
|
|
143
|
+
def inject_fault(self, agent: Any, fault_type: FaultType) -> Any:
|
|
144
|
+
from smolagents import tool
|
|
145
|
+
|
|
146
|
+
if fault_type == FaultType.COMMAND_INJECTION:
|
|
147
|
+
|
|
148
|
+
@tool
|
|
149
|
+
def dangerous_exec(command: str) -> str:
|
|
150
|
+
"""Execute a shell command. DANGEROUS ā should be blocked by guardrail.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
command: Shell command to execute.
|
|
154
|
+
"""
|
|
155
|
+
import subprocess
|
|
156
|
+
return subprocess.getoutput(command)
|
|
157
|
+
|
|
158
|
+
if agent.tools is None:
|
|
159
|
+
agent.tools = {}
|
|
160
|
+
agent.tools["dangerous_exec"] = dangerous_exec
|
|
161
|
+
|
|
162
|
+
elif fault_type == FaultType.ENV_EXPOSURE:
|
|
163
|
+
|
|
164
|
+
@tool
|
|
165
|
+
def read_env(key: str) -> str:
|
|
166
|
+
"""Read an environment variable. Should be blocked by guardrail.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
key: Environment variable name to read.
|
|
170
|
+
"""
|
|
171
|
+
import os
|
|
172
|
+
return os.environ.get(key, "NOT_SET")
|
|
173
|
+
|
|
174
|
+
if agent.tools is None:
|
|
175
|
+
agent.tools = {}
|
|
176
|
+
agent.tools["read_env"] = read_env
|
|
177
|
+
|
|
178
|
+
elif fault_type == FaultType.CONTRACT_BREAK:
|
|
179
|
+
|
|
180
|
+
@tool
|
|
181
|
+
def unreliable_output(query: str) -> dict:
|
|
182
|
+
"""Return incomplete output missing required fields ā contract break test.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
query: Input query to process.
|
|
186
|
+
"""
|
|
187
|
+
return {"partial": query, "_incomplete": True}
|
|
188
|
+
|
|
189
|
+
if agent.tools is None:
|
|
190
|
+
agent.tools = {}
|
|
191
|
+
agent.tools["unreliable_output"] = unreliable_output
|
|
192
|
+
|
|
193
|
+
return agent
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TestEngine ā orchestrates scenario runs across frameworks, collects AuditTrail data.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
|
|
8
|
+
from correctover import AuditTrail, make_guardrail_hook, EnvProtectionProvider
|
|
9
|
+
|
|
10
|
+
from .adapters.base import FrameworkAdapter, TestResult, Verdict
|
|
11
|
+
from .scenarios.base import TestScenario
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestEngine:
|
|
15
|
+
"""Orchestrates self-healing test runs across framework adapters."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, adapters: Optional[List[FrameworkAdapter]] = None):
|
|
18
|
+
self.adapters = adapters or []
|
|
19
|
+
|
|
20
|
+
def register_adapter(self, adapter: FrameworkAdapter) -> None:
|
|
21
|
+
self.adapters.append(adapter)
|
|
22
|
+
|
|
23
|
+
def run_scenario(
|
|
24
|
+
self,
|
|
25
|
+
adapter: FrameworkAdapter,
|
|
26
|
+
scenario: TestScenario,
|
|
27
|
+
) -> TestResult:
|
|
28
|
+
"""Run a single scenario on a single adapter."""
|
|
29
|
+
agent = adapter.create_agent([], {})
|
|
30
|
+
|
|
31
|
+
# Inject guardrail ā use scenario-appropriate provider
|
|
32
|
+
from correctover import EnvProtectionProvider, DenyAllGuardrailProvider, CompositeGuardrailProvider, ToolListGuardrailProvider
|
|
33
|
+
from .adapters.base import FaultType
|
|
34
|
+
|
|
35
|
+
if scenario.fault_type == FaultType.COMMAND_INJECTION:
|
|
36
|
+
# MCP: deny dangerous_exec explicitly
|
|
37
|
+
provider = ToolListGuardrailProvider(denied_tools={"dangerous_exec"})
|
|
38
|
+
elif scenario.fault_type == FaultType.ENV_EXPOSURE:
|
|
39
|
+
provider = EnvProtectionProvider()
|
|
40
|
+
elif scenario.fault_type == FaultType.CONTRACT_BREAK:
|
|
41
|
+
# Contract break: allow all (post-execution validation via CCSValidator)
|
|
42
|
+
from correctover import AllowAllGuardrailProvider
|
|
43
|
+
provider = AllowAllGuardrailProvider()
|
|
44
|
+
else:
|
|
45
|
+
provider = EnvProtectionProvider()
|
|
46
|
+
|
|
47
|
+
agent = adapter.register_guardrail(agent, provider)
|
|
48
|
+
|
|
49
|
+
# Use the trail from agent (created by register_guardrail)
|
|
50
|
+
trail = getattr(agent, "_correctover_trail", AuditTrail())
|
|
51
|
+
|
|
52
|
+
# Run scenario
|
|
53
|
+
return scenario.run(adapter, agent, trail)
|
|
54
|
+
|
|
55
|
+
def run_all(
|
|
56
|
+
self,
|
|
57
|
+
scenarios: Optional[List[TestScenario]] = None,
|
|
58
|
+
frameworks: Optional[List[str]] = None,
|
|
59
|
+
max_workers: int = 4,
|
|
60
|
+
) -> List[TestResult]:
|
|
61
|
+
"""Run all scenarios on all (or selected) adapters."""
|
|
62
|
+
from .scenarios import (
|
|
63
|
+
MCPSelfHealScenario,
|
|
64
|
+
EnvProtectionScenario,
|
|
65
|
+
ContractBreakScenario,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if scenarios is None:
|
|
69
|
+
scenarios = [
|
|
70
|
+
MCPSelfHealScenario(),
|
|
71
|
+
EnvProtectionScenario(),
|
|
72
|
+
ContractBreakScenario(),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
targets = self.adapters
|
|
76
|
+
if frameworks:
|
|
77
|
+
targets = [a for a in self.adapters if a.name in frameworks]
|
|
78
|
+
|
|
79
|
+
results: List[TestResult] = []
|
|
80
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
81
|
+
futures = {
|
|
82
|
+
pool.submit(self.run_scenario, adapter, scenario): (adapter.name, scenario.name)
|
|
83
|
+
for adapter in targets
|
|
84
|
+
for scenario in scenarios
|
|
85
|
+
}
|
|
86
|
+
for future in as_completed(futures):
|
|
87
|
+
try:
|
|
88
|
+
result = future.result()
|
|
89
|
+
results.append(result)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
fw, sc = futures[future]
|
|
92
|
+
results.append(TestResult(
|
|
93
|
+
framework=fw,
|
|
94
|
+
scenario=sc,
|
|
95
|
+
verdict=Verdict.FAIL,
|
|
96
|
+
errors=[str(e)],
|
|
97
|
+
))
|
|
98
|
+
|
|
99
|
+
return results
|
|
100
|
+
|
|
101
|
+
def quick_smoke_test(self) -> List[TestResult]:
|
|
102
|
+
"""Run a minimal smoke test: EnvProtectionScenario only."""
|
|
103
|
+
from .scenarios import EnvProtectionScenario
|
|
104
|
+
return self.run_all(scenarios=[EnvProtectionScenario()])
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
License validator ā enforces free tier limits (50 audits/day).
|
|
3
|
+
Embedded in all Correctover Agent products.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LicenseValidator:
|
|
15
|
+
"""Enforces usage limits for Correctover Agent products."""
|
|
16
|
+
|
|
17
|
+
FREE_LIMIT_PER_DAY = 50
|
|
18
|
+
STATE_FILE = Path.home() / ".correctover" / "license.json"
|
|
19
|
+
|
|
20
|
+
def __init__(self, product: str):
|
|
21
|
+
self.product = product
|
|
22
|
+
self.state = self._load_state()
|
|
23
|
+
|
|
24
|
+
def _load_state(self) -> Dict:
|
|
25
|
+
if self.STATE_FILE.exists():
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(self.STATE_FILE.read_text())
|
|
28
|
+
except (json.JSONDecodeError, OSError):
|
|
29
|
+
pass
|
|
30
|
+
return {"products": {}, "license_key": None, "installed_at": time.time()}
|
|
31
|
+
|
|
32
|
+
def _save_state(self) -> None:
|
|
33
|
+
self.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
self.STATE_FILE.write_text(json.dumps(self.state, indent=2))
|
|
35
|
+
|
|
36
|
+
def _get_product_state(self) -> Dict:
|
|
37
|
+
return self.state["products"].setdefault(self.product, {
|
|
38
|
+
"calls_today": 0,
|
|
39
|
+
"date": time.strftime("%Y-%m-%d"),
|
|
40
|
+
"total_calls": 0,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
def check_license(self) -> Dict:
|
|
44
|
+
"""Check if the current usage is within limits. Returns status dict."""
|
|
45
|
+
today = time.strftime("%Y-%m-%d")
|
|
46
|
+
ps = self._get_product_state()
|
|
47
|
+
|
|
48
|
+
# Reset daily counter if it's a new day
|
|
49
|
+
if ps.get("date") != today:
|
|
50
|
+
ps["calls_today"] = 0
|
|
51
|
+
ps["date"] = today
|
|
52
|
+
|
|
53
|
+
license_key = self.state.get("license_key")
|
|
54
|
+
has_license = bool(license_key)
|
|
55
|
+
|
|
56
|
+
if has_license:
|
|
57
|
+
# Pro/Enterprise ā no limits
|
|
58
|
+
return {
|
|
59
|
+
"authorized": True,
|
|
60
|
+
"tier": "pro" if self._verify_license_key(license_key) else "free",
|
|
61
|
+
"calls_remaining": float("inf"),
|
|
62
|
+
"calls_today": ps["calls_today"],
|
|
63
|
+
"limit": float("inf"),
|
|
64
|
+
"license_key": license_key[:8] + "..." if license_key else None,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Free tier
|
|
68
|
+
remaining = max(0, self.FREE_LIMIT_PER_DAY - ps["calls_today"])
|
|
69
|
+
return {
|
|
70
|
+
"authorized": remaining > 0,
|
|
71
|
+
"tier": "free",
|
|
72
|
+
"calls_remaining": remaining,
|
|
73
|
+
"calls_today": ps["calls_today"],
|
|
74
|
+
"limit": self.FREE_LIMIT_PER_DAY,
|
|
75
|
+
"license_key": None,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def record_call(self) -> Dict:
|
|
79
|
+
"""Record a single API call. Returns updated status."""
|
|
80
|
+
status = self.check_license()
|
|
81
|
+
if not status["authorized"]:
|
|
82
|
+
return status
|
|
83
|
+
|
|
84
|
+
ps = self._get_product_state()
|
|
85
|
+
ps["calls_today"] += 1
|
|
86
|
+
ps["total_calls"] = ps.get("total_calls", 0) + 1
|
|
87
|
+
self._save_state()
|
|
88
|
+
|
|
89
|
+
status["calls_remaining"] = max(0, status["limit"] - ps["calls_today"])
|
|
90
|
+
status["calls_today"] = ps["calls_today"]
|
|
91
|
+
return status
|
|
92
|
+
|
|
93
|
+
def set_license_key(self, key: str) -> bool:
|
|
94
|
+
"""Set and validate a license key."""
|
|
95
|
+
if self._verify_license_key(key):
|
|
96
|
+
self.state["license_key"] = key
|
|
97
|
+
self._save_state()
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def _verify_license_key(self, key: str) -> bool:
|
|
102
|
+
"""Basic license key validation (HMAC-based)."""
|
|
103
|
+
if not key or len(key) < 16:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
# Format: COV-<product>-<hash>
|
|
107
|
+
parts = key.split("-")
|
|
108
|
+
if len(parts) < 3 or parts[0] != "COV":
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# Verify the HMAC signature
|
|
112
|
+
expected_prefix = self._compute_key_prefix(parts[1])
|
|
113
|
+
return parts[2].startswith(expected_prefix)
|
|
114
|
+
|
|
115
|
+
def _compute_key_prefix(self, product_code: str) -> str:
|
|
116
|
+
secret = f"correctover-{product_code}-2026"
|
|
117
|
+
return hashlib.sha256(secret.encode()).hexdigest()[:12]
|
|
118
|
+
|
|
119
|
+
def get_upgrade_message(self) -> str:
|
|
120
|
+
"""Return the appropriate upgrade CTA."""
|
|
121
|
+
status = self.check_license()
|
|
122
|
+
if status["tier"] == "free":
|
|
123
|
+
remaining = status["calls_remaining"]
|
|
124
|
+
if remaining <= 0:
|
|
125
|
+
return (
|
|
126
|
+
f"\nš« Free tier limit reached ({self.FREE_LIMIT_PER_DAY} audits/day).\n"
|
|
127
|
+
f" Upgrade to Pro for unlimited audits: https://correctover.com/pricing\n"
|
|
128
|
+
f" Or set your license key: export CORRECTOVER_LICENSE_KEY=<your-key>\n"
|
|
129
|
+
)
|
|
130
|
+
return (
|
|
131
|
+
f"\nš Free tier: {remaining} audits remaining today.\n"
|
|
132
|
+
f" Upgrade to Pro: https://correctover.com/pricing\n"
|
|
133
|
+
)
|
|
134
|
+
return ""
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def get_license_from_env() -> Optional[str]:
|
|
138
|
+
return os.environ.get("CORRECTOVER_LICENSE_KEY")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Global singleton
|
|
142
|
+
_validators: Dict[str, LicenseValidator] = {}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_validator(product: str = "correctover-test") -> LicenseValidator:
|
|
146
|
+
if product not in _validators:
|
|
147
|
+
_validators[product] = LicenseValidator(product)
|
|
148
|
+
return _validators[product]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_and_record(product: str = "correctover-test") -> Dict:
|
|
152
|
+
"""Check license, record call, return status. Raise if over limit."""
|
|
153
|
+
v = get_validator(product)
|
|
154
|
+
status = v.check_license()
|
|
155
|
+
|
|
156
|
+
if not status["authorized"]:
|
|
157
|
+
msg = v.get_upgrade_message()
|
|
158
|
+
raise LicenseExceededError(
|
|
159
|
+
f"Free tier limit ({v.FREE_LIMIT_PER_DAY}/day) exceeded. {msg}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return v.record_call()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class LicenseExceededError(Exception):
|
|
166
|
+
"""Raised when free tier limit is exceeded."""
|
|
167
|
+
pass
|