diff --git a/docs/design-decisions/repo-sentinel-external-policy-root-v1.md b/docs/design-decisions/repo-sentinel-external-policy-root-v1.md new file mode 100644 index 0000000..270003b --- /dev/null +++ b/docs/design-decisions/repo-sentinel-external-policy-root-v1.md @@ -0,0 +1,339 @@ +# Repo Sentinel External Policy Root v1 + +Status: reviewed protocol plumbing only. Production authority is inactive. This +change creates no signer, GitHub App, workflow, status, branch rule, final v2 +bundle, or production v2 bundle digest. + +## Problem + +The first controller protocol fixed the expected policy-bundle digest inside +controller source. Once the controller itself becomes a protected v2 control, +that design forms a cycle: + +```text +controller bytes + -> protected manifest + -> bundle digest + -> controller digest constant + -> changed controller bytes +``` + +The policy identity must instead enter the trusted evaluation boundary from an +external authenticated policy-admission record: + +```text +external authenticated policy root + -> expected bundle digest + -> trusted controller + -> verified bundle + -> P_v(H) +``` + +The controller validates an evaluation. It does not establish that the input +digest is authorized for production publication. + +## Invariant + +The controller supports exactly one selector and one trusted relative policy +root: + +```text +selector: v2 +policy epoch: repo-sentinel-authority-v2 +bundle root: policy/repo-sentinel-authority/v2/ +controller protocol: external-policy-root-v1 +``` + +The caller cannot choose a bundle path, schema, mirror root, mandatory-control +set, scanner identity, or selector-to-epoch mapping. The expected bundle digest +is the only externally supplied policy identity. It must be an exact Python +`str` containing exactly 64 lowercase hexadecimal characters. + +For a validated request, the same digest value is passed unchanged to bundle +verification, the commit-authoritative worker, worker-result validation, and +the controller result envelope. No repository-controlled runtime source +contains a final v2 digest. + +## Design Decision + +### Explicit trusted policy contracts + +`PolicyBundleContract` is an immutable representation of a reviewed policy +contract. The verifier has an exact registry containing only `v1` and `v2`. +Callers pass a selector, not a caller-built contract. + +The contract binds: + +- bundle schema version; +- full policy epoch; +- worker semantic algorithm version; +- portable-path policy version; +- exact mirror root; +- mandatory protected paths; +- protected namespaces; +- scanner distribution, version, and artifact digest. + +Unknown selectors fail closed. There is no `latest` lookup, directory scan, or +epoch inference from bundle contents. + +### Version separation + +The v2 contract deliberately keeps these existing semantics: + +```text +bundle schema: 1 +portable path policy: portable-v1 +worker semantic algorithm: commit-authoritative-v1 +scanner: repo-sentinel-lite 0.8.1 +``` + +Only the policy epoch, mirror root, protected-control set, and controller +protocol change. The bundle digest domain remains +`repo-sentinel-authority-policy-bundle-v1` because serialization and hashing are +unchanged. + +### External digest flow + +The controller fixes the v2 selector, full epoch, and relative root. It accepts +an expected digest only after exact syntax validation and never compares it to +a compiled-in production digest. The worker receives both the selector and the +same digest. It resolves the selector through the trusted contract registry, +verifies the exact bundle, and derives mirror, protected, and coverage behavior +from that verified contract. + +`AUTHORITY_RESULT` is retained to avoid an unnecessary result-name migration. +In `external-policy-root-v1`, it means a validated semantic authority evaluation +result only. It is not authenticated production publication authorization. +Direct local execution can produce `AUTHORITY_RESULT`; it cannot publish the +production Commit Status. + +### Future signer registry record + +A future signer-side registry record has an immutable `record_id` and +`revision`. Together they identify one reviewed record whose complete binding +includes at least: + +```text +registry record ID and revision +repository ID +owner ID +policy selector +full policy epoch +approved bundle digest +controller protocol +trusted control source identity +approved workflow identity +action and dependency closure +runtime identity +scanner artifact identity +status context +dedicated App identity +``` + +The trusted control source identity includes the reviewed controller, +bootstrap, worker, verifier, acquisition, reader, and materializer closure. The +production workflow identity is recorded externally only after its source is +final. It is not written back into the protected repository tree. + +No field in an admitted registry record may be updated in place. Changing any +field requires a new reviewed record and revision. Historical records remain +immutable even after they stop being active. + +The registry enforces this epoch-to-digest invariant: + +```text +(repository ID, full policy epoch) -> exactly one approved bundle digest +``` + +A frozen epoch cannot acquire a second meaning. Any semantic bundle-digest +change requires an explicit policy-epoch migration rather than a new digest +under the existing epoch. + +Current activation is a separate, audited mapping: + +```text +(repository ID, full policy epoch, status context) + -> at most one active registry record revision +``` + +Activation is an explicit operation. A signer must never infer the active +record from the highest revision, newest timestamp, directory ordering, main +branch contents, or most recently observed record. Two active revisions for +one activation key make the registry invalid and evaluation fails closed. + +This PR does not create that registry, choose a production App, or freeze a v2 +record. + +### Future evaluation admission record + +Before production evaluation, a signer-side service must create an +authenticated server-side record containing at least: + +```text +evaluation ID +registry record ID and revision +repository ID +exact head commit H +policy epoch +expected bundle digest +controller protocol +trusted control source identity +scanner artifact identity +workflow run ID +workflow run attempt +execution identity +issued-at time +expiry time +finalization state +``` + +Admission must resolve the uniquely active reviewed registry revision and +atomically persist that exact `record_id` and `revision` in the evaluation. If +the service cannot prove and persist one exact frozen revision, including +during a concurrent activation update, it must not issue an evaluation. + +An evaluation ID is a lookup key, not a bearer authorization. The server-side +evaluation and registry records are authoritative. Client-returned revision, +digest, ticket JSON, controller result, or similar values are assertions to +compare, never authority to resolve or replace those records. + +Operational admission fields stay outside `P_v(H)` and the worker semantic +digest. Run IDs, attempts, evaluation IDs, expiry, OIDC claims, and workflow +identity describe authenticated execution, not the commit-intrinsic policy +predicate. + +### Registry lifecycle semantics + +Finalization loads the evaluation server-side, then loads the exact immutable +registry record referenced by that evaluation. It validates the controller +result, OIDC identity, execution identity, approved workflow and dependency +closure, trusted control source, runtime and scanner identities, status +context, and dedicated App identity against those frozen records. It never +resolves a current, latest, highest, or otherwise newly active revision during +finalization. + +For example, let registry revision R1 bind digest D1 and admit evaluation E. +If R2 is activated with a different allowed identity set before E completes, +E can only finalize under R1 and D1 or be rejected. It must never be +reinterpreted as R2, D2, R2's workflow or App, R2's control closure, or any +other current configuration. + +Lifecycle operations have these fail-closed meanings: + +- **Registry update:** affects future admission only. It cannot change an + issued evaluation's referenced record. +- **Revocation:** may cause outstanding evaluations referencing the revoked + record to be rejected. It cannot change their meaning, and the revoked record + remains immutable historical evidence. +- **Expiry:** causes the evaluation to be rejected. There is no implicit + refresh; another execution requires new admission and a new evaluation. +- **Rollback:** explicitly activates a reviewed record revision. It does not + mutate a record, and outstanding evaluations remain bound to their admitted + revisions. +- **Concurrent update:** admission must persist the selected revision + transactionally, with compare-and-swap or an equivalent mechanism. It must + not issue an evaluation whose exact frozen revision is uncertain. + +The frozen registry record also governs publication identity. An evaluation +admitted under R1's status context and dedicated App identity cannot silently +publish through R2's context, App, or publisher policy. + +These cases are normative regression requirements for a future signer service; +they do not claim that the service exists in this PR: + +| Scenario | Required decision | +| --- | --- | +| R1/D1 admits E and no registry update occurs | Finalize only under R1/D1 after all frozen identities validate. | +| R1/D1 admits E, then R2 becomes active | Finalize under R1/D1 or reject; never reinterpret E under R2. | +| R1 is revoked before E finalizes | Reject E without changing its recorded meaning. | +| E expires before finalization | Reject E; require new admission for another execution. | +| Client claims R2 for an evaluation bound to R1 | Reject the mismatch. | +| Client changes the expected digest from D1 to D2 | Reject the mismatch. | +| Two revisions are active for one activation key | Treat the registry as invalid and fail closed. | +| An operation attempts to mutate R1 in place | Prohibit the operation. | +| New bundle semantics are proposed under a frozen epoch | Prohibit the change; require explicit epoch migration. | + +## Threat And Failure Model + +This protocol fails closed against: + +- arbitrary or malformed digest values; +- caller-selected bundle paths or selector/path combinations; +- unknown selectors and unsupported epoch contents; +- v1 bundles presented as v2 and v2 bundles presented as v1; +- modified bundle bytes under a previously admitted digest; +- missing or modified mandatory controls; +- unreviewed workflow or action namespace members; +- v1/v2 mirror substitution or prefix fallback; +- worker results whose epoch or bundle digest differs from the request; +- a future caller treating local evaluation as signer authorization. + +The protocol does not authenticate the external digest. Future production +security therefore depends on the signer registry, authenticated evaluation +admission, execution binding, expiry/replay controls, and dedicated App status +publication described above. + +## Rejected Alternatives + +1. **Keep the exact v2 digest in controller source.** Rejected because the + controller is a v2 protected control and would recreate the self-reference + cycle. +2. **Let callers provide arbitrary epoch/root pairs.** Rejected because it turns + reviewed policy selection into input-controlled path selection. +3. **Discover the newest policy directory.** Rejected because directory state + and ordering would decide authority outside a reviewed contract. +4. **Ignore all `policy/**` files during coverage.** Rejected because it would + hide historical or unexpected policy artifacts instead of making a later v2 + migration decide them explicitly. +5. **Put workflow/OIDC/ticket fields into `P_v(H)`.** Rejected because those are + operational authorization evidence, not commit-intrinsic semantics. +6. **Treat `AUTHORITY_RESULT` as publication authority.** Rejected because only + a future authenticated signer-side finalization can authorize publication. + +## Compatibility + +The seven files below `policy/repo-sentinel-authority/v1/` remain byte-identical. +The v1 contract remains the default for the policy-bundle CLI and reproduces the +historical digest: + +```text +6f25ebb773ce1453e8de623bca5aaecc936f1f188288f8df20aedeadb3bf4612 +``` + +The v2 contract adds the bootstrap and controller to the mandatory protected +set while retaining complete `.github/workflows/**` and `.github/actions/**` +membership protection. No final `policy/repo-sentinel-authority/v2/` directory +is created here; tests build deterministic temporary bundles instead. + +Acquisition, reader, materializer, scanner semantics, report schemas, v1 +history, workflows, and branch rules are unchanged. Production authority stays +inactive. + +## Validation + +Validation for this boundary includes: + +- exact v1 digest reproduction and contract-field verification; +- deterministic one-pass synthetic v2 bundle generation; +- digest mismatch and selector/root cross-wire rejection; +- bootstrap/controller mandatory-entry rejection; +- workflow/action namespace membership enforcement; +- exact v1/v2 mirror admission; +- controller digest pass-through and worker result binding; +- self-reference regression showing no generated digest is written into runtime + sources; +- focused and full repository suites, lint, formatting, pre-commit, Markdown, + taxonomy, snapshot, pattern-library, privacy, and credential checks; +- mutation checks for each removed digest back-edge and v1-global assumption. + +Exact execution evidence belongs in the Draft PR validation record. + +## Rollback + +Before activation, rollback is a normal revert of the protocol-plumbing PR. No +production status, signer, App, workflow, branch rule, or v2 policy record needs +coordination because none is created here. + +After a later v2 migration, rollback must be a new externally reviewed registry +revision. A signer must never silently fall back to v1, discover another policy +directory, or accept an unregistered digest. diff --git a/scripts/repo_sentinel_authority_controller.py b/scripts/repo_sentinel_authority_controller.py index c86908a..7273a21 100644 --- a/scripts/repo_sentinel_authority_controller.py +++ b/scripts/repo_sentinel_authority_controller.py @@ -21,15 +21,14 @@ from types import ModuleType CONTROLLER_SCHEMA_VERSION = 1 +CONTROLLER_PROTOCOL = "external-policy-root-v1" REPOSITORY_ID = 1_130_304_545 OWNER_ID = 219_124_580 REPOSITORY = "stacknil/sec-writeups-public" REMOTE_URL = "https://github.com/stacknil/sec-writeups-public.git" -POLICY_EPOCH = "v1" -WORKER_POLICY_EPOCH = "repo-sentinel-authority-v1" -POLICY_BUNDLE_SHA256 = ( - "6f25ebb773ce1453e8de623bca5aaecc936f1f188288f8df20aedeadb3bf4612" -) +POLICY_SELECTOR = "v2" +EXPECTED_POLICY_EPOCH = "repo-sentinel-authority-v2" +POLICY_BUNDLE_ROOT = "policy/repo-sentinel-authority/v2" SCANNER_ARTIFACT_SHA256 = ( "0a949a4d00c6e6ae37eba60a6cb74e4e15bc3ec5fce2f1d4c99aa0ef309b36e3" ) @@ -84,7 +83,7 @@ "--remote-url", "--pull-number", "--head-oid", - "--policy-epoch", + "--policy-selector", "--policy-bundle-sha256", "--scratch-root", "--scanner-artifact", @@ -123,6 +122,8 @@ class ControllerOutcome(str, Enum): + """AUTHORITY_RESULT is evaluation evidence, not publication authorization.""" + AUTHORITY_RESULT = "AUTHORITY_RESULT" INFRASTRUCTURE_REFUSAL = "INFRASTRUCTURE_REFUSAL" @@ -141,7 +142,7 @@ class ControllerRequest: remote_url: str = field(repr=False) pull_number: int head_oid: str - policy_epoch: str + policy_selector: str expected_policy_bundle_sha256: str = field(repr=False) scratch_root: Path = field(repr=False) scanner_artifact: Path = field(repr=False) @@ -149,11 +150,13 @@ class ControllerRequest: @dataclass(frozen=True, slots=True) class ControllerResult: + controller_protocol: str controller_schema_version: int repository_id: int head_oid: str | None + policy_selector: str | None policy_epoch: str | None - policy_bundle_sha256: str + policy_bundle_sha256: str | None worker_result: dict[str, object] | None worker_semantic_sha256: str | None controller_outcome: ControllerOutcome @@ -388,7 +391,7 @@ def parse_request(argv: list[str]) -> ControllerRequest: remote_url=values["--remote-url"], pull_number=_parse_decimal(values["--pull-number"]), head_oid=values["--head-oid"], - policy_epoch=values["--policy-epoch"], + policy_selector=values["--policy-selector"], expected_policy_bundle_sha256=values["--policy-bundle-sha256"], scratch_root=Path(values["--scratch-root"]), scanner_artifact=Path(values["--scanner-artifact"]), @@ -406,7 +409,7 @@ def _validate_request( or type(request.repository) is not str or type(request.remote_url) is not str or type(request.head_oid) is not str - or type(request.policy_epoch) is not str + or type(request.policy_selector) is not str or type(request.expected_policy_bundle_sha256) is not str or not isinstance(request.scratch_root, Path) or not isinstance(request.scanner_artifact, Path) @@ -419,10 +422,10 @@ def _validate_request( or request.remote_url != REMOTE_URL ): raise ControllerRefused("repository_identity_mismatch") - if request.policy_epoch != POLICY_EPOCH: + if request.policy_selector != POLICY_SELECTOR: raise ControllerRefused("unknown_policy_epoch") - if request.expected_policy_bundle_sha256 != POLICY_BUNDLE_SHA256: - raise ControllerRefused("policy_bundle_mismatch") + if _DIGEST.fullmatch(request.expected_policy_bundle_sha256) is None: + raise ControllerRefused("invalid_request") if _OID.fullmatch(request.head_oid) is None: raise ControllerRefused("invalid_head_oid") for path in (request.scratch_root, request.scanner_artifact): @@ -554,8 +557,8 @@ def _validate_worker_result( raise ControllerRefused("worker_result_invalid") if ( payload["policy_schema_version"] != 1 - or payload["policy_epoch"] != WORKER_POLICY_EPOCH - or payload["policy_bundle_sha256"] != POLICY_BUNDLE_SHA256 + or payload["policy_epoch"] != EXPECTED_POLICY_EPOCH + or payload["policy_bundle_sha256"] != request.expected_policy_bundle_sha256 or payload["repository_id"] != REPOSITORY_ID or payload["head_oid"] != request.head_oid or payload["scanner_distribution"] != "repo-sentinel-lite" @@ -575,21 +578,28 @@ def _refusal(request: ControllerRequest | None, code: str) -> ControllerResult: if request is not None and _OID.fullmatch(request.head_oid) is not None else None ) - safe_epoch = ( - request.policy_epoch - if request is not None and request.policy_epoch == POLICY_EPOCH + safe_selector = ( + request.policy_selector + if request is not None and request.policy_selector == POLICY_SELECTOR + else None + ) + safe_digest = ( + request.expected_policy_bundle_sha256 + if request is not None and _valid_digest(request.expected_policy_bundle_sha256) else None ) return ControllerResult( - CONTROLLER_SCHEMA_VERSION, - REPOSITORY_ID, - safe_head, - safe_epoch, - POLICY_BUNDLE_SHA256, - None, - None, - ControllerOutcome.INFRASTRUCTURE_REFUSAL, - code if code in _REFUSAL_CODES else "unexpected_failure", + controller_protocol=CONTROLLER_PROTOCOL, + controller_schema_version=CONTROLLER_SCHEMA_VERSION, + repository_id=REPOSITORY_ID, + head_oid=safe_head, + policy_selector=safe_selector, + policy_epoch=EXPECTED_POLICY_EPOCH if safe_selector is not None else None, + policy_bundle_sha256=safe_digest, + worker_result=None, + worker_semantic_sha256=None, + controller_outcome=ControllerOutcome.INFRASTRUCTURE_REFUSAL, + fixed_refusal_code=(code if code in _REFUSAL_CODES else "unexpected_failure"), ) @@ -597,15 +607,17 @@ def _authority_result( request: ControllerRequest, worker: dict[str, object] ) -> ControllerResult: return ControllerResult( - CONTROLLER_SCHEMA_VERSION, - REPOSITORY_ID, - request.head_oid, - POLICY_EPOCH, - POLICY_BUNDLE_SHA256, - worker, - str(worker["semantic_sha256"]), - ControllerOutcome.AUTHORITY_RESULT, - None, + controller_protocol=CONTROLLER_PROTOCOL, + controller_schema_version=CONTROLLER_SCHEMA_VERSION, + repository_id=REPOSITORY_ID, + head_oid=request.head_oid, + policy_selector=POLICY_SELECTOR, + policy_epoch=EXPECTED_POLICY_EPOCH, + policy_bundle_sha256=request.expected_policy_bundle_sha256, + worker_result=worker, + worker_semantic_sha256=str(worker["semantic_sha256"]), + controller_outcome=ControllerOutcome.AUTHORITY_RESULT, + fixed_refusal_code=None, ) @@ -632,7 +644,7 @@ def run_controller( control = _without_aliases(control_root, directory=True) scratch, artifact = _validate_request(request, control) policy = _without_aliases( - control / "policy" / "repo-sentinel-authority" / "v1", + control.joinpath(*POLICY_BUNDLE_ROOT.split("/")), directory=True, ) git_probe(control) @@ -673,7 +685,8 @@ def run_controller( request.head_oid, acquired_root, policy, - POLICY_BUNDLE_SHA256, + request.expected_policy_bundle_sha256, + POLICY_SELECTOR, artifact, worker_root, ) @@ -709,11 +722,13 @@ def run_controller( def result_dict(result: ControllerResult) -> dict[str, object]: return { "controller_outcome": result.controller_outcome.value, + "controller_protocol": result.controller_protocol, "controller_schema_version": result.controller_schema_version, "fixed_refusal_code": result.fixed_refusal_code, "head_oid": result.head_oid, "policy_bundle_sha256": result.policy_bundle_sha256, "policy_epoch": result.policy_epoch, + "policy_selector": result.policy_selector, "repository_id": result.repository_id, "worker_result": result.worker_result, "worker_semantic_sha256": result.worker_semantic_sha256, diff --git a/scripts/repo_sentinel_commit_authoritative.py b/scripts/repo_sentinel_commit_authoritative.py index 6a2dfd9..f654a49 100644 --- a/scripts/repo_sentinel_commit_authoritative.py +++ b/scripts/repo_sentinel_commit_authoritative.py @@ -28,8 +28,6 @@ ) from repo_sentinel_policy_bundle import ( BUNDLE_FILENAMES, - MANDATORY_PROTECTED_PATHS, - POLICY_BUNDLE_MIRROR_ROOT, PolicyBundleRefused, PolicyEntry, RuntimeContract, @@ -194,6 +192,7 @@ class CommitAuthoritativeRequest: repository: Path = field(repr=False, compare=False) policy_bundle_root: Path = field(repr=False, compare=False) expected_policy_bundle_sha256: str = field(repr=False) + policy_selector: str scanner_artifact: Path = field(repr=False, compare=False) scratch_root: Path = field(repr=False, compare=False) @@ -326,6 +325,7 @@ def _valid_request(request: CommitAuthoritativeRequest) -> bool: and _OID.fullmatch(request.head_oid) is not None and type(request.expected_policy_bundle_sha256) is str and _DIGEST.fullmatch(request.expected_policy_bundle_sha256) is not None + and type(request.policy_selector) is str ) @@ -388,7 +388,9 @@ def _admit_protected( ) -> None: approved = {entry.path: entry for entry in bundle.protected_entries} aliases = {portable_v1_alias(path): path for path in approved} - mandatory_aliases = {portable_v1_alias(path) for path in MANDATORY_PROTECTED_PATHS} + mandatory_aliases = { + portable_v1_alias(path) for path in bundle.contract.mandatory_protected_paths + } namespace_aliases = tuple( portable_v1_alias(path) for path in bundle.protected_namespaces ) @@ -408,10 +410,11 @@ def _admit_policy_bundle_mirror( files: dict[str, SnapshotFile], bundle: VerifiedPolicyBundle ) -> None: expected = { - f"{POLICY_BUNDLE_MIRROR_ROOT}{name}": data for name, data in bundle.bundle_files + f"{bundle.contract.mirror_root}{name}": data + for name, data in bundle.bundle_files } expected_aliases = {portable_v1_alias(path): path for path in expected} - mirror_alias_root = portable_v1_alias(POLICY_BUNDLE_MIRROR_ROOT) + mirror_alias_root = portable_v1_alias(bundle.contract.mirror_root) observed = { path: item for path, item in files.items() @@ -451,7 +454,7 @@ def _coverage_inventory( scanned: list[str] = [] ignored: list[str] = [] skipped: list[tuple[str, str]] = [] - mirror_paths = {f"{POLICY_BUNDLE_MIRROR_ROOT}{name}" for name in BUNDLE_FILENAMES} + mirror_paths = {f"{bundle.contract.mirror_root}{name}" for name in BUNDLE_FILENAMES} for path, item in sorted(files.items()): if path in mirror_paths: ignored.append(path) @@ -1023,7 +1026,11 @@ def _execute( ) -> CommitAuthoritativeResult: repository, policy_root, scratch_root = _validate_request(request) try: - bundle = load_policy_bundle(policy_root, request.expected_policy_bundle_sha256) + bundle = load_policy_bundle( + policy_root, + request.expected_policy_bundle_sha256, + policy_selector=request.policy_selector, + ) except PolicyBundleRefused as error: raise WorkerRefused(str(error)) from None if not _runtime_matches(runtime_facts_provider(), bundle.runtime): @@ -1174,6 +1181,7 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--repository", type=Path, required=True) parser.add_argument("--policy-bundle", type=Path, required=True) parser.add_argument("--policy-bundle-sha256", required=True) + parser.add_argument("--policy-selector", required=True) parser.add_argument("--scanner-artifact", type=Path, required=True) parser.add_argument("--scratch-root", type=Path, required=True) return parser @@ -1188,6 +1196,7 @@ def main(argv: list[str] | None = None) -> int: args.repository, args.policy_bundle, args.policy_bundle_sha256, + args.policy_selector, args.scanner_artifact, args.scratch_root, ) diff --git a/scripts/repo_sentinel_policy_bundle.py b/scripts/repo_sentinel_policy_bundle.py index 4d31d7b..cb21755 100644 --- a/scripts/repo_sentinel_policy_bundle.py +++ b/scripts/repo_sentinel_policy_bundle.py @@ -14,12 +14,12 @@ import tomllib from dataclasses import dataclass, field from pathlib import Path, PurePosixPath +from types import MappingProxyType from typing import Any from repo_sentinel_reader import SnapshotFile POLICY_SCHEMA_VERSION = 1 -POLICY_EPOCH = "repo-sentinel-authority-v1" WORKER_POLICY_VERSION = "commit-authoritative-v1" PORTABLE_PATH_POLICY_VERSION = "portable-v1" SCANNER_DISTRIBUTION = "repo-sentinel-lite" @@ -39,7 +39,6 @@ "suppression-manifest.json", } ) -POLICY_BUNDLE_MIRROR_ROOT = "policy/repo-sentinel-authority/v1/" _COMPONENT_FILENAMES = BUNDLE_FILENAMES - {"epoch.json"} _BUNDLE_DOMAIN = b"repo-sentinel-authority-policy-bundle-v1\0" _MAX_BUNDLE_FILE_BYTES = 2 * 1024 * 1024 @@ -87,8 +86,8 @@ ) DEFAULT_MAX_TEXT_FILE_SIZE = 1_048_576 DEFAULT_REQUIRED_FILES = ("README.md", "LICENSE", ".gitignore") -PROTECTED_NAMESPACES = (".github/actions/", ".github/workflows/") -MANDATORY_PROTECTED_PATHS = frozenset( +_PROTECTED_NAMESPACES = (".github/actions/", ".github/workflows/") +_V1_MANDATORY_PROTECTED_PATHS = frozenset( { ".gitattributes", ".reposentinel.toml", @@ -103,6 +102,13 @@ "scripts/test_repo_sentinel_integration.py", } ) +_V2_MANDATORY_PROTECTED_PATHS = frozenset( + { + *_V1_MANDATORY_PROTECTED_PATHS, + "scripts/repo_sentinel_authority_bootstrap.sh", + "scripts/repo_sentinel_authority_controller.py", + } +) EXCLUSION_REASONS = frozenset( {"config_ignore", "binary", "oversize", "unsupported_encoding"} ) @@ -128,6 +134,72 @@ class RuntimeContract: architecture: str +@dataclass(frozen=True, slots=True) +class PolicyBundleContract: + selector: str + schema_version: int + policy_epoch: str + worker_policy_version: str + portable_path_policy_version: str + mirror_root: str + mandatory_protected_paths: frozenset[str] + protected_namespaces: tuple[str, ...] + scanner_distribution: str + scanner_version: str + scanner_artifact_sha256: str + + +V1_POLICY_CONTRACT = PolicyBundleContract( + selector="v1", + schema_version=POLICY_SCHEMA_VERSION, + policy_epoch="repo-sentinel-authority-v1", + worker_policy_version=WORKER_POLICY_VERSION, + portable_path_policy_version=PORTABLE_PATH_POLICY_VERSION, + mirror_root="policy/repo-sentinel-authority/v1/", + mandatory_protected_paths=_V1_MANDATORY_PROTECTED_PATHS, + protected_namespaces=_PROTECTED_NAMESPACES, + scanner_distribution=SCANNER_DISTRIBUTION, + scanner_version=SCANNER_VERSION, + scanner_artifact_sha256=SCANNER_WHEEL_SHA256, +) +V2_POLICY_CONTRACT = PolicyBundleContract( + selector="v2", + schema_version=POLICY_SCHEMA_VERSION, + policy_epoch="repo-sentinel-authority-v2", + worker_policy_version=WORKER_POLICY_VERSION, + portable_path_policy_version=PORTABLE_PATH_POLICY_VERSION, + mirror_root="policy/repo-sentinel-authority/v2/", + mandatory_protected_paths=_V2_MANDATORY_PROTECTED_PATHS, + protected_namespaces=_PROTECTED_NAMESPACES, + scanner_distribution=SCANNER_DISTRIBUTION, + scanner_version=SCANNER_VERSION, + scanner_artifact_sha256=SCANNER_WHEEL_SHA256, +) +_TRUSTED_POLICY_CONTRACTS = MappingProxyType( + { + V1_POLICY_CONTRACT.selector: V1_POLICY_CONTRACT, + V2_POLICY_CONTRACT.selector: V2_POLICY_CONTRACT, + } +) + +# Historical v1 names remain public compatibility aliases. +POLICY_EPOCH = V1_POLICY_CONTRACT.policy_epoch +POLICY_BUNDLE_MIRROR_ROOT = V1_POLICY_CONTRACT.mirror_root +MANDATORY_PROTECTED_PATHS = V1_POLICY_CONTRACT.mandatory_protected_paths +PROTECTED_NAMESPACES = V1_POLICY_CONTRACT.protected_namespaces + + +def trusted_policy_contract(selector: str) -> PolicyBundleContract: + """Return one reviewed policy contract without accepting caller composites.""" + + if type(selector) is not str: + raise PolicyBundleRefused("policy_schema_unsupported") + try: + return _TRUSTED_POLICY_CONTRACTS[selector] + except KeyError: + raise PolicyBundleRefused("policy_schema_unsupported") from None + + @dataclass(frozen=True, slots=True) class VerifiedPolicyBundle: schema_version: int @@ -139,6 +211,7 @@ class VerifiedPolicyBundle: scanner_version: str scanner_artifact_sha256: str runtime: RuntimeContract + contract: PolicyBundleContract = field(repr=False) scanner_config: bytes = field(repr=False) baseline: bytes = field(repr=False) bundle_files: tuple[tuple[str, bytes], ...] = field(repr=False) @@ -357,8 +430,14 @@ def _config_contract(data: bytes) -> tuple[tuple[str, ...], int, tuple[str, ...] return effective, max_size, tuple(required) -def load_policy_bundle(root: Path, expected_sha256: str) -> VerifiedPolicyBundle: - if _DIGEST.fullmatch(expected_sha256) is None: +def load_policy_bundle( + root: Path, + expected_sha256: str, + *, + policy_selector: str = "v1", +) -> VerifiedPolicyBundle: + contract = trusted_policy_contract(policy_selector) + if type(expected_sha256) is not str or _DIGEST.fullmatch(expected_sha256) is None: raise PolicyBundleRefused("policy_bundle_invalid") files = _read_bundle(root) actual_bundle_sha256 = compute_bundle_sha256(files) @@ -378,7 +457,7 @@ def load_policy_bundle(root: Path, expected_sha256: str) -> VerifiedPolicyBundle "component_sha256", }, ) - if epoch["schema_version"] != POLICY_SCHEMA_VERSION: + if epoch["schema_version"] != contract.schema_version: raise PolicyBundleRefused("policy_schema_unsupported") scanner = epoch["scanner"] runtime = epoch["runtime"] @@ -401,7 +480,7 @@ def load_policy_bundle(root: Path, expected_sha256: str) -> VerifiedPolicyBundle dependencies = _json_object(files["dependencies.json"]) _exact_keys(dependencies, {"schema_version", "scanner", "runtime_dependencies"}) dependency_scanner = dependencies["scanner"] - if dependencies["schema_version"] != POLICY_SCHEMA_VERSION or not isinstance( + if dependencies["schema_version"] != contract.schema_version or not isinstance( dependency_scanner, dict ): raise PolicyBundleRefused("policy_bundle_invalid") @@ -418,21 +497,21 @@ def load_policy_bundle(root: Path, expected_sha256: str) -> VerifiedPolicyBundle protected = _json_object(files["protected-manifest.json"]) _exact_keys(protected, {"schema_version", "namespaces", "entries"}) namespaces = _string_tuple(protected["namespaces"]) - if protected["schema_version"] != POLICY_SCHEMA_VERSION or namespaces != tuple( - sorted(PROTECTED_NAMESPACES) + if protected["schema_version"] != contract.schema_version or namespaces != tuple( + sorted(contract.protected_namespaces) ): raise PolicyBundleRefused("policy_bundle_invalid") for namespace in namespaces: _logical_path(namespace, directory=True) protected_entries = _policy_entries(protected["entries"], reasons=False) - if not MANDATORY_PROTECTED_PATHS.issubset( + if not contract.mandatory_protected_paths.issubset( {entry.path for entry in protected_entries} ): raise PolicyBundleRefused("policy_bundle_invalid") suppressions = _json_object(files["suppression-manifest.json"]) _exact_keys(suppressions, {"schema_version", "entries"}) - if suppressions["schema_version"] != POLICY_SCHEMA_VERSION: + if suppressions["schema_version"] != contract.schema_version: raise PolicyBundleRefused("policy_bundle_invalid") suppression_entries = _policy_entries(suppressions["entries"], reasons=False) @@ -446,7 +525,7 @@ def load_policy_bundle(root: Path, expected_sha256: str) -> VerifiedPolicyBundle "approved_exclusions", }, ) - if coverage["schema_version"] != POLICY_SCHEMA_VERSION: + if coverage["schema_version"] != contract.schema_version: raise PolicyBundleRefused("policy_bundle_invalid") effective, max_size, required = _config_contract(files["scanner-config.toml"]) if ( @@ -475,27 +554,29 @@ def load_policy_bundle(root: Path, expected_sha256: str) -> VerifiedPolicyBundle ): raise PolicyBundleRefused("policy_bundle_invalid") if ( - epoch["policy_epoch"] != POLICY_EPOCH - or epoch["semantic_worker_policy_version"] != WORKER_POLICY_VERSION - or epoch["portable_path_policy_version"] != PORTABLE_PATH_POLICY_VERSION - or scanner["distribution"] != SCANNER_DISTRIBUTION - or scanner["version"] != SCANNER_VERSION - or scanner["artifact_sha256"] != SCANNER_WHEEL_SHA256 + epoch["policy_epoch"] != contract.policy_epoch + or epoch["semantic_worker_policy_version"] != contract.worker_policy_version + or epoch["portable_path_policy_version"] + != contract.portable_path_policy_version + or scanner["distribution"] != contract.scanner_distribution + or scanner["version"] != contract.scanner_version + or scanner["artifact_sha256"] != contract.scanner_artifact_sha256 ): raise PolicyBundleRefused("policy_schema_unsupported") _json_object(files["baseline.json"]) return VerifiedPolicyBundle( - schema_version=POLICY_SCHEMA_VERSION, - policy_epoch=POLICY_EPOCH, + schema_version=contract.schema_version, + policy_epoch=contract.policy_epoch, bundle_sha256=actual_bundle_sha256, - worker_policy_version=WORKER_POLICY_VERSION, - portable_path_policy_version=PORTABLE_PATH_POLICY_VERSION, + worker_policy_version=contract.worker_policy_version, + portable_path_policy_version=contract.portable_path_policy_version, scanner_distribution=str(scanner["distribution"]), scanner_version=str(scanner["version"]), scanner_artifact_sha256=_digest(scanner["artifact_sha256"]), runtime=runtime_contract, + contract=contract, scanner_config=files["scanner-config.toml"], baseline=files["baseline.json"], bundle_files=tuple(sorted(files.items())), @@ -602,12 +683,21 @@ def build_policy_bundle( root: Path, runtime: RuntimeContract, *, - scanner_artifact_sha256: str = SCANNER_WHEEL_SHA256, + policy_selector: str = "v1", + scanner_artifact_sha256: str | None = None, ) -> str: """Generate a candidate bundle from an explicitly staged repository state.""" - mirror_paths = {f"{POLICY_BUNDLE_MIRROR_ROOT}{name}" for name in BUNDLE_FILENAMES} - mirror_alias_root = portable_v1_alias(POLICY_BUNDLE_MIRROR_ROOT) + contract = trusted_policy_contract(policy_selector) + artifact_sha256 = ( + contract.scanner_artifact_sha256 + if scanner_artifact_sha256 is None + else scanner_artifact_sha256 + ) + if artifact_sha256 != contract.scanner_artifact_sha256: + raise PolicyBundleRefused("policy_source_invalid") + mirror_paths = {f"{contract.mirror_root}{name}" for name in BUNDLE_FILENAMES} + mirror_alias_root = portable_v1_alias(contract.mirror_root) if any( portable_v1_alias(item.path).startswith(mirror_alias_root) and item.path not in mirror_paths @@ -616,17 +706,17 @@ def build_policy_bundle( raise PolicyBundleRefused("policy_source_invalid") source_files = tuple(item for item in files if item.path not in mirror_paths) by_path = {item.path: item for item in source_files} - if len(by_path) != len(source_files) or not MANDATORY_PROTECTED_PATHS.issubset( - by_path - ): + if len(by_path) != len( + source_files + ) or not contract.mandatory_protected_paths.issubset(by_path): raise PolicyBundleRefused("policy_source_invalid") scanner_config = by_path[".reposentinel.toml"].data baseline = by_path[".reposentinel-baseline.json"].data effective, max_size, _required = _config_contract(scanner_config) protected_paths = sorted( - MANDATORY_PROTECTED_PATHS - | {path for path in by_path if path.startswith(PROTECTED_NAMESPACES)} + contract.mandatory_protected_paths + | {path for path in by_path if path.startswith(contract.protected_namespaces)} ) protected_entries = [_entry(by_path[path]) for path in protected_paths] suppression_entries = [ @@ -649,20 +739,20 @@ def build_policy_bundle( "baseline.json": baseline, "protected-manifest.json": _render( { - "schema_version": POLICY_SCHEMA_VERSION, - "namespaces": sorted(PROTECTED_NAMESPACES), + "schema_version": contract.schema_version, + "namespaces": sorted(contract.protected_namespaces), "entries": [_entry_dict(entry) for entry in protected_entries], } ), "suppression-manifest.json": _render( { - "schema_version": POLICY_SCHEMA_VERSION, + "schema_version": contract.schema_version, "entries": [_entry_dict(entry) for entry in suppression_entries], } ), "coverage-policy.json": _render( { - "schema_version": POLICY_SCHEMA_VERSION, + "schema_version": contract.schema_version, "effective_ignore_globs": list(effective), "max_text_file_size": max_size, "approved_exclusions": [ @@ -672,25 +762,25 @@ def build_policy_bundle( ), "dependencies.json": _render( { - "schema_version": POLICY_SCHEMA_VERSION, + "schema_version": contract.schema_version, "scanner": { - "distribution": SCANNER_DISTRIBUTION, - "version": SCANNER_VERSION, - "wheel_sha256": scanner_artifact_sha256, + "distribution": contract.scanner_distribution, + "version": contract.scanner_version, + "wheel_sha256": artifact_sha256, }, "runtime_dependencies": [], } ), } epoch = { - "schema_version": POLICY_SCHEMA_VERSION, - "policy_epoch": POLICY_EPOCH, - "semantic_worker_policy_version": WORKER_POLICY_VERSION, - "portable_path_policy_version": PORTABLE_PATH_POLICY_VERSION, + "schema_version": contract.schema_version, + "policy_epoch": contract.policy_epoch, + "semantic_worker_policy_version": contract.worker_policy_version, + "portable_path_policy_version": contract.portable_path_policy_version, "scanner": { - "distribution": SCANNER_DISTRIBUTION, - "version": SCANNER_VERSION, - "artifact_sha256": scanner_artifact_sha256, + "distribution": contract.scanner_distribution, + "version": contract.scanner_version, + "artifact_sha256": artifact_sha256, }, "runtime": { "implementation": runtime.implementation, @@ -764,9 +854,11 @@ def _parser() -> argparse.ArgumentParser: build.add_argument("--python-version", required=True) build.add_argument("--os-family", required=True) build.add_argument("--architecture", required=True) + build.add_argument("--policy-selector", default="v1") verify = subparsers.add_parser("verify") verify.add_argument("--bundle", type=Path, required=True) verify.add_argument("--expected-sha256") + verify.add_argument("--policy-selector", default="v1") return parser @@ -784,11 +876,16 @@ def main(argv: list[str] | None = None) -> int: _staged_files(args.repository.resolve()), args.bundle.resolve(), runtime, + policy_selector=args.policy_selector, ) else: digest = bundle_sha256(args.bundle.resolve()) expected = args.expected_sha256 or digest - load_policy_bundle(args.bundle.resolve(), expected) + load_policy_bundle( + args.bundle.resolve(), + expected, + policy_selector=args.policy_selector, + ) except PolicyBundleRefused as error: print(str(error), file=sys.stderr) return 2 @@ -807,12 +904,15 @@ def main(argv: list[str] | None = None) -> int: "POLICY_EPOCH", "POLICY_SCHEMA_VERSION", "PolicyBundleRefused", + "PolicyBundleContract", "PolicyEntry", "RuntimeContract", "SCANNER_DISTRIBUTION", "SCANNER_VERSION", "SCANNER_WHEEL_SHA256", "VerifiedPolicyBundle", + "V1_POLICY_CONTRACT", + "V2_POLICY_CONTRACT", "build_policy_bundle", "bundle_sha256", "contains_inline_suppression", @@ -821,4 +921,5 @@ def main(argv: list[str] | None = None) -> int: "portable_v1_alias", "scanner_exclusion_reason", "sha256_bytes", + "trusted_policy_contract", ] diff --git a/tests/test_repo_sentinel_authority_controller.py b/tests/test_repo_sentinel_authority_controller.py index 5596daf..57e55c0 100644 --- a/tests/test_repo_sentinel_authority_controller.py +++ b/tests/test_repo_sentinel_authority_controller.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import inspect import json import os import subprocess @@ -24,6 +25,7 @@ HEAD_OID = "b" * 40 OTHER_OID = "c" * 40 EXACT_RUNTIME = controller.RuntimeFacts("cpython", "3.12.3", "Linux", "x86_64") +EXTERNAL_POLICY_DIGEST = "e" * 64 class FakeAcquisitionRefused(ValueError): @@ -35,6 +37,8 @@ def worker_payload( *, head_oid: str = HEAD_OID, refusal_code: str | None = None, + policy_bundle_sha256: str = EXTERNAL_POLICY_DIGEST, + policy_epoch: str = controller.EXPECTED_POLICY_EPOCH, ) -> dict[str, object]: semantic = verdict != "INFRASTRUCTURE_REFUSAL" report = semantic and verdict != "POLICY_ADMISSION_FAILURE" @@ -45,8 +49,8 @@ def worker_payload( "files_scanner_skipped": 0, "files_total": 3 if semantic else 0, "head_oid": head_oid if semantic else "", - "policy_bundle_sha256": (controller.POLICY_BUNDLE_SHA256 if semantic else None), - "policy_epoch": controller.WORKER_POLICY_EPOCH if semantic else None, + "policy_bundle_sha256": policy_bundle_sha256 if semantic else None, + "policy_epoch": policy_epoch if semantic else None, "policy_schema_version": 1 if semantic else None, "protected_manifest_sha256": "b" * 64 if semantic else None, "refusal_code": refusal_code, @@ -75,6 +79,7 @@ def __init__(self) -> None: self.scratch = self.root / "scratch" self.artifact = self.root / "scanner.whl" (self.control / "policy" / "repo-sentinel-authority" / "v1").mkdir(parents=True) + (self.control / "policy" / "repo-sentinel-authority" / "v2").mkdir(parents=True) self.scratch.mkdir() self.artifact.write_bytes(b"fixture") self.request = controller.ControllerRequest( @@ -84,8 +89,8 @@ def __init__(self) -> None: controller.REMOTE_URL, 7, HEAD_OID, - controller.POLICY_EPOCH, - controller.POLICY_BUNDLE_SHA256, + controller.POLICY_SELECTOR, + EXTERNAL_POLICY_DIGEST, self.scratch, self.artifact, ) @@ -176,8 +181,8 @@ def argv(self, harness: Harness) -> list[str]: str(request.pull_number), "--head-oid", request.head_oid, - "--policy-epoch", - request.policy_epoch, + "--policy-selector", + request.policy_selector, "--policy-bundle-sha256", request.expected_policy_bundle_sha256, "--scratch-root", @@ -205,6 +210,10 @@ def test_strict_parser_accepts_only_complete_unique_option_pairs(self) -> None: def test_repository_policy_and_head_inputs_fail_closed(self) -> None: harness = self.harness() + + class StringAlias(str): + pass + cases = { "repository-id": ( replace(harness.request, repository_id=1), @@ -223,12 +232,28 @@ def test_repository_policy_and_head_inputs_fail_closed(self) -> None: "repository_identity_mismatch", ), "epoch": ( - replace(harness.request, policy_epoch="v2"), + replace(harness.request, policy_selector="v1"), "unknown_policy_epoch", ), "digest": ( - replace(harness.request, expected_policy_bundle_sha256="0" * 64), - "policy_bundle_mismatch", + replace(harness.request, expected_policy_bundle_sha256="A" * 64), + "invalid_request", + ), + "digest-subclass": ( + replace( + harness.request, + expected_policy_bundle_sha256=StringAlias( + harness.request.expected_policy_bundle_sha256 + ), + ), + "invalid_request", + ), + "selector-subclass": ( + replace( + harness.request, + policy_selector=StringAlias(controller.POLICY_SELECTOR), + ), + "invalid_request", ), "head": ( replace(harness.request, head_oid="HEAD\n::error::secret"), @@ -268,11 +293,68 @@ def test_scratch_must_be_absolute_non_aliasing_and_outside_control(self) -> None class OrchestrationTests(HarnessTestCase): + def test_any_well_formed_external_digest_is_passed_through_unchanged(self) -> None: + harness = self.harness() + digest = "0" * 64 + request = replace( + harness.request, + expected_policy_bundle_sha256=digest, + ) + harness.worker = worker_payload(policy_bundle_sha256=digest) + + result = harness.run(request) + + self.assertEqual(result.controller_outcome.value, "AUTHORITY_RESULT") + self.assertEqual(result.policy_bundle_sha256, digest) + self.assertEqual(result.worker_result["policy_bundle_sha256"], digest) + assert harness.worker_arguments is not None + self.assertEqual(harness.worker_arguments[4], digest) + + def test_worker_digest_or_epoch_disagreement_is_not_authority(self) -> None: + for worker in ( + worker_payload(policy_bundle_sha256="0" * 64), + worker_payload(policy_epoch="repo-sentinel-authority-v1"), + ): + with self.subTest(worker=worker): + harness = self.harness() + harness.worker = worker + result = harness.run() + self.assertEqual( + result.controller_outcome.value, + "INFRASTRUCTURE_REFUSAL", + ) + self.assertEqual(result.fixed_refusal_code, "worker_result_invalid") + self.assertIsNone(result.worker_result) + self.assertEqual( + result.policy_bundle_sha256, + harness.request.expected_policy_bundle_sha256, + ) + + def test_caller_cannot_select_policy_bundle_path(self) -> None: + fields = controller.ControllerRequest.__dataclass_fields__ + self.assertNotIn("policy_bundle_root", fields) + self.assertNotIn("policy_path", fields) + self.assertEqual( + controller.POLICY_BUNDLE_ROOT, + "policy/repo-sentinel-authority/v2", + ) + source = inspect.getsource(controller.run_controller) + self.assertIn( + 'control.joinpath(*POLICY_BUNDLE_ROOT.split("/"))', + source, + ) + self.assertNotIn(".iterdir()", source) + def test_pass_is_bounded_and_worker_handoff_is_commit_intrinsic(self) -> None: harness = self.harness() result = harness.run() self.assertEqual(result.controller_outcome.value, "AUTHORITY_RESULT") + self.assertEqual(result.controller_protocol, controller.CONTROLLER_PROTOCOL) + self.assertEqual(result.controller_schema_version, 1) + self.assertEqual(result.policy_selector, controller.POLICY_SELECTOR) + self.assertEqual(result.policy_epoch, controller.EXPECTED_POLICY_EPOCH) + self.assertEqual(result.policy_bundle_sha256, EXTERNAL_POLICY_DIGEST) self.assertIsNone(result.fixed_refusal_code) self.assertEqual(result.worker_result, harness.worker) self.assert_worker_semantic_digest( @@ -287,10 +369,11 @@ def test_pass_is_bounded_and_worker_handoff_is_commit_intrinsic(self) -> None: self.assertEqual(arguments[1], HEAD_OID) self.assertEqual( arguments[3], - harness.control / "policy" / "repo-sentinel-authority" / "v1", + harness.control / "policy" / "repo-sentinel-authority" / "v2", ) - self.assertEqual(arguments[4], controller.POLICY_BUNDLE_SHA256) - self.assertEqual(arguments[5], harness.artifact) + self.assertEqual(arguments[4], EXTERNAL_POLICY_DIGEST) + self.assertEqual(arguments[5], controller.POLICY_SELECTOR) + self.assertEqual(arguments[6], harness.artifact) self.assertNotIn(harness.request.pull_number, arguments[0:2]) self.assertEqual(list(harness.scratch.iterdir()), []) @@ -345,7 +428,7 @@ class StringAlias(str): ("policy_schema_version", 1.0), ("repository_id", float(controller.REPOSITORY_ID)), ("verdict", StringAlias("PASS")), - ("policy_epoch", StringAlias(controller.WORKER_POLICY_EPOCH)), + ("policy_epoch", StringAlias(controller.EXPECTED_POLICY_EPOCH)), ("head_oid", StringAlias(HEAD_OID)), ("scanner_distribution", StringAlias("repo-sentinel-lite")), ("scanner_version", StringAlias("0.8.1")), diff --git a/tests/test_repo_sentinel_commit_authoritative.py b/tests/test_repo_sentinel_commit_authoritative.py index 873455c..45e4373 100644 --- a/tests/test_repo_sentinel_commit_authoritative.py +++ b/tests/test_repo_sentinel_commit_authoritative.py @@ -11,6 +11,7 @@ import unittest from collections.abc import Callable, Iterator from contextlib import contextmanager +from dataclasses import replace from pathlib import Path from unittest.mock import patch @@ -38,7 +39,9 @@ def snapshot_file( return SnapshotFile(path, mode, oid, data) -def minimum_files() -> tuple[SnapshotFile, ...]: +def minimum_files( + contract: policy.PolicyBundleContract = policy.V1_POLICY_CONTRACT, +) -> tuple[SnapshotFile, ...]: files = [ snapshot_file("README.md"), snapshot_file("LICENSE"), @@ -54,7 +57,7 @@ def minimum_files() -> tuple[SnapshotFile, ...]: existing = {item.path for item in files} files.extend( snapshot_file(path) - for path in policy.MANDATORY_PROTECTED_PATHS + for path in contract.mandatory_protected_paths if path not in existing ) unique = {item.path: item for item in files} @@ -94,7 +97,12 @@ def with_file( class Harness: - def __init__(self, files: tuple[SnapshotFile, ...] | None = None) -> None: + def __init__( + self, + files: tuple[SnapshotFile, ...] | None = None, + *, + policy_selector: str = "v1", + ) -> None: self._temporary = tempfile.TemporaryDirectory(prefix="commit-authority-test-") self.root = Path(self._temporary.name) self.repository = self.root / "repository" @@ -103,20 +111,28 @@ def __init__(self, files: tuple[SnapshotFile, ...] | None = None) -> None: self.artifact = self.root / "scanner.whl" for directory in (self.repository, self.scratch): directory.mkdir() - requested_files = files or minimum_files() + self.contract = policy.trusted_policy_contract(policy_selector) + requested_files = files or minimum_files(self.contract) source_files = tuple( item for item in requested_files - if not item.path.startswith(policy.POLICY_BUNDLE_MIRROR_ROOT) + if not item.path.startswith(self.contract.mirror_root) ) self.artifact.write_bytes(b"test wheel bytes") self.bundle_digest = policy.build_policy_bundle( - source_files, self.policy_root, RUNTIME + source_files, + self.policy_root, + RUNTIME, + policy_selector=policy_selector, + ) + self.bundle = policy.load_policy_bundle( + self.policy_root, + self.bundle_digest, + policy_selector=policy_selector, ) - self.bundle = policy.load_policy_bundle(self.policy_root, self.bundle_digest) mirror_files = tuple( snapshot_file( - f"{policy.POLICY_BUNDLE_MIRROR_ROOT}{name}", + f"{self.contract.mirror_root}{name}", data, ) for name, data in self.bundle.bundle_files @@ -131,6 +147,7 @@ def __init__(self, files: tuple[SnapshotFile, ...] | None = None) -> None: self.repository, self.policy_root, self.bundle_digest, + policy_selector, self.artifact, self.scratch, ) @@ -149,7 +166,7 @@ def with_policy_mirror( mirror = tuple( item for item in self.files - if item.path.startswith(policy.POLICY_BUNDLE_MIRROR_ROOT) + if item.path.startswith(self.contract.mirror_root) ) return tuple(sorted((*files, *mirror), key=lambda item: item.path)) @@ -252,8 +269,13 @@ def run( class HarnessTestCase(unittest.TestCase): - def harness(self, files: tuple[SnapshotFile, ...] | None = None) -> Harness: - harness = Harness(files) + def harness( + self, + files: tuple[SnapshotFile, ...] | None = None, + *, + policy_selector: str = "v1", + ) -> Harness: + harness = Harness(files, policy_selector=policy_selector) self.addCleanup(harness.cleanup) return harness @@ -263,6 +285,7 @@ def test_request_identity_excludes_pull_and_base_fields(self) -> None: fields = inspect.signature(authoritative.CommitAuthoritativeRequest).parameters self.assertIn("repository_id", fields) self.assertIn("head_oid", fields) + self.assertIn("policy_selector", fields) self.assertNotIn("pull_number", fields) self.assertNotIn("base_oid", fields) @@ -508,6 +531,115 @@ def test_bundle_mirror_mutations_fail_before_materialization(self) -> None: self.assertEqual(harness.materializer_calls, 0) +class V2PolicyContractTests(HarnessTestCase): + def test_v2_contract_passes_with_its_exact_mirror_and_controls(self) -> None: + harness = self.harness(policy_selector="v2") + + result = harness.run() + + self.assertEqual(result.verdict, authoritative.CommitAuthorityVerdict.PASS) + self.assertEqual(result.policy_epoch, "repo-sentinel-authority-v2") + self.assertEqual(harness.bundle.contract, policy.V2_POLICY_CONTRACT) + + def test_v2_bootstrap_controller_and_namespace_members_are_protected( + self, + ) -> None: + contract = policy.V2_POLICY_CONTRACT + original = minimum_files(contract) + trusted = self.harness(original, policy_selector="v2") + cases = { + "bootstrap": replace_file( + original, + "scripts/repo_sentinel_authority_bootstrap.sh", + data=b"changed\n", + ), + "controller": replace_file( + original, + "scripts/repo_sentinel_authority_controller.py", + data=b"changed\n", + ), + "controller-alias": with_file( + original, + snapshot_file("scripts/Repo_Sentinel_Authority_Controller.py"), + ), + "workflow": with_file( + original, + snapshot_file(".github/workflows/unreviewed.yml"), + ), + "action": with_file( + original, + snapshot_file(".github/actions/unreviewed/action.yml"), + ), + } + for name, mutated in cases.items(): + with self.subTest(name=name): + trusted.snapshot = Snapshot( + HEAD_OID, + "c" * 40, + trusted.with_policy_mirror(mutated), + ) + result = trusted.run() + self.assertEqual( + result.verdict, + authoritative.CommitAuthorityVerdict.POLICY_ADMISSION_FAILURE, + ) + self.assertEqual(result.refusal_code, "protected_control_mismatch") + self.assertEqual(trusted.materializer_calls, 0) + + def test_v2_rejects_v1_mirror_and_v1_rejects_v2_mirror(self) -> None: + for selector, wrong_root in ( + ("v2", policy.V1_POLICY_CONTRACT.mirror_root), + ("v1", policy.V2_POLICY_CONTRACT.mirror_root), + ): + with self.subTest(selector=selector): + harness = self.harness(policy_selector=selector) + right_root = harness.contract.mirror_root + wrong = tuple( + snapshot_file( + item.path.replace(right_root, wrong_root, 1), + item.data, + item.mode, + ) + if item.path.startswith(right_root) + else item + for item in harness.files + ) + harness.snapshot = Snapshot(HEAD_OID, "c" * 40, wrong) + result = harness.run() + self.assertEqual( + result.verdict, + authoritative.CommitAuthorityVerdict.POLICY_ADMISSION_FAILURE, + ) + self.assertEqual( + result.refusal_code, + "policy_bundle_mirror_mismatch", + ) + self.assertEqual(harness.materializer_calls, 0) + + def test_epoch_path_cross_wiring_and_unknown_selector_fail_closed(self) -> None: + for bundle_selector, request_selector, refusal in ( + ("v1", "v2", "policy_bundle_invalid"), + ("v2", "v1", "policy_schema_unsupported"), + ("v2", "unknown", "policy_schema_unsupported"), + ): + with self.subTest( + bundle_selector=bundle_selector, + request_selector=request_selector, + ): + harness = self.harness(policy_selector=bundle_selector) + harness.request = replace( + harness.request, + policy_selector=request_selector, + ) + result = harness.run() + self.assertEqual( + result.verdict, + authoritative.CommitAuthorityVerdict.INFRASTRUCTURE_REFUSAL, + ) + self.assertEqual(result.refusal_code, refusal) + self.assertEqual(harness.materializer_calls, 0) + + class SuppressionAdmissionTests(HarnessTestCase): def test_suppression_mutations_fail_before_scanner(self) -> None: original = minimum_files() diff --git a/tests/test_repo_sentinel_policy_contracts.py b/tests/test_repo_sentinel_policy_contracts.py new file mode 100644 index 0000000..f61c968 --- /dev/null +++ b/tests/test_repo_sentinel_policy_contracts.py @@ -0,0 +1,274 @@ +"""Compatibility and self-reference tests for trusted policy contracts.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from dataclasses import FrozenInstanceError +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import repo_sentinel_policy_bundle as policy # noqa: E402 +from tests.test_repo_sentinel_commit_authoritative import ( # noqa: E402 + RUNTIME, + minimum_files, + replace_file, +) + +V1_BUNDLE_SHA256 = "6f25ebb773ce1453e8de623bca5aaecc936f1f188288f8df20aedeadb3bf4612" +RUNTIME_SOURCES = ( + "scripts/repo_sentinel_authority_bootstrap.sh", + "scripts/repo_sentinel_authority_controller.py", + "scripts/repo_sentinel_commit_authoritative.py", + "scripts/repo_sentinel_policy_bundle.py", +) + + +def v2_source_files(): + files = minimum_files(policy.V2_POLICY_CONTRACT) + for path in RUNTIME_SOURCES: + files = replace_file( + files, + path, + data=(ROOT / path).read_bytes(), + mode="100755" if path.endswith("bootstrap.sh") else "100644", + ) + return files + + +def rewrite_component(root: Path, name: str, value: object) -> str: + data = policy._render(value) + (root / name).write_bytes(data) + epoch_path = root / "epoch.json" + epoch = json.loads(epoch_path.read_text(encoding="utf-8")) + epoch["component_sha256"][name] = policy.sha256_bytes(data) + epoch_path.write_bytes(policy._render(epoch)) + return policy.bundle_sha256(root) + + +class TrustedContractTests(unittest.TestCase): + def test_contracts_are_immutable_and_only_known_selectors_resolve(self) -> None: + self.assertIs( + policy.trusted_policy_contract("v1"), + policy.V1_POLICY_CONTRACT, + ) + self.assertIs( + policy.trusted_policy_contract("v2"), + policy.V2_POLICY_CONTRACT, + ) + with self.assertRaises(FrozenInstanceError): + policy.V2_POLICY_CONTRACT.policy_epoch = "changed" # type: ignore[misc] + for candidate in ("latest", "repo-sentinel-authority-v2", 2): + with self.subTest(candidate=candidate): + with self.assertRaisesRegex( + policy.PolicyBundleRefused, + "policy_schema_unsupported", + ): + policy.trusted_policy_contract(candidate) # type: ignore[arg-type] + + def test_v1_historical_bundle_reproduces_exact_contract(self) -> None: + contract = policy.V1_POLICY_CONTRACT + root = ROOT.joinpath(*contract.mirror_root.rstrip("/").split("/")) + + digest = policy.bundle_sha256(root) + bundle = policy.load_policy_bundle( + root, + V1_BUNDLE_SHA256, + policy_selector="v1", + ) + epoch = json.loads((root / "epoch.json").read_text(encoding="utf-8")) + + self.assertEqual(digest, V1_BUNDLE_SHA256) + self.assertIs(bundle.contract, contract) + self.assertEqual(bundle.schema_version, 1) + self.assertEqual(bundle.policy_epoch, "repo-sentinel-authority-v1") + self.assertEqual(bundle.worker_policy_version, "commit-authoritative-v1") + self.assertEqual(bundle.portable_path_policy_version, "portable-v1") + self.assertEqual(bundle.scanner_distribution, "repo-sentinel-lite") + self.assertEqual(bundle.scanner_version, "0.8.1") + self.assertEqual( + bundle.scanner_artifact_sha256, + policy.SCANNER_WHEEL_SHA256, + ) + self.assertEqual( + bundle.runtime, + policy.RuntimeContract("cpython", "3.12.3", "Linux", "x86_64"), + ) + self.assertEqual(bundle.contract.mirror_root, contract.mirror_root) + self.assertEqual( + bundle.contract.mandatory_protected_paths, + policy.MANDATORY_PROTECTED_PATHS, + ) + for name, expected in epoch["component_sha256"].items(): + self.assertEqual(policy.sha256_bytes((root / name).read_bytes()), expected) + + def test_synthetic_v2_is_deterministic_and_keeps_v1_algorithms(self) -> None: + files = v2_source_files() + with tempfile.TemporaryDirectory(prefix="synthetic-policy-v2-") as temporary: + first = Path(temporary) / "first" + second = Path(temporary) / "second" + first_digest = policy.build_policy_bundle( + files, + first, + RUNTIME, + policy_selector="v2", + ) + second_digest = policy.build_policy_bundle( + files, + second, + RUNTIME, + policy_selector="v2", + ) + bundle = policy.load_policy_bundle( + first, + first_digest, + policy_selector="v2", + ) + + self.assertEqual(first_digest, second_digest) + self.assertEqual( + {name: (first / name).read_bytes() for name in policy.BUNDLE_FILENAMES}, + { + name: (second / name).read_bytes() + for name in policy.BUNDLE_FILENAMES + }, + ) + self.assertIs(bundle.contract, policy.V2_POLICY_CONTRACT) + self.assertEqual(bundle.schema_version, 1) + self.assertEqual(bundle.policy_epoch, "repo-sentinel-authority-v2") + self.assertEqual(bundle.worker_policy_version, "commit-authoritative-v1") + self.assertEqual(bundle.portable_path_policy_version, "portable-v1") + self.assertEqual(bundle.scanner_distribution, "repo-sentinel-lite") + self.assertEqual(bundle.scanner_version, "0.8.1") + self.assertEqual( + bundle.scanner_artifact_sha256, + policy.SCANNER_WHEEL_SHA256, + ) + + def test_epoch_path_cross_wiring_and_digest_mismatch_fail_closed(self) -> None: + v1 = policy.V1_POLICY_CONTRACT + v1_root = ROOT.joinpath(*v1.mirror_root.rstrip("/").split("/")) + with self.assertRaisesRegex( + policy.PolicyBundleRefused, + "policy_bundle_invalid", + ): + policy.load_policy_bundle( + v1_root, + V1_BUNDLE_SHA256, + policy_selector="v2", + ) + + with tempfile.TemporaryDirectory(prefix="synthetic-policy-v2-") as temporary: + root = Path(temporary) / "bundle" + digest = policy.build_policy_bundle( + v2_source_files(), + root, + RUNTIME, + policy_selector="v2", + ) + with self.assertRaisesRegex( + policy.PolicyBundleRefused, + "policy_schema_unsupported", + ): + policy.load_policy_bundle(root, digest, policy_selector="v1") + with self.assertRaisesRegex( + policy.PolicyBundleRefused, + "policy_bundle_mismatch", + ): + policy.load_policy_bundle(root, "0" * 64, policy_selector="v2") + (root / "baseline.json").write_bytes(b'{"findings": []}\n') + with self.assertRaisesRegex( + policy.PolicyBundleRefused, + "policy_bundle_mismatch", + ): + policy.load_policy_bundle(root, digest, policy_selector="v2") + + def test_v2_requires_bootstrap_and_controller_in_source_and_manifest(self) -> None: + files = v2_source_files() + for path in ( + "scripts/repo_sentinel_authority_bootstrap.sh", + "scripts/repo_sentinel_authority_controller.py", + ): + with self.subTest(stage="source", path=path): + with tempfile.TemporaryDirectory( + prefix="synthetic-policy-v2-" + ) as temporary: + with self.assertRaisesRegex( + policy.PolicyBundleRefused, + "policy_source_invalid", + ): + policy.build_policy_bundle( + tuple(item for item in files if item.path != path), + Path(temporary) / "bundle", + RUNTIME, + policy_selector="v2", + ) + + with self.subTest(stage="manifest", path=path): + with tempfile.TemporaryDirectory( + prefix="synthetic-policy-v2-" + ) as temporary: + root = Path(temporary) / "bundle" + policy.build_policy_bundle( + files, + root, + RUNTIME, + policy_selector="v2", + ) + manifest = json.loads( + (root / "protected-manifest.json").read_text(encoding="utf-8") + ) + manifest["entries"] = [ + entry for entry in manifest["entries"] if entry["path"] != path + ] + digest = rewrite_component( + root, + "protected-manifest.json", + manifest, + ) + with self.assertRaisesRegex( + policy.PolicyBundleRefused, + "policy_bundle_invalid", + ): + policy.load_policy_bundle( + root, + digest, + policy_selector="v2", + ) + + def test_v2_build_terminates_without_writing_digest_back_to_runtime(self) -> None: + files = v2_source_files() + before = { + item.path: item.data for item in files if item.path in RUNTIME_SOURCES + } + with tempfile.TemporaryDirectory(prefix="synthetic-policy-v2-") as temporary: + root = Path(temporary) / "bundle" + digest = policy.build_policy_bundle( + files, + root, + RUNTIME, + policy_selector="v2", + ) + after = {path: (ROOT / path).read_bytes() for path in RUNTIME_SOURCES} + + self.assertEqual(before, after) + self.assertTrue( + all(digest.encode("ascii") not in data for data in after.values()) + ) + self.assertFalse((ROOT / "policy/repo-sentinel-authority/v2").exists()) + + def test_runtime_has_no_policy_bundle_digest_back_edge_symbol(self) -> None: + for path in RUNTIME_SOURCES: + with self.subTest(path=path): + source = (ROOT / path).read_text(encoding="utf-8") + self.assertNotIn("POLICY_BUNDLE_SHA256", source) + self.assertNotIn("EXPECTED_POLICY_BUNDLE_SHA256", source) + + +if __name__ == "__main__": + unittest.main()