Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/slopsearx-pilot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,10 @@ jobs:
checks: read
pull-requests: read
actions: read
uses: groktopus/codereview/.github/workflows/pr-analysis.yml@537418ca9f542ba403b6bc6b8280eacb52d1ce0f
uses: groktopus/codereview/.github/workflows/pr-analysis.yml@23852759ed3cfb1e6964b7438141e293508aa178
with:
harness_repository: groktopus/codereview
harness_sha: 537418ca9f542ba403b6bc6b8280eacb52d1ce0f
harness_sha: 23852759ed3cfb1e6964b7438141e293508aa178
target_repository: magnus919/SlopSearX
pull_request_number: ${{ fromJSON(inputs.pull_request_number) }}
base_ref: ${{ needs.resolve-target.outputs.base_ref }}
Expand Down
4 changes: 2 additions & 2 deletions docs/protected-publication-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ When enabled by a protected configuration, the runtime verifies its clean checke

The target is restricted to PRs whose base repository is the protected workflow’s repository and whose current base ref is the configured default branch. A fork head remains untrusted source data; it cannot select the publisher repository, credentials, or executable runtime. Before reading the artifact or App key, the runtime fetches the configured default branch from GitHub's repository API and binds the manifest's caller workflow SHA to that reported tip. It keeps the reviewed PR base/head SHAs separate: an older open PR may have a base commit behind the current default-branch tip. The App identity canary independently re-fetches the branch tip before reading the key. Before receipt persistence and again after receipt acknowledgement, the runtime rechecks that the tip is unchanged and that the PR still has the candidate base/head. These are fail-closed API observations; the source-context probe records `GITHUB_SHA`, `GITHUB_WORKFLOW_SHA`, the default-branch API tip, run API head, and PR base/head separately. Do not assume unobserved context equality or treat the probe as identity proof. The producer must independently bind its caller workflow SHA to the protected source revision. Manual dispatch and cross-repository publication remain outside this runtime.

The policy file is a versioned contract, not an input to the PR analysis. Schema v1 is App-only and remains unchanged. Schema v2 explicitly selects the App route or the target job's Actions token route and requires `authorization_mode: STANDING_BOUNDED`; this records narrow owner authorization for every result matching the exact policy envelope, without per-result human approval. Both templates remain disabled. The policy pins the repository and numeric ID, source and publisher workflow IDs/paths/refs, called harness commit, profile and provider-configuration identities, allowed disposition/event scope, credential route and expected actor, artifact redirect hosts, and upper bounds for run/page/review counts and wall-clock time. V2 also pins the exact publisher workflow file SHA-256; the runtime compares that digest with both the checked-out file and the file at `GITHUB_SHA` before accessing GitHub or the token. A target-local owner must review every identity and limit against authoritative repository configuration while keeping `enabled` false. The runtime also requires the workflow checkout to be clean and its HEAD to equal `GITHUB_SHA`; it rejects an uncommitted or mismatched policy file.
The policy file is a versioned contract, not an input to the PR analysis. Schema v1 is App-only and remains unchanged. Schema v2 explicitly selects the App route or the target job's Actions token route and requires `authorization_mode: STANDING_BOUNDED`; this records narrow owner authorization for every result matching the exact policy envelope, without per-result human approval. Schemas v1 and v2 retain their static artifact redirect host allowlist. Schema v3 keeps the bounded authorization and credential contract, pins the exact publisher workflow SHA-256, and replaces the host list with `artifact_download_mode: AUTHENTICATED_API_LOCATION_NO_AUTH`. In that mode, the runtime accepts only a well-formed HTTPS signed location obtained from the authenticated GitHub artifact API response for the already-bound artifact ID; the bearer credential is used only for the API request and never forwarded to the signed download. The downloader does not follow a second redirect. The location is used ephemerally and is not written to logs or receipts. V3 still validates the API artifact digest, bounded archive, manifest identity, and source-run bindings. The validator rejects malformed URLs and obvious local/private IP destinations; it does not perform DNS resolution or claim protection against DNS rebinding. All three policy templates remain disabled. The policy pins the repository and numeric ID, source and publisher workflow IDs/paths/refs, called harness commit, profile and provider-configuration identities, allowed disposition/event scope, credential route and expected actor, artifact-download mode, and upper bounds for run/page/review counts and wall-clock time. V2 and v3 pin the exact publisher workflow file SHA-256; the runtime compares that digest with both the checked-out file and the file at `GITHUB_SHA` before accessing GitHub or the token. A target-local owner must review every identity and limit against authoritative repository configuration while keeping `enabled` false. The runtime also requires the workflow checkout to be clean and its HEAD to equal `GITHUB_SHA`; it rejects an uncommitted or mismatched policy file.

