@kici-dev/compiler 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/cli.js +5 -4
  2. package/dist/commands/docs.js +2 -2
  3. package/dist/commands/feedback.d.ts +23 -1
  4. package/dist/commands/feedback.js +86 -9
  5. package/dist/commands/index.d.ts +2 -2
  6. package/dist/commands/index.js +2 -2
  7. package/dist/commands/local.d.ts +13 -5
  8. package/dist/commands/local.js +19 -8
  9. package/dist/commands/report/identity.js +1 -1
  10. package/dist/commands/run-banner.d.ts +1 -1
  11. package/dist/commands/run-banner.js +1 -1
  12. package/dist/commands/run-routed.js +3 -0
  13. package/dist/commands/run.js +5 -2
  14. package/dist/commands/runs/logs.js +3 -2
  15. package/dist/commands/verify-attestation.js +1 -1
  16. package/dist/llm-context/llms-architecture.txt +9 -7
  17. package/dist/llm-context/llms-cli-remote.txt +27 -12
  18. package/dist/llm-context/llms-cli.txt +6 -4
  19. package/dist/llm-context/llms-features-execution.txt +9 -9
  20. package/dist/llm-context/llms-features.txt +452 -133
  21. package/dist/llm-context/llms-full.txt +561 -197
  22. package/dist/llm-context/llms-getting-started.txt +34 -17
  23. package/dist/llm-context/llms-providers.txt +2 -2
  24. package/dist/llm-context/llms-sdk-runtime.txt +16 -2
  25. package/dist/llm-context/llms-sdk.txt +7 -12
  26. package/dist/llm-context/llms.txt +7 -6
  27. package/dist/local-plane/orchestrator-process.d.ts +4 -5
  28. package/dist/local-plane/orchestrator-process.js +5 -1
  29. package/dist/local-plane/paths.d.ts +1 -0
  30. package/dist/local-plane/paths.js +1 -0
  31. package/dist/local-plane/plane-log.d.ts +27 -0
  32. package/dist/local-plane/plane-log.js +39 -0
  33. package/dist/local-plane/plane-manager.js +2 -2
  34. package/dist/local-plane/plane-trigger.d.ts +28 -0
  35. package/dist/local-plane/plane-trigger.js +57 -2
  36. package/dist/local-plane/postgres.js +9 -6
  37. package/dist/local-plane/run-follow.js +2 -1
  38. package/dist/remote/output/streaming.d.ts +12 -0
  39. package/dist/remote/output/streaming.js +20 -1
  40. package/dist/remote/platform-client.d.ts +2 -0
  41. package/dist/templates/agents-md.d.ts +1 -1
  42. package/dist/templates/agents-md.js +9 -7
  43. package/dist/templates/package-json.d.ts +9 -7
  44. package/dist/templates/package-json.js +11 -9
  45. package/dist/templates/workflows/hello-world.ts +1 -1
  46. package/dist/templates/workflows/pr-checks.ts +2 -2
  47. package/dist/test-runner/job-executor.js +3 -2
  48. package/dist/types.d.ts +12 -35
  49. package/dist/types.js +2 -12
  50. package/dist/types.test-d.d.ts +2 -0
  51. package/dist/types.test-d.js +63 -0
  52. package/dist/workflows/hello-world.ts +1 -1
  53. package/dist/workflows/pr-checks.ts +2 -2
  54. package/package.json +15 -15
  55. package/sbom.spdx.json +1303 -1483
@@ -1,6 +1,6 @@
1
1
  # KiCI Workflow features: access and approval
2
2
 
3
- This bundle covers: Deployment contexts, scoped secrets, private registries, approvals, provenance, dashboard and account access.
3
+ This bundle covers: Deployment contexts, scoped secrets, private registries, approvals, workload identity, provenance, dashboard and account access.
4
4
 
5
5
  ## Account and sign-in
6
6
 
@@ -557,13 +557,12 @@ Hold a job whose run came from a fork:
557
557
  Minimum trust: trusted
