Your Pipeline Agent Should Not Hold the Keys

AI agents are moving into CI/CD, where build containers have always been trusted completely. GitHub's new agentic workflows invert that assumption on purpose, and the architecture they landed on is a useful checklist for anyone about to let a model run inside a deployment pipeline.

Talk to an Engineer

Your Build Container Has Always Been Trusted Completely

Look at what a normal deployment job receives. It gets a cloud credential with enough authority to create and destroy infrastructure, a read-write mount of your source tree, unrestricted outbound network access, and whatever secrets the pipeline needs along the way. Nobody considers this alarming, because that job has always run a script that a person wrote, reviewed, and merged. Containers inherited the assumption from the build servers that came before them, and it held up fine for a long time: the build is trusted because its instructions are trusted.

An agent breaks that chain in one specific way. Its instructions are not fully known at merge time, because part of what it will act on is data it reads while running: the text of an issue, the contents of a pull request diff, a dependency's release notes, a web page it fetches to answer a question. Any of those can contain text written by someone who is not on your team. Prompt injection is not an exotic research finding at this point, it is the expected failure mode of a system that reads untrusted input and then takes actions.

So the design question flips. It stops being "will the agent behave well" and becomes "what can the agent do at the moment it does not." That is a much better question, because it has an architectural answer rather than a hopeful one, and because the same answer protects you from an ordinary bug just as well as from a malicious instruction. We have written before about what has to be true before an agent with production access is boring; this is the pipeline-shaped version of that argument.

A Worked Example, Because Someone Already Built It

GitHub and Microsoft recently put GitHub Agentic Workflows into public preview. The pitch is that you write repository automation as a plain markdown file, pick a coding agent to execute it, and it runs in GitHub Actions. The choice of engine covers GitHub Copilot CLI, Claude Code, OpenAI Codex, and Google Gemini CLI, with a couple of experimental options besides. That part is pleasant but not novel.

The interesting part is what comes out of the compiler. We wrote a thirty-four line markdown file asking for a plain-language summary of a pull request diff, and compiled it. The output was a one thousand six hundred and nineteen line GitHub Actions workflow, and that file is meant to be committed to the repository alongside the markdown. Every architectural decision below is visible in it, which is the reason it makes such a good teaching artifact. You do not have to take anyone's word about the security model, you can read it. So here it is.

pr-review-helper.lock.yml  ·  generated  ·  1,619 lines
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8d89d345416b7871c0db5a83706cb5db415b0289ea1af3a72c702eb7d48968ce","body_hash":"890c9e306ef0c002cbe6f30a93cb68c3f23f7bec199ca5aee2402e8dc99b   ... line continues for 109 more characters
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4   ... line continues for 2368 more characters
# This file was automatically generated by gh-aw (v0.83.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#
#   ... 24 lines of ASCII banner elided ...
#
# Secrets used:
#   - COPILOT_GITHUB_TOKEN
#   - GH_AW_GITHUB_MCP_SERVER_TOKEN
#   - GH_AW_GITHUB_TOKEN
#   - GITHUB_TOKEN
#
# Custom actions used:
#   - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
#   - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
#   - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
#   - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
#   - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
#   - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
#   - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
#   - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
#   - github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4
#
# Container images used:
#   - ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b
#   - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607
#   - ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0
#   - ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c
#   - ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748
#   - ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308

name: "Pull Request Review Helper"
on:
  pull_request:
    types:
      - opened
      - synchronize

permissions: {}

concurrency:
  group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}"
  cancel-in-progress: true

run-name: "Pull Request Review Helper"

