mcp-capdiff 0.1.2__tar.gz → 0.1.3__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 (109) hide show
  1. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/CHANGELOG.md +17 -0
  2. {mcp_capdiff-0.1.2/src/mcp_capdiff.egg-info → mcp_capdiff-0.1.3}/PKG-INFO +1 -1
  3. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/precision-matrix.md +3 -3
  4. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/releasing.md +1 -1
  5. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/versioning.md +1 -1
  6. mcp_capdiff-0.1.3/fixtures/benchmark_semantics/server.py +88 -0
  7. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/__init__.py +1 -1
  8. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/scanners/source/python.py +90 -13
  9. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3/src/mcp_capdiff.egg-info}/PKG-INFO +1 -1
  10. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/SOURCES.txt +1 -0
  11. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/scan.json +1 -1
  12. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/scan.sarif.json +1 -1
  13. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_real_world_regressions.py +40 -0
  14. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_reporters.py +2 -2
  15. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/LICENSE +0 -0
  16. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/MANIFEST.in +0 -0
  17. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/README.md +0 -0
  18. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/SECURITY.md +0 -0
  19. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/action.yml +0 -0
  20. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/policy.md +0 -0
  21. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/report-schema.md +0 -0
  22. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP001.md +0 -0
  23. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP002.md +0 -0
  24. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP003.md +0 -0
  25. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP004.md +0 -0
  26. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP005.md +0 -0
  27. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP007.md +0 -0
  28. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP010.md +0 -0
  29. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP016.md +0 -0
  30. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP017.md +0 -0
  31. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/README.md +0 -0
  32. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/v0.1-scope.md +0 -0
  33. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/.github/workflows/mcp-audit.yml +0 -0
  34. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/README.md +0 -0
  35. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/mcp-audit.yaml +0 -0
  36. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/pyproject.toml +0 -0
  37. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/approval_removed/after.py +0 -0
  38. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/approval_removed/before.py +0 -0
  39. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/filesystem_expansion/after.py +0 -0
  40. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/filesystem_expansion/before.py +0 -0
  41. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/network_allowlist_added/after.py +0 -0
  42. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/network_allowlist_added/before.py +0 -0
  43. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/after.py +0 -0
  44. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/before.py +0 -0
  45. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/destructive_with_approval/server.py +0 -0
  46. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/destructive_without_approval/server.py +0 -0
  47. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/fastmcp_async_annotations/server.py +0 -0
  48. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/filesystem_guard_after_read/server.py +0 -0
  49. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/filesystem_helper_restricted/server.py +0 -0
  50. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/filesystem_restricted/server.py +0 -0
  51. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/filesystem_unrestricted/server.py +0 -0
  52. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/local_filesystem_operations/server.py +0 -0
  53. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/postgresql_read_only/server.py +0 -0
  54. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/fixture_tools/__init__.py +0 -0
  55. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/fixture_tools/filesystem.py +0 -0
  56. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/fixture_tools/state.py +0 -0
  57. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/server.py +0 -0
  58. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/safe_server/server.py +0 -0
  59. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/sensitive_read_only/server.py +0 -0
  60. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/sensitive_to_network/server.py +0 -0
  61. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/shell_direct/server.py +0 -0
  62. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/shell_dynamic_no_shell/server.py +0 -0
  63. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/shell_safe_allowlist/server.py +0 -0
  64. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/sql_destructive/server.py +0 -0
  65. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/sqlite_read_only/server.py +0 -0
  66. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/url_allowlisted/server.py +0 -0
  67. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/url_arbitrary/server.py +0 -0
  68. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/url_guard_after_request/server.py +0 -0
  69. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/url_hostname_unguarded/server.py +0 -0
  70. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/vulnerable_server/server.py +0 -0
  71. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/mcp-audit.yaml +0 -0
  72. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/pyproject.toml +0 -0
  73. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/setup.cfg +0 -0
  74. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/__main__.py +0 -0
  75. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/analysis/__init__.py +0 -0
  76. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/analysis/diff.py +0 -0
  77. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/analysis/scan.py +0 -0
  78. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/cli/__init__.py +0 -0
  79. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/cli/main.py +0 -0
  80. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/models/__init__.py +0 -0
  81. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/models/capability.py +0 -0
  82. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/models/finding.py +0 -0
  83. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/models/report.py +0 -0
  84. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/policy/__init__.py +0 -0
  85. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/policy/evaluator.py +0 -0
  86. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/policy/loader.py +0 -0
  87. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/__init__.py +0 -0
  88. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/json.py +0 -0
  89. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/manifest.py +0 -0
  90. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/sarif.py +0 -0
  91. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/terminal.py +0 -0
  92. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/rules/__init__.py +0 -0
  93. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/rules/engine.py +0 -0
  94. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/scanners/__init__.py +0 -0
  95. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/scanners/source/__init__.py +0 -0
  96. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/dependency_links.txt +0 -0
  97. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/entry_points.txt +0 -0
  98. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/requires.txt +0 -0
  99. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/top_level.txt +0 -0
  100. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/diff.txt +0 -0
  101. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/manifest.yaml +0 -0
  102. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/terminal.txt +0 -0
  103. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_demo_scenarios.py +0 -0
  104. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_diff.py +0 -0
  105. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_discovery.py +0 -0
  106. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_golden.py +0 -0
  107. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_policy.py +0 -0
  108. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_rule_accuracy.py +0 -0
  109. {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_scan.py +0 -0
@@ -4,6 +4,23 @@ All notable changes follow Keep a Changelog. Releases use semantic versioning fo
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.3] - 2026-10-01
8
+
9
+ ### Fixed
10
+
11
+ - Stop ordinary string, datetime, and other object `.replace()` calls from being classified as filesystem writes; retain detection for `os.replace(...)` and statically known `Path.replace(...)` calls.
12
+ - Detect model-controlled interactive process execution through `pexpect.spawn(...)` and `pexpect.spawnu(...)`.
13
+ - Require action verbs for semantic external-write inference so nouns such as `email` and `message` do not turn list/read tools into writes.
14
+ - Prefer read-only tool names and explicit negation over incidental destructive words in descriptions while retaining destructive SQL detection for execution tools.
15
+
16
+ ### Verified
17
+
18
+ - 71 automated tests, including positive controls for shell execution, filesystem replacement, destructive actions, and external writes.
19
+ - `metabase-mcp` and `mcp_agent_mail`: string and datetime replacement no longer produce MCP002 findings.
20
+ - `interactive-terminal-mcp`: `spawn_process` is classified as shell execution and produces MCP001 evidence at `pexpect.spawn(...)`.
21
+ - `MCP-PostgreSQL-Ops` and `winremote-mcp`: the read-only statistics tool and GUI `Type` tool no longer produce MCP004/MCP010 findings.
22
+ - The 14-repository benchmark covered 888 discovered tools; interprocedural network propagation remains deferred for a later release.
23
+
7
24
  ## [0.1.2] - 2026-10-01
