Files
CASAN/packages/casan-harness/kernel/policy.py
T

401 lines
16 KiB
Python

"""Deterministic security policy primitives shared by every CASAN adapter.
This module is deliberately stdlib-only. It never calls a model and never treats
model text as an enforcement decision. The JSON action registry is the canonical
machine-readable source; callers receive structured decisions rather than booleans.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import stat
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, MutableMapping, Optional
POLICY_VERSION = "1.0.0"
RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
PRODUCTION_PROFILES = {"prod", "production", "strict"}
DEVELOPMENT_PROFILES = {"", "dev", "development", "local", "test", "testing"}
FAILURE_POLICIES = {"halt", "quarantine", "require_approval", "record_only"}
def _harness_root() -> Path:
override = os.environ.get("CASAN_HARNESS_ROOT")
return Path(override).resolve() if override else Path(__file__).resolve().parents[1]
def _action_registry_path() -> Path:
override = os.environ.get("CASAN_ACTION_CLASS_REGISTRY")
return Path(override).resolve() if override else _harness_root() / "config" / "action-classes.json"
def _load_action_registry() -> dict[str, Any]:
with _action_registry_path().open(encoding="utf-8") as handle:
payload = json.load(handle)
if not isinstance(payload, dict) or not isinstance(payload.get("classes"), dict):
raise ValueError("action class registry is malformed")
return payload
def _normal(value: object) -> str:
return re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
def _higher(left: str, right: str) -> str:
return right if RISK_ORDER.get(right, 3) > RISK_ORDER.get(left, 3) else left
def _content_risk(text: str) -> str:
value = (text or "").lower()
if re.search(r"\b(drop\s+table|truncate|password|api[_-]?key|secret|credential|shutdown|rm\s+-rf)\b", value):
return "high"
if re.search(r"\b(deploy|release|migration|permission|policy|external\s+api|database)\b", value):
return "medium"
return "low"
def _resource_risk(resource: str) -> str:
value = (resource or "").lower()
if re.search(r"(^|/)(\.env|\.ssh|\.aws)(/|$)|\.(pem|key|p12|pfx)$|credential|secret", value):
return "high"
if re.search(r"\b(prod|production|customer|tenant)\b", value):
return "medium"
return "low"
def _environment_risk(environment: str) -> str:
return "medium" if _normal(environment) in PRODUCTION_PROFILES else "low"
def classify_action(
action: str = "",
tool: str = "",
resource: str = "",
command: str = "",
) -> dict[str, Any]:
"""Classify an action using deterministic aliases and command patterns.
When several signals match, the class with the highest risk floor wins. An
unknown side-effect-shaped tool is therefore never downgraded by benign text.
"""
registry = _load_action_registry()
classes = registry["classes"]
candidates: list[tuple[str, str]] = []
action_key = _normal(action)
tool_key = _normal(tool)
action_class = registry.get("action_aliases", {}).get(action_key)
if action_class:
candidates.append(("action", action_class))
tool_class = registry.get("tool_aliases", {}).get(tool_key)
if tool_class:
candidates.append(("tool", tool_class))
combined = " ".join(part for part in (command, resource) if part)
for rule in registry.get("command_patterns", []):
try:
if re.search(str(rule["pattern"]), combined, re.IGNORECASE):
candidates.append(("command_or_resource", str(rule["class"])))
except (KeyError, re.error, TypeError) as exc:
raise ValueError("invalid action class pattern") from exc
shell_like = tool_key in {"bash", "shell", "exec", "run", "run_command", "terminal"}
if not candidates and shell_like:
candidates.append(("unknown_shell_command", "unknown"))
if not candidates:
candidates.append(("unclassified", "unknown"))
selected_source, selected_class = candidates[0]
selected_risk = str(classes[selected_class]["risk_floor"])
for source, candidate in candidates[1:]:
risk = str(classes[candidate]["risk_floor"])
if RISK_ORDER.get(risk, 3) > RISK_ORDER.get(selected_risk, 3):
selected_source, selected_class, selected_risk = source, candidate, risk
metadata = classes[selected_class]
return {
"schema_version": registry.get("schema_version", POLICY_VERSION),
"action_class": selected_class,
"classification_source": selected_source,
"risk_floor": selected_risk,
"side_effect_level": metadata["side_effect_level"],
"side_effecting": metadata["side_effect_level"] != "none",
"requires_approval": bool(metadata["requires_approval"]),
"actor_required": bool(metadata["actor_required"]),
"evidence_required": bool(metadata["evidence_required"]),
"matched_classes": [candidate for _source, candidate in candidates],
}
def evaluate_risk(
action: str = "",
tool: str = "",
resource: str = "",
command: str = "",
content: str = "",
actor: str = "",
environment: str = "",
) -> dict[str, Any]:
classification = classify_action(action, tool, resource, command)
actor_present = bool(str(actor or "").strip())
identity_risk = "high" if classification["actor_required"] and not actor_present else "low"
factors = {
"content_risk": _content_risk(content),
"action_risk": classification["risk_floor"],
"resource_risk": _resource_risk(resource),
"identity_risk": identity_risk,
"environment_risk": _environment_risk(environment),
}
effective = "low"
for factor in factors.values():
effective = _higher(effective, factor)
reason_codes: list[str] = ["action_risk_floor_applied"]
if classification["actor_required"] and not actor_present:
decision = "deny"
reason_codes.append("actor_identity_required")
elif classification["requires_approval"] or effective in {"high", "critical"}:
decision = "require_approval"
reason_codes.append("explicit_approval_required")
else:
decision = "allow"
reason_codes.append("risk_within_auto_approval_policy")
return {
"schema_version": POLICY_VERSION,
"policy_id": "casan.action-risk-floor",
"policy_version": POLICY_VERSION,
"decision": decision,
"reason_codes": reason_codes,
"effective_risk": effective,
"risk_factors": factors,
"actor_identity_present": actor_present,
"evidence_requirement": "required" if classification["evidence_required"] else "standard",
**classification,
}
def evaluate_registry_configuration(
mode: str,
profile: str,
explicit_value: Optional[str],
) -> dict[str, Any]:
normalized_mode = _normal(mode)
normalized_profile = _normal(profile)
explicit = None if explicit_value is None else _normal(explicit_value) in {"1", "true", "yes", "on", "enabled"}
production = normalized_profile in PRODUCTION_PROFILES
if explicit is False and production:
return {
"enabled": True,
"valid": False,
"unsafe_bypass": False,
"severity": "critical",
"reason_code": "h2_registry_bypass_forbidden",
}
if explicit is False:
return {
"enabled": False,
"valid": normalized_profile in DEVELOPMENT_PROFILES,
"unsafe_bypass": True,
"severity": "high",
"reason_code": "h2_registry_unsafe_development_bypass",
}
return {
"enabled": True if explicit is None else explicit,
"valid": True,
"unsafe_bypass": False,
"severity": "info",
"reason_code": "h2_registry_default_enabled" if explicit is None and normalized_mode == "enforce" else "h2_registry_enabled",
}
def evaluate_failure_policy(
*,
side_effecting: bool,
mode: str,
profile: str,
configured: Optional[str] = None,
) -> dict[str, Any]:
"""Select single-step failure handling without conflating telemetry success.
Invalid configuration fails closed. Production side effects may be made
stricter, but they may never be configured as record-only.
"""
normalized = _normal(configured or "")
production = _normal(profile) in PRODUCTION_PROFILES
enforce = _normal(mode) == "enforce"
if normalized and normalized not in FAILURE_POLICIES:
policy, source, valid = "halt", "invalid_policy_fail_closed", False
elif normalized == "record_only" and side_effecting and production:
policy, source, valid = "halt", "production_record_only_forbidden", False
elif normalized:
policy, source, valid = normalized, "configured", True
elif side_effecting and (enforce or production):
policy, source, valid = "halt", "safe_enforce_default", True
else:
policy, source, valid = "record_only", "observe_or_read_only_default", True
return {
"schema_version": POLICY_VERSION,
"policy_id": "casan.single-step-failure",
"policy_version": POLICY_VERSION,
"failure_policy": policy,
"source": source,
"valid": valid,
"side_effecting": side_effecting,
"mode": _normal(mode) or "observe",
"profile": _normal(profile) or "development",
"execution_result": "failed",
"telemetry_result": "recorded",
"assurance_may_continue": policy == "record_only",
"reason_codes": [source],
}
@dataclass(frozen=True)
class H2GateContext:
mode: str
actor: str
action: str
tool: str
execution_id: str
enforcement_path: str
idempotency_key: str
timeout_seconds: float = 8.0
def _h2_result(context: H2GateContext, underlying: str, reason_code: str, detail: str) -> dict[str, Any]:
enforce = _normal(context.mode) == "enforce"
actual_deny = underlying == "deny"
dependency_failure = underlying == "error"
execution_allowed = not enforce or (not actual_deny and not dependency_failure)
if enforce:
decision = "allow" if execution_allowed else "deny"
else:
decision = "observe_only"
return {
"schema_version": POLICY_VERSION,
"policy_id": "casan.h2.tool-registry",
"policy_version": POLICY_VERSION,
"decision": decision,
"underlying_decision": underlying,
"reason_codes": [reason_code],
"reason_code": reason_code,
"mode": _normal(context.mode) or "observe",
"actor": context.actor or "unidentified",
"action": context.action,
"tool": context.tool,
"execution_id": context.execution_id,
"enforcement_path": context.enforcement_path,
"execution_allowed": execution_allowed,
"certifiable": enforce and execution_allowed,
"assurance_status": "enforced" if enforce and execution_allowed else "denied" if enforce else "degraded",
"severity": "high" if reason_code != "h2_ok" else "info",
"detail": detail[:400],
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
def evaluate_h2_gate(
gate_path: str,
context: H2GateContext,
env: Optional[Mapping[str, str]] = None,
bash_path: str = "bash",
) -> dict[str, Any]:
"""Execute the H2 dependency and convert every outcome into one policy decision."""
path = Path(gate_path)
if not path.is_file():
return _h2_result(context, "error", "h2_gate_unavailable", "gate file is missing")
try:
mode_bits = stat.S_IMODE(path.stat().st_mode)
except OSError as exc:
return _h2_result(context, "error", "h2_gate_unavailable", f"gate stat failed: {type(exc).__name__}")
if mode_bits & 0o444 == 0:
return _h2_result(context, "error", "h2_gate_permission_denied", "gate file is not readable")
resolved_bash = shutil.which(bash_path) if not os.path.isabs(bash_path) else bash_path
if not resolved_bash or not Path(resolved_bash).is_file():
return _h2_result(context, "error", "h2_gate_unavailable", "bash runtime is unavailable")
child_env: MutableMapping[str, str] = dict(os.environ)
if env:
child_env.update({str(key): str(value) for key, value in env.items()})
child_env["CASAN_IDEMPOTENCY_KEY"] = context.idempotency_key
if context.actor and not child_env.get("CASAN_AGENT"):
child_env["CASAN_AGENT"] = context.actor
try:
proc = subprocess.run(
[resolved_bash, str(path), context.action],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=max(0.001, context.timeout_seconds),
env=child_env,
check=False,
text=True,
)
except subprocess.TimeoutExpired:
return _h2_result(context, "error", "h2_gate_timeout", "gate exceeded internal timeout")
except (OSError, ValueError, subprocess.SubprocessError) as exc:
return _h2_result(context, "error", "h2_gate_internal_error", type(exc).__name__)
stdout = proc.stdout.strip()[:4096]
stderr = proc.stderr.strip()[:4096]
if proc.returncode == 0 and re.search(r"\bTOOL_APPROVED\b", stdout):
return _h2_result(context, "allow", "h2_ok", stdout)
if re.search(r"\bTOOL_DENIED\b", stdout + "\n" + stderr):
reason_match = re.search(r"reason=([^\s]+)", stdout + "\n" + stderr)
reason = "h2_denied:%s" % (reason_match.group(1) if reason_match else "policy")
return _h2_result(context, "deny", reason, stderr or stdout)
if proc.returncode in {126, 127}:
return _h2_result(context, "error", "h2_gate_unavailable", stderr or "gate runtime unavailable")
if proc.returncode != 0:
return _h2_result(context, "error", "h2_gate_internal_error", stderr or stdout or f"exit={proc.returncode}")
return _h2_result(context, "error", "h2_gate_malformed_response", stdout or "empty response")
def evaluate_trust_capabilities(env: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
values = dict(os.environ if env is None else env)
profile = _normal(values.get("CASAN_PROFILE", ""))
production = profile in PRODUCTION_PROFILES
signing_provider = _normal(values.get("CASAN_SIGNING_PROVIDER", "local_openssl"))
anchor_provider = _normal(values.get("CASAN_IMMUTABLE_ANCHOR_PROVIDER", "local_hash_chain"))
emergency = _normal(values.get("CASAN_TRUST_EMERGENCY_OVERRIDE", "")) in {"1", "true", "yes", "on"}
signing_external = signing_provider in {"vault", "vault_kms", "kms", "hsm"}
anchor_external = anchor_provider in {"s3_object_lock", "qldb", "external_worm"}
vault_addr = str(values.get("VAULT_ADDR") or "")
signing_configured = signing_external and vault_addr.startswith("https://") and bool(values.get("VAULT_TOKEN"))
anchor_configured = anchor_external and bool(values.get("CASAN_S3_BUCKET")) and bool(values.get("CASAN_S3_KMS_KEY_ID"))
ready = (not production) or (signing_configured and anchor_configured)
certifiable = ready and not emergency
reasons: list[str] = []
if production and not signing_configured:
reasons.append("external_signing_trust_root_required")
if production and not anchor_configured:
reasons.append("external_immutable_anchor_required")
if emergency:
reasons.append("emergency_local_trust_override_active")
return {
"schema_version": POLICY_VERSION,
"profile": profile or "development",
"production": production,
"signing_provider": signing_provider or "local_openssl",
"signing_capability": "external" if signing_configured else "local_or_unavailable",
"immutable_anchor_provider": anchor_provider or "local_hash_chain",
"immutable_anchor_capability": "external" if anchor_configured else "local_or_unavailable",
"ready": ready,
"certifiable": certifiable,
"emergency_override": emergency,
"severity": "critical" if emergency or not ready else "info",
"reason_codes": reasons or ["trust_capabilities_satisfied"],
}