diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7fb733..55dea86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,19 @@ permissions: contents: read jobs: + public-boundary: + name: Public repository boundary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Reject private-only material + run: python scripts/check_public_boundary.py + test: name: Python ${{ matrix.python }} / ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ee3f67..47e91cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: "3.13" + - name: Enforce public repository boundary + run: python scripts/check_public_boundary.py - name: Build wheel and source distribution run: | python -m pip install build @@ -78,10 +82,16 @@ jobs: needs: [python, native-linux, native-macos] runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/download-artifact@v4 with: path: release-assets merge-multiple: true + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Validate release payload shell: bash run: | @@ -92,6 +102,8 @@ jobs: test -f release-assets/skill-runtime-hook-native-darwin-arm64 test -f release-assets/skill-runtime.pyz test -f release-assets/install.sh + - name: Enforce public release boundary + run: python scripts/check_public_boundary.py --release-dir release-assets - uses: softprops/action-gh-release@v2 with: files: release-assets/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea7a31e..47b1d28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,22 @@ Keep integrations behind versioned adapters and preserve raw source events separately from normalized and inferred records. New behavior must strengthen the Skill-specific run panorama or diagnosis workflow. +## Public repository boundary + +This repository and every published release contain public material only. Do +not commit private infrastructure URLs, organization-only installation or +distribution instructions, credentials, employee identity data, or other +non-public content. + +Run the same fail-closed check used by CI before opening a pull request: + +```bash +python scripts/check_public_boundary.py +``` + +The release workflow repeats this check against both the source tree and the +contents of packaged archives before uploading any asset. + ## Pull requests 1. Open an issue for substantial behavior or event-model changes. diff --git a/scripts/check_public_boundary.py b/scripts/check_public_boundary.py new file mode 100644 index 0000000..0c53550 --- /dev/null +++ b/scripts/check_public_boundary.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Fail closed when public source or release assets contain internal material.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path, PurePosixPath +from typing import Iterable + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + +# Assemble markers so this policy file does not match itself. Keep the list +# intentionally narrow and high-confidence: these values identify private +# infrastructure, not ordinary uses of words such as "internal" in source code. +BLOCKED_CONTENT = ( + ("private Alibaba domain", b"alibaba" + b"-inc.com"), + ("private Alipay domain", b"alipay" + b"-inc.com"), + ("private Alipay domain", b"alipay" + b".com"), + ("private ATA domain", b"ata" + b"tech.org"), + ("private Yuque domain", b"ali" + b"yuque."), + ("private Aliyun domain", b"aliyun" + b"-inc.com"), + ("private proxy domain", b"intranet" + b"proxy."), + ("private CLI distribution reference", b"cli" + b"-hub."), + ("private distribution wording", b"Alibaba " + b"internal distribution"), + ("private installation wording", b"internal installation " + b"guide"), + ("private CLI distribution wording", b"Aone CLI " + b"Hub"), +) + +BLOCKED_PATHS = { + "/".join(("docs", "internal" + "-installation.md")), + "/".join(("scripts", "install" + "-internal.sh")), +} + + +def _normalized_path(value: str) -> str: + return PurePosixPath(value.replace("\\", "/")).as_posix().lstrip("./").lower() + + +def scan_path_name(label: str) -> list[str]: + normalized = _normalized_path(label) + if normalized in BLOCKED_PATHS or any( + normalized.endswith(f"/{blocked}") for blocked in BLOCKED_PATHS + ): + return [f"{label}: private-only path is not allowed in the public repository"] + return [] + + +def scan_blob(label: str, payload: bytes) -> list[str]: + lowered = payload.lower() + findings = [] + for description, marker in BLOCKED_CONTENT: + if marker.lower() in lowered: + findings.append(f"{label}: contains {description}") + return findings + + +def scan_archive(path: Path, label: str | None = None) -> list[str]: + display = label or str(path) + findings = scan_path_name(display) + try: + if zipfile.is_zipfile(path): + with zipfile.ZipFile(path) as archive: + for member in archive.infolist(): + member_label = f"{display}!{member.filename}" + findings.extend(scan_path_name(member_label)) + if not member.is_dir(): + findings.extend(scan_blob(member_label, archive.read(member))) + return findings + if tarfile.is_tarfile(path): + with tarfile.open(path, mode="r:*") as archive: + for member in archive.getmembers(): + member_label = f"{display}!{member.name}" + findings.extend(scan_path_name(member_label)) + if member.isfile(): + extracted = archive.extractfile(member) + if extracted is not None: + findings.extend(scan_blob(member_label, extracted.read())) + return findings + findings.extend(scan_blob(display, path.read_bytes())) + except (OSError, tarfile.TarError, zipfile.BadZipFile) as error: + findings.append(f"{display}: could not be inspected: {error}") + return findings + + +def tracked_files(root: Path) -> Iterable[Path]: + result = subprocess.run( + ["git", "ls-files", "-z"], + cwd=root, + check=True, + stdout=subprocess.PIPE, + ) + for relative in result.stdout.decode("utf-8", errors="strict").split("\0"): + if relative: + yield root / relative + + +def scan_source_tree(root: Path) -> list[str]: + findings = [] + for path in tracked_files(root): + relative = path.relative_to(root).as_posix() + findings.extend(scan_path_name(relative)) + if path.is_file(): + findings.extend(scan_blob(relative, path.read_bytes())) + return findings + + +def scan_git_metadata(root: Path) -> list[str]: + result = subprocess.run( + ["git", "log", "--format=%H%x00%B%x00", "HEAD"], + cwd=root, + check=True, + stdout=subprocess.PIPE, + ) + return scan_blob("public commit history", result.stdout) + + +def scan_release_directory(directory: Path) -> list[str]: + findings = [] + for path in sorted(directory.rglob("*")): + if path.is_file(): + findings.extend(scan_archive(path, path.relative_to(directory).as_posix())) + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--release-dir", + type=Path, + help="also inspect every release file and supported archive member", + ) + parser.add_argument( + "--skip-history", + action="store_true", + help="skip public commit-message inspection", + ) + args = parser.parse_args() + + findings = scan_source_tree(REPOSITORY_ROOT) + if not args.skip_history: + findings.extend(scan_git_metadata(REPOSITORY_ROOT)) + if args.release_dir is not None: + findings.extend(scan_release_directory(args.release_dir.resolve())) + + if findings: + print("Public boundary check failed:", file=sys.stderr) + for finding in sorted(set(findings)): + print(f"- {finding}", file=sys.stderr) + return 1 + print("Public boundary check passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_public_boundary.py b/tests/test_public_boundary.py new file mode 100644 index 0000000..d28b8ad --- /dev/null +++ b/tests/test_public_boundary.py @@ -0,0 +1,39 @@ +import importlib.util +import tempfile +import unittest +import zipfile +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "check_public_boundary.py" +SPEC = importlib.util.spec_from_file_location("check_public_boundary", SCRIPT_PATH) +assert SPEC and SPEC.loader +BOUNDARY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BOUNDARY) + + +class PublicBoundaryTests(unittest.TestCase): + def test_clean_public_content_passes(self): + self.assertEqual(BOUNDARY.scan_blob("README.md", b"Public release docs"), []) + + def test_private_domain_is_rejected(self): + marker = b"https://service." + b"alibaba" + b"-inc.com/path" + findings = BOUNDARY.scan_blob("README.md", marker) + self.assertTrue(any("private Alibaba domain" in item for item in findings)) + + def test_private_only_path_is_rejected(self): + path = "/".join(("docs", "internal" + "-installation.md")) + self.assertTrue(BOUNDARY.scan_path_name(path)) + + def test_embedded_archive_content_is_rejected(self): + marker = b"https://service." + b"aliyun" + b"-inc.com/path" + with tempfile.TemporaryDirectory() as directory: + archive_path = Path(directory) / "release.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("docs/help.txt", marker) + findings = BOUNDARY.scan_archive(archive_path) + self.assertTrue(any("private Aliyun domain" in item for item in findings)) + + +if __name__ == "__main__": + unittest.main()