feat(governance): pin selective upstream import contract
This commit is contained in:
+268
-22
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Safely import an explicit research allowlist into the commercial repository.
|
||||
|
||||
The importer is deliberately one-way and offline. It never writes semantic-merge
|
||||
or protected paths, and defaults to a dry run. A source without usable Git
|
||||
metadata is recorded as an auditable snapshot with ``source_commit=unknown``.
|
||||
The importer is deliberately one-way and offline. It never writes semantic-merge
|
||||
or protected paths, and defaults to a dry run. Non-Git archives are accepted
|
||||
only when an operator binds them to an explicit commit and expected source-tree
|
||||
SHA-256; missing or mismatched provenance fails closed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,6 +21,7 @@ import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_POLICY = ROOT / "references/cross_project_contract/sync_policy.v2.json"
|
||||
@@ -27,6 +29,29 @@ EXIT_OK = 0
|
||||
EXIT_SEMANTIC_REVIEW = 2
|
||||
EXIT_POLICY_REJECTED = 3
|
||||
EXIT_VALIDATION_FAILED = 4
|
||||
COMMIT_HEX_LENGTH = 40
|
||||
SHA256_HEX_LENGTH = 64
|
||||
IMPORT_RECORD_DIR = ROOT / "references/cross_project_contract/imports"
|
||||
REQUIRED_PROTECTED_PATTERNS = frozenset({
|
||||
"skills/jyotish-birth-time-rectification/**",
|
||||
"skills/jyotish-vedic-astrology/**",
|
||||
"SKILL.md",
|
||||
"frontend/**",
|
||||
"deploy/**",
|
||||
".gitea/**",
|
||||
".github/**",
|
||||
"frontend/db/**",
|
||||
"frontend/supabase/**",
|
||||
"references/oracle/commercial_skill_truth_overlay.v1.json",
|
||||
"**/.env",
|
||||
"**/.env.*",
|
||||
"**/*payment*",
|
||||
"**/*billing*",
|
||||
"**/*subscription*",
|
||||
"**/*entitlement*",
|
||||
"**/*admin*",
|
||||
"**/*service_role*",
|
||||
})
|
||||
|
||||
|
||||
class ImportRejected(ValueError):
|
||||
@@ -54,8 +79,14 @@ def _safe_relative(value: str) -> str:
|
||||
|
||||
|
||||
def _resolved_file(root: Path, relative: str, *, must_exist: bool) -> Path:
|
||||
path = root / _safe_relative(relative)
|
||||
root_resolved = root.resolve()
|
||||
safe_relative = _safe_relative(relative)
|
||||
path = root_resolved / safe_relative
|
||||
current = root_resolved
|
||||
for part in PurePosixPath(safe_relative).parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise ImportRejected(f"symlink_component_rejected:{relative}")
|
||||
if must_exist and not path.exists():
|
||||
return path
|
||||
resolved = path.resolve(strict=must_exist)
|
||||
@@ -81,12 +112,20 @@ def load_policy(path: Path) -> dict[str, Any]:
|
||||
raise ImportRejected("reverse_sync_must_be_forbidden")
|
||||
if "commercial_to_research" in json.dumps(policy, sort_keys=True):
|
||||
raise ImportRejected("reverse_direction_is_not_expressible")
|
||||
expected_identity = _repository_identity(str(policy.get("source_repository_url", "")))
|
||||
if expected_identity is None:
|
||||
raise ImportRejected("source_repository_url_invalid")
|
||||
if expected_identity[1] != str(policy.get("source_repository", "")).strip("/").casefold():
|
||||
raise ImportRejected("source_repository_url_slug_mismatch")
|
||||
modes = policy.get("modes")
|
||||
if not isinstance(modes, dict) or set(modes) != {"mirror", "semantic_merge", "protected"}:
|
||||
raise ImportRejected("policy_modes_invalid")
|
||||
if not isinstance(modes["mirror"], list) or not isinstance(modes["semantic_merge"], list):
|
||||
raise ImportRejected("policy_allowlists_invalid")
|
||||
protected = [_safe_relative(value) if "*" not in value else value.replace("\\", "/") for value in modes["protected"]]
|
||||
missing_protected = sorted(REQUIRED_PROTECTED_PATTERNS - set(protected))
|
||||
if missing_protected:
|
||||
raise ImportRejected(f"required_protected_patterns_missing:{','.join(missing_protected)}")
|
||||
for mapping in modes["mirror"]:
|
||||
if not isinstance(mapping, dict) or not {"source", "target", "license"} <= set(mapping):
|
||||
raise ImportRejected("mirror_mapping_invalid")
|
||||
@@ -108,12 +147,71 @@ def _git_output(root: Path, *args: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def source_identity(source: Path) -> tuple[str, str]:
|
||||
def _normalized_hex(value: str | None, *, length: int, label: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
if len(normalized) != length or any(ch not in "0123456789abcdef" for ch in normalized):
|
||||
raise ImportRejected(f"{label}_invalid")
|
||||
return normalized
|
||||
|
||||
|
||||
def source_identity(
|
||||
source: Path,
|
||||
*,
|
||||
source_mode: str = "auto",
|
||||
source_commit: str | None = None,
|
||||
expected_tree_sha256: str | None = None,
|
||||
actual_tree_sha256: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
if source_mode not in {"auto", "git", "archive"}:
|
||||
raise ImportRejected("source_mode_must_be_auto_git_or_archive")
|
||||
if source_mode != "archive" and (source_commit is None) != (expected_tree_sha256 is None):
|
||||
raise ImportRejected("source_commit_and_expected_tree_sha256_must_be_provided_together")
|
||||
|
||||
explicit_commit = _normalized_hex(
|
||||
source_commit, length=COMMIT_HEX_LENGTH, label="source_commit"
|
||||
)
|
||||
expected_tree = _normalized_hex(
|
||||
expected_tree_sha256, length=SHA256_HEX_LENGTH, label="expected_tree_sha256"
|
||||
)
|
||||
actual_tree = actual_tree_sha256 or source_tree_hash(source)
|
||||
|
||||
inside = _git_output(source, "rev-parse", "--show-toplevel")
|
||||
commit = _git_output(source, "rev-parse", "HEAD") if inside and Path(inside).resolve() == source.resolve() else None
|
||||
if commit and len(commit) == 40 and all(ch in "0123456789abcdef" for ch in commit.lower()):
|
||||
return "git", commit.lower()
|
||||
return "snapshot", "unknown"
|
||||
detected_commit = (
|
||||
_git_output(source, "rev-parse", "HEAD")
|
||||
if inside and Path(inside).resolve() == source.resolve()
|
||||
else None
|
||||
)
|
||||
detected_commit = _normalized_hex(
|
||||
detected_commit, length=COMMIT_HEX_LENGTH, label="detected_source_commit"
|
||||
)
|
||||
|
||||
if source_mode == "archive" and detected_commit:
|
||||
raise ImportRejected("archive_source_must_not_be_git_checkout")
|
||||
|
||||
if source_mode != "archive" and detected_commit:
|
||||
status = _git_output(source, "status", "--porcelain=v1", "--untracked-files=all")
|
||||
ignored = _git_output(source, "ls-files", "--others", "--ignored", "--exclude-standard")
|
||||
if status is None or ignored is None:
|
||||
raise ImportRejected("git_source_status_unavailable")
|
||||
if status or ignored:
|
||||
raise ImportRejected("git_source_must_be_clean")
|
||||
if explicit_commit and explicit_commit != detected_commit:
|
||||
raise ImportRejected("source_commit_mismatch")
|
||||
if expected_tree and expected_tree != actual_tree:
|
||||
raise ImportRejected("source_tree_sha256_mismatch")
|
||||
return "git", detected_commit
|
||||
|
||||
if source_mode == "git":
|
||||
raise ImportRejected("git_source_metadata_unavailable")
|
||||
if explicit_commit is None:
|
||||
raise ImportRejected("archive_source_commit_required")
|
||||
if expected_tree is None:
|
||||
raise ImportRejected("archive_expected_tree_sha256_required")
|
||||
if expected_tree != actual_tree:
|
||||
raise ImportRejected("source_tree_sha256_mismatch")
|
||||
return "archive", explicit_commit
|
||||
|
||||
|
||||
def _normalized_remote(root: Path) -> str | None:
|
||||
@@ -124,6 +222,54 @@ def _normalized_remote(root: Path) -> str | None:
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
def _repository_identity(value: str) -> tuple[str, str] | None:
|
||||
value = value.strip().removesuffix(".git").rstrip("/")
|
||||
if not value:
|
||||
return None
|
||||
if "://" in value:
|
||||
parsed = urlsplit(value)
|
||||
host = (parsed.hostname or "").casefold()
|
||||
path = parsed.path
|
||||
elif ":" in value and "@" in value.split(":", 1)[0]:
|
||||
host = value.split("@", 1)[1].split(":", 1)[0].casefold()
|
||||
path = value.split(":", 1)[1]
|
||||
else:
|
||||
return None
|
||||
parts = [part for part in path.split("/") if part]
|
||||
if not host or len(parts) < 2:
|
||||
return None
|
||||
return host, "/".join(parts).casefold()
|
||||
|
||||
|
||||
def _remote_repository_identity(root: Path) -> tuple[str, str] | None:
|
||||
remote = _git_output(root, "remote", "get-url", "origin")
|
||||
return _repository_identity(remote or "")
|
||||
|
||||
|
||||
def validate_git_source_repository(source: Path, expected_repository: str, expected_url: str) -> None:
|
||||
expected = expected_repository.strip().removesuffix(".git").strip("/").casefold()
|
||||
expected_identity = _repository_identity(expected_url)
|
||||
actual = _remote_repository_identity(source)
|
||||
if actual is None:
|
||||
raise ImportRejected("git_source_origin_unavailable")
|
||||
if expected_identity is None or expected_identity[1] != expected:
|
||||
raise ImportRejected("source_repository_url_invalid")
|
||||
if actual != expected_identity:
|
||||
raise ImportRejected(f"git_source_repository_mismatch:{actual[0]}/{actual[1]}")
|
||||
|
||||
|
||||
def validate_clean_target(target: Path) -> None:
|
||||
top_level = _git_output(target, "rev-parse", "--show-toplevel")
|
||||
if not top_level or Path(top_level).resolve() != target.resolve():
|
||||
raise ImportRejected("target_must_be_git_repository_root")
|
||||
status = _git_output(target, "status", "--porcelain=v1", "--untracked-files=all")
|
||||
ignored = _git_output(target, "ls-files", "--others", "--ignored", "--exclude-standard")
|
||||
if status is None or ignored is None:
|
||||
raise ImportRejected("target_status_unavailable")
|
||||
if status or ignored:
|
||||
raise ImportRejected("target_must_be_clean")
|
||||
|
||||
|
||||
def validate_roots(source: Path, target: Path) -> None:
|
||||
source = source.expanduser().resolve(strict=True)
|
||||
target = target.expanduser().resolve(strict=True)
|
||||
@@ -141,7 +287,11 @@ def source_tree_hash(source: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()):
|
||||
relative = path.relative_to(source).as_posix()
|
||||
if ".git" in PurePosixPath(relative).parts or path.is_symlink() or not path.is_file():
|
||||
if ".git" in PurePosixPath(relative).parts:
|
||||
continue
|
||||
if path.is_symlink():
|
||||
raise ImportRejected(f"source_tree_symlink_rejected:{relative}")
|
||||
if not path.is_file():
|
||||
continue
|
||||
digest.update(relative.encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
@@ -150,6 +300,37 @@ def source_tree_hash(source: Path) -> str:
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _normalized_commit_timestamp(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return None
|
||||
return parsed.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def deterministic_generated_at(
|
||||
source: Path,
|
||||
*,
|
||||
source_mode: str,
|
||||
source_commit: str,
|
||||
target: Path,
|
||||
target_commit: str,
|
||||
) -> str:
|
||||
timestamp = None
|
||||
if source_mode == "git":
|
||||
timestamp = _git_output(source, "show", "-s", "--format=%cI", source_commit)
|
||||
if timestamp is None:
|
||||
timestamp = _git_output(target, "show", "-s", "--format=%cI", target_commit)
|
||||
normalized = _normalized_commit_timestamp(timestamp)
|
||||
if normalized is None:
|
||||
raise ImportRejected("deterministic_generated_at_unavailable")
|
||||
return normalized
|
||||
|
||||
|
||||
def _privacy_rejection(path: Path, relative: str, privacy: dict[str, Any]) -> str | None:
|
||||
lowered = relative.casefold()
|
||||
basenames = {str(value).casefold() for value in privacy.get("forbidden_basenames", [])}
|
||||
@@ -189,17 +370,50 @@ def build_manifest(
|
||||
policy_path: Path = DEFAULT_POLICY,
|
||||
target: Path = ROOT,
|
||||
apply: bool = False,
|
||||
source_mode: str = "auto",
|
||||
source_commit: str | None = None,
|
||||
expected_tree_sha256: str | None = None,
|
||||
tests_run: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
source = source.expanduser().resolve(strict=True)
|
||||
target = target.expanduser().resolve(strict=True)
|
||||
validate_roots(source, target)
|
||||
policy = load_policy(policy_path)
|
||||
validate_roots(source, target)
|
||||
validate_clean_target(target)
|
||||
protected = list(policy["modes"]["protected"])
|
||||
privacy = policy.get("privacy") if isinstance(policy.get("privacy"), dict) else {}
|
||||
source_mode, source_commit = source_identity(source)
|
||||
target_commit = _git_output(target, "rev-parse", "HEAD")
|
||||
if not target_commit or len(target_commit) != 40:
|
||||
actual_tree_sha256 = source_tree_hash(source)
|
||||
resolved_source_mode, resolved_source_commit = source_identity(
|
||||
source,
|
||||
source_mode=source_mode,
|
||||
source_commit=source_commit,
|
||||
expected_tree_sha256=expected_tree_sha256,
|
||||
actual_tree_sha256=actual_tree_sha256,
|
||||
)
|
||||
if resolved_source_mode == "git":
|
||||
validate_git_source_repository(
|
||||
source,
|
||||
str(policy["source_repository"]),
|
||||
str(policy["source_repository_url"]),
|
||||
)
|
||||
source_skill_file = _resolved_file(source, "SKILL.md", must_exist=True)
|
||||
if not source_skill_file.exists():
|
||||
raise ImportRejected("root_skill_missing")
|
||||
source_skill_sha256 = sha256_file(source_skill_file)
|
||||
target_commit = _normalized_hex(
|
||||
_git_output(target, "rev-parse", "HEAD"),
|
||||
length=COMMIT_HEX_LENGTH,
|
||||
label="target_base_commit",
|
||||
)
|
||||
if target_commit is None:
|
||||
raise ImportRejected("target_base_commit_unavailable")
|
||||
generated_at = deterministic_generated_at(
|
||||
source,
|
||||
source_mode=resolved_source_mode,
|
||||
source_commit=resolved_source_commit,
|
||||
target=target,
|
||||
target_commit=target_commit,
|
||||
)
|
||||
|
||||
mirror_files: list[dict[str, Any]] = []
|
||||
privacy_rejections: list[str] = []
|
||||
@@ -291,22 +505,41 @@ def build_manifest(
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"source_repository": policy["source_repository"],
|
||||
"source_commit": source_commit,
|
||||
"source_tree_hash": source_tree_hash(source),
|
||||
"source_mode": source_mode,
|
||||
"source_repository_url": policy["source_repository_url"],
|
||||
"source_commit": resolved_source_commit,
|
||||
"source_tree_hash": actual_tree_sha256,
|
||||
"source_skill_sha256": source_skill_sha256,
|
||||
"source_mode": resolved_source_mode,
|
||||
"target_repository": policy["target_repository"],
|
||||
"target_base_commit": target_commit.lower(),
|
||||
"target_base_commit": target_commit,
|
||||
"policy_version": 2,
|
||||
"generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||
"generated_at": generated_at,
|
||||
"mirror_files": mirror_files,
|
||||
"semantic_merge_files": semantic_files,
|
||||
"protected_rejections": [],
|
||||
"tests_run": [],
|
||||
"tests_run": sorted(set(tests_run or [])),
|
||||
"privacy_scan": {"status": "pass", "scanned_files": len(mirror_files), "rejections": []},
|
||||
"operator_review_required": any(row["status"] in {"review_required", "target_missing"} for row in semantic_files),
|
||||
}
|
||||
|
||||
|
||||
def validate_output_path(path: Path, *, target: Path = ROOT, source: Path | None = None) -> Path:
|
||||
"""Allow manifests outside the target or inside the dedicated import-record directory only."""
|
||||
resolved = path.expanduser().resolve(strict=False)
|
||||
target_resolved = target.expanduser().resolve(strict=True)
|
||||
if source is not None:
|
||||
source_resolved = source.expanduser().resolve(strict=True)
|
||||
if resolved == source_resolved or source_resolved in resolved.parents:
|
||||
raise ImportRejected("manifest_output_must_not_overlap_source")
|
||||
if resolved == target_resolved or target_resolved in resolved.parents:
|
||||
import_root = (target_resolved / IMPORT_RECORD_DIR.relative_to(ROOT)).resolve(strict=False)
|
||||
if import_root not in resolved.parents:
|
||||
raise ImportRejected("manifest_output_must_be_external_or_import_record_json")
|
||||
if resolved.suffix.casefold() != ".json":
|
||||
raise ImportRejected("manifest_output_must_be_json")
|
||||
return resolved
|
||||
|
||||
|
||||
def write_json_atomic(path: Path, value: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
@@ -326,14 +559,27 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", type=Path, required=True)
|
||||
parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY)
|
||||
parser.add_argument("--source-mode", choices=("auto", "git", "archive"), default="auto")
|
||||
parser.add_argument("--source-commit")
|
||||
parser.add_argument("--expected-tree-sha256")
|
||||
parser.add_argument("--test-run", action="append", default=[], help="Test path recorded in the manifest; repeatable.")
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--dry-run", action="store_true")
|
||||
mode.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
manifest = build_manifest(args.source, policy_path=args.policy, apply=args.apply)
|
||||
write_json_atomic(args.output, manifest)
|
||||
output = validate_output_path(args.output, source=args.source)
|
||||
manifest = build_manifest(
|
||||
args.source,
|
||||
policy_path=args.policy,
|
||||
apply=args.apply,
|
||||
source_mode=args.source_mode,
|
||||
source_commit=args.source_commit,
|
||||
expected_tree_sha256=args.expected_tree_sha256,
|
||||
tests_run=args.test_run,
|
||||
)
|
||||
write_json_atomic(output, manifest)
|
||||
except ImportRejected as error:
|
||||
print(json.dumps({"status": "rejected", "reason": str(error)}, ensure_ascii=False), file=sys.stderr)
|
||||
return EXIT_POLICY_REJECTED
|
||||
|
||||
Reference in New Issue
Block a user