8
25
 
9
26
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-capdiff
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Security regression scanner for Model Context Protocol servers.
5
5
  Author: MCP Audit contributors
6
6
  License-Expression: MIT
@@ -4,8 +4,8 @@ The release gate requires positive, negative, guarded, and edge coverage for the
4
4
 
5
5
  | Rule | True positive | True negative | Guarded safe | Tricky edge |
6
6
  | --- | --- | --- | --- | --- |
7
- | MCP001 | `shell_direct` | `safe_server` | `shell_safe_allowlist` | `shell_dynamic_no_shell` detects a dynamic executable without `shell=True` |
8
- | MCP002 | `filesystem_unrestricted` | `sensitive_read_only` | `filesystem_restricted` | `filesystem_guard_after_read` rejects a late guard |
7
+ | MCP001 | `shell_direct` | `safe_server` | `shell_safe_allowlist` | `shell_dynamic_no_shell` and `benchmark_semantics` detect dynamic subprocess and pexpect execution |
8
+ | MCP002 | `filesystem_unrestricted` | `sensitive_read_only` | `filesystem_restricted` | `filesystem_guard_after_read` rejects a late guard while generic `.replace()` calls remain non-filesystem operations |
9
9
  | MCP003 | `url_arbitrary` | fixed URL in demo baseline | `url_allowlisted` | hostname mention and guard-after-request remain findings |
