mcpgawk 0.1.7__tar.gz → 0.1.8__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.
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/.github/workflows/publish-pypi.yml +13 -1
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/CHANGELOG.md +42 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/PKG-INFO +1 -1
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/pyproject.toml +1 -1
- mcpgawk-0.1.8/src/mcpgawk/baseline.py +155 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/cli.py +124 -1
- mcpgawk-0.1.8/tests/test_baseline_spine.py +108 -0
- mcpgawk-0.1.8/tests/test_free_cli_platform_hint.py +70 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/.github/workflows/ci.yml +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/.github/workflows/mcpgawk-gate-selftest.yml +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/.gitignore +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/BRAND.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/CONTRIBUTING.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/LICENSE +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/NOTICE +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/README.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/SECURITY.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/THREAT-MODEL.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/action.yml +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/demo.gif +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/favicon.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/identity-board.html +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/nativerse-mark.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/social-preview.png +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/social-preview.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/wordmark-dark.png +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/wordmark-dark.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/wordmark-light.png +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/wordmark-light.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/.gitignore +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/CNAME +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/api/lead.js +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/brand/favicon.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/brand/nativerse-mark.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/brand/social-preview.png +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/brand/social-preview.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/alibaba.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/anthropic.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/baidu.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/cerebras.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/cloudflare.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/cohere.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/copilot.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/cursor.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/deepseek.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/discord.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/docker.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/figma.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/framer.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/gemini.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/github.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/gitlab.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/gojek.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/google.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/grab.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/grok.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/huawei.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/huggingface.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/inflection.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/infosys.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/kakaotalk.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/kimi.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/klarna.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/langchain.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/line.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/linear.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/mercadolibre.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/meta.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/microsoft.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/midjourney.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/mistral.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/naver.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/netlify.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/notion.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/nubank.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/nvidia.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/ollama.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/openai.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/paytm.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/perplexity.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/postgresql.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/python.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/qwen.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/rakuten.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/replicate.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/replit.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/revolut.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/runway.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/sap.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/sentry.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/shopee.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/slack.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/sony.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/spotify.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/stability.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/stripe.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/suno.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/supabase.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/swiggy.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/tiktok.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/togetherai.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/vercel.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/wechat.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/weibo.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/wise.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/xai.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/xiaomi.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/zoho.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/zomato.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/index.html +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/logofield.js +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/report.html +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/styles.css +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/examples/mcp.json +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/scripts/ci_gate.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/__init__.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/__main__.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/ambient.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/consent.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/discover.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/drift.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/fingerprint.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/fleet.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/grade.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/history.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/label.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/mcp_server.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/measure.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/oauth_login.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/oauth_scopes.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/probe.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/redact.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/servercard.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/signals.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/supplychain.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/transport.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/supabase/leads.sql +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/supabase/lockdown.sql +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_ambient.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_cli_integration.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_drift.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_drift_invariants.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_grade.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_label_axes.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_layer_invariants.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_mcp_server.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_measure.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_no_egress.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_oauth_scopes.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_servercard.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_signals.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_supplychain.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_thin_guidance.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.8}/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
|
-
|
|
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.
|
|
3
|
+
Version: 0.1.8
|
|
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
|
+
version = "0.1.8"
|
|
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(
|
|
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="?",
|
|
@@ -185,6 +206,48 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
185
206
|
return p
|
|
186
207
|
|
|
187
208
|
|
|
209
|
+
def _baseline(args) -> int:
|
|
210
|
+
"""Print what the operator has agreed to trust — the spine other pillars read.
|
|
211
|
+
|
|
212
|
+
Only approved state is exported. A sighting is not a baseline: printing the last thing seen
|
|
213
|
+
would hand verify and monitor the poisoned surface as though it were trusted, which is the
|
|
214
|
+
exact failure ADR-0012 exists to prevent.
|
|
215
|
+
"""
|
|
216
|
+
from . import baseline as _baseline_mod
|
|
217
|
+
|
|
218
|
+
data = _baseline_mod.export(getattr(args, "history", None))
|
|
219
|
+
servers = data["servers"]
|
|
220
|
+
|
|
221
|
+
if args.server:
|
|
222
|
+
key = _baseline_mod.resolve(args.server, getattr(args, "history", None))
|
|
223
|
+
if key is None or key not in servers:
|
|
224
|
+
print(f"mcpgawk: nothing approved for '{args.server}'. "
|
|
225
|
+
f"Run `mcpgawk scan`, then `mcpgawk approve {args.server}`.", file=sys.stderr)
|
|
226
|
+
return 2
|
|
227
|
+
servers = {key: servers[key]}
|
|
228
|
+
data = {**data, "servers": servers}
|
|
229
|
+
|
|
230
|
+
if args.json:
|
|
231
|
+
print(json.dumps(data, indent=2, sort_keys=True))
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
if not servers:
|
|
235
|
+
print("Nothing approved yet. Run `mcpgawk scan`, then `mcpgawk approve <server>` to set "
|
|
236
|
+
"the baseline that verify and monitor will compare against.")
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
print(f"Approved baseline — {len(servers)} server(s). "
|
|
240
|
+
f"verify and monitor compare against exactly this.\n")
|
|
241
|
+
for key in sorted(servers):
|
|
242
|
+
rec = servers[key]
|
|
243
|
+
alias = f" ({', '.join(rec['aliases'])})" if rec.get("aliases") else ""
|
|
244
|
+
print(f" {key}{alias}")
|
|
245
|
+
print(f" pin {rec.get('pin') or '—'}")
|
|
246
|
+
print(f" tools {len(rec.get('tools') or {})}")
|
|
247
|
+
print(f" approved {rec.get('approved_at') or '—'}")
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
188
251
|
def _approve(args) -> int:
|
|
189
252
|
"""`mcpgawk approve` — move the trusted baseline forward, deliberately."""
|
|
190
253
|
path = history.default_path()
|
|
@@ -222,10 +285,70 @@ def _approve(args) -> int:
|
|
|
222
285
|
return 0
|
|
223
286
|
|
|
224
287
|
|
|
288
|
+
# The paid capabilities, reachable as `mcpgawk <capability>` when gawk Platform is installed.
|
|
289
|
+
#
|
|
290
|
+
# ONE BINARY, on purpose (2026-07-26). Two reasons, both load-bearing:
|
|
291
|
+
# 1. `gawk` cannot be an executable name. It is GNU AWK — it owns /usr/bin/gawk across the
|
|
292
|
+
# Debian family and supplies /usr/bin/awk there through the alternatives system, so our own
|
|
293
|
+
# `gawk` on PATH risks breaking a machine's `awk`. Debian Policy §10.1 requires one of two
|
|
294
|
+
# colliding programs to be renamed; Homebrew's `gawk` formula is GNU awk, so the name is
|
|
295
|
+
# simply unavailable. Everyone who tried this retreated: ast-grep deprecated `sg`, fd ships
|
|
296
|
+
# as `fdfind`, bat as `batcat`.
|
|
297
|
+
# 2. A second binary for the paid tier is against convention anyway. Semgrep, Snyk, GitLab and
|
|
298
|
+
# Terraform all keep ONE command and unlock with a licence/login. A separate binary is only
|
|
299
|
+
# conventional when the paid thing is a different artefact (Docker Desktop). Ours isn't — it
|
|
300
|
+
# is more analysis on the same inputs.
|
|
301
|
+
#
|
|
302
|
+
# They are listed in `--help` even when unavailable: a free user should be able to SEE what the
|
|
303
|
+
# subscription adds without installing anything, and get one honest line if they try it.
|
|
304
|
+
PLATFORM_CAPABILITIES = {
|
|
305
|
+
"verify": "run a server in a no-egress sandbox and reproduce misbehaviour",
|
|
306
|
+
"enforce": "guard a live server, call by call",
|
|
307
|
+
"monitor": "watch a server for drift after you approved it",
|
|
308
|
+
"build": "generate a server from an OpenAPI spec (in development)",
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_PLATFORM_UNAVAILABLE = (
|
|
312
|
+
"mcpgawk {cap}: {desc}.\n"
|
|
313
|
+
"This is a gawk Platform capability and it isn't installed in this environment.\n"
|
|
314
|
+
" £29/month, 7-day free trial — https://mcp.gawk.dev/pricing.html\n"
|
|
315
|
+
" Already subscribed? Your purchase email has the install instructions.\n"
|
|
316
|
+
"The free scanner (`mcpgawk scan`) stays free and open-source either way."
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _run_platform_capability(capability: str, rest: list[str]) -> int:
|
|
321
|
+
"""Delegate to gawk Platform if it is installed, else say so honestly and exit 3.
|
|
322
|
+
|
|
323
|
+
The import is deliberately OPTIONAL and local: the free scanner is published to PyPI on its
|
|
324
|
+
own and must never depend on, or ship, the paid engine. A paid install supersedes the free
|
|
325
|
+
distribution in place (same distribution name), which is the GitLab CE→EE shape.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
from gawk_platform.cli import run_pillar
|
|
329
|
+
except ImportError:
|
|
330
|
+
print(
|
|
331
|
+
_PLATFORM_UNAVAILABLE.format(capability=capability, cap=capability,
|
|
332
|
+
desc=PLATFORM_CAPABILITIES[capability]),
|
|
333
|
+
file=sys.stderr,
|
|
334
|
+
)
|
|
335
|
+
return 3 # the same "not licensed / not available" code every paid entry point returns
|
|
336
|
+
return run_pillar(capability, rest)
|
|
337
|
+
|
|
338
|
+
|
|
225
339
|
def main(argv: list[str] | None = None) -> int:
|
|
340
|
+
# Intercept the paid capabilities BEFORE argparse: their arguments are the pillar's own, and
|
|
341
|
+
# the free parser must not try to validate them.
|
|
342
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
343
|
+
if raw and raw[0] in PLATFORM_CAPABILITIES:
|
|
344
|
+
return _run_platform_capability(raw[0], raw[1:])
|
|
345
|
+
|
|
226
346
|
p = build_parser()
|
|
227
347
|
args = p.parse_args(argv)
|
|
228
348
|
|
|
349
|
+
if args.cmd == "baseline":
|
|
350
|
+
return _baseline(args)
|
|
351
|
+
|
|
229
352
|
if args.cmd == "approve":
|
|
230
353
|
return _approve(args)
|
|
231
354
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""One baseline, read by every pillar.
|
|
2
|
+
|
|
3
|
+
The product kept three memories of "what did this server look like when I trusted it":
|
|
4
|
+
history.py (scan), packages/verify/src/pins.ts (verify) and monitor/store.py (the daemon). You
|
|
5
|
+
could approve a server in one and still be told it had drifted by another — which is why no flow
|
|
6
|
+
felt joined up. This is the shared view they all read.
|
|
7
|
+
|
|
8
|
+
Deliberately a NARROW VIEW over history.json rather than a fourth file: a new store would need
|
|
9
|
+
migrating into, keeping in sync, and would itself become another memory to disagree with.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from mcpgawk import baseline, history
|
|
21
|
+
|
|
22
|
+
REC = {
|
|
23
|
+
"measured_at": "2026-07-27T10:00:00Z",
|
|
24
|
+
"pin": "abc123def4567890",
|
|
25
|
+
"tools": {"read_file": "9c98141c4c35", "write_file": "aac5a0de16f1"},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def store(tmp_path: Path) -> str:
|
|
31
|
+
return str(tmp_path / "history.json")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_nothing_is_approved_until_someone_approves_it(store):
|
|
35
|
+
history.record("srv", REC, path=store)
|
|
36
|
+
# First sighting is trust-on-first-use in scan, so this IS approved; a second, different
|
|
37
|
+
# sighting must NOT move it.
|
|
38
|
+
history.record("srv", {**REC, "pin": "ffffffffffffffff"}, path=store)
|
|
39
|
+
assert baseline.approved_pin("srv", store) == REC["pin"], "a sighting is not an approval"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_approve_moves_the_shared_baseline(store):
|
|
43
|
+
history.record("srv", REC, path=store)
|
|
44
|
+
moved = {**REC, "pin": "ffffffffffffffff", "tools": {"read_file": "111111111111"}}
|
|
45
|
+
history.record("srv", moved, path=store)
|
|
46
|
+
history.approve("srv", path=store)
|
|
47
|
+
assert baseline.approved_pin("srv", store) == "ffffffffffffffff"
|
|
48
|
+
assert baseline.approved_tools("srv", store) == {"read_file": "111111111111"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_a_name_the_user_typed_resolves_to_the_key_it_is_stored_under(store):
|
|
52
|
+
"""Identity key and configured name are different strings, and the same server is routinely
|
|
53
|
+
configured under different names in different tools. Without this the shared baseline silently
|
|
54
|
+
does nothing for half the fleet."""
|
|
55
|
+
history.record("mcp:abc123", REC, path=store, alias="slack")
|
|
56
|
+
assert baseline.resolve("slack", store) == "mcp:abc123"
|
|
57
|
+
assert baseline.resolve("mcp:abc123", store) == "mcp:abc123"
|
|
58
|
+
assert baseline.resolve("never-heard-of-it", store) is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_export_carries_only_approved_state(store):
|
|
62
|
+
"""A sighting must never cross the boundary: handing verify the last thing SEEN would give it
|
|
63
|
+
the poisoned surface as though it were trusted."""
|
|
64
|
+
history.record("approved-one", REC, path=store)
|
|
65
|
+
history.approve("approved-one", path=store)
|
|
66
|
+
|
|
67
|
+
raw = json.loads(Path(store).read_text())
|
|
68
|
+
raw["servers"]["seen-only"] = {"seen": [REC]} # sighted, never approved
|
|
69
|
+
Path(store).write_text(json.dumps(raw))
|
|
70
|
+
|
|
71
|
+
data = baseline.export(store)
|
|
72
|
+
assert data["schema"] == baseline.SCHEMA
|
|
73
|
+
assert "approved-one" in data["servers"]
|
|
74
|
+
assert "seen-only" not in data["servers"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_never_approved_is_none_not_empty(store):
|
|
78
|
+
"""'Never approved' and 'approved as empty' must not collapse: the second reports drift on a
|
|
79
|
+
server the operator has never looked at."""
|
|
80
|
+
assert baseline.approved_pin("unknown", store) is None
|
|
81
|
+
assert baseline.approved_tools("unknown", store) == {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_the_cli_emits_the_cross_runtime_shape(store, tmp_path):
|
|
85
|
+
"""`mcpgawk baseline --json` is the contract verify reads. If this shape moves, verify breaks."""
|
|
86
|
+
history.record("srv", REC, path=store)
|
|
87
|
+
history.approve("srv", path=store)
|
|
88
|
+
|
|
89
|
+
r = subprocess.run(
|
|
90
|
+
[sys.executable, "-m", "mcpgawk.cli", "baseline", "--json"],
|
|
91
|
+
capture_output=True, text=True, timeout=60,
|
|
92
|
+
env={"PATH": "/usr/bin:/bin", "MCPGAWK_HISTORY": store, "HOME": str(tmp_path)},
|
|
93
|
+
)
|
|
94
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
95
|
+
data = json.loads(r.stdout)
|
|
96
|
+
assert data["schema"] == "gawk.baseline/1"
|
|
97
|
+
assert data["servers"]["srv"]["tools"] == REC["tools"]
|
|
98
|
+
assert data["servers"]["srv"]["pin"] == REC["pin"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_asking_for_an_unapproved_server_fails_loudly(store, tmp_path):
|
|
102
|
+
r = subprocess.run(
|
|
103
|
+
[sys.executable, "-m", "mcpgawk.cli", "baseline", "--server", "nope"],
|
|
104
|
+
capture_output=True, text=True, timeout=60,
|
|
105
|
+
env={"PATH": "/usr/bin:/bin", "MCPGAWK_HISTORY": store, "HOME": str(tmp_path)},
|
|
106
|
+
)
|
|
107
|
+
assert r.returncode == 2
|
|
108
|
+
assert "nothing approved" in r.stderr.lower()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""What a FREE install does when someone types a paid capability.
|
|
2
|
+
|
|
3
|
+
Public-safe by construction: this file must never import gawk_platform, because it is synced into
|
|
4
|
+
the public mcpgawk repo where the paid engine does not exist. It covers the path every free user
|
|
5
|
+
can hit — `mcpgawk verify ...` with nothing but the free scanner installed.
|
|
6
|
+
|
|
7
|
+
The shape it locks: ONE binary. `gawk` cannot be an executable name (it is GNU AWK, which owns
|
|
8
|
+
/usr/bin/gawk across the Debian family and supplies /usr/bin/awk there via the alternatives
|
|
9
|
+
system), and one-command-plus-licence-unlock is the convention for this product type — Semgrep,
|
|
10
|
+
Snyk, GitLab, Terraform all do it. So the paid capabilities live under `mcpgawk`, and a free user
|
|
11
|
+
must get ONE honest line, never an ImportError traceback and never a silent success.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import builtins
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from mcpgawk import cli as free_cli
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def no_platform(monkeypatch):
|
|
24
|
+
"""Simulate a free-only install even when the paid engine happens to be importable."""
|
|
25
|
+
real_import = builtins.__import__
|
|
26
|
+
|
|
27
|
+
def _import(name, *a, **kw):
|
|
28
|
+
if name.startswith("gawk_platform"):
|
|
29
|
+
raise ImportError("No module named 'gawk_platform'")
|
|
30
|
+
return real_import(name, *a, **kw)
|
|
31
|
+
|
|
32
|
+
monkeypatch.setattr(builtins, "__import__", _import)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_paid_capabilities_are_advertised_in_free_help():
|
|
36
|
+
"""A free user should SEE what the subscription adds without installing anything."""
|
|
37
|
+
help_text = free_cli.build_parser().format_help()
|
|
38
|
+
for capability in free_cli.PLATFORM_CAPABILITIES:
|
|
39
|
+
assert capability in help_text
|
|
40
|
+
assert "gawk Platform" in help_text
|
|
41
|
+
assert "pricing" in help_text
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("capability", ["verify", "enforce", "monitor", "build"])
|
|
45
|
+
def test_paid_capability_on_a_free_install_exits_3_with_one_actionable_line(
|
|
46
|
+
capability, no_platform, capsys
|
|
47
|
+
):
|
|
48
|
+
rc = free_cli.main([capability, "whatever.json"])
|
|
49
|
+
|
|
50
|
+
assert rc == 3, "the same 'not available/not licensed' code every paid entry point returns"
|
|
51
|
+
err = capsys.readouterr().err
|
|
52
|
+
assert "gawk Platform" in err
|
|
53
|
+
assert "pricing.html" in err
|
|
54
|
+
assert "free" in err.lower(), "must reassure that the free scanner is unaffected"
|
|
55
|
+
assert "Traceback" not in err
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_the_hint_never_leaks_a_python_import_error(no_platform, capsys):
|
|
59
|
+
"""An ImportError reaching the user is the failure mode this replaced."""
|
|
60
|
+
free_cli.main(["verify"])
|
|
61
|
+
err = capsys.readouterr().err
|
|
62
|
+
assert "ImportError" not in err
|
|
63
|
+
assert "gawk_platform" not in err, "internal module names are not a customer-facing detail"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_free_scan_is_unaffected_by_the_paid_dispatch(no_platform):
|
|
67
|
+
"""The whole point of the free tier: scan needs no licence and no platform."""
|
|
68
|
+
with pytest.raises(SystemExit) as exc:
|
|
69
|
+
free_cli.main(["scan", "--help"])
|
|
70
|
+
assert exc.value.code == 0
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|