Keep management surfaces private
Bind the engine to loopback or a private network unless GitHub must deliver webhooks. Put a path-filtering proxy in front of it and publish only:/api/v1/*native management routes and run logs;/api/v3/actions/runner-registrationand registration-token routes;/api/v1/github/registerand/api/v1/github/callbackwhile App registration is in progress—the callback displays a private key and webhook secret, and both routes return404unlessPRELOOP_ENABLE_APP_REGISTRATIONis1,true, oron;/setup/githuband/setup/github/callback, which serve the engine-hosted App-creation form and GitHub’s redirect; both return404unless a setup link is open, and/api/v1/github/setup-sessions—the authenticated endpoint that mints that link—is never a public route;- runner protocol, broker, artifact, cache, OIDC, and signed blob URLs.
/api/v1/github/webhooks: a path-filtering proxy or a Cloudflare ingress rule does that by path, while Tailscale Funnel does it by giving the webhook a hostname with nothing else mounted on its port (Funnel publishes the host and port, not just the path). The reason is POST /api/v3/actions/runner-registration: the official GitHub runner authenticates there with a registration token GitHub issued, which a self-hosted control plane cannot validate, so the safe default (PRELOOP_REGISTRATION_POLICY=strict) is what keeps the surface closed.
Native management routes use the system bearer, but the namespace is not uniform: webhooks use X-Hub-Signature-256, signed download URLs carry their own credential, and runner protocol routes use job or runner tokens. Do not rely on one blanket /api/v1 rule in a proxy.
The default PRELOOP_LISTEN is 127.0.0.1:9090. PRELOOP_SYSTEM_TOKEN protects native management and is generated into the selected credential store when not supplied explicitly. The mounted Unix socket is mode 0600 and exposes only the runner surface.
Runner registration is strict by default:
Legacy provision tokens are single-use and expire after 600 seconds. Never enable permissive registration on a shared or exposed listener.
Fork pull requests
Fork code is the classic untrusted-code boundary. Keep the policy in the operator’s config file, not in a workflow repository:-
run_fork_workflows = falsedrops fork pull-request events before workflow fetch and matching. -
require_approval = truecreates the run but holds its jobs inPending. Approve with the native system credential:The HTTP equivalent isPOST /api/v1/runs/RUN_ID/approve-forkwith an optional{"note":"..."}body. An unapproved run fails closed after 24 hours.pull_request_targetuses base-repository trust and is not covered by this fork approval gate.
Codified workflow deny policy
There is no[admin] table. The operator-owned deny rules live in [execution_protection]:
evaluate logs matching rules and continues. enforce skips the event or workflow. Omit workflows to match every workflow; patterns support *, **, and ?, not character classes. Only deny is accepted. An absent table carries a default unscoped pull_request_target rule in evaluate mode; set event_rules = [] explicitly to remove it.
Rules can also be managed with the system bearer:
event-0 and actor-0; deleting a rule shifts later IDs. Changes are written to the config file and take effect after restart.
Token ceilings and environment gates
Use an optional ceiling to prevent workflows from requesting more GitHub authority than the operator permits:none, read, write, and admin. The table is inactive when omitted. When present, allow_create_approve_pr = false caps pull-request permissions at read. See GitHub tokens for App-grant narrowing and PAT behavior.
For local environment protection, use [environment_rules."owner/repo".production] with deployment_branches, deployment_tags, wait_timer_minutes, and required_reviewers. Environment approval uses POST /api/v1/runs/RUN_ID/jobs/JOB_ID/approve; the system bearer records an admin_override note. Local reviewer counts above one are rejected because the engine has no user identities. With a GitHub App, environment resolution needs Actions and Contents read; team reviewers need Members read; deployment status and Check Run reporting need Deployments write and Checks write.
Secrets and credentials
Manage secrets without printing values:UPPER_SNAKE. Repository scopes use owner/repo; environment names use ASCII letters, digits, hyphens, and underscores, are at most 255 bytes, and cannot start with - or _. Precedence is run-supplied values, environment secrets, repository secrets, then global secrets. Fork jobs do not receive stored secrets.
The config file is mode 0600; file credentials live in a 0700 credentials directory with 0600 entries. secrets_store = "memory" keeps secret mutations only in engine memory and loses them at restart. credential_store = "file" is the default; credential_store = "os" uses the operating-system store. PRELOOP_CREDENTIAL_STORE can additionally select the nonpersistent memory backend for test-like deployments.
Stored secrets ([secrets] in config.toml) are sealed with a key that lives in the same credential store. If that key cannot be read—for example while the macOS login keychain is locked—startup fails and names what to unlock. Preloop never generates a replacement key over sealed secrets, because a new key cannot open them. A secrets.key file beside the config that disagrees with the store is renamed secrets.key.orphan-<unix-ts> rather than deleted.
For systemd, use an encrypted credential rather than plaintext secrets:
preloop secret rm cannot remove a value supplied by that credential.
Webhook signatures use per-App secrets. PRELOOP_WEBHOOK_SECRET verifies X-Hub-Signature-256 for the default App; a payload it signs is bound to that App’s installation coverage. Set another App’s secret against that App’s id instead:
