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.
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/.github/workflows/publish-pypi.yml +13 -1
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/CHANGELOG.md +42 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/PKG-INFO +1 -1
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/pyproject.toml +1 -1
- mcpgawk-0.1.9/src/mcpgawk/baseline.py +155 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/cli.py +194 -1
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/label.py +8 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/signals.py +252 -4
- mcpgawk-0.1.9/src/mcpgawk/skills.py +320 -0
- mcpgawk-0.1.9/tests/test_baseline_spine.py +108 -0
- mcpgawk-0.1.9/tests/test_canary_skills.py +36 -0
- mcpgawk-0.1.9/tests/test_free_cli_platform_hint.py +70 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_layer_invariants.py +10 -1
- mcpgawk-0.1.9/tests/test_skills.py +265 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/.github/workflows/ci.yml +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/.github/workflows/mcpgawk-gate-selftest.yml +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/.gitignore +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/BRAND.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/CONTRIBUTING.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/LICENSE +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/NOTICE +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/README.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/SECURITY.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/THREAT-MODEL.md +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/action.yml +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/demo.gif +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/favicon.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/identity-board.html +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/nativerse-mark.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/social-preview.png +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/social-preview.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/wordmark-dark.png +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/wordmark-dark.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/wordmark-light.png +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/assets/brand/wordmark-light.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/.gitignore +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/CNAME +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/api/lead.js +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/brand/favicon.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/brand/nativerse-mark.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/brand/social-preview.png +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/brand/social-preview.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/alibaba.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/anthropic.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/baidu.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/cerebras.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/cloudflare.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/cohere.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/copilot.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/cursor.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/deepseek.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/discord.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/docker.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/figma.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/framer.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/gemini.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/github.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/gitlab.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/gojek.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/google.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/grab.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/grok.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/huawei.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/huggingface.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/inflection.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/infosys.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/kakaotalk.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/kimi.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/klarna.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/langchain.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/line.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/linear.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/mercadolibre.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/meta.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/microsoft.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/midjourney.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/mistral.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/naver.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/netlify.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/notion.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/nubank.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/nvidia.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/ollama.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/openai.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/paytm.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/perplexity.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/postgresql.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/python.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/qwen.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/rakuten.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/replicate.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/replit.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/revolut.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/runway.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/sap.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/sentry.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/shopee.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/slack.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/sony.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/spotify.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/stability.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/stripe.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/suno.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/supabase.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/swiggy.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/tiktok.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/togetherai.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/vercel.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/wechat.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/weibo.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/wise.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/xai.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/xiaomi.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/zoho.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/assets/logos/zomato.svg +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/index.html +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/logofield.js +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/report.html +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/docs/styles.css +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/examples/mcp.json +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/scripts/ci_gate.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/__init__.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/__main__.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/ambient.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/consent.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/discover.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/drift.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/fingerprint.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/fleet.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/grade.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/history.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/mcp_server.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/measure.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/oauth_login.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/oauth_scopes.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/probe.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/redact.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/servercard.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/supplychain.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/src/mcpgawk/transport.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/supabase/leads.sql +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/supabase/lockdown.sql +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_ambient.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_cli_integration.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_drift.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_drift_invariants.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_grade.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_label_axes.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_mcp_server.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_measure.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_no_egress.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_oauth_scopes.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_servercard.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_signals.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_supplychain.py +0 -0
- {mcpgawk-0.1.7 → mcpgawk-0.1.9}/tests/test_thin_guidance.py +0 -0
- {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
|
-
|
|
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.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
|
+
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(
|
|
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
|
|