coding-agent-audit 0.10.0__tar.gz → 0.11.0__tar.gz

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.
Files changed (24) hide show
  1. {coding_agent_audit-0.10.0/src/coding_agent_audit.egg-info → coding_agent_audit-0.11.0}/PKG-INFO +11 -1
  2. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/README.md +10 -0
  3. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/pyproject.toml +1 -1
  4. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/agentaudit/__init__.py +1 -1
  5. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/agentaudit/cli.py +19 -10
  6. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/agentaudit/configscan.py +95 -0
  7. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/agentaudit/mcp_server.py +25 -1
  8. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0/src/coding_agent_audit.egg-info}/PKG-INFO +11 -1
  9. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/tests/test_configscan.py +93 -0
  10. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/LICENSE +0 -0
  11. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/setup.cfg +0 -0
  12. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/agentaudit/checks.py +0 -0
  13. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/agentaudit/findings.py +0 -0
  14. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/agentaudit/iocs.py +0 -0
  15. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/agentaudit/phantomdeps.py +0 -0
  16. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/SOURCES.txt +0 -0
  17. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/dependency_links.txt +0 -0
  18. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/entry_points.txt +0 -0
  19. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/requires.txt +0 -0
  20. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/top_level.txt +0 -0
  21. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/tests/test_checks.py +0 -0
  22. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/tests/test_cli.py +0 -0
  23. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/tests/test_mcp_server.py +0 -0
  24. {coding_agent_audit-0.10.0 → coding_agent_audit-0.11.0}/tests/test_phantomdeps.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-agent-audit
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Scan your machine for known AI-coding-assistant supply-chain backdoor indicators (Claude Code / Cursor / Windsurf extensions, MCP servers) plus risky hook/permission config patterns. Usable as a CLI or as an MCP server.
5
5
  Author-email: StackScribe AI <survivalagent320@gmail.com>
6
6
  License: MIT
@@ -183,6 +183,16 @@ next `npm install` / `pip install` ("slopsquatting"). Network errors are
183
183
  treated as inconclusive, not as a finding, so a flaky connection can't
184
184
  produce a false positive.
185
185
 
186
+ **MCP phantom-package scan** (`scan_mcp_phantom_packages`, also opt-in via
187
+ `--check-deps`) — the same registry-lookup idea, applied to the MCP server
188
+ commands themselves: any `npx`/`uvx`/`pipx run` invocation in a scanned
189
+ config is checked against npm/PyPI. This is the "MCPhantom" pattern flagged
190
+ by Glow Labs in November 2026: a coding agent fills in a plausible but
191
+ nonexistent MCP server package name from training data (or copies one from
192
+ a long-archived tutorial whose package was since renamed/unpublished), and
193
+ nothing stops an attacker from registering that exact name and having it
194
+ run as the MCP server next time the config loads.
195
+
186
196
  New incidents can be added to `iocs.py` as data, without touching the
187
197
  check logic.
188
198
 
@@ -162,6 +162,16 @@ next `npm install` / `pip install` ("slopsquatting"). Network errors are
162
162
  treated as inconclusive, not as a finding, so a flaky connection can't
163
163
  produce a false positive.
164
164
 
165
+ **MCP phantom-package scan** (`scan_mcp_phantom_packages`, also opt-in via
166
+ `--check-deps`) — the same registry-lookup idea, applied to the MCP server
167
+ commands themselves: any `npx`/`uvx`/`pipx run` invocation in a scanned
168
+ config is checked against npm/PyPI. This is the "MCPhantom" pattern flagged
169
+ by Glow Labs in November 2026: a coding agent fills in a plausible but
170
+ nonexistent MCP server package name from training data (or copies one from
171
+ a long-archived tutorial whose package was since renamed/unpublished), and
172
+ nothing stops an attacker from registering that exact name and having it
173
+ run as the MCP server next time the config loads.
174
+
165
175
  New incidents can be added to `iocs.py` as data, without touching the
166
176
  check logic.
167
177
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "coding-agent-audit"
7
- version = "0.10.0"
7
+ version = "0.11.0"
8
8
  description = "Scan your machine for known AI-coding-assistant supply-chain backdoor indicators (Claude Code / Cursor / Windsurf extensions, MCP servers) plus risky hook/permission config patterns. Usable as a CLI or as an MCP server."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -3,4 +3,4 @@ for AI coding assistants (Claude Code, Cursor, Windsurf, VS Code forks, MCP
3
3
  servers).
