deer-flow/scripts/skill_review_waivers.py

296 lines
12 KiB
Python

"""Trusted, exact-match waivers for the public-skill CI review gate."""
from __future__ import annotations
import hashlib
import json
import re
import subprocess
from dataclasses import dataclass
from datetime import date
from pathlib import Path, PurePosixPath
from typing import Any
SCHEMA_VERSION = "deerflow.skill-review-waivers.v1"
MANIFEST_PATH = PurePosixPath(".github/skill-review-waivers.v1.json")
_SHA256_RE = re.compile(r"sha256:[0-9a-f]{64}\Z")
_SAFE_REF_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]*\Z")
_REQUIRED_ENTRY_FIELDS = {
"package",
"source",
"rule_id",
"path",
"line",
"evidence",
"file_sha256",
"reason",
"expires_on",
}
_OPTIONAL_ENTRY_FIELDS = {"approved_in", "preapproved_file_sha256s"}
_MAX_PREAPPROVED_FILE_SHA256S = 8
class WaiverManifestError(ValueError):
"""Raised when a waiver manifest is missing required trust properties."""
@dataclass(frozen=True)
class SkillReviewWaiver:
package: str
source: str
rule_id: str
path: str
line: int
evidence: str
file_sha256: str
reason: str
expires_on: date
approved_in: str | None = None
preapproved_file_sha256s: tuple[str, ...] = ()
@property
def finding_key(self) -> tuple[str, str, str, int, str]:
return (self.source, self.rule_id, self.path, self.line, self.evidence)
@property
def approved_file_sha256s(self) -> tuple[str, ...]:
return (self.file_sha256, *self.preapproved_file_sha256s)
@dataclass(frozen=True)
class WaiverManifest:
waivers: tuple[SkillReviewWaiver, ...] = ()
EMPTY_MANIFEST = WaiverManifest()
def parse_manifest(payload: bytes | str, *, source: str) -> WaiverManifest:
"""Parse the versioned waiver manifest with strict, fail-closed validation."""
try:
data = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError, TypeError) as exc:
raise WaiverManifestError(f"{source}: invalid JSON: {exc}") from exc
if not isinstance(data, dict):
raise WaiverManifestError(f"{source}: manifest root must be an object")
unknown_root = set(data) - {"schema_version", "waivers"}
if unknown_root:
raise WaiverManifestError(f"{source}: unknown root field(s): {', '.join(sorted(unknown_root))}")
if data.get("schema_version") != SCHEMA_VERSION:
raise WaiverManifestError(f"{source}: schema_version must be {SCHEMA_VERSION!r}")
raw_waivers = data.get("waivers")
if not isinstance(raw_waivers, list):
raise WaiverManifestError(f"{source}: waivers must be an array")
if len(raw_waivers) > 256:
raise WaiverManifestError(f"{source}: waivers must contain at most 256 entries")
waivers: list[SkillReviewWaiver] = []
identities: set[tuple[str, str, str, int, str, str]] = set()
for index, raw in enumerate(raw_waivers):
entry_source = f"{source}: waivers[{index}]"
if not isinstance(raw, dict):
raise WaiverManifestError(f"{entry_source}: entry must be an object")
fields = set(raw)
missing = _REQUIRED_ENTRY_FIELDS - fields
unknown = fields - _REQUIRED_ENTRY_FIELDS - _OPTIONAL_ENTRY_FIELDS
if missing:
raise WaiverManifestError(f"{entry_source}: missing field(s): {', '.join(sorted(missing))}")
if unknown:
raise WaiverManifestError(f"{entry_source}: unknown field(s): {', '.join(sorted(unknown))}")
package = _canonical_relative_path(raw["package"], field=f"{entry_source}.package")
if not package.startswith("skills/public/"):
raise WaiverManifestError(f"{entry_source}.package: must be below skills/public/")
path = _canonical_relative_path(raw["path"], field=f"{entry_source}.path")
source_name = _nonempty_string(raw["source"], field=f"{entry_source}.source")
rule_id = _nonempty_string(raw["rule_id"], field=f"{entry_source}.rule_id")
evidence = _nonempty_string(raw["evidence"], field=f"{entry_source}.evidence")
file_sha256 = _parse_sha256(raw["file_sha256"], field=f"{entry_source}.file_sha256")
raw_preapproved_hashes = raw.get("preapproved_file_sha256s", [])
if not isinstance(raw_preapproved_hashes, list):
raise WaiverManifestError(f"{entry_source}.preapproved_file_sha256s: must be an array")
if len(raw_preapproved_hashes) > _MAX_PREAPPROVED_FILE_SHA256S:
raise WaiverManifestError(f"{entry_source}.preapproved_file_sha256s: must contain at most {_MAX_PREAPPROVED_FILE_SHA256S} entries")
preapproved_file_sha256s = tuple(_parse_sha256(value, field=f"{entry_source}.preapproved_file_sha256s[{hash_index}]") for hash_index, value in enumerate(raw_preapproved_hashes))
if len(set(preapproved_file_sha256s)) != len(preapproved_file_sha256s):
raise WaiverManifestError(f"{entry_source}.preapproved_file_sha256s: entries must be unique")
if file_sha256 in preapproved_file_sha256s:
raise WaiverManifestError(f"{entry_source}.preapproved_file_sha256s: must not repeat file_sha256")
reason = _nonempty_string(raw["reason"], field=f"{entry_source}.reason")
if len(reason) < 20:
raise WaiverManifestError(f"{entry_source}.reason: must contain at least 20 characters")
line = raw["line"]
if isinstance(line, bool) or not isinstance(line, int) or line < 1:
raise WaiverManifestError(f"{entry_source}.line: must be a positive integer")
expires_on = _parse_date(raw["expires_on"], field=f"{entry_source}.expires_on")
approved_in = raw.get("approved_in")
if approved_in is not None:
approved_in = _nonempty_string(approved_in, field=f"{entry_source}.approved_in")
waiver = SkillReviewWaiver(
package=package,
source=source_name,
rule_id=rule_id,
path=path,
line=line,
evidence=evidence,
file_sha256=file_sha256,
reason=reason,
expires_on=expires_on,
approved_in=approved_in,
preapproved_file_sha256s=preapproved_file_sha256s,
)
identity = (*waiver.finding_key, waiver.package)
if identity in identities:
raise WaiverManifestError(f"{entry_source}: duplicates an earlier waiver")
identities.add(identity)
waivers.append(waiver)
return WaiverManifest(tuple(waivers))
def load_manifest_at_ref(repo_root: Path, ref: str, *, label: str) -> WaiverManifest:
"""Read a manifest from a Git ref; absence means no waivers at that ref."""
if not _SAFE_REF_RE.fullmatch(ref) or ref.startswith("-") or ".." in ref or ":" in ref:
raise WaiverManifestError(f"{label}: unsafe Git ref {ref!r}")
object_name = f"{ref}:{MANIFEST_PATH.as_posix()}"
result = subprocess.run(
["git", "show", object_name],
cwd=repo_root,
capture_output=True,
check=False,
)
if result.returncode != 0:
return EMPTY_MANIFEST
return parse_manifest(result.stdout, source=f"{label} manifest at {ref}")
def file_sha256(repo_root: Path, waiver: SkillReviewWaiver) -> str | None:
resolved_repo_root = repo_root.resolve()
package_candidate = resolved_repo_root / waiver.package
if package_candidate.is_symlink() or not package_candidate.is_dir():
return None
package_root = package_candidate.resolve()
try:
package_root.relative_to(resolved_repo_root / "skills" / "public")
except ValueError:
return None
candidate = package_root / waiver.path
if candidate.is_symlink() or not candidate.is_file():
return None
resolved = candidate.resolve()
try:
resolved.relative_to(package_root)
except ValueError:
return None
digest = hashlib.sha256(resolved.read_bytes()).hexdigest()
return f"sha256:{digest}"
def finding_key(finding: dict[str, Any]) -> tuple[str, str, str, int, str] | None:
source = finding.get("source")
rule_id = finding.get("rule_id")
path = finding.get("path")
line = finding.get("line")
evidence = finding.get("evidence")
if not isinstance(source, str) or not isinstance(rule_id, str) or not isinstance(path, str) or isinstance(line, bool) or not isinstance(line, int) or not isinstance(evidence, str):
return None
return (source, rule_id, path, line, evidence)
def matching_waiver(
finding: dict[str, Any],
*,
package: str,
manifest: WaiverManifest,
repo_root: Path,
today: date | None = None,
) -> SkillReviewWaiver | None:
"""Return an active waiver only for an exact error finding and file digest."""
if finding.get("severity") != "error":
return None
key = finding_key(finding)
if key is None:
return None
current_date = today or date.today()
for waiver in manifest.waivers:
if waiver.package != package or waiver.finding_key != key:
continue
if waiver.expires_on < current_date:
continue
if file_sha256(repo_root, waiver) not in waiver.approved_file_sha256s:
continue
return waiver
return None
def validate_manifest_against_facts(
manifest: WaiverManifest,
*,
facts_by_package: dict[str, dict[str, Any]],
repo_root: Path,
today: date | None = None,
) -> list[str]:
"""Reject expired, stale, hash-mismatched, or non-error waiver entries."""
errors: list[str] = []
current_date = today or date.today()
for waiver in manifest.waivers:
description = f"{waiver.package}/{waiver.path}:{waiver.line} ({waiver.source}/{waiver.rule_id})"
if waiver.expires_on < current_date:
errors.append(f"{description}: waiver expired on {waiver.expires_on.isoformat()}")
continue
facts = facts_by_package.get(waiver.package)
if facts is None:
errors.append(f"{description}: package review facts are unavailable")
continue
matches = [finding for finding in facts.get("findings", []) if isinstance(finding, dict) and finding_key(finding) == waiver.finding_key]
if not matches:
errors.append(f"{description}: no exact current finding matches this waiver")
continue
if len(matches) != 1:
errors.append(f"{description}: expected one exact current finding, found {len(matches)}")
continue
if any(finding.get("severity") != "error" for finding in matches):
errors.append(f"{description}: waivers may target error findings only; blockers can never be waived")
continue
actual_hash = file_sha256(repo_root, waiver)
if actual_hash not in waiver.approved_file_sha256s:
expected_hashes = ", ".join(waiver.approved_file_sha256s)
errors.append(f"{description}: file digest changed (expected one of {expected_hashes}, found {actual_hash or 'unavailable'})")
return errors
def _canonical_relative_path(value: object, *, field: str) -> str:
text = _nonempty_string(value, field=field)
if "\\" in text:
raise WaiverManifestError(f"{field}: backslashes are not allowed")
path = PurePosixPath(text)
if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts) or path.as_posix() != text:
raise WaiverManifestError(f"{field}: must be a canonical relative POSIX path without traversal")
return text
def _nonempty_string(value: object, *, field: str) -> str:
if not isinstance(value, str) or not value.strip() or value != value.strip():
raise WaiverManifestError(f"{field}: must be a non-empty string without surrounding whitespace")
return value
def _parse_sha256(value: object, *, field: str) -> str:
digest = _nonempty_string(value, field=field)
if not _SHA256_RE.fullmatch(digest):
raise WaiverManifestError(f"{field}: must be sha256 followed by 64 lowercase hex characters")
return digest
def _parse_date(value: object, *, field: str) -> date:
text = _nonempty_string(value, field=field)
try:
parsed = date.fromisoformat(text)
except ValueError as exc:
raise WaiverManifestError(f"{field}: must be an ISO 8601 calendar date") from exc
if parsed.isoformat() != text:
raise WaiverManifestError(f"{field}: must use YYYY-MM-DD format")
return parsed