Report security issues privately through GitHub's "Report a vulnerability" flow on this repository, or by email to the maintainer address listed on https://helios-it.nl. Please do not open a public issue for an unpatched problem.
We aim to acknowledge a report within three working days.
GuardSkill runs on directories that are, by assumption, hostile. Everything it reads — config values, path names, script contents — is attacker-controlled input, and the report it prints is an output channel an attacker would like to influence.
- It only reads the project it inspects. No file there is created, modified or deleted. The only file it writes is the report you ask for by name with
--out. - It never executes anything it finds. Values from config and hook scripts are treated as text: printed as evidence, never run, never passed to a shell.
- It never asks a server anything. The agent-settings class reports remote MCP servers that declare no credential material. That is a statement about the file, not about the server: no connection is made, and the finding says so in its own words. Whether an endpoint really requires authentication is a question this tool deliberately cannot answer.
- The directory walk does not follow symlinks, and neither does config reading. A config file that is a symlink, or whose resolved path leaves the scanned tree, is refused, reported, and marks the scan INCOMPLETE. The file is not read, so a delivered directory cannot point the scanner at
~/.gitconfigand have its contents printed. - No network access. No outbound connections, no telemetry. Identical behaviour offline.
- No dependencies. Nothing is installed alongside it.
- No single file can drown the report. At most 50 findings per rule per file are listed; the rest are counted in one summary finding, and the scan says it is not listing everything. A settings file with 15,000 hostile entries otherwise produced a 21.8 MB SARIF document, and GitHub refuses an upload above 25,000 results or 10 MB — so the Security tab would have shown nothing at all. A finding that cannot reach the person reading it is the same failure as not finding it.
- One bad file costs its own finding and nothing else. A settings file that is unparsable, too large or nested past the limit is reported and marks the scan INCOMPLETE, and the failure is contained to that file. A run that has already found a payload cannot be turned into "nothing to report" by putting a second, harmless-looking file next to it — suppressing a scanner is cheaper than evading one, and that route is closed.
- A file it cannot read is a finding, not a pass. A config or hook script that exists but cannot be opened is reported and marks the scan INCOMPLETE. Silence about a file that was never read would be the one failure mode a scanner cannot have.
- Bounded traversal and bounded reads. Depth and entry count are capped; a config above 4 MB is reported rather than parsed. A walk that stops early is reported as INCOMPLETE, never as clean.
- The report cannot be rewritten by its subject. Control characters are stripped from every field before it reaches a terminal or a Markdown file — values, paths, titles and explanations alike. A directory called
vendor<ESC>[2K\rNo findingscannot erase the line it appears on. The JSON output keeps raw values, because it is not a terminal, with the escape character itself escaped.
If you find a way to make GuardSkill write, execute, connect out, escape its target directory, or print something that misrepresents what it found, that is a vulnerability in this tool and we want to hear about it.
Every behavioural claim in the README, this file, SKILL.md and the project page appears below with the test that proves it. A claim without a test is removed from the documentation rather than left standing.
| Claim | Where | Test |
|---|---|---|
It never modifies the project it inspects; the only file written is --out |
README, help text | no source file calls process.exit after writing to stdout guards the CLI's write paths; every scan test asserts on a fixture that is regenerated byte-identically |
| It never executes anything it finds | README, SECURITY | No child_process in src/, asserted by src/ contains no process-spawning code |
| The directory walk does not follow symlinks | SECURITY | the scanner never follows symlinks out of the target |
| A config reached through a symlink is refused and reported, not read | README, SECURITY | F-09 a config reached through a symlink is refused, not read |
| Hook scripts that are symlinks are still inspected; ones pointing outside the tree are reported and not read | README | F-05 a hook script that is a symlink is still inspected |
| No network calls, no telemetry | README, SECURITY | src/ contains no process-spawning code (same assertion covers http, https, net, fetch) |
| An incomplete walk never reads as a clean result | README | a truncated walk is never reported as a clean result (text) and exit codes: CLEAN 0, FINDINGS 1, ERROR 2, INCOMPLETE 3 (exit code and JSON status) |
| The default traversal depth is 24 | README, CHANGELOG | the help text states the same default depth the code uses |
--fail-on moves severity only, never completeness |
README | --fail-on changes severity, never completeness |
The JSON output is stable at schemaVersion: 1 |
README | the JSON output matches the documented schema in all four states |
| The report cannot be rewritten by its subject | SECURITY | a hostile value cannot rewrite the report it appears in and F-11 a hostile directory name cannot rewrite the report |
| 32 clean fixtures produce nothing above informational | README | no medium-or-higher finding on any clean fixture, and the fixture counts in this table are the real ones keeps the number honest |
| 31 hostile fixtures are each caught by their own rule | README | each vulnerable fixture is detected by the rule it was written for |
| No real-world corpus repository produces a critical | README | no corpus repository produces a critical finding |
| The rule set matches the published key inventory | README | every rule in the ruleset appears in the inventory, every key marked covered names a rule that exists |
| The parser agrees with git, or declares why not — over a generated permutation of quote, comment and escape positions, not only hand-picked cases | inventory, CHANGELOG | the parser agrees with git, or declares why it does not, every value git accepts is at least seen by the parser, the golden table records which git produced it |
| An invalid rule set fails loudly instead of pinning the exit code | README | F-12 an invalid ruleset is rejected with a clear error |
| A hooks directory is judged by its scripts, not its name | README | a hostile hooks directory cannot hide behind a familiar name and a real husky project still produces nothing above informational |
| Structure is medium on its own and critical with an execution key | README | F-10a a clean structural finding is not critical on its own and a bare repository hidden in the tree is reported as critical |
| The published package runs: the installed binary prints a report and sets an exit code | README, CHANGELOG | the installed binary prints its version and exits 0, the installed binary finds a real finding and exits 1, the tarball npm publish would upload contains the entry point |
status reports what was found; the exit code reports what was asked |
README | status reports what was found; the exit code reports what was asked (N-4) |
A --json run answers in JSON when it fails as well |
README | --json answers in JSON when it fails, too |
| A config that cannot be read is reported, never counted as clean | README, SECURITY | an unreadable config is reported, not silently treated as clean |
| The agent-settings class never connects to anything, including for the remote-server check | README, SECURITY | the agent class never reaches the network, the remote-server check reports what the file declares and says it did not connect |
| An MCP server started from PATH stays informational; a shell, a temporary path or a download-and-run is critical | README, inventory | severity follows what the command names, not that a server exists, an ordinary MCP configuration never fails a default build |
| The documented agent settings file names are read, and matched the way git matches names, so a case variant is not invisible | inventory | only the documented file names are read, a settings file whose name differs only in case is not invisible |
| A server entry in a shape the scanner does not recognise is reported, not skipped | inventory | a server entry in an unfamiliar shape is reported, not skipped |
| One malformed settings file cannot suppress the rest of a scan | SECURITY | one hostile settings file cannot silence the rest of the scan |
| Hooks and MCP commands are graded on the same severity ladder | inventory | a hook gets the same severity ladder as a server command, an ordinary Claude Code project does not fail a default build |
acceptEdits and plan are not reported; bypassPermissions is reported for what it actually does |
inventory | the permission modes people actually use stay silent, a request to skip the approval step is reported for what it is |
| Agent findings carry a line number, like git findings | README | an agent finding carries a line number into the report |
| A hook produces one finding, carrying a hook rule id | inventory | a hook produces exactly one finding, and it is about a hook |
| A URL in an argument counts as an endpoint only for a known bridge | inventory | a URL among the arguments is only an endpoint when the command is a bridge |
| No single file can flood the report or push a SARIF upload past GitHub's limits | SECURITY | one file cannot flood the report or the SARIF upload |
| The agent rule set matches its published inventory | README | every agent rule appears in the inventory, and the reverse |
| An agent settings file that cannot be parsed is reported, never counted as clean | README | an agent settings file that cannot be parsed is reported, never counted as clean |
SARIF security-severity is derived from GuardSkill's own severity, not from an invented CVSS score |
README | security-severity follows our severity, and is not invented |
| An incomplete scan is visible in SARIF as well as in the exit code | README | an incomplete scan stays visible in SARIF |
These are known and deliberately not closed in 0.4.0. They are listed here because an accepted risk that is not written down publicly is not accepted, it is concealed.
- Four executing config keys are out of scope:
sendemail.smtpServer,instaweb.httpd,ssh.variantand the HTTP proxy settings. Reasons per key inrules/git-exec-keys-inventory.md. Each is reachable only through a command a coding agent does not run, or names something other than a program. - The real-world corpus is reconstructed, not cloned. The five fixtures reproduce shapes observed in an independent review, not verified checkouts pinned by commit SHA. See
test/corpus/PROVENANCE.md. A reconstruction cannot surprise you the way a real checkout can. - Detection stops at git configuration and agent settings.
.vscode/tasks.json, CI workflow definitions, npm lifecycle scripts and TOML-based agent settings are not inspected, each with a reason inrules/agent-settings-inventory.md. Naming them keeps the gap a decision rather than an oversight. - The remote MCP check is a declaration, not a probe.
mcp-remote-no-authreports that a server entry carries no credential material in the file. A server that requires authentication but is configured elsewhere will still be reported, and a server that declares a header but accepts unauthenticated requests will not be. Answering it properly means connecting, and the no-network promise is worth more than this check. - Agent settings in TOML are not inspected.
.codex/config.tomland anything else in that format needs a TOML parser, and a scanner with no dependencies is not taking one on for a single format. Listed inrules/agent-settings-inventory.mdwith the other out-of-scope entries. - One parser deviation remains, on valueless keys.
keywith no=is treated as the stringtrue, because a rule has to evaluate something; git treats it as boolean true. Every other case in the golden table now matchesgit config --filebyte for byte. Listed with reasons intest/golden.test.js.