jobs:
  activation:
    needs: pre_activation
    if: >
      needs.pre_activation.outputs.activated == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id)
    runs-on: ubuntu-slim
    permissions:
      actions: read
      contents: read
    env:
      GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
      GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
    outputs:
      body: ${{ steps.sanitized.outputs.body }}
      comment_id: ""
      comment_repo: ""
      daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }}
      daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }}
      daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }}
      engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
      lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
      model: ${{ steps.generate_aw_info.outputs.model }}
      oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }}
      secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
      setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
      setup-span-id: ${{ steps.setup.outputs.span-id }}
      setup-trace-id: ${{ steps.setup.outputs.trace-id }}
      stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}
      text: ${{ steps.sanitized.outputs.text }}
      title: ${{ steps.sanitized.outputs.title }}
    steps:
      - name: Setup Scripts
        id: setup
        uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4
        with:
          destination: ${{ runner.temp }}/gh-aw/actions
          job-name: ${{ github.job }}
          trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }}
          parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }}
          safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
        env:
          GH_AW_SETUP_WORKFLOW_NAME: "Pull Request Review Helper"
          GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-review-helper.lock.yml@${{ github.ref }}
          GH_AW_INFO_VERSION: "1.0.75"
          GH_AW_INFO_AWF_VERSION: "v0.27.42"
          GH_AW_INFO_ENGINE_ID: "copilot"
      - name: Generate agentic run info
        id: generate_aw_info
        env:
          GH_AW_INFO_ENGINE_ID: "copilot"
          GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
          GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
          GH_AW_INFO_VERSION: "1.0.75"
          GH_AW_INFO_AGENT_VERSION: "1.0.75"
          GH_AW_INFO_CLI_VERSION: "v0.83.4"
          GH_AW_INFO_WORKFLOW_NAME: "Pull Request Review Helper"
          GH_AW_INFO_EXPERIMENTAL: "false"
          GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
          GH_AW_INFO_STAGED: "false"
          GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]'
          GH_AW_INFO_FIREWALL_ENABLED: "true"
          GH_AW_INFO_AWF_VERSION: "v0.27.42"
          GH_AW_INFO_AWMG_VERSION: ""
          GH_AW_INFO_FIREWALL_TYPE: "squid"
          GH_AW_COMPILED_STRICT: "true"
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        with:
          script: |
            const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
            setupGlobals(core, github, context, exec, io, getOctokit);
            const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
            await main(core, context);
      - name: Restore daily AIC usage cache
        id: restore-daily-aic-cache
        if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
        continue-on-error: true
        uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          key: agentic-workflow-usage-prreviewhelper-${{ github.run_id }}
          restore-keys: agentic-workflow-usage-prreviewhelper-
          path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl
      - name: Restore daily AIC usage cache (artifact fallback)
        id: restore-daily-aic-cache-fallback
        if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
        continue-on-error: true
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }}
          GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }}
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
            setupGlobals(core, github, context, exec, io, getOctokit);
            const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs');
            await main();
      - name: Check daily workflow token guardrail
        id: daily-effective-workflow-guardrail
        if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          GH_AW_WORKFLOW_NAME: "Pull Request Review Helper"
          GH_AW_WORKFLOW_ID: "pr-review-helper"
          GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
          GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
          GH_AW_HAS_SLASH_COMMAND: "false"
          GH_AW_HAS_LABEL_COMMAND: "false"
          GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
            setupGlobals(core, github, context, exec, io, getOctokit);
            const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs');
            await main();
      - name: Validate COPILOT_GITHUB_TOKEN secret
        id: validate-secret
        run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default
        env:
          COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
      - name: Check for OAuth tokens
        id: check-oauth-tokens
        run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh"
        env:
          COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
          GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
          GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
      - name: Checkout .github and .agents folders
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
          sparse-checkout: |
            .github
            .agents
            .antigravity
            .claude
            .codex
            .gemini
            .opencode
            .pi
          sparse-checkout-cone-mode: true
          fetch-depth: 1
      - name: Save agent config folders for base branch restoration
showing the first 190 lines of 1,619  ·  scroll inside the window

Nobody writes this by hand, and that is the point. It opens by declaring its own supply chain: every secret the run can reach, every action pinned to a commit hash, every container pinned to an image digest. Then it starts assembling a sandbox around a job that has not begun.

That is thirty-four lines of intent expanded into sixteen hundred lines of scaffolding, and almost none of it is the task. It is setup, artifact plumbing, sandbox construction, egress rules, and gates. The compiled file does not contain one job either. It contains six, and the way the work is divided across them is the whole idea.

the compiled workflow - job graph and permissions
# the job that runs the model
agent:
  permissions:
    actions:  read
    contents: read

# a separate pass that re-reads the agent's output
detection:
  needs: [activation, agent]
  permissions:
    contents: read

# the only jobs that can change anything
safe_outputs:
  permissions:
    contents:      read
    issues:        write
    pull-requests: write

The model runs in a job that cannot write. Not a job that is told not to write. A job holding a token with no write scope on it at all.

