Skip to main content
Glama
workflowlint-mcp

workflowlint-mcp

by 0xmortuex
README.md
# workflowlint-mcp, The Hard Way

Build an MCP server that lets an AI **audit GitHub Actions workflows**, from "what does `on:` parse to?" to a
tested stdio server. No actionlint, no framework doing the hard part for you.

At the end you will have built a small version of
[workflowlint-mcp](https://github.com/0xmortuex/workflowlint-mcp) yourself, and you will understand the
things most workflow linters get wrong: **YAML** (PyYAML turns `on:` into `True`), **line numbers** (a finding
inside `run: |` must point at the real line), **injection** (`${{ }}` is text substitution, not a variable),
**supply chain** (what pinning to a SHA does and does not buy you), **permissions** (job-level replaces
workflow-level), and **cron** (the day-of-month / day-of-week rule).

## Why This Exists

Most "workflow linter" write-ups are a list of regexes over raw text. That breaks on the first file that
uses an anchor, a multi-line `run:`, or `${{ format('}}') }}`. A linter an AI relies on has to be right about
structure first, point at the exact line, and stay quiet on good files: every false positive teaches the
model to ignore you.

## Who This Is For

- Developers who built a hello-world MCP server and want a real one
- Anyone who writes workflows and wants to know *why* the usual advice (pin SHAs, set permissions, use
  `env:`) is the usual advice
- Anyone curious how GitHub cron really works

**Prerequisites:** Python and a rough idea of what a workflow file looks like. Nothing here talks to GitHub
except one module that talks to a fake GitHub on localhost.

```bash
pip install -r requirements.txt
python verify.py          # runs every module and checks it
```

## Modules

| # | Module | What You'll Build |
|---|--------|-------------------|
| 01 | [YAML Pitfalls](01-yaml-pitfalls/) | Why `yaml.safe_load` is wrong for workflows (`on` -> `True`, `yes`, `3.10`), and a composer-level parser with line numbers, anchors and an alias-bomb guard |
| 02 | [A Workflow Model with Line Numbers](02-a-workflow-model-with-line-numbers/) | Jobs, steps, triggers, and exact line numbers inside `run: \|` and `run: >` blocks |
| 03 | [Expressions and Injection](03-expressions-and-injection/) | Finding `${{ }}` with nested quotes, the attacker-controlled contexts, a rule engine with ignore comments |
| 04 | [Pinning and Supply-Chain Risk](04-pinning-and-supply-chain/) | The pin rule, an offline pin plan, and an online resolver that can only talk to one host |
| 05 | [The Permissions Model](05-the-permissions-model/) | Effective token permissions, inferring what a job needs, and admitting when the inference is partial |
| 06 | [Cron Math](06-cron-math/) | A cron implementation, next run times, and the OR rule, cross-checked against brute force |
| 07 | [MCP Wrapper and Testing](07-mcp-wrapper-and-testing/) | A sandboxed stdio server, `ToolError` at the boundary, and a test that launches it for real |

The code lives in [`minilint/`](minilint/), one file per concern, and grows as you go. Each module has a
`main.py` that exercises that stage and checks its own claims with `assert`; `verify.py` runs them all.

## Design rules that run through every module

1. **Parse, then judge.** Rules look at parsed nodes, never at raw text.
2. **Every finding has a line number and a concrete fix.** "Consider improving security" is not a finding.
3. **Names, never values.** Report the secret's name; the value stays out of the model's context.
4. **Stay quiet on good files.** A corpus of good workflows with zero findings is part of the test suite.
5. **Static means static.** Nothing is run, and the network is off unless you ask for it.
6. **Fail loudly and usefully.** Errors say what to do next.