
Read-only scanner for what lets a repository run code in a coding agent (Claude Code, Codex, Cursor, Copilot): git settings, hooks, and committed MCP server definitions and agent settings. SARIF output. No dependencies, no network, no telemetry.
A repository can make your coding agent run code the moment it opens the folder. GuardSkill checks for that before you do.
npx guardskill .
Read-only. No network calls, no telemetry, no configuration, no account, no dependencies. It reads git configuration and hook scripts, prints what it found, and exits.
Coding agents gather context by running ordinary git commands — git status, git diff — as soon as they open a project. Git reads its settings from the repository's own .git/config, and roughly two dozen of those settings name a program for git to execute. Put a command in core.fsmonitor and it runs, with your privileges, outside any sandbox, before you have typed anything.
Two pieces of public research make this concrete:
.git/config.git directory already inside@github/copilot 1.0.43 by forcing safe.bareRepository=explicit. (GitHub Advisory)The recommended user-side mitigation in both write-ups is the same: inspect the git configuration before you open the directory with an agent. That is tedious to do by hand across a tree. This tool does it in a second.
The git vectors have one saving grace: the repository has to arrive as files, because a clone does not carry .git/config. Agent settings have no such requirement. A .mcp.json, a .claude/settings.json or a .vscode/mcp.json is an ordinary tracked file — it arrives with git clone like any other — and a coding agent reads it when it opens the project. An MCP server entry names a program and the agent starts it; a hook names a command and the agent runs it on an event; a permission setting can switch off the step where the agent asks you first.
How carefully these are configured in practice is worth one external number. Bloomberry measured 1,412 company-hosted MCP servers in February 2026 and found 38.7% with no authentication at all, up from 425 servers six months earlier. Those are public endpoints rather than the server definitions in your repository — a different population — but it is the same ecosystem, and it says something about how much care these configurations get. (Bloomberry)
One more thing worth saying plainly, without putting a number on it: the gap between "someone finds this" and "someone uses this" is closing, because the finding and the using are increasingly done by the same automated machinery. That is a reason to look before you open a directory, not a reason to panic about any particular line in a report — which is why GuardSkill does not attach a time-to-exploit claim to individual findings. It cannot know.
Every git configuration an agent could pick up, not only the obvious one: the project's own .git, any nested .git that arrived as content, any bare repository hidden in a subdirectory, the .git file a submodule or linked worktree leaves behind, config.worktree, every .git/modules/<name>/config, and any file pulled in through include.path that lies inside the tree.
And the agent settings files a repository can ship: .mcp.json anywhere in the tree, .claude/settings.json and settings.local.json, .vscode/mcp.json, .cursor/mcp.json, .gemini/settings.json, .windsurf/mcp.json. Both classes are found in one walk of the directory tree; the second one costs nothing extra.
The full list, key by key, with a source and a coverage status for each, is in rules/git-exec-keys-inventory.md. A test fails if that inventory and the rule set drift apart, so the list is auditable rather than aspirational. In summary:
| Class | Checks |
|---|---|
| Runs on ordinary commands | core.fsmonitor, core.pager, pager.<cmd>, core.editor, sequence.editor, diff.external, core.askPass, core.alternateRefsCommand |
| Runs on network operations | core.sshCommand, core.gitProxy, remote.<n>.uploadPack / receivePack, uploadpack.packObjectsHook |
Runs through .gitattributes | filter.<d>.clean / .smudge / .process, diff.<d>.textconv, merge.<d>.driver |
| Runs on demand | mergetool / difftool / browser / guitool / man .cmd, credential.helper, gpg.program, trailer.<t>.command, any alias.* that shells out or re-enters git with -c |
| Introduces settings later | include.path, includeIf.*.path (followed and inspected), init.templateDir |
| Transports that execute | protocol.allow, remote and submodule URLs using ext:: |
| Hooks | core.hooksPath, active scripts in .git/hooks, hook scripts that download or decode code, hook scripts that are symlinks |
| Structure | bare repositories in the tree, nested .git directories that are not registered submodules, directories named .git in a different case |
Four keys are deliberately out of scope, each with a reason in the inventory: sendemail.smtpServer, instaweb.httpd, ssh.variant and the HTTP proxy settings. That is why this README lists what it checks instead of claiming to cover a whole vulnerability class — a claim would only be true when that column is empty.
The second class, with its own inventory in rules/agent-settings-inventory.md and the same drift test behind it.
| Check | Severity |
|---|---|
An MCP server started from PATH (npx, uvx, node, docker) | low — informational. This is how most MCP servers are configured |
| The command is a path inside the tree, or an argument names a file inside it | high |
The command is a shell, downloads and runs code, or lives in /tmp or a hidden directory | critical |
A remote server declared with no credential material in headers or env | medium |
| A hook that runs a command on an agent event | the same ladder as above — npx prettier --write is informational, a script from the tree is high, a shell pipeline is critical |
A request to skip the approval step (bypassPermissions) | high, with the finding saying what the client actually does with it |
| A server entry in a shape the scanner does not recognise | medium — not understood is not the same as not there |
A wildcard in permissions.allow | high |
| A credential-shaped value | high |
If your own project ships its own MCP server, that entry is reported at high — GuardSkill cannot tell a repository you wrote from one you were handed. Use --fail-on critical, or --exclude the path.
And the other way round: for a tree you were handed, scan with --fail-on low. A server started as npx -y @attacker/mcp-helper is informational by design — the command is ordinary and the package name is the whole of the attack, and judging a package by its name is a registry-reputation question that needs the network. The default threshold will not fail on it. That default is tuned for your own repository in CI; a delivered directory is a different question and deserves the lower threshold and a reading of the list.
acceptEdits and plan are not reported. The documentation is explicit about what acceptEdits permits — reads, file edits and common filesystem commands, with Bash and network still prompting — so calling it an approval bypass would be wrong on the facts, and it is one of the most-used settings there is.
What is deliberately not checked: whether a remote MCP server actually requires authentication. That is only answerable by connecting to it, and this tool makes no network connections. The mcp-remote-no-auth finding reports what the file declares, and says so in its own text.
npx guardskill . # scan the current project
npx guardskill ~/code/some-project # scan a specific path
npx guardskill . --json # machine-readable
npx guardskill . --out report.md # also write a Markdown report
npx guardskill . --fail-on critical # only fail the build on critical findings
npx guardskill . --allow-incomplete # accept a partial walk
npx guardskill . --exclude test/fixtures
npx guardskill . --sarif-out results.sarif # for GitHub code scanning
Or install it once: npm install -g guardskill, then guardskill ..
From a clone the entry point is node bin/guardskill.js. src/ is a library — running
it does nothing, which is deliberate: the bug that shipped in the first release
candidate was a CLI that guarded its own execution and lost that guard to npm's bin
symlink. The published binary is now the only thing that starts a scan, and a
packaging test drives it on all three platforms.
These are two different answers and the tool reports both. The status in the JSON says what was found. The exit code says whether that is bad enough to fail on, given the threshold you set.
status | Meaning |
|---|---|
CLEAN | Zero findings, and the whole tree was inspected |
FINDINGS | At least one finding, at any severity — including ones below your threshold |
INCOMPLETE | No findings, but part of the tree was not inspected |
ERROR | The scan did not run |
| Code | Meaning |
|---|---|
0 | Nothing at or above --fail-on, and the whole tree was inspected |
1 | Findings at or above --fail-on (default: high) |
2 | The scan could not run: bad path, bad options, invalid rule set |
3 | Part of the tree was not inspected — a truncated walk, a refused symlink, an include outside the tree |
So a repository with one medium finding and a default threshold reports status: FINDINGS and exits 0. That combination is deliberate: exit 0 is a policy answer, and a consumer reading the JSON still has to be able to see what was found before deciding. CLEAN means zero findings and nothing else.
--fail-on moves the severity threshold and nothing else — it never removes a finding from the report. Completeness is a separate axis again: an incomplete scan fails unless you pass --allow-incomplete, because "we did not look there" is not the same as "there is nothing there".
With --json, a failure answers in JSON too: an error document with the same fields, status: "ERROR", scanned: false and the reason in reason, so a consumer piping into a parser gets a diagnosis instead of a parse error. The human-readable reason also goes to stderr.
In CI:
- name: Check for git execution vectors
run: npx guardskill . --fail-on high
Or as an action, which pins the version for you:
- uses: soemoescode/[email protected]
with:
fail-on: high # critical, high, medium or low
exclude: test/fixtures # comma-separated, optional
With SARIF, findings stop being a red cross and become rows in the repository's own code-scanning view, with the file, the line, and a status per finding:
- uses: soemoescode/[email protected]
with:
sarif-file: guardskill.sarif
fail-on: critical # let the Security tab carry the rest
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: guardskill.sarif
At most 50 findings per rule per file are listed, with the remainder counted in one summary finding — a settings file with thousands of identical problems otherwise produces a SARIF document GitHub refuses to accept, and then the Security tab shows nothing at all.
--sarif writes the same document to stdout, and --sarif-out <file> writes it alongside the readable report rather than instead of it. The severity mapping is stated rather than guessed: critical and high become error, medium warning, low note, and security-severity — the number GitHub sorts on — is derived from those same four levels. GuardSkill computes no CVSS score, and filling that field from one would be a claim it cannot support. An incomplete scan travels too, as a tool notification on the run: a Security tab that quietly shows nothing about a tree half of which was never opened would be the same failure as exiting 0 on it.
The JSON output carries schemaVersion: 1. Fields will be added within version 1; existing ones will not change meaning.
Example output:
GuardSkill - git execution-vector scan (read-only)
Path: /Users/dev/clients/handover-package
Git configurations inspected: 3 Directories walked: 412
[CRITICAL] vendor/tooling.git - Bare git repository found inside the project tree
what Git discovers bare repositories while walking directories and applies their
configuration, including keys that execute commands.
found bare repository at vendor/tooling.git
do Do not open this project with a coding agent until you have inspected it.
[CRITICAL] vendor/tooling.git/config:4 - core.fsmonitor runs an external command
found core.fsmonitor = /tmp/.cache/fsmonitor-helper.sh
2 critical, 0 high, 0 medium, 0 low.
Nothing was changed - this scan only reads.
A security tool that cries wolf gets uninstalled. Four things hold the line, and all four run on every commit:
.githooks, registered submodules, credential helpers, custom editors and pagers, signing config. The build fails if any produces a finding above informational.test/corpus/PROVENANCE.md) — five repository shapes people actually have. No fixture may produce a critical, and every high must be declared. This is the check that catches a severity model which is technically right and practically unusable.Three deliberate design choices come out of that:
cat, sops, jupyter nbconvert) is informational. The same key naming /tmp/x.sh, carrying a shell metacharacter, or re-entering git with -c is not..husky running npm test is informational, and the finding lists what will run. The same directory reaching into /tmp is not.It never modifies the project it inspects; the only file it writes is the report you ask for with --out. It never executes anything it finds. It makes no network calls and collects no telemetry — run it offline and it behaves identically. It has no dependencies, so installing it does not pull in a supply chain of its own. The directory walk does not follow symlinks, and a config file reached through one is refused and reported rather than read.
It does not scan npm dependencies, .claude/settings.json, .vscode/tasks.json or MCP server definitions. That is the next class, and it is not in this version.
Every one of those sentences is tied to the test that proves it in SECURITY.md. A claim without a test does not belong in this file.
SKILL.md lets a coding agent run the scan itself before it opens an unfamiliar project — so the check happens without you remembering to ask for it.
npx skills add soemoescode/guardskill
That installs the skill for Claude Code, Cursor and Codex; --agent claude-code narrows it to one. You can also copy SKILL.md into your skills folder by hand, or point your agent at this repository.
npm test # regenerates fixtures, then runs every suite
node tools/generate-golden.mjs # only when the parser's golden table changes; needs git, run offline
New detection rules go in rules/git-exec-keys.json and must be listed in rules/git-exec-keys-inventory.md, with a fixture on both sides — one repository that must trigger it, one realistic repository that must stay quiet. A rule with only a positive case will not be merged.
The agent-settings class and SARIF output shipped in 0.5.0. Next: a separate confidence axis alongside severity, so a finding can say how sure it is rather than only how bad it would be; TOML-based agent settings, which need a parser this project does not want to take on lightly; and the CI definitions a repository ships. Continuous monitoring, Slack and Teams alerts and auto-fix pull requests are planned as a paid layer. The scanner stays free and MIT-licensed, and the detection rules stay in the open repository — a security tool whose rules you cannot read is not one you should trust.
MIT. Built and maintained by Helios IT Solutions, a Dutch IT service provider. Security issues: see SECURITY.md.
Every release is published from a tag by GitHub Actions with npm provenance, so the package on the registry is traceable to the workflow run and the commit that produced it. Check it yourself before you trust it:
npm audit signatures
The independent security reviews this tool was put through, and the acceptance gate they were judged against, are in docs/.