Skip to content

Native restricted CI setup

Restricted CI credentials are available in OSS for an account's human owner. They identify a stable machine principal bound to one project and its dedicated hosted flow. They are not account keys with scope strings. Existing account keys remain broader; adding a scope or renaming one never converts it. There is no OIDC or federation exchange in this version.

Prepare a dedicated hosted flow

Use an existing GitHub or GitLab tracker and synced project, then create a hosted review flow as a human administrator through the usual Flows API or console. The project needs a provider repository identifier and slug. The flow must be enabled, use runner_pool: "server", and trigger on exactly that project. Use the human PUT /api/v1/flows/{flow_id} endpoint to set the dedicated clone binding. Replace the synthetic IDs below with your disposable resources:

{
  "runner_pool": "server",
  "trigger_project_ids": ["00000000-0000-4000-8000-000000000001"],
  "git_clone_config": {
    "enabled": true,
    "repositories": [{
      "project_id": "00000000-0000-4000-8000-000000000001",
      "tracker_id": "00000000-0000-4000-8000-000000000002"
    }]
  }
}

Do not supply an arbitrary repository URL, a second repository or a runner pool. The server verifies the actual project, tracker and flow rather than trusting the selector. If the repository identity changes, provision a new principal; its stored project/flow/repository snapshot is immutable. Configure the flow to return a structured result with a review string when using the publication example below.

Console and CLI setup

Open Settings → CI identities. The console uses the common human permissions API, stays in OSS, and offers only actually dedicated hosted bindings. Preview the chosen grant before creation. Choose an expiry and the least actions needed. The backend capability check prevents issuance during an incomplete rollout. EE adds view_ci_identities and manage_ci_identities, intersected with current project/flow and action permissions. Only owner/admin gain the new permissions by default; custom roles can explicitly grant them. View does not imply manage.

CLI setup uses your saved human login or PRELOOP_TOKEN from a secret manager. These commands reject the plaintext --token flag. Never put credentials in URLs, shell arguments or JSON request files. Save this public grant as ci-request.json (use your own synthetic test resource IDs):

{
  "name": "Hosted review CI",
  "grant": {
    "version": 1,
    "project_id": "00000000-0000-4000-8000-000000000001",
    "flow_id": "00000000-0000-4000-8000-000000000003",
    "actions": ["flow:trigger", "execution:read", "execution:result:read", "execution:stop"]
  },
  "expires_at": "2030-01-01T00:00:00Z"
}

preview takes only {"grant": ...}, without name or expires_at:

preloop ci capabilities
preloop ci preview --input ci-preview.json
preloop ci create --input ci-request.json --secret-file /private/new-ci-token
preloop ci list
preloop ci show PRINCIPAL_ID
preloop ci rotate PRINCIPAL_ID KEY_ID --secret-file /private/rotated-ci-token
preloop ci revoke PRINCIPAL_ID KEY_ID
preloop ci issue PRINCIPAL_ID --secret-file /private/replacement-ci-token

The CLI reserves a new exclusive file with mode 0600 before requesting a secret. Existing files and symlinks fail before issuance. Stdout contains only safe metadata. The file contains the issued token followed by one newline; move it directly into the repository secret manager and remove the local copy. Use a private directory, including on Windows where POSIX mode alone does not define access control. Tokens and signing secrets are shown once in the console and cleared when the modal closes or the component disconnects.

Issuance and file storage cannot be a transaction across the server and local disk. If a request times out, inspect metadata before retrying. If storage fails after issuance, inspect the new key ID and revoke/replace it. An interrupted reservation can be empty; remove it only after checking server metadata.

preloop ci update PRINCIPAL_ID --input change.json accepts a non-null grant and/or explicit boolean enabled. Disable with {"enabled": false}. Re-enable requires an active binding. Narrowing actions, disabling and revocation remain available when a bound flow/project is inactive. An expired or revoked key cannot rotate, but a human can issue a replacement on the same active principal. Rotation keeps the old key's action ceiling and expiry unless an expiry is explicitly supplied. It never broadens ownership or re-enables the identity.

Completion subscriptions

Include only the callback actions you need in the principal grant. For example, subscription:create, subscription:read, subscription:update, subscription:delete and subscription:secret:rotate. A human creates the endpoint with preloop ci subscribe PRINCIPAL_ID --input callback.json --secret-file /private/new-signing-secret. The request is:

{
  "key_id": "00000000-0000-4000-8000-000000000004",
  "url": "https://example.com/preloop-completion"
}

