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 one approved hosted flow. The flow is either dedicated to that project, as described next, or a shared read-only review flow that an administrator has opened to several projects of the account; either way the identity reaches its own project only. 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. The
one exception is a
read-only review on a private runner,
which names its private pool instead.
A complete POST /api/v1/flows body for a dedicated read-only review flow.
name, prompt_template and agent_config are required (the API answers 422
without them). Replace the placeholders with your own IDs:
{
"name": "Repository review",
"prompt_template": "Review the checked-out pull request. Do not edit files. Write result.json with a review string.",
"ai_model_id": "<model id>",
"agent_type": "codex",
"agent_config": {"limits": {"max_total_tokens": 5000000, "max_turns": 300, "max_usd": 25}},
"runner_pool": "server",
"allowed_mcp_tools": [],
"allowed_mcp_servers": [],
"trigger_project_ids": ["<project id>"],
"validate_result": true,
"result_schema": {
"type": "object",
"required": ["review"],
"properties": {"review": {"type": "string"}}
},
"git_clone_config": {
"enabled": true,
"publication_mode": "read_only",
"repositories": [{"project_id": "<project id>", "tracker_id": "<GitHub App tracker id>"}]
}
}
Set ai_model_id when you create the flow. A flow saved without a model is
accepted and then runs on the account's default model, which may not be the
model, key or budget you meant for reviews. To attach a model to an existing
flow, or change it, send only that field to PUT /api/v1/flows/{flow_id}
(GET /api/v1/ai-models lists the IDs):
GET /api/v1/ai-models/{model_id}/used-by then lists the flow.
To bind an existing flow instead, 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 another
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.
Model eligibility¶
The dedicated hosted flow must use a model its execution credential can invoke:
an API-key or hosted model (Codex also requires Responses support), or a Codex
or Claude subscription model whose owner turned on Use from my flows and
also owns this flow. The native read-only review on the server pool works
with such a subscription model; usage is attributed to the review execution.
Any other subscription selection is refused with HTTP 422 before an execution
is created: selecting its registration does not attach its owning agent to the
CI execution. Active credentials alone do not establish eligibility, and
supports_server_side_generation is not an executor compatibility check.
Alternatively, run the read-only review on a private runner Codex host profile;
there the host's own Codex login serves the model and no subscription
registration needs to be shared with flows.
See subscription credentials and execution identity.
Read-only native GitHub App review¶
For a review that must clone source without provider write credentials, set
git_clone_config.publication_mode to "read_only" on the dedicated flow above.
Select agent_type: codex. Two executors are supported:
- Hosted:
runner_pool: "server", the ephemeral hosted Codex executor. The model must be a server-usable API-key or hosted model, or a subscription model whose owner turned on Use from my flows and owns this flow (see Model eligibility). - Private runner: a named private runner pool plus
agent_config.host_exec_profilenaming a Codex host profile. Codex runs on the runner host with that host's own Codex login, so a ChatGPT (or other Codex) subscription can serve the review. See Read-only review on a private runner.
The default openhands executor is rejected because its checkout does not
implement this capability. Persistent execution, harness routing
(agent_config.harness), runner_pool: "auto" or an unset pool, an authorized
host directory (agent_config.workspace) and a host profile on the server pool
are rejected at provisioning, admission, lease issuance and launch.
Keep the repository's existing owning GitHub App tracker ID. A different tracker,
arbitrary URL, or unapproved repository is rejected. Shared flows require the
explicit project-selection mode below. The flow must be
enabled for preview and provisioning; enabling a flow does not issue a CI key or
trigger a review.
Only the CI identity can start a read-only review. A trigger from a person,
from the console, a session or an account key, is refused with HTTP 409
before any execution is created; the message names the route
(POST /api/v1/flows/{flow_id}/trigger with the CI key) and its two body
fields, pr_number and head_sha. The console shows Run now disabled on
such a flow with the same explanation.
Codex native structured results are on by default in this mode when the flow
has a result schema (see
Native Codex structured results);
set agent_config.native_result: false to opt out.
Each review execution runs under the per-execution ceilings shown as Run
limits on the flow page: the flow's own agent_config.limits, otherwise an
instance default when the operator set PRELOOP_DEFAULT_MAX_TOTAL_TOKENS,
PRELOOP_DEFAULT_MAX_TURNS or PRELOOP_DEFAULT_MAX_USD, otherwise none.
There is no built-in default, so set explicit limits on review flows, for
example {"limits": {"max_total_tokens": 5000000, "max_turns": 300, "max_usd":
25}} for a PR review (large reviews use several million tokens). A run
stopped by the flow's own ceiling fails with budget_exceeded, one stopped by
an instance default with default_limit_reached.
This mode requires an App installation and the control plane's configured App
signing key. At execution startup, Preloop rechecks the principal and repository
binding and requests one repository with contents: read (plus GitHub's implicit
metadata: read). An unsupported credential, failed issuance, or broader returned
scope fails the execution; it never falls back to the tracker's normal token.
The checkout is pinned to the server-admitted PR head SHA. The principal snapshots
this mode: changing between legacy and read-only invalidates the old identity's
binding, so provision a new principal instead of silently changing its capability.
This initial mode does not allow MCP tools or MCP servers. Set
allowed_mcp_tools: [] and allowed_mcp_servers: []. Native shell helpers may read
source and write local result.json; the local filesystem remains writable.
Remote MCP finalizers are unsupported. Runtime credentials cannot call MCP tools
or general account REST routes, even when legacy scope enforcement is disabled.
The normal execution-bound evidence upload capability still accepts only its own
execution and operation. Results remain agent-authored, not an attested command
history or custom diff/challenge proof.
Omit verification, publication approval, report publication, follow-up filing and
backport configuration; create_pull_request must be false. Completion persists
the result and emits the existing principal-filtered signed callback. Neither
native post-execution commands nor the trusted publisher push a branch or create
a PR, and Preloop does not post commit statuses in this mode. Use the separately
authorized publisher to verify the owned result and publish it. An agent-authored
success or publication bundle does not grant publication authority.
Workspace/session restoration and continuation are unsupported. A recovered controller may monitor the existing execution, but cannot restore an old workspace or broaden its credentials. The read lease expires at GitHub's deadline (normally one hour); terminal cleanup revokes the lease when the issuing controller still holds it. A lost controller relies on provider expiry. There is no automatic clone credential refresh. Operator-supplied images, setup commands and workspace inputs must not inject independent provider secrets; this mode narrows the credentials Preloop supplies, not credentials supplied separately by an administrator.
Trigger, startup receipt and result¶
Start a review with POST /api/v1/flows/{flow_id}/trigger and the CI
credential. The body is exactly {"pr_number": 7, "head_sha": "<sha>"}
(head_sha is the full lowercase 40 or 64 character SHA). Any other field,
such as a diff, base SHA, challenge or template data, is rejected with 422; it
is never silently ignored. Preloop reads the open pull request from the
provider, requires its current head to equal head_sha, builds the pull
request event server-side and starts one production execution
(test_mode: false). The flow webhook does not accept CI credentials. Bind
diffs, base revisions or challenges through the flow's own configured review
inputs and your own validator instead.
GET /api/v1/flows/executions/{id} (the status projection) and GET /api/v1/flows/executions/{id}/result include
startup_receipt, written by the controller, never by the model:
{
"version": 1,
"executor": "hosted",
"commit": "<admitted head SHA>",
"checkout_verified": true,
"setup_completed": true,
"agent_started_at": "2030-01-01T00:00:00+00:00",
"lease_revoked": true,
"agent_cli": {"name": "codex", "version": "0.153.4"}
}
On the hosted executor the container's startup chain fetches the admitted
commit, checks out exactly that commit, verifies HEAD equals it and runs
setup commands. Any failure exits before the agent starts. Its last step
prints a startup line naming the admitted commit; the controller records the
receipt only when that line is seen before the agent start marker, and
withdraws it (checkout_verified and setup_completed false) when a setup
failure is reported. lease_revoked is null until terminal cleanup, then
records whether GitHub confirmed revocation of every read lease. The receipt
is null when startup did not complete. A private runner review reports its
runner receipt in the same shape with executor: "private_runner" and its
sandbox. agent_cli names the CLI and the version the container printed
(codex --version) before the agent started; it is null when no version line
was seen before the start marker, and always null for a private runner, whose
runner does not report it yet. Fields are only ever added to version: 1, so
ignore keys you do not know. The receipt describes the controller's observation of the
container's own output; setup commands run repository code inside that same
container, so treat it as the platform's assertion, not an independent
attestation. The completion callback body is unchanged; fetch GET /api/v1/flows/executions/{id} or
/result for the receipt.
The restricted /result returns result, the captured result.json
(at most 256 KiB at capture) without controller-private members: keys that
start with _ or name credentials, headers, prompts, logs, environment or
runtime configuration, at any depth. result_withheld_paths lists the JSON
Pointers of withheld members; an empty list means result is the complete
captured document. schema_validation has valid, error_count and up to
20 errors with their path; error messages are included only when nothing
was withheld, since a message can quote a value. It is null when the flow
sets no result schema. Name your result fields so that none is withheld.
Human-owned historical executions retain their human ownership. These CI permissions do not authorize reading or publishing another execution. Historic result recovery/publication uses the explicit export grant described below. GitLab, PAT and alternate-tracker clone bindings are not supported by this mode. Local tests use a synthetic provider; they do not certify an installation's live GitHub permissions.
Read-only review on a private runner¶
A read-only review can run on your own machine with the Codex CLI already logged in there (for example with a ChatGPT subscription). Preloop never sees that login; it sends the runner the prompt, a model alias and a short-lived read-only clone credential, and the runner starts Codex locally.
What the runner enforces for this mode:
- Clone: exactly one repository at the admitted pull request head SHA, with
the delivered GitHub App lease (
contents: readon that one repository). The host's own git configuration is not used for the checkout: system and global git config and credential helpers are disabled, so a missing or rejected lease fails the run instead of falling back to your own git login. After checkout the runner verifiesHEADequals the admitted SHA. - Lease: the runner revokes the lease as soon as the checkout is verified, before Codex starts. The control plane revokes it again at completion, and GitHub's expiry (normally one hour) is the fallback.
- Codex:
codex exec --sandbox read-onlywithapproval_policy="never". No MCP server, no publication, no session resume and no setup commands. - Receipt: the completion carries a runner receipt (admitted commit,
read-onlysandbox, whether the lease was revoked). A successful completion without a matching receipt is recorded asFAILED. The control plane stores it on the result ashost_read_only_review.
Runners that do not advertise the host_read_only_review profile capability
(older CLIs, or a Codex profile without allow_checkout) are never assigned
this work; the run waits in the queue for a capable runner.
Step by step¶
- On the runner host, install a Preloop CLI release that includes this capability and the Codex CLI, as the OS user that will run the runner.
-
Onboard Codex through Preloop as that user, with the approval hook:
preloop agents onboard "Codex CLI" --approvals. Onboarding writesmodel_provider = "preloop"to Codex'sconfig.toml, so every model call goes through the Preloop gateway with the agent's own key. Then opencodex, run/hooksand trust the PreloopPreToolUsehook. The runner checks the hook is enabled and trusted before every run.Keep this onboarded configuration. Do not run the runner with a separate
CODEX_HOMEthat lacks the Preloop model provider: Codex would then call its model service directly, the run would not be governed or metered, and it endsFAILEDwithgateway_bypassed. 3. Add a Codex profile with checkout enabled to~/.preloop/runner-host-profiles.json:{ "profiles": [ { "name": "codex-review", "executable": "codex", "allow_checkout": true, "timeout_seconds": 1200, "model_map": {"review-model": "<a model your Codex login offers>"} } ] }model_mapmaps the model the flow selects (itsmodel_identifier) to the Preloop model alias your onboarded Codex uses (preloop models list). If the flow selects no model, omitmodel_mapand Codex uses the model onboarding configured. -
Start the runner in a named pool, for example
preloop runner fg --labels review-hosts(or install it as a service). Runpreloop runner inventory --refreshafter editing profiles. In Runners the profile should list thehost_read_only_reviewcapability. -
In Preloop, set the dedicated review flow to (with
PUT /api/v1/flows/{flow_id}):{ "agent_type": "codex", "runner_pool": "review-hosts", "agent_config": {"host_exec_profile": "codex-review"}, "allowed_mcp_tools": [], "allowed_mcp_servers": [], "trigger_project_ids": ["00000000-0000-4000-8000-000000000001"], "git_clone_config": { "enabled": true, "publication_mode": "read_only", "repositories": [{ "project_id": "00000000-0000-4000-8000-000000000001", "tracker_id": "00000000-0000-4000-8000-000000000002" }] } }The model may be any registration, including an agent-bound subscription model: on a host profile it only names the alias the runner maps. Codex calls the gateway with the onboarded agent's own key, so that agent must be allowed to use the model. Give the flow a prompt that asks for a review of the checked-out change and does not ask Codex to edit files (the sandbox would refuse). 6. Provision the CI identity as below (Settings → CI identities lists the flow; the preview shows your private pool), add the completion subscription, and trigger reviews exactly as for the hosted executor.
Usage and run limits: the runner sends each run's gateway requests with an
execution marker (X-Preloop-Execution, signed by the control plane at
delivery and added to Codex's Preloop provider for that run only; your
config.toml is not edited). The gateway attributes those requests to the
flow execution as well as the agent, so the execution page shows the run's
requests, tokens and cost, and the token, turn and USD ceilings on the flow
page (including the defaults) are enforced on the host run: the request after
a ceiling is crossed is refused and the run ends FAILED with
budget_exceeded or default_limit_reached. The completed result records
gateway_metered: true and gateway_request_count. A run whose result shows
model output but no gateway request bypassed Preloop and ends FAILED with
gateway_bypassed (gateway_call_check: no_gateway_rows). Codex exposes no
per-request count, so a Codex run with at least one gateway row passes
(gateway_call_check: at_least_one). For Claude Code host profiles the runner
reports the exact model call count (cli_model_calls), and a run whose CLI
calls exceed its gateway rows by more than 2 calls and more than 20% partly
bypassed Preloop and fails the same way (gateway_call_check:
cli_call_count). Read-only review has no opt-out
from this.
Rules that require approval pause this run. A command that matches one does
not run: the review pauses, an approval request appears in Approvals, and
the run resumes on approval (the command may then run once) or ends FAILED
with approval_denied or approval_timeout. The read-only mode is not special:
if reviewers should never pause, write the rules so review commands do not
need approval. Hosted Codex runs install the Preloop permission hook, so deny
and approval rules apply to their shell commands. With sandbox_type:
read-only, Codex has no shell tool at all. A hosted Codex run that has such
a rule but cannot install the hook is refused before Codex starts with
approval_unavailable.
Other hosted harnesses have no native hook and are refused before launch with
approval_unavailable when such a rule applies.
Before a hosted Codex container that installs the hook starts, Preloop checks
the execution's runtime credential with the same rules
POST /api/v1/agents/permission-check applies on every hook call. If the hook
would be refused (for example a read-only review key with a widened scope, an
attached managed agent, or no runtime credential at all), the run ends
FAILED with hook_binding_invalid before the container starts: no model
call is made and no startup receipt is issued. The error message names the
refusal. This rule applies whether or not a deny or approval rule exists. It
points at a server-side binding fault, not at your CI configuration; retry
once, and report the execution ID if it repeats.
Limits of a host run compared with the hosted container: the Codex sandbox and
the approval hook are the only boundaries between the review and the runner
user's account. Codex's read-only sandbox blocks writes and network access for
the commands it runs, but the process still runs as your OS user and can read
whatever that user can read. Use a dedicated OS user or VM for repositories you
do not trust. Model traffic goes through the Preloop gateway with the
onboarded agent's key, so usage is metered per execution and results are
marked gateway_metered: true.
Shared read-only review flows¶
One read-only review flow can serve several projects of the same account. Use this when the repositories should be reviewed with the same prompt, model, limits and result schema and you do not want one flow per repository. Supported from Preloop 1.1.0 (the selection itself exists since 1.0.0; the allowlist API, CLI commands and console support are new).
What stays the same: each CI identity is bound to exactly one project. It can
start a review only for that project's repository and can read, list and stop
only its own executions. Another identity on the same flow, its executions,
results, startup receipts and tool evidence, and the list of admitted projects
are not visible to it. The trigger body is still exactly pr_number and
head_sha; Preloop selects the repository, the revision and the clone source
from the identity's project. Nothing per pull request is written to the flow.
The boundary is the account. A tracker organisation is not an access boundary in Preloop, and a flow cannot admit a project of another account. To keep groups of repositories apart, give each group its own shared flow (each flow admits only its own projects) or its own account.
Configure two repositories on one flow¶
- Create one read-only review flow as described in
Read-only native GitHub App review:
git_clone_config.enabled: true,publication_mode: "read_only", Codex, no MCP tools. Each repository must be a synced project on a GitHub App tracker of this account. - As the account owner, set the flow's project allowlist. The list is the
complete allowlist and replaces the previous one. Save it as
projects.json:
{
"project_ids": [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000004"
]
}
The API is PUT /api/v1/ci-identities/shared-flows/{flow_id} and
GET on the same path, for human administrators only. Preloop writes
ci_project_selection: true, the trigger projects and one clone repository
per project, taking each repository and tracker from the project itself. It
accepts no repository URL, tracker or wildcard.
3. Provision one identity per project, each with its own grant
(project_id of that repository, flow_id of the shared flow), with
preloop ci preview and preloop ci create or in Settings → CI
identities, where the flow is listed as "(shared review flow)". The
identity reports binding_mode: "shared".
4. In each repository, store that repository's own token and use the
workflow example unchanged: the same
PRELOOP_FLOW_ID in both, each with its own PRELOOP_PROJECT_ID and
PRELOOP_CI_TOKEN.
Check the isolation yourself with both tokens. The script starts no review:
PRELOOP_CI_TOKEN_A=... PRELOOP_CI_TOKEN_B=... python \
scripts/verify_restricted_ci.py shared-flow --url https://preloop.example \
--flow FLOW_ID --project-a PROJECT_A_ID --project-b PROJECT_B_ID
It prints one PASS, FAIL or SKIP line per check and exits 0 only when
every check passed (1 on a failure, 2 when a check was skipped because there
was nothing to probe, for example before the first review or with a key that
lacks flow:trigger). The checks: each token lists only
executions of its own project on the shared flow, every startup receipt names
the admitted head commit, neither token can read the flow, its allowlist or
the identity list, a trigger that names the other project is refused, and
neither token can read the other's execution, result or tool evidence. Run one
review per repository first so that the cross-read checks have something to
probe. The checks pass on 1.0.x as well, where the selection already exists.
Move a working dedicated identity to a shared flow¶
An identity cannot be converted: its project, flow and binding mode are fixed when it is provisioned. Move a repository without interrupting its reviews by running both side by side:
- Leave the dedicated flow and its identity as they are.
- Create a second read-only review flow with the same prompt, model and
limits, and admit the repositories with
preloop ci share-flow. - Provision a new identity for the repository on the shared flow
(
preloop ci preview, thenpreloop ci create). The old and the new identity both work; each lists only its own executions. - In the repository, replace
PRELOOP_FLOW_IDandPRELOOP_CI_TOKENwith the shared flow and the new token, and run one review to confirm it. - Retire the old identity with
preloop ci retire PRINCIPAL_ID. Its execution history is kept, and the dedicated flow can then be deleted.
Without the allowlist API (Preloop 1.0.x)¶
Releases before 1.1.0 have the same selection but no share-flow command. Set
it with the human PUT /api/v1/flows/{flow_id} endpoint instead: list the
admitted projects in trigger_project_ids and give each exactly one entry in
git_clone_config.repositories with its owning tracker, next to
"publication_mode": "read_only" and "ci_project_selection": true:
{
"trigger_project_ids": [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000004"
],
"git_clone_config": {
"enabled": true,
"publication_mode": "read_only",
"ci_project_selection": true,
"repositories": [
{"project_id": "00000000-0000-4000-8000-000000000001",
"tracker_id": "00000000-0000-4000-8000-000000000002"},
{"project_id": "00000000-0000-4000-8000-000000000004",
"tracker_id": "00000000-0000-4000-8000-000000000002"}
]
}
}
The two lists must name the same projects, without duplicates and without a
repository URL, or every identity on the flow is denied. preloop ci
capabilities on those releases still prints the older one-project wording;
the behaviour described on this page is the contract.
Rules and refusals¶
409fromshare-flowmeans one of: the flow is not an enabled read-only review flow; a listed project is not an active project of this account on a GitHub App tracker (the answer is the same for a project that does not exist); or the flow is dedicated and still has identities bound to it.- An identity's binding mode is fixed when it is provisioned. To turn a
dedicated flow into a shared one, retire its identities first
(
preloop ci retire), share the flow, then provision new identities. Editing the flow JSON by hand to flip the mode denies the existing identities instead of converting them. - A project that is not on the allowlist cannot get an identity on the flow.
- Removing a project denies every identity bound to it from its next request: reads return 401, a pending run is refused at dispatch and no clone lease is minted for it. A run that is already cloning or running finishes on its read-only, repository-scoped lease, which expires in about an hour. Adding the project back restores the same identities.
- Revoking a key, letting it expire, or retiring an identity affects that identity only.
- A shared flow must be a read-only review. A flow that can push, open pull requests or use MCP tools cannot be shared.
- Every admitted project gets the same prompt, model, limits and setup commands, and the flow's capacity and budget are shared between them.
- A review clones exactly one repository. Admitting extra read-only context repositories for a review is designed but not available yet; see the architecture chapter.
The threat model, with the test that covers each case, is in Shared review flows for restricted CI.
Own-execution tool evidence¶
Grant the separate action execution:evidence:read to retrieve
GET /api/v1/flows/executions/{id}/tool-evidence?skip=0&limit=10 (maximum 10 records
per page, with an 8 MiB aggregate records cap; a page always holds at least one
record). Continue using next_skip, since
the byte cap can return fewer records than limit. Fresh principal, project, flow and execution ownership checks apply to
every page. General logs, artifacts, prompts and other principals' executions
remain unavailable to the CI credential. Key rotation retains principal ownership;
revocation or binding changes deny subsequent reads.
Enterprise rollout requires the coordinated EE action mapping
READ_EVIDENCE: "view_flows" and the matching core revision.
Deploy both before granting or advertising this action; an older EE authorizer
fails closed even when a principal grant contains the new action.
New gateway requests retain tool-only observations before console preview clipping.
Request-side request_scope: latest_tool_exchange captures the latest tool call
batch and its results, including when prose follows that batch; response-side
capture retains the current response. Replayed
older request history is deliberately excluded, so storage grows with new exchanges
rather than accumulated history. capture_complete refers to this declared scope,
not a historical conversation import. Request-side observations whose call ID was
already stored with the same sanitized content digest are omitted; changed
observations under the same call ID remain retained (request_duplicates_omitted counts
them), so retries do not repeat an exchange. The dedupe lookup reads a small
per-record key list, never the stored documents. If an evidence write fails, the
usage row and agent log batch are still stored, and a record with
capture_failed: true and capture_complete: false takes its place.
Codex native command events are captured before the ordinary log-string cap.
Gateway call IDs and native item IDs are distinct namespaces; match only actual
correlation data, never infer that a model-requested call executed. Native exit
codes are runtime observations, not a successful execution or accepted review.
The capture excludes general conversation text. Capture-disabled content is absent;
credential and account storage-redaction rules still apply. JSONB-unsafe controls
and lone surrogates are removed before encoding; altered content sets redacted,
storage_redacted, and capture_complete: false.
Each record's sha256 hashes its document encoded as compact JSON with sorted
keys, UTF-8, ensure_ascii=false, and no nonfinite numbers. Execution and gateway
usage IDs are inside that document. page_sha256 hashes the returned records
array using the same encoding. These authenticated-server digests verify retained
representations; they are not an independent signature or a proof of unobserved
process activity. captured_result_sha256 uses the same encoding of the restricted
/result projection, which strips private controller fields.
Read all pages and require integrity_valid, capture_complete, no redacted, no
truncated, and no storage_redacted when exact bytes are an acceptance condition.
A request record is limited to 512 observations and 2 MiB; omitted observations set
capture_complete: false. Oversized native commands are explicitly marked absent.
missing_gateway_requests compares capture with retained usage rows. coverage
is missing, captured, or unavailable; it is not an assertion of successful
execution. Native command coverage is explicitly observed_events_only. A running
execution can acquire more records after a page read. For a terminal audit, verify
stable totals and record IDs across the completed export.
The endpoint also returns safe terminal_deliveries for that principal: delivery
ID, state, attempt count, HTTP status and next-attempt time. No receiver URL, secret,
response body or another principal's delivery is exposed. An empty list does not
identify the cause of a missing callback. A stopped execution now queues its
completion in the same transaction as the stop, using the existing deduplication
key, without relying on a surviving orchestrator. Delivery still requires current
principal authority and an eligible subscription.
Previously clipped arguments cannot be recovered by this feature. Historic runs without capture records report unavailable/missing coverage; the API never invents a command, verdict, helper challenge or accepted result.
Console and CLI setup¶
Open Settings → CI identities. The console uses the common human permissions
API, stays in OSS, and offers only currently admissible project/flow bindings
(hosted, or a read-only review on a private runner host profile). 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.
The grant is the same for a dedicated and a shared flow: one project_id and
the flow_id of the flow that reviews it. On a shared flow the project must be
on the flow's allowlist (preloop ci shared-flow FLOW_ID), and each repository
gets its own identity with its own project_id. Grant only the actions you
use; add subscription:create (and subscription:read) only if you will add a
completion subscription.
preview takes only {"grant": ...}, without name or expires_at. Save it
as ci-preview.json:
{
"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"]
}
}
create takes the same grant plus a name and an optional expires_at. Save
this 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"
}
--secret-file names a new file in a directory you own and only you can
read. Create that directory once:
mkdir -m 700 ~/ci-secrets
preloop ci capabilities
preloop ci preview --input ci-preview.json
preloop ci create --input ci-request.json --secret-file ~/ci-secrets/new-ci-token
preloop ci list
preloop ci show PRINCIPAL_ID
preloop ci rotate PRINCIPAL_ID KEY_ID --secret-file ~/ci-secrets/rotated-ci-token
preloop ci revoke PRINCIPAL_ID KEY_ID
preloop ci issue PRINCIPAL_ID --secret-file ~/ci-secrets/replacement-ci-token
preloop ci retire PRINCIPAL_ID
preloop ci retire ships with CLI 1.0.0. An older CLI answers unknown
command "retire": upgrade it, or call
POST /api/v1/ci-identities/{id}/retire with a human API 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.
The error says why a --secret-file path cannot be used: the file already
exists, its directory does not exist, or you cannot create files in that
directory (for example /private on macOS, which belongs to the system).
PRINCIPAL_ID is the identity's id; KEY_ID is the id of one of its
keys. create prints both as identity.id and key_id; issue and
rotate print them as principal_id and key_id; and preloop ci show PRINCIPAL_ID lists the identity's keys with their ids.
rotate and revoke need both ids: an identity can hold more than one key,
so the CLI does not guess which one you mean.
preview prints the binding Preloop will record, without issuing anything.
For a dedicated flow:
{
"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"]},
"flow_name": "Repository review",
"tracker_id": "00000000-0000-4000-8000-000000000002",
"repository_identifier": "123456789",
"repository_slug": "example/repository",
"tracker_type": "github",
"binding_mode": "dedicated",
"runner_pool": "server"
}
For a shared flow the same request, with that repository's project_id and
the shared flow's flow_id, prints "binding_mode": "shared", the shared
flow's flow_name and the repository_slug of that one project. Check that
flow_name and repository_slug are the ones you meant before create.
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.
Retiring an identity¶
A CI identity is never deleted, and its flow binding cannot change, so while an
identity is bound to a flow that flow cannot be deleted (the delete returns 409
and names the identity). To release the flow, retire the identity with the
Retire identity button in the console, preloop ci retire PRINCIPAL_ID, or
POST /api/v1/ci-identities/{id}/retire. Retiring:
- disables the identity permanently and revokes all of its keys;
- clears its flow binding, so the flow can then be deleted;
- keeps the grant (which still records the original flow), execution attribution and audit history.
Retirement cannot be undone. A retired identity cannot be re-enabled, narrowed,
reissued, subscribed or used for result exports; provision a new identity to
resume CI. It uses the same authority as disabling, and retiring an already
retired identity changes nothing. Reads show "retired": true.
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. The grant must include
subscription:create before you subscribe; without it the CLI stops and names
the missing action. Add it to an existing identity with
preloop ci update PRINCIPAL_ID --input change.json, where change.json holds
the identity's complete grant with the added action:
{
"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", "subscription:create", "subscription:read"]
}
}
A human then creates the endpoint:
preloop ci subscribe PRINCIPAL_ID --input callback.json --secret-file ~/ci-secrets/new-signing-secret
callback.json names one key of the identity and the HTTPS URL of your
receiver. description and active are optional:
{
"key_id": "00000000-0000-4000-8000-000000000004",
"url": "https://example.com/preloop-completion"
}
The signing secret is written once to the --secret-file; store it with your
receiver. Stdout shows the subscription's id, url and event_types.
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.
The completion data block, the delivery and retry contract and replay
handling are documented in
Restricted CI completion. It names
the bound repository, PR number, head SHA and the base SHA recorded when the
review was accepted, plus result_sha256, the digest of the result the CI key
will read.
Result readiness¶
Preloop stores the result together with the terminal status, then queues
flow.execution.finished, and materializes each delivery from that stored
state. A completion event therefore never arrives before its result:
result_ready: true means GET /api/v1/flows/executions/{id}/result returns
it now. GET .../result answers:
200with the result;409withdetail.code: "result_pending"and the currentdetail.statuswhile the execution has not finished, even if a checkpoint is stored, for example while it is parked (retry after it finishes);404when the execution does not exist for the caller, or finished without reporting a result. This does not change later.
Review and status posted by Preloop¶
A read-only review flow can have Preloop itself post the result: one pull request review and one commit status on the commit that was reviewed. The run stays read-only. The container, the model and your CI job never hold a GitHub write token, and you need no publisher workflow. Design and threat model: Review and status posted by Preloop.
It is off by default. Turn it on with git_clone_config.platform_review on a
flow that has publication_mode: "read_only", validate_result: true and a
result_schema. It works with and without ci_project_selection. Enabling it
needs no new CI identities.
Turn it on in the console¶
Open the flow. Read-only review flows show a Post review on the pull request card under the publication policy:
- What it needs lists the prerequisites as checks: the flow is a
read-only review, result validation is on, the result schema has a
required
verdictwith fixed values, the flow names its repositories, and every repository is connected through a GitHub App (not a personal access token, not GitLab). A failed check states the exact reason. The GitHub App permissions (Pull requests: Read and write, Commit statuses: Read and write) cannot be read from Preloop, so the card asks you to confirm them in GitHub. - Switch on Post the review on the pull request.
- To set a commit status, tick Set a commit status for each verdict and
choose Success, Failure or Error for every verdict of your schema. Keep
the status name (
preloop/reviewby default) stable if a branch protection rule requires it. Optionally choose what to set when a run stops, fails or returns no valid result. - Save. The card refuses a save the server would refuse and says why; a refusal from the server is shown as it was returned.
Users who may not edit flows see the card read-only.
Turn it on with the CLI¶
preloop flow review-posting show pull-request-reviewer
preloop flow review-posting enable pull-request-reviewer \
--state pass=success --state fail=failure --incomplete-state error
preloop flow review-posting enable pull-request-reviewer --no-status # review only
preloop flow review-posting set-states pull-request-reviewer --state fail=error
preloop flow review-posting set-states pull-request-reviewer --status-context ci/review
preloop flow review-posting disable pull-request-reviewer # keeps the mapping
show prints whether posting is on, the verdict mapping and the
prerequisites it can read from the flow. enable and set-states refuse,
before saving, a verdict that is not in the schema or a schema verdict
without a state. Each write changes only git_clone_config.platform_review.
Every command takes --json. The commands use your saved login or
PRELOOP_TOKEN.
The same in JSON¶
"platform_review": {
"enabled": true,
"status": {
"context": "preloop/review",
"verdict_states": {"pass": "success", "fail": "failure"},
"incomplete_state": "error"
}
}
| Key | Meaning |
|---|---|
enabled |
Turns posting on. |
verdict_actions |
Optional map from verdict to comment, approve or request_changes. Without an entry the review is a comment. An approval is never implied. |
status |
Optional. Omit it to post the review only. |
status.context |
Status name, default preloop/review. Keep it stable so a branch protection rule can require it. |
status.verdict_states |
success, failure or error for every verdict your schema admits. Saving fails if one is missing. |
status.incomplete_state |
Optional error or failure, set with a neutral description when the run stopped, failed or returned no valid result, so a required status is not left waiting. Without it nothing is posted for such runs. |
What your result must contain. Preloop reads only these fields of the
validated result; your schema decides which are required beyond verdict:
verdict: the schema must declare it as a required string with anenum.summary: text.findings: a list of objects withseverity,title,body(ordescription,message,detail),path(orfile) andline.checked(orchecks): a list of short strings saying what was reviewed.
What gets posted:
- A review with the verdict, the summary, every finding with its severity, file and line, what was checked, the reviewed commit and a link to the execution. A finding whose file and line are in the pull request's diff is also an inline comment. All text from the result is shown as literal text: it cannot mention a user, link anywhere, reference or close an issue, or carry HTML.
- A commit status on the reviewed SHA with the state mapped from the verdict.
- Nothing from the result chooses the target. Repository, pull request and
commit are the ones Preloop bound when it accepted the trigger
(
pr_numberandhead_sha). - If the pull request head moved while the review ran, the status is still
set on the reviewed commit and the review is not posted
(
review_reason: "head_moved"). Trigger a review of the new head. - A run that stopped, failed or returned an invalid result never posts a verdict.
GitHub App permissions¶
The GitHub App installation that owns the repository needs Pull requests:
Read and write and, for the status, Commit statuses: Read and write.
For each publication Preloop mints a token for that one repository with
those two permissions and no contents permission, uses it for the two
writes, reads both back and revokes it. If the installation does not hold
Commit statuses, the review is still posted and the record says
status: "failed", reason: "statuses_permission_missing". An organisation
owner then has to approve the added permission on the App installation;
after that the publish call below adds the status without posting the review
again. Personal access token trackers, GitLab and Bitbucket are refused when
you save the opt-in.
Existing identities must be provisioned again¶
A CI identity records which provider writes its flow can cause when it is
provisioned. After you turn platform_review on, add status, or add
approve or request_changes, the flow's existing identities stop
authenticating (401) until you provision new ones and replace the keys in
your CI. This is deliberate: an identity created for a flow that could not
write is never silently upgraded. Retire the old identities
(Retiring an identity).
The posting record¶
GET /api/v1/flows/executions/{id}, GET .../result and the data of
flow.execution.finished carry platform_review (null when the flow has
not opted in):
{
"status": "posted",
"reason": null,
"head_sha": "<reviewed sha>",
"review": "posted",
"review_id": "2890000001",
"review_action": "comment",
"review_reason": null,
"commit_status": "failure",
"status_context": "preloop/review",
"status_reason": null,
"posted_at": "2030-01-01T00:00:05+00:00",
"attempts": 1,
"retry_scheduled": false
}
On GET /api/v1/flows/executions/{id} for an account user the record also
carries pull_request_url, review_url and commit_url (the commit that
holds the status), built from the execution's own repository, pull request
and reviewed commit. Each is null until there is something to link to.
In the console the execution page shows the record under Posted on the pull request: posted, skipped (for example because the head moved) or failed with the reason in words, such as a missing Commit statuses permission, with links to the review, the status and the pull request. From the CLI:
status is pending (not attempted yet), publishing, posted, skipped
(the rules decided nothing is posted) or failed. A posting failure never
changes the execution's status or result. reason values include
head_moved, pull_request_closed, run_not_succeeded, result_not_valid,
verdict_unmapped, admission_revoked, binding_changed,
statuses_permission_missing, provider_unavailable, provider_denied,
provider_rejected, lease_refused, lease_unavailable,
attempt_interrupted, platform_review_disabled and readback_mismatch. Preloop retries provider_unavailable and similar
transient failures by itself, up to six attempts with growing delays.
Publish a finished review again, without a model run¶
curl -fsS -X POST \
-H "Authorization: Bearer $PRELOOP_CI_KEY" \
"$PRELOOP_URL/api/v1/flows/executions/$EXECUTION_ID/platform-review"
In the console, Publish again on the execution page makes the same call. It is shown to users who may edit flows, once the run has finished and while something is left to post. From the CLI:
The call takes no body. It publishes the stored, validated result of that
execution on the commit it reviewed, or does nothing if it is already there,
and returns the record above. Use it after a posting failure. It is allowed
for an account user who may edit flows and for the execution's own CI
identity when its grant and key include execution:review:publish (the
identity does not also need flow:trigger for this call). It
answers 409 with detail.code platform_review_disabled,
execution_not_finished or no_review_binding when nothing can be
published, and 404 for an execution the caller does not own. An execution
whose head has moved, whose result is not valid or whose identity was
disabled is never published by this call either.
Worked example: one flow for an organisation, two repositories¶
One hosted Codex flow reviews two repositories. Each repository has its own CI identity, so neither can trigger for, read or cause a post on the other.
-
Flow (an administrator saves this with
POST /api/v1/flows; the model, project and tracker IDs are yours):{ "name": "Organisation review", "prompt_template": "Review the checked-out pull request. Do not edit files. Write result.json with verdict, summary and findings.", "ai_model_id": "<model id>", "agent_type": "codex", "agent_config": {"limits": {"max_total_tokens": 5000000, "max_turns": 300, "max_usd": 25}}, "runner_pool": "server", "allowed_mcp_tools": [], "allowed_mcp_servers": [], "validate_result": true, "result_schema": { "type": "object", "required": ["verdict", "summary", "findings"], "properties": { "verdict": {"type": "string", "enum": ["pass", "fail"]}, "summary": {"type": "string"}, "findings": { "type": "array", "items": { "type": "object", "required": ["severity", "title", "body"], "properties": { "severity": {"type": "string", "enum": ["high", "medium", "low"]}, "title": {"type": "string"}, "body": {"type": "string"}, "path": {"type": "string"}, "line": {"type": "integer"} } } }, "checked": {"type": "array", "items": {"type": "string"}} } }, "trigger_project_ids": ["<project A id>", "<project B id>"], "git_clone_config": { "enabled": true, "publication_mode": "read_only", "ci_project_selection": true, "repositories": [ {"project_id": "<project A id>", "tracker_id": "<GitHub App tracker id>"}, {"project_id": "<project B id>", "tracker_id": "<GitHub App tracker id>"} ], "platform_review": { "enabled": true, "status": { "context": "preloop/review", "verdict_states": {"pass": "success", "fail": "failure"}, "incomplete_state": "error" } } } } -
Two identities, one per repository, each with the grant below and its own
project_id. Store each key as a secret of its own repository only. -
In each repository's CI, on a pull request event, trigger with that repository's key and a body of exactly
{"pr_number": <number>, "head_sha": "<full head sha>"}. That is the whole job: no checkout, no GitHub write token, no publisher step. - When the run finishes, the review and the
preloop/reviewstatus are on the pull request. Requirepreloop/reviewin branch protection if you want it to gate merging.
How this relates to your own publisher¶
The subscription and repository_dispatch routes below stay supported and
work the same with the opt-in on or off. Use one or the other for posting:
with both active a pull request gets two reviews. A publisher of your own is
still the route when you need a review format of your own, a provider other
than GitHub, or writes Preloop does not make (labels, check runs, threaded
replies).
Completion by GitHub repository_dispatch¶
Instead of a URL, a human administrator can have Preloop deliver the
completion as a GitHub repository_dispatch to the identity's bound
repository. A workflow in that repository then runs on completion: no
polling and no callback receiver to host.
curl -X POST "$PRELOOP_URL/api/v1/ci-identities/$PRINCIPAL_ID/github-dispatch-subscriptions" \
-H "Authorization: Bearer $PRELOOP_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"key_id": "<principal key id>", "event_type": "preloop-review-completed", "token": "<GitHub token>"}'
The principal's grant must include subscription:create, as for an HTTP
subscription; removing it stops future dispatches, including queued retries.
The response returns the signing secret once; store it as the publisher's
PRELOOP_CALLBACK_SECRET. The target URL is always derived from the bound
repository (https://api.github.com/repos/{owner}/{repo}/dispatches, or
<host>/api/v3/... for GitHub Enterprise Server) and cannot be edited.
Replace the token with
PUT /api/v1/ci-identities/{principal_id}/github-dispatch-subscriptions/{endpoint_id}/token.
The dispatch token needs write access. GitHub requires contents: write
on the repository for POST /repos/{owner}/{repo}/dispatches. Use the
narrowest credential you can:
- A dedicated GitHub App installed on this one repository with only
contents: write(preferred). Note that installation tokens expire after one hour, so the stored token must be refreshed with the replace route by your own automation, or use option 2. - A fine-grained personal access token limited to this one repository with
only
Contents: read and write, owned by a machine account.
Do not reuse the publisher's credentials or the review integration's App.
contents: write also allows pushing to the repository, so keep this token
out of every workflow; Preloop holds it only to send the dispatch.
The dispatch credential is configured only by a human administrator (a CI key is refused), stored encrypted on the subscription, never returned by any read, and used only by the delivery worker. It is never placed in the execution, the runner or the agent environment, so the read-only reviewer still holds no GitHub-write credential. Deleting the subscription deletes the token.
Callback-driven publisher example¶
restricted-ci-publish.yml with
scripts/restricted_ci_publish.py is a repository_dispatch publisher. The
flow uses the native read-only mode described above (a complete
POST /api/v1/flows body):
{
"name": "Repository review",
"prompt_template": "Review the checked-out pull request. Do not edit files. Write result.json with a review string.",
"ai_model_id": "<model id>",
"agent_type": "codex",
"agent_config": {"limits": {"max_total_tokens": 5000000, "max_turns": 300, "max_usd": 25}},
"runner_pool": "server",
"trigger_project_ids": ["<project id>"],
"validate_result": true,
"result_schema": {
"type": "object",
"required": ["review"],
"properties": {"review": {"type": "string"}}
},
"git_clone_config": {
"enabled": true,
"publication_mode": "read_only",
"repositories": [{"project_id": "<project id>", "tracker_id": "<GitHub App tracker id>"}]
},
"allowed_mcp_tools": [],
"allowed_mcp_servers": []
}
Give the principal key used by the publisher only execution:read and
execution:result:read. Whatever starts reviews (for example a separate job
calling POST /api/v1/flows/{flow_id}/trigger) needs only flow:trigger
and no GitHub write permission. The publisher job:
- reads
client_payloadfrom the event file (never from the shell command line) and verifiessignatureovereventwithPRELOOP_CALLBACK_SECRET; - re-fetches the execution and
/resultwithPRELOOP_CI_TOKENand requires the execution, flow, project, repository, PR, head SHA, base SHA, status andresult_sha256to match the signed completion; - reads the pull request with its own
GITHUB_TOKEN. If the head or base moved, it creates aneutralcheck run and publishes no review; - otherwise publishes one COMMENT review on the exact head and one check run
(
successorfailure), using only that job'sGITHUB_TOKENwithchecks: writeandpull-requests: write. Both are deduplicated by execution ID, so a redelivered dispatch publishes nothing new.
GitHub calls use the Actions-provided GITHUB_API_URL, so the example also
works on GitHub Enterprise Server. The example publishes only executions that
stored a result; a run that
finished without one ends the job with an error and publishes nothing.
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.
Credential-check resilience¶
The middleware uses a separate synchronous credential-check pool in each
API/gateway process. DATABASE_AUTH_POOL_SIZE (default 2) and
DATABASE_AUTH_MAX_OVERFLOW (3) control its retained and overflow connections;
DATABASE_AUTH_POOL_TIMEOUT (3 seconds) bounds checkout waits. Connections use a
3-second connect timeout and a 5-second statement timeout. Include this pool in
connection budgets alongside both request pools and the health connection.
The DB pool monitor includes it as engine=auth in saturation warnings and
preloop.db.engine=auth in OpenTelemetry connection gauges.
Only non-machine credential classifications are cached, using SHA-256 keys and
a maximum of 10,000 entries per process. CI_AUTH_PASS_THROUGH_TTL_SECONDS
(default 60) controls freshness. If a database lookup fails, an entry can be used
until CI_AUTH_PASS_THROUGH_STALE_TTL_SECONDS (default 600) from its original
insertion, not from the latest hit. Unknown or expired entries still fail closed
when verification is unavailable. Machine credential results are never cached.
A pass-through classification supplies no identity or authorization: the route's
normal authentication still checks current credential validity and restrictions.
Explicit export of a completed human review¶
A current account owner can authorize one completed account-owned review for a
specific CI principal without changing execution ownership or rerunning the
model. This is an explicit API operation, not automatic access to history.
The principal must already carry execution:result-export:read. EE additionally
requires current CI identity administration and resource permissions for the
owner; those permissions are checked again at every export. An active account
flow halt denies new grants and reads with the explicit halt 403 before provider
I/O; revocation remains available.
- Review the original execution and public result. The human tool-evidence
response's
captured_result_sha256is the digest of the public result projection: sorted, compact UTF-8 JSON, without private controller fields. Inspect the fixed helper's diff/challenge/verdict evidence separately; a hash proves byte agreement, not the correctness of the review. - As the current human owner, send
POST /api/v1/flows/executions/{execution_id}/result-exportwithprincipal_id,pr_number,head_sha,base_sha,result_sha256and an awareexpires_atno more than 24 hours ahead. No arbitrary URLs or repository inputs are accepted. The original event must record the matching project, immutable provider repository and PR IDs, same-repository head and base SHAs. Missing historical provenance is rejected, never filled from caller claims. - Use the returned grant UUID with the restricted credential at
GET /api/v1/flows/executions/{execution_id}/result-export/{grant_id}. Every call checks the current principal/key/actions, owner authority, expiry, revocation, unchanged source flow/event/result and a fresh provider PR read. Only SUCCEEDED executions with a captured result and no failed schema validation qualify. STOPPED/failed runs, CI-owned runs and fork reviews do not. - To revoke, the current owner sends
DELETE /api/v1/flows/executions/{execution_id}/result-export/{grant_id}. Revocation is irreversible. Key rotation preserves the principal's grant; disabling/narrowing the identity removes access immediately.
The export contains a public result, exact revision/digest receipt and a stable
publication_idempotency_key. Consent identity, digest, revisions and expiry
are immutable in the database. New grants for the same reviewed result keep the
same publication key. General execution, result and log routes continue to deny
the principal access to this historical execution. No historical grant is
created by the migration.
The GitHub operator example supports this path with these additional flags:
python scripts/restricted_ci_review.py \
--url "$PRELOOP_URL" --project "$PROJECT_ID" --flow "$FLOW_ID" \
--repository "$REPOSITORY" --pr "$PR_NUMBER" --head "$HEAD_SHA" \
--execution "$EXECUTION_ID" --result-export-grant "$GRANT_ID" \
--export-account "$ACCOUNT_ID" --base "$BASE_SHA" \
--expected-result-sha256 "$REVIEWED_RESULT_SHA256"
The default verifies the export only. --publish-review additionally requires a
separately authorized GITHUB_TOKEN with review-write permission. The example
never launches, cancels, polls or adopts an execution in export mode. It checks
current head/base, reviewed digest and grant again before a write, deduplicates
by the stable marker, and independently reads back the exact review body,
commit and expected author. Reviews above the provider body limit are rejected
without truncating approved text. The example is GitHub-only; the export API
also verifies GitLab when its original event retained the necessary revisions.
Serialize publishers using one trusted workflow concurrency group per repository and PR. The marker protects sequential retries; GitHub does not provide an atomic review-post idempotency key across concurrent external publishers. Consent revocation, provider head/base changes and publication are separate systems: a change immediately after the final preflight can still race the external write. Final read-back and grant checks then fail instead of reporting current authorized publication success. No live activation or external receipt is implied by the synthetic tests.
Rejected model keys at trigger time¶
Before creating an execution, clone lease or container, a restricted CI trigger
checks whether its selected customer model has a standing authentication
rejection. Only a marked model receives an authenticated probe with that model's
stored key. Success clears the mark and starts the review. Another upstream
authentication rejection returns HTTP 409 with
detail.code: "model_credentials_rejected" and detail.model_name. The
restricted response includes no provider sentence, secret reference or key
material. A human owner can replace or rotate the API key for that model.
Timeouts, network failures, rate limits and provider 5xx responses allow the
review to proceed. Billing rejection marks do not block reviews; unmarked models
have no extra provider call. Probe calls appear in gateway usage and audit.