protect-mcp 0.9.3 → 0.9.5
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/CHANGELOG.md +49 -0
- package/README.md +21 -1
- package/dist/{chunk-DX7QOA3E.mjs → chunk-H332ZNJ6.mjs} +1 -1
- package/dist/{chunk-HPKHMVZX.mjs → chunk-WWPQNIVF.mjs} +34 -1
- package/dist/{claim-3EBRPP5U.mjs → claim-RIXFOQM7.mjs} +93 -16
- package/dist/cli.js +728 -311
- package/dist/cli.mjs +224 -12
- package/dist/hook-server.js +34 -1
- package/dist/hook-server.mjs +2 -2
- package/dist/index.js +34 -1
- package/dist/index.mjs +2 -2
- package/dist/sample-6LRP73ZD.mjs +99 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.5: replayable from scratch
|
|
4
|
+
|
|
5
|
+
The public demo film (legate.scopeblind.com/record) is now reproducible by
|
|
6
|
+
anyone in 60 seconds, and the CLI answers the first two commands every new
|
|
7
|
+
user types.
|
|
8
|
+
|
|
9
|
+
- **`sample`.** Seeds a clearly-labeled sample record (fresh Ed25519 keypair,
|
|
10
|
+
kid `sample-demo`): 8 decisions including one BLOCKED network call and two
|
|
11
|
+
payments ($0.02 x402-style per-call, $12.50 invoice), plus
|
|
12
|
+
`demo-tampered.jsonl` with one decision flipped after signing. The receipts
|
|
13
|
+
are real signed artifacts (same envelope and canonical-JSON preimage as the
|
|
14
|
+
gate's signer), so `record`, `claim --payment-under`, `verify-claim`, and
|
|
15
|
+
`anchor-record` work on the folder immediately. Refuses to touch an existing
|
|
16
|
+
record or signing key; `--force` overrides.
|
|
17
|
+
- **`--version` / `version` / `-V`.** Prints the installed version. Previously
|
|
18
|
+
these fell through to wrap-mode parsing and errored demanding a `--`
|
|
19
|
+
separator, which was the first thing many new users saw.
|
|
20
|
+
- **`help` word.** `npx protect-mcp help` prints usage (previously errored).
|
|
21
|
+
Help and version flags are now only recognized BEFORE the `--` separator, so
|
|
22
|
+
`protect-mcp -- node server.js --version` wraps that command as intended
|
|
23
|
+
instead of printing our help.
|
|
24
|
+
|
|
25
|
+
## 0.9.4: agent payments get receipts, records get heartbeats, anchors get names
|
|
26
|
+
|
|
27
|
+
The provenance layer reaches the agentic economy's payment rails (x402), the
|
|
28
|
+
record gains continuous completeness, and anchoring can carry an identity.
|
|
29
|
+
|
|
30
|
+
- **Payment receipts (x402 interop).** The gate now tags agent payments with a
|
|
31
|
+
signed `payment` capability, detected broadly across x402 wire shapes
|
|
32
|
+
(`paymentRequirements`, `X-PAYMENT`, EIP-3009 `transferWithAuthorization`)
|
|
33
|
+
and payment-shaped tools. Receipts carry minimum-disclosure payment facts:
|
|
34
|
+
amount (only when clearly readable in human units), asset, a HASHED recipient,
|
|
35
|
+
and the x402 scheme. `claim --no payment` proves no agent paid anything;
|
|
36
|
+
`claim --payment-under <cap>` proves every payment stayed under a cap, and an
|
|
37
|
+
amount the gate could not read counts as OVER (you cannot prove an amount you
|
|
38
|
+
could not read, so the claim cannot lie).
|
|
39
|
+
- **Record checkpoints (`anchor-record`).** Anchors the record's CURRENT
|
|
40
|
+
commitment (the same Merkle root a claim commits to, plus count and time
|
|
41
|
+
range) into the public log. Run it on a heartbeat and the record grows an
|
|
42
|
+
anchored history: a later claim whose root matches a checkpoint is provably
|
|
43
|
+
over the complete set as of that checkpoint. Skips when the record is
|
|
44
|
+
unchanged; writes a local `.protect-mcp-anchors.jsonl` history; only the
|
|
45
|
+
root, count, and time range leave the machine.
|
|
46
|
+
- **Pinned identity in the anchor loop.** `claim --anchor`, `anchor-record`,
|
|
47
|
+
and `verify-claim` now resolve the anchoring key against the public ScopeBlind
|
|
48
|
+
key directory: an enrolled key shows "anchored as <Org> (key pinned)", a
|
|
49
|
+
revoked key fails verification, an anonymous key points at enrollment. The
|
|
50
|
+
free anchor stays anonymous; the named identity is the paid upgrade.
|
|
51
|
+
|
|
3
52
|
## 0.9.3: the skeptic's tools sharpen
|
|
4
53
|
|
|
5
54
|
Two verification upgrades: the anchor check moves into the verifier, and the
|
package/README.md
CHANGED
|
@@ -185,6 +185,24 @@ npx protect-mcp claim --no net.egress --anchor
|
|
|
185
185
|
This is an accountable, position-blind attestation, not full zero-knowledge: it
|
|
186
186
|
reveals the shape, not the content.
|
|
187
187
|
|
|
188
|
+
## Try it in 60 seconds (no agent required)
|
|
189
|
+
|
|
190
|
+
Watch the two-minute film at [legate.scopeblind.com/record](https://legate.scopeblind.com/record), then replay it against your own copy:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npx protect-mcp sample # seed a labeled sample record (8 decisions: 1 blocked, 2 payments)
|
|
194
|
+
npx protect-mcp record # open it: signatures verified in your browser
|
|
195
|
+
|
|
196
|
+
npx protect-mcp claim --payment-under 100 --anchor --output payments-under-100.json
|
|
197
|
+
npx protect-mcp verify-claim payments-under-100.json
|
|
198
|
+
npx protect-mcp anchor-record
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Drop the generated `demo-tampered.jsonl` into the record page to watch a
|
|
202
|
+
post-signing edit get caught. `sample` refuses to touch an existing record, so
|
|
203
|
+
run it in an empty folder. When you are ready for the real thing, wire the gate
|
|
204
|
+
below and the same commands run against your agent's own record.
|
|
205
|
+
|
|
188
206
|
## Claude Code hook quickstart
|
|
189
207
|
|
|
190
208
|
```bash
|
|
@@ -368,12 +386,14 @@ To report a vulnerability, see [SECURITY.md](./SECURITY.md).
|
|
|
368
386
|
|---------|-------------|
|
|
369
387
|
| `serve` | Start the HTTP hook server for Claude Code (port 9377). `--enforce` runs the restraint self-test first; `--cedar <dir>` and `--policy <path>` select the policy. |
|
|
370
388
|
| `init` | Generate an Ed25519 keypair (`keys/gateway.json`), a config template, and a sample policy. |
|
|
389
|
+
| `sample` | Seed a clearly-labeled sample record (8 decisions: one blocked call, two payments; kid `sample-demo`) plus a tampered copy, so `record`, `claim`, `verify-claim`, and `anchor-record` are replayable from scratch before wiring an agent. Refuses to touch an existing record; `--force` overrides. |
|
|
371
390
|
| `wrap` | Print a protected MCP command or patch Claude Desktop MCP servers. Dry-run by default; use `--write` to update Claude Desktop config. |
|
|
372
391
|
| `dashboard` | Start a local-only dashboard on `127.0.0.1` showing tool inventory, risk, policy coverage, exact-action approvals, receipt chains, and audit export. |
|
|
373
392
|
| `recommend` | Draft a reviewable JSON policy from observed local calls. Dry-run by default; use `--write` to create `protect-mcp.recommended.json`. |
|
|
374
393
|
| `registry` | Create an org identity, anchor receipt digests, and write a static verifier page. Hosted mode uploads digests only. |
|
|
375
394
|
| `record` | Open a local, searchable viewer over your receipts (`--live` streams as the agent runs): Ed25519 signatures verified in your browser against your gateway key, capability tags, a provenance tree, and one-click signed export. All local, nothing uploaded. |
|
|
376
|
-
| `claim` | Mint a signed, position-blind attestation of a predicate over the record (`--no <cap
|
|
395
|
+
| `claim` | Mint a signed, position-blind attestation of a predicate over the record (`--no <cap>` incl. `--no payment`, `--only <c1,c2>`, `--no-verdict <verdict>`, `--count <verdict>`, `--payment-under <cap>`), disclosing only decision categories. Add `--anchor` to record the claim digest in the public transparency log; enrolled keys anchor as a named org. |
|
|
396
|
+
| `anchor-record` | Checkpoint the record's Merkle root + count + time range into the public log (heartbeat-friendly: skips when unchanged). A later claim whose commitment matches an anchored checkpoint is provably over the complete record as of that checkpoint. |
|
|
377
397
|
| `verify-claim` | Verify a claim pack offline: signature, recomputed Merkle root, independently recomputed predicate, and the anchor sidecar when present (binds the anchored envelope to this exact claim, then confirms the public log holds it). `--check-anchor` requires the anchor; `--offline` skips the log hop. |
|
|
378
398
|
| `killer-demo` | Generate a complete shadow-mode to policy to approval to signed-receipt demo pack. |
|
|
379
399
|
| `verify-disclosure` | Verify a `scopeblind.selective_disclosure.v0` package and explain disclosed versus hidden fields. |
|
|
@@ -43,7 +43,11 @@ var RULES = [
|
|
|
43
43
|
{ cap: "secret.adjacent", text: /\.env\b|secret|credential|passwd|password|api[_-]?key|private[_-]?key|\.pem\b|\.key\b|id_rsa|bearer\s|aws_(access|secret)|authorization/ },
|
|
44
44
|
{ cap: "destructive", text: /rm\s+-[a-z]*[rf]|\brmdir\b|drop\s+table|truncate\s+table|delete\s+from|reset\s+--hard|--force\b|\bmkfs\b|\bdd\s+if=|shutdown|reboot|kill\s+-9|>\s*\/dev\/sd/ },
|
|
45
45
|
{ cap: "financial", text: /\b(order|trade|buy|sell|transfer|wire|payment|withdraw|deposit|swap|invoice|charge|refund|settle)\b/ },
|
|
46
|
-
{ cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ }
|
|
46
|
+
{ cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ },
|
|
47
|
+
// Agent payments (x402 / value transfer). Deliberately BROAD: a false positive
|
|
48
|
+
// only makes a `claim --no payment` harder to assert (conservative); a false
|
|
49
|
+
// negative would let a real payment escape the record's payment claims.
|
|
50
|
+
{ cap: "payment", tool: /(^|[_.-])(pay|payment|x402|checkout)($|[_.-])|wallet.*send|send.*payment/, text: /x402|x-payment|paymentrequirements|maxamountrequired|payto|"pay_to"|eip-3009|transferwithauthorization|payment_intent|send_payment|create_payment/ }
|
|
47
51
|
];
|
|
48
52
|
function deriveCapabilities(tool, input) {
|
|
49
53
|
const t = String(tool || "").toLowerCase();
|
|
@@ -77,6 +81,33 @@ function deriveResource(input) {
|
|
|
77
81
|
}
|
|
78
82
|
return void 0;
|
|
79
83
|
}
|
|
84
|
+
function findField(input, names, depth = 0) {
|
|
85
|
+
if (depth > 4 || input === null || typeof input !== "object") return void 0;
|
|
86
|
+
const o = input;
|
|
87
|
+
const keys = Object.keys(o).sort();
|
|
88
|
+
for (const k of keys) {
|
|
89
|
+
if (names.indexOf(k.toLowerCase()) >= 0 && o[k] !== void 0 && o[k] !== null) return o[k];
|
|
90
|
+
}
|
|
91
|
+
for (const k of keys) {
|
|
92
|
+
const v = findField(o[k], names, depth + 1);
|
|
93
|
+
if (v !== void 0) return v;
|
|
94
|
+
}
|
|
95
|
+
return void 0;
|
|
96
|
+
}
|
|
97
|
+
function derivePayment(tool, input) {
|
|
98
|
+
if (deriveCapabilities(tool, input).indexOf("payment") < 0) return void 0;
|
|
99
|
+
const p = { amount: null, asset: null, recipient_digest: null };
|
|
100
|
+
const amt = findField(input, ["amount"]);
|
|
101
|
+
if (typeof amt === "number" && Number.isFinite(amt) && amt >= 0) p.amount = amt;
|
|
102
|
+
else if (typeof amt === "string" && /^\d{1,15}(\.\d{1,18})?$/.test(amt.trim()) && amt.indexOf(".") >= 0) p.amount = parseFloat(amt);
|
|
103
|
+
const asset = findField(input, ["asset", "currency", "token"]);
|
|
104
|
+
if (typeof asset === "string" && asset.trim()) p.asset = asset.trim().slice(0, 64);
|
|
105
|
+
const to = findField(input, ["payto", "pay_to", "recipient", "destination", "to"]);
|
|
106
|
+
if (typeof to === "string" && to.trim()) p.recipient_digest = sha256Hex(to.trim().toLowerCase());
|
|
107
|
+
const scheme = findField(input, ["scheme"]);
|
|
108
|
+
if (typeof scheme === "string" && scheme.trim()) p.scheme = scheme.trim().slice(0, 32);
|
|
109
|
+
return p;
|
|
110
|
+
}
|
|
80
111
|
function buildEnrichment(tool, input) {
|
|
81
112
|
const e = {
|
|
82
113
|
v: ENRICHMENT_VERSION,
|
|
@@ -85,6 +116,8 @@ function buildEnrichment(tool, input) {
|
|
|
85
116
|
};
|
|
86
117
|
const resource = deriveResource(input);
|
|
87
118
|
if (resource) e.resource = resource;
|
|
119
|
+
const payment = derivePayment(tool, input);
|
|
120
|
+
if (payment) e.payment = payment;
|
|
88
121
|
return e;
|
|
89
122
|
}
|
|
90
123
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
canonicalJson
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-WWPQNIVF.mjs";
|
|
4
4
|
import {
|
|
5
5
|
sha256
|
|
6
6
|
} from "./chunk-AYNQIEN7.mjs";
|
|
@@ -30,7 +30,13 @@ function receiptToLeaf(e) {
|
|
|
30
30
|
const ms = typeof tsRaw === "number" ? tsRaw : typeof tsRaw === "string" ? Date.parse(tsRaw) : NaN;
|
|
31
31
|
const t = isFinite(ms) ? new Date(ms).toISOString() : "";
|
|
32
32
|
const d = sha256Hex(canonicalJson(e));
|
|
33
|
-
|
|
33
|
+
const leaf = { d, v, c, t };
|
|
34
|
+
const pay = enr && enr.payment;
|
|
35
|
+
if (pay && typeof pay === "object") {
|
|
36
|
+
const amt = pay.amount;
|
|
37
|
+
leaf.p = typeof amt === "number" && Number.isFinite(amt) ? amt : null;
|
|
38
|
+
}
|
|
39
|
+
return leaf;
|
|
34
40
|
}
|
|
35
41
|
function leafHash(leaf) {
|
|
36
42
|
return sha256Hex(canonicalJson(leaf));
|
|
@@ -63,6 +69,10 @@ function evaluate(pred, leaves) {
|
|
|
63
69
|
const matched2 = leaves.filter((l) => l.v === pred.verdict).length;
|
|
64
70
|
return { statement: `No action was ${pred.verdict}`, holds: matched2 === 0, matched: matched2 };
|
|
65
71
|
}
|
|
72
|
+
if (pred.kind === "payment_under") {
|
|
73
|
+
const matched2 = leaves.filter((l) => "p" in l && (l.p === null || l.p >= pred.cap)).length;
|
|
74
|
+
return { statement: `Every payment stayed under ${pred.cap} (unknown amounts count as over)`, holds: matched2 === 0, matched: matched2 };
|
|
75
|
+
}
|
|
66
76
|
const matched = leaves.filter((l) => l.v === pred.verdict).length;
|
|
67
77
|
return { statement: `${matched} action${matched === 1 ? " was" : "s were"} ${pred.verdict}`, holds: true, matched };
|
|
68
78
|
}
|
|
@@ -229,11 +239,9 @@ async function checkClaimAnchor(pack, sidecar, opts) {
|
|
|
229
239
|
return out;
|
|
230
240
|
}
|
|
231
241
|
}
|
|
232
|
-
async function
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
const doFetch = opts.fetchImpl || globalThis.fetch;
|
|
236
|
-
if (!doFetch) return { ok: false, claim_digest: envelope.claim_digest, error: "fetch_unavailable", envelope };
|
|
242
|
+
async function submitEnvelope(envelope, base, fetchImpl) {
|
|
243
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
244
|
+
if (!doFetch) return { ok: false, error: "fetch_unavailable" };
|
|
237
245
|
const encoded = toBase64(new TextEncoder().encode(JSON.stringify(envelope)));
|
|
238
246
|
try {
|
|
239
247
|
const resp = await doFetch(`${base}/fn/log/anchor-pack`, {
|
|
@@ -243,32 +251,101 @@ async function anchorClaim(pack, key, opts) {
|
|
|
243
251
|
});
|
|
244
252
|
const data = await resp.json().catch(() => null);
|
|
245
253
|
if (!resp.ok || !data || !data.ok || typeof data.seq !== "number") {
|
|
246
|
-
return { ok: false,
|
|
254
|
+
return { ok: false, error: data && data.error || `http_${resp.status}` };
|
|
247
255
|
}
|
|
256
|
+
return { ok: true, seq: data.seq, anchored_at: data.anchored_at, already_anchored: !!data.already_anchored };
|
|
257
|
+
} catch {
|
|
258
|
+
return { ok: false, error: "network_error" };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function anchorClaim(pack, key, opts) {
|
|
262
|
+
const envelope = buildAnchorEnvelope(pack, key, opts.issuedAt);
|
|
263
|
+
const base = (opts.log || DEFAULT_LOG).replace(/\/+$/, "");
|
|
264
|
+
const out = await submitEnvelope(envelope, base, opts.fetchImpl);
|
|
265
|
+
if (!out.ok) return { ok: false, claim_digest: envelope.claim_digest, error: out.error, envelope };
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
claim_digest: envelope.claim_digest,
|
|
269
|
+
seq: out.seq,
|
|
270
|
+
entry_url: `${base}/fn/log/${out.seq}`,
|
|
271
|
+
anchored_at: out.anchored_at,
|
|
272
|
+
already_anchored: out.already_anchored,
|
|
273
|
+
envelope
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
var CHECKPOINT_SCHEMA = "scopeblind.protect-mcp.record-checkpoint.v1";
|
|
277
|
+
function buildRecordCheckpoint(receipts, key, issuedAt) {
|
|
278
|
+
const leaves = receipts.map(receiptToLeaf);
|
|
279
|
+
const times = leaves.map((l) => l.t).filter(Boolean).sort();
|
|
280
|
+
const signed = {
|
|
281
|
+
type: "evidence_pack",
|
|
282
|
+
schema: CHECKPOINT_SCHEMA,
|
|
283
|
+
anchors: "protect-mcp-record",
|
|
284
|
+
record_root: merkleRoot(leaves.map(leafHash)),
|
|
285
|
+
total: leaves.length,
|
|
286
|
+
from: times[0] || "",
|
|
287
|
+
to: times[times.length - 1] || "",
|
|
288
|
+
issued_at: issuedAt,
|
|
289
|
+
verification_key: key.publicKey,
|
|
290
|
+
disclosure: "internal"
|
|
291
|
+
};
|
|
292
|
+
const hash = sha256(new TextEncoder().encode(JSON.stringify(anchorDeepSort(signed))));
|
|
293
|
+
const digest = bytesToHex(hash);
|
|
294
|
+
const signature = bytesToHex(ed25519.sign(hash, hexToBytes(key.privateKey)));
|
|
295
|
+
return { ...signed, signature, digest };
|
|
296
|
+
}
|
|
297
|
+
async function anchorRecordCheckpoint(receipts, key, opts) {
|
|
298
|
+
const checkpoint = buildRecordCheckpoint(receipts, key, opts.issuedAt);
|
|
299
|
+
const base = (opts.log || DEFAULT_LOG).replace(/\/+$/, "");
|
|
300
|
+
const out = await submitEnvelope(checkpoint, base, opts.fetchImpl);
|
|
301
|
+
if (!out.ok) return { ok: false, record_root: checkpoint.record_root, total: checkpoint.total, checkpoint, error: out.error };
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
record_root: checkpoint.record_root,
|
|
305
|
+
total: checkpoint.total,
|
|
306
|
+
seq: out.seq,
|
|
307
|
+
entry_url: `${base}/fn/log/${out.seq}`,
|
|
308
|
+
anchored_at: out.anchored_at,
|
|
309
|
+
already_anchored: out.already_anchored,
|
|
310
|
+
checkpoint
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
async function lookupPinnedIdentity(publicKey, opts) {
|
|
314
|
+
const base = (opts && opts.log || DEFAULT_LOG).replace(/\/+$/, "");
|
|
315
|
+
const doFetch = opts && opts.fetchImpl || globalThis.fetch;
|
|
316
|
+
if (!doFetch || !/^[0-9a-f]{64}$/i.test(publicKey)) return null;
|
|
317
|
+
try {
|
|
318
|
+
const resp = await doFetch(`${base}/fn/log/keys/lookup/${publicKey.toLowerCase()}`, { headers: { accept: "application/json" } });
|
|
319
|
+
const data = await resp.json().catch(() => null);
|
|
320
|
+
if (!data || data.ok !== true) return null;
|
|
321
|
+
if (!data.found) return { found: false };
|
|
248
322
|
return {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
envelope
|
|
323
|
+
found: true,
|
|
324
|
+
name: data.name,
|
|
325
|
+
slug: data.slug,
|
|
326
|
+
kid: data.kid,
|
|
327
|
+
enrolled_at: data.enrolled_at,
|
|
328
|
+
revoked: !!(data.revoked || data.revoked_at)
|
|
256
329
|
};
|
|
257
330
|
} catch {
|
|
258
|
-
return
|
|
331
|
+
return null;
|
|
259
332
|
}
|
|
260
333
|
}
|
|
261
334
|
export {
|
|
262
335
|
ANCHOR_SCHEMA,
|
|
336
|
+
CHECKPOINT_SCHEMA,
|
|
263
337
|
CLAIM_TYPE,
|
|
264
338
|
DEFAULT_LOG,
|
|
265
339
|
anchorClaim,
|
|
340
|
+
anchorRecordCheckpoint,
|
|
266
341
|
buildAnchorEnvelope,
|
|
267
342
|
buildClaim,
|
|
343
|
+
buildRecordCheckpoint,
|
|
268
344
|
checkClaimAnchor,
|
|
269
345
|
claimDigest,
|
|
270
346
|
evaluate,
|
|
271
347
|
leafHash,
|
|
348
|
+
lookupPinnedIdentity,
|
|
272
349
|
merkleRoot,
|
|
273
350
|
receiptToLeaf,
|
|
274
351
|
verifyAnchorEnvelope,
|