OpenSpace/openspace/skill_engine/evolution/validator.py
2026-07-17 11:43:42 +08:00

1180 lines
41 KiB
Python

"""Deterministic validator for staged skill evolution edits."""
from __future__ import annotations
import difflib
import inspect
import re
import uuid
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Mapping
from openspace.skill_engine.capture_contract import (
capture_contract_ref_ids,
normalize_capture_contract,
)
from openspace.skill_engine.evidence import EvidencePacket, ResourceRef
from openspace.skill_engine.evidence.redaction import contains_secret
from openspace.skill_engine.evolution.authoring_contract import (
contract_validation_failures,
)
from openspace.skill_engine.skill_utils import (
SKILL_FILENAME,
parse_frontmatter,
)
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
_OUTCOMES = {"approve", "reject", "needs_human_review"}
_MAX_SKILL_NAME_LENGTH = 50
_SAFE_SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_HIGH_RISK_RUNTIME_OVERLAY_FIELDS = {
"allowed-tools",
"disable-model-invocation",
"user-invocable",
"model",
"effort",
"hooks",
"context",
"agent",
"shell",
}
_RUNTIME_OVERLAY_FIELD_ALIASES = {
"allowed_tools": "allowed-tools",
"allowedTools": "allowed-tools",
"disable_model_invocation": "disable-model-invocation",
"disableModelInvocation": "disable-model-invocation",
"user_invocable": "user-invocable",
"userInvocable": "user-invocable",
}
_FORBIDDEN_FRONTMATTER_KEYS = _HIGH_RISK_RUNTIME_OVERLAY_FIELDS
_LOCAL_FORBIDDEN_FILENAMES = {
".env",
".skill_id",
".cloud_skill.json",
".upload_meta.json",
}
_CAUSAL_REF_TYPES = {
"runtime_snapshot",
"transcript_message",
"tool_event",
"tool_result",
"tool_incident",
"file_history",
"skill_event",
"manual_request_ref",
}
_CAPTURE_FALLBACK_ONLY_TYPES = {
"memory_ref",
"recording_ref",
"background_task_result",
"compact_summary",
}
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"(?i)\bAuthorization\s*:\s*(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+"),
re.compile(r"(?i)\bCookie\s*:\s*[^;\n]+(?:;[^\n]+)?"),
re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"),
re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"),
re.compile(
r"(?i)\b[A-Z0-9_]*(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*"
r"\s*[:=]\s*[^\s\"']+"
),
)
@dataclass(frozen=True, slots=True)
class ValidationResult:
validation_id: str
authoring_id: str
decision_id: str
packet_id: str
outcome: str
deterministic_failures: list[str] = field(default_factory=list)
semantic_warnings: list[str] = field(default_factory=list)
changed_files: list[str] = field(default_factory=list)
provenance_refs: list[str] = field(default_factory=list)
checked_at: str = ""
checked_by: str = "deterministic_validator"
@property
def passed(self) -> bool:
return self.outcome == "approve"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_mapping(cls, data: Mapping[str, Any]) -> "ValidationResult":
outcome = str(data.get("outcome") or "reject").strip().lower()
if outcome not in _OUTCOMES:
outcome = "reject"
return cls(
validation_id=str(data.get("validation_id") or ""),
authoring_id=str(data.get("authoring_id") or ""),
decision_id=str(data.get("decision_id") or ""),
packet_id=str(data.get("packet_id") or ""),
outcome=outcome,
deterministic_failures=_str_list(data.get("deterministic_failures")),
semantic_warnings=_str_list(data.get("semantic_warnings")),
changed_files=_str_list(data.get("changed_files")),
provenance_refs=_str_list(data.get("provenance_refs")),
checked_at=str(data.get("checked_at") or ""),
checked_by=str(data.get("checked_by") or "deterministic_validator"),
)
class EvolutionValidator:
"""Commit-preflight validator for staged skill edits.
The validator is intentionally deterministic-first. Optional semantic
review can add warnings or make the outcome stricter, but cannot override
hard deterministic failures.
"""
def __init__(
self,
*,
evidence_store: Any | None = None,
skill_store: Any | None = None,
registry: Any | None = None,
semantic_validator: Any | None = None,
semantic_enabled: bool = False,
checked_by: str = "deterministic_validator",
) -> None:
self.evidence_store = evidence_store
self.skill_store = skill_store
self.registry = registry
self.semantic_validator = semantic_validator
self.semantic_enabled = bool(semantic_enabled)
self.checked_by = checked_by
def validate(
self,
authoring: Any,
validator_packet: EvidencePacket,
decision: Any,
admission: Any,
) -> ValidationResult:
result = self._run_validation(
authoring,
validator_packet,
decision,
admission,
run_semantic=True,
)
return self._persist_fail_closed(result)
async def validate_async(
self,
authoring: Any,
validator_packet: EvidencePacket,
decision: Any,
admission: Any,
) -> ValidationResult:
result = self._run_validation(
authoring,
validator_packet,
decision,
admission,
run_semantic=False,
)
if (
not result.deterministic_failures
and self.semantic_enabled
and self.semantic_validator is not None
):
outcome, warnings = await self._run_semantic_validator_async(
result.outcome,
result.semantic_warnings,
authoring=authoring,
validator_packet=validator_packet,
decision=decision,
admission=admission,
)
result = replace(
result,
outcome=outcome,
semantic_warnings=warnings,
checked_by=f"{self.checked_by}+semantic",
)
return self._persist_fail_closed(result)
def _run_validation(
self,
authoring: Any,
validator_packet: EvidencePacket,
decision: Any,
admission: Any,
*,
run_semantic: bool,
) -> ValidationResult:
validation_id = f"val_{uuid.uuid4().hex}"
checked_at = _utc_now()
try:
return self._validate(
validation_id=validation_id,
checked_at=checked_at,
authoring=authoring,
validator_packet=validator_packet,
decision=decision,
admission=admission,
run_semantic=run_semantic,
)
except Exception as exc:
logger.debug("Evolution validation failed internally", exc_info=True)
return ValidationResult(
validation_id=validation_id,
authoring_id=str(_attr(authoring, "authoring_id") or ""),
decision_id=str(
_attr(authoring, "decision_id")
or _attr(decision, "decision_id")
or ""
),
packet_id=str(_attr(validator_packet, "packet_id") or ""),
outcome="reject",
deterministic_failures=["validator_internal_error"],
semantic_warnings=[str(exc)[:500]],
changed_files=_changed_files(_attr(authoring, "staged_edit")),
provenance_refs=_provenance_refs(
authoring,
validator_packet,
decision,
admission,
),
checked_at=checked_at,
checked_by=self.checked_by,
)
def _persist_fail_closed(self, result: ValidationResult) -> ValidationResult:
try:
self._persist(result)
except Exception as exc:
logger.debug(
"Failed to persist validation result %s",
result.validation_id,
exc_info=True,
)
result = replace(
result,
outcome="reject",
deterministic_failures=_dedupe(
[*result.deterministic_failures, "validation_persist_failed"]
),
semantic_warnings=_dedupe(
[*result.semantic_warnings, str(exc)[:500]]
),
)
try:
self._persist(result)
except Exception:
logger.debug(
"Failed to persist validation reject result %s",
result.validation_id,
exc_info=True,
)
return result
def _validate(
self,
*,
validation_id: str,
checked_at: str,
authoring: Any,
validator_packet: EvidencePacket,
decision: Any,
admission: Any,
run_semantic: bool = True,
) -> ValidationResult:
staged = _attr(authoring, "staged_edit")
action = _action_type(decision, staged)
failures: list[str] = []
review_warnings: list[str] = []
changed_files = _changed_files(staged)
provenance_refs = _provenance_refs(
authoring,
validator_packet,
decision,
admission,
)
if str(_attr(authoring, "status") or "").strip().lower() != "staged":
failures.append("authoring_not_staged")
if staged is None:
failures.append("missing_staged_edit")
if str(getattr(validator_packet, "packet_type", "") or "").lower() != "validator":
failures.append("validator_packet_required")
if staged is not None:
snapshot = _snapshot(staged)
failures.extend(self._schema_failures(snapshot, action, admission))
failures.extend(
self._scope_failures(
staged,
validator_packet,
action,
decision,
changed_files,
snapshot,
)
)
failures.extend(
self._provenance_failures(
staged,
validator_packet,
decision,
admission,
action,
)
)
failures.extend(
self._capture_contract_failures(
staged,
action,
decision,
admission,
)
)
failures.extend(self._secret_failures(snapshot, validator_packet))
failures.extend(self._duplicate_failures(staged, action))
failures.extend(contract_validation_failures(staged, action))
review_warnings.extend(self._overlay_review_warnings(staged, admission))
failures = _dedupe(failures)
semantic_warnings = _dedupe(review_warnings)
outcome = "reject" if failures else (
"needs_human_review" if semantic_warnings else "approve"
)
if (
run_semantic
and not failures
and self.semantic_enabled
and self.semantic_validator is not None
):
outcome, semantic_warnings = self._run_semantic_validator(
outcome,
semantic_warnings,
authoring=authoring,
validator_packet=validator_packet,
decision=decision,
admission=admission,
)
return ValidationResult(
validation_id=validation_id,
authoring_id=str(_attr(authoring, "authoring_id") or ""),
decision_id=str(
_attr(authoring, "decision_id")
or _attr(decision, "decision_id")
or ""
),
packet_id=validator_packet.packet_id,
outcome=outcome,
deterministic_failures=failures,
semantic_warnings=semantic_warnings,
changed_files=changed_files,
provenance_refs=provenance_refs,
checked_at=checked_at,
checked_by=self.checked_by,
)
def _schema_failures(
self,
snapshot: dict[str, str],
action: str,
admission: Any,
) -> list[str]:
failures: list[str] = []
skill_md = snapshot.get(SKILL_FILENAME)
if skill_md is None:
return ["missing_skill_md"]
frontmatter, frontmatter_error = _strict_frontmatter(skill_md)
if frontmatter_error:
failures.append(frontmatter_error)
return failures
if _has_secondary_frontmatter(skill_md):
failures.append("multiple_frontmatter_blocks")
name = str(frontmatter.get("name") or "").strip()
if not name:
failures.append("missing_skill_name")
elif len(name) > _MAX_SKILL_NAME_LENGTH:
failures.append("skill_name_too_long")
elif not _SAFE_SKILL_NAME_RE.match(name) or _sanitize_skill_name(name) != name:
failures.append("skill_name_not_sanitized")
description = str(frontmatter.get("description") or "").strip()
if action in {"DERIVED", "CAPTURED"} and not description:
failures.append("missing_description")
allowed_keys = _allowed_frontmatter_keys(admission)
for key in sorted(str(item) for item in frontmatter):
normalized_key = _normalize_overlay_key(key)
if (
normalized_key in _FORBIDDEN_FRONTMATTER_KEYS
and normalized_key not in allowed_keys
):
failures.append(f"forbidden_frontmatter_key:{normalized_key}")
return failures
def _capture_contract_failures(
self,
staged: Any,
action: str,
decision: Any,
admission: Any,
) -> list[str]:
if action != "CAPTURED":
return []
failures: list[str] = []
if not bool(_attr(admission, "source_validation_passed", False)):
failures.append("captured_source_validation_not_admitted")
decision_contract = normalize_capture_contract(
_attr(decision, "proposal_contract")
)
staged_contract = normalize_capture_contract(
_mapping_or_empty(_attr(staged, "apply_metadata")).get(
"proposal_contract"
)
)
if not decision_contract:
failures.append("captured_missing_decision_contract")
if not staged_contract:
failures.append("captured_missing_staged_contract")
elif staged_contract != decision_contract:
failures.append("captured_contract_changed_during_authoring")
staged_refs = set(_str_list(_attr(staged, "evidence_refs")))
if not set(capture_contract_ref_ids(decision_contract)).issubset(staged_refs):
failures.append("captured_contract_refs_missing_from_provenance")
return failures
def _scope_failures(
self,
staged: Any,
packet: EvidencePacket,
action: str,
decision: Any,
changed_files: list[str],
snapshot: dict[str, str],
) -> list[str]:
failures: list[str] = []
diff_paths = _diff_paths(str(_attr(staged, "content_diff") or ""))
expected_changed = diff_paths or set(snapshot)
if set(changed_files) != expected_changed:
failures.append("changed_files_mismatch")
paths_to_check = set(changed_files) | set(snapshot) | diff_paths
for rel_path in sorted(paths_to_check):
if not _safe_relative_path(rel_path):
failures.append(f"path_escape:{rel_path}")
continue
if _forbidden_local_path(rel_path):
failures.append(f"forbidden_changed_file:{rel_path}")
target_dir = _resolved_path(_attr(staged, "target_dir"))
if target_dir is None:
failures.append("missing_target_dir")
return failures
if action == "FIX":
target_skill_ids = _target_skill_ids(decision, staged)
if len(target_skill_ids) != 1:
failures.append("fix_requires_single_target")
expected_dirs = _target_skill_dirs(packet, target_skill_ids)
if not expected_dirs:
failures.append("missing_target_skill_ref")
elif target_dir not in expected_dirs:
failures.append("fix_target_dir_mismatch")
return failures
if action in {"DERIVED", "CAPTURED"}:
allowed_roots = _allowed_skill_roots(packet, action, decision)
if allowed_roots and not any(
target_dir.parent == root or root in target_dir.parent.parents
for root in allowed_roots
):
failures.append("target_dir_outside_allowed_skill_root")
return failures
def _provenance_failures(
self,
staged: Any,
packet: EvidencePacket,
decision: Any,
admission: Any,
action: str,
) -> list[str]:
failures: list[str] = []
if action == "NOOP":
return failures
staged_refs = _str_list(_attr(staged, "evidence_refs"))
if not staged_refs:
failures.append("missing_provenance_refs")
packet_refs = _packet_refs(packet)
decision_refs = set(_decision_ref_ids(decision))
admission_refs = set(_admission_ref_ids(admission))
allowed_refs = set(packet_refs) | decision_refs | admission_refs
allowed_refs.update(
{
f"packet:{packet.packet_id}",
f"decision:{_attr(decision, 'decision_id') or ''}",
f"authoring:{_attr(staged, 'authoring_id') or ''}",
}
)
for ref_id in staged_refs:
if ref_id and ref_id not in allowed_refs:
failures.append(f"missing_provenance_ref:{ref_id}")
apply_metadata = _mapping_or_empty(_attr(staged, "apply_metadata"))
authoring_packet_id = str(apply_metadata.get("action_packet_id") or "")
if not authoring_packet_id:
authoring_packet_id = str(_attr(staged, "packet_id") or "")
if not authoring_packet_id:
authoring_packet_id = str(_attr(staged, "source_packet_id") or "")
source_packet_ids = {
str(ref.metadata.get("packet_id") or ref.ref_id.removeprefix("packet:"))
for ref in packet_refs.values()
if ref.ref_type == "evidence_packet_ref"
}
if authoring_packet_id and authoring_packet_id not in source_packet_ids:
failures.append("missing_action_packet_ref")
if authoring_packet_id:
for ref in packet_refs.values():
if ref.ref_type != "evidence_packet_ref":
continue
packet_id = str(
ref.metadata.get("packet_id") or ref.ref_id.removeprefix("packet:")
)
if packet_id != authoring_packet_id:
continue
source_watermark = _int_or_none(ref.metadata.get("manifest_watermark"))
if (
source_watermark is not None
and int(packet.manifest_watermark) < source_watermark
):
failures.append("validator_packet_watermark_regressed")
break
if action == "FIX":
target_skill_ids = _target_skill_ids(decision, staged)
if not _target_skill_dirs(packet, target_skill_ids):
failures.append("fix_missing_target_skill_refs")
referenced = set(staged_refs) | decision_refs | admission_refs
has_causal_ref = any(
ref.ref_id in referenced and ref.ref_type in _CAUSAL_REF_TYPES
for ref in packet_refs.values()
)
if not has_causal_ref:
failures.append("fix_missing_causality_refs")
if action == "CAPTURED":
primary_refs = [
ref
for ref in packet_refs.values()
if str(ref.role).lower() == "primary"
]
if not any(
ref.ref_type not in _CAPTURE_FALLBACK_ONLY_TYPES
for ref in primary_refs
):
failures.append("captured_requires_primary_non_fallback_refs")
return failures
def _secret_failures(
self,
snapshot: dict[str, str],
packet: EvidencePacket,
) -> list[str]:
failures: list[str] = []
for rel_path, content in snapshot.items():
if _content_contains_secret(content):
failures.append(f"secret_in_proposed_content:{rel_path}")
proposed_text = "\n".join(snapshot.values())
for ref in _packet_refs(packet).values():
if not ref.contains_secret:
continue
preview = str(ref.preview or "").strip()
if len(preview) >= 40 and preview in proposed_text:
failures.append(f"sensitive_ref_copied_wholesale:{ref.ref_id}")
return failures
def _duplicate_failures(self, staged: Any, action: str) -> list[str]:
if action not in {"CAPTURED", "DERIVED"}:
return []
proposed_name = str(_attr(staged, "proposed_name") or "").strip()
proposed_description = str(_attr(staged, "proposed_description") or "").strip()
proposed_tags = _frontmatter_tags(_snapshot(staged).get(SKILL_FILENAME, ""))
skip_ids = set(_str_list(_attr(staged, "parent_skill_ids")))
skip_ids.update(_str_list(_attr(staged, "target_skill_ids")))
proposed_skill_id = str(_attr(staged, "proposed_skill_id") or "")
if proposed_skill_id:
skip_ids.add(proposed_skill_id)
failures: list[str] = []
for existing in self._existing_skills():
skill_id = str(existing.get("skill_id") or "")
if skill_id and skill_id in skip_ids:
continue
if _is_duplicate_skill(
proposed_name=proposed_name,
proposed_description=proposed_description,
proposed_tags=proposed_tags,
existing=existing,
):
identifier = skill_id or str(existing.get("name") or "unknown")
failures.append(f"duplicate_skill:{identifier}")
return failures
def _overlay_review_warnings(self, staged: Any, admission: Any) -> list[str]:
allowed = _allowed_overlay_fields(admission)
overlay_fields = _mapping_or_empty(_attr(staged, "overlay_fields"))
warnings: list[str] = []
for key in sorted(overlay_fields):
normalized_key = _normalize_overlay_key(key)
if (
normalized_key in _HIGH_RISK_RUNTIME_OVERLAY_FIELDS
and normalized_key not in allowed
):
warnings.append(f"high_risk_overlay_field:{normalized_key}")
return warnings
def _run_semantic_validator(
self,
outcome: str,
semantic_warnings: list[str],
*,
authoring: Any,
validator_packet: EvidencePacket,
decision: Any,
admission: Any,
) -> tuple[str, list[str]]:
try:
response = self._call_semantic_reviewer(
authoring,
validator_packet,
decision,
admission,
)
if inspect.isawaitable(response):
close = getattr(response, "close", None)
if callable(close):
close()
action = _action_type(decision, _attr(authoring, "staged_edit"))
next_outcome = (
"needs_human_review" if action == "CAPTURED" else outcome
)
return next_outcome, _dedupe(
[*semantic_warnings, "semantic_async_reviewer_requires_async_validation"]
)
except Exception as exc:
action = _action_type(decision, _attr(authoring, "staged_edit"))
warning = f"semantic_validator_error:{str(exc)[:160]}"
next_outcome = "needs_human_review" if action == "CAPTURED" else outcome
return next_outcome, _dedupe([*semantic_warnings, warning])
return _apply_semantic_response(outcome, semantic_warnings, response)
async def _run_semantic_validator_async(
self,
outcome: str,
semantic_warnings: list[str],
*,
authoring: Any,
validator_packet: EvidencePacket,
decision: Any,
admission: Any,
) -> tuple[str, list[str]]:
try:
response = self._call_semantic_reviewer(
authoring,
validator_packet,
decision,
admission,
)
if inspect.isawaitable(response):
response = await response
except Exception as exc:
action = _action_type(decision, _attr(authoring, "staged_edit"))
warning = f"semantic_validator_error:{str(exc)[:160]}"
next_outcome = "reject" if action == "CAPTURED" else outcome
return next_outcome, _dedupe([*semantic_warnings, warning])
return _apply_semantic_response(outcome, semantic_warnings, response)
def _call_semantic_reviewer(
self,
authoring: Any,
validator_packet: EvidencePacket,
decision: Any,
admission: Any,
) -> Any:
reviewer = self.semantic_validator
if reviewer is None:
return {"outcome": "approve", "warnings": []}
if callable(reviewer):
return reviewer(authoring, validator_packet, decision, admission)
method = getattr(reviewer, "validate", None)
if not callable(method):
raise TypeError("semantic validator has no callable validate method")
return method(authoring, validator_packet, decision, admission)
def _existing_skills(self) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
if self.skill_store is not None:
load_all = getattr(self.skill_store, "load_all", None)
if callable(load_all):
try:
for record in load_all(active_only=True).values():
result.append(_skill_record_mapping(record))
except Exception:
logger.debug("Validator SkillStore lookup failed", exc_info=True)
if self.registry is not None:
list_skills = getattr(self.registry, "list_skills", None)
if callable(list_skills):
try:
for meta in list_skills():
result.append(_skill_record_mapping(meta))
except Exception:
logger.debug("Validator registry lookup failed", exc_info=True)
deduped: dict[str, dict[str, Any]] = {}
for item in result:
key = str(item.get("skill_id") or item.get("path") or item.get("name") or "")
if key:
deduped[key] = item
return list(deduped.values())
def _persist(self, result: ValidationResult) -> None:
persist = getattr(self.evidence_store, "persist_validation", None)
if not callable(persist):
return
persist(result)
def _apply_semantic_response(
outcome: str,
semantic_warnings: list[str],
response: Any,
) -> tuple[str, list[str]]:
next_outcome = outcome
warnings = list(semantic_warnings)
if isinstance(response, Mapping):
warnings.extend(_str_list(response.get("warnings")))
requested = str(response.get("outcome") or "").strip().lower()
if requested in {"reject", "needs_human_review"}:
next_outcome = requested
elif isinstance(response, ValidationResult):
warnings.extend(response.semantic_warnings)
if response.outcome in {"reject", "needs_human_review"}:
next_outcome = response.outcome
elif isinstance(response, str) and response.strip():
requested = response.strip().lower()
if requested in {"reject", "needs_human_review"}:
next_outcome = requested
elif requested not in {"approve", "passed", "ok"}:
warnings.append(requested)
return next_outcome, _dedupe(warnings)
def _strict_frontmatter(content: str) -> tuple[dict[str, Any], str | None]:
if not content.startswith("---"):
return {}, "missing_frontmatter"
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not match:
return {}, "invalid_frontmatter"
raw = match.group(1)
try:
import yaml
parsed = yaml.safe_load(raw) or {}
if not isinstance(parsed, dict):
return {}, "invalid_frontmatter"
return dict(parsed), None
except Exception:
parsed = parse_frontmatter(content)
if not parsed:
return {}, "invalid_frontmatter"
# The fallback parser is intentionally permissive. Treat values that
# leave obvious unbalanced YAML collection markers as malformed.
for value in parsed.values():
text = str(value).strip()
if text in {"[", "{", "]", "}"}:
return {}, "invalid_frontmatter"
return parsed, None
def _has_secondary_frontmatter(content: str) -> bool:
first = re.match(r"^---\n.*?\n---(?:\n|$)", content, re.DOTALL)
if first is None:
return False
lines = content[first.end():].splitlines()
fenced = False
for index, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("```") or stripped.startswith("~~~"):
fenced = not fenced
continue
if fenced or stripped != "---":
continue
block = lines[index + 1:index + 14]
if any(re.match(r"^(name|description)\s*:", item.strip()) for item in block):
return True
return False
def _snapshot(staged: Any) -> dict[str, str]:
snapshot = _attr(staged, "content_snapshot")
if isinstance(snapshot, Mapping):
return {str(key): str(value) for key, value in snapshot.items()}
return {}
def _changed_files(staged: Any) -> list[str]:
if staged is None:
return []
return sorted(dict.fromkeys(_str_list(_attr(staged, "changed_files"))))
def _action_type(decision: Any, staged: Any = None) -> str:
raw = (
_attr(decision, "proposed_action")
or _attr(decision, "action_type")
or _attr(decision, "evolution_type")
or _attr(staged, "action_type")
or ""
)
return str(getattr(raw, "value", raw) or "").strip().upper()
def _target_skill_ids(decision: Any, staged: Any = None) -> list[str]:
return _str_list(
_attr(decision, "target_skill_ids")
or _attr(decision, "target_skills")
or _attr(staged, "target_skill_ids")
)
def _target_skill_dirs(packet: EvidencePacket, skill_ids: list[str]) -> set[Path]:
targets = set(skill_ids)
result: set[Path] = set()
for ref in _packet_refs(packet).values():
if ref.ref_type != "skill_file":
continue
ref_skill_ids = _metadata_values(ref.metadata, "skill_id", "skill_ids")
if targets and not ref_skill_ids.intersection(targets):
continue
path_text = str(ref.metadata.get("path") or ref.uri or "").split("#", 1)[0]
path = _resolved_path(path_text)
if path is None:
continue
result.add(path.parent if path.name == SKILL_FILENAME else path)
return result
def _allowed_skill_roots(
packet: EvidencePacket,
action: str,
decision: Any,
) -> set[Path]:
roots: set[Path] = set()
for key in ("capture_destination_root", "capture_root", "capture_skill_dir"):
path = _resolved_path(packet.instructions.get(key))
if path is not None:
roots.add(path)
for ref in _packet_refs(packet).values():
for key in ("capture_destination_root", "capture_root", "capture_skill_dir"):
path = _resolved_path(ref.metadata.get(key))
if path is not None:
roots.add(path)
if action == "DERIVED":
for target_dir in _target_skill_dirs(packet, _target_skill_ids(decision)):
roots.add(target_dir.parent)
return roots
def _safe_relative_path(path: str) -> bool:
text = str(path or "").replace("\\", "/")
if not text or text.startswith("/"):
return False
pure = PurePosixPath(text)
if pure.is_absolute():
return False
return all(part not in {"", ".", ".."} for part in pure.parts)
def _forbidden_local_path(path: str) -> bool:
parts = [part.lower() for part in PurePosixPath(path.replace("\\", "/")).parts]
return any(
part in _LOCAL_FORBIDDEN_FILENAMES
or part.startswith(".env.")
or part.endswith(".env")
for part in parts
)
def _diff_paths(diff: str) -> set[str]:
paths: set[str] = set()
for line in diff.splitlines():
if line.startswith("+++ b/"):
value = line.removeprefix("+++ b/").strip()
elif line.startswith("--- a/"):
value = line.removeprefix("--- a/").strip()
else:
continue
if value and value != "/dev/null":
paths.add(value)
return paths
def _content_contains_secret(content: str) -> bool:
if contains_secret(content):
return True
return any(pattern.search(content) for pattern in _SECRET_PATTERNS)
def _packet_refs(packet: EvidencePacket) -> dict[str, ResourceRef]:
refs: dict[str, ResourceRef] = {}
for group in packet.selected_refs.values():
for ref in group:
if ref.ref_id:
refs[ref.ref_id] = ref
for path in packet.readable_paths:
if path.ref_id and path.ref_id not in refs:
refs[path.ref_id] = ResourceRef(
ref_id=path.ref_id,
ref_type="tool_result",
uri=path.path,
reliability="persisted",
role="supporting",
contains_secret=path.contains_secret,
metadata={"path": path.path, "purpose": path.purpose},
)
return refs
def _decision_ref_ids(decision: Any) -> list[str]:
refs: list[str] = []
decision_id = str(_attr(decision, "decision_id") or "")
if decision_id:
refs.append(f"decision:{decision_id}")
source_analysis_id = str(_attr(decision, "source_analysis_id") or "")
if source_analysis_id:
refs.append(source_analysis_id)
for claim in list(_attr(decision, "evidence_claims") or []):
refs.extend(_str_list(_attr(claim, "refs")))
return _dedupe(refs)
def _admission_ref_ids(admission: Any) -> list[str]:
refs: list[str] = []
admission_id = str(_attr(admission, "admission_id") or "")
if admission_id:
refs.append(f"admission:{admission_id}")
refs.extend(_str_list(_attr(admission, "required_refs_checked")))
return _dedupe(refs)
def _provenance_refs(
authoring: Any,
packet: EvidencePacket,
decision: Any,
admission: Any,
) -> list[str]:
refs: list[str] = []
authoring_id = str(_attr(authoring, "authoring_id") or "")
if authoring_id:
refs.append(f"authoring:{authoring_id}")
refs.extend(_decision_ref_ids(decision))
refs.extend(_admission_ref_ids(admission))
if getattr(packet, "packet_id", ""):
refs.append(f"packet:{packet.packet_id}")
staged = _attr(authoring, "staged_edit")
if staged is not None:
refs.extend(_str_list(_attr(staged, "evidence_refs")))
refs.extend(_packet_refs(packet))
return _dedupe(refs)
def _frontmatter_tags(skill_md: str) -> list[str]:
frontmatter, error = _strict_frontmatter(skill_md)
if error:
return []
return _str_list(frontmatter.get("tags"))
def _is_duplicate_skill(
*,
proposed_name: str,
proposed_description: str,
proposed_tags: list[str],
existing: dict[str, Any],
) -> bool:
existing_name = str(existing.get("name") or "")
existing_description = str(existing.get("description") or "")
if _normalized_text(proposed_name) and (
_normalized_text(proposed_name) == _normalized_text(existing_name)
):
return True
name_ratio = _similarity(proposed_name, existing_name)
description_ratio = _similarity(proposed_description, existing_description)
if name_ratio >= 0.9 and description_ratio >= 0.82:
return True
if description_ratio >= 0.94 and name_ratio >= 0.75:
return True
existing_tags = set(_str_list(existing.get("tags")))
if proposed_tags and existing_tags and set(proposed_tags).intersection(existing_tags):
return name_ratio >= 0.82 or description_ratio >= 0.88
return False
def _skill_record_mapping(record: Any) -> dict[str, Any]:
if isinstance(record, Mapping):
return {
"skill_id": str(record.get("skill_id") or ""),
"name": str(record.get("name") or ""),
"description": str(record.get("description") or ""),
"tags": _str_list(record.get("tags")),
"path": str(record.get("path") or ""),
}
metadata = _mapping_or_empty(getattr(record, "metadata", None))
return {
"skill_id": str(getattr(record, "skill_id", "") or metadata.get("skill_id") or ""),
"name": str(getattr(record, "name", "") or metadata.get("name") or ""),
"description": str(
getattr(record, "description", "") or metadata.get("description") or ""
),
"tags": _str_list(getattr(record, "tags", None) or metadata.get("tags")),
"path": str(getattr(record, "path", "") or metadata.get("path") or ""),
}
def _allowed_frontmatter_keys(admission: Any) -> set[str]:
keys: set[str] = set()
for field_name in (
"allowed_frontmatter_keys",
"approved_frontmatter_keys",
"approved_runtime_overlay_fields",
):
keys.update(_normalize_overlay_key(item) for item in _str_list(_attr(admission, field_name)))
metadata = _mapping_or_empty(_attr(admission, "metadata"))
for field_name in (
"allowed_frontmatter_keys",
"approved_frontmatter_keys",
"approved_runtime_overlay_fields",
):
keys.update(_normalize_overlay_key(item) for item in _str_list(metadata.get(field_name)))
return keys
def _allowed_overlay_fields(admission: Any) -> set[str]:
keys: set[str] = set()
for field_name in (
"allowed_overlay_fields",
"approved_overlay_fields",
"approved_runtime_overlay_fields",
):
keys.update(_normalize_overlay_key(item) for item in _str_list(_attr(admission, field_name)))
metadata = _mapping_or_empty(_attr(admission, "metadata"))
for field_name in (
"allowed_overlay_fields",
"approved_overlay_fields",
"approved_runtime_overlay_fields",
):
keys.update(_normalize_overlay_key(item) for item in _str_list(metadata.get(field_name)))
return keys
def _normalize_overlay_key(key: Any) -> str:
text = str(key or "").strip()
return _RUNTIME_OVERLAY_FIELD_ALIASES.get(text, text)
def _metadata_values(metadata: Mapping[str, Any], *keys: str) -> set[str]:
values: set[str] = set()
for key in keys:
value = metadata.get(key)
if isinstance(value, str):
values.add(value)
elif isinstance(value, (list, tuple, set)):
values.update(str(item) for item in value if str(item))
return values
def _sanitize_skill_name(name: str) -> str:
clean = re.sub(r"[^a-z0-9\-]", "-", name.lower().strip())
clean = re.sub(r"-{2,}", "-", clean).strip("-")
return clean[:_MAX_SKILL_NAME_LENGTH].strip("-")
def _normalized_text(value: Any) -> str:
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
def _similarity(left: Any, right: Any) -> float:
left_text = _normalized_text(left)
right_text = _normalized_text(right)
if not left_text or not right_text:
return 0.0
return difflib.SequenceMatcher(None, left_text, right_text).ratio()
def _attr(value: Any, name: str, default: Any = None) -> Any:
if value is None:
return default
if isinstance(value, Mapping):
return value.get(name, default)
return getattr(value, name, default)
def _mapping_or_empty(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _resolved_path(value: Any) -> Path | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
try:
return Path(text).expanduser().resolve()
except OSError:
return None
def _str_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [value] if value else []
if isinstance(value, (list, tuple, set)):
return [str(item) for item in value if str(item)]
return []
def _dedupe(items: list[str]) -> list[str]:
return [item for item in dict.fromkeys(items) if item]
def _int_or_none(value: Any) -> int | None:
try:
if value is None or str(value) == "":
return None
return int(value)
except (TypeError, ValueError):
return None
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()