Initial commit
This commit is contained in:
449
skills/policy.enforce/policy_enforce.py
Executable file
449
skills/policy.enforce/policy_enforce.py
Executable file
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
policy_enforce.py - Implementation of the policy.enforce Skill
|
||||
|
||||
Enforces policy rules for skill and agent manifests including:
|
||||
- Naming conventions (lowercase, dot-separated, no spaces)
|
||||
- Semantic versioning
|
||||
- Permissions validation (only filesystem, network, read, write)
|
||||
- Status lifecycle checks (draft, active, deprecated, archived)
|
||||
|
||||
Supports both single-file validation and batch mode.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import yaml
|
||||
import re
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from betty.config import BASE_DIR, SKILLS_DIR, AGENTS_DIR
|
||||
from betty.validation import validate_path, validate_version, ValidationError
|
||||
from betty.logging_utils import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Policy definitions
|
||||
ALLOWED_PERMISSIONS = {"filesystem", "network", "read", "write"}
|
||||
ALLOWED_STATUSES = {"draft", "active", "deprecated", "archived"}
|
||||
VALID_NAME_PATTERN = r"^[a-z][a-z0-9.]*[a-z0-9]$" # lowercase, dots allowed, no spaces
|
||||
|
||||
|
||||
class PolicyViolation:
|
||||
"""Represents a single policy violation."""
|
||||
|
||||
def __init__(self, field: str, rule: str, message: str, severity: str = "error"):
|
||||
self.field = field
|
||||
self.rule = rule
|
||||
self.message = message
|
||||
self.severity = severity # "error" or "warning"
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {
|
||||
"field": self.field,
|
||||
"rule": self.rule,
|
||||
"message": self.message,
|
||||
"severity": self.severity
|
||||
}
|
||||
|
||||
|
||||
def load_manifest(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load and parse a manifest from YAML file.
|
||||
|
||||
Args:
|
||||
path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
Parsed manifest dictionary
|
||||
|
||||
Raises:
|
||||
Exception: If manifest cannot be loaded or parsed
|
||||
"""
|
||||
try:
|
||||
with open(path) as f:
|
||||
manifest = yaml.safe_load(f)
|
||||
return manifest
|
||||
except FileNotFoundError:
|
||||
raise Exception(f"Manifest file not found: {path}")
|
||||
except yaml.YAMLError as e:
|
||||
raise Exception(f"Failed to parse YAML: {e}")
|
||||
|
||||
|
||||
def validate_name_format(name: str) -> Optional[PolicyViolation]:
|
||||
"""
|
||||
Validate that name follows naming convention:
|
||||
- lowercase letters and numbers only
|
||||
- dots allowed for namespacing
|
||||
- no spaces, underscores, or hyphens (except in skill directory names for backwards compatibility)
|
||||
|
||||
Args:
|
||||
name: Name to validate
|
||||
|
||||
Returns:
|
||||
PolicyViolation if invalid, None if valid
|
||||
"""
|
||||
if not name:
|
||||
return PolicyViolation(
|
||||
field="name",
|
||||
rule="naming_convention",
|
||||
message="Name cannot be empty"
|
||||
)
|
||||
|
||||
# Check for spaces
|
||||
if ' ' in name:
|
||||
return PolicyViolation(
|
||||
field="name",
|
||||
rule="naming_convention",
|
||||
message=f"Name contains spaces: '{name}'. Names must not contain spaces."
|
||||
)
|
||||
|
||||
# Check for uppercase
|
||||
if name != name.lower():
|
||||
return PolicyViolation(
|
||||
field="name",
|
||||
rule="naming_convention",
|
||||
message=f"Name contains uppercase letters: '{name}'. Names must be lowercase."
|
||||
)
|
||||
|
||||
# Check format: lowercase, dots allowed, must start with letter
|
||||
if not re.match(VALID_NAME_PATTERN, name):
|
||||
return PolicyViolation(
|
||||
field="name",
|
||||
rule="naming_convention",
|
||||
message=f"Invalid name format: '{name}'. "
|
||||
"Names must start with a lowercase letter, contain only lowercase letters, "
|
||||
"numbers, and dots for namespacing, and end with a letter or number."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_version_format(version: str) -> Optional[PolicyViolation]:
|
||||
"""
|
||||
Validate that version follows semantic versioning.
|
||||
|
||||
Args:
|
||||
version: Version string to validate
|
||||
|
||||
Returns:
|
||||
PolicyViolation if invalid, None if valid
|
||||
"""
|
||||
try:
|
||||
validate_version(version)
|
||||
return None
|
||||
except ValidationError as e:
|
||||
return PolicyViolation(
|
||||
field="version",
|
||||
rule="semantic_versioning",
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
|
||||
def validate_permissions(manifest: Dict[str, Any], manifest_type: str) -> List[PolicyViolation]:
|
||||
"""
|
||||
Validate that all permissions are in the allowed set.
|
||||
|
||||
Args:
|
||||
manifest: Manifest dictionary
|
||||
manifest_type: "skill" or "agent"
|
||||
|
||||
Returns:
|
||||
List of PolicyViolations for invalid permissions
|
||||
"""
|
||||
violations = []
|
||||
|
||||
# For skills, check entrypoints permissions
|
||||
if manifest_type == "skill":
|
||||
entrypoints = manifest.get("entrypoints", [])
|
||||
for idx, entrypoint in enumerate(entrypoints):
|
||||
permissions = entrypoint.get("permissions", [])
|
||||
for perm in permissions:
|
||||
# Handle both simple permissions and scoped permissions (e.g., "filesystem:read")
|
||||
base_perm = perm.split(':')[0] if ':' in perm else perm
|
||||
|
||||
if base_perm not in ALLOWED_PERMISSIONS:
|
||||
violations.append(PolicyViolation(
|
||||
field=f"entrypoints[{idx}].permissions",
|
||||
rule="allowed_permissions",
|
||||
message=f"Invalid permission: '{perm}'. "
|
||||
f"Only {', '.join(sorted(ALLOWED_PERMISSIONS))} are allowed."
|
||||
))
|
||||
|
||||
# For agents, permissions might be in a different location
|
||||
# (checking if there's a permissions field at the top level)
|
||||
elif manifest_type == "agent":
|
||||
permissions = manifest.get("permissions", [])
|
||||
for perm in permissions:
|
||||
base_perm = perm.split(':')[0] if ':' in perm else perm
|
||||
|
||||
if base_perm not in ALLOWED_PERMISSIONS:
|
||||
violations.append(PolicyViolation(
|
||||
field="permissions",
|
||||
rule="allowed_permissions",
|
||||
message=f"Invalid permission: '{perm}'. "
|
||||
f"Only {', '.join(sorted(ALLOWED_PERMISSIONS))} are allowed."
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def validate_status(status: str) -> Optional[PolicyViolation]:
|
||||
"""
|
||||
Validate that status is one of the allowed values.
|
||||
|
||||
Args:
|
||||
status: Status string to validate
|
||||
|
||||
Returns:
|
||||
PolicyViolation if invalid, None if valid
|
||||
"""
|
||||
if not status:
|
||||
return PolicyViolation(
|
||||
field="status",
|
||||
rule="allowed_status",
|
||||
message="Status field is required"
|
||||
)
|
||||
|
||||
if status not in ALLOWED_STATUSES:
|
||||
return PolicyViolation(
|
||||
field="status",
|
||||
rule="allowed_status",
|
||||
message=f"Invalid status: '{status}'. "
|
||||
f"Must be one of: {', '.join(sorted(ALLOWED_STATUSES))}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_manifest_policies(path: str, manifest_type: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate a single manifest against all policy rules.
|
||||
|
||||
Args:
|
||||
path: Path to manifest file
|
||||
manifest_type: "skill" or "agent" (auto-detected if None)
|
||||
|
||||
Returns:
|
||||
Dictionary with validation results
|
||||
"""
|
||||
violations = []
|
||||
|
||||
try:
|
||||
# Validate path
|
||||
validate_path(path, must_exist=True)
|
||||
|
||||
# Load manifest
|
||||
manifest = load_manifest(path)
|
||||
|
||||
# Auto-detect manifest type if not specified
|
||||
if manifest_type is None:
|
||||
if "skill.yaml" in path or path.endswith(".yaml") and "skills/" in path:
|
||||
manifest_type = "skill"
|
||||
elif "agent.yaml" in path or path.endswith(".yaml") and "agents/" in path:
|
||||
manifest_type = "agent"
|
||||
else:
|
||||
# Try to detect from content
|
||||
if "entrypoints" in manifest or "inputs" in manifest:
|
||||
manifest_type = "skill"
|
||||
elif "capabilities" in manifest or "reasoning_mode" in manifest:
|
||||
manifest_type = "agent"
|
||||
else:
|
||||
manifest_type = "unknown"
|
||||
|
||||
# Validate name
|
||||
name = manifest.get("name")
|
||||
if name:
|
||||
violation = validate_name_format(name)
|
||||
if violation:
|
||||
violations.append(violation)
|
||||
else:
|
||||
violations.append(PolicyViolation(
|
||||
field="name",
|
||||
rule="required_field",
|
||||
message="Name field is required"
|
||||
))
|
||||
|
||||
# Validate version
|
||||
version = manifest.get("version")
|
||||
if version:
|
||||
violation = validate_version_format(version)
|
||||
if violation:
|
||||
violations.append(violation)
|
||||
else:
|
||||
violations.append(PolicyViolation(
|
||||
field="version",
|
||||
rule="required_field",
|
||||
message="Version field is required"
|
||||
))
|
||||
|
||||
# Validate permissions
|
||||
perm_violations = validate_permissions(manifest, manifest_type)
|
||||
violations.extend(perm_violations)
|
||||
|
||||
# Validate status
|
||||
status = manifest.get("status")
|
||||
violation = validate_status(status)
|
||||
if violation:
|
||||
violations.append(violation)
|
||||
|
||||
# Build result
|
||||
success = len(violations) == 0
|
||||
|
||||
result = {
|
||||
"success": success,
|
||||
"path": path,
|
||||
"manifest_type": manifest_type,
|
||||
"violations": [v.to_dict() for v in violations],
|
||||
"violation_count": len(violations)
|
||||
}
|
||||
|
||||
if success:
|
||||
result["message"] = "All policy checks passed"
|
||||
else:
|
||||
result["message"] = f"Found {len(violations)} policy violation(s)"
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating manifest {path}: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"path": path,
|
||||
"error": str(e),
|
||||
"violations": [],
|
||||
"violation_count": 0
|
||||
}
|
||||
|
||||
|
||||
def find_all_manifests() -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Find all skill and agent manifests in the repository.
|
||||
|
||||
Returns:
|
||||
List of tuples (path, type) where type is "skill" or "agent"
|
||||
"""
|
||||
manifests = []
|
||||
|
||||
# Find skill manifests
|
||||
skills_dir = Path(SKILLS_DIR)
|
||||
if skills_dir.exists():
|
||||
for skill_yaml in skills_dir.glob("*/skill.yaml"):
|
||||
manifests.append((str(skill_yaml), "skill"))
|
||||
|
||||
# Find agent manifests
|
||||
agents_dir = Path(AGENTS_DIR)
|
||||
if agents_dir.exists():
|
||||
for agent_yaml in agents_dir.glob("*/agent.yaml"):
|
||||
manifests.append((str(agent_yaml), "agent"))
|
||||
|
||||
return manifests
|
||||
|
||||
|
||||
def validate_batch(strict: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate all manifests in batch mode.
|
||||
|
||||
Args:
|
||||
strict: If True, treat warnings as errors
|
||||
|
||||
Returns:
|
||||
Dictionary with batch validation results
|
||||
"""
|
||||
manifests = find_all_manifests()
|
||||
|
||||
if not manifests:
|
||||
return {
|
||||
"success": True,
|
||||
"mode": "batch",
|
||||
"message": "No manifests found to validate",
|
||||
"total_manifests": 0,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"results": []
|
||||
}
|
||||
|
||||
results = []
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for path, manifest_type in manifests:
|
||||
logger.info(f"Validating {manifest_type}: {path}")
|
||||
result = validate_manifest_policies(path, manifest_type)
|
||||
results.append(result)
|
||||
|
||||
if result.get("success"):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
overall_success = failed == 0
|
||||
|
||||
return {
|
||||
"success": overall_success,
|
||||
"mode": "batch",
|
||||
"message": f"Validated {len(manifests)} manifest(s): {passed} passed, {failed} failed",
|
||||
"total_manifests": len(manifests),
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"results": results
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Enforce policy rules for skill and agent manifests"
|
||||
)
|
||||
parser.add_argument(
|
||||
"manifest_path",
|
||||
nargs="?",
|
||||
help="Path to manifest file to validate (omit for batch mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch",
|
||||
action="store_true",
|
||||
help="Validate all manifests in skills/ and agents/ directories"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="Treat warnings as errors"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Batch mode
|
||||
if args.batch or not args.manifest_path:
|
||||
logger.info("Running in batch mode")
|
||||
result = validate_batch(strict=args.strict)
|
||||
else:
|
||||
# Single file mode
|
||||
logger.info(f"Validating single manifest: {args.manifest_path}")
|
||||
result = validate_manifest_policies(args.manifest_path)
|
||||
|
||||
# Output JSON result
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
# Exit with appropriate code
|
||||
sys.exit(0 if result.get("success") else 1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
error_result = {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "Unexpected error during policy enforcement"
|
||||
}
|
||||
print(json.dumps(error_result, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user