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
correctover_test/cli.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
correctover-test CLI — self-healing test agent entry point.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
correctover-test --quick # 5-second smoke test
|
|
6
|
+
correctover-test --framework crewai # Single framework
|
|
7
|
+
correctover-test --all --html # Full audit, HTML report
|
|
8
|
+
correctover-test --quick --cloud # Upload results to Correctover Cloud
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from .core.engine import TestEngine
|
|
19
|
+
from .core.reporter import Reporter
|
|
20
|
+
from .core.license import get_validator, LicenseExceededError
|
|
21
|
+
from .core.adapters import CrewAIAdapter, SmolagentsAdapter, LlamaIndexAdapter
|
|
22
|
+
from .core.scenarios import MCPSelfHealScenario, EnvProtectionScenario, ContractBreakScenario
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ADAPTER_REGISTRY = {
|
|
26
|
+
"crewai": CrewAIAdapter,
|
|
27
|
+
"smolagents": SmolagentsAdapter,
|
|
28
|
+
"llamaindex": LlamaIndexAdapter,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
SCENARIO_REGISTRY = {
|
|
32
|
+
"mcp_self_heal": MCPSelfHealScenario,
|
|
33
|
+
"env_protection": EnvProtectionScenario,
|
|
34
|
+
"contract_break": ContractBreakScenario,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
VERSION = "0.2.0"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.command()
|
|
41
|
+
@click.option(
|
|
42
|
+
"--framework", "-f",
|
|
43
|
+
default="all",
|
|
44
|
+
help="Target framework: crewai, smolagents, llamaindex, or all",
|
|
45
|
+
)
|
|
46
|
+
@click.option(
|
|
47
|
+
"--scenario", "-s",
|
|
48
|
+
default="all",
|
|
49
|
+
help="Test scenario: mcp_self_heal, env_protection, contract_break, or all",
|
|
50
|
+
)
|
|
51
|
+
@click.option(
|
|
52
|
+
"--output", "-o",
|
|
53
|
+
default=None,
|
|
54
|
+
help="Output file path (.json / .md / .html based on extension)",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--quick", "-q",
|
|
58
|
+
is_flag=True,
|
|
59
|
+
help="5-second smoke test: env_protection on all frameworks",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"--all", "run_all",
|
|
63
|
+
is_flag=True,
|
|
64
|
+
help="Full audit: all scenarios on all frameworks",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--format", "output_format",
|
|
68
|
+
type=click.Choice(["terminal", "json", "markdown", "html"]),
|
|
69
|
+
default="terminal",
|
|
70
|
+
help="Output format (default: terminal)",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--cloud",
|
|
74
|
+
default=None,
|
|
75
|
+
help="Correctover Cloud endpoint URL (e.g. https://cloud.correctover.com/api/v1/audit)",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--no-cta",
|
|
79
|
+
is_flag=True,
|
|
80
|
+
help="Suppress the 'Fix this' CTA in output",
|
|
81
|
+
)
|
|
82
|
+
@click.version_option(version=VERSION, prog_name="correctover-test")
|
|
83
|
+
def main(
|
|
84
|
+
framework: str,
|
|
85
|
+
scenario: str,
|
|
86
|
+
output: Optional[str],
|
|
87
|
+
quick: bool,
|
|
88
|
+
run_all: bool,
|
|
89
|
+
output_format: str,
|
|
90
|
+
cloud: Optional[str],
|
|
91
|
+
no_cta: bool,
|
|
92
|
+
):
|
|
93
|
+
"""🔒 Correctover Agent Security Audit
|
|
94
|
+
|
|
95
|
+
Tests your AI agents for guardrail gaps, command injection,
|
|
96
|
+
environment variable leaks, and contract violations.
|
|
97
|
+
|
|
98
|
+
\b
|
|
99
|
+
Quick start:
|
|
100
|
+
correctover-test --quick # 5-second audit
|
|
101
|
+
correctover-test --all --html # Full report
|
|
102
|
+
|
|
103
|
+
\b
|
|
104
|
+
Correctover Cloud:
|
|
105
|
+
correctover-test --quick --cloud https://cloud.correctover.com/api/v1/audit
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# Resolve frameworks
|
|
109
|
+
if quick:
|
|
110
|
+
framework = "all"
|
|
111
|
+
scenario = "env_protection"
|
|
112
|
+
elif run_all:
|
|
113
|
+
framework = "all"
|
|
114
|
+
scenario = "all"
|
|
115
|
+
|
|
116
|
+
if framework == "all":
|
|
117
|
+
adapters = [cls() for cls in ADAPTER_REGISTRY.values()]
|
|
118
|
+
else:
|
|
119
|
+
fws = [f.strip() for f in framework.split(",")]
|
|
120
|
+
adapters = []
|
|
121
|
+
for fw in fws:
|
|
122
|
+
if fw not in ADAPTER_REGISTRY:
|
|
123
|
+
click.echo(
|
|
124
|
+
f"❌ Unknown framework: {fw}. Available: {list(ADAPTER_REGISTRY.keys())}",
|
|
125
|
+
err=True,
|
|
126
|
+
)
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
adapters.append(ADAPTER_REGISTRY[fw]())
|
|
129
|
+
|
|
130
|
+
# Resolve scenarios
|
|
131
|
+
if scenario == "all":
|
|
132
|
+
scenarios = [cls() for cls in SCENARIO_REGISTRY.values()]
|
|
133
|
+
else:
|
|
134
|
+
scs = [s.strip() for s in scenario.split(",")]
|
|
135
|
+
scenarios = []
|
|
136
|
+
for sc in scs:
|
|
137
|
+
if sc not in SCENARIO_REGISTRY:
|
|
138
|
+
click.echo(
|
|
139
|
+
f"❌ Unknown scenario: {sc}. Available: {list(SCENARIO_REGISTRY.keys())}",
|
|
140
|
+
err=True,
|
|
141
|
+
)
|
|
142
|
+
sys.exit(1)
|
|
143
|
+
scenarios.append(SCENARIO_REGISTRY[sc]())
|
|
144
|
+
|
|
145
|
+
# License check — download-to-pay enforcement
|
|
146
|
+
try:
|
|
147
|
+
from .core.license import LicenseValidator
|
|
148
|
+
license_key = LicenseValidator.get_license_from_env()
|
|
149
|
+
validator = get_validator()
|
|
150
|
+
if license_key:
|
|
151
|
+
validator.set_license_key(license_key)
|
|
152
|
+
status = validator.record_call()
|
|
153
|
+
if status["tier"] == "free":
|
|
154
|
+
click.echo(
|
|
155
|
+
f"📊 Free tier: {status['calls_remaining']} audits remaining today "
|
|
156
|
+
f"({status['calls_today']}/{status['limit']} used)",
|
|
157
|
+
err=True,
|
|
158
|
+
)
|
|
159
|
+
except LicenseExceededError as e:
|
|
160
|
+
click.echo(str(e), err=True)
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
# Run
|
|
164
|
+
click.echo(
|
|
165
|
+
f"🔍 Auditing {len(adapters)} framework(s) × {len(scenarios)} scenario(s)...",
|
|
166
|
+
err=True,
|
|
167
|
+
)
|
|
168
|
+
engine = TestEngine(adapters)
|
|
169
|
+
results = engine.run_all(scenarios=scenarios)
|
|
170
|
+
|
|
171
|
+
# Upload to cloud
|
|
172
|
+
if cloud:
|
|
173
|
+
try:
|
|
174
|
+
payload = json.dumps({
|
|
175
|
+
"source": "correctover-test",
|
|
176
|
+
"version": VERSION,
|
|
177
|
+
"results": [r.to_dict() for r in results],
|
|
178
|
+
}).encode("utf-8")
|
|
179
|
+
req = urllib.request.Request(
|
|
180
|
+
cloud,
|
|
181
|
+
data=payload,
|
|
182
|
+
headers={"Content-Type": "application/json"},
|
|
183
|
+
method="POST",
|
|
184
|
+
)
|
|
185
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
186
|
+
cloud_resp = json.loads(resp.read())
|
|
187
|
+
click.echo(
|
|
188
|
+
f"☁️ Uploaded to Correctover Cloud: {cloud_resp.get('dashboard_url', cloud)}",
|
|
189
|
+
err=True,
|
|
190
|
+
)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
click.echo(f"⚠️ Cloud upload failed: {e}", err=True)
|
|
193
|
+
|
|
194
|
+
# Output
|
|
195
|
+
if output:
|
|
196
|
+
if output.endswith(".md"):
|
|
197
|
+
content = Reporter.to_markdown(results)
|
|
198
|
+
elif output.endswith(".html"):
|
|
199
|
+
content = Reporter.to_html(results)
|
|
200
|
+
else:
|
|
201
|
+
content = Reporter.to_json(results)
|
|
202
|
+
with open(output, "w") as f:
|
|
203
|
+
f.write(content)
|
|
204
|
+
click.echo(f"📄 Report written to {output}", err=True)
|
|
205
|
+
else:
|
|
206
|
+
if output_format == "json":
|
|
207
|
+
click.echo(Reporter.to_json(results))
|
|
208
|
+
elif output_format == "markdown":
|
|
209
|
+
click.echo(Reporter.to_markdown(results))
|
|
210
|
+
elif output_format == "html":
|
|
211
|
+
click.echo(Reporter.to_html(results))
|
|
212
|
+
else:
|
|
213
|
+
Reporter.to_terminal(results, show_cta=not no_cta)
|
|
214
|
+
|
|
215
|
+
# Exit code
|
|
216
|
+
total = len(results)
|
|
217
|
+
failed = sum(1 for r in results if r.verdict.value == "FAIL")
|
|
218
|
+
sys.exit(0 if failed == 0 else 1)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from .base import FrameworkAdapter
|
|
2
|
+
from .crewai_adapter import CrewAIAdapter
|
|
3
|
+
from .smolagents_adapter import SmolagentsAdapter
|
|
4
|
+
from .llamaindex_adapter import LlamaIndexAdapter
|
|
5
|
+
|
|
6
|
+
__all__ = ["FrameworkAdapter", "CrewAIAdapter", "SmolagentsAdapter", "LlamaIndexAdapter"]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FrameworkAdapter ABC and shared data models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FaultType(str, Enum):
|
|
13
|
+
COMMAND_INJECTION = "command_injection"
|
|
14
|
+
ENV_EXPOSURE = "env_exposure"
|
|
15
|
+
CONTRACT_BREAK = "contract_break"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Verdict(str, Enum):
|
|
19
|
+
PASS = "PASS"
|
|
20
|
+
FAIL = "FAIL"
|
|
21
|
+
SKIP = "SKIP"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class MetricSnapshot:
|
|
26
|
+
"""6-dimensional verification metrics per test run."""
|
|
27
|
+
dimension: str # integrity | authorization | auditability | latency | coverage | recovery
|
|
28
|
+
value: float
|
|
29
|
+
threshold: float # pass threshold
|
|
30
|
+
passed: bool
|
|
31
|
+
detail: str = ""
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"dimension": self.dimension,
|
|
36
|
+
"value": self.value,
|
|
37
|
+
"threshold": self.threshold,
|
|
38
|
+
"passed": self.passed,
|
|
39
|
+
"detail": self.detail,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class TestResult:
|
|
45
|
+
"""Single scenario run result."""
|
|
46
|
+
framework: str
|
|
47
|
+
scenario: str
|
|
48
|
+
verdict: Verdict
|
|
49
|
+
metrics: List[MetricSnapshot] = field(default_factory=list)
|
|
50
|
+
decisions_count: int = 0
|
|
51
|
+
envelopes_count: int = 0
|
|
52
|
+
integrity_verified: bool = False
|
|
53
|
+
duration_ms: float = 0.0
|
|
54
|
+
errors: List[str] = field(default_factory=list)
|
|
55
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def self_heal_rate(self) -> float:
|
|
59
|
+
"""Fraction of metrics that passed."""
|
|
60
|
+
if not self.metrics:
|
|
61
|
+
return 0.0
|
|
62
|
+
return sum(1 for m in self.metrics if m.passed) / len(self.metrics)
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"framework": self.framework,
|
|
67
|
+
"scenario": self.scenario,
|
|
68
|
+
"verdict": self.verdict.value,
|
|
69
|
+
"self_heal_rate": round(self.self_heal_rate, 4),
|
|
70
|
+
"metrics": [m.to_dict() for m in self.metrics],
|
|
71
|
+
"decisions_count": self.decisions_count,
|
|
72
|
+
"envelopes_count": self.envelopes_count,
|
|
73
|
+
"integrity_verified": self.integrity_verified,
|
|
74
|
+
"duration_ms": round(self.duration_ms, 2),
|
|
75
|
+
"errors": self.errors,
|
|
76
|
+
"details": self.details,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FrameworkAdapter(ABC):
|
|
81
|
+
"""Abstract adapter for hooking correctover into an Agent framework."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def create_agent(self, tools: List[Any], config: Optional[Dict] = None) -> Any:
|
|
87
|
+
"""Create a native agent instance for this framework."""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def register_guardrail(self, agent: Any, provider: Any) -> Any:
|
|
92
|
+
"""
|
|
93
|
+
Inject a correctover GuardrailProvider into the agent.
|
|
94
|
+
Returns the modified agent or a wrapper.
|
|
95
|
+
"""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def run_task(self, agent: Any, task: str) -> Dict[str, Any]:
|
|
100
|
+
"""
|
|
101
|
+
Execute a task through the agent.
|
|
102
|
+
Returns {"output": str, "duration_ms": float, "error": Optional[str]}.
|
|
103
|
+
"""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def detect_missing_guardrail(self, agent: Any) -> List[Dict]:
|
|
108
|
+
"""
|
|
109
|
+
Scan the agent for missing guardrail hooks.
|
|
110
|
+
Returns list of findings, empty list = no issues.
|
|
111
|
+
"""
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
@abstractmethod
|
|
115
|
+
def inject_fault(self, agent: Any, fault_type: FaultType) -> Any:
|
|
116
|
+
"""
|
|
117
|
+
Inject a fault into the agent's tool call pipeline.
|
|
118
|
+
Returns the modified agent/context.
|
|
119
|
+
"""
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestScenario(ABC):
|
|
124
|
+
"""Abstract test scenario that runs against an adapter+agent pair."""
|
|
125
|
+
|
|
126
|
+
name: str
|
|
127
|
+
description: str
|
|
128
|
+
fault_type: FaultType
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def setup(self, adapter: FrameworkAdapter, agent: Any) -> Any:
|
|
132
|
+
"""Prepare the agent for this scenario. Return modified agent/context."""
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def execute(self, adapter: FrameworkAdapter, agent: Any) -> Dict[str, Any]:
|
|
137
|
+
"""Run the scenario. Return raw results dict."""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def verify(
|
|
142
|
+
self,
|
|
143
|
+
adapter: FrameworkAdapter,
|
|
144
|
+
agent: Any,
|
|
145
|
+
raw_result: Dict[str, Any],
|
|
146
|
+
trail: Any, # correctover.AuditTrail
|
|
147
|
+
) -> List[MetricSnapshot]:
|
|
148
|
+
"""Verify the 6-dimension metrics from AuditTrail."""
|
|
149
|
+
...
|
|
150
|
+
|
|
151
|
+
def run(
|
|
152
|
+
self,
|
|
153
|
+
adapter: FrameworkAdapter,
|
|
154
|
+
agent: Any,
|
|
155
|
+
trail: Any,
|
|
156
|
+
) -> TestResult:
|
|
157
|
+
"""Orchestrate full scenario lifecycle."""
|
|
158
|
+
t0 = time.time()
|
|
159
|
+
errors = []
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
agent = self.setup(adapter, agent)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
errors.append(f"setup: {e}")
|
|
165
|
+
return TestResult(
|
|
166
|
+
framework=adapter.name,
|
|
167
|
+
scenario=self.name,
|
|
168
|
+
verdict=Verdict.FAIL,
|
|
169
|
+
errors=errors,
|
|
170
|
+
details={"phase": "setup"},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
raw = self.execute(adapter, agent)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
errors.append(f"execute: {e}")
|
|
177
|
+
return TestResult(
|
|
178
|
+
framework=adapter.name,
|
|
179
|
+
scenario=self.name,
|
|
180
|
+
verdict=Verdict.FAIL,
|
|
181
|
+
errors=errors,
|
|
182
|
+
details={"phase": "execute"},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
metrics = self.verify(adapter, agent, raw, trail)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
errors.append(f"verify: {e}")
|
|
189
|
+
metrics = []
|
|
190
|
+
|
|
191
|
+
duration_ms = (time.time() - t0) * 1000
|
|
192
|
+
all_pass = all(m.passed for m in metrics) if metrics else False
|
|
193
|
+
integrity_ok = trail.verify_all() if trail else False
|
|
194
|
+
|
|
195
|
+
return TestResult(
|
|
196
|
+
framework=adapter.name,
|
|
197
|
+
scenario=self.name,
|
|
198
|
+
verdict=Verdict.PASS if all_pass and not errors else Verdict.FAIL,
|
|
199
|
+
metrics=metrics,
|
|
200
|
+
decisions_count=trail.decision_count if trail else 0,
|
|
201
|
+
envelopes_count=trail.envelope_count if trail else 0,
|
|
202
|
+
integrity_verified=integrity_ok,
|
|
203
|
+
duration_ms=duration_ms,
|
|
204
|
+
errors=errors,
|
|
205
|
+
details={"raw_output": raw.get("output", "")[:500]},
|
|
206
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CrewAI Framework Adapter.
|
|
3
|
+
|
|
4
|
+
Hooks into CrewAI's global before_tool_call / after_tool_call decorator system
|
|
5
|
+
and the agent.guardrail attribute for guardrail detection.
|
|
6
|
+
|
|
7
|
+
For testing without an LLM, run_task simulates tool call guardrail checks
|
|
8
|
+
directly against the registered provider, bypassing actual LLM calls.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from .base import FrameworkAdapter, FaultType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CrewAIAdapter(FrameworkAdapter):
|
|
17
|
+
name = "crewai"
|
|
18
|
+
|
|
19
|
+
def create_agent(self, tools: List[Any], config: Optional[Dict] = None) -> Any:
|
|
20
|
+
from crewai import Agent
|
|
21
|
+
|
|
22
|
+
return Agent(
|
|
23
|
+
role=config.get("role", "test-agent") if config else "test-agent",
|
|
24
|
+
goal=config.get("goal", "Execute tools safely with guardrail protection") if config else "Execute tools safely with guardrail protection",
|
|
25
|
+
backstory=config.get("backstory", "A test agent for correctover self-healing validation") if config else "A test agent for correctover self-healing validation",
|
|
26
|
+
tools=tools,
|
|
27
|
+
verbose=False,
|
|
28
|
+
allow_delegation=False,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def register_guardrail(self, agent: Any, provider: Any) -> Any:
|
|
32
|
+
"""
|
|
33
|
+
Inject guardrail via CrewAI's global before_tool_call hook system.
|
|
34
|
+
Wraps the provider into a callable that matches CrewAI's hook signature.
|
|
35
|
+
"""
|
|
36
|
+
from crewai.hooks.tool_hooks import register_before_tool_call_hook
|
|
37
|
+
from correctover import GuardrailContext, ToolCallContext, AuditTrail
|
|
38
|
+
|
|
39
|
+
trail = AuditTrail()
|
|
40
|
+
ctx = GuardrailContext(provider=provider, trail=trail)
|
|
41
|
+
|
|
42
|
+
def before_hook(*, tool=None, tool_input=None, agent_role=None, agent=None, **kwargs):
|
|
43
|
+
tool_name = getattr(tool, "name", str(tool))
|
|
44
|
+
tool_args = tool_input if isinstance(tool_input, dict) else {}
|
|
45
|
+
agent_id = agent_role or "unknown"
|
|
46
|
+
|
|
47
|
+
tc = ToolCallContext(
|
|
48
|
+
tool_name=tool_name,
|
|
49
|
+
tool_args=tool_args,
|
|
50
|
+
agent_id=agent_id,
|
|
51
|
+
metadata={"framework": "crewai"},
|
|
52
|
+
)
|
|
53
|
+
decision = ctx.authorize(tc)
|
|
54
|
+
if not decision.authorized:
|
|
55
|
+
raise PermissionError(
|
|
56
|
+
f"Tool '{tool_name}' denied by correctover guardrail"
|
|
57
|
+
)
|
|
58
|
+
return tool_input
|
|
59
|
+
|
|
60
|
+
register_before_tool_call_hook(before_hook)
|
|
61
|
+
|
|
62
|
+
agent._correctover_trail = trail
|
|
63
|
+
agent._correctover_context = ctx
|
|
64
|
+
|
|
65
|
+
return agent
|
|
66
|
+
|
|
67
|
+
def run_task(self, agent: Any, task: str) -> Dict[str, Any]:
|
|
68
|
+
"""
|
|
69
|
+
Simulate a tool call against the guardrail directly — no LLM required.
|
|
70
|
+
Extracts tool name from the task string and tests the guardrail provider.
|
|
71
|
+
"""
|
|
72
|
+
import time
|
|
73
|
+
from correctover import ToolCallContext
|
|
74
|
+
|
|
75
|
+
t0 = time.time()
|
|
76
|
+
error = None
|
|
77
|
+
output = ""
|
|
78
|
+
|
|
79
|
+
trail = getattr(agent, "_correctover_trail", None)
|
|
80
|
+
ctx = getattr(agent, "_correctover_context", None)
|
|
81
|
+
|
|
82
|
+
# Parse tool name from task: "Use X to do Y" -> X
|
|
83
|
+
tool_name = "unknown_tool"
|
|
84
|
+
task_lower = task.lower()
|
|
85
|
+
for word in task.split():
|
|
86
|
+
if word in ["dangerous_exec", "read_env", "unreliable_output"]:
|
|
87
|
+
tool_name = word
|
|
88
|
+
break
|
|
89
|
+
# Fallback: find tool from agent.tools
|
|
90
|
+
if tool_name == "unknown_tool" and hasattr(agent, "tools"):
|
|
91
|
+
for t in agent.tools:
|
|
92
|
+
tname = getattr(t, "name", str(t))
|
|
93
|
+
if tname.lower() in task_lower:
|
|
94
|
+
tool_name = tname
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
# Extract command arg from task if relevant
|
|
98
|
+
tool_args = {}
|
|
99
|
+
if "rm -rf" in task:
|
|
100
|
+
tool_args["command"] = "rm -rf /"
|
|
101
|
+
elif "TEST_API_KEY" in task:
|
|
102
|
+
tool_args["key"] = "TEST_API_KEY"
|
|
103
|
+
else:
|
|
104
|
+
tool_args["query"] = task
|
|
105
|
+
|
|
106
|
+
# Direct guardrail check
|
|
107
|
+
if ctx and ctx.provider:
|
|
108
|
+
tc = ToolCallContext(
|
|
109
|
+
tool_name=tool_name,
|
|
110
|
+
tool_args=tool_args,
|
|
111
|
+
agent_id=getattr(agent, "role", "unknown"),
|
|
112
|
+
metadata={"framework": "crewai", "mode": "direct-check"},
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
decision = ctx.authorize(tc)
|
|
116
|
+
ctx.after_tool_call(decision, {"simulated": True}, time.time())
|
|
117
|
+
if decision.authorized:
|
|
118
|
+
output = f"Tool '{tool_name}' was authorized"
|
|
119
|
+
else:
|
|
120
|
+
error = f"Tool '{tool_name}' denied by guardrail: {decision.claims.get('reason', 'unauthorized')}"
|
|
121
|
+
output = f"[BLOCKED] {error}"
|
|
122
|
+
except Exception as e:
|
|
123
|
+
error = str(e)
|
|
124
|
+
output = f"[ERROR] {e}"
|
|
125
|
+
else:
|
|
126
|
+
# Fallback: try actual agent kickoff (needs LLM)
|
|
127
|
+
try:
|
|
128
|
+
result = agent.kickoff(messages=task)
|
|
129
|
+
output = str(result) if result else ""
|
|
130
|
+
except Exception as e:
|
|
131
|
+
error = str(e)
|
|
132
|
+
output = ""
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"output": output,
|
|
136
|
+
"duration_ms": (time.time() - t0) * 1000,
|
|
137
|
+
"error": error,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def detect_missing_guardrail(self, agent: Any) -> List[Dict]:
|
|
141
|
+
findings = []
|
|
142
|
+
|
|
143
|
+
if getattr(agent, "guardrail", None) is None:
|
|
144
|
+
findings.append({
|
|
145
|
+
"severity": "WARNING",
|
|
146
|
+
"pattern": "CREWAI-GUARDRAIL-MISS-001",
|
|
147
|
+
"agent": agent.role,
|
|
148
|
+
"message": f"Agent '{agent.role}' has no CrewAI guardrail set",
|
|
149
|
+
"remediation": "Set agent.guardrail or inject correctover via register_guardrail()",
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
if not hasattr(agent, "_correctover_trail"):
|
|
153
|
+
findings.append({
|
|
154
|
+
"severity": "CRITICAL",
|
|
155
|
+
"pattern": "AS-GUARDRAIL-MISS-001",
|
|
156
|
+
"agent": agent.role,
|
|
157
|
+
"message": f"Agent '{agent.role}' has no correctover guardrail injected",
|
|
158
|
+
"remediation": "Call adapter.register_guardrail(agent, provider)",
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
from crewai.hooks.tool_hooks import get_before_tool_call_hooks
|
|
162
|
+
if not get_before_tool_call_hooks():
|
|
163
|
+
findings.append({
|
|
164
|
+
"severity": "HIGH",
|
|
165
|
+
"pattern": "CREWAI-HOOK-MISS-001",
|
|
166
|
+
"agent": agent.role,
|
|
167
|
+
"message": "No global before_tool_call hooks registered",
|
|
168
|
+
"remediation": "Register a before_tool_call hook with correctover provider",
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return findings
|
|
172
|
+
|
|
173
|
+
def inject_fault(self, agent: Any, fault_type: FaultType) -> Any:
|
|
174
|
+
from crewai.tools import tool
|
|
175
|
+
|
|
176
|
+
if fault_type == FaultType.COMMAND_INJECTION:
|
|
177
|
+
|
|
178
|
+
@tool("dangerous_exec")
|
|
179
|
+
def dangerous_exec(command: str) -> str:
|
|
180
|
+
"""Execute a shell command. DANGEROUS — should be blocked by guardrail."""
|
|
181
|
+
return f"executed: {command}"
|
|
182
|
+
|
|
183
|
+
agent.tools.append(dangerous_exec)
|
|
184
|
+
|
|
185
|
+
elif fault_type == FaultType.ENV_EXPOSURE:
|
|
186
|
+
|
|
187
|
+
@tool("read_env")
|
|
188
|
+
def read_env(key: str) -> str:
|
|
189
|
+
"""Read an environment variable. Should be blocked by guardrail."""
|
|
190
|
+
import os
|
|
191
|
+
return os.environ.get(key, "NOT_SET")
|
|
192
|
+
|
|
193
|
+
agent.tools.append(read_env)
|
|
194
|
+
|
|
195
|
+
elif fault_type == FaultType.CONTRACT_BREAK:
|
|
196
|
+
|
|
197
|
+
@tool("unreliable_output")
|
|
198
|
+
def unreliable_output(query: str) -> dict:
|
|
199
|
+
"""Return incomplete output missing required fields — contract break test."""
|
|
200
|
+
return {"partial": query, "_incomplete": True}
|
|
201
|
+
|
|
202
|
+
agent.tools.append(unreliable_output)
|
|
203
|
+
|
|
204
|
+
return agent
|