For v2 `ACTIONS_TOKEN`, the runtime consumes only the protected workflow's fixed `${{ github.token }}` binding and validates the publisher workflow bytes against the policy's SHA-256 pin before any GitHub API request or token read. It also validates source/run/repository facts through bounded API admission. `PLATFORM_BOUND` means token provenance is bound to that exact protected workflow and those API facts; it does not mean the opaque token was independently inspected or that GitHub's effective write scope was observed. The v2 pre-POST receipt records the complete declared writer-job permission map and workflow digest with `permission_basis: WORKFLOW_DECLARATION` and `write_capability: NOT_TESTED`. The adapter's narrower `actions`/`pull-requests` tuple describes the operations it needs, not the full effective token permissions. Only the actual review response and later history readback showing the expected `github-actions[bot]` actor can establish that exact effect as confirmed. Actor mismatch or missing readback after submission leaves the result `UNKNOWN`; there is no App-key fallback or automatic retry. The existing App route remains `VERIFIED` through its separate App/installation APIs and token scope response.
For v2/v3 `ACTIONS_TOKEN`, the runtime consumes only the protected workflow's fixed `${{ github.token }}` binding and validates the publisher workflow bytes against the policy's SHA-256 pin before any GitHub API request or token read. It also validates source/run/repository facts through bounded API admission. `PLATFORM_BOUND` means token provenance is bound to that exact protected workflow and those API facts; it does not mean the opaque token was independently inspected or that GitHub's effective write scope was observed. The v2/v3 pre-POST receipt records the complete declared writer-job permission map and workflow digest with `permission_basis: WORKFLOW_DECLARATION` and `write_capability: NOT_TESTED`. The adapter's narrower `actions`/`pull-requests` tuple describes the operations it needs, not the full effective token permissions. Only the actual review response and later history readback showing the expected `github-actions[bot]` actor can establish that exact effect as confirmed. Actor mismatch or missing readback after submission leaves the result `UNKNOWN`; there is no App-key fallback or automatic retry. The existing App route remains `VERIFIED` through its separate App/installation APIs and token scope response.

The writer adapter uses repository-wide concurrency (`pr-review-publish-<repository-id>`) so two PRs in the same repository cannot overlap receipt and review effects. The workflow template queues runs and does not cancel an in-progress publisher. The App route requires its named `code-review-publication` environment and App key secret; the Actions-token route uses no App key or installation and instead relies on the target workflow's per-job token. In either route, the YAML declaration does not prove configured protections or effective write capability.

Expand Down
2 changes: 1 addition & 1 deletion scripts/central_pilot_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
TARGET = "magnus919/SlopSearX"
BASE_REF = "main"
REUSABLE_PATH = ".github/workflows/pr-analysis.yml"
REUSABLE_SHA = "537418ca9f542ba403b6bc6b8280eacb52d1ce0f"
REUSABLE_SHA = "23852759ed3cfb1e6964b7438141e293508aa178"
MAX_EVENT_BYTES = 64 * 1024
SHA1 = re.compile(r"^[0-9a-f]{40}$")
RUN_ID = re.compile(r"^[1-9][0-9]{0,19}$")
Expand Down
92 changes: 81 additions & 11 deletions src/pr_review_harness/actions_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import hashlib
import io
import ipaddress
import json
import math
import re
Expand Down Expand Up @@ -105,6 +106,60 @@ class ArtifactTrustMode(StrEnum):
SIGNED_ATTESTATION = "SIGNED_ATTESTATION"


class ArtifactDownloadMode(StrEnum):
"""How a trusted GitHub artifact API redirect location is admitted."""

STATIC_HOST_ALLOWLIST = "STATIC_HOST_ALLOWLIST"
AUTHENTICATED_API_LOCATION_NO_AUTH = "AUTHENTICATED_API_LOCATION_NO_AUTH"


