{
  "id": "github-actions-hardening",
  "type": "loadout",
  "title": "GitHub Actions hardening for agent workflows",
  "summary": "Rules and a zizmor skill for agents editing GitHub Actions: SHA-pinned actions, permissions: {}, no template injection, no pull_request_target, read-only agent jobs.",
  "description": "Agents now write and edit CI workflows, and increasingly run inside them on untrusted issue and PR text. The common mistakes are the ones attackers use: actions pinned to a movable tag, an issue title expanded straight into a shell script, pull_request_target checking out fork code, a default token with write access to everything. This loadout gives your agent the hardening rules from GitHub's secure-use guide and Security Lab, a pattern for agent workflows that separates thinking (read-only) from acting (narrow writes), and a skill that runs zizmor offline and fixes each audit. The verifier runs zizmor 1.31.1 on the example workflow (clean) and on an unsafe agent-triage workflow.\n",
  "highlights": [
    "Actions pinned to full commit SHAs, kept current by Dependabot",
    "permissions: {} at the top; per-job grants; persist-credentials: false",
    "Untrusted event text through env, never inline in run",
    "Agent jobs read-only; a separate narrow job applies their output",
    "zizmor --offline as the check, no token needed"
  ],
  "version": "1.0.0",
  "license": "MIT",
  "author": {
    "name": "Agent Loadout",
    "url": "https://loadout.jnw-it.com"
  },
  "credits": [
    {
      "title": "GitHub docs: secure use reference for Actions",
      "url": "https://docs.github.com/en/actions/reference/security/secure-use"
    },
    {
      "title": "GitHub Security Lab: preventing pwn requests",
      "url": "https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/"
    },
    {
      "title": "zizmor: static analysis for GitHub Actions",
      "url": "https://github.com/zizmorcore/zizmor"
    },
    {
      "title": "zizmor docs: audit rules",
      "url": "https://docs.zizmor.sh/audits/"
    }
  ],
  "categories": [
    "security",
    "workflow"
  ],
  "stacks": [
    "github-actions",
    "github"
  ],
  "files": [
    {
      "path": "AGENTS.md",
      "role": "instructions",
      "description": "Rules for writing and changing GitHub Actions workflows, including ones that run AI agents.",
      "skill": null,
      "agent": null,
      "install_to": {
        "claude-code": "AGENTS.md",
        "codex": "AGENTS.md",
        "cursor": "AGENTS.md",
        "gemini-cli": "AGENTS.md",
        "antigravity-cli": "AGENTS.md",
        "copilot": "AGENTS.md"
      },
      "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/AGENTS.md",
      "raw_latest_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/latest/AGENTS.md",
      "bytes": 2314,
      "lang": "markdown",
      "content": "# AGENTS.md: GitHub Actions hardening\n\nRules for writing or changing anything under `.github/workflows/`, including workflows that run AI agents on issues\nand pull requests. A workflow is code that runs with the repository's tokens and secrets; review it like code.\n\n## Every workflow\n- Pin every third-party action to a full commit SHA with the version in a comment:\n  `uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`. Tags and branches can be moved.\n  Keep pins current with Dependabot (`package-ecosystem: github-actions`), not by switching back to tags.\n- `permissions: {}` at the top, then grant each job only what it needs (`contents: read`, `issues: write`, ...).\n- `actions/checkout` with `persist-credentials: false` unless a later step must push.\n- Never write `${{ github.event.* }}`, `${{ github.head_ref }}` or other attacker-controllable values inside `run:`.\n  Pass them through `env:` and quote the variable: `TITLE: ${{ github.event.issue.title }}` then `\"$TITLE\"`.\n- Secrets only in the steps that need them, through `env:` or `with:`. Never echo them or write them to files that\n  are uploaded as artifacts.\n- Pin runner images (`ubuntu-24.04`, not `ubuntu-latest`) when reproducibility matters.\n\n## Triggers\n- Avoid `pull_request_target` and `workflow_run`. If one is unavoidable, never check out or run the pull request's\n  code in that job, and never give it secrets.\n- Code from forks runs under `pull_request`, with a read-only token and no secrets. Keep it that way.\n\n## Workflows that run AI agents\n- Issue and PR text is untrusted input to the agent. Run the agent job with a read-only token and no write secrets.\n- Split \"think\" from \"act\": the agent job writes its proposal to an artifact or output; a separate job with narrow\n  write permission applies it after checks (labels from a fixed list, comments only, no pushes to default branches).\n- Pin agent actions and CLIs to exact versions, and cap spend and turns where the tool supports it.\n\n## Before finishing\n- Run the `harden-workflow` skill: `uvx zizmor@1.31.1 --offline .github/workflows` must report no findings, or each\n  remaining finding is justified in a comment and the PR description.\n- Do not push workflow changes to the default branch or re-run workflows with secrets unless the user asks.\n"
    },
    {
      "path": "CLAUDE.md",
      "role": "adapter",
      "description": "Imports AGENTS.md for Claude Code.",
      "skill": null,
      "agent": "claude-code",
      "install_to": {
        "claude-code": "CLAUDE.md",
        "codex": null,
        "cursor": null,
        "gemini-cli": null,
        "antigravity-cli": null,
        "copilot": null
      },
      "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/CLAUDE.md",
      "raw_latest_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/latest/CLAUDE.md",
      "bytes": 404,
      "lang": "markdown",
      "content": "# CLAUDE.md\n\n@AGENTS.md\n\n## Claude Code notes\n- Skills from this loadout live in `.claude/skills/`: `harden-workflow`. Use them when their description matches the task.\n- Follow AGENTS.md above; where this file and AGENTS.md disagree, AGENTS.md wins.\n\n<!-- From the Agent Loadout \"GitHub Actions hardening for agent workflows\" v1.0.0 — https://loadout.jnw-it.com/loadouts/github-actions-hardening/ -->\n"
    },
    {
      "path": "GEMINI.md",
      "role": "adapter",
      "description": "Imports AGENTS.md for Gemini CLI.",
      "skill": null,
      "agent": "gemini-cli",
      "install_to": {
        "claude-code": null,
        "codex": null,
        "cursor": null,
        "gemini-cli": "GEMINI.md",
        "antigravity-cli": null,
        "copilot": null
      },
      "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/GEMINI.md",
      "raw_latest_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/latest/GEMINI.md",
      "bytes": 405,
      "lang": "markdown",
      "content": "# GEMINI.md\n\n@./AGENTS.md\n\n## Gemini CLI notes\n- Skills from this loadout live in `.agents/skills/`: `harden-workflow`. Use them when their description matches the task.\n- Follow AGENTS.md above; where this file and AGENTS.md disagree, AGENTS.md wins.\n\n<!-- From the Agent Loadout \"GitHub Actions hardening for agent workflows\" v1.0.0 — https://loadout.jnw-it.com/loadouts/github-actions-hardening/ -->\n"
    },
    {
      "path": "skills/harden-workflow/SKILL.md",
      "role": "skill",
      "description": "Audit with zizmor and fix each finding.",
      "skill": "harden-workflow",
      "agent": null,
      "install_to": {
        "claude-code": ".claude/skills/harden-workflow/SKILL.md",
        "codex": ".agents/skills/harden-workflow/SKILL.md",
        "cursor": ".agents/skills/harden-workflow/SKILL.md",
        "gemini-cli": ".agents/skills/harden-workflow/SKILL.md",
        "antigravity-cli": ".agents/skills/harden-workflow/SKILL.md",
        "copilot": ".agents/skills/harden-workflow/SKILL.md"
      },
      "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/SKILL.md",
      "raw_latest_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/latest/skills/harden-workflow/SKILL.md",
      "bytes": 1638,
      "lang": "markdown",
      "content": "---\nname: harden-workflow\ndescription: Audit and fix GitHub Actions workflows with zizmor before they are committed. Pins actions to commit SHAs, narrows permissions, removes template injection and unsafe triggers. Use for any change under .github/workflows.\n---\n\n# Harden a workflow\n\nExample of the target shape: `references/issue-triage.yml`.\n\n1. Audit offline (no token needed): `uvx zizmor@1.31.1 --offline .github/workflows`. Exit code 0 means no findings.\n   With a `GH_TOKEN`, drop `--offline` to enable the online audits (known-vulnerable actions, impostor commits).\n2. Fix by audit name:\n   - `unpinned-uses`: resolve the SHA for the tag you want with\n     `gh api repos/OWNER/REPO/git/ref/tags/TAG --jq .object.sha`. If `.object.type` is `tag` (annotated),\n     follow it once more with `gh api repos/OWNER/REPO/git/tags/SHA --jq .object.sha`. Write\n     `uses: OWNER/REPO@SHA # TAG`.\n   - `template-injection`: move the expression into `env:` and reference `\"$VAR\"` in the script.\n   - `excessive-permissions`: add `permissions: {}` at the top and per-job grants.\n   - `artipacked`: set `persist-credentials: false` on checkout.\n   - `dangerous-triggers`: switch `pull_request_target` to `pull_request`, or split privileged work into a job that\n     never touches PR code.\n3. Re-run zizmor until clean. If you must keep a finding, add an inline `# zizmor: ignore[audit-name]` comment with\n   the reason, and mention it to the user.\n4. Check the workflow still parses: `gh workflow view <file>` on a pushed branch, or `actionlint` if installed.\n5. Summarise the changes for the user. Do not push to the default branch yourself.\n"
    },
    {
      "path": "skills/harden-workflow/references/issue-triage.yml",
      "role": "reference",
      "description": "Hardened example workflow.",
      "skill": "harden-workflow",
      "agent": null,
      "install_to": {
        "claude-code": ".claude/skills/harden-workflow/references/issue-triage.yml",
        "codex": ".agents/skills/harden-workflow/references/issue-triage.yml",
        "cursor": ".agents/skills/harden-workflow/references/issue-triage.yml",
        "gemini-cli": ".agents/skills/harden-workflow/references/issue-triage.yml",
        "antigravity-cli": ".agents/skills/harden-workflow/references/issue-triage.yml",
        "copilot": ".agents/skills/harden-workflow/references/issue-triage.yml"
      },
      "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/references/issue-triage.yml",
      "raw_latest_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/latest/skills/harden-workflow/references/issue-triage.yml",
      "bytes": 1143,
      "lang": "yaml",
      "content": "# Labels new issues from their title. Shows the hardened shape: SHA pins, no default permissions,\n# per-job grants, no persisted credentials, untrusted text passed through env.\nname: issue-triage\non:\n  issues:\n    types: [opened]\n\npermissions: {}\n\njobs:\n  triage:\n    runs-on: ubuntu-24.04\n    timeout-minutes: 5\n    permissions:\n      contents: read\n      issues: write\n    steps:\n      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1\n        with:\n          persist-credentials: false\n      - name: Pick a label from a fixed list\n        id: pick\n        env:\n          TITLE: ${{ github.event.issue.title }}\n        run: |\n          label=question\n          case \"${TITLE,,}\" in\n            *bug*|*crash*|*error*) label=bug ;;\n            *feature*|*request*) label=enhancement ;;\n          esac\n          echo \"label=$label\" >> \"$GITHUB_OUTPUT\"\n      - name: Apply the label\n        env:\n          GH_TOKEN: ${{ github.token }}\n          ISSUE: ${{ github.event.issue.number }}\n          LABEL: ${{ steps.pick.outputs.label }}\n        run: gh issue edit \"$ISSUE\" --repo \"$GITHUB_REPOSITORY\" --add-label \"$LABEL\"\n"
    }
  ],
  "requires": {
    "tools": [
      "uv",
      "gh"
    ],
    "env": [],
    "mcp": []
  },
  "tested": {
    "status": "untested",
    "last_run": null,
    "fixture": null,
    "per_agent": {
      "claude-code": "untested",
      "codex": "untested",
      "cursor": "untested",
      "gemini-cli": "untested",
      "antigravity-cli": "untested",
      "copilot": "untested"
    }
  },
  "security": {
    "scanned": true,
    "scanner": "intake-precheck/1",
    "verdict": "clean",
    "network": true,
    "scripts": [],
    "notes": "uvx downloads zizmor from PyPI. Offline mode makes no GitHub API calls; the online audits need a GH_TOKEN. The rules forbid pushing workflow changes to the default branch unless asked."
  },
  "featured": false,
  "deprecated": false,
  "last_checked": "2026-10-17",
  "published": "2026-10-17",
  "updated": "2026-10-17",
  "compatibility": {
    "claude-code": "native",
    "codex": "native",
    "cursor": "native",
    "gemini-cli": "native",
    "antigravity-cli": "native",
    "copilot": "native"
  },
  "counts": {
    "rules": 1,
    "skills": 1,
    "mcp": 0
  },
  "url": "https://loadout.jnw-it.com/loadouts/github-actions-hardening/",
  "api_url": "https://loadout.jnw-it.com/api/loadouts/github-actions-hardening.json",
  "verification": {
    "level": "verified-install",
    "status": "verified",
    "label": "Verified install",
    "verified": true,
    "checked_at": "2026-10-17T09:35:26Z",
    "commit": "021470d33f1f82ab91e12f0c64c43ad15bac6118",
    "content_current": true,
    "agent_tests": "not-run",
    "counts": {
      "pass": 9,
      "fail": 0,
      "skip": 0,
      "not_applicable": 1
    },
    "meaning": "Level 1: automated checks on the files (parse, install paths, MCP config, safety scan, references, size, licence, starter build). No AI agent was run.",
    "checks": [
      {
        "id": "listing",
        "name": "Listing",
        "status": "pass",
        "summary": "Schema valid; 5 files declared, none missing or extra",
        "details": [
          "tested.status: untested (agent tests are separate from Level 1)"
        ]
      },
      {
        "id": "parse",
        "name": "Files parse",
        "status": "pass",
        "summary": "All 5 files parse; front matter valid",
        "details": []
      },
      {
        "id": "adapters",
        "name": "Adapters",
        "status": "pass",
        "summary": "CLAUDE.md and GEMINI.md import AGENTS.md and are up to date",
        "details": [
          "CLAUDE.md: matches the generator",
          "GEMINI.md: matches the generator"
        ]
      },
      {
        "id": "install-paths",
        "name": "Install paths",
        "status": "pass",
        "summary": "Correct paths for all 6 agents; layouts re-discovered",
        "details": [
          "Claude Code: 4 files, 1 skill in .claude/skills/",
          "Codex CLI: 3 files, 1 skill in .agents/skills/",
          "Cursor: 3 files, 1 skill in .agents/skills/",
          "Gemini CLI: 4 files, 1 skill in .agents/skills/",
          "Antigravity CLI: 3 files, 1 skill in .agents/skills/",
          "GitHub Copilot: 3 files, 1 skill in .agents/skills/"
        ]
      },
      {
        "id": "mcp",
        "name": "MCP config",
        "status": "n/a",
        "summary": "No MCP servers in this loadout",
        "details": []
      },
      {
        "id": "safety",
        "name": "Safety scan",
        "status": "pass",
        "summary": "Clean: no credentials, risky shell or hidden text",
        "details": [
          "Scanner intake-precheck/1: verdict clean; network yes (listing says yes)"
        ]
      },
      {
        "id": "references",
        "name": "References",
        "status": "pass",
        "summary": "2 references between files resolve for every agent",
        "details": []
      },
      {
        "id": "size",
        "name": "Size limits",
        "status": "pass",
        "summary": "5 files, 5.8 KB total; largest AGENTS.md 2.3 KB",
        "details": [
          "Limits: 30 files, 32 KB per file, 128 KB total, 500 lines per skill or rule"
        ]
      },
      {
        "id": "licence",
        "name": "Licence",
        "status": "pass",
        "summary": "MIT, by Agent Loadout; text in the repository LICENSE",
        "details": []
      },
      {
        "id": "starter-build",
        "name": "Starter build",
        "status": "pass",
        "summary": "zizmor 1.31.1 (offline) passes the harden-workflow skill's example workflow with no findings and reports template injection, pull_request_target, unpinned actions, persisted credentials and default permissions in an agent-triage workflow",
        "details": [
          "Applied 7 loadout files at every agent's paths",
          "✓ place the example workflow: bash -c mkdir -p good/.github/workflows && cp .agents/skills/harden-workflow/references/issue-triage.yml good/.github/workflows/ && echo placed",
          "✓ hardened workflow: no findings: uvx zizmor@1.31.1 --offline good",
          "✓ every uses: is pinned to a full SHA with a version comment: bash -c grep -E '^\\s*- uses:' good/.github/workflows/issue-triage.yml | grep -vE '@[0-9a-f]{40} # v[0-9]' && exit 1; echo pinned",
          "✓ unsafe agent-triage workflow is caught: bash -c uvx zizmor@1.31.1 --offline bad; echo \"exit=$?\"",
          "Covers: SHA-pinned actions with version comments",
          "Covers: permissions: {} at the top with per-job grants; persist-credentials: false",
          "Covers: Untrusted issue text passed through env, never expanded inside run",
          "Covers: pull_request_target and unpinned agent actions reported"
        ]
      }
    ]
  },
  "install": {
    "claude-code": {
      "files": [
        {
          "path": "AGENTS.md",
          "target": "AGENTS.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/AGENTS.md",
          "role": "instructions"
        },
        {
          "path": "CLAUDE.md",
          "target": "CLAUDE.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/CLAUDE.md",
          "role": "adapter"
        },
        {
          "path": "skills/harden-workflow/SKILL.md",
          "target": ".claude/skills/harden-workflow/SKILL.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/SKILL.md",
          "role": "skill"
        },
        {
          "path": "skills/harden-workflow/references/issue-triage.yml",
          "target": ".claude/skills/harden-workflow/references/issue-triage.yml",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/references/issue-triage.yml",
          "role": "reference"
        }
      ],
      "manual_steps": [],
      "zip_url": "https://loadout.jnw-it.com/download/github-actions-hardening/claude-code.zip"
    },
    "codex": {
      "files": [
        {
          "path": "AGENTS.md",
          "target": "AGENTS.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/AGENTS.md",
          "role": "instructions"
        },
        {
          "path": "skills/harden-workflow/SKILL.md",
          "target": ".agents/skills/harden-workflow/SKILL.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/SKILL.md",
          "role": "skill"
        },
        {
          "path": "skills/harden-workflow/references/issue-triage.yml",
          "target": ".agents/skills/harden-workflow/references/issue-triage.yml",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/references/issue-triage.yml",
          "role": "reference"
        }
      ],
      "manual_steps": [],
      "zip_url": "https://loadout.jnw-it.com/download/github-actions-hardening/codex.zip"
    },
    "cursor": {
      "files": [
        {
          "path": "AGENTS.md",
          "target": "AGENTS.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/AGENTS.md",
          "role": "instructions"
        },
        {
          "path": "skills/harden-workflow/SKILL.md",
          "target": ".agents/skills/harden-workflow/SKILL.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/SKILL.md",
          "role": "skill"
        },
        {
          "path": "skills/harden-workflow/references/issue-triage.yml",
          "target": ".agents/skills/harden-workflow/references/issue-triage.yml",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/references/issue-triage.yml",
          "role": "reference"
        }
      ],
      "manual_steps": [],
      "zip_url": "https://loadout.jnw-it.com/download/github-actions-hardening/cursor.zip"
    },
    "gemini-cli": {
      "files": [
        {
          "path": "AGENTS.md",
          "target": "AGENTS.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/AGENTS.md",
          "role": "instructions"
        },
        {
          "path": "GEMINI.md",
          "target": "GEMINI.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/GEMINI.md",
          "role": "adapter"
        },
        {
          "path": "skills/harden-workflow/SKILL.md",
          "target": ".agents/skills/harden-workflow/SKILL.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/SKILL.md",
          "role": "skill"
        },
        {
          "path": "skills/harden-workflow/references/issue-triage.yml",
          "target": ".agents/skills/harden-workflow/references/issue-triage.yml",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/references/issue-triage.yml",
          "role": "reference"
        }
      ],
      "manual_steps": [],
      "zip_url": "https://loadout.jnw-it.com/download/github-actions-hardening/gemini-cli.zip"
    },
    "antigravity-cli": {
      "files": [
        {
          "path": "AGENTS.md",
          "target": "AGENTS.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/AGENTS.md",
          "role": "instructions"
        },
        {
          "path": "skills/harden-workflow/SKILL.md",
          "target": ".agents/skills/harden-workflow/SKILL.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/SKILL.md",
          "role": "skill"
        },
        {
          "path": "skills/harden-workflow/references/issue-triage.yml",
          "target": ".agents/skills/harden-workflow/references/issue-triage.yml",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/references/issue-triage.yml",
          "role": "reference"
        }
      ],
      "manual_steps": [],
      "zip_url": "https://loadout.jnw-it.com/download/github-actions-hardening/antigravity-cli.zip"
    },
    "copilot": {
      "files": [
        {
          "path": "AGENTS.md",
          "target": "AGENTS.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/AGENTS.md",
          "role": "instructions"
        },
        {
          "path": "skills/harden-workflow/SKILL.md",
          "target": ".agents/skills/harden-workflow/SKILL.md",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/SKILL.md",
          "role": "skill"
        },
        {
          "path": "skills/harden-workflow/references/issue-triage.yml",
          "target": ".agents/skills/harden-workflow/references/issue-triage.yml",
          "raw_url": "https://loadout.jnw-it.com/raw/github-actions-hardening/1.0.0/skills/harden-workflow/references/issue-triage.yml",
          "role": "reference"
        }
      ],
      "manual_steps": [],
      "zip_url": "https://loadout.jnw-it.com/download/github-actions-hardening/copilot.zip"
    }
  },
  "source_zip_url": "https://loadout.jnw-it.com/download/github-actions-hardening/source.zip"
}