mcpgawk 0.1.7__tar.gz → 0.1.9__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 (157) hide show
  1. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/.github/workflows/publish-pypi.yml +13 -1
  2. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/CHANGELOG.md +42 -0
  3. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/PKG-INFO +1 -1
  4. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/pyproject.toml +1 -1
  5. mcpgawk-0.1.9/src/mcpgawk/baseline.py +155 -0
  6. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/cli.py +194 -1
  7. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/label.py +8 -0
  8. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/signals.py +252 -4
  9. mcpgawk-0.1.9/src/mcpgawk/skills.py +320 -0
  10. mcpgawk-0.1.9/tests/test_baseline_spine.py +108 -0
  11. mcpgawk-0.1.9/tests/test_canary_skills.py +36 -0
  12. mcpgawk-0.1.9/tests/test_free_cli_platform_hint.py +70 -0
  13. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_layer_invariants.py +10 -1
  14. mcpgawk-0.1.9/tests/test_skills.py +265 -0
  15. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/.github/workflows/ci.yml +0 -0
  16. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/.github/workflows/mcpgawk-gate-selftest.yml +0 -0
  17. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/.gitignore +0 -0
  18. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/BRAND.md +0 -0
  19. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/CONTRIBUTING.md +0 -0
  20. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/LICENSE +0 -0
  21. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/NOTICE +0 -0
  22. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/README.md +0 -0
  23. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/SECURITY.md +0 -0
  24. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/THREAT-MODEL.md +0 -0
  25. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/action.yml +0 -0
  26. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/demo.gif +0 -0
  27. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/favicon.svg +0 -0
  28. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/identity-board.html +0 -0
  29. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/nativerse-mark.svg +0 -0
  30. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/social-preview.png +0 -0
  31. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/social-preview.svg +0 -0
  32. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/wordmark-dark.png +0 -0
  33. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/wordmark-dark.svg +0 -0
  34. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/wordmark-light.png +0 -0
  35. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/wordmark-light.svg +0 -0
  36. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/.gitignore +0 -0
  37. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/CNAME +0 -0
  38. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/api/lead.js +0 -0
  39. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/brand/favicon.svg +0 -0
  40. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/brand/nativerse-mark.svg +0 -0
  41. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/brand/social-preview.png +0 -0
  42. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/brand/social-preview.svg +0 -0
  43. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/alibaba.svg +0 -0
  44. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/anthropic.svg +0 -0
  45. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/baidu.svg +0 -0
  46. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/cerebras.svg +0 -0
  47. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/cloudflare.svg +0 -0
  48. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/cohere.svg +0 -0
  49. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/copilot.svg +0 -0
  50. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/cursor.svg +0 -0
  51. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/deepseek.svg +0 -0
  52. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/discord.svg +0 -0
  53. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/docker.svg +0 -0
  54. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/figma.svg +0 -0
  55. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/framer.svg +0 -0
  56. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/gemini.svg +0 -0
  57. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/github.svg +0 -0
  58. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/gitlab.svg +0 -0
  59. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/gojek.svg +0 -0
  60. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/google.svg +0 -0
  61. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/grab.svg +0 -0
  62. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/grok.svg +0 -0
  63. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/huawei.svg +0 -0
  64. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/huggingface.svg +0 -0
  65. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/inflection.svg +0 -0
  66. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/infosys.svg +0 -0
  67. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/kakaotalk.svg +0 -0
  68. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/kimi.svg +0 -0
  69. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/klarna.svg +0 -0
  70. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/langchain.svg +0 -0
  71. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/line.svg +0 -0
  72. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/linear.svg +0 -0
  73. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/mercadolibre.svg +0 -0
  74. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/meta.svg +0 -0
  75. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/microsoft.svg +0 -0
  76. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/midjourney.svg +0 -0
  77. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/mistral.svg +0 -0
  78. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/naver.svg +0 -0
  79. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/netlify.svg +0 -0
  80. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/notion.svg +0 -0
  81. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/nubank.svg +0 -0
  82. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/nvidia.svg +0 -0
  83. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/ollama.svg +0 -0
  84. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/openai.svg +0 -0
  85. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/paytm.svg +0 -0
  86. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/perplexity.svg +0 -0
  87. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/postgresql.svg +0 -0
  88. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/python.svg +0 -0
  89. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/qwen.svg +0 -0
  90. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/rakuten.svg +0 -0
  91. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/replicate.svg +0 -0
  92. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/replit.svg +0 -0
  93. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/revolut.svg +0 -0
  94. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/runway.svg +0 -0
  95. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/sap.svg +0 -0
  96. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/sentry.svg +0 -0
  97. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/shopee.svg +0 -0
  98. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/slack.svg +0 -0
  99. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/sony.svg +0 -0
  100. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/spotify.svg +0 -0
  101. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/stability.svg +0 -0
  102. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/stripe.svg +0 -0
  103. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/suno.svg +0 -0
  104. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/supabase.svg +0 -0
  105. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/swiggy.svg +0 -0
  106. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/tiktok.svg +0 -0
  107. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/togetherai.svg +0 -0
  108. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/vercel.svg +0 -0
  109. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/wechat.svg +0 -0
  110. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/weibo.svg +0 -0
  111. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/wise.svg +0 -0
  112. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/xai.svg +0 -0
  113. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/xiaomi.svg +0 -0
  114. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/zoho.svg +0 -0
  115. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/zomato.svg +0 -0
  116. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/index.html +0 -0
  117. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/logofield.js +0 -0
  118. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/report.html +0 -0
  119. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/styles.css +0 -0
  120. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/examples/mcp.json +0 -0
  121. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/scripts/ci_gate.py +0 -0
  122. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/__init__.py +0 -0
  123. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/__main__.py +0 -0
  124. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/ambient.py +0 -0
  125. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/consent.py +0 -0
  126. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/discover.py +0 -0
  127. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/drift.py +0 -0
  128. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/fingerprint.py +0 -0
  129. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/fleet.py +0 -0
  130. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/grade.py +0 -0
  131. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/history.py +0 -0
  132. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/mcp_server.py +0 -0
  133. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/measure.py +0 -0
  134. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/oauth_login.py +0 -0
  135. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/oauth_scopes.py +0 -0
  136. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/probe.py +0 -0
  137. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/redact.py +0 -0
  138. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/servercard.py +0 -0
  139. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/supplychain.py +0 -0
  140. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/transport.py +0 -0
  141. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/supabase/leads.sql +0 -0
  142. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/supabase/lockdown.sql +0 -0
  143. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_ambient.py +0 -0
  144. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_cli_integration.py +0 -0
  145. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_drift.py +0 -0
  146. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_drift_invariants.py +0 -0
  147. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_grade.py +0 -0
  148. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_label_axes.py +0 -0
  149. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_mcp_server.py +0 -0
  150. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_measure.py +0 -0
  151. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_no_egress.py +0 -0
  152. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_oauth_scopes.py +0 -0
  153. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_servercard.py +0 -0
  154. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_signals.py +0 -0
  155. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_supplychain.py +0 -0
  156. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_thin_guidance.py +0 -0
  157. {mcpgawk-0.1.7 → mcpgawk-0.1.9}/uv.lock +0 -0