Split the Job, Not the Prompt

The job that actually runs the model has exactly two permissions: read actions, read contents. It cannot comment, cannot open a pull request, cannot push a branch, cannot touch a release. When the agent decides it wants to leave a comment, it does not leave a comment. It writes a structured request to a file, that file becomes an artifact, and a later job with comment permission reads the request and performs it. In between sits a separate detection job whose entire purpose is to re-read the agent's proposed output adversarially before any of it is acted on.

Comparison diagram showing one CI job where the if-check and the write token sit inside the same box and can be bypassed versus three separate jobs where the agent and verify steps are read-only and only the final writer job holds a write-scoped token

Compare that to how most teams guard a risky pipeline today. There is usually a conditional near the top of the job, something that checks the branch or the event type and exits early if the combination looks dangerous. Those checks are genuinely useful and we write them too, and we have argued at length that a branch name is really a deployment policy. But a check that runs inside the same job, holding the same token as the thing it is guarding, is a policy rather than a boundary. If anything upstream of it resolves the wrong value, the policy evaluates to true and the token is still sitting right there.

The portable lesson has nothing to do with AI. Run the read-only half of your pipeline with read-only credentials, in its own job, and make the half that mutates things a downstream job that depends on it. Most pipelines could adopt that shape this quarter, and the ones that do get a real reduction in blast radius from a change that is mostly YAML rearrangement.

The Credential Should Never Enter the Room

Here is the single line from the compiled workflow that we found most instructive. When it launches the sandboxed agent it passes every environment variable through, and then explicitly subtracts the ones that matter: pass all, minus the token that pays for inference, minus the MCP server token, minus the gateway key. The credential is present in the job that launches the agent and deliberately withheld from the agent's own environment. It lives instead with a proxy container that sits between the agent and the outside world, adds authentication on the way out, and never hands it inward.

State the principle without the implementation and it sounds obvious. The process that can be talked into doing something should not be the process that holds the credential. Yet the standard shape of a deployment job is the exact opposite: a docker run invocation that passes the cloud access key, the session token, and the client secret straight into the container as environment variables, because the tool inside needs them to call the cloud API.

Sometimes that is unavoidable. Infrastructure tooling has to authenticate to the cloud provider, and no amount of architecture removes that. But it is worth separating the jobs that genuinely need a credential from the jobs that merely run alongside one, and it is worth checking whether a proxy can hold the secret for the ones in between. The answer is more often yes than teams expect.

Decide, in Advance, What the Agent Can Reach

The sandboxed agent runs behind a proxy container with an explicit list of domains it is allowed to contact, forty-five entries in the workflow we compiled, covering the package registries, the certificate authorities, the model endpoint, and the version control host. Everything else is refused. That list is generated from the workflow definition, so adding a destination is a change to a file that someone reviews rather than an ambient capability.

This is the control that matters most and gets skipped most often. Exfiltration requires a destination. An agent that has been convinced to go looking for secrets in your repository still has to send them somewhere, and a default-deny egress list turns that from a quiet success into a logged, failed connection attempt. The same list also constrains an ordinary bug, like a dependency that phones home more than you knew.

The same layer carries the cost controls, which are worth mentioning because they are the failure mode teams actually hit first. There is a credit ceiling per run, a cap on how many times the agent may iterate, and a cap on cache misses. An agent stuck in a loop is a budget event before it is ever a security event, and the two problems have the same answer: a hard limit enforced somewhere the agent cannot edit.

Can You Prove What Ran in Your Pipeline Last Night?

Every third-party action in that compiled workflow is pinned to a full commit hash rather than a version tag. Every container is pinned to an image digest rather than a tag like latest. The second line of the file is a manifest listing every secret the workflow can reach, every action it calls with its hash, and every container it runs with its digest. It is, in effect, a bill of materials for a single automated job.

Then it does something we had not seen before. We added an integration that pulled in one new credential, and the compiler noticed. It named the secret, told us to either pass an explicit approval flag or revert the change, and instructed the agent doing the work to write a security review note in the pull request explaining why the new credential was safe to add. To be precise about what this is and is not: the build still succeeds and still writes its output, so this is enforced change detection rather than a hard gate. But treating the set of secrets a pipeline can reach as reviewable state, diffed on every compile, is a genuinely good idea that almost nobody implements.

