DEV Community

Dakota Huang
Dakota Huang

Posted on

Model-Generated GitHub Actions Need a Permission Gate

A model can write a valid GitHub Actions workflow that still leaks a secret. Lint the syntax, then gate the permissions.

The failure mode

Most checks on model-generated YAML stop at syntax. A workflow can parse cleanly, pass actionlint, and still:

  • request id-token: write or contents: write
  • read repository secrets in an unexpected step
  • add a job that pushes to the default branch
  • run on pull_request_target with elevated permissions

If the YAML came from a prompt, treat it as a patch, not a trusted config.

When a workflow is drafted by MonkeyCode's free model, nobody should review the whole file line by line. Run a small permission gate instead. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The artifact: a read-only gate

The gate has two layers:

  1. actionlint catches malformed expressions and unsupported keys.
  2. A small Python check rejects workflows that ask for write permissions or touch secrets.

Save this as check_workflow.py:

import sys
import yaml

def main(path):
    with open(path) as f:
        data = yaml.safe_load(f)

    perms = data.get("permissions", {})
    if perms.get("contents") != "read":
        print("workflow-level permissions.contents must be read", file=sys.stderr)
        return 1

    for name, job in data.get("jobs", {}).items():
        job_perms = job.get("permissions", {})
        if job_perms.get("contents") == "write":
            print(f"job {name} requests contents: write", file=sys.stderr)
            return 1
        if any("secrets" in str(step) for step in job.get("steps", [])):
            print(f"job {name} references secrets", file=sys.stderr)
            return 1

    return 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1]))
Enter fullscreen mode Exit fullscreen mode

Run it with:

actionlint .github/workflows/draft.yml
python check_workflow.py .github/workflows/draft.yml
Enter fullscreen mode Exit fullscreen mode

This is not a complete security review. The secrets check is intentionally coarse: it flags any step whose string form contains the word secrets. A real gate should parse the workflow with a schema or an AST, but this catches the common mistake before merge.

What the gate catches

Consider this model-generated fragment:

name: Draft
on: [pull_request_target]
permissions: write-all
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "${{ secrets.DEPLOY_KEY }}" | base64
Enter fullscreen mode Exit fullscreen mode

actionlint will probably accept the file. The Python gate rejects it immediately: workflow-level permissions is not contents: read, and a step mentions secrets. That is the common failure mode: valid syntax, invalid trust.

Why a free server helps

The MonkeyCode free server option gives a clean place to run these checks without installing actionlint and PyYAML on a local machine every time.

The flow stays small:

  • generate the YAML with the free model
  • write it to a draft file
  • run the two checks in the free server shell
  • merge only if the checks return zero

The draft can stay isolated from local secrets. The free server sees only the draft file, not repository tokens.

What this gate does not catch

Static checks cannot prove intent. A workflow can pass the gate and still:

  • exfiltrate data over a network at runtime
  • abuse a third-party action with unexpected behavior
  • hide malicious code inside a shell step that only writes read-only outputs
  • use pull_request_target to read secrets indirectly

The permission check only looks at declared YAML. It cannot know what a step's command actually does. actionlint validates syntax, not trust.

Who should not rely on this

  • Repositories that manage production secrets or deploy artifacts.
  • Teams that need write permissions for normal CI jobs.
  • Workflows that use reusable workflows or dynamic permissions the simple checker cannot parse.
  • Anyone unwilling to review the final diff before merge.

For those cases, the gate needs to be stricter: require signed commits, pin actions by SHA, and run the workflow in an isolated environment first.

A smaller default

The fastest improvement is to make permissions: contents: read the default for every model-generated workflow. Most free-model drafts do not need write access. If the prompt asks for a CI step that only runs tests, the workflow should not be able to push.

Start from read-only. Reject write requests. Add back the minimum permission only after a human reads the diff.

A valid YAML file is not a valid action. Gate the permissions before you trust the model's output.

Top comments (0)