key_id is a real key belonging to the principal, used only as an audit anchor. It may be revoked/expired; it is not the human's authentication. The event filter is fixed to flow.execution.finished and the stored principal/flow binding. Subscription management by machines is own-only through the five endpoint lifecycle operations. Generic test delivery, replay, history, retries and raw event filtering are denied. Rotation/revocation of a key preserves the stable subscription. Disabling the principal or removing its callback grant prevents future dispatch, including already queued retries.

Verify X-Preloop-Signature over the original bytes as HMAC-SHA256 of timestamp.body, accept a small freshness window, and deduplicate completion by execution ID plus exact head. A valid signature is insufficient by itself: fetch the current owned execution and result and compare execution, project, flow, provider repository identifier, PR number/provider ID, exact SHA and terminal status. No general execution logs, artifacts or session contents are included in restricted results.

Operator-run GitHub Actions example

Copy the workflow example and scripts/restricted_ci_review.py into a disposable repository's trusted default branch. Set repository secret PRELOOP_CI_TOKEN, and variables PRELOOP_URL, PRELOOP_PROJECT_ID and PRELOOP_FLOW_ID. Dispatch from the default branch only with an open same-repository PR carrying the ci-approved label and its current exact head SHA. The script rejects fork PRs and never checks out or executes PR code. Do not change this into a pull_request_target workflow that runs untrusted code with secrets.

The read-only job validates authoritative context, deduplicates matching owned executions, stops its principal's obsolete runs on the same PR, and polls the persisted terminal result. The optional publication job has separate pull-requests: write permission, repeats verification and confirms the returned review receipt, commit and expected bot author. Only then does stdout contain a non-null review_id. A successful flow or callback is not proof of publication. Concurrency queues dispatches rather than abruptly cancelling runners, allowing the script to stop owned obsolete executions. Use a separate principal per workflow so one workflow cannot cancel another workflow's runs.

For a captured signed callback, pass --callback-body with the original body file and supply PRELOOP_CALLBACK_SECRET/PRELOOP_CALLBACK_SIGNATURE from the receiver. The signature covers the full production event envelope; the verifier checks its fixed type/version and compares the completion in data. Supply --callback-account to additionally pin the envelope to your known account UUID. Reject stale captures. No receiver is exposed by this example. For manual publication with a human GitHub token, set --review-author to that token's public user login; Actions defaults to github-actions[bot]. Draft/pending or dismissed receipts never count as published review proof. The current exact PR head is rechecked after receipt verification. GitHub publication and head updates are separate external operations, so a concurrent head change can leave a review attached to the old commit; the verifier then fails and does not report current-head publication success.

The legacy run-flow action accepts broader account credentials and rejects typed ci_ tokens. Restricted credentials cannot enroll/administer runners, edit flows/projects/policies, create keys/users/identities, retry/resume/matrix executions or exchange sessions.

Local release gate and remaining proof

Create and migrate an isolated disposable PostgreSQL database using the native development guide. Set DATABASE_URL to that database, activate the venv and run:

PRELOOP_DISABLE_TELEMETRY=true python scripts/verify_restricted_ci.py
cd cli
PRELOOP_DISABLE_TELEMETRY=true go test ./internal/cmd -run '^TestCI'

The gate uses real fixture resources and actual HTTP plus CRUD state assertions:

Acceptance scenario Automated evidence
Bound PR/head trigger and persisted own result; stop/idempotence execution admission, dispatch, HTTP and ownership tests
A/B same flow, other flow/project/account, human and null legacy rows execution and callback ownership/HTTP tests
Principal-owned callback lifecycle and exact event filtering subscription HTTP and callback contract tests
Rotation/revocation/expiry, replacement, inactive-resource recovery identity and human setup HTTP tests
Disable/narrowing affects queued dispatch and retries callback contract/failure and execution dispatch tests
Valid forbidden writes, auth/session/runner/log routes, partial rollout REST route inventory and human setup denial tests
Malformed/mixed markers, unknown actions/version, scope audit/off identity, REST, admission and callback fail-closed tests
Signature/context mismatch and separate publication receipt operator workflow verifier tests
Fresh human permissions, ordinary roles and owner/admin seed defaults permission and seed tests; EE suite separately tests team administration and core ceiling

Run the EE edition tests against its pinned core SHA before release. The local provider and runner calls are synthetic: they prove admission and state controls, not a live hosted run. No external Actions smoke or customer configuration was performed for this implementation. The workflow is a runnable operator path; automatic Actions acceptance is not live-proven. On disposable resources, record the accepted exact head, signed completion, matching persisted result and the independent GitHub review receipt before calling that external path proven.