File size: 8,628 Bytes
0a66b10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
#!/usr/bin/env python3
"""validate_openenv.py — Pre-flight compliance checker for OpenEnv.

Validates that the OmniGuard-Evolved-V2 codebase does NOT use reserved tool names
(reset, step, state, close) as MCP tool identifiers, and verifies the openenv.yaml
manifest is well-formed.

Usage:
    python scripts/validate_openenv.py
"""

from __future__ import annotations

import ast
import pathlib
import sys
import yaml

# OpenEnv reserves these names for the Gym-style API surface.
# MCP tools MUST NOT shadow these.
RESERVED_TOOL_NAMES = frozenset({"reset", "step", "state", "close"})

PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
SERVER_DIR = PROJECT_ROOT / "server"
MANIFEST_PATH = PROJECT_ROOT / "openenv.yaml"

PASS = "\033[92m✓\033[0m"
FAIL = "\033[91m✗\033[0m"
WARN = "\033[93m⚠\033[0m"


def check_reserved_tool_names() -> list[str]:
    """Scan all Python files in server/ for string literals matching reserved names
    used as MCP tool identifiers (e.g., tool_name='step')."""
    violations: list[str] = []

    for py_file in SERVER_DIR.rglob("*.py"):
        try:
            source = py_file.read_text(encoding="utf-8")
            tree = ast.parse(source, filename=str(py_file))
        except (SyntaxError, UnicodeDecodeError):
            continue

        for node in ast.walk(tree):
            # Check keyword arguments like tool_name="step"
            if isinstance(node, ast.keyword):
                if node.arg and "tool" in node.arg.lower():
                    if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
                        if node.value.value.lower() in RESERVED_TOOL_NAMES:
                            violations.append(
                                f"  {py_file.relative_to(PROJECT_ROOT)}:{node.lineno} "
                                f"— reserved tool name '{node.value.value}' used in kwarg '{node.arg}'"
                            )
            # Check dict literals with tool_name keys
            if isinstance(node, ast.Dict):
                for key, value in zip(node.keys, node.values):
                    if (
                        isinstance(key, ast.Constant)
                        and isinstance(key.value, str)
                        and "tool" in key.value.lower()
                        and isinstance(value, ast.Constant)
                        and isinstance(value.value, str)
                        and value.value.lower() in RESERVED_TOOL_NAMES
                    ):
                        violations.append(
                            f"  {py_file.relative_to(PROJECT_ROOT)}:{node.lineno} "
                            f"— reserved tool name '{value.value}' in dict key '{key.value}'"
                        )
    return violations


def check_mcp_tool_definitions() -> list[str]:
    """Check MCPToolContext usages don't clash with reserved names."""
    violations: list[str] = []

    for py_file in SERVER_DIR.rglob("*.py"):
        try:
            source = py_file.read_text(encoding="utf-8")
        except UnicodeDecodeError:
            continue

        for line_no, line in enumerate(source.splitlines(), start=1):
            # Quick heuristic: look for tool_name= assignments with reserved strings
            if "tool_name" in line:
                for reserved in RESERVED_TOOL_NAMES:
                    if f'"{reserved}"' in line or f"'{reserved}'" in line:
                        violations.append(
                            f"  {py_file.relative_to(PROJECT_ROOT)}:{line_no} "
                            f"— tool_name set to reserved '{reserved}'"
                        )
    return violations


def check_manifest() -> list[str]:
    """Validate openenv.yaml exists and has required top-level keys."""
    issues: list[str] = []
    if not MANIFEST_PATH.exists():
        issues.append("  openenv.yaml not found at project root")
        return issues

    try:
        with open(MANIFEST_PATH) as f:
            manifest = yaml.safe_load(f)
    except Exception as e:
        issues.append(f"  openenv.yaml parse error: {e}")
        return issues

    required_keys = {"name", "description", "version", "entrypoint", "tasks"}
    missing = required_keys - set(manifest.keys())
    if missing:
        issues.append(f"  openenv.yaml missing required keys: {missing}")

    # Validate tasks have names
    tasks = manifest.get("tasks", [])
    if not tasks:
        issues.append("  openenv.yaml: no tasks defined")
    else:
        for i, task in enumerate(tasks):
            if "name" not in task:
                issues.append(f"  openenv.yaml: task[{i}] missing 'name'")

    return issues


