From harness-claude
> Audit `.github/workflows/*.yml` for quality and hygiene: stale path filters, missing gates, permission scoping, script injection, action pinning, self-trigger loops, and ratchet calibration. Every check taxonomy entry is grounded in defect patterns observed in downstream repos, where each defect silently broke a gate and was only caught by manual review.
How this skill is triggered — by the user, by Claude, or both
Slash command
/harness-claude:harness-workflow-auditThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> Audit `.github/workflows/*.yml` for quality and hygiene: stale path filters, missing gates, permission scoping, script injection, action pinning, self-trigger loops, and ratchet calibration. Every check taxonomy entry is grounded in defect patterns observed in downstream repos, where each defect silently broke a gate and was only caught by manual review.
Audit
.github/workflows/*.ymlfor quality and hygiene: stale path filters, missing gates, permission scoping, script injection, action pinning, self-trigger loops, and ratchet calibration. Every check taxonomy entry is grounded in defect patterns observed in downstream repos, where each defect silently broke a gate and was only caught by manual review.
harness:audit)on_milestone fires as part of a release-readiness sweepharness-security-scan / harness-security-review)harness-supply-chain-audit for package dependencies)A gate that never fires is worse than no gate — it manufactures false confidence. Every check in this skill exists because a workflow that looked correct silently stopped enforcing anything. Never mark a workflow "healthy" based on its YAML looking reasonable; verify each filter, permission, and guard against the actual state of the repository.
Resolve project root. Use the path argument or default to the current directory. If workflow was passed, restrict the audit to that single file (all phases still run).
List workflow files. Glob .github/workflows/*.{yml,yaml}. If none exist, report "No workflow files found" and stop.
Parse each workflow. For every file record: triggers (on: block with any paths: / paths-ignore: / branches: filters), jobs and their steps, permissions: blocks (workflow- and job-level), concurrency: groups, all uses: action references with their ref, and every run: script.
Build the documented-gate list. Collect what the repo claims to enforce:
package.json / pyproject.toml / Makefile scripts: build, typecheck, lint, test, coverage commandseslint.config.*, tsconfig.json, .ruff.toml, etc.)README.md / CONTRIBUTING.md / AGENTS.md ("CI runs X", "all PRs must pass Y")Snapshot the file tree (git ls-files) for path-filter resolution in Phase 2.
Proceed to MECHANICAL.
Run every check against every workflow. Record each finding as {file, line, check, severity, evidence, suggested_patch}.
paths: / paths-ignore:, match it against the git ls-files snapshot.paths: glob that matches zero tracked files — the trigger is dead and the gate never fires (the signature failure after a directory rename).paths: filter misses files the job actually operates on (e.g., a lint gate filtered to src/** while the linter also covers scripts/**). Compare filter coverage against the paths the job's commands touch.permissions: block — the workflow inherits the repo default, which is often write-all.permissions: write-all, or contents: write / pull-requests: write on jobs whose steps only read (no push, no comment, no release step).permissions: block derived from what the steps actually do.actions/, non-github/) action pinned to a floating branch (@main, @master) — the action's author can change the code you execute at any time.@<40-char-sha> # vX.Y.Z) as the strongest form.actions/* on a floating branch.git ls-remote <action-repo> <ref> when network access allows; otherwise state the command for the user to run).git push, commit, or otherwise write back to a branch.[skip ci] in the commit message, an actor guard (if: github.actor != '<bot>'), or a paths-ignore: covering the generated file.git ls-remote --exit-code --heads origin <branch> guard before the push, exiting cleanly when the branch is gone.concurrency: group on workflows where overlapping runs race (deploy, push-back, cache-refresh jobs).run: step that echos / prints a value derived from ${{ secrets.* }}.env:.run: steps invoking scripts/paths that do not exist in the tree; workflow_call / uses: references to local workflows or composite actions that are missing.Proceed to JUDGMENT. Do not skip Phase 3 because Phase 2 found nothing — the judgment checks catch the failures mechanical checks cannot.
These require reading the workflow's intent against the repo's reality.
${{ github.event.* }}, ${{ github.head_ref }}, and other attacker-controlled values (PR titles, bodies, branch names, commit messages, issue comments) interpolated directly into run: scripts. Severity error — a crafted PR title becomes shell code.env: (env: TITLE: ${{ github.event.pull_request.title }} then "$TITLE" in the script), which makes it data instead of code.pull_request_target combined with a checkout of the PR head (ref: github.event.pull_request.head.sha) as error — untrusted code with secrets access.${{ github.event.inputs.* }} on workflow_dispatch is operator-supplied and usually fine; the same pattern on issue_comment is not.package.json for a TypeScript repo with no CI job running it, tests documented in CONTRIBUTING but never executed, coverage thresholds configured but unenforced.pull_request events without handling fork PRs, where GITHUB_TOKEN is read-only — the job fails or silently no-ops for outside contributors.if: github.event.pull_request.head.repo.full_name == github.repository guard with a degraded read-only path, or a workflow_run split.Proceed to REPORT.
Rank findings by severity (error → warning → info), then by blast radius (a dead path filter on the security gate outranks one on a docs job).
Emit one entry per finding:
[ERROR] path-filter-dead .github/workflows/security.yml:7
paths: 'lib/scanner/**' matches 0 tracked files (directory renamed to packages/scanner in <commit>)
Effect: security gate has not run on any PR since the rename.
Patch:
- - 'lib/scanner/**'
+ - 'packages/scanner/**'
Every entry must carry: severity, check id, file:line, evidence (what was observed and why it matters), and a concrete suggested patch (diff or exact YAML). No finding ships without a patch or an explicit "requires human decision: ".
Summary block:
WORKFLOW AUDIT: <repo>
Workflows audited: N Findings: E error, W warning, I info
Gates that never fire: <list or none>
Documented-but-unwired gates: <list or none>
Filter by the severity argument if provided (default: report everything).
Composition note: when invoked as a dimension of harness:audit, return the findings list and summary block to the orchestrating skill instead of terminating — workflow hygiene is one axis of the full-repo audit.
git ls-files output for every glob. A filter you did not resolve is a filter you did not audit.harness skill run harness-workflow-audit — Run the audit (args: path, workflow, severity).harness:audit — the full-codebase audit orchestrator consumes this skill's findings as its CI-hygiene dimension.harness-security-scan — that skill scans application code mechanically; this one audits the workflow files that decide whether that scan (and every other gate) actually runs.harness verify — after the user applies suggested patches, the quick gate confirms the repo's own checks still pass.Cite the observation behind every finding:
git ls-files match countrun: line it lands inNever assert "this gate does not run" without showing the zero-match glob or the missing step.
paths: / paths-ignore: glob in every workflow was resolved against the tracked file tree, and every zero-match glob is reported as an errorfile:line, evidence, and a concrete suggested patchpull_request_target, deploy credentials): surface it at the top of the report regardless of rank order.| Rationalization | Reality |
|---|---|
| "The workflow is green on every PR, so it must be working" | A workflow whose path filter matches nothing is green because it never runs. Green means "did not fail", not "enforced something". Resolve the filters. |
| "The path globs look right — they match the directory names I can see" | Filters go stale exactly when directories are renamed, which is when they still look right. Only a match against git ls-files counts as verification. |
| "It's a private repo, so injection and pinning findings don't matter" | Private repos have contractors, compromised accounts, and dependency-of-dependency actions. Report the finding with severity intact; let the human accept the risk explicitly. |
| "This interpolation is fine because the value comes from our own team" | Branch names and PR titles are attacker-controlled the moment an outside contributor (or a compromised account) opens a PR. Route it through env: — the fix costs two lines. |
| "The push-back workflow has run for months without a loop, so it doesn't need guards" | It hasn't looped yet because timing has been kind. The branch-deleted race and the re-trigger loop are both timing-dependent; absence of incident is not presence of a guard. |
| "I'll report the finding without a patch — the maintainer will know what to do" | A finding without a concrete patch gets triaged to "later" and dies there. The patch is the deliverable; the finding is its justification. |
$ harness skill run harness-workflow-audit --path .
WORKFLOW AUDIT: example-monorepo
Workflows audited: 5 Findings: 3 error, 3 warning, 1 info
Gates that never fire: security-scan.yml (dead path filter)
Documented-but-unwired gates: typecheck
[ERROR] path-filter-dead .github/workflows/security-scan.yml:9
paths: 'src/services/**' matches 0 tracked files (tree uses packages/*/src since the workspace migration)
Effect: the security gate has not executed on any PR touching service code.
Patch:
- - 'src/services/**'
+ - 'packages/*/src/**'
[ERROR] gate-missing .github/workflows/ci.yml
package.json declares "typecheck": "tsc -b" and the repo is TypeScript, but no workflow step runs it.
Effect: type errors land on main; the gate is assumed but absent.
Patch: add to the ci job, after install:
+ - name: Typecheck
+ run: pnpm typecheck
[ERROR] injection .github/workflows/label.yml:24
${{ github.event.pull_request.title }} interpolated into run:. A crafted title executes as shell.
Patch: pass via env:
+ env:
+ PR_TITLE: ${{ github.event.pull_request.title }}
- run: echo "Title: ${{ github.event.pull_request.title }}"
+ run: echo "Title: $PR_TITLE"
[WARNING] pushback-race .github/workflows/ledger.yml:41
Job pushes to the PR head branch with no existence check; branch auto-delete on merge races this run.
Patch: guard the push:
+ - run: git ls-remote --exit-code --heads origin "$HEAD" || { echo "branch gone, skipping"; exit 0; }
[WARNING] ratchet-calibration .github/workflows/ledger.yml:18
"New findings" ratchet blocks on info severity while the error gate passes — false-blocks PRs on fixture noise.
Patch: gate on --severity error, report info findings as a non-blocking comment.
[WARNING] permissions-broad .github/workflows/ci.yml:5
permissions: contents: write at workflow level; no step pushes. Read suffices.
Patch:
-permissions:
- contents: write
+permissions:
+ contents: read
[INFO] pinning .github/workflows/ci.yml:31
third-party/setup-tool@main is a floating branch. Pin to a SHA:
- uses: third-party/setup-tool@main
+ uses: third-party/setup-tool@4f2c3a1b… # v2.3.1
Next steps: fix the two dead/missing gates first — every other gate's meaning depends on them.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeReviews GitHub Actions workflows for supply chain risks: enforces SHA pinning, rates third-party actions, scopes permissions, checks triggers and inputs, and recommends updates.
Reviews GitHub Actions workflows for exploitation vulnerabilities including expression injection, credential theft, and supply chain attacks with concrete PoC scenarios.
Finds exploitable vulnerabilities in GitHub Actions workflows with concrete attack scenarios. Use when reviewing .github/workflows for security issues.