@@ -75,4 +75,16 @@ jobs:
75
75
  TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
76
76
  run: |
77
77
  python -m pip install twine
78
- twine upload dist/*
78
+ # --skip-existing + retry: on 2026-07-23 (0.1.7) upload.pypi.org connect-timeouts made
79
+ # twine exit 1 AFTER both artefacts had already landed — a RED run over a SUCCESSFUL
80
+ # publish, and a plain re-run would then fail with "file already exists". --skip-existing
81
+ # makes a retry idempotent (already-uploaded files are skipped, not errored); the loop
82
+ # rides out a transient network blip instead of failing the whole release on it.
83
+ for attempt in 1 2 3; do
84
+ if twine upload --non-interactive --skip-existing dist/*; then
85
+ echo "upload ok (attempt $attempt)"; exit 0
86
+ fi
87
+ echo "::warning::twine upload attempt $attempt failed (likely a transient upload.pypi.org timeout) — retrying"
88
+ sleep 20
89
+ done
90
+ echo "::error::twine upload failed after 3 attempts"; exit 1
@@ -2,6 +2,48 @@
2
2
 
3
3
  All notable changes to mcpgawk. Format: [Keep a Changelog](https://keepachangelog.com/); versioning: [SemVer](https://semver.org/).
4
4
 
5
+ ## [0.1.8] — 2026-07-26
6
+
7
+ One command. The paid capabilities are now subcommands of `mcpgawk`, not a separate binary.
8
+
9
+ ### Changed
10
+
11
+ - **`mcpgawk --help` now lists the gawk Platform capabilities** (`verify`, `enforce`, `monitor`,
12
+ `build`) so you can see what a subscription adds without installing anything. Running one on a
13
+ free install prints a single line explaining what it is and where to get it, and exits 3 — no
14
+ Python traceback, and never a silent success.
15
+ - **The free scanner is unchanged and stays free.** `mcpgawk scan` needs no licence, no account and
16
+ no network beyond the servers you point it at. Nothing in this release adds a paid dependency:
17
+ the paid engine is not in this package and the import that reaches for it is optional.
18
+
19
+ ### Why one command
20
+
21
+ The paid tier used to install a second executable named `gawk`. That name belongs to **GNU AWK** —
22
+ it owns `/usr/bin/gawk` across the Debian family and supplies `/usr/bin/awk` there through the
23
+ alternatives system, so shipping our own `gawk` could break a machine's `awk`. Debian Policy §10.1
24
+ requires a rename when two packages claim one path, and Homebrew's `gawk` formula *is* GNU awk.
25
+ Projects that hit this before us all retreated: ast-grep deprecated its colliding `sg`, `fd` ships
26
+ as `fdfind`, `bat` as `batcat`.
27
+
28
+ One command plus a licence unlock is also the ordinary shape for this kind of tool — Semgrep, Snyk,
29
+ GitLab and Terraform all do it. "gawk" remains the brand (gawk.dev); `mcpgawk` is the command.
30
+
31
+ ## [0.1.7] — 2026-07-23
32
+
33
+ Engine sync release. Dispatch-aware drift, credential-safe fleet URLs, sturdier baselines.
34
+
35
+ ### Added
36
+
37
+ - `fingerprint.py` — one shared surface-fingerprint used by both drift and the fleet view, so the
38
+ two can no longer disagree about whether a server changed.
39
+
40
+ ### Changed
41
+
42
+ - **Fleet URLs are redacted for display.** A secret-named query parameter (`apiKey=`, `token=`) or
43
+ userinfo in a server URL no longer renders in the fleet listing. The real URL is untouched for
44
+ connecting — only what gets shown is masked.
45
+ - Drift and history handling hardened around dispatch-style servers and baseline recording.
46
+
5
47
  ## [0.1.6] — 2026-07-22
6
48
 
7
49
  The drift release. Everything here is about one question a fresh look at your machine can never
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpgawk
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: A local-first CLI that measures what an MCP server costs and what it can do. Nothing leaves your machine.
5
5
  Project-URL: Homepage, https://mcp.gawk.dev
6
6
  Project-URL: Documentation, https://mcp.gawk.dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mcpgawk"
7
- version = "0.1.7"
7
+ version = "0.1.9"
8
8
  description = "A local-first CLI that measures what an MCP server costs and what it can do. Nothing leaves your machine."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,155 @@
1
+ """The one answer to "what did this server look like when I trusted it".
2
+
3
+ The product kept three memories of that: `history.py` (scan), `packages/verify/src/pins.ts`
4
+ (verify) and `monitor/store.py` (the daemon). You could approve a server in one and still be told
5
+ it had drifted by another, which is why no flow felt joined up — there wasn't one spine, there were
6
+ three.
7
+
8
+ This is the spine. It is deliberately a NARROW VIEW over the store that already exists
9
+ (`history.json`) rather than a fourth file: a new store would have to be migrated into, kept in
10
+ sync, and would itself become another memory to disagree with. The canonical record was already
11
+ there — `servers[key]["approved"]` — it simply had no name and no reader outside scan.
12
+
13
+ What makes one shared baseline possible at all is that the fingerprint is now identical everywhere:
14
+ `fingerprint.surface_pin` is computed by scan (through `measure`), by monitor, and — since the
15
+ schema-2.0 change — byte-for-byte by verify in TypeScript. Before that, sharing a baseline would
16
+ have meant sharing a number the three pillars each interpreted differently.
17
+
18
+ Shape (stable, and the contract other runtimes read through `mcpgawk baseline --json`):
19
+
20
+ {"schema": "gawk.baseline/1",
21
+ "servers": {"<key>": {"pin": "<16-hex>", "tools": {"<name>": "<hash>"},
22
+ "approved_at": "<iso8601>", "aliases": [...]}}}
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ from . import history
30
+
31
+ #: Bumped only when the exported shape changes incompatibly. Readers check it rather than guessing.
32
+ SCHEMA = "gawk.baseline/1"
33
+
34
+
35
+ def approved_record(key: str, path: str | None = None) -> dict[str, Any] | None:
36
+ """The full approved record for one server, or None if nothing has been approved yet."""
37
+ store = history.load(path or history.default_path())
38
+ return history.approved(store, key)
39
+
40
+
41
+ def approved_pin(key: str, path: str | None = None) -> str | None:
42
+ """The approved whole-surface pin — the single value every pillar compares against.
43
+
44
+ None means "never approved", which is NOT the same as "approved as empty": a caller that
45
+ conflates them reports drift against a baseline that does not exist, on a server the operator
46
+ has never looked at. Trust-on-first-use is scan's job (see history.record); this only reports.
47
+ """
48
+ rec = approved_record(key, path)
49
+ return (rec or {}).get("pin")
50
+
51
+
52
+ def approved_tools(key: str, path: str | None = None) -> dict[str, str]:
53
+ """`{tool name: hash}` from the approved record. Empty dict when nothing is approved."""
54
+ rec = approved_record(key, path) or {}
55
+ tools = rec.get("tools")
56
+ return dict(tools) if isinstance(tools, dict) else {}
57
+
58
+
59
+ def approved_annotations(key: str, path: str | None = None) -> dict[str, dict[str, Any]]:
60
+ """`{tool name: annotations}` as they were WHEN APPROVED.
61
+
62
+ The distinction that makes this worth having: `enforce` can already derive a policy from the
63
+ annotations a server declares at connect time — but that is the server's CURRENT claim about
64
+ itself. A server that quietly flips `readOnlyHint` to true would have the guard relax to match
65
+ it. Deriving from the approved record instead means the policy reflects the operator's decision,
66
+ and a server changing its story does not change what it is allowed to do.
67
+
68
+ Records store typed keys (`tool.read_file`) because a prompt and a tool may share a name; only
69
+ tools carry scopes, so only tools come back.
70
+ """
71
+ rec = approved_record(key, path) or {}
72
+ raw = rec.get("annotations")
73
+ if not isinstance(raw, dict):
74
+ return {}
75
+ out: dict[str, dict[str, Any]] = {}
76
+ for ident, ann in raw.items():
77
+ if not isinstance(ident, str) or not ident.startswith("tool."):
78
+ continue
79
+ if isinstance(ann, dict):
80
+ out[ident[len("tool."):]] = dict(ann)
81
+ return out
82
+
83
+
84
+ def export(path: str | None = None) -> dict[str, Any]:
85
+ """The whole approved baseline, in the shape other runtimes read.
86
+
87
+ Only APPROVED state crosses this boundary — never the sighting history. What a server looked
88
+ like last Tuesday is scan's business; what the operator has agreed to trust is everyone's.
89
+ """
90
+ store = history.load(path or history.default_path())
91
+ out: dict[str, Any] = {}
92
+ for key, entry in (store.get("servers") or {}).items():
93
+ if not isinstance(entry, dict):
94
+ continue
95
+ rec = entry.get("approved")
96
+ if not isinstance(rec, dict):
97
+ continue # seen but never approved — deliberately absent, not exported as empty
98
+ out[key] = {
99
+ "pin": rec.get("pin"),
100
+ "tools": dict(rec.get("tools") or {}),
101
+ "approved_at": rec.get("measured_at"),
102
+ "aliases": list(entry.get("aliases") or []),
103
+ "annotations": {
104
+ ident[len("tool."):]: dict(ann)
105
+ for ident, ann in (rec.get("annotations") or {}).items()
106
+ if isinstance(ident, str) and ident.startswith("tool.") and isinstance(ann, dict)
107
+ },
108
+ }
109
+ return {"schema": SCHEMA, "servers": out}
110
+
111
+
112
+ def publish(key: str, *, pin: str, tools: dict[str, str], approved_at: str,
113
+ alias: str | None = None, path: str | None = None) -> None:
114
+ """Write an approval INTO the spine from another pillar.
115
+
116
+ The read path (`export`) alone makes the spine a one-way mirror: verify and monitor could see
117
+ what scan approved, but an operator approving a drift in `gawk-monitor approve` left scan still
118
+ reporting it. A shared baseline that only one pillar can move is three memories again, with
119
+ extra steps.
120
+
121
+ Deliberately narrow — pin, per-tool hashes, when. A monitor Snapshot carries more (grade,
122
+ signals, verification state) and that stays in monitor's own store: the spine is the answer to
123
+ "what surface did we agree to trust", not a general-purpose replica.
124
+ """
125
+ p = path or history.default_path()
126
+ with history.locked(p):
127
+ store = history.load(p)
128
+ entry = store.setdefault("servers", {}).setdefault(key, {})
129
+ entry["approved"] = {
130
+ **(entry.get("approved") or {}),
131
+ "measured_at": approved_at,
132
+ "pin": pin,
133
+ "tools": dict(tools),
134
+ }
135
+ if alias:
136
+ entry["aliases"] = sorted(set(entry.get("aliases", [])) | {alias})
137
+ history.save(store, p)
138
+
139
+
140
+ def resolve(name: str, path: str | None = None) -> str | None:
141
+ """Map a name the user typed to the key the baseline is stored under.
142
+
143
+ A server's identity key and the name it is configured under in an AI tool are not the same
144
+ string, and the same server is routinely configured under different names in different tools.
145
+ Without this, `approve slack` in one pillar and a lookup of `slack-mcp` in another miss each
146
+ other and the shared baseline silently does nothing.
147
+ """
148
+ store = history.load(path or history.default_path())
149
+ servers = store.get("servers") or {}
150
+ if name in servers:
151
+ return name
152
+ for key, entry in servers.items():
153
+ if isinstance(entry, dict) and name in (entry.get("aliases") or []):
154
+ return key
155
+ return None
@@ -127,7 +127,16 @@ def build_parser() -> argparse.ArgumentParser:
127
127
 
128
128
  Whether tracking defaults on is a product invariant (ADR-0012 N2), not a detail — asserting it
129
129
  needs a parser you can build without running a scan."""
130
- p = argparse.ArgumentParser(prog="mcpgawk", description="gawk at an MCP server before you trust it")
130
+ p = argparse.ArgumentParser(
131
+ prog="mcpgawk",
132
+ description="gawk at an MCP server before you trust it",
133
+ epilog=(
134
+ "gawk Platform capabilities (£29/month — https://mcp.gawk.dev/pricing.html):\n"
135
+ + "".join(f" {c:<9} {d}\n" for c, d in PLATFORM_CAPABILITIES.items())
136
+ + "Run `mcpgawk <capability>` once subscribed; scan stays free and open-source."
137
+ ),
138
+ formatter_class=argparse.RawDescriptionHelpFormatter,
139
+ )
131
140
  sub = p.add_subparsers(dest="cmd", required=True)
132
141
  s = sub.add_parser("scan", help="measure MCP server(s) locally")
133
142
  s.add_argument("config", nargs="?", help="path to an mcp.json config")
@@ -175,6 +184,18 @@ def build_parser() -> argparse.ArgumentParser:
175
184
  # The other half of a sticky alarm. Drift now re-reports on every scan until acknowledged
176
185
  # (ADR-0012 N1), so there MUST be an obvious way to acknowledge it — an alarm a user cannot
177
186
  # clear is one they will silence with --no-track, which costs them the baseline entirely.
187
+ b = sub.add_parser(
188
+ "baseline",
189
+ help="print the approved baseline — what you have agreed to trust, per server",
190
+ description=(
191
+ "The shared baseline every pillar compares against. `verify` and `monitor` read this "
192
+ "so that approving a server once is approving it everywhere, instead of each keeping "
193
+ "its own memory and contradicting the others."
194
+ ),
195
+ )
196
+ b.add_argument("--json", action="store_true", help="machine-readable (the cross-runtime shape)")
197
+ b.add_argument("--server", metavar="NAME", help="one server (name or alias) instead of all")
198
+
178
199
  a = sub.add_parser("approve",
179
200
  help="accept a server's current tools as the trusted baseline (clears DRIFT)")
180
201
  a.add_argument("server", nargs="?",
@@ -182,9 +203,118 @@ def build_parser() -> argparse.ArgumentParser:
182
203
  a.add_argument("--all", action="store_true", help="approve every server with pending drift")
183
204
  a.add_argument("--list", action="store_true",
184
205
  help="show which servers have changes you have not approved, and change nothing")
206
+
207
+ k = sub.add_parser(
208
+ "skills",
209
+ help="scan agent SKILLS (SKILL.md trees) for injection, hidden text and risky content",
210
+ description=(
211
+ "Discovers agent skills across every supported host (Claude Code, Codex, Cursor, "
212
+ "Copilot, Windsurf, Antigravity, Kiro, Gemini, opencode, Amp) and scans their content "
213
+ "locally — nothing is uploaded anywhere. Give paths to scan a specific skill dir, a "
214
+ "skills root, or a project instead of the whole machine."
215
+ ),
216
+ )
217
+ k.add_argument("paths", nargs="*",
218
+ help="skill dir / skills root / project root (default: discover all hosts)")
219
+ k.add_argument("--json", action="store_true", help="machine-readable output")
220
+ k.add_argument("--fail-on-findings", action="store_true",
221
+ help="exit 1 if any finding fires (CI gate) — default reports and exits 0, "
222
+ "because a signal is a signal, not a verdict")
185
223
  return p
186
224
 
187
225
 
226
+ _SKILLS_NOT_CHECKED = (
227
+ "not checked (needs semantic analysis, not attempted): intent vs stated purpose, "
228
+ "financial-capability, third-party-content-exposure, system-service classification"
229
+ )
230
+
231
+
232
+ def _skills(args) -> int:
233
+ """Scan agent skills. Local-only by construction: skills.py has no network imports at all —
234
+ the differentiator against agent-scan, whose client uploads raw skill content for analysis."""
235
+ from pathlib import Path
236
+
237
+ from .signals import as_dicts
238
+ from .skills import discover_skills
239
+
240
+ snaps = discover_skills(explicit_paths=[Path(p) for p in args.paths] or None)
241
+ total_findings = sum(len(s.findings) for s in snaps)
242
+
243
+ if args.json:
244
+ print(json.dumps({
245
+ "skills": [{
246
+ "name": s.name, "root": s.root, "hosts": s.hosts,
247
+ "description": s.description,
248
+ "files_scanned": len(s.files), "files_seen": s.files_seen, "capped": s.capped,
249
+ "findings": as_dicts(s.findings),
250
+ } for s in snaps],
251
+ "not_checked": _SKILLS_NOT_CHECKED,
252
+ }, indent=2))
253
+ else:
254
+ if not snaps:
255
+ where = "the given path(s)" if args.paths else "any supported host on this machine"
256
+ print(f"mcpgawk skills: no skills found under {where}.")
257
+ for s in snaps:
258
+ hosts = ", ".join(s.hosts)
259
+ print(f"\n {s.name} ({s.root}; loaded by: {hosts})")
260
+ scope = f" {len(s.files)} file(s) scanned"
261
+ if s.capped:
262
+ scope += f" of {s.files_seen} seen — CAPPED, the remainder was not examined"
263
+ print(scope)
264
+ if not s.findings:
265
+ print(" clean under the local detectors")
266
+ for f in s.findings:
267
+ print(f" ⚠ [{f.kind}] {f.tool}: {f.evidence}")
268
+ if snaps:
269
+ print(f"\n {len(snaps)} skill(s), {total_findings} finding(s). "
270
+ f"All analysis LOCAL — no skill content left this machine.")
271
+ print(f" {_SKILLS_NOT_CHECKED}.")
272
+
273
+ return 1 if (args.fail_on_findings and total_findings) else 0
274
+
275
+
276
+ def _baseline(args) -> int:
277
+ """Print what the operator has agreed to trust — the spine other pillars read.
278
+
279
+ Only approved state is exported. A sighting is not a baseline: printing the last thing seen
280
+ would hand verify and monitor the poisoned surface as though it were trusted, which is the
281
+ exact failure ADR-0012 exists to prevent.
282
+ """
283
+ from . import baseline as _baseline_mod
284
+
285
+ data = _baseline_mod.export(getattr(args, "history", None))
286
+ servers = data["servers"]
287
+
288
+ if args.server:
289
+ key = _baseline_mod.resolve(args.server, getattr(args, "history", None))
290
+ if key is None or key not in servers:
291
+ print(f"mcpgawk: nothing approved for '{args.server}'. "
292
+ f"Run `mcpgawk scan`, then `mcpgawk approve {args.server}`.", file=sys.stderr)
293
+ return 2
294
+ servers = {key: servers[key]}
295
+ data = {**data, "servers": servers}
296
+
297
+ if args.json:
298
+ print(json.dumps(data, indent=2, sort_keys=True))
299
+ return 0
300
+
301
+ if not servers:
302
+ print("Nothing approved yet. Run `mcpgawk scan`, then `mcpgawk approve <server>` to set "
303
+ "the baseline that verify and monitor will compare against.")
304
+ return 0
305
+
306
+ print(f"Approved baseline — {len(servers)} server(s). "
307
+ f"verify and monitor compare against exactly this.\n")
308
+ for key in sorted(servers):
309
+ rec = servers[key]
310
+ alias = f" ({', '.join(rec['aliases'])})" if rec.get("aliases") else ""
311
+ print(f" {key}{alias}")
312
+ print(f" pin {rec.get('pin') or '—'}")
313
+ print(f" tools {len(rec.get('tools') or {})}")
314
+ print(f" approved {rec.get('approved_at') or '—'}")
315
+ return 0
316
+
317
+
188
318
  def _approve(args) -> int:
189
319
  """`mcpgawk approve` — move the trusted baseline forward, deliberately."""
190
320
  path = history.default_path()
@@ -222,13 +352,76 @@ def _approve(args) -> int:
222
352
  return 0
223
353
 
224
354
 
355
+ # The paid capabilities, reachable as `mcpgawk <capability>` when gawk Platform is installed.
356
+ #
357
+ # ONE BINARY, on purpose (2026-07-26). Two reasons, both load-bearing:
358
+ # 1. `gawk` cannot be an executable name. It is GNU AWK — it owns /usr/bin/gawk across the
359
+ # Debian family and supplies /usr/bin/awk there through the alternatives system, so our own
360
+ # `gawk` on PATH risks breaking a machine's `awk`. Debian Policy §10.1 requires one of two
361
+ # colliding programs to be renamed; Homebrew's `gawk` formula is GNU awk, so the name is
362
+ # simply unavailable. Everyone who tried this retreated: ast-grep deprecated `sg`, fd ships
363
+ # as `fdfind`, bat as `batcat`.
364
+ # 2. A second binary for the paid tier is against convention anyway. Semgrep, Snyk, GitLab and
365
+ # Terraform all keep ONE command and unlock with a licence/login. A separate binary is only
366
+ # conventional when the paid thing is a different artefact (Docker Desktop). Ours isn't — it
367
+ # is more analysis on the same inputs.
368
+ #
369
+ # They are listed in `--help` even when unavailable: a free user should be able to SEE what the
370
+ # subscription adds without installing anything, and get one honest line if they try it.
371
+ PLATFORM_CAPABILITIES = {
372
+ "verify": "run a server in a no-egress sandbox and reproduce misbehaviour",
373
+ "enforce": "guard a live server, call by call",
374
+ "monitor": "watch a server for drift after you approved it",
375
+ "build": "generate a server from an OpenAPI spec (in development)",
376
+ }
377
+
378
+ _PLATFORM_UNAVAILABLE = (
379
+ "mcpgawk {cap}: {desc}.\n"
380
+ "This is a gawk Platform capability and it isn't installed in this environment.\n"
381
+ " £29/month, 7-day free trial — https://mcp.gawk.dev/pricing.html\n"
382
+ " Already subscribed? Your purchase email has the install instructions.\n"
383
+ "The free scanner (`mcpgawk scan`) stays free and open-source either way."
384
+ )
385
+
386
+
387
+ def _run_platform_capability(capability: str, rest: list[str]) -> int:
388
+ """Delegate to gawk Platform if it is installed, else say so honestly and exit 3.
389
+
390
+ The import is deliberately OPTIONAL and local: the free scanner is published to PyPI on its
391
+ own and must never depend on, or ship, the paid engine. A paid install supersedes the free
392
+ distribution in place (same distribution name), which is the GitLab CE→EE shape.
393
+ """
394
+ try:
395
+ from gawk_platform.cli import run_pillar
396
+ except ImportError:
397
+ print(
398
+ _PLATFORM_UNAVAILABLE.format(capability=capability, cap=capability,
399
+ desc=PLATFORM_CAPABILITIES[capability]),
400
+ file=sys.stderr,
401
+ )
402
+ return 3 # the same "not licensed / not available" code every paid entry point returns
403
+ return run_pillar(capability, rest)
404
+
405
+
225
406
  def main(argv: list[str] | None = None) -> int:
407
+ # Intercept the paid capabilities BEFORE argparse: their arguments are the pillar's own, and
408
+ # the free parser must not try to validate them.
409
+ raw = list(sys.argv[1:] if argv is None else argv)
410
+ if raw and raw[0] in PLATFORM_CAPABILITIES:
411
+ return _run_platform_capability(raw[0], raw[1:])
412
+
226
413
  p = build_parser()
227
414
  args = p.parse_args(argv)
228
415
 
416
+ if args.cmd == "baseline":
417
+ return _baseline(args)
418
+
229
419
  if args.cmd == "approve":
230
420
  return _approve(args)
231
421
 
422
+ if args.cmd == "skills":
423
+ return _skills(args)
424
+
232
425
  # No args at all is VALID: it means "discover and scan everything on this machine". _run handles
233
426
  # the nothing-found message and default-deny consent before launching any discovered stdio server.
234
427
  snaps, entries, skipped = asyncio.run(_run(args))
@@ -28,6 +28,12 @@ LABEL_SCHEMA = "mcpgawk/label@0.1"
28
28
  #: thing.
29
29
  _SIGNAL_LEAD_BY_KIND = {
30
30
  "shadowing:cross-server-reference": "cross-server tool reference from",
31
+ "skill:download-url": "suspicious download URL in",
32
+ "skill:piped-exec": "fetch-and-execute pattern in",
33
+ "skill:runtime-fetch": "runtime instruction fetch in",
34
+ "skill:credential-emission": "credential-emission instruction in",
35
+ "skill:secret-hardcoded": "hardcoded secret in",
36
+ "skill:malformed": "unreviewable skill structure in",
31
37
  }
32
38
 
33
39
  _SIGNAL_LEAD = {
@@ -38,6 +44,8 @@ _SIGNAL_LEAD = {
38
44
  # Obfuscation is its OWN class, not a flavour of injection (Invariant separates them too: W021
39
45
  # vs E001). Hiding text is evidence of intent; what it hides is reported by its own detector.
40
46
  "obfuscation": "text hidden with invisible characters in",
47
+ # Agent-skill findings (SKILL.md trees) — surfaced by `mcpgawk skills`, not the server scan.
48
+ "skill": "risky skill content in",
41
49
  }
42
50
 
43
51