def check_base_class_inheritance() -> list[str]:
    """Verify OmniGuardStateMachine inherits from BaseMCPEnvironment."""
    issues: list[str] = []
    env_path = SERVER_DIR / "env.py"

    if not env_path.exists():
        issues.append("  server/env.py not found")
        return issues

    source = env_path.read_text(encoding="utf-8")
    if "BaseMCPEnvironment" not in source:
        issues.append("  server/env.py: OmniGuardStateMachine does not reference BaseMCPEnvironment")
    if "class OmniGuardStateMachine" not in source:
        issues.append("  server/env.py: OmniGuardStateMachine class not found")

    # Verify import of openenv_adapter
    if "from server.openenv_adapter import" not in source:
        issues.append("  server/env.py: missing import from server.openenv_adapter")

    return issues


def main() -> int:
    print("=" * 60)
    print("  OmniGuard-Evolved-V2 — OpenEnv Compliance Validator")
    print("=" * 60)
    print()

    exit_code = 0

    # 1. Reserved tool names (AST scan)
    print("1. Checking reserved tool names (reset, step, state, close)...")
    violations = check_reserved_tool_names()
    violations += check_mcp_tool_definitions()
    if violations:
        print(f"   {FAIL} Found {len(violations)} violation(s):")
        for v in violations:
            print(f"      {v}")
        exit_code = 1
    else:
        print(f"   {PASS} No reserved tool name collisions found.")

    print()

    # 2. Manifest validation
    print("2. Validating openenv.yaml manifest...")
    manifest_issues = check_manifest()
    if manifest_issues:
        print(f"   {FAIL} Found {len(manifest_issues)} issue(s):")
        for issue in manifest_issues:
            print(f"      {issue}")
        exit_code = 1
    else:
        print(f"   {PASS} openenv.yaml is valid and complete.")

    print()

    # 3. Base class inheritance
    print("3. Checking OpenEnv base class inheritance...")
    inheritance_issues = check_base_class_inheritance()
    if inheritance_issues:
        print(f"   {FAIL} Found {len(inheritance_issues)} issue(s):")
        for issue in inheritance_issues:
            print(f"      {issue}")
        exit_code = 1
    else:
        print(f"   {PASS} OmniGuardStateMachine correctly inherits BaseMCPEnvironment.")

    print()

    # 4. Client/server separation
    print("4. Checking client/server separation...")
    client_violations: list[str] = []
    training_dir = PROJECT_ROOT / "training"
    eval_dir = PROJECT_ROOT / "eval"
    for scan_dir in [training_dir, eval_dir]:
        if not scan_dir.exists():
            continue
        for py_file in scan_dir.rglob("*.py"):
            try:
                source = py_file.read_text(encoding="utf-8")
            except UnicodeDecodeError:
                continue
            # Clients must NOT import server internals (except models for type hints)
            bad_imports = [
                "from server.env import",
                "from server.graders import",
                "from server.generator import",
                "from server.verifier import",
                "from server.vector_env import",
            ]
            for bad in bad_imports:
                if bad in source:
                    client_violations.append(
                        f"  {py_file.relative_to(PROJECT_ROOT)} imports server internals: {bad}"
                    )

    if client_violations:
        print(f"   {WARN} Found {len(client_violations)} potential violation(s):")
        for v in client_violations:
            print(f"      {v}")
    else:
        print(f"   {PASS} Client code respects server boundary.")

    print()
    print("=" * 60)
    if exit_code == 0:
        print(f"  {PASS} ALL CHECKS PASSED — Ready for OpenEnv submission.")
    else:
        print(f"  {FAIL} COMPLIANCE ISSUES FOUND — Fix before submission.")
    print("=" * 60)

    return exit_code


if __name__ == "__main__":
    sys.exit(main())