Secure CI/CD for AI-assisted code: trust boundaries before automation
Separate untrusted code validation from reviewed delivery, scope GitHub Actions permissions and OIDC trust, and verify artifacts without overstating provenance.
- Published
The short answer
Treat AI-generated code, pull-request text, build scripts and downloaded artifacts as untrusted inputs until the relevant checks and human reviews are complete. Keep pull-request validation without deployment credentials, build the reviewed revision in a separate trusted context, verify the exact artifact against independently maintained expectations, then authorise delivery with narrowly scoped short-lived identity. Neither a passing workflow, a pinned action nor signed provenance proves that the code is safe. Bind release approval to the source revision, workflow, artifact digest and destination—not to an agent's summary.
Generated code is a proposal, not a trusted principal
This guide assumes familiarity with Git, pull requests, YAML workflows and basic cloud identity. The practical question is not whether a model or a person wrote a patch. It is which capabilities become available when that patch is processed. A generated package script, workflow edit or test helper can execute with the runner's filesystem, network reachability and credentials. Tests themselves are executable code. A convincing explanation in a pull request does not justify giving its author or an agent production access.
An attacker can control a branch, pull-request title, issue body, dependency response or artifact produced by an earlier untrusted job. Repository documentation and logs can also contain instructions aimed at an assistant. Treat these as data, not permission to change a policy, approve a release or retrieve a secret. Review generated code for ordinary defects as well as malicious behaviour: a model need not be compromised to produce an unsafe workflow.
| Stage | Permitted input and authority | Evidence before advancing |
|---|---|---|
| Validate a pull request | Untrusted source and metadata; minimal read-only token, isolated runner, no deployment identity. | Tests and analysis of the proposed change, including workflow/build-script changes; failures remain visible. |
| Build after review | The exact protected, approved source revision and reviewed build definition; only the minimum build permissions. | Recorded source revision, dependencies, builder identity and output digest; provenance from the selected trusted builder. |
| Verify a candidate | Treat artifact bytes and accompanying metadata as untrusted until checked by a trusted verifier. | Signature/trust-root validation plus artifact digest, expected source, builder, build type and parameters. |
| Approve and deliver | An approved candidate digest and destination; short-lived, narrowly scoped delivery role. | Human release decision for that candidate, verified environment protections and rollback/stop ownership. |
Do not move a pull-request artifact into production merely because a second workflow runs with more privilege. In a reviewed-source design, rebuild the approved revision and verify that candidate. Where promotion of an existing build is required, prove the original build and source satisfy the release policy. Never silently rebuild after approval and assume the new bytes are covered by the old decision.
Keep untrusted text out of generated shell scripts
GitHub expressions are evaluated before the shell runs. Interpolating a pull-request title directly into a run command can turn punctuation in the title into shell syntax. GitHub recommends passing the value through an action argument or an intermediate environment variable. Quote shell variables and avoid eval, sh -c on the value, or later string-to-command conversion. Passing through env removes this particular script-generation boundary; it does not validate an arbitrary command or make a dangerous program safe. [1]
# Unsafe: PR text becomes part of the generated script
- run: echo "${{ github.event.pull_request.title }}"
# Data handling: keep the format string fixed and quote the value
- env:
TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$TITLE"Apply the same reasoning to branch names, matrix values, workflow inputs and downloaded filenames. Avoid assuming an allowlist of suspicious characters is a complete shell parser. Also review tools invoked by the script: a safe argument boundary can still pass a dangerous option, path or program to the next interpreter. The exercise deliberately flags every expression in run strings; that conservative rule can flag harmless expressions too.
Keep privileges away from untrusted execution
For ordinary validation, prefer pull_request with explicit minimal permissions. Set restrictive workflow defaults and grant extra permissions only to a job that needs them. An action can access github.token even if the workflow does not explicitly pass it as an input. A read-only GITHUB_TOKEN is not a network sandbox, and private registry credentials can still be stolen by install hooks. Keep secrets out of untrusted execution, including application setup and tests. [1]
pull_request_target and workflow_run are privileged contexts, not shortcuts around fork restrictions. GitHub warns that checking out untrusted code there can expose write tokens, secrets and default-branch cache scope. workflow_run can separate privileges, but its artifacts and upstream conclusions are still inputs to validate; 'the preceding run succeeded' is not artifact authentication. Avoid these triggers unless that privileged function is necessary, and never execute an untrusted checkout in that context. [1]
- Protect workflow, build-script, dependency and policy changes with actual enforced review rules. CODEOWNERS alone is not a required-review rule; confirm branch protection/rulesets and bypass rights.
- Disable persisted checkout credentials where later steps do not need Git authentication. This reduces one credential surface, not all token access available to actions.
- Separate untrusted validation and delivery runner pools, workspaces, caches and artifacts. Do not restore attacker-influenced executable cache content into a privileged job.
- Use clean isolated runners with deliberate network restrictions. A self-hosted label or an ephemeral registration does not prove clean hardware, absence of internal network access or isolation from another job.
- Keep logs and evidence minimal: no tokens, customer payloads or private prompts. Automatic secret masking is not guaranteed, especially after transformations. Define who may read logs and how long they are retained.
A new checkout release's guardrails are not a substitute for this design. The pinned fixture uses checkout v6.0.2; its exact action metadata was inspected and declares node24. The release README requires Actions Runner 2.327.1 or later for that runtime and 2.329.0 or later for authenticated Git from Docker container actions. The current mutable v6 README contains an additional unsafe-PR-checkout input that is absent from the inspected v6.0.2 action metadata. Do not assume documentation for a moving major tag describes a pinned commit. None of these runner behaviours was executed here. [7–9]
Lock actions and application dependencies separately
As checked on 31 October 2026, GitHub's secure-use reference recommends workflow dependency lockfiles as stronger protection for repositories that adopt them. The locking how-to lists GitHub Enterprise Cloud and repository write access. Do not assume the feature exists on another plan or GitHub Enterprise Server, or claim it is enabled because a file exists. Check actual eligibility, workflow onboarding and enforcement. No lockfile extension was installed or run for this guide. [1–3]
The documented .github/workflows/actions.lock is generated by gh actions-lock, not hand-edited. It records commits and source owner/repository IDs for action dependencies, including transitive action dependencies. The checked example schema is v0.0.3, not a universal promise about future formats. Calls to reusable workflows, jobs.<job_id>.uses, are not pinned by the caller's lockfile: pin those calls to full SHAs and review the called repository's own dependency controls. Local ./ actions run checked-out code and need separate scrutiny. A dependency entry for another workflow is not coverage for this one. [2–3]
Where lockfiles cannot be adopted, pin action and reusable-workflow references to verified full commit SHAs from the intended upstream repository. A syntactically valid 40-character SHA is not proof of upstream identity, reviewed code, supported runtime or safe transitive behaviour. Review action source and updates; tags can move. Container images need immutable digests and provenance checks, not just a version-like tag. The offline checker models only direct full-SHA action references and deliberately rejects local, container and reusable-workflow cases rather than pretending to inspect them.
An Actions lockfile does not lock your application's packages, runner image, input parameters or downloaded tools. Commit the package manager's lockfile; use its frozen/immutable installation mode; pin the package manager, runtime and tool versions; verify distributions from their intended source. Review lifecycle scripts and generated dependency updates as code. Deterministic resolution can faithfully reinstall a vulnerable or malicious package, so keep vulnerability analysis, licence/IP checks and an owned update process separate from pinning.
Match OIDC claims to the actual repository context
OIDC replaces certain stored long-lived cloud credentials with short-lived identity; it does not decide what a release should be allowed to do. id-token: write permits a job to request an OIDC token. It is not itself cloud write access, but the cloud role obtained with that token may have powerful permissions. Grant it only to the job requiring that exchange, keep untrusted steps out of that job, and separately constrain the resulting role's resource permissions. [4–5]
The provider must validate the signed token, issuer, validity and intended audience, then enforce precise trust conditions for the source context. A repository-wide wildcard subject and a broad cloud role can turn short-lived identity into broad short-lived authority. The audience is not universally sts.amazonaws.com: GitHub documents an owner-URL default, while login actions/providers can request a different audience. Match the actual provider/action contract; the AWS-style audience in the exercise is synthetic, not an account configuration. [5]
| Context | Illustrative subject | Important condition |
|---|---|---|
| Previous branch format | repo:example-org/example-app:ref:refs/heads/main | Not an environment subject; older repositories may still use this format. |
| Immutable branch format | repo:example-org@123456/example-app@456789:ref:refs/heads/main | Includes owner and repository IDs; do not remove them when copying policy. |
| Immutable environment context | repo:example-org@123456/example-app@456789:environment:production | Environment replaces the branch context; enforce allowed deployment branches separately. |
| Previous PR format, without environment | repo:example-org/example-app:pull_request | Do not use this as production-delivery trust. |
| Colon inside an environment name | repo:example-org/example-app:environment:Production%3AV1 | Metadata colons are encoded as %3A; exact spelling and context matter. |
The current reference says repositories created after 15 July 2026 use the immutable default subject format; older repositories retain the previous format unless they opt in. Renames and transfers after that date also move to immutable subjects. This rollout does not include GitHub Enterprise Server. Custom subject templates replace the default format and require matching provider policy; immutable IDs remain included in the repo segment when that segment is used. Environment context takes precedence over branch/PR context, so an environment subject alone does not prove the release came from main. [4–5]
For reusable delivery workflows, consider binding the reviewed workflow identity as well as the caller context. Which claims a provider can condition on differs; GitHub's secure-use reference notes that AWS does not support custom claims directly. Do not paste a condition on job_workflow_ref and assume AWS will evaluate it as a standalone claim. Evaluate a supported custom subject design where appropriate and verify its actual emitted format. Plan any transition with the identity owner so policy and token format change together without temporarily opening broad trust. [1,5]
Before an approved live adoption, record expected issuer, audience, exact subject, repository identity, environment restrictions, workflow revision and permitted resources. Inspect only the required claim fields through an approved procedure; do not paste bearer JWTs into public token decoders or logs. The offline exercise neither requests a token nor verifies any real claims. Its exact-string comparison is intentionally not a JWT verifier or a cloud IAM policy evaluator.
Provenance helps establish origin, not vulnerability freedom
SLSA v1.2 is the explicit specification reference here, not an achieved level. Its artifact-verification guidance includes verifying the provenance envelope signature against configured trust roots, matching the subject digest to the actual artifact bytes, checking the provenance predicate type, and checking the expected builder, canonical source, build type and external parameters. Unrecognised external parameters should fail verification unless an explicit reviewed policy allows them. Expectations must come from a trusted policy—not from the same untrusted download as the artifact. [6]
A checksum copied alongside an artifact detects accidental disagreement, but an attacker who can replace both can keep them consistent. A signed statement from an untrusted builder is also insufficient. Verify trust and expectations at the consumption boundary and deliver by the verified immutable digest, avoiding a mutable 'latest' lookup between verification and deployment. Record which revision and candidate were approved. Artifact names and successful run labels are not substitutes for digest binding.
The SLSA build requirements distinguish provenance existence, authenticity, resistance to forgery and build isolation. Even stronger provenance does not prove the approved source lacks vulnerabilities, licence issues, data disclosure or faulty business logic. It does not prove a model's output was reviewed. Keep code review, tests, dependency/security analysis, source controls and operational acceptance alongside provenance verification. Do not convert a passing synthetic digest check into a SLSA claim. [6,10]
Run an offline policy exercise and inspect what it misses
The original companion exercise lives at scripts/examples/cicd-policy/ with its assertions in scripts/cicd-policy.test.mjs in this website's repository. Both safe.yml and unsafe.yml are data outside .github/workflows; no workflow is registered. It uses yaml 2.9.0, pinned exactly in the package manifest and workspace lockfile, and was run with Node.js 24.22.1. Parsing uses YAML 1.2, rejects duplicate keys, parser warnings/errors and aliases, and limits input size to 64 KiB. After dependencies are present, the test itself uses only local files and built-in hashing; it does not install anything, execute fixture commands, request tokens or contact GitHub/cloud services.
node --test artifacts/icommunix-site/scripts/cicd-policy.test.mjs
# Observed on 31 October 2026, Node.js v24.22.1:
# tests 28; pass 28; fail 0; skipped 0| Assertions | Observed result | What this establishes |
|---|---|---|
| 13 workflow/parser cases | All passed; safe YAML yielded no modeled findings; unsafe YAML yielded seven expected codes. | Checks for interpolation, privileged triggers/checkout, explicit/default/job token scope, moving/local actions, runner profile, duplicate keys, aliases and malformed YAML. |
| 6 identity cases | All passed: exact match accepted; wildcard/unscoped trust, wrong issuer/audience/subject and legacy-versus-immutable mismatch rejected. | Only equality against a synthetic independently supplied tuple—not JWT authenticity, token expiry, cloud trust or role permissions. |
| 7 artifact cases | All passed: local file digest and metadata match accepted; changed bytes, wrong builder/source/revision/build type and an extra parameter rejected. | SHA-256 over actual synthetic file content plus exact custom metadata comparisons—not a signed provenance envelope. |
| 2 deliberate blind-spot cases | Both passed by asserting that the checker accepts the modeled bypass. | A command invoking an uninspected malicious script yields no finding. Replacing bytes and recomputing unsigned metadata also yields no finding. |
The unsafe fixture expects action-not-full-sha, expression-in-shell, outside-pr-profile, persisted-checkout-token, privileged-checkout, privileged-trigger and token-scope. Focused mutations then isolate individual rules. This matters: a single broad 'unsafe rejected' test could pass while a specific control was missing. Safe here means only no findings under the narrow read-only PR profile, not a secure or production-ready workflow.
The checker does not implement GitHub's workflow schema, expression engine, effective repository permissions, lockfile verification, action/reusable-workflow resolution, shell dataflow or network isolation. Its runner allowlist is a profile choice, not proof that ubuntu-24.04 is an immutable image. It does not inspect action code, package scripts, caches, repository rules or cloud settings. Its unsigned artifact metadata is deliberately not presented as SLSA/in-toto provenance. JSON-string parameter comparison is order-sensitive and intentionally conservative. A full-SHA rule can accept an unreviewed commit and reject an action legitimately covered by an enforced lockfile. Use these checks to discuss boundaries, not as the only release gate.
Make the release decision reviewable
Human approval needs a stable object. Show the reviewer the source revision, build/workflow revision, dependency changes, verification outcome, artifact digest, destination and permitted action. Invalidate the approval if these change. An agent may prepare a change or summarise evidence, but it must not silently approve its own proposal, weaken a failed policy or substitute a different artifact.
- Have the delivery owner and independent security reviewer examine the trust map, workflow/build scripts, direct and transitive dependencies, privileged triggers and runner/cache boundaries.
- Verify enforced source reviews and deployment protections in the selected repository. Environment names in YAML do not create required reviewers, prevent self-review or disable administrator bypass.
- Confirm account eligibility: GitHub documents environments broadly on current public-repository plans, but private/internal environment access and protection rules differ by plan. Free/Pro/Team required reviewers and wait timers are listed for public repositories only. Do not promise a private repository gate without checking. [11]
- Approve the candidate digest and target after trusted signature/provenance verification and code/security acceptance. Scope OIDC trust and role permissions to the actual context and resources; keep cloud identity out of untrusted validation.
- Name the release/rollback owner, define stop signals and preserve a compatible previous artifact. Application rollback does not automatically undo data migrations, revoke issued credentials or repair external side effects.
- Retain the minimum authorised evidence, with controlled access and expiry. Recheck after workflow, provider, repository ownership, subject-template, runner/runtime or dependency changes, and at least every 90 days.
Delivery and supply-chain trust complement contact-centre integration testing, which checks agent journeys, API contracts, CRM writes and failure recovery. Passing those integration tests does not validate pipeline controls. Keep both evidence sets when a CX integration is the delivered product. An isolated Actions rehearsal, token-claim inspection or cloud operation requires explicit authorisation and is outside this offline exercise.
Limitations
This is engineering guidance, not evidence of icommunix security controls, a customer pipeline, an audit, a SLSA level or vulnerability freedom. The original exercise runs offline against synthetic YAML, identity tuples and unsigned artifact metadata; it never executes a workflow or verifies a JWT/signature. Primary sources were checked on 31 October 2026. Repository settings, account eligibility, runner compatibility, real token claims and cloud enforcement remain unverified. Obtain qualified technical/security review before adapting the exercise to a real pipeline. No pipeline, secret access, cloud operation or deployment is authorised by this guide.
References
- [1] GitHub Actions secure use — untrusted input, token scope, checkout, runner and dependency boundariesAccessed
- [2] GitHub workflow dependency lockfiles — scope, transitive actions and reusable-workflow exclusionsAccessed
- [3] GitHub locking how-to — Enterprise Cloud eligibility, generation and per-workflow coverageAccessed
- [4] GitHub OIDC concepts — short-lived identity and subject migration noticeAccessed
- [5] GitHub OIDC reference — immutable subjects, precedence, audience and custom templatesAccessed
- [6] SLSA v1.2 verifying artifacts — trust roots, digest binding and expectationsAccessed
- [7] checkout v6.0.2 upstream tag reference — resolved full commitAccessed
- [8] checkout exact commit action metadata — Node 24 runtime and inputsAccessed
- [9] checkout exact commit README — runner minimums and credential changesAccessed
- [10] SLSA v1.2 build requirements — provenance authenticity, unforgeability and isolationAccessed
- [11] GitHub managing environments — plan/repository eligibility and protection settingsAccessed
Resources
Need to clarify a delivery trust boundary?
Discuss the proposed workflow, ownership and constraints with our team. Start with a non-sensitive architecture outline—not credentials, private source, customer data or unredacted security reports.