10
10
  | MCP005 | `sensitive_to_network` | `sensitive_read_only` | constrained source-only server | root corpus proves independent MCP contexts are isolated |
11
11
  | MCP016 | network expansion diff | unchanged diff | allowlist-added demo resolves risk | a new high-impact tool is an escalation |
@@ -17,4 +17,4 @@ MCP004, MCP007, and MCP010 also have paired approved/bounded and unapproved/unbo
17
17
 
18
18
  Discovery is separately benchmarked by `fastmcp_async_annotations`, which must find both plain async `@mcp.tool()` and `@mcp.tool(annotations=ToolAnnotations(...))` decorators. Missing targets, unreadable trees, parse failures, and unexpected zero-tool scans fail closed.
19
19
 
20
- Real-world regression fixtures also cover imported `mcp.tool()(function)` registration, helper-based path containment, SQLite reads, local file modes, and local directory creation. MCP005 requires an actual outbound network destination; local writes and semantic-only action names cannot become exfiltration sinks.
20
+ Real-world regression fixtures also cover imported `mcp.tool()(function)` registration, helper-based path containment, SQLite reads, local file modes, local directory creation, `pexpect.spawn`, typed `Path.replace`, and ordinary string/datetime `.replace()` calls. MCP005 requires an actual outbound network destination; local writes and semantic-only action names cannot become exfiltration sinks. Read/list/status tools and incidental destructive words in documentation are negative cases for side-effect inference.
@@ -19,7 +19,7 @@ Install the wheel into a clean environment and verify `mcp-audit --version`, a p
19
19
 
20
20
  ## Publish
21
21
 
22
- 1. Create and push the matching Git tag, such as `v0.1.2`.
22
+ 1. Create and push the matching Git tag, such as `v0.1.3`.
23
23
  2. Publish the matching GitHub Release. This automatically runs `release.yml`.
24
24
  3. Verify `pipx install mcp-capdiff` in a clean environment.
25
25
  4. Run the tagged Action from the demo repository and confirm SARIF appears in Code Scanning.
@@ -4,7 +4,7 @@ MCP Audit maintains four independent versions:
4
4
 
5
5
  | Contract | Current | Change policy |
6
6
  | --- | --- | --- |
7
- | CLI/package | `0.1.2` | Semantic versioning; pre-1.0 minor releases may change CLI behavior |
7
+ | CLI/package | `0.1.3` | Semantic versioning; pre-1.0 minor releases may change CLI behavior |
8
8
  | Ruleset | `0.1` | Minor adds or materially changes rules; patch corrects implementation without intended semantic change |
9
9
  | JSON report schema | `1.0` | Minor changes are additive; major changes may remove fields or change meaning |
10
10
  | Manifest schema | `1.0` | Minor changes are additive; major changes may remove fields or change meaning |
