@whisper-security/whisper-mcp 0.242.0 → 0.250.0
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.
- package/README.md +8 -5
- package/bin/whisper-mcp.js +118 -25
- package/package.json +19 -2
package/README.md
CHANGED
|
@@ -47,11 +47,14 @@ investigation procedures).
|
|
|
47
47
|
|
|
48
48
|
## What this package does
|
|
49
49
|
|
|
50
|
-
It resolves the `whisper` binary for your platform from the GitHub release
|
|
51
|
-
|
|
52
|
-
and runs `whisper mcp`.
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
It resolves the `whisper` binary for your platform from the GitHub release this package
|
|
51
|
+
pins, checks it against the sha256 digest written into this package when it was published,
|
|
52
|
+
caches it, and runs `whisper mcp`. An npm version cannot change, so a given version of this
|
|
53
|
+
package always resolves to the same bytes: an asset replaced under the release tag later does
|
|
54
|
+
not match the pin and is refused, never run. The verified digest is recorded beside the binary
|
|
55
|
+
and re-checked on every run, so a cached copy that no longer matches it is never executed.
|
|
56
|
+
|
|
57
|
+
`npx @whisper-security/whisper-mcp --version` prints this package's version and the CLI's.
|
|
55
58
|
|
|
56
59
|
Already have the CLI (`brew install whisper-sec/tap/whisper`, `apt install whisper`,
|
|
57
60
|
`scoop install whisper`)? Then `whisper mcp` is the same server and you do not need this
|
package/bin/whisper-mcp.js
CHANGED
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
* which presents as "the server does not work" with nothing in any log. Every diagnostic
|
|
13
13
|
* here goes to stderr, deliberately and without exception.
|
|
14
14
|
*
|
|
15
|
-
* TRUST. The binary is fetched from the GitHub release
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* TRUST. The binary is fetched from the GitHub release this package pins and checked against
|
|
16
|
+
* the sha256 digest written into this package's own package.json (`whisperCliDigests`) when
|
|
17
|
+
* it was published. An npm version is immutable, so a given version of this package always
|
|
18
|
+
* resolves to the same bytes, even if somebody later replaces an asset under the release tag:
|
|
19
|
+
* the replaced file does not match the pin and is refused. The `.sha256` published beside the
|
|
20
|
+
* asset is read only to explain a mismatch. The verified digest is recorded next to the binary
|
|
21
|
+
* and re-checked on EVERY run, so a cached copy that no longer matches it is never run.
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
24
|
const fs = require('fs');
|
|
@@ -33,6 +36,13 @@ const PKG = require('../package.json');
|
|
|
33
36
|
*/
|
|
34
37
|
const CLI_VERSION = PKG.whisperCliVersion || PKG.version;
|
|
35
38
|
const VERSION = CLI_VERSION;
|
|
39
|
+
/*
|
|
40
|
+
* The per-asset digests of that release, pinned at publish time from its checksums.txt by
|
|
41
|
+
* pin-digests.js. A package without them (a local checkout, never a published one: the pack
|
|
42
|
+
* refuses it) falls back to the `.sha256` beside the asset, which proves the download arrived
|
|
43
|
+
* intact but not that the asset is the one that was released.
|
|
44
|
+
*/
|
|
45
|
+
const PINS = PKG.whisperCliDigests || null;
|
|
36
46
|
const REPO = 'whisper-sec/whisper-cli';
|
|
37
47
|
const BASE = `https://github.com/${REPO}/releases/download/v${CLI_VERSION}`;
|
|
38
48
|
|
|
@@ -67,6 +77,58 @@ const TARGETS = {
|
|
|
67
77
|
'win32-arm64': 'whisper-windows-arm64.exe',
|
|
68
78
|
};
|
|
69
79
|
|
|
80
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The digest this package pins for an asset, or null when the package pins none. A package that
|
|
84
|
+
* pins digests but not THIS one, or pins something that is not a sha256, was packed wrong, and
|
|
85
|
+
* running an unpinned binary from it would be the exact hole the pins close, so that is fatal.
|
|
86
|
+
*/
|
|
87
|
+
function pinnedDigest(pins, asset) {
|
|
88
|
+
if (!pins) return null;
|
|
89
|
+
const d = typeof pins[asset] === 'string' ? pins[asset].toLowerCase() : '';
|
|
90
|
+
if (!HEX64.test(d)) {
|
|
91
|
+
die(
|
|
92
|
+
`this package pins no valid sha256 for ${asset}, so the download cannot be verified.`,
|
|
93
|
+
` Nothing was executed. Please report this at https://github.com/${REPO}/issues`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return d;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Judge downloaded bytes against the pin. Returns null when they match, or the message and the
|
|
101
|
+
* hint to die with. `published` is the `.sha256` beside the asset, fetched only now, to say
|
|
102
|
+
* WHICH way it went wrong: a published checksum that names the bytes we got means the asset was
|
|
103
|
+
* replaced under the tag after this package was published; one that names the pin means the
|
|
104
|
+
* bytes were damaged or substituted on the way here.
|
|
105
|
+
*/
|
|
106
|
+
function judgePinned(got, pinned, published) {
|
|
107
|
+
if (got === pinned) return null;
|
|
108
|
+
let why;
|
|
109
|
+
if (published === got) {
|
|
110
|
+
why =
|
|
111
|
+
` The release now publishes a checksum for these new bytes, so the asset was REPLACED\n` +
|
|
112
|
+
` after this package version was published. This package will only run the bytes it pins.`;
|
|
113
|
+
} else if (published === pinned) {
|
|
114
|
+
why = ` The release still publishes the pinned digest, so the download was damaged or substituted on the way.`;
|
|
115
|
+
} else {
|
|
116
|
+
why = ` The checksum published beside the asset matches neither.`;
|
|
117
|
+
}
|
|
118
|
+
return [
|
|
119
|
+
'CHECKSUM MISMATCH - refusing to run the downloaded binary.',
|
|
120
|
+
` pinned by this package ${pinned}\n downloaded ${got}\n` +
|
|
121
|
+
(published ? ` published beside it ${published}\n` : '') +
|
|
122
|
+
`${why}\n Nothing was executed. Please report this at https://github.com/${REPO}/issues`,
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The first field of a "<hex> <name>" checksum file, lowercased; '' when it is not a digest. */
|
|
127
|
+
function parseSum(txt) {
|
|
128
|
+
const d = String(txt).trim().split(/\s+/)[0].toLowerCase();
|
|
129
|
+
return HEX64.test(d) ? d : '';
|
|
130
|
+
}
|
|
131
|
+
|
|
70
132
|
function assetName() {
|
|
71
133
|
const key = `${process.platform}-${process.arch}`;
|
|
72
134
|
const asset = TARGETS[key];
|
|
@@ -131,9 +193,11 @@ async function ensureBinary() {
|
|
|
131
193
|
// execution inside a process holding WHISPER_API_KEY in its environment. On a laptop that
|
|
132
194
|
// path is private; under a shared XDG_CACHE_HOME or on CI it is not, and the check costs
|
|
133
195
|
// about a tenth of a second.
|
|
196
|
+
const pinned = pinnedDigest(PINS, asset);
|
|
134
197
|
if (fs.existsSync(exe) && fs.existsSync(stamp)) {
|
|
135
198
|
const recorded = fs.readFileSync(stamp, 'utf8').trim().slice(0, 64);
|
|
136
|
-
|
|
199
|
+
// With a pin, the cached copy must be the pinned bytes, not merely the bytes recorded.
|
|
200
|
+
if (HEX64.test(recorded) && (!pinned || recorded === pinned) && sha256(fs.readFileSync(exe)) === recorded) {
|
|
137
201
|
return exe;
|
|
138
202
|
}
|
|
139
203
|
note('the cached binary no longer matches its recorded digest; fetching a clean copy');
|
|
@@ -149,23 +213,32 @@ async function ensureBinary() {
|
|
|
149
213
|
}
|
|
150
214
|
|
|
151
215
|
note(`fetching the Whisper CLI ${VERSION} for ${process.platform}-${process.arch} (first run only)`);
|
|
152
|
-
const
|
|
153
|
-
fetchOrDie(`${BASE}/${asset}`, 'the binary'),
|
|
154
|
-
fetchOrDie(`${BASE}/${asset}.sha256`, 'its checksum'),
|
|
155
|
-
]);
|
|
156
|
-
|
|
157
|
-
// "<hex> <name>" - take the first field and nothing else.
|
|
158
|
-
const want = String(sumTxt).trim().split(/\s+/)[0].toLowerCase();
|
|
216
|
+
const bin = await fetchOrDie(`${BASE}/${asset}`, 'the binary');
|
|
159
217
|
const got = sha256(bin);
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
218
|
+
if (pinned) {
|
|
219
|
+
if (got !== pinned) {
|
|
220
|
+
// Best effort: the explanation must never mask the refusal, so a failed fetch is ''.
|
|
221
|
+
let published = '';
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch(`${BASE}/${asset}.sha256`, { redirect: 'follow', signal: AbortSignal.timeout(20_000) });
|
|
224
|
+
if (res.ok) published = parseSum(await res.text());
|
|
225
|
+
} catch (_) {
|
|
226
|
+
published = '';
|
|
227
|
+
}
|
|
228
|
+
die(...judgePinned(got, pinned, published));
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
const want = parseSum(await fetchOrDie(`${BASE}/${asset}.sha256`, 'its checksum'));
|
|
232
|
+
if (!want) {
|
|
233
|
+
die('the published checksum is not a sha256 digest, so the download cannot be verified.');
|
|
234
|
+
}
|
|
235
|
+
if (got !== want) {
|
|
236
|
+
die(
|
|
237
|
+
'CHECKSUM MISMATCH - refusing to run the downloaded binary.',
|
|
238
|
+
` expected ${want}\n got ${got}\n` +
|
|
239
|
+
` Nothing was executed. Please report this at https://github.com/${REPO}/issues`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
169
242
|
}
|
|
170
243
|
|
|
171
244
|
// 0o700 on the directory too, not just the file: a group-writable cache directory hands the
|
|
@@ -181,11 +254,26 @@ async function ensureBinary() {
|
|
|
181
254
|
return exe;
|
|
182
255
|
}
|
|
183
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The CLI arguments for what the caller typed. The server is the default, so everything is
|
|
259
|
+
* passed to `whisper mcp`, with one exception: `--version` asks which CLI this package runs,
|
|
260
|
+
* and `whisper mcp --version` is an unknown flag (#1624). That one goes straight to the CLI.
|
|
261
|
+
* `--help` keeps the prefix on purpose: `whisper mcp --help` is the help for the server, which
|
|
262
|
+
* is what someone running whisper-mcp is asking for.
|
|
263
|
+
*/
|
|
264
|
+
function cliArgs(argv) {
|
|
265
|
+
if (argv.length === 1 && argv[0] === '--version') return ['--version'];
|
|
266
|
+
return ['mcp', ...argv];
|
|
267
|
+
}
|
|
268
|
+
|
|
184
269
|
async function main() {
|
|
270
|
+
const argv = process.argv.slice(2);
|
|
271
|
+
const args = cliArgs(argv);
|
|
272
|
+
if (args[0] === '--version') {
|
|
273
|
+
// Not the MCP stream, so stdout is right here: this is what a person or a script reads.
|
|
274
|
+
process.stdout.write(`whisper-mcp ${PKG.version} (runs the Whisper CLI ${CLI_VERSION})\n`);
|
|
275
|
+
}
|
|
185
276
|
const exe = await ensureBinary();
|
|
186
|
-
// Default to the MCP server; anything the caller passes is appended, so
|
|
187
|
-
// `npx @whisper-security/whisper-mcp --help` and the bare form both do the right thing.
|
|
188
|
-
const args = ['mcp', ...process.argv.slice(2)];
|
|
189
277
|
|
|
190
278
|
const child = spawn(exe, args, { stdio: 'inherit', env: process.env });
|
|
191
279
|
|
|
@@ -211,4 +299,9 @@ async function main() {
|
|
|
211
299
|
});
|
|
212
300
|
}
|
|
213
301
|
|
|
214
|
-
|
|
302
|
+
// Required as a module (by pin-digests.js and the tests) it only exports; run, it runs.
|
|
303
|
+
if (require.main === module) {
|
|
304
|
+
main().catch((e) => die(e && e.stack ? e.stack : String(e)));
|
|
305
|
+
} else {
|
|
306
|
+
module.exports = { TARGETS, HEX64, cliArgs, judgePinned, parseSum, pinnedDigest };
|
|
307
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whisper-security/whisper-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.250.0",
|
|
4
4
|
"mcpName": "io.github.whisper-sec/whisper",
|
|
5
|
-
"whisperCliVersion": "0.
|
|
5
|
+
"whisperCliVersion": "0.250.0",
|
|
6
6
|
"description": "Give an AI agent a real, routable IPv6 identity, verifiable egress and DNS policy. Keyless: verify any agent's identity and query the infrastructure graph. With a key: register, govern and route agents.",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"mcp",
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"bin": {
|
|
32
32
|
"whisper-mcp": "bin/whisper-mcp.js"
|
|
33
33
|
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"prepack": "node pin-digests.js --check",
|
|
36
|
+
"test": "node --test test/wrapper.test.js"
|
|
37
|
+
},
|
|
34
38
|
"files": [
|
|
35
39
|
"bin/whisper-mcp.js",
|
|
36
40
|
"README.md",
|
|
@@ -41,5 +45,18 @@
|
|
|
41
45
|
},
|
|
42
46
|
"publishConfig": {
|
|
43
47
|
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"whisperCliDigests": {
|
|
50
|
+
"whisper-darwin-amd64": "29a5464ca9ffe17be34daf21434a8b39200ed3401af77acb85c3d5c08eb21daf",
|
|
51
|
+
"whisper-darwin-arm64": "8d4f47d562b7cfb0ce42609f91da423e9f9444ce5153c65ecb48067aaae20801",
|
|
52
|
+
"whisper-linux-386": "f260dd1ff1faee7829da9e390309909ede0345c1daea113dbff5a54d7ba55a30",
|
|
53
|
+
"whisper-linux-amd64": "48e408205f5001eeb852b66cea57401a829839fcd079d0df6545891b626f72d4",
|
|
54
|
+
"whisper-linux-arm": "452dd96e0812d1e9daacaf5abe361d51694253d91a3bc4d968246b6a2d38c4ca",
|
|
55
|
+
"whisper-linux-arm64": "b6e211c490a918885787e5a49e0439c2aea3fb941d38974568fe6af356851d04",
|
|
56
|
+
"whisper-linux-mips": "bca68da0f87f81ad966076b86754409ee8b9c1991e0ec73ffee83affb3412877",
|
|
57
|
+
"whisper-linux-mipsle": "9d84c297d783e506dcc56db5f6f3cf7e291d331cf1dd195a53c466c418c69cc8",
|
|
58
|
+
"whisper-linux-riscv64": "8dd1194b6536f17d4fa8e1252d0082996a491dffbd060f6a8b984afbfddc3133",
|
|
59
|
+
"whisper-windows-amd64.exe": "a94dbd418ce90ac643f64288ba0b384d3780bdc718c1c9592d13dd6ae70bf130",
|
|
60
|
+
"whisper-windows-arm64.exe": "450ae61200d9cfe78b7dddd45424373bb3a4317a7fe830d6c8e74f777c154da0"
|
|
44
61
|
}
|
|
45
62
|
}
|