Skip to content
Open
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
140 changes: 140 additions & 0 deletions .github/scripts/check-swift-self-hosted-runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Verify that fork pull requests cannot reach the Swift self-hosted runner."""

import re
import sys
from pathlib import Path


CALLER_PATH = Path(".github/workflows/tests.yml")
CALLEE_PATH = Path(".github/workflows/swift-sdk-build.yml")
JOB_NAME = "swift-sdk-build"
CALLEE_POLICY = (
"github.event_name!='pull_request'||"
"github.event.pull_request.head.repo.full_name==github.repository"
)
CALLER_POLICY = (
"always()&&("
f"{CALLEE_POLICY}"
")&&needs.changes.outputs.swift-sdk-changed=='true'"
"&&needs.changes.result!='failure'"
"&&needs.rs-workspace-tests.result!='failure'"
"&&needs.rs-wallet-tests.result!='failure'"
)


def job_block(workflow: str, job_name: str) -> str:
"""Return one top-level job block without accepting sibling job guards."""
lines = workflow.splitlines(keepends=True)
start_pattern = re.compile(rf"^ {re.escape(job_name)}:\s*(?:#.*)?$")
sibling_pattern = re.compile(r"^ [A-Za-z0-9_-]+:\s*(?:#.*)?$")

start = next(
(
index
for index, line in enumerate(lines)
if start_pattern.match(line.rstrip("\n"))
),
None,
)
if start is None:
return ""

end = len(lines)
for index in range(start + 1, len(lines)):
if sibling_pattern.match(lines[index].rstrip("\n")):
end = index
break
return "".join(lines[start:end])


def job_property(block: str, property_name: str) -> str:
"""Return one job-level property, excluding step-level lookalikes."""
lines = block.splitlines(keepends=True)
start_pattern = re.compile(rf"^ {re.escape(property_name)}:\s*(?:.*)?$")
sibling_pattern = re.compile(r"^ [A-Za-z0-9_-]+:\s*(?:.*)?$")

start = next(
(
index
for index, line in enumerate(lines)
if start_pattern.match(line.rstrip("\n"))
),
None,
)
if start is None:
return ""

end = len(lines)
for index in range(start + 1, len(lines)):
if sibling_pattern.match(lines[index].rstrip("\n")):
end = index
break
return "".join(lines[start:end])


def normalized_if(block: str) -> str:
"""Normalize one job condition for an exact policy comparison."""
condition = job_property(block, "if")
Comment on lines +33 to +78

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Regex-based YAML folding is fragile to harmless reformatting

The checker depends on exact indentation and manual line folding, so semantically neutral YAML reformatting can cause false CI failures. It fails closed, but structured YAML parsing would reduce maintenance risk.

source: ['sonnet5-general']

if not condition:
return ""

first_line, *continuation = condition.splitlines()
first_value = first_line.split(":", 1)[1].strip()
if first_value in {">", ">-", "|", "|-"}:
first_value = ""
expression = first_value + "".join(line.strip() for line in continuation)
if expression.startswith("$" + "{{") and expression.endswith("}}"):
expression = expression[3:-2]
return re.sub(r"\s+", "", expression)


def check_policy(root: Path) -> list[str]:
caller = (root / CALLER_PATH).read_text(encoding="utf-8")
callee = (root / CALLEE_PATH).read_text(encoding="utf-8")
caller_job = job_block(caller, JOB_NAME)
callee_job = job_block(callee, JOB_NAME)
errors = []

if not re.search(r"(?m)^ pull_request\s*:", caller):
errors.append(f"{CALLER_PATH}: expected pull_request trigger")
if not re.search(
r"uses:\s*\./\.github/workflows/swift-sdk-build\.yml(?:\s|$)",
job_property(caller_job, "uses"),
):
errors.append(f"{CALLER_PATH}: {JOB_NAME} must call the Swift workflow")
if normalized_if(caller_job) != CALLER_POLICY:
errors.append(f"{CALLER_PATH}: {JOB_NAME} lacks the strict fork PR policy")
if normalized_if(callee_job) != CALLEE_POLICY:
errors.append(f"{CALLEE_PATH}: {JOB_NAME} lacks the strict fork PR policy")
if not re.search(
r"(?m)^\s*runs-on:.*\bself-hosted\b", job_property(callee_job, "runs-on")
):
errors.append(f"{CALLEE_PATH}: expected self-hosted runner boundary")
if not re.search(
r"(?m)^\s*bash\s+packages/swift-sdk/run_tests\.sh\s*$", callee_job
):
errors.append(f"{CALLEE_PATH}: expected Swift test entrypoint")

return errors


def main() -> int:
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
try:
errors = check_policy(root)
except (OSError, UnicodeDecodeError) as error:
print(f"self-hosted Swift runner policy check failed: {error}", file=sys.stderr)
return 2

if errors:
for error in errors:
print(f"error: {error}", file=sys.stderr)
return 1

print("Self-hosted Swift runner policy OK: fork pull requests are rejected")
return 0


if __name__ == "__main__":
raise SystemExit(main())
3 changes: 3 additions & 0 deletions .github/workflows/swift-sdk-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
jobs:
swift-sdk-build:
name: Swift SDK build + tests (warnings as errors)
if: >-
github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository
Comment on lines +9 to +11

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: New guard omits the repo's established thepastaclaw trusted-fork exception

Sibling self-hosted workflows allow thepastaclaw-owned fork PRs, while this new Swift guard only accepts same-repository PRs. If that stricter policy is intentional, document it; otherwise match the established exception consistently in the caller, callee, and checker.

source: ['sonnet5-general']

runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 90

Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ jobs:
with:
fetch-depth: 0

- name: Verify self-hosted Swift runner policy
run: python3 .github/scripts/check-swift-self-hosted-runner.py

- uses: dorny/paths-filter@v4
id: filter-js
if: ${{ github.event_name != 'workflow_dispatch' }}
Expand Down Expand Up @@ -342,7 +345,14 @@ jobs:
# At most one of the two Rust jobs runs (none for e.g. Swift-only PRs);
# skipped jobs report result 'skipped', not 'failure', so this only
# blocks on a Rust job that actually ran and failed.
if: ${{ always() && needs.changes.outputs.swift-sdk-changed == 'true' && needs.changes.result != 'failure' && needs.rs-workspace-tests.result != 'failure' && needs.rs-wallet-tests.result != 'failure' }}
if: >-
${{ always()
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository)
&& needs.changes.outputs.swift-sdk-changed == 'true'
&& needs.changes.result != 'failure'
&& needs.rs-workspace-tests.result != 'failure'
&& needs.rs-wallet-tests.result != 'failure' }}
secrets: inherit
uses: ./.github/workflows/swift-sdk-build.yml
Comment on lines +348 to 357

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Guard lives entirely inside fork-controlled content, so it doesn't close DS-CAND-103 against a real attacker

For pull_request events, GitHub runs workflow YAML from the PR merge commit, and the local reusable workflow resolves from that same commit. A malicious fork therefore controls the caller guard, the callee guard, and the checked-out policy checker, and can weaken all three together. Keep these checks as defense in depth, but close the trust boundary with a server-side control the PR cannot edit, such as required environment approval or repository/organization approval for outside-collaborator workflow runs.

source: ['codex-general', 'sonnet5-general']


Expand Down
Loading