@@ -0,0 +1,88 @@
1
+ from datetime import datetime, timezone
2
+ from pathlib import Path
3
+
4
+ import os
5
+ import pexpect
6
+ from fastmcp import FastMCP
7
+
8
+
9
+ mcp = FastMCP("benchmark-semantics")
10
+
11
+
12
+ @mcp.tool()
13
+ def list_tables(description: str) -> str:
14
+ """List database tables without modifying them."""
15
+ return description.replace("|", "\\|")
16
+
17
+
18
+ @mcp.tool()
19
+ def get_message_time() -> str:
20
+ """Read the current message timestamp."""
21
+ return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
22
+
23
+
24
+ @mcp.tool()
25
+ def spawn_process(command: str) -> str:
26
+ """Spawn an interactive command."""
27
+ return str(pexpect.spawn(command))
28
+
29
+
30
+ @mcp.tool()
31
+ def spawn_unicode_process(command: str) -> str:
32
+ """Spawn an interactive Unicode command."""
33
+ return str(pexpect.spawnu(command))
34
+
35
+
36
+ @mcp.tool()
37
+ def get_vacuum_analyze_stats() -> str:
38
+ """Retrieve read-only delete and revoke statistics; does not execute destructive operations."""
39
+ return "stats"
40
+
41
+
42
+ @mcp.tool(name="Type")
43
+ def type_text(text: str) -> str:
44
+ """Type text into the GUI. Clear existing content first with Ctrl+A, Delete."""
45
+ return text
46
+
47
+
48
+ @mcp.tool()
49
+ def list_email_tags() -> list[str]:
50
+ """List email tags without changing them."""
51
+ return []
52
+
53
+
54
+ @mcp.tool()
55
+ def delete_comment(comment_id: str) -> str:
56
+ """Delete a remote comment."""
57
+ return comment_id
58
+
59
+
60
+ @mcp.tool()
61
+ def send_email(message: str) -> str:
62
+ """Send an email message."""
63
+ return message
64
+
65
+
66
+ @mcp.tool()
67
+ def send_status(status: str) -> str:
68
+ """Send a status notification."""
69
+ return status
70
+
71
+
72
+ @mcp.tool()
73
+ def delete_history(history_id: str) -> str:
74
+ """Delete remote history."""
75
+ return history_id
76
+
77
+
78
+ @mcp.tool()
79
+ def replace_path(source: str, destination: str) -> str:
80
+ path = Path(source)
81
+ path.replace(destination)
82
+ return destination
83
+
84
+
85
+ @mcp.tool()
86
+ def replace_path_with_os(source: str, destination: str) -> str:
87
+ os.replace(source, destination)
88
+ return destination
@@ -1,6 +1,6 @@
1
1
  """MCP Audit public package and independently evolving contract versions."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
4
4
  RULESET_VERSION = "0.1"
5
5
  REPORT_SCHEMA_VERSION = "1.0"
6
6
  MANIFEST_SCHEMA_VERSION = "1.0"
@@ -318,7 +318,7 @@ def _tool_from_function(
318
318
  location=_function_location(file_path, node),
319
319
  )
320
320
  )
321
- if _looks_destructive(lower_text):
321
+ if _looks_destructive(name, description):
322
322
  capability.destructive = True
323
323
  capability.side_effect = "destructive_action"
324
324
  analyzer.evidence.append(
@@ -364,6 +364,11 @@ class FunctionAnalyzer(ast.NodeVisitor):
364
364
  self.file_path = file_path
365
365
  self.source_text = source_text
366
366
  self.parameter_names = {parameter.name for parameter in parameters}
367
+ self.path_objects = {
368
+ parameter.name
369
+ for parameter in parameters
370
+ if parameter.annotation and parameter.annotation.rsplit(".", 1)[-1] == "Path"
371
+ }
367
372
  self.path_helpers = path_helpers
368
373
  self.aliases: dict[str, set[str]] = {}
369
374
  self.host_guarded_parameters: set[str] = set()
@@ -387,14 +392,21 @@ class FunctionAnalyzer(ast.NodeVisitor):
387
392
  def visit_Assign(self, node: ast.Assign) -> None:
388
393
  roots = self._parameter_roots(node.value)
389
394
  for target in node.targets:
390
- if isinstance(target, ast.Name) and roots:
391
- self.aliases[target.id] = roots
395
+ if isinstance(target, ast.Name):
396
+ if roots:
397
+ self.aliases[target.id] = roots
398
+ if _is_path_expression(node.value, self.path_objects):
399
+ self.path_objects.add(target.id)
392
400
  self.generic_visit(node)
393
401
 
394
402
  def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
395
403
  roots = self._parameter_roots(node.value)
396
- if isinstance(node.target, ast.Name) and roots:
397
- self.aliases[node.target.id] = roots
404
+ if isinstance(node.target, ast.Name):
405
+ if roots:
406
+ self.aliases[node.target.id] = roots
407
+ annotation = ast.unparse(node.annotation).rsplit(".", 1)[-1]
408
+ if annotation == "Path" or _is_path_expression(node.value, self.path_objects):
409
+ self.path_objects.add(node.target.id)
398
410
  self.generic_visit(node)
399
411
 
400
412
  def visit_For(self, node: ast.For) -> None:
@@ -426,6 +438,11 @@ class FunctionAnalyzer(ast.NodeVisitor):
426
438
  elif call_name.startswith("subprocess.") and _has_model_controlled_executable(node, self._parameter_roots):
427
439
  self.shell_execution = True
428
440
  self._record(node, f"{call_name}(...) uses a model-controlled executable or interpreter payload.", "execution")
441
+ if call_name in {"pexpect.spawn", "pexpect.spawnu"} and _has_model_controlled_executable(
442
+ node, self._parameter_roots
443
+ ):
444
+ self.shell_execution = True
445
+ self._record(node, f"{call_name}(...) starts a model-controlled interactive process.", "execution")
429
446
  open_access = _file_open_access(node, call_name)
430
447
  if open_access is not None:
431
448
  reads, writes = open_access
@@ -459,7 +476,9 @@ class FunctionAnalyzer(ast.NodeVisitor):
459
476
  "shutil.copyfile",
460
477
  "shutil.move",
461
478
  "shutil.rmtree",
462
- } or call_name.endswith((".mkdir", ".unlink", ".rename", ".replace")):
479
+ } or call_name.endswith((".mkdir", ".unlink", ".rename")) or _is_path_replace(
480
+ node, call_name, self.path_objects
481
+ ):
463
482
  self.filesystem_write = True
464
483
  self.filesystem_operation_lines.append(node.lineno)
465
484
  self._mark_inline_path_guard(node)
@@ -610,6 +629,37 @@ def _file_open_access(node: ast.Call, call_name: str) -> tuple[bool, bool] | Non
610
629
  return reads, writes
611
630
 
612
631
 
632
+ def _is_path_expression(node: ast.AST | None, path_objects: set[str]) -> bool:
633
+ if node is None:
634
+ return False
635
+ if isinstance(node, ast.Name):
636
+ return node.id in path_objects
637
+ if isinstance(node, ast.Call):
638
+ call_name = _call_name(node.func)
639
+ if call_name in {"Path", "pathlib.Path"}:
640
+ return True
641
+ if isinstance(node.func, ast.Attribute) and node.func.attr in {
642
+ "absolute",
643
+ "expanduser",
644
+ "resolve",
645
+ "with_name",
646
+ "with_stem",
647
+ "with_suffix",
648
+ }:
649
+ return _is_path_expression(node.func.value, path_objects)
650
+ return False
651
+
652
+
653
+ def _is_path_replace(node: ast.Call, call_name: str, path_objects: set[str]) -> bool:
654
+ if call_name in {"os.replace", "Path.replace", "pathlib.Path.replace"}:
655
+ return True
656
+ return (
657
+ isinstance(node.func, ast.Attribute)
658
+ and node.func.attr == "replace"
659
+ and _is_path_expression(node.func.value, path_objects)
660
+ )
661
+
662
+
613
663
  def _has_model_controlled_executable(node: ast.Call, roots_for) -> bool:
614
664
  if not node.args:
615
665
  return False
@@ -694,7 +744,11 @@ def _function_location(file_path: Path, node: ast.FunctionDef | ast.AsyncFunctio
694
744
 
695
745
 
696
746
  def _semantic_tokens(text: str) -> set[str]:
697
- return set(re.findall(r"[a-z0-9]+", text.lower()))
747
+ return set(_semantic_words(text))
748
+
749
+
750
+ def _semantic_words(text: str) -> list[str]:
751
+ return re.findall(r"[a-z0-9]+", text.lower())
698
752
 
699
753
 
700
754
  def _highest_sensitivity(data_classes: set[str]) -> str:
@@ -705,11 +759,34 @@ def _highest_sensitivity(data_classes: set[str]) -> str:
705
759
 
706
760
 
707
761
  def _looks_external_write(text: str) -> bool:
708
- return bool(
709
- _semantic_tokens(text)
710
- & {"send", "email", "slack", "webhook", "publish", "post", "message", "notify", "upload"}
711
- )
762
+ words = _semantic_words(text)
763
+ if words and words[0] in {"get", "list", "read", "search", "show", "monitor"}:
764
+ return False
765
+ return bool(set(words) & {"send", "forward", "publish", "post", "notify", "upload"})
712
766
 
713
767
 
714
- def _looks_destructive(text: str) -> bool:
715
- return bool(_semantic_tokens(text) & {"delete", "drop", "remove", "revoke", "terminate", "destroy"})
768
+ def _looks_destructive(name: str, description: str) -> bool:
769
+ name_words = _semantic_words(name)
770
+ name_tokens = set(name_words)
771
+ if name_words and name_words[0] in {"get", "list", "read", "search", "show", "monitor"}:
772
+ return False
773
+ destructive = {"delete", "drop", "remove", "revoke", "terminate", "destroy"}
774
+ if name_tokens & destructive:
775
+ return True
776
+ normalized_description = " ".join(description.lower().split())
777
+ if any(
778
+ phrase in normalized_description
779
+ for phrase in {"does not execute", "doesn't execute", "read-only", "strictly prohibited", "monitor only"}
780
+ ):
781
+ return False
782
+ declares_destructive_action = bool(
783
+ re.match(
784
+ r"^(?:this tool\s+)?(?:delete|deletes|drop|drops|remove|removes|revoke|revokes|terminate|terminates|destroy|destroys)\b",
785
+ normalized_description,
786
+ )
787
+ )
788
+ execution_tool_describes_destructive_sql = bool(
789
+ name_tokens & {"execute", "run", "apply"}
790
+ and _semantic_tokens(normalized_description) & destructive
791
+ )
792
+ return declares_destructive_action or execution_tool_describes_destructive_sql
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-capdiff
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Security regression scanner for Model Context Protocol servers.
5
5
  Author: MCP Audit contributors
6
6
  License-Expression: MIT
@@ -34,6 +34,7 @@ examples/vulnerable-fastmcp/scenarios/network_allowlist_added/after.py
34
34
  examples/vulnerable-fastmcp/scenarios/network_allowlist_added/before.py
35
35
  examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/after.py
36
36
  examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/before.py
37
+ fixtures/benchmark_semantics/server.py
37
38
  fixtures/destructive_with_approval/server.py
38
39
  fixtures/destructive_without_approval/server.py
39
40
  fixtures/fastmcp_async_annotations/server.py
@@ -74,7 +74,7 @@
74
74
  }
75
75
  ],
76
76
  "versions": {
77
- "cli": "0.1.2",
77
+ "cli": "0.1.3",
78
78
  "rules": "0.1"
79
79
  }
80
80
  }
@@ -81,7 +81,7 @@
81
81
  }
82
82
  }
83
83
  ],
84
- "semanticVersion": "0.1.2"
84
+ "semanticVersion": "0.1.3"
85
85
  }
86
86
  }
87
87
  }
@@ -50,3 +50,43 @@ def test_sqlite_reads_are_not_writes_or_exfiltration_sinks() -> None:
50
50
  report = scan_path(ROOT / "fixtures/sqlite_read_only")
51
51
  assert all(tool.capability.side_effect == "none" for tool in report.tools)
52
52
  assert not {"MCP004", "MCP005"} & {finding.rule_id for finding in report.findings}
53
+
54
+
55
+ def test_generic_replace_calls_are_not_filesystem_writes() -> None:
56
+ report = scan_path(ROOT / "fixtures/benchmark_semantics")
57
+ tools = {tool.name: tool for tool in report.tools}
58
+ assert tools["list_tables"].capability.filesystem == "none"
59
+ assert tools["get_message_time"].capability.filesystem == "none"
60
+ assert tools["replace_path"].capability.side_effect == "local_write"
61
+ assert tools["replace_path_with_os"].capability.side_effect == "local_write"
62
+
63
+
64
+ def test_pexpect_spawn_is_model_controlled_execution() -> None:
65
+ report = scan_path(ROOT / "fixtures/benchmark_semantics")
66
+ tools = {tool.name: tool for tool in report.tools}
67
+ assert tools["spawn_process"].capability.execution == "shell"
68
+ assert tools["spawn_unicode_process"].capability.execution == "shell"
69
+ findings = {
70
+ item.tool: item for item in report.findings if item.rule_id == "MCP001"
71
+ }
72
+ assert findings["spawn_process"].evidence[0].snippet == "pexpect.spawn(command)"
73
+ assert findings["spawn_unicode_process"].evidence[0].snippet == "pexpect.spawnu(command)"
74
+
75
+
76
+ def test_read_only_context_suppresses_destructive_lexical_matches() -> None:
77
+ report = scan_path(ROOT / "fixtures/benchmark_semantics")
78
+ destructive_tools = {
79
+ finding.tool for finding in report.findings if finding.rule_id in {"MCP004", "MCP010"}
80
+ }
81
+ assert "get_vacuum_analyze_stats" not in destructive_tools
82
+ assert "Type" not in destructive_tools
83
+ assert "delete_comment" in destructive_tools
84
+
85
+
86
+ def test_external_write_inference_requires_an_action_verb() -> None:
87
+ report = scan_path(ROOT / "fixtures/benchmark_semantics")
88
+ tools = {tool.name: tool for tool in report.tools}
89
+ assert tools["list_email_tags"].capability.side_effect == "none"
90
+ assert tools["send_email"].capability.side_effect == "external_write"
91
+ assert tools["send_status"].capability.side_effect == "external_write"
92
+ assert tools["delete_history"].capability.side_effect == "destructive_action"
@@ -15,7 +15,7 @@ def test_json_schema_has_stable_version_and_structured_evidence() -> None:
15
15
  payload = scan_path(ROOT / "fixtures/url_arbitrary").as_dict()
16
16
  assert payload["schema_version"] == "1.0"
17
17
  assert payload["report_type"] == "scan"
18
- assert payload["versions"] == {"cli": "0.1.2", "rules": "0.1"}
18
+ assert payload["versions"] == {"cli": "0.1.3", "rules": "0.1"}
19
19
  evidence = payload["findings"][0]["evidence"][0]
20
20
  assert set(evidence) == {"kind", "message", "location", "snippet"}
21
21
  assert payload["tools"][0]["context"] == "server.py:mcp"
@@ -31,7 +31,7 @@ def test_terminal_finding_answers_risk_and_remediation() -> None:
31
31
  def test_sarif_contains_source_region_snippet_and_help() -> None:
32
32
  payload = json.loads(render_sarif(scan_path(ROOT / "fixtures/url_arbitrary")))
33
33
  run = payload["runs"][0]
34
- assert run["tool"]["driver"]["semanticVersion"] == "0.1.2"
34
+ assert run["tool"]["driver"]["semanticVersion"] == "0.1.3"
35
35
  assert run["tool"]["driver"]["properties"]["rulesVersion"] == "0.1"
36
36
  result = next(item for item in run["results"] if item["ruleId"] == "MCP003")
37
37
  region = result["locations"][0]["physicalLocation"]["region"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes