Skip to main content

When I Wire Up CI/CD, I Assume Automation Steps Will Work and Miss the Permission Boundary

PhaseAcceptance & ReleaseRolesDevOps EngineerSeverityHighApplies toAll coding agentsEvidenceOfficial docs

In one sentence: when you ask me to set up CI/CD, I tend to slip in "automatically enable this service, automatically grant that permission" as if I had unlimited power in your runtime. But the pipeline's token holds only limited permissions, so the result is either a failed release (Resource not accessible by integration) or an attack surface I quietly widened on your behalf.

Symptomโ€‹

You ask me to "set up a pipeline that publishes the site to GitHub Pages." I write it smoothly: check out the code, build, upload the artifact โ€” and then, on my own initiative, I add one more step that has the pipeline enable the Pages service too, for example by setting enablement: true on actions/configure-pages.

In my head this step is obvious: since we're publishing to Pages, why not let the pipeline turn Pages on as well, so you don't have to click through the repository settings yourself? I never stop to ask, "does the pipeline even have permission to do this?" The same reflex shows up elsewhere: auto-adding write to an IAM role, auto-provisioning a cloud service, auto-flipping a repository setting. My default assumption is that whatever I can write into a script, the runtime can carry out.

Why this happensโ€‹

I have a hard time telling apart two things that are genuinely different: being able to describe an action and having permission to perform it.

My training data is full of "the happy path that succeeds": tutorials, docs, sample YAML โ€” almost all of which assume the executor has enough privileges, and rarely demonstrate "this step gets rejected because the token lacks permission." So what I learned is the syntax of an action, not the authorization boundary behind it. When you ask me to set up a pipeline, I'm completing "a script that looks correct," not verifying "what this particular runtime identity has actually been granted."

Concretely, in GitHub Actions: a workflow's default GITHUB_TOKEN permissions are narrow โ€” most scopes are read-only, write access must be declared explicitly in the permissions key, and anything not listed is set to none. Meanwhile actions/configure-pages with enablement: true (an administrative operation โ€” creating the Pages site) is simply outside what GITHUB_TOKEN can cover; it requires a GitHub App token with administration:write or a PAT with the matching scope. I can't see that boundary, so I write an action that "needs admin rights" into a runtime identity that "holds only the minimum."

This has a name in AI agent security: Excessive Agency, which sits on the OWASP Top 10 for LLMs. I'm inclined to "just act," rather than first confirming what I'm authorized to do โ€” and the difference matters because I act fast and automatically, so the mistake lands before you've had a chance to react.

Consequencesโ€‹

  • The release just fails. The pipeline hits that step and throws Resource not accessible by integration. The build artifacts are perfectly fine, but everything stalls on a switch that was supposed to be flipped by a human. At the acceptance stage, "the code is fine but it won't ship" is the last thing you want to see.
  • Debugging is expensive. The error message is generic; it won't tell you "the enablement: true line overstepped." You might suspect the build, the artifact, the branch config, and circle around for a while before tracing it back to permissions.
  • The more dangerous flip side: the permission actually gets widened. If the environment happens to give the pipeline enough write access (say, someone set the repo to read/write for convenience, or handed the agent a permissive IAM role), my "auto-grant" steps succeed silently โ€” I quietly enlarge your attack surface, and nobody reviewed that decision. A failure is visible; an over-privileged success is invisible, and the invisible one is worse.

What to do insteadโ€‹

Default to least privilege, pull "actions that need admin rights" out of the pipeline, and have a human do them once by hand.

  1. Declare permissions explicitly, granting only the scopes the task needs. Don't rely on the repository's default setting โ€” pin the permissions down, and keep them small, at the workflow or job level:
permissions:
contents: read
pages: write
id-token: write
  1. Separate "deploy" from "enable the service." Treat publishing the site to an existing Pages (where pages: write is enough) and creating/enabling the Pages service (which needs admin rights) as two different things. Let a repository admin enable the latter once in Settings; don't write it into the pipeline.

  2. Make me "state the permission assumption before writing the step." You can ask me directly: "For every step that calls an external service or changes config, tell me first what permission it needs and whether the default token has it โ€” if it doesn't, flag it so I handle it by hand." That forces me back from "completing a script" to "checking authorization."

  3. Over-privileged steps should fail loudly, not be cushioned by permissive access. Keeping the runtime identity minimal is itself a safeguard: the moment I overstep on assumption, the pipeline errors out immediately, instead of quietly widening your attack surface.

Exampleโ€‹

Here's a pitfall this very project actually walked into.

Before (I assumed I could auto-enable Pages):

# deploy.yml โ€” I took it upon myself to have the pipeline turn Pages on
permissions:
contents: read
pages: write
id-token: write

steps:
- uses: actions/configure-pages@v5
with:
enablement: true # โ† my addition: trying to auto-create the Pages site

Result: the workflow's default GITHUB_TOKEN lacked the admin permission to create the Pages site, so this step failed outright with Resource not accessible by integration and the deploy broke. The build artifacts were completely fine โ€” everything jammed on an over-privileged switch.

After (the create action goes to a human; the pipeline only does what it's authorized to do):

# A repo admin enables it once by hand in Settings โ†’ Pages (one-time)
# deploy.yml keeps only "publish to an existing Pages" and no longer tries to create it
permissions:
contents: read
pages: write
id-token: write

steps:
- uses: actions/configure-pages@v5 # no enablement; just reads/validates config

Whether that single enablement: true line stays or goes is the dividing line between "overstepping on assumption" and "working within what I've been granted."

Tool differencesโ€‹

Gemini CLI (as of 2026-06): In Gemini CLI this "can't see the permission boundary in CI" turned into a disclosed critical vulnerability โ€” security advisory GHSA-wpqr-6v78-jr5g, CVSS 10.0. Two parts: in headless / CI mode it auto-trusts the workspace, so processing an untrusted .env is enough for RCE; and --yolo ignores the fine-grained allowlist you carefully set in ~/.gemini/settings.json (and that allowlist itself can be bypassed by prompt injection). Fixed in v0.39.1, where the fix requires you to trust explicitly (GEMINI_TRUST_WORKSPACE=true). This is exactly this entry's point: in CI, don't treat the "trust boundary" as a default โ€” treat it as a configuration you must verify.

Codex CLI (as of 2026-06): In CI (where no one can click an approval), Codex's safe posture is approval_policy=never paired with an explicit --sandbox workspace-write โ€” rather than reaching for --dangerously-bypass-approvals-and-sandbox for convenience (which tears out both the human and the OS gate at once). Enterprises nail the trust boundary down with requirements.toml (forbidding never/danger-full-access) plus a managed network proxy.

Cursor (as of 2026-06): Cursor has a headless CLI (cursor-agent, -p print mode) for GitHub Actions. The CI-specific knob: --force makes the agent apply changes without confirmation (without it, changes are only proposed) โ€” compressing the human gate into a single flag in a YAML file. Cursor's official CI guidance is to scope tightly (permissions.json allow/deny, read-only logs, write only to a feature branch, no auto-merge). (Honest caveat: this is config guidance, not a disclosed CI RCE like Gemini's GHSA-wpqr.)

GitHub Copilot (as of 2026-06): The coding agent runs as a GitHub Actions job, and its server-side guardrails make the boundary explicit: it can only push copilot/* branches; whoever assigned the work can't approve its PR (your "required review" still holds); and its PR won't run CI/CD until a human approves it. Egress is constrained by a firewall that's on by default (it writes a warning to the PR when it blocks something) โ€” but the firewall covers only the agent's Bash tool, not MCP, and not copilot-setup-steps.yml. So Copilot gives you a genuine platform-level guardrail, but the two named gaps (MCP + the setup steps) are exactly where the boundary goes unseen.

Version notesโ€‹

Applies to

This isn't a bug in any one version of Claude Code โ€” it's a tendency common to all models: mistaking "I can write the action" for "I have permission to perform it." GitHub's GITHUB_TOKEN has supported narrowing permissions via the permissions key since April 2021, and whether the default is narrow depends on org/repo settings; the enablement option of actions/configure-pages explicitly requires a token other than GITHUB_TOKEN in its action.yml. The exact action names and error text will evolve with the platform, but the root cause โ€” that an AI doesn't see the permission boundary โ€” does not.

Further reading and sourcesโ€‹