Skip to content

feat(pull-request-gate): new action + ExternalContributorPolicy typed primitive to block drive-by PRs fleet-wide #2

@drzln

Description

@drzln

Context

On 2026-05-28 an external account OyaAIProd — production account of "Oya.ai / SafeSkill" — opened a drive-by PR in pleme-io/kotoba (#1) adding a "SafeSkill 91/100 Verified Safe" shields.io badge to the README. The PR pattern is:

  • FIRST_TIME_CONTRIBUTOR, fork created 6 seconds before the PR.
  • Branch name is a unix timestamp suffix (safeskill-scan-1779933979225) — clearly script-generated.
  • The "scan" admits in its own info finding it skipped all Rust source (it's a JS/TS scanner).
  • The "high finding" is a false positive against a URL inside CLAUDE.md documentation.
  • The badge URL safeskill.dev/scan/pleme-io-kotoba is operator-controlled — they can mutate what it shows at any time (score, redirect, tracking).
  • Verified blast radius: this account has hit ~100 repos in the past 2 weeks with the same template, targeting projects whose README mentions "MCP server". Several merged.

Immediate response landed (2026-05-28):

  • Close PR chore(deps): bump actions/checkout from 4 to 6 #1 as not planned
  • Lock conversation as spam
  • Block OyaAIProd at the pleme-io org level
  • Sweep org confirmed clean: no other OyaAIProd PRs, no merged safeskill/safeskill.dev references anywhere in pleme-io code

This issue tracks the long-term fix — a typed primitive that makes this class of drive-by impossible to land again.


Destination (the long-term shape)

A typed ExternalContributorPolicy declared in Pangea Ruby, reconciled by the live pangea-operator on the rio cluster (the same controller that already manages repo settings via pleme-io-github-posture).

# pangea-github-posture/lib/pangea/github_posture/external_contributor_policy.rb
defexternalcontributorpolicy :pleme_io_default do
  org                   "pleme-io"
  allowlist             %w[drzln dependabot[bot] renovate[bot] github-actions[bot]]
  blocklist             [
    blocked(:OyaAIProd, reason: "SafeSkill drive-by spam, 2026-05-28")
  ]
  rejection_action      :close_and_lock          # :close_and_lock | :request_approval | :label_only
  gate_workflow_version "v1"
  restricted_paths      %w[README.md .github/** LICENSE *.md docs/**]
  bot_grace             :allow
end

Reconciles to three artifacts

Artifact API surface Per repo?
Org blocklist PUT /orgs/{org}/blocks/{user} for each blocklist entry No — org-wide
PR-author gate workflow .github/workflows/external-contributor-gate.yml consuming pleme-io/actions/pull-request-gate@v1 Yes — one shim per repo, via the existing repo-posture FluxCD pipeline
Branch-protection: restricted-paths GitHub Branch Protection API — require CODEOWNERS review when a non-allowlisted author touches restricted_paths Yes — per repo

Gate logic (the action this issue creates)

pull-request-gate is a new tlisp-based action under pleme-io/actions/pull-request-gate/ following the canonical 2-file shape (action.yml + run.tlisp, see argocd-sync/ for the model). Runs on pull_request_target:

on: pull_request_target
  → check author against ALLOWLIST env (org members + curated externals)
  ├── in allowlist:                                      no-op, exit 0
  ├── known bot (dependabot/renovate/github-actions):    label `bot-pr`, exit 0
  ├── FIRST_TIME_CONTRIBUTOR + restricted-only diff:     auto-close + lock + label `external-drive-by` + brief comment
  └── FIRST_TIME_CONTRIBUTOR + substantive diff:         label `external-review-required`, request CODEOWNERS review

The OyaAIProd PR shape — FIRST_TIME_CONTRIBUTOR + only README.md + one badge line — falls in branch 3 and is auto-rejected without human intervention.


Path (the three phases)

Phase 1 — ship pull-request-gate@v1 action (this issue's primary deliverable)

  • pleme-io/actions/pull-request-gate/action.yml — composite action declaring inputs (allowlist, bot-allowlist, restricted-paths, rejection-action), wraps stdlib + run.tlisp via the standard pleme-io/actions/tatara-script@v1 pattern.
  • pleme-io/actions/pull-request-gate/run.tlisp — gate logic using _tlisp-stdlib/stdlib.tlisp helpers + gh api calls for author lookup, close, lock, label, comment.
  • pleme-io/actions/pull-request-gate/README.md — usage docs, four behavior branches, examples.
  • Add to pleme-actions-catalog.lisp (typed catalog entry per the catalog-reflection invariant).
  • Dogfood: add .github/workflows/external-contributor-gate.yml shim to 3 high-traffic reposkotoba, caixa-cargo-watch (10 open bot PRs = good load test), hanabi. Verify a week of runs.

Phase 2 — typed ExternalContributorPolicy primitive

  • Add ExternalContributorPolicy Dry::Struct + (defexternalcontributorpolicy …) Lisp form to pleme-io-github-posture (or whichever pangea-architectures sub-gem owns repo settings — TBD on first read of the gem).
  • Extend the github-posture reconciler in pangea-operator to:
    • PUT /orgs/{org}/blocks/{user} for each blocklist entry
    • PUT the gate workflow file via the existing per-repo posture pipeline (FluxCD-driven git commits to each repo)
    • PUT the branch-protection restricted-paths rule
  • Declare :pleme_io_default instance in the org's pangea-architectures workspace.
  • Roll out fleet-wide: ~120 repos get the workflow shim mechanically.

Phase 3 — BlocklistDriftController (proactive)

  • Add a typed BlocklistDriftController to pangea-operator that runs weekly:
    • Sweep gh search prs --author <new-account> against the org (paginated, samba-rate-limited)
    • Cluster new external accounts by PR template hash (catches future "ScanAndBadgeCorp" before they hit us)
    • Emit a ntfy alert via the existing observability path when an unknown vendor pattern appears
  • Status receipts emitted via OutcomeChain (peer of other pangea-operator controllers).

Design choices already made

Choice Decision Why
Gate aggressiveness Strict — auto-close+lock first-time contributors who only touch restricted paths Kills badge-trojans outright; real typo-fix PRs to README still need to coordinate (acceptable trade-off for a hardened fleet)
Tracking issue home pleme-io/actions The action ships here; this issue is the single source of truth for both Phase 1 (action) and Phase 2 (typed primitive in github-posture)
Blocklist storage In-repo, typed Ruby value reconciled by pangea-operator Reversible by git revert; auditable via OutcomeChain; reconciled automatically every controller tick

Pillar alignment

  • Pillar 7 (Kubernetes control) — pangea-operator on rio is the reconciler
  • Pillar 12 (generation over composition) — one typed declaration generates 120 workflow shims
  • NO SHELL — Rust controller, tatara-lisp action, Ruby DSL, YAML rendered output
  • GitOps-native — blocklist + workflow shims live in git, committed by the operator, reverted via git revert
  • Compounding directive — extends pleme-io-github-posture (existing primitive), does not parallel it

References

  • Incident memory: ~/.claude/projects/-Users-drzzln-code-github-pleme-io-nix/memory/incident_oyaai_safeskill_drive_by_2026_05_28.md
  • Skill: pleme-io-github-posture
  • Skill: pangea-operator-author
  • Skill: pleme-actions (action-authoring conventions)
  • Skill: pleme-io-pattern-core (canonical action shape)
  • Canonical action model: pleme-io/actions/argocd-sync/
  • Substrate auto-release wiring: pleme-io-auto-release skill

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions