mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
315 lines
11 KiB
Python
315 lines
11 KiB
Python
"""
|
|
R103: Policy-as-code posture controls.
|
|
Manages signed, versioned security policy bundles with atomic activation and rollback.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
# Try to import cryptography for signature verification
|
|
try:
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
|
|
HAS_CRYPTO = True
|
|
except ImportError:
|
|
HAS_CRYPTO = False
|
|
|
|
from .audit_events import build_audit_event, emit_audit_event
|
|
from .state_dir import get_state_dir
|
|
|
|
logger = logging.getLogger("ComfyUI-OpenClaw.services.policy_posture")
|
|
|
|
POLICY_DIR_NAME = "policy"
|
|
ACTIVE_BUNDLE_NAME = "active.bundle.json"
|
|
BACKUP_BUNDLE_NAME = "backup.bundle.json"
|
|
STAGED_BUNDLE_NAME = "staged.bundle.json"
|
|
TRUSTED_KEYS_NAME = "trusted_keys.json"
|
|
|
|
|
|
@dataclass
|
|
class PolicyPayload:
|
|
"""The actual policy content."""
|
|
|
|
allowlists: Dict[str, List[str]] = field(default_factory=dict)
|
|
high_risk_flags: Dict[str, bool] = field(default_factory=dict)
|
|
quota_posture: Dict[str, Any] = field(default_factory=dict)
|
|
meta: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_canonical_bytes(self) -> bytes:
|
|
"""
|
|
Produce a canonical byte representation for signing.
|
|
Sort keys, no spaces.
|
|
"""
|
|
data = {
|
|
"allowlists": self.allowlists,
|
|
"high_risk_flags": self.high_risk_flags,
|
|
"quota_posture": self.quota_posture,
|
|
"meta": self.meta,
|
|
}
|
|
return json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "PolicyPayload":
|
|
return cls(
|
|
allowlists=data.get("allowlists", {}),
|
|
high_risk_flags=data.get("high_risk_flags", {}),
|
|
quota_posture=data.get("quota_posture", {}),
|
|
meta=data.get("meta", {}),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PolicyBundle:
|
|
"""Signed policy bundle container."""
|
|
|
|
payload: PolicyPayload
|
|
signature: str # Hex-encoded signature
|
|
signer_id: str
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"payload": {
|
|
"allowlists": self.payload.allowlists,
|
|
"high_risk_flags": self.payload.high_risk_flags,
|
|
"quota_posture": self.payload.quota_posture,
|
|
"meta": self.payload.meta,
|
|
},
|
|
"signature": self.signature,
|
|
"signer_id": self.signer_id,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "PolicyBundle":
|
|
payload_data = data.get("payload", {})
|
|
return cls(
|
|
payload=PolicyPayload.from_dict(payload_data),
|
|
signature=data.get("signature", ""),
|
|
signer_id=data.get("signer_id", ""),
|
|
)
|
|
|
|
def verify(self, public_keys: Dict[str, str]) -> bool:
|
|
"""
|
|
Verify the signature against trusted public keys.
|
|
public_keys: dict of {signer_id: hex_encoded_public_key}
|
|
"""
|
|
if not HAS_CRYPTO:
|
|
logger.warning(
|
|
"Cryptography module missing, cannot verify policy signature. FAIL-CLOSED."
|
|
)
|
|
return False
|
|
|
|
if self.signer_id not in public_keys:
|
|
logger.error(f"Unknown signer_id: {self.signer_id}")
|
|
return False
|
|
|
|
pub_key_hex = public_keys[self.signer_id]
|
|
try:
|
|
pub_key_bytes = bytes.fromhex(pub_key_hex)
|
|
public_key = ed25519.Ed25519PublicKey.from_public_bytes(pub_key_bytes)
|
|
|
|
sig_bytes = bytes.fromhex(self.signature)
|
|
data_bytes = self.payload.to_canonical_bytes()
|
|
|
|
public_key.verify(sig_bytes, data_bytes)
|
|
return True
|
|
except (ValueError, InvalidSignature) as e:
|
|
logger.error(f"Signature verification failed: {e}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during verification: {e}")
|
|
return False
|
|
|
|
|
|
class PolicyManager:
|
|
"""Manages policy lifecycle: stage -> activate -> rollback."""
|
|
|
|
def __init__(self):
|
|
self.state_dir = Path(get_state_dir()) / POLICY_DIR_NAME
|
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
self.active_policy: Optional[PolicyBundle] = None
|
|
self.trusted_keys: Dict[str, str] = {}
|
|
|
|
self._load_trusted_keys()
|
|
self._load_active_policy()
|
|
|
|
def _load_trusted_keys(self):
|
|
"""Load trusted public keys from disk."""
|
|
keys_path = self.state_dir / TRUSTED_KEYS_NAME
|
|
if keys_path.exists():
|
|
try:
|
|
with open(keys_path, "r", encoding="utf-8") as f:
|
|
self.trusted_keys = json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load trusted keys: {e}")
|
|
|
|
# If no keys, we might be in uninitialized state.
|
|
# But if we have an active policy, we MUST have keys to verify it on startup (fail-closed).
|
|
|
|
def _load_active_policy(self):
|
|
"""Load and verify active policy. Fail-closed if invalid."""
|
|
active_path = self.state_dir / ACTIVE_BUNDLE_NAME
|
|
if not active_path.exists():
|
|
logger.info(
|
|
"No active policy bundle found. Running with default/empty policy."
|
|
)
|
|
return
|
|
|
|
try:
|
|
with open(active_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
bundle = PolicyBundle.from_dict(data)
|
|
|
|
# Fail-closed check
|
|
if not self.trusted_keys:
|
|
# If we have a policy but no keys, that's a security risk.
|
|
# In a strict hardened mode, we should crash.
|
|
# For now, we log critical error and refuse to make it active.
|
|
# ACTUALLY, requirements say "Hardened posture must fail-closed".
|
|
# I'll log critical and raise exception if keys are missing but policy exists.
|
|
msg = "Fail-closed: Active policy exists but no trusted keys found to verify it."
|
|
logger.critical(msg)
|
|
raise RuntimeError(msg)
|
|
|
|
if not bundle.verify(self.trusted_keys):
|
|
msg = "Fail-closed: Active policy signature invalid."
|
|
logger.critical(msg)
|
|
raise RuntimeError(msg)
|
|
|
|
self.active_policy = bundle
|
|
logger.info(
|
|
f"Active policy loaded: {bundle.payload.meta.get('version', 'unknown')}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.critical(f"Failed to load active policy: {e}")
|
|
raise RuntimeError(f"Policy load failure: {e}")
|
|
|
|
def get_effective_policy(self) -> Optional[PolicyBundle]:
|
|
return self.active_policy
|
|
|
|
def stage_bundle(self, bundle_json: Dict[str, Any]) -> bool:
|
|
"""
|
|
Validate and stage a new policy bundle.
|
|
Returns True if successful.
|
|
"""
|
|
try:
|
|
bundle = PolicyBundle.from_dict(bundle_json)
|
|
|
|
if not bundle.verify(self.trusted_keys):
|
|
self._audit(
|
|
"policy.stage_failed",
|
|
{"reason": "invalid_signature", "signer": bundle.signer_id},
|
|
)
|
|
return False
|
|
|
|
# Save to staging
|
|
staged_path = self.state_dir / STAGED_BUNDLE_NAME
|
|
with open(staged_path, "w", encoding="utf-8") as f:
|
|
json.dump(bundle.to_dict(), f, indent=2)
|
|
|
|
self._audit(
|
|
"policy.staged", {"version": bundle.payload.meta.get("version")}
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to stage bundle: {e}")
|
|
self._audit("policy.stage_failed", {"reason": str(e)})
|
|
return False
|
|
|
|
def activate_staged(self) -> bool:
|
|
"""Promote staged bundle to active."""
|
|
staged_path = self.state_dir / STAGED_BUNDLE_NAME
|
|
active_path = self.state_dir / ACTIVE_BUNDLE_NAME
|
|
backup_path = self.state_dir / BACKUP_BUNDLE_NAME
|
|
|
|
if not staged_path.exists():
|
|
logger.warning("No staged bundle to activate")
|
|
return False
|
|
|
|
try:
|
|
# Load staged to verify it one last time (and get version)
|
|
with open(staged_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
new_bundle = PolicyBundle.from_dict(data)
|
|
|
|
# 1. Backup existing active
|
|
if active_path.exists():
|
|
shutil.copy2(active_path, backup_path)
|
|
|
|
# 2. Move staged to active
|
|
shutil.move(staged_path, active_path)
|
|
|
|
# 3. Update memory
|
|
self.active_policy = new_bundle
|
|
|
|
self._audit(
|
|
"policy.activated",
|
|
{
|
|
"version": new_bundle.payload.meta.get("version"),
|
|
"hash": new_bundle.signature[:8],
|
|
},
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Activation failed: {e}")
|
|
self._audit("policy.activation_failed", {"error": str(e)})
|
|
# Try to restore from backup if we messed up active
|
|
if backup_path.exists() and not active_path.exists():
|
|
shutil.copy2(backup_path, active_path)
|
|
return False
|
|
|
|
def rollback(self) -> bool:
|
|
"""Rollback to previous active bundle."""
|
|
active_path = self.state_dir / ACTIVE_BUNDLE_NAME
|
|
backup_path = self.state_dir / BACKUP_BUNDLE_NAME
|
|
|
|
if not backup_path.exists():
|
|
logger.warning("No backup bundle found for rollback")
|
|
return False
|
|
|
|
try:
|
|
# Move backup to active
|
|
shutil.copy2(backup_path, active_path)
|
|
|
|
# Reload
|
|
self._load_active_policy()
|
|
|
|
version = "unknown"
|
|
if self.active_policy:
|
|
version = self.active_policy.payload.meta.get("version", "unknown")
|
|
|
|
self._audit("policy.rollback", {"version": version})
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Rollback failed: {e}")
|
|
self._audit("policy.rollback_failed", {"error": str(e)})
|
|
return False
|
|
|
|
def _audit(self, event_type: str, payload: Dict[str, Any]):
|
|
event = build_audit_event(
|
|
event_type=event_type, payload=payload, meta={"component": "PolicyManager"}
|
|
)
|
|
emit_audit_event(event)
|
|
|
|
|
|
# Global singleton
|
|
_policy_manager = None
|
|
|
|
|
|
def get_policy_manager() -> PolicyManager:
|
|
global _policy_manager
|
|
if _policy_manager is None:
|
|
_policy_manager = PolicyManager()
|
|
return _policy_manager
|