Scan AI components in CI with the SkillTotal GitHub Action
Add one workflow file and every push and pull request gets a static security scan of the MCP servers, agent skills, prompts and packages in your repository. Findings go to Code Scanning with file:line evidence, and the job fails when something crosses the gate you set.
What the action does
The action runs the open-source SkillTotal engine on the runner. It reads your repository (or a package or git URL you name), never executes the code it reads, and never calls an LLM, so the same commit gives the same result on every run. The engine ships inside the tag you pin, so the tag also fixes the scanner version and nothing is downloaded from PyPI.
It looks for the same things as the scanner on this site: tool poisoning and prompt injection in agent-facing text, hidden Unicode, install-time scripts, decode-and-execute droppers, credential reads combined with network egress, embedded secrets, and dangerous MCP tools. A malicious indicator fails the job at any severity. A capability on its own, such as a shell call or network access, never does.
Copy-paste workflow
Save this as .github/workflows/skilltotal.yml. Drop pull-requests: write and comment-on-pr if you do not want the pull request comment.
# .github/workflows/skilltotal.yml
name: SkillTotal
on: [push, pull_request]
permissions:
contents: read
security-events: write # upload SARIF to Code Scanning
actions: read # the SARIF upload in a private repository
pull-requests: write # only for comment-on-pr
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pezhik/skilltotal@v0.73.0
id: skilltotal
with:
source: .
fail-on: high
comment-on-pr: 'true'on: [push, pull_request] scans a branch with an open pull request twice, once per event. To scan once, limit push to your default branch with push: { branches: [main] }. For a pin that cannot move, use the release's commit SHA instead of the tag: pezhik/skilltotal@<commit> # v0.73.0.
Inputs
| Input | Default | What it does |
|---|---|---|
source | . | A path in the repository, a git URL, or an npm:/pypi:<name> spec. |
version | empty | Install this skilltotal release from PyPI instead of running the engine that ships with the tag. |
fail-on | empty | low, medium, high, critical or none. Empty uses the gate of the applied .skilltotal.toml when it sets one, else high. |
fail-on-score | empty | Also fail at or above this risk score (0-100). |
fail-on-unanalyzed | false | true: also fail when part of the component could not be analyzed. |
exclude | empty | Path globs to skip, one per line or comma-separated. |
config | empty | Path to a .skilltotal.toml. Empty reads one from the working directory; none ignores it. |
upload-sarif | true | Upload SARIF to Code Scanning (needs security-events: write). |
sarif-category | empty | Code Scanning category. Empty: none for source ., else skilltotal/<source>. |
comment-on-pr | false | Keep one summary comment on the pull request (needs pull-requests: write). |
baseline | empty | Baseline file of accepted findings. |
sarif-file | skilltotal.sarif | Where the SARIF report is written. |
Outputs
risk-level, risk-score, sarif-file and verdict. verdict is malicious whenever a malicious indicator was found, whatever the score. Give the step an id and read them as steps.<id>.outputs.<name>. A tripped gate fails the step, so a later step that should still run needs if: always().
Where the results show up
Code Scanning (SARIF)
The action writes a SARIF 2.1.0 report and uploads it, so each finding appears under Security, then Code scanning, with one alert per location and a link to the line. For a source in a subfolder, paths are rewritten relative to the repository root so alerts point at real files. SARIF for an npm:, pypi: or git URL source is written but not uploaded, since its alerts would point at files your repository does not have.
Job summary
Every run writes a summary: the applied .skilltotal.toml, the verdict, the risk score and gate result, the gate lines that tripped, a table of risk findings with severity, rule and file:line, the capabilities, and how many findings a baseline or inline marker suppressed.
Pull request comment
With comment-on-pr: 'true', the action keeps a single comment on the pull request up to date: verdict, risk, gate result, the top five risk findings with file:line, and a link to the run. On a pull request from a fork the token is read-only, so the action skips the comment with a warning and does not fail the job.
The log
The step log names the gate that tripped and lists every location of each risk finding, up to ten per rule. Text from scanned files cannot issue workflow commands in the log.
Private repositories without GitHub Advanced Security
Code Scanning in a private repository needs GitHub Advanced Security. Without it, the upload fails with a warning and the gate still decides the job. Turn the upload off and keep the report as a build artifact instead:
- uses: pezhik/skilltotal@v0.73.0
with:
source: .
upload-sarif: 'false'
# The runner is discarded after the job: keep the report, also after a tripped gate
- if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: skilltotal-report
path: skilltotal.sarifThe job summary and the log still list every finding.
Roll out on an existing repository with a baseline
A first scan of an established repository may report findings you have already reviewed. Record them once, commit the baseline, and gate only on new findings:
skilltotal scan . --write-baseline .skilltotal-baseline.json
# .skilltotal.toml
baseline = ".skilltotal-baseline.json"
fail_on = "high"Read the report before you write the baseline: a baseline accepts everything in it, malicious indicators included. Put .skilltotal.toml and the baseline under CODEOWNERS so loosening the gate needs a review. For a single false positive, an inline # skilltotal:ignore[ST-ID] on that line keeps the decision next to the code. The action names the applied config in the log, the job summary and the PR comment, because a pull request can change it.
Re-check a dependency on a schedule
source also takes a package. A weekly job can re-scan an MCP server or library you depend on at the version you pinned:
on:
schedule: [{ cron: '0 6 * * 1' }] # runs only from the default branch
jobs:
mcp-server:
runs-on: ubuntu-latest
permissions: { contents: read }
steps:
- uses: pezhik/skilltotal@v0.73.0
with:
source: npm:some-mcp-server@1.4.2
config: none # this repository's .skilltotal.toml is not about that packageGitLab CI, Jenkins and other systems
Anywhere Python 3.10 or newer runs, install a pinned release with pip install skilltotal==0.73.0 and gate on the exit code of skilltotal scan . --sarif --output skilltotal.sarif --fail-on high: 0 passed, 2 a gate tripped, 1 the scan could not run. The README has ready GitLab CI and Jenkins examples, and the docs cover the pre-commit hook. Every option of the action is documented in the README.
FAQ
- Does the action send my code anywhere?
- No. The engine runs on the GitHub runner, calls no LLM and sends no telemetry. The only network use is fetching what you ask it to scan: a git URL, or an npm or PyPI package. SARIF goes to your repository's Code Scanning through GitHub's own upload action.
- Will it fail my build because a package uses the shell or the network?
- No. Capabilities such as shell, filesystem and network access are reported but never trip the gate. The job fails on a malicious indicator at any severity, or on a risk finding at or above fail-on (high by default).
- Does it work in private repositories?
- Yes. Code Scanning upload needs GitHub Advanced Security in a private repository. Without it, set upload-sarif: 'false' and keep the SARIF file with actions/upload-artifact; the gate, the job summary and the log work the same.
- Which version should I pin?
- A released tag such as pezhik/skilltotal@v0.73.0, or that release's commit SHA, which cannot be moved. The tag pins the engine too, since the engine ships inside the action.