def validate_api_issued_artifact_location(location: str) -> bool:
"""Validate URL syntax for an ephemeral location already received from GitHub's artifact API."""
if not isinstance(location, str) or not location or len(location) > 16_384:
return False
try:
parsed = urllib.parse.urlsplit(location)
hostname = parsed.hostname
port = parsed.port
except ValueError:
return False
if (
parsed.scheme.lower() != "https"
or not hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
or "#" in location
or not parsed.query
or port not in (None, 443)
or "\\" in location
or any(character.isspace() or ord(character) < 0x21 or ord(character) == 0x7F for character in location)
):
return False
normalized_host = hostname.rstrip(".").lower()
if (
not normalized_host
or normalized_host == "localhost"
or normalized_host.endswith((".localhost", ".local", ".internal"))
):
return False
try:
address = ipaddress.ip_address(normalized_host)
except ValueError:
labels = normalized_host.split(".")
if (
len(normalized_host) > 253
or len(labels) < 2
or all(re.fullmatch(r"(?:0x[0-9a-f]+|[0-9]+)", label) for label in labels)
):
return False
return all(
re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) is not None
for label in labels
)
return address.is_global


@dataclass(frozen=True)
class VerifiedPublisherIdentity:
"""Writer identity bound by the trusted runtime adapter/canary."""
Expand Down Expand Up @@ -367,6 +422,7 @@ class GitHubPublicationPolicy:
provider_configuration_identity: str
allowed_actor_login: str
artifact_trust_mode: ArtifactTrustMode
artifact_download_mode: ArtifactDownloadMode = ArtifactDownloadMode.STATIC_HOST_ALLOWLIST
result_artifact_name: str = "pr-review-result"
receipt_artifact_prefix: str = "pr-review-attempt"
api_base_url: str = "https://api.github.com"
Expand Down Expand Up @@ -426,6 +482,8 @@ def __post_init__(self) -> None:
raise GitHubPublicationError("publication_actor_policy_invalid")
if not isinstance(self.artifact_trust_mode, ArtifactTrustMode):
raise GitHubPublicationError("artifact_trust_mode_invalid")
if not isinstance(self.artifact_download_mode, ArtifactDownloadMode):
raise GitHubPublicationError("artifact_download_mode_invalid")
if not isinstance(self.concurrency_scope, str) or self.concurrency_scope not in {"pull_request", "repository"}:
raise GitHubPublicationError("publication_concurrency_scope_invalid")
if self.credential_provenance is not None:
Expand Down Expand Up @@ -453,6 +511,14 @@ def __post_init__(self) -> None:
for host in self.artifact_redirect_hosts
):
raise GitHubPublicationError("artifact_redirect_host_invalid")
if self.artifact_download_mode is ArtifactDownloadMode.AUTHENTICATED_API_LOCATION_NO_AUTH:
if (
parsed.hostname != "api.github.com"
or parsed.port not in (None, 443)
or parsed.path not in {"", "/"}
or self.artifact_redirect_hosts
):
raise GitHubPublicationError("artifact_download_mode_invalid")

def receipt_artifact_name(self, slot: EffectSlot) -> str:
return f"{self.receipt_artifact_prefix}-{slot.pull_request_number}-{slot.head_sha}"
Expand Down Expand Up @@ -844,17 +910,21 @@ def _download_artifact(self, artifact: dict, timeout_seconds: float, cap: int) -
if response.status != 302:
raise GitHubPublicationError("artifact_download_redirect_missing")
redirect = response.headers.get("location")
parsed = urllib.parse.urlsplit(redirect or "")
allowed = {host.lower() for host in self.policy.artifact_redirect_hosts}
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.hostname.lower() not in allowed
or parsed.username
or parsed.password
or not parsed.query
or parsed.fragment
):
if self.policy.artifact_download_mode is ArtifactDownloadMode.AUTHENTICATED_API_LOCATION_NO_AUTH:
trusted_location = validate_api_issued_artifact_location(redirect)
else:
parsed = urllib.parse.urlsplit(redirect or "")
allowed = {host.lower() for host in self.policy.artifact_redirect_hosts}
trusted_location = (
parsed.scheme == "https"
and bool(parsed.hostname)
and parsed.hostname.lower() in allowed
and not parsed.username
and not parsed.password
and bool(parsed.query)
and not parsed.fragment
)
if not trusted_location:
raise GitHubPublicationError("artifact_download_location_untrusted")
remaining = self._deadline(timeout_seconds)
if remaining <= 0:
Expand Down
Loading
Loading