Trust levels and secrets
A pull request from a fork runs code that nobody with write access has read yet. If that code can read a token, it can send the token anywhere.
TanStack Source gives every CI run a trust level. Each level gets its own secret values and its own permissions, and no secret value ever enters a job container.
Trust levels
The forge sets the trust level of a run from the event that started it. A workflow file cannot change the level.
| Level | When |
|---|---|
untrusted | A pull request or a push by a person without write access to the base repository, for example from a fork. |
trusted | A pull request by a person with write access. A push by that person to a ref that is not protected. A manual() run on a ref that is not protected. |
protected | A push or a manual() run on a protected ref by a person with write access. A protected ref is the default branch, a tag, or a branch with a branch rule. A schedule() run. |
The rules:
- For a pull request, the steps come from the pull request. The secrets, permissions and egress hosts come from the base branch. If the pull request asks for more, the run shows a warning, and the job runs without it until the change is merged.
- Only a person with write access starts a
manual()run. - An
after()run gets the level of the run that ended. - A run that a CI push or a
fixup()commit starts gets the lower of two levels: the level of the run that pushed, and the level of the target. - No job result and no agent raises a level. Only a person with write access does.
Add a secret
A secret has a name, one value for each trust level, and the hosts that the value can go to. A level can have no value. This example gives forks a read-only Nx Cloud token, and gives all other runs a read-write token.
- Open the repository, then Settings, then CI settings (
/<owner>/<repo>/settings/ci). - Add a secret with the name
NX_CLOUD_ACCESS_TOKEN. - Give each level a value: the read-only token for
untrusted, and the read-write token fortrustedandprotected. - Bind the secret to the host
cloud.nx.app. - Save the secret.
To do the same from a terminal, run src ci secret set. It gives each level its own value. It uses your src login and the repository of your clone. A value of - reads the value from stdin, so the value stays out of your shell history. The third command adds NPM_TOKEN with a value for protected runs only.
src ci secret set NX_CLOUD_ACCESS_TOKEN --untrusted - --host cloud.nx.app < nx-read-only.txt
src ci secret set NX_CLOUD_ACCESS_TOKEN --trusted - --protected - < nx-read-write.txt
src ci secret set NPM_TOKEN --protected - --host registry.npmjs.org < npm-publish.txt
src ci secret list
NAME LEVELS HOSTS
NPM_TOKEN protected registry.npmjs.org
NX_CLOUD_ACCESS_TOKEN untrusted,trusted,protected cloud.nx.app
Over REST, PUT the secret with the personal access token of an admin. A level that you leave out keeps its value, and null removes the value of a level. Hosts that you leave out stay as they are.
export TSS_FORGE_URL=https://forge.example/api/v1
export TSS_TOKEN=<your personal access token>
curl -X PUT "$TSS_FORGE_URL/repos/acme/app/ci/secrets/NX_CLOUD_ACCESS_TOKEN" \
-H "Authorization: Bearer $TSS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"values":{"untrusted":"<read-only token>","trusted":"<read-write token>","protected":"<read-write token>"},"hosts":["cloud.nx.app"]}'
Secret values are write-only. No person, agent or job can read a value back. The list and the answers show only the levels that have a value, and the hosts.
The forge refuses:
- A name that is not an environment variable name. A name has letters, digits and
_, and does not start with a digit. - A value over 16 KiB, or a value with a line break.
- A value that is a TanStack Source token of any kind (
tsp_,tsa_,tsj_,tsl_, and thegfp_,gfa_,gfj_,gfl_forms). A job gets its own token for the forge. - A host that is an IP address or the git host of the forge, and any host that is not an exact host name.
- More than 10 hosts for one secret, or more than 100 secrets in one repository.
Set a secret from the terminal
You know gh secret set, and one value is enough for your secret. src secret set takes gh's form:
src secret set NPM_TOKEN --host registry.npmjs.org < npm-publish.txt
src secret list
src secret setgives the value totrustedandprotectedruns.- With
--untrusted,untrustedruns get the same value too, such as runs of pull requests from forks. - Without
--untrusted,srcremoves theuntrustedvalue. So a secret likeNX_CLOUD_ACCESS_TOKENabove loses its read-only value for forks. - A new secret needs at least one
--host. If you leave out--host, a secret that exists keeps its hosts. - The value comes from stdin, from
--body, or from a dotenv file with--env-file. In a terminal,srcdoes not ask for it.
src secret delete NPM_TOKEN deletes the secret, with its values and its hosts.
Use a secret in a job
A job declares the names of the secrets it needs, and the hosts it can reach (egress). The workflow syntax is in Your first workflow.
import { defineWorkflow, pullRequest } from '@tanstack-source/ci'
export default defineWorkflow('pr', { on: [pullRequest({ branches: ['main'] })] })
.job('test', {
secrets: { NX_CLOUD_ACCESS_TOKEN: {} },
egress: ['registry.npmjs.org', 'cloud.nx.app'],
steps: [
{ name: 'Install', run: 'pnpm install --frozen-lockfile' },
{ name: 'Test', run: 'pnpm nx run-many -t test' },
],
})
- The forge picks the value for the trust level of the run.
- If a secret has no value at that level, the job is skipped, with the reason. To run the job without the secret, declare it as
{ optional: true }. - A host that a secret is bound to must also be in the
egresslist of the job. - A value that is not secret, like a deploy URL, can be a CI variable instead.
What a job sees
A job never holds a secret value. Each declared secret is an environment variable that holds a placeholder: tss_ph_ and 40 random letters and digits, new for each run. In a do step, ctx.secrets holds the same placeholders.
The forge swaps a placeholder for the value on the way out, only when both of these are true:
- The request goes to a host that the secret is bound to.
- The placeholder is in a request header. This includes the user name or the password of HTTP Basic credentials.
The forge refuses the request with 403 in these cases:
- A placeholder goes to any other host. The forge records the request as a leak attempt. The job error names the secret, never the value.
- A placeholder is in the URL path or query, also for a bound host. A host can send its URL back in a response.
The forge swaps no placeholder in a request body. A service that reads its token from the body gets the placeholder.
Each job also gets TSS_TOKEN, the placeholder of its run token. The forge swaps it only for its own git host and REST API. So git push and the forge() client work, within the permissions below.
The egress audit
Every outbound request of a job goes through the forge, and the forge records it in the job's own record:
- The first 500 requests, one by one: the method, the URL, the decision and the status. The URL has no credentials, and each placeholder shows as the name of its secret.
- All requests after those, as a count for each host and decision. After 64 such counts, the requests to other hosts share one count for each decision.
A job makes at most 50,000 outbound requests. A request past that gets 429, and the job error says why.
The audit stays in the record of the job for as long as the forge keeps the logs of the job.
Permissions
A job writes to the forge only with the permissions that it declares, for example permissions: { comment: 'own-pr' }. The default is none: the run token only reads. The trust level of the run sets a ceiling:
| Permission | untrusted | trusted | protected |
|---|---|---|---|
| Read the repository | yes | yes | yes |
comment: 'own-pr' | no | yes | yes |
push: 'own-pr', push: ['ci/*'] | no | yes | yes |
push: ['main', 'tags'], issues: 'write', pulls: 'create', comment: 'any-pr' | no | no | yes |
- A trusted run never pushes to the default branch, a tag, or a branch with a branch rule. A trusted job that asks for
comment: 'any-pr'getscomment: 'own-pr'. - The run shows a warning for each permission that its level takes away.
- The forge audits every write with the run, the job, and the person who started the run. Writes of trusted and protected runs are not screened.
- A hidden workflow gets no egress and no permission to write. It sends no
fixup()and saves no cache, so it writes nothing that other people read. Its logs, findings and artifacts are for admins only.
The full permission types are in the reference.
Approve untrusted runs
An untrusted run waits until a person with write access approves it. Agents and jobs never approve. The approval decides only that the run starts, never what the run can do.
- Open the run page, or the checks of the pull request. The run says that it waits for an approval.
- Read the warnings, and the list of what the run can do.
- Click Approve and run.
From a terminal, run src ci approve <run>.
Approval gates hold protected jobs in the same way. Add a job to the approval gates in the CI settings of the repository, as <job> or <workflow>/<job>. In a protected run, that job waits for Approve and run before it starts. Gates hold jobs of protected runs only.
Cache scopes
cache() keeps its entries in scopes. A run reads its own scope first, then the scopes above it. It writes only to its own scope.
- Protected runs write to
protected, and read onlyprotected. - Trusted runs write to
branch:<name>of their branch. They read it, then the base branch of their pull request, thenprotected. - Untrusted runs of a pull request write to
pr:<number>. They read it, then the base branch, thenprotected. - Untrusted runs without a pull request write nothing.
So a fork never writes an entry that a trusted or protected run reads. A repository keeps 10 GB of cache by default, and the least recently used entries go first.
The images of warm starts are cache entries, so a fork never saves an image that a trusted or protected run mounts. A hidden workflow takes no warm start and makes none.
What a fork pull request gets
Approve the pr run of a pull request from a fork into main. On the run page:
- The run has the label Untrusted.
- Nx gets the read-only value of
NX_CLOUD_ACCESS_TOKEN, and onlycloud.nx.appreceives it. - If the pull request adds
NPM_TOKENto its job, the run shows a warning, and the job runs without the secret.NPM_TOKENalso has nountrustedvalue. - A
git pushor a comment with the run token is refused.
The code of the fork ran with a read-only token, and it had nothing that can publish.