4
4
  """
5
5
 
6
- __version__ = "0.10.0"
6
+ __version__ = "0.11.0"
@@ -7,7 +7,12 @@ from pathlib import Path
7
7
 
8
8
  from . import __version__
9
9
  from .checks import run_all_ioc_checks
10
- from .configscan import default_config_paths, scan_paths, scan_tool_descriptions
10
+ from .configscan import (
11
+ default_config_paths,
12
+ scan_paths,
13
+ scan_paths_for_phantom_mcp_packages,
14
+ scan_tool_descriptions,
15
+ )
11
16
  from .findings import Finding, worst_severity
12
17
  from .phantomdeps import check_phantom_dependencies
13
18
 
@@ -65,10 +70,12 @@ def build_parser() -> argparse.ArgumentParser:
65
70
  "--check-deps",
66
71
  action="store_true",
67
72
  help=(
68
- "with --project, also check package.json/requirements.txt/"
69
- "pyproject.toml dependencies against npm/PyPI and flag any "
70
- "that don't exist (possible AI-hallucinated/slopsquattable "
71
- "package name). Makes read-only network requests."
73
+ "check package names against npm/PyPI and flag any that don't "
74
+ "exist (possible AI-hallucinated/slopsquattable or 'MCPhantom' "
75
+ "package name): with --project, also covers package.json/"
76
+ "requirements.txt/pyproject.toml; always covers npx/uvx/pipx "
77
+ "MCP server commands in the scanned config files. Makes "
78
+ "read-only network requests."
72
79
  ),
73
80
  )
74
81
  parser.add_argument(
@@ -95,17 +102,19 @@ def main(argv: list[str] | None = None) -> int:
95
102
  parser = build_parser()
96
103
  args = parser.parse_args(argv)
97
104
 
105
+ config_paths = default_config_paths(Path.home(), args.project)
106
+
98
107
  findings: list[Finding] = []
99
108
  if not args.skip_ioc:
100
109
  project_dirs = [args.project] if args.project is not None else []
101
110
  findings += run_all_ioc_checks(project_dirs=project_dirs)
102
111
  if not args.skip_config:
103
- home = Path.home()
104
- paths = default_config_paths(home, args.project)
105
- findings += scan_paths(paths)
112
+ findings += scan_paths(config_paths)
106
113
 
107
- if args.check_deps and args.project is not None:
108
- findings += check_phantom_dependencies(args.project)
114
+ if args.check_deps:
115
+ if args.project is not None:
116
+ findings += check_phantom_dependencies(args.project)
117
+ findings += scan_paths_for_phantom_mcp_packages(config_paths)
109
118
 
110
119
  if args.tools_json is not None:
111
120
  try:
@@ -14,6 +14,7 @@ import re
14
14
  from pathlib import Path
15
15
  from typing import Iterable
16
16
 
17
+ from . import phantomdeps
17
18
  from .findings import Finding
18
19
 
19
20
  # Commands/strings that show up in real exfiltration or persistence attempts.
@@ -287,6 +288,83 @@ def scan_mcp_servers(config: dict, source_path: str) -> list[Finding]:
287
288
  return findings
288
289
 
289
290
 
291
+ _MCP_RUNNER_RE = re.compile(r"\b(npx|uvx|pipx\s+run)\b", re.IGNORECASE)
292
+
293
+
294
+ def _extract_mcp_package_spec(full_cmdline: str) -> tuple[str, str] | None:
295
+ """Return ``(ecosystem, package_name)`` for the package a `npx`/`uvx`/
296
+ `pipx run` MCP server command would fetch, or ``None`` if no such
297
+ runner (or no package argument after it) is found."""
298
+ match = _MCP_RUNNER_RE.search(full_cmdline)
299
+ if not match:
300
+ return None
301
+ ecosystem = "npm" if match.group(1).lower() == "npx" else "pypi"
302
+ for token in full_cmdline[match.end():].split():
303
+ if token.startswith("-"):
304
+ continue
305
+ if token.startswith("@"):
306
+ # scoped npm package, optionally pinned: @scope/name[@version]
307
+ scoped, _, _version = token[1:].partition("@")
308
+ return ecosystem, f"@{scoped}"
309
+ name, _, _version = token.partition("@")
310
+ name = name.split("==", 1)[0]
311
+ return ecosystem, name
312
+ return None
313
+
314
+
315
+ def scan_mcp_phantom_packages(config: dict, source_path: str) -> list[Finding]:
316
+ """Flag MCP servers launched via `npx`/`uvx`/`pipx run` that point at a
317
+ package name which doesn't exist on the registry - the "MCPhantom"
318
+ pattern (Glow Labs research, Nov 2026): coding agents fill in a
319
+ plausible-sounding MCP server name from training data, or copy one
320
+ from a long-archived tutorial/repo whose package was since renamed or
321
+ unpublished. Either way, nothing stops an attacker from registering
322
+ that exact name and having it run with the user's own permissions on
323
+ the very next launch. Makes read-only registry lookups; network
324
+ errors are treated as "unknown", never as "phantom" (see phantomdeps).
325
+ """
326
+ servers = config.get("mcpServers") or config.get("mcp_servers")
327
+ if not isinstance(servers, dict):
328
+ return []
329
+ findings: list[Finding] = []
330
+ for server_name, server_cfg in servers.items():
331
+ if not isinstance(server_cfg, dict):
332
+ continue
333
+ command = server_cfg.get("command", "")
334
+ args = server_cfg.get("args") or []
335
+ full_cmdline = " ".join([command, *[str(a) for a in args]])
336
+ spec = _extract_mcp_package_spec(full_cmdline)
337
+ if spec is None:
338
+ continue
339
+ ecosystem, name = spec
340
+ if ecosystem == "npm":
341
+ if not phantomdeps._is_plausible_npm_name(name):
342
+ continue
343
+ exists = phantomdeps.check_npm_package_exists(name)
344
+ else:
345
+ exists = phantomdeps.check_pypi_package_exists(name)
346
+ if exists is not False:
347
+ continue
348
+ findings.append(
349
+ Finding(
350
+ check_id="mcp-phantom-package",
351
+ severity="critical",
352
+ title=f"MCP server '{server_name}' launches a package that doesn't exist ('{name}')",
353
+ detail=(
354
+ f"Command '{full_cmdline}' fetches '{name}' from "
355
+ f"{'npm' if ecosystem == 'npm' else 'PyPI'} on every launch, but no "
356
+ f"package by that name currently exists there (the "
357
+ f"'MCPhantom' pattern: a hallucinated or long-deprecated "
358
+ f"MCP server name). Anyone can register it and have "
359
+ f"their code run as this MCP server, with no update or "
360
+ f"confirmation step in between."
361
+ ),
362
+ path=source_path,
363
+ )
364
+ )
365
+ return findings
366
+
367
+
290
368
  def scan_permissions(config: dict, source_path: str) -> list[Finding]:
291
369
  permissions = config.get("permissions")
292
370
  if not isinstance(permissions, dict):
@@ -429,3 +507,20 @@ def scan_paths(paths: Iterable[Path]) -> list[Finding]:
429
507
  if isinstance(config, dict):
430
508
  findings += scan_config_dict(config, str(path))
431
509
  return findings
510
+
511
+
512
+ def scan_paths_for_phantom_mcp_packages(paths: Iterable[Path]) -> list[Finding]:
513
+ """Like `scan_paths`, but only runs `scan_mcp_phantom_packages` (the
514
+ network-dependent check), for callers that opt in with `--check-deps`.
515
+ """
516
+ findings: list[Finding] = []
517
+ for path in paths:
518
+ if not path.is_file():
519
+ continue
520
+ try:
521
+ config = json.loads(path.read_text())
522
+ except (OSError, json.JSONDecodeError):
523
+ continue
524
+ if isinstance(config, dict):
525
+ findings += scan_mcp_phantom_packages(config, str(path))
526
+ return findings
@@ -18,7 +18,7 @@ from mcp.types import ToolAnnotations
18
18
 
19
19
  from . import __version__
20
20
  from .checks import run_all_ioc_checks
21
- from .configscan import default_config_paths, scan_paths
21
+ from .configscan import default_config_paths, scan_paths, scan_paths_for_phantom_mcp_packages
22
22
  from .findings import Finding, worst_severity
23
23
  from .phantomdeps import check_phantom_dependencies
24
24
 
@@ -113,6 +113,30 @@ def scan_phantom_dependencies(project_dir: str) -> str:
113
113
  return _render(check_phantom_dependencies(Path(project_dir)))
114
114
 
115
115
 
116
+ @server.tool(annotations=_NETWORK_READ_ONLY)
117
+ def scan_mcp_phantom_packages(project_dir: Optional[str] = None) -> str:
118
+ """Check the `npx`/`uvx`/`pipx run` commands in this machine's (and
119
+ optionally a project's) MCP server config against the npm/PyPI
120
+ registries, and flag any package name that doesn't actually exist
121
+ there - the "MCPhantom" pattern (Glow Labs, Nov 2026): a coding agent
122
+ fills in a plausible-sounding MCP server name from training data, or
123
+ copies one from a long-archived tutorial whose package was since
124
+ renamed or unpublished. An attacker can register that exact name and
125
+ have it run as the MCP server, with the user's own permissions, the
126
+ next time the config is loaded. Read-only HTTP lookups only; this
127
+ never connects to or executes the MCP server itself.
128
+
129
+ Args:
130
+ project_dir: optional path to a project whose .claude/ and
131
+ .mcp.json should also be scanned, in addition to the user's
132
+ home-directory config.
133
+ """
134
+ home = Path.home()
135
+ project = Path(project_dir) if project_dir else None
136
+ paths = default_config_paths(home, project)
137
+ return _render(scan_paths_for_phantom_mcp_packages(paths))
138
+
139
+
116
140
  @server.tool(annotations=_LOCAL_READ_ONLY)
117
141
  def scan_tool_descriptions(tools: list[dict]) -> str:
118
142
  """Scan a list of MCP tool definitions (the 'tools/list' response an
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-agent-audit
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Scan your machine for known AI-coding-assistant supply-chain backdoor indicators (Claude Code / Cursor / Windsurf extensions, MCP servers) plus risky hook/permission config patterns. Usable as a CLI or as an MCP server.
5
5
  Author-email: StackScribe AI <survivalagent320@gmail.com>
6
6
  License: MIT
@@ -183,6 +183,16 @@ next `npm install` / `pip install` ("slopsquatting"). Network errors are
183
183
  treated as inconclusive, not as a finding, so a flaky connection can't
184
184
  produce a false positive.
185
185
 
186
+ **MCP phantom-package scan** (`scan_mcp_phantom_packages`, also opt-in via
187
+ `--check-deps`) — the same registry-lookup idea, applied to the MCP server
188
+ commands themselves: any `npx`/`uvx`/`pipx run` invocation in a scanned
189
+ config is checked against npm/PyPI. This is the "MCPhantom" pattern flagged
190
+ by Glow Labs in November 2026: a coding agent fills in a plausible but
191
+ nonexistent MCP server package name from training data (or copies one from
192
+ a long-archived tutorial whose package was since renamed/unpublished), and
193
+ nothing stops an attacker from registering that exact name and having it
194
+ run as the MCP server next time the config loads.
195
+
186
196
  New incidents can be added to `iocs.py` as data, without touching the
187
197
  check logic.
188
198
 
@@ -1,4 +1,5 @@
1
1
  from pathlib import Path
2
+ from unittest.mock import patch
2
3
 
3
4
  from agentaudit.configscan import (
4
5
  default_config_paths,
@@ -6,8 +7,10 @@ from agentaudit.configscan import (
6
7
  scan_hooks,
7
8
  scan_known_cve_packages,
8
9
  scan_lethal_trifecta,
10
+ scan_mcp_phantom_packages,
9
11
  scan_mcp_servers,
10
12
  scan_paths,
13
+ scan_paths_for_phantom_mcp_packages,
11
14
  scan_permissions,
12
15
  scan_tool_descriptions,
13
16
  )
@@ -155,6 +158,96 @@ def test_scan_known_cve_packages_not_a_dict():
155
158
  assert scan_known_cve_packages({"mcpServers": "nope"}, "src") == []
156
159
 
157
160
 
161
+ def test_scan_mcp_phantom_packages_flags_nonexistent_npm():
162
+ config = {
163
+ "mcpServers": {
164
+ "ghost": {"command": "npx", "args": ["-y", "mcp-server-totally-made-up-9x7z"]}
165
+ }
166
+ }
167
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=False):
168
+ findings = scan_mcp_phantom_packages(config, "src")
169
+ assert len(findings) == 1
170
+ assert findings[0].check_id == "mcp-phantom-package"
171
+ assert findings[0].severity == "critical"
172
+ assert "mcp-server-totally-made-up-9x7z" in findings[0].title
173
+
174
+
175
+ def test_scan_mcp_phantom_packages_flags_nonexistent_pypi():
176
+ config = {
177
+ "mcpServers": {"ghost": {"command": "uvx", "args": ["mcp-server-ghost-xyz"]}}
178
+ }
179
+ with patch("agentaudit.phantomdeps.check_pypi_package_exists", return_value=False):
180
+ findings = scan_mcp_phantom_packages(config, "src")
181
+ assert len(findings) == 1
182
+ assert "mcp-server-ghost-xyz" in findings[0].title
183
+
184
+
185
+ def test_scan_mcp_phantom_packages_real_package_not_flagged():
186
+ config = {
187
+ "mcpServers": {
188
+ "fs": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}
189
+ }
190
+ }
191
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=True):
192
+ findings = scan_mcp_phantom_packages(config, "src")
193
+ assert findings == []
194
+
195
+
196
+ def test_scan_mcp_phantom_packages_network_unknown_not_flagged():
197
+ config = {"mcpServers": {"ghost": {"command": "npx", "args": ["-y", "some-pkg"]}}}
198
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=None):
199
+ findings = scan_mcp_phantom_packages(config, "src")
200
+ assert findings == []
201
+
202
+
203
+ def test_scan_mcp_phantom_packages_versioned_pin_strips_version():
204
+ config = {
205
+ "mcpServers": {
206
+ "ghost": {"command": "npx", "args": ["-y", "mcp-server-ghost-xyz@1.2.3"]}
207
+ }
208
+ }
209
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=False) as mock_check:
210
+ findings = scan_mcp_phantom_packages(config, "src")
211
+ mock_check.assert_called_once_with("mcp-server-ghost-xyz")
212
+ assert len(findings) == 1
213
+
214
+
215
+ def test_scan_mcp_phantom_packages_scoped_versioned_pin():
216
+ config = {
217
+ "mcpServers": {
218
+ "ghost": {"command": "npx", "args": ["-y", "@scope/ghost-pkg@2.0.0"]}
219
+ }
220
+ }
221
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=False) as mock_check:
222
+ findings = scan_mcp_phantom_packages(config, "src")
223
+ mock_check.assert_called_once_with("@scope/ghost-pkg")
224
+ assert len(findings) == 1
225
+
226
+
227
+ def test_scan_mcp_phantom_packages_no_runner_not_flagged():
228
+ config = {"mcpServers": {"local": {"command": "/usr/local/bin/my-server"}}}
229
+ assert scan_mcp_phantom_packages(config, "src") == []
230
+
231
+
232
+ def test_scan_mcp_phantom_packages_not_a_dict():
233
+ assert scan_mcp_phantom_packages({"mcpServers": "nope"}, "src") == []
234
+
235
+
236
+ def test_scan_paths_for_phantom_mcp_packages(tmp_path: Path):
237
+ config_path = tmp_path / ".mcp.json"
238
+ config_path.write_text(
239
+ '{"mcpServers": {"ghost": {"command": "npx", "args": ["-y", "mcp-ghost-xyz"]}}}'
240
+ )
241
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=False):
242
+ findings = scan_paths_for_phantom_mcp_packages([config_path])
243
+ assert len(findings) == 1
244
+ assert findings[0].check_id == "mcp-phantom-package"
245
+
246
+
247
+ def test_scan_paths_for_phantom_mcp_packages_missing_file(tmp_path: Path):
248
+ assert scan_paths_for_phantom_mcp_packages([tmp_path / "missing.json"]) == []
249
+
250
+
158
251
  def test_scan_permissions_scoped_rule_ok():
159
252
  config = {"permissions": {"allow": ["Bash(git diff:*)", "Read(//workspace/**)"]}}
160
253
  assert scan_permissions(config, "src") == []