code-guardian-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """code-guardian: is this codebase safe to run, will it work, and is it complete?
2
+
3
+ Deterministic Safe, Works and Complete scores (0-100) with an Overall A-F grade, from static scanners, a static call
4
+ graph and project-file checks, plus an optional plain-English explanation from a local model that never changes the
5
+ score. Standard library only; the scanned code is parsed, never executed, imported or installed.
6
+ """
7
+
8
+ __version__ = "0.2.0" # keep equal to pyproject.toml version and mcp_server.VERSION
@@ -0,0 +1,6 @@
1
+ """python -m code_guardian scan PATH ..."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
@@ -0,0 +1,216 @@
1
+ """The check catalogue: every finding code, what it means, how to fix it, and when it can be wrong.
2
+
3
+ SARIF rules and the Markdown output read it; tests/test_readme_sync.py checks the README catalogue against it.
4
+
5
+ Public contract:
6
+ CATALOGUE = {code: {"title", "means", "fix", "false_positives"}}
7
+ """
8
+
9
+ import sys
10
+
11
+ from .finding import summarize
12
+
13
+
14
+ def _e(title, means, fix, false_positives):
15
+ return {"title": title, "means": means, "fix": fix, "false_positives": false_positives}
16
+
17
+
18
+ CATALOGUE = {
19
+ # Safe
20
+ "secret.key": _e(
21
+ "Secret in the source",
22
+ "An API-key shape (OpenAI-style sk-, GitHub, Google, AWS, Stripe live, Slack, Hugging Face and others), an AWS "
23
+ "secret key, a private-key block, a password-like literal or a high-entropy literal in a name ending _KEY or _TOKEN is "
24
+ "written into a file (a Stripe test key is INFO).",
25
+ "Move it to an environment variable or a secret store, and rotate it: it is in the history.",
26
+ "Test fixtures and documentation samples; values that call themselves fake (mock, dummy, example) are skipped."),
27
+ "exec.dynamic": _e(
28
+ "Code built at runtime",
29
+ "eval, exec or compile runs a non-literal string, or __import__ loads a module named at runtime; FAIL when "
30
+ "eval or exec runs text from input(), sys.argv, request data, the environment or the network.",
31
+ "Use a parser, a lookup table or ast.literal_eval; import modules by name; never run input as code.",
32
+ "Plugin loaders and REPL-style tools use these on purpose."),
33
+ "exec.shell": _e(
34
+ "Shell command",
35
+ "os.system, os.popen or subprocess with shell=True; FAIL when the command text comes from input, the environment "
36
+ "or the network.",
37
+ "Pass an argument list without shell=True; validate any outside value against an allow-list.",
38
+ "Fixed command strings in build scripts are WARN, not FAIL; taint is tracked one function deep."),
39
+ "deser.unsafe": _e(
40
+ "Unsafe deserialisation",
41
+ "pickle, marshal, yaml.load without SafeLoader, or torch.load without weights_only=True can run code hidden in "
42
+ "the data.",
43
+ "Load only files you created, or use a data-only format (json, safetensors, yaml.safe_load).",
44
+ "Caches the program writes and reads itself are lower risk."),
45
+ "net.egress": _e(
46
+ "Network egress",
47
+ "The egress inventory: every URL and IP literal and every network call site. WARN for a public IP, or plain "
48
+ "http/ws/ftp to a public name or to a host set at runtime.",
49
+ "Use https, and confirm each destination is expected.",
50
+ "Local-network and documentation hosts stay INFO; a URL in a help string is still inventoried."),
51
+ "net.bind_all": _e(
52
+ "Listens on every interface",
53
+ "A server binds 0.0.0.0 or ::, so other machines can reach it.",
54
+ "Bind to 127.0.0.1 unless other machines must connect.",
55
+ "Containers usually must bind 0.0.0.0; a test that asserts the bind is refused still matches."),
56
+ "tls.off": _e(
57
+ "TLS checking switched off",
58
+ "verify=False, ssl._create_unverified_context, CERT_NONE or check_hostname = False.",
59
+ "Remove it; point at a CA bundle if the certificate is private.",
60
+ "Rare: code that deliberately probes broken servers."),
61
+ "install.hook": _e(
62
+ "Code runs on install",
63
+ "setup.py cmdclass or install-command subclasses, package.json preinstall/install/postinstall, or an in-tree "
64
+ "build backend.",
65
+ "Read what the hook runs before installing; prefer packages without install-time code.",
66
+ "Projects that compile extensions legitimately customise build commands."),
67
+ "obfuscation": _e(
68
+ "Obfuscated code",
69
+ "exec/eval of base64, zlib, marshal or hex-decoded data (FAIL); long encoded blobs or high-entropy literals (WARN).",
70
+ "Decode the payload offline and read what it does before running anything.",
71
+ "Embedded icons, test vectors and hashes can look like blobs."),
72
+ "fs.outside": _e(
73
+ "Writes outside the project",
74
+ "A write, delete or move aimed at an absolute path, the home directory or a .. escape.",
75
+ "Write under the project or under a path the user passes in.",
76
+ "Tools whose job is to write to /tmp or a user config folder."),
77
+ "deps.unpinned": _e(
78
+ "Unpinned dependency",
79
+ "A dependency with no pin and no upper bound.",
80
+ "Pin it (==) or cap it (< next major).",
81
+ "Libraries often prefer open ranges on purpose; INFO, never scored."),
82
+ "supply.unknown_package": _e(
83
+ "Unknown or invented package",
84
+ "Offline: the name is not in the bundled list of known packages (INFO). Online: PyPI has no such project (FAIL), "
85
+ "or it is new or has a single release (WARN).",
86
+ "Check the name is the one you meant; remove names an assistant invented; run with --online.",
87
+ "Offline misses are common for less-known real packages, which is why they are INFO. Existing is not safe."),
88
+ "supply.lookalike": _e(
89
+ "Lookalike package name",
90
+ "The name is one or two edits away from a known package without being it: the typosquatting pattern.",
91
+ "Check you meant the well-known package.",
92
+ "Real packages with names close to a popular one."),
93
+ # Works
94
+ "works.syntax": _e(
95
+ "File does not parse",
96
+ "ast.parse rejects the file, so it cannot be imported or run (INFO when the project needs a newer Python than "
97
+ "the one scanning).",
98
+ "Fix the syntax error; port Python 2 code to Python 3.",
99
+ "Files kept on purpose for another interpreter, or templates with a .py name."),
100
+ "works.import_unresolved": _e(
101
+ "Import nothing provides",
102
+ "An absolute import that is not in the standard library, not in the repo and not a declared dependency.",
103
+ "Declare the distribution that provides it, or guard an optional import with try/except ImportError.",
104
+ "Packages pulled in only by another dependency (they work by accident); import names the mapping table misses."),
105
+ "works.relative_missing": _e(
106
+ "Relative import to nothing",
107
+ "from .x import y points at a module (or a package name) the repo does not have.",
108
+ "Fix the module path or add the missing module.",
109
+ "Modules generated at build time."),
110
+ "works.tests_missing": _e(
111
+ "No tests",
112
+ "There are no test files at all.",
113
+ "Add a tests/ folder with at least one test per entry point.",
114
+ "Tests kept in another repository."),
115
+ "works.test_no_assert": _e(
116
+ "Tests that check nothing",
117
+ "Test functions with no assert, no pytest.raises/warns, no self.assert* and no call to an asserting helper.",
118
+ "Assert on the result; use pytest.raises for expected errors.",
119
+ "Smoke tests that only check nothing raises; helpers defined in another file."),
120
+ "works.entry_missing": _e(
121
+ "Command points at nothing",
122
+ "A [project.scripts] (or setup.cfg console script) target module:function is not in the tree.",
123
+ "Point the script at an existing module:function.",
124
+ "Modules generated at build time."),
125
+ "orphan.dead": _e(
126
+ "Dead code",
127
+ "A function, class or module nothing reachable from the entry points uses.",
128
+ "Delete it, or list it in keep if something loads it by name.",
129
+ "Code loaded by name (plugins, entry points of other tools); add those to keep."),
130
+ "orphan.test_only": _e(
131
+ "Used only by tests",
132
+ "Only tests reach this code; the product never does.",
133
+ "Delete it with its tests, or call it from the product.",
134
+ "Test helpers kept in the package on purpose."),
135
+ "orphan.uncertain": _e(
136
+ "Maybe used dynamically",
137
+ "getattr/globals/importlib with a computed name, or a string registry, could reach it.",
138
+ "List it in keep if it is loaded by name; otherwise delete it.",
139
+ "It is reported as uncertain precisely because the parse cannot tell."),
140
+ # Complete
141
+ "complete.license_missing": _e(
142
+ "No licence file",
143
+ "There is no LICENSE, LICENCE, COPYING or UNLICENSE file at the root.",
144
+ "Add a LICENSE file.",
145
+ "Licences kept only in a subfolder."),
146
+ "complete.license_undeclared": _e(
147
+ "Licence not declared",
148
+ "Packaging metadata exists but declares no licence.",
149
+ "Add license = \"...\" (or license-files) to [project].",
150
+ "Licences declared only by a build plugin."),
151
+ "complete.readme_missing": _e(
152
+ "No README",
153
+ "There is no README at the root.",
154
+ "Add a README.md that says what it is, how to install it and how to use it.",
155
+ "README kept under docs/ only."),
156
+ "complete.readme_short": _e(
157
+ "README too short",
158
+ "The README is under 200 bytes.",
159
+ "Describe what it does, how to install it and how to use it.",
160
+ "A README that only links to full documentation."),
161
+ "complete.readme_sections": _e(
162
+ "README lacks install or usage",
163
+ "A README of 200+ bytes with no install heading or no usage heading.",
164
+ "Add an Install heading and a Usage heading.",
165
+ "Headings worded differently from the words the check knows."),
166
+ "complete.ci_missing": _e(
167
+ "No CI",
168
+ "No CI configuration was found.",
169
+ "Add a CI workflow that runs the tests on each change.",
170
+ "CI configured outside the repository."),
171
+ "complete.metadata": _e(
172
+ "Incomplete metadata",
173
+ "pyproject.toml [project] lacks name, version (or dynamic) or requires-python.",
174
+ "Add the missing keys to [project].",
175
+ "None known."),
176
+ "complete.lockfile": _e(
177
+ "Application without a lockfile",
178
+ "The repo is an application (a Dockerfile, a __main__.py without scripts, or a web app object) and has no "
179
+ "lockfile.",
180
+ "Commit a lockfile (uv lock, poetry lock or pip-compile).",
181
+ "Libraries are never asked for one; an example app outside examples/ can make a library look like an app."),
182
+ "complete.env_example": _e(
183
+ "No .env.example",
184
+ "The code reads environment variables but there is no .env.example listing them.",
185
+ "Add a .env.example with every variable and a safe placeholder.",
186
+ "Libraries that read only optional, well-known variables (NO_COLOR, TERM)."),
187
+ "deps.declared_unused": _e(
188
+ "Declared but never imported",
189
+ "A runtime dependency nothing in the repo imports.",
190
+ "Remove it, or note why it is needed (a plugin or a runner).",
191
+ "Plugins and runners loaded by other tools; INFO, never scored."),
192
+ "check.crashed": _e(
193
+ "A check crashed",
194
+ "One of code-guardian's own checks raised on this file; the rest of the scan ran.",
195
+ "Report it at github.com/LuminariSoftwares/code-guardian/issues with the file if you can share it.",
196
+ "It is a code-guardian bug, never a finding about your code."),
197
+ }
198
+
199
+
200
+ def main(argv=None):
201
+ argv = sys.argv[1:] if argv is None else argv
202
+ if "--selftest" in argv:
203
+ from . import score
204
+ codes = {c for table in score.TABLES.values() for c, _ in table}
205
+ checks = [
206
+ ("covers_every_scored_code", codes <= set(CATALOGUE)),
207
+ ("four_fields", all(set(e) == {"title", "means", "fix", "false_positives"} for e in CATALOGUE.values())),
208
+ ("short_titles", all(len(e["title"]) <= 40 for e in CATALOGUE.values())),
209
+ ]
210
+ return summarize("catalogue", checks)
211
+ sys.stderr.write("usage: python -m code_guardian.catalogue --selftest\n")
212
+ return 2
213
+
214
+
215
+ if __name__ == "__main__":
216
+ sys.exit(main())
code_guardian/cli.py ADDED
@@ -0,0 +1,146 @@
1
+ """code-guardian command line.
2
+
3
+ code-guardian scan PATH [--json | --sarif | --markdown] [--goal FILE] [--explain] [--online] [--fail-on LEVEL]
4
+ code-guardian orphans PATH [--json] [--goal FILE] [--fail-on LEVEL]
5
+ code-guardian init [PATH] [--force]
6
+ code-guardian mcp # the MCP server on stdio (same as code-guardian-mcp)
7
+
8
+ Exit codes: 0 nothing at or above --fail-on (default FAIL); 1 findings at or above it; 2 usage or parse error.
9
+ scan gates on every scored finding (Safe, Works, Complete); orphans gates on the hygiene findings.
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+
19
+ from . import __version__, goal, outputs, report
20
+ from .finding import summarize
21
+
22
+
23
+ def _parser():
24
+ p = argparse.ArgumentParser(prog="code-guardian", description="Is this codebase safe to run, and is all of it used?")
25
+ p.add_argument("--version", action="version", version="code-guardian %s" % __version__)
26
+ sub = p.add_subparsers(dest="command")
27
+ fail_on = {"default": "FAIL", "type": str.upper, "choices": ["INFO", "WARN", "FAIL"],
28
+ "help": "exit 1 when any finding is at or above this level (default FAIL)"}
29
+ s = sub.add_parser("scan", help="Safe, Works and Complete scores, findings, egress inventory and orphan hygiene")
30
+ s.add_argument("path")
31
+ fmt = s.add_mutually_exclusive_group()
32
+ fmt.add_argument("--json", action="store_true", help="print the report as JSON")
33
+ fmt.add_argument("--sarif", action="store_true", help="print SARIF 2.1.0 (for GitHub code scanning)")
34
+ fmt.add_argument("--markdown", action="store_true", help="print a Markdown report (for a PR comment or job summary)")
35
+ s.add_argument("--goal", metavar="FILE", help="goal contract (default PATH/%s if present)" % goal.FILENAME)
36
+ s.add_argument("--explain", action="store_true", help="add a plain-English explanation from a local model")
37
+ s.add_argument("--online", action="store_true",
38
+ help="ask PyPI whether unknown dependency names exist (network; off by default)")
39
+ s.add_argument("--fail-on", **fail_on)
40
+ o = sub.add_parser("orphans", help="code nothing reachable from the entry points uses")
41
+ o.add_argument("path")
42
+ o.add_argument("--json", action="store_true")
43
+ o.add_argument("--goal", metavar="FILE")
44
+ o.add_argument("--fail-on", **fail_on)
45
+ i = sub.add_parser("init", help="write a starter %s with the inferred entry points" % goal.FILENAME)
46
+ i.add_argument("path", nargs="?", default=".")
47
+ i.add_argument("--force", action="store_true", help="overwrite an existing goal file")
48
+ return p
49
+
50
+
51
+ def init(path, force=False, out=print):
52
+ """Write PATH/code-guardian.goal.toml with the inferred entry points. --force replaces an existing file and keeps
53
+ its goal sentence, keep and exclude lists."""
54
+ root = report._root(path)
55
+ target = os.path.join(root, goal.FILENAME)
56
+ old = None
57
+ if os.path.exists(target):
58
+ if not force:
59
+ raise report.UsageError("%s already exists (use --force to overwrite)" % goal.FILENAME)
60
+ old = goal.load(target)
61
+ entries = report.orphans(root, infer=True)["entry_points"]
62
+ sentence = (old or {}).get("goal") or "Describe in one sentence what this project is for."
63
+ with open(target, "w", encoding="utf-8") as fh:
64
+ fh.write(goal.render(sentence, entries, (old or {}).get("keep", ()), (old or {}).get("exclude", ())))
65
+ out("wrote %s with %d inferred entry point(s):" % (goal.FILENAME, len(entries)))
66
+ for e in entries:
67
+ out(" " + e)
68
+ if not entries:
69
+ out("no entry points found: add them by hand as module:function")
70
+ return 0
71
+
72
+
73
+ def main(argv=None):
74
+ argv = sys.argv[1:] if argv is None else argv
75
+ if argv[:1] == ["mcp"]: # `code-guardian mcp` starts the same server as `code-guardian-mcp`
76
+ from . import mcp_server
77
+ return mcp_server.main(argv[1:])
78
+ if argv[:1] == ["--selftest"]:
79
+ return selftest()
80
+ args = _parser().parse_args(argv)
81
+ try:
82
+ if args.command == "scan":
83
+ rep = report.scan(args.path, args.goal, explain=args.explain, online=args.online)
84
+ if args.json:
85
+ sys.stdout.write(json.dumps(rep, indent=1) + "\n")
86
+ elif args.sarif:
87
+ sys.stdout.write(json.dumps(outputs.to_sarif(rep), indent=1) + "\n")
88
+ elif args.markdown:
89
+ sys.stdout.write(outputs.to_markdown(rep))
90
+ else:
91
+ sys.stdout.write(report.render(rep))
92
+ return report.exit_code(rep["findings"], args.fail_on)
93
+ if args.command == "orphans":
94
+ hyg = report.orphans(args.path, args.goal)
95
+ sys.stdout.write(json.dumps(hyg, indent=1) + "\n" if args.json else report.render_orphans(hyg))
96
+ return report.exit_code(hyg["findings"], args.fail_on)
97
+ if args.command == "init":
98
+ return init(args.path, args.force)
99
+ except (report.UsageError, goal.GoalError) as exc:
100
+ sys.stderr.write("code-guardian: %s\n" % exc)
101
+ return 2
102
+ _parser().print_help(sys.stderr)
103
+ return 2
104
+
105
+
106
+ def selftest():
107
+ import contextlib
108
+ import io
109
+
110
+ tmp = tempfile.mkdtemp()
111
+ try:
112
+ os.makedirs(os.path.join(tmp, "app"))
113
+ with open(os.path.join(tmp, "app", "run.py"), "w", encoding="utf-8") as fh:
114
+ fh.write("import requests\ndef main():\n requests.get('https://example.com', verify=False)\n"
115
+ "if __name__ == '__main__':\n main()\n")
116
+
117
+ def run(*argv):
118
+ out, err = io.StringIO(), io.StringIO()
119
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
120
+ try:
121
+ code = main(list(argv))
122
+ except SystemExit as exc:
123
+ code = exc.code
124
+ return code, out.getvalue()
125
+
126
+ scan_code, scan_json = run("scan", tmp, "--json")
127
+ init_code, _ = run("init", tmp)
128
+ again, _ = run("init", tmp)
129
+ forced, _ = run("init", tmp, "--force")
130
+ checks = [
131
+ ("scan_fail_exit_1", scan_code == 1 and json.loads(scan_json)["scores"]["safe"] == 75),
132
+ ("fail_on_case_insensitive", run("scan", tmp, "--fail-on", "fail")[0] == 1),
133
+ ("orphans_exit_0", run("orphans", tmp)[0] == 0),
134
+ ("usage_exit_2", run("scan")[0] == 2 and run("scan", os.path.join(tmp, "nope"))[0] == 2),
135
+ ("bad_level_exit_2", run("scan", tmp, "--fail-on", "LOUD")[0] == 2),
136
+ ("init", init_code == 0 and goal.load(os.path.join(tmp, goal.FILENAME))["entry_points"] == ["app.run:__main__"]),
137
+ ("init_no_overwrite", again == 2 and forced == 0),
138
+ ("bad_goal_exit_2", run("scan", tmp, "--goal", os.path.join(tmp, "missing.toml"))[0] == 2),
139
+ ]
140
+ finally:
141
+ shutil.rmtree(tmp, ignore_errors=True)
142
+ return summarize("cli", checks)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())