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 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>`, `--only <c1,c2>`, `--no-verdict <verdict>`, `--count <verdict>`), disclosing only decision categories. Add `--anchor` to record the claim digest in the public transparency log. |
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. |
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  buildEnrichment
3
- } from "./chunk-HPKHMVZX.mjs";
3
+ } from "./chunk-WWPQNIVF.mjs";
4
4
  import {
5
5
  ReceiptBuffer,
6
6
  buildActionReadback,
@@ -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-HPKHMVZX.mjs";
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
- return { d, v, c, t };
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 anchorClaim(pack, key, opts) {
233
- const envelope = buildAnchorEnvelope(pack, key, opts.issuedAt);
234
- const base = (opts.log || DEFAULT_LOG).replace(/\/+$/, "");
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, claim_digest: envelope.claim_digest, error: data && data.error || `http_${resp.status}`, envelope };
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
- ok: true,
250
- claim_digest: envelope.claim_digest,
251
- seq: data.seq,
252
- entry_url: `${base}/fn/log/${data.seq}`,
253
- anchored_at: data.anchored_at,
254
- already_anchored: !!data.already_anchored,
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 { ok: false, claim_digest: envelope.claim_digest, error: "network_error", envelope };
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,