558
558
  ```
559
559
 
560
- | Value | Effect |
561
- | --------- | ---------------------------------------------------------- |
562
- | `trusted` | Holds a run whose ref came from a fork |
563
- | `known` | Same effect; the value is deprecated and removed at v1.0.0 |
564
- | (unset) | No trust-based gating |
560
+ | Value | Effect |
561
+ | --------- | -------------------------------------- |
562
+ | `trusted` | Holds a run whose ref came from a fork |
563
+ | (unset) | No trust-based gating |
565
564
 
566
- Both values block the same thing. Trust comes from the git ref, and that judgement has two answers: a ref in your repository is `trusted`, a ref from a fork is `unknown`. The value you declare still decides the wording of the hold reason.
565
+ Trust comes from the git ref, and that judgement has two answers: a ref in your repository is `trusted`, a ref from a fork is `unknown`.
567
566
 
568
567
  When the gate holds a job, it enters the security approval queue. Someone with `ci_trust:write` or higher must approve it before execution proceeds.
569
568
 
@@ -718,6 +717,439 @@ This guide is split across the following pages:
718
717
 
719
718
  ---
720
719
 
720
+ ## Workload identity with OIDC
721
+
722
+ Source: https://docs.kici.dev/user/oidc/
723
+
724
+ A job step can prove **which run it is** to an outside service without a stored
725
+ secret. The orchestrator mints a short-lived OIDC identity token for the step,
726
+ signs it with its own key, and the service verifies it against the
727
+ orchestrator's public keys. Your repository holds no long-lived credential for
728
+ that service; the exchanged credential exists only inside the step.
729
+
730
+ The orchestrator is the OIDC issuer. It publishes the discovery document and
731
+ the key set under `KICI_ORCHESTRATOR_PROVENANCE_ISSUER`, and the service you
732
+ exchange with must be able to reach that base URL — see
733
+ [network requirements](https://docs.kici.dev/operator/network-requirements/) and
734
+ [signing keys](https://docs.kici.dev/operator/orchestrator/signing-keys/) for the operator side.
735
+
736
+ ## Requesting an identity token
737
+
738
+ Call `ctx.kici.oidc.token({ audience })` from a step when you need the identity
739
+ token for a tool that trusts your orchestrator:
740
+
741
+ ```typescript
742
+ step('mint', async (ctx) => {
743
+ const minted = await ctx.kici.oidc.token({ audience: 'sigstore' });
744
+ if ('deferred' in minted) throw new Error(`ID token deferred: ${minted.code}`);
745
+ const { token, expiresIn } = minted;
746
+ ctx.log.info(`Got an ID token valid for ${expiresIn}s`);
747
+ // Hand `token` to a tool that exchanges it with a service trusting the issuer.
748
+ });
749
+ ```
750
+
751
+ The result is either the minted token or `{ deferred: true, code }` when the
752
+ orchestrator could not mint one right now (`unavailable` or `failed`). An
753
+ exchange needs a live token, so a step that exchanges it fails on `deferred`;
754
+ [`ctx.attestProvenance`](https://docs.kici.dev/user/provenance/) handles that case for you by freezing
755
+ the statement and fulfilling it later. The token is a short-lived (about 10
756
+ minutes) signed JWT scoped to the current run and job. Its identity claims
757
+ (`repository`, `ref`, `sha`, `kici_run_id`, `kici_job_id`) are derived by the
758
+ orchestrator from the run context, so a step cannot spoof them. The returned
759
+ token value is automatically masked in step logs, and the step never holds
760
+ signing credentials — the orchestrator mints and signs the token on the step's
761
+ behalf from its own run records. It is only available inside a running job step.
762
+
763
+ ## Exchanging the token with an external service
764
+
765
+ Any service that federates with a generic OIDC issuer can trust your
766
+ orchestrator directly. The service fetches your issuer's discovery document
767
+ (`<issuer>/.well-known/openid-configuration`) and public keys, so the issuer
768
+ base URL must be reachable from that service.
769
+
770
+ The pattern is the same everywhere: request a token with the audience the
771
+ service expects, exchange it for the service's own short-lived credential, and
772
+ use that credential inside the step. The workflows below are the ones KiCI's
773
+ own test suite runs against a real Cloudsmith organization and a real AWS
774
+ account. Each publishes a probe, reads it back, and deletes it. Replace the
775
+ probe with your real publish or upload.
776
+
777
+ Only the ID token is masked in step logs automatically. A credential you
778
+ exchange it for is an ordinary string, so do not log it. Hand it to child
779
+ processes through a scratch file or the environment, never on a command line.
780
+
781
+ ### Cloudsmith
782
+
783
+ Cloudsmith exchanges any trusted issuer's token for a Cloudsmith API token
784
+ through `POST https://api.cloudsmith.io/openid/<org>/`. The exchanged
785
+ credential works as the Cloudsmith API key and as an npm auth token:
786
+
787
+ <!-- BEGIN GENERATED: oidc-cloudsmith-workflow (do not edit; run the doc generator) -->
788
+
789
+ ```typescript
790
+ import { writeFile } from 'node:fs/promises';
791
+ import { join } from 'node:path';
792
+ import { workflow, job, step, push } from '@kici-dev/sdk';
793
+
794
+ // Exchanges the job's OIDC ID token with Cloudsmith, publishes a probe npm
795
+ // package, reads it back through the Cloudsmith API and deletes it. Every
796
+ // Cloudsmith call is made with the exchanged token; nothing here is a secret
797
+ // the repo stores.
798
+ const ORG = 'my-org';
799
+ const REPO = 'my-repo';
800
+ const REGISTRY = `https://npm.cloudsmith.io/${ORG}/${REPO}/`;
801
+ const PACKAGES_API = `https://api.cloudsmith.io/v1/packages/${ORG}/${REPO}/`;
802
+ const PREFIX = '[cloudsmith-oidc]';
803
+
804
+ function decodeJwtPart(token: string, index: number): Record<string, unknown> {
805
+ return JSON.parse(Buffer.from(token.split('.')[index], 'base64url').toString('utf8'));
806
+ }
807
+
808
+ export default workflow('publish', {
809
+ on: push({ branches: ['main'] }),
810
+ jobs: [
811
+ job('publish', {
812
+ runsOn: 'container',
813
+ steps: [
814
+ step('exchange-and-publish', async (ctx) => {
815
+ // 1. Mint. Log the signing algorithm, key id and subject: when the
816
+ // provider rejects the token, this line says which claim to compare
817
+ // against the provider's required claims.
818
+ const minted = await ctx.kici.oidc.token({ audience: 'cloudsmith' });
819
+ // A deferred mint is a transient orchestrator-side failure; an exchange
820
+ // needs a live token, so the step fails rather than freezing a statement.
821
+ if ('deferred' in minted) throw new Error(`ID token mint deferred: ${minted.code}`);
822
+ const { token } = minted;
823
+ const header = decodeJwtPart(token, 0);
824
+ const payload = decodeJwtPart(token, 1);
825
+ ctx.log.info(`${PREFIX} alg=${header.alg} kid=${header.kid} sub=${payload.sub}`);
826
+
827
+ // 2. Exchange.
828
+ const exchange = await fetch(`https://api.cloudsmith.io/openid/${ORG}/`, {
829
+ method: 'POST',
830
+ headers: { 'content-type': 'application/json' },
831
+ body: JSON.stringify({ oidc_token: token, service_slug: 'ci-publisher' }),
832
+ });
833
+ if (!exchange.ok) {
834
+ const body = (await exchange.text()).split(token).join('<id-token>');
835
+ ctx.log.error(`${PREFIX} exchange=failed status=${exchange.status} body=${body}`);
836
+ throw new Error(`Cloudsmith token exchange failed: ${exchange.status}`);
837
+ }
838
+ const { token: csToken } = (await exchange.json()) as { token: string };
839
+ ctx.log.info(`${PREFIX} exchange=ok`);
840
+
841
+ // 3. Publish a unique probe version. The npmrc carrying the exchanged
842
+ // token is a scratch file the job removes; it never reaches argv.
843
+ // The run-id segment is prefixed with a letter: a prerelease identifier
844
+ // made only of digits is numeric under semver and may not start with
845
+ // 0, so a run id such as 01234567 would make npm refuse the version.
846
+ const runIdShort = String(payload.kici_run_id).slice(0, 8);
847
+ const version = `0.0.0-e2e.r${runIdShort}.${Math.floor(Date.now() / 1000)}`;
848
+ const name = '@kici-e2e/oidc-probe';
849
+ const pkgDir = await ctx.mktemp('oidc-probe');
850
+ await writeFile(
851
+ join(pkgDir.path, 'package.json'),
852
+ JSON.stringify({ name, version, license: 'MIT', main: 'index.js' }, null, 2),
853
+ );
854
+ await writeFile(join(pkgDir.path, 'index.js'), 'module.exports = "kici oidc probe";\n');
855
+ const npmrc = await ctx.mktempFile('cloudsmith-npmrc');
856
+ await writeFile(
857
+ npmrc.path,
858
+ `//npm.cloudsmith.io/${ORG}/${REPO}/:_authToken=${csToken}\n`,
859
+ );
860
+ await ctx.$({
861
+ cwd: pkgDir.path,
862
+ env: { ...process.env, NPM_CONFIG_USERCONFIG: npmrc.path },
863
+ })`npm publish --registry ${REGISTRY} --tag e2e`;
864
+
865
+ // 4. Read it back through the API with the same token, then delete.
866
+ // The version is unique per run, so it is the whole query.
867
+ const authHeaders = { 'X-Api-Key': `Bearer ${csToken}` };
868
+ const query = encodeURIComponent(`version:${version}`);
869
+ let slug: string | undefined;
870
+ const deadline = Date.now() + 120_000;
871
+ while (Date.now() < deadline && !slug) {
872
+ const list = await fetch(`${PACKAGES_API}?query=${query}`, { headers: authHeaders });
873
+ if (list.ok) {
874
+ const rows = (await list.json()) as Array<{
875
+ slug: string;
876
+ is_sync_completed: boolean;
877
+ }>;
878
+ const done = rows.find((r) => r.is_sync_completed);
879
+ if (done) slug = done.slug;
880
+ }
881
+ if (!slug) await new Promise((r) => setTimeout(r, 5_000));
882
+ }
883
+ if (!slug) throw new Error(`${name}@${version} never finished syncing in Cloudsmith`);
884
+ const del = await fetch(`${PACKAGES_API}${slug}/`, {
885
+ method: 'DELETE',
886
+ headers: authHeaders,
887
+ });
888
+ if (!del.ok) throw new Error(`Cloudsmith delete failed: ${del.status}`);
889
+ ctx.log.info(`${PREFIX} verdict=ok package=${name}@${version} deleted=true`);
890
+ }),
891
+ ],
892
+ }),
893
+ ],
894
+ });
895
+ ```
896
+
897
+ <!-- END GENERATED: oidc-cloudsmith-workflow -->
898
+
899
+ On the Cloudsmith side, add an OpenID Connect provider under your organization's
900
+ authentication settings. The **provider URL** is your orchestrator's issuer
901
+ (`KICI_ORCHESTRATOR_PROVENANCE_ISSUER`), and the **service account** is the one
902
+ named by `service_slug`. The **required claims** pin which runs may
903
+ authenticate. Pin `sub` to the build identity; for production add the fork
904
+ conditions from the [claim table](https://docs.kici.dev/user/oidc/#the-claim-set) below so a fork pull request
905
+ cannot exchange a token:
906
+
907
+ ```
908
+ sub: repo:acme/app:ref:main:workflow:publish
909
+ is_fork: false
910
+ head_repository: acme/app
911
+ ```
912
+
913
+ ### AWS
914
+
915
+ AWS exchanges the token through `AssumeRoleWithWebIdentity`, which needs no AWS
916
+ credential of its own. The step imports `@aws-sdk/client-sts` and
917
+ `@aws-sdk/client-s3`; declare both under `dependencies` in `.kici/package.json`
918
+ so the compiled workflow can load them.
919
+
920
+ <!-- BEGIN GENERATED: oidc-aws-workflow (do not edit; run the doc generator) -->
921
+
922
+ ```typescript
923
+ import { workflow, job, step, push } from '@kici-dev/sdk';
924
+ import { STSClient, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
925
+ import {
926
+ S3Client,
927
+ PutObjectCommand,
928
+ GetObjectCommand,
929
+ DeleteObjectCommand,
930
+ ListObjectsV2Command,
931
+ } from '@aws-sdk/client-s3';
932
+
933
+ // Exchanges the job's OIDC ID token with AWS STS for role credentials, writes
934
+ // one probe object to S3, reads it back and deletes it. Nothing here is a
935
+ // secret the repo stores; the credentials exist only inside this step.
936
+ const REGION = 'eu-central-1';
937
+ const ROLE_ARN = 'arn:aws:iam::123456789012:role/ci-uploader';
938
+ const BUCKET = 'my-artifacts';
939
+ const PREFIX = '[aws-oidc]';
940
+
941
+ function decodeJwtPart(token: string, index: number): Record<string, unknown> {
942
+ return JSON.parse(Buffer.from(token.split('.')[index], 'base64url').toString('utf8'));
943
+ }
944
+
945
+ export default workflow('upload', {
946
+ on: push({ branches: ['main'] }),
947
+ jobs: [
948
+ job('upload', {
949
+ runsOn: 'container',
950
+ steps: [
951
+ step('assume-and-put', async (ctx) => {
952
+ // 1. Mint. Log the signing algorithm, key id, subject and audience:
953
+ // when STS rejects the token, this line says which claim to compare
954
+ // against the role's trust policy.
955
+ const minted = await ctx.kici.oidc.token({ audience: 'sts.amazonaws.com' });
956
+ // A deferred mint is a transient orchestrator-side failure; an exchange
957
+ // needs a live token, so the step fails rather than freezing a statement.
958
+ if ('deferred' in minted) throw new Error(`ID token mint deferred: ${minted.code}`);
959
+ const { token } = minted;
960
+ const header = decodeJwtPart(token, 0);
961
+ const payload = decodeJwtPart(token, 1);
962
+ ctx.log.info(
963
+ `${PREFIX} alg=${header.alg} kid=${header.kid} sub=${payload.sub} aud=${payload.aud}`,
964
+ );
965
+
966
+ // 2. Exchange. AssumeRoleWithWebIdentity is an unsigned operation, so
967
+ // the client needs no credentials of its own.
968
+ const runId = String(payload.kici_run_id);
969
+ const sts = new STSClient({ region: REGION });
970
+ let creds;
971
+ try {
972
+ const out = await sts.send(
973
+ new AssumeRoleWithWebIdentityCommand({
974
+ RoleArn: ROLE_ARN,
975
+ RoleSessionName: `kici-e2e-${runId.slice(0, 8)}`,
976
+ WebIdentityToken: token,
977
+ DurationSeconds: 900,
978
+ }),
979
+ );
980
+ creds = out.Credentials;
981
+ } catch (err) {
982
+ const e = err as { name?: string; message?: string };
983
+ const message = String(e.message ?? err)
984
+ .split(token)
985
+ .join('<id-token>');
986
+ ctx.log.error(`${PREFIX} assume=failed code=${e.name ?? 'unknown'} message=${message}`);
987
+ throw err;
988
+ }
989
+ if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) {
990
+ throw new Error('AssumeRoleWithWebIdentity returned no credentials');
991
+ }
992
+ ctx.log.info(`${PREFIX} assume=ok`);
993
+
994
+ // 3. Put + get with the role credentials.
995
+ const s3 = new S3Client({
996
+ region: REGION,
997
+ credentials: {
998
+ accessKeyId: creds.AccessKeyId,
999
+ secretAccessKey: creds.SecretAccessKey,
1000
+ sessionToken: creds.SessionToken,
1001
+ },
1002
+ });
1003
+ const key = `e2e/${runId}/probe.txt`;
1004
+ const body = `kici aws-oidc probe run=${runId} ts=${new Date().toISOString()}\n`;
1005
+ await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body }));
1006
+ const got = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }));
1007
+ const readBack = await got.Body?.transformToString();
1008
+ if (readBack !== body) {
1009
+ throw new Error(`probe object did not round-trip: got ${JSON.stringify(readBack)}`);
1010
+ }
1011
+
1012
+ // 4. Delete, then prove the prefix is empty.
1013
+ await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
1014
+ const left = await s3.send(
1015
+ new ListObjectsV2Command({ Bucket: BUCKET, Prefix: `e2e/${runId}/` }),
1016
+ );
1017
+ if ((left.KeyCount ?? 0) !== 0) {
1018
+ throw new Error(`${left.KeyCount} object(s) left under e2e/${runId}/`);
1019
+ }
1020
+ ctx.log.info(`${PREFIX} verdict=ok bucket=${BUCKET} key=${key} deleted=true`);
1021
+ }),
1022
+ ],
1023
+ }),
1024
+ ],
1025
+ });
1026
+ ```
1027
+
1028
+ <!-- END GENERATED: oidc-aws-workflow -->
1029
+
1030
+ On the AWS side, create an IAM OpenID Connect identity provider whose URL is
1031
+ your orchestrator's issuer and whose audience is `sts.amazonaws.com`, then
1032
+ attach the [worked trust policy](https://docs.kici.dev/user/oidc/#a-worked-aws-trust-policy) to the role. When
1033
+ the issuer URL carries a path, the condition keys carry it too:
1034
+ `orch.example.com/kici:sub`, not `orch.example.com:sub`.
1035
+
1036
+ ## ID-token claims and cloud trust policies
1037
+
1038
+ A cloud provider's OIDC trust policy decides which builds may assume a role. The
1039
+ token below is what your policy matches on, so read this section before you
1040
+ write one.
1041
+
1042
+ ### The claim set
1043
+
1044
+ | Claim | Value |
1045
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
1046
+ | `iss` | Your orchestrator's provenance issuer |
1047
+ | `aud` | The audience you asked for |
1048
+ | `sub` | The build identity — see the two shapes below |
1049
+ | `repository` | `owner/repo` the run acted on |
1050
+ | `ref` | The branch or tag the run PRESENTS. For a pull request this is the **base** branch, not the contributor's branch |
1051
+ | `base_ref` | The same value as `ref`, named the way GitHub Actions names it |
1052
+ | `head_ref` | The pull request's HEAD branch; `''` for a non-PR run |
1053
+ | `head_repository` | `owner/repo` of the pull-request HEAD — the contributor's fork for a fork PR; `''` for a non-PR run |
1054
+ | `is_fork` | `'true'`, `'false'`, or `'unresolved'` |
1055
+ | `event_name` | The event that started the run (`push`, `pull_request:opened`, `schedule`, …) |
1056
+ | `trust_tier` | The resolved trust tier of the triggering actor, or `'unresolved'` |
1057
+ | `actor` | Provider login of the triggering actor |
1058
+ | `sha` | The run's commit |
1059
+ | `workflow_ref` | `<workflow name>@<sha>` |
1060
+ | `kici_run_id` / `kici_job_id` | The run and job this token was minted for |
1061
+ | `org_id` | Your organization id |
1062
+ | `orchestrator_id` | The orchestrator that ran the job |
1063
+ | `provider` | The source provider the run came from (`github`, `gitlab`, …) |
1064
+ | `source_origin` | `triggered` for a webhook-driven run; `run-remote` when the run executed an uploaded working tree (`kici run remote`) |
1065
+ | `attestation_origin` | `live` when the token was minted during the job; `deferred` or `offline-backfill` when it was minted later for a frozen statement |
1066
+ | `statement_hash` | The hash of the frozen statement a deferred token is bound to; `null` for a live token |
1067
+
1068
+ Every claim in the table is **always present**. A value the run did not resolve
1069
+ is `''`, `'unresolved'` or `null`, never omitted and never guessed. That matters: an
1070
+ absent claim makes a `StringEquals` condition pass, which would silently remove
1071
+ a constraint you wrote expecting it to be enforced.
1072
+
1073
+ ### The two `sub` shapes
1074
+
1075
+ ```
1076
+ push, tag, schedule, … repo:<owner/repo>:ref:<ref>:workflow:<workflow name>
1077
+ pull request, review repo:<owner/repo>:pull_request
1078
+ ```
1079
+
1080
+ The pull-request shape carries **no ref segment**, mirroring GitHub Actions. A
1081
+ pull request's `ref` is its base branch. So a ref-bearing subject would be
1082
+ identical for a fork pull request targeting `main` and a trusted push to `main`.
1083
+ A policy pinning that subject would hand your cloud role to any contributor who
1084
+ opened a pull request running the same workflow.
1085
+
1086
+ **A re-run keeps the shape of the run it repeats.** Re-running a pull-request
1087
+ run presents `repo:<owner/repo>:pull_request`, because it rebuilds the same
1088
+ commit from the same source. Its `event_name` claim still reads `rerun` — that
1089
+ claim says what started the run, while `sub` says which identity the run
1090
+ presents. A policy that pins the branch-shaped subject therefore does not match
1091
+ a re-run of a pull request, which is the same protection the first run gets.
1092
+
1093
+ ### A worked AWS trust policy
1094
+
1095
+ Pin `sub`, and pin the fork context too. `sub` alone tells you a pull request
1096
+ ran; it does not tell you whose code ran.
1097
+
1098
+ ```json
1099
+ {
1100
+ "Version": "2012-10-17",
1101
+ "Statement": [
1102
+ {
1103
+ "Effect": "Allow",
1104
+ "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/orch.example.com" },
1105
+ "Action": "sts:AssumeRoleWithWebIdentity",
1106
+ "Condition": {
1107
+ "StringEquals": {
1108
+ "orch.example.com:aud": "sts.amazonaws.com",
1109
+ "orch.example.com:sub": "repo:acme/app:ref:main:workflow:upload",
1110
+ "orch.example.com:is_fork": "false",
1111
+ "orch.example.com:head_repository": "acme/app",
1112
+ "orch.example.com:trust_tier": "trusted"
1113
+ }
1114
+ }
1115
+ }
1116
+ ]
1117
+ }
1118
+ ```
1119
+
1120
+ This grants the role only to a run on `main` in `acme/app`, from code in that
1121
+ same repository, triggered by an actor your orchestrator resolved as trusted.
1122
+ A fork pull request fails on every one of the extra conditions, and a run whose
1123
+ context did not resolve fails too — `'unresolved'` matches none of them, so the
1124
+ policy fails closed.
1125
+
1126
+ To let a same-repo pull request assume the role, add a second statement pinning
1127
+ `"sub": "repo:acme/app:pull_request"` alongside `"is_fork": "false"` and
1128
+ `"head_repository": "acme/app"`.
1129
+
1130
+ ### Migrating an existing policy
1131
+
1132
+ If you already pin a ref-bearing `sub` for pull-request runs, that policy stops
1133
+ matching once you upgrade — which is the fix, because it was matching runs it
1134
+ should not have. Move it to `repo:<owner/repo>:pull_request` plus the fork
1135
+ conditions above. The same move covers a re-run of a pull request, which
1136
+ presents the pull-request subject too.
1137
+
1138
+ ## See also
1139
+
1140
+ - [Build provenance and attestations](https://docs.kici.dev/user/provenance/) — `ctx.attestProvenance`
1141
+ builds on the identity token this page mints.
1142
+ - [Private registries](https://docs.kici.dev/user/private-registries/) — registry auth for installs,
1143
+ and where an exchanged Cloudsmith token fits.
1144
+ - [SDK runtime reference](https://docs.kici.dev/user/sdk/runtime/) — the `ctx.kici.oidc.token` step
1145
+ API in full.
1146
+ - [Signing keys](https://docs.kici.dev/operator/orchestrator/signing-keys/) — provisioning and
1147
+ rotating the key that signs these tokens.
1148
+ - [Network requirements](https://docs.kici.dev/operator/network-requirements/) — what the
1149
+ exchanging service must reach.
1150
+
1151
+ ---
1152
+
721
1153
  ## Private npm registries
722
1154
 
723
1155
  Source: https://docs.kici.dev/user/private-registries/
@@ -941,6 +1373,10 @@ registries: [
941
1373
  ],
942
1374
  ```