We took that checklist and ran it against a delivery pipeline of our own, one that is mature, in production, and deploys real infrastructure for real customers. It found four things. The container image that performs deployments was being pulled by a mutable tag rather than a digest, which means the artifact applying infrastructure changes at three in the morning is whatever that tag resolved to at three in the morning. Several third-party actions were referenced by version tag rather than commit hash. A cloud credential was being written into the build workspace with world-readable permissions and never cleaned up afterward. And execution plan files were being uploaded as build artifacts, where they can carry resource values you would not paste into a chat window.

None of that is exotic and all of it is inexpensive to fix. We are pointing at it precisely because the pipeline is a good one, maintained by people who care. A checklist that only finds problems in bad systems is not worth running. This one found four in a system we would have described as solid, which is the entire argument for running it against yours. Turning those findings into something a customer or an auditor will accept is its own discipline, and it is most of what our compliance and security engineering work involves.

safe update mode detected unapproved changes New restricted secret(s): - JIRA_API_TOKEN Remediation options: 1. Use the --approve flag to allow the changes. 2. Revert the unapproved changes. 3. Use an interactive coding agent to review and approve.

Real output. Adding one integration that needed one credential was enough to trigger it. Most pipelines will accept a new secret with no ceremony at all.

The Short Version

If you are about to let an agent run inside your pipeline, or you already have and want to know how exposed you are, this is the list. It is ordered by how much protection each item buys relative to the effort, and the first three are achievable in an afternoon by most teams. The wider set of practices these draw from is written down in our DevOps manual.

  1. Give the agent a read-only token. If it needs to change something, it should describe the change and let a later job with narrower scope perform it.
  2. Split read from write across jobs. A conditional inside a job that already holds the credential is a policy, not a boundary.
  3. Pin what you run. Commit hashes for actions, image digests for containers. No mutable tags anywhere on a path that reaches production.
  4. Keep credentials out of the agent's process. Use a proxy that adds authentication outbound, and reserve direct credential injection for the jobs that genuinely cannot work another way.
  5. Default-deny outbound network. Name the destinations the job may reach and refuse the rest. Exfiltration needs somewhere to go.
  6. Put a hard ceiling on cost. Spend caps and iteration limits, enforced outside the agent's reach.
  7. Track the secret set as a reviewed artifact. A new credential appearing in a pipeline should require a human to say yes in writing.
  8. Check what your artifacts contain. Plan files, logs, and debug output routinely carry values that belong in a vault.

When You Do Not Need Any of This

If there is no agent anywhere near your pipeline and none planned, this is premature. Pin your actions and move on with your day. The pinning advice is worth acting on regardless because supply chain attacks against build systems predate agents by years, but the rest of the architecture solves a problem you do not have yet.

If your agent only ever opens pull requests that a human reviews and merges, you are already getting most of the benefit from two items on the list rather than eight. A read-only token and pinned dependencies cover the realistic failure modes. Building out proxy isolation and egress allowlists for a workflow whose worst case is a bad pull request suggestion is effort better spent elsewhere.

The full architecture starts earning its keep when the pipeline touches production infrastructure, when it handles credentials for systems whose compromise you would have to disclose, or when the agent reads content from outside your organization. Those three conditions are also, not coincidentally, the ones that make a regulator or a customer's security questionnaire interested in your answer. The same reasoning applies one layer down, to the changes that live in your database rather than your repository.

One last thing, and it is the one we would push back on hardest. Do not hand the agent your deployment credential because it is convenient and the agent seems to be doing fine. Every control described here exists to keep a model that reads untrusted input away from the authority to change production directly. The moment the agent holds that credential, the rest of the architecture is decoration.

Where This Fits in Our Practice

Array

The delivery practice this page belongs to, one layer at a time.

Your Branch Name Is Your Deployment Policy

How branch naming becomes the control that decides where code is allowed to land.

When a Data Change Is a Production Deployment

The data-plane companion: three gates for the changes that live in the database.

Compliance and Security Engineering

The controls above, assembled into evidence an auditor will accept.

Preview Environments for Dynamic Sites

A running replica per branch, for systems with a server and a database behind them.

UDX DevOps Manual

The practices these pages draw from, written down in one place.

"

Talk to an Expert

Connect with our diverse group of UDX experts that can help you implement successful DevOps practices within your organization.