943
1375
 
1376
+ To publish to Cloudsmith from a step without a stored token, exchange the job's
1377
+ OIDC identity token for a Cloudsmith credential — see
1378
+ [exchanging the token with an external service](https://docs.kici.dev/user/oidc/#exchanging-the-token-with-an-external-service).
1379
+
944
1380
  ## Security model
945
1381
 
946
1382
  - **Per-context scoping.** Every `tokenSecret` and `installEnv` entry is qualified with a context name. The orchestrator runs the same protection-rule pipeline (branch / trust / concurrency / reviewer / wait-timer) against each named context **before** resolving any secret, so a workflow that wants a `production` token from a feature branch is rejected exactly like a job that tries to deploy to `production` from a feature branch. A reviewer-gated install context **pauses** the whole workflow dispatch as a workflow-scoped held run instead of resolving the token — see [Reviewer-gated installs](https://docs.kici.dev/user/private-registries/#reviewer-gated-installs) below.
@@ -972,7 +1408,7 @@ The orchestrator exposes Prometheus counters and a histogram under the `kici_orc
972
1408
  | ------------------------------------------------------------- | --------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
973
1409
  | `kici_orch_install_secrets_decisions_total` | Counter | `decision`, `reason` | Pass / reject / hold volume. `decision=hold` (reason `held`) counts dispatches paused at a reviewer-gated install context. Reject reasons enumerate the failure mode: `malformed_ref`, `invalid_url_scheme`, `env_not_found`, `protection_rule_block`, `missing_token`, `missing_install_env`, etc. |
974
1410
  | `kici_orch_install_secrets_npm_registry_used_total` | Counter | `channel`, `provider`, `scope` | Per-channel + per-scope usage. `channel=registries` is Option A, `channel=install_env` is Option C. `scope=default` marks a no-scope default registry; `scope=-` marks Option C entries. |
975
- | `kici_orch_install_secrets_contributor_stripped_total` | Counter | `trust_tier` | Number of dispatches where registry tokens were stripped because the contributor tier wasn't `trusted` (fork PRs from unknown / known contributors). Expected to be 0 in single-tenant orgs. |
1411
+ | `kici_orch_install_secrets_contributor_stripped_total` | Counter | `trust_tier` | Number of dispatches where registry tokens were stripped because the contributor tier wasn't `trusted` (fork PRs). Expected to be 0 in single-tenant orgs. |
976
1412
  | `kici_orch_install_secrets_token_resolution_duration_seconds` | Histogram | `environment` | Latency of per-environment secret resolution. Pathological tails (>500ms) usually mean a Vault timeout or a slow Postgres replica. |
977
1413
 
978
1414
  The dashboard JSON lives at `infra/terraform/modules/grafana/dashboards/install-secrets.json`; if you maintain your own monitoring stack, you can import it directly.
@@ -1069,7 +1505,10 @@ path or a precomputed digest:
1069
1505
 
1070
1506
  The identity token is fetched and masked in logs automatically — you never
1071
1507
  handle it. The call returns `{ storageKey, subjectDigest, bundleMediaType }`
1072
- identifying the stored bundle.
1508
+ identifying the stored bundle. `ctx.attestProvenance` builds on the run's OIDC
1509
+ identity token; to mint that token yourself and exchange it with Cloudsmith,
1510
+ AWS, or any OIDC-federated service, see
1511
+ [Workload identity with OIDC](https://docs.kici.dev/user/oidc/).
1073
1512
 
1074
1513
  `ctx.attestProvenance` is only available inside a running job step; calling it
1075
1514
  outside one rejects with a clear error. `kici run --local` runs are supported:
@@ -1077,128 +1516,6 @@ the offline local dev plane signs with a dev identity under the
1077
1516
  clearly-non-production issuer `kici-local`, and those bundles verify against a
1078
1517
  trust root exported with `kici local trust-root`.
1079
1518
 
1080
- ### Requesting a raw identity token
1081
-
1082
- `ctx.attestProvenance` builds on a lower-level primitive you can call directly
1083
- when you need the identity token for a different tool:
1084
-
1085
- ```typescript
1086
- step('mint', async (ctx) => {
1087
- const { token, expiresIn } = await ctx.kici.oidc.token({ audience: 'sigstore' });
1088
- ctx.log.info(`Got an ID token valid for ${expiresIn}s`);
1089
- // Hand `token` to a tool that exchanges it with a service trusting the issuer.
1090
- });
1091
- ```
1092
-
1093
- The token is a short-lived (about 10 minutes) signed JWT scoped to the current
1094
- run and job. Its identity claims (`repository`, `ref`, `sha`, `kici_run_id`,
1095
- `kici_job_id`) are derived by the orchestrator from the run context, so a step
1096
- cannot spoof them. The returned token value is automatically masked in step logs,
1097
- and the step never holds signing credentials — the orchestrator mints and signs
1098
- the token on the step's behalf from its own run records. Like `attestProvenance`,
1099
- it is only available inside a running job step.
1100
-
1101
- ## ID-token claims and cloud trust policies
1102
-
1103
- A cloud provider's OIDC trust policy decides which builds may assume a role. The
1104
- token below is what your policy matches on, so read this section before you
1105
- write one.
1106
-
1107
- ### The claim set
1108
-
1109
- | Claim | Value |
1110
- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |
1111
- | `iss` | Your orchestrator's provenance issuer |
1112
- | `aud` | The audience you asked for |
1113
- | `sub` | The build identity — see the two shapes below |
1114
- | `repository` | `owner/repo` the run acted on |
1115
- | `ref` | The branch or tag the run PRESENTS. For a pull request this is the **base** branch, not the contributor's branch |
1116
- | `base_ref` | The same value as `ref`, named the way GitHub Actions names it |
1117
- | `head_ref` | The pull request's HEAD branch; `''` for a non-PR run |
1118
- | `head_repository` | `owner/repo` of the pull-request HEAD — the contributor's fork for a fork PR; `''` for a non-PR run |
1119
- | `is_fork` | `'true'`, `'false'`, or `'unresolved'` |
1120
- | `event_name` | The event that started the run (`push`, `pull_request:opened`, `schedule`, …) |
1121
- | `trust_tier` | The resolved trust tier of the triggering actor, or `'unresolved'` |
1122
- | `actor` | Provider login of the triggering actor |
1123
- | `sha` | The run's commit |
1124
- | `workflow_ref` | `<workflow name>@<sha>` |
1125
- | `kici_run_id` / `kici_job_id` | The run and job this token was minted for |
1126
- | `org_id` | Your organization id |
1127
-
1128
- Every claim in the table is **always present**. A value the run did not resolve
1129
- is `''` or `'unresolved'`, never omitted and never guessed. That matters: an
1130
- absent claim makes a `StringEquals` condition pass, which would silently remove
1131
- a constraint you wrote expecting it to be enforced.
1132
-
1133
- ### The two `sub` shapes
1134
-
1135
- ```
1136
- push, tag, schedule, … repo:<owner/repo>:ref:<ref>:workflow:<workflow name>
1137
- pull request, review repo:<owner/repo>:pull_request
1138
- ```
1139
-
1140
- The pull-request shape carries **no ref segment**, mirroring GitHub Actions. A
1141
- pull request's `ref` is its base branch. So a ref-bearing subject would be
1142
- identical for a fork pull request targeting `main` and a trusted push to `main`.
1143
- A policy pinning that subject would hand your cloud role to any contributor who
1144
- opened a pull request running the same workflow.
1145
-
1146
- **A re-run keeps the shape of the run it repeats.** Re-running a pull-request
1147
- run presents `repo:<owner/repo>:pull_request`, because it rebuilds the same
1148
- commit from the same source. Its `event_name` claim still reads `rerun` — that
1149
- claim says what started the run, while `sub` says which identity the run
1150
- presents. A policy that pins the branch-shaped subject therefore does not match
1151
- a re-run of a pull request, which is the same protection the first run gets.
1152
-
1153
- ### A worked AWS trust policy
1154
-
1155
- Pin `sub`, and pin the fork context too. `sub` alone tells you a pull request
1156
- ran; it does not tell you whose code ran.
1157
-
1158
- ```json
1159
- {
1160
- "Version": "2012-10-17",
1161
- "Statement": [
1162
- {
1163
- "Effect": "Allow",
1164
- "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/orch.example.com" },
1165
- "Action": "sts:AssumeRoleWithWebIdentity",
1166
- "Condition": {
1167
- "StringEquals": {
1168
- "orch.example.com:aud": "sts.amazonaws.com",
1169
- "orch.example.com:sub": "repo:acme/app:ref:main:workflow:deploy",
1170
- "orch.example.com:is_fork": "false",
1171
- "orch.example.com:head_repository": "acme/app",
1172
- "orch.example.com:trust_tier": "trusted"
1173
- }
1174
- }
1175
- }
1176
- ]
1177
- }
1178
- ```
1179
-
1180
- This grants the role only to a run on `main` in `acme/app`, from code in that
1181
- same repository, triggered by an actor your orchestrator resolved as trusted.
1182
- A fork pull request fails on all three of the extra conditions, and a run whose
1183
- context did not resolve fails too — `'unresolved'` matches none of them, so the
1184
- policy fails closed.
1185
-
1186
- To let a same-repo pull request assume the role, add a second statement pinning
1187
- `"sub": "repo:acme/app:pull_request"` alongside `"is_fork": "false"` and
1188
- `"head_repository": "acme/app"`.
1189
-
1190
- ### Migrating an existing policy
1191
-
1192
- If you already pin a ref-bearing `sub` for pull-request runs, that policy stops
1193
- matching once you upgrade — which is the fix, because it was matching runs it
1194
- should not have. Move it to `repo:<owner/repo>:pull_request` plus the fork
1195
- conditions above. The same move covers a re-run of a pull request, which
1196
- presents the pull-request subject too.
1197
-
1198
- While you migrate, `KICI_OIDC_LEGACY_PR_SUB=1` on the orchestrator restores the
1199
- old subject. It restores the collision with it, so treat it as a short bridge,
1200
- not a setting. See [deprecations](https://docs.kici.dev/user/deprecations/).
1201
-
1202
1519
  ## Verifying an attestation
1203
1520
 
1204
1521
  Verify a bundle with the `kici verify-attestation` command. It establishes the
@@ -1374,8 +1691,10 @@ This page shows the parsed provenance for one attestation.
1374
1691
 
1375
1692
  ## See also
1376
1693
 
1377
- - [SDK runtime reference](https://docs.kici.dev/user/sdk/runtime/) — the `ctx.attestProvenance` and
1378
- `ctx.kici.oidc.token` step APIs in full.
1694
+ - [Workload identity with OIDC](https://docs.kici.dev/user/oidc/) — minting the identity token
1695
+ yourself and exchanging it with an external service.
1696
+ - [SDK runtime reference](https://docs.kici.dev/user/sdk/runtime/) — the `ctx.attestProvenance` step
1697
+ API in full.
1379
1698
  - [CLI reference](https://docs.kici.dev/user/cli/notifications-and-diagnostics/#kici-verify-attestation) — every
1380
1699
  `kici verify-attestation` flag and exit code.
1381
1700