protect-mcp 0.9.3 → 0.9.4

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.4: agent payments get receipts, records get heartbeats, anchors get names
4
+
5
+ The provenance layer reaches the agentic economy's payment rails (x402), the
6
+ record gains continuous completeness, and anchoring can carry an identity.
7
+
8
+ - **Payment receipts (x402 interop).** The gate now tags agent payments with a
9
+ signed `payment` capability, detected broadly across x402 wire shapes
10
+ (`paymentRequirements`, `X-PAYMENT`, EIP-3009 `transferWithAuthorization`)
11
+ and payment-shaped tools. Receipts carry minimum-disclosure payment facts:
12
+ amount (only when clearly readable in human units), asset, a HASHED recipient,
13
+ and the x402 scheme. `claim --no payment` proves no agent paid anything;
14
+ `claim --payment-under <cap>` proves every payment stayed under a cap, and an
15
+ amount the gate could not read counts as OVER (you cannot prove an amount you
16
+ could not read, so the claim cannot lie).
17
+ - **Record checkpoints (`anchor-record`).** Anchors the record's CURRENT
18
+ commitment (the same Merkle root a claim commits to, plus count and time
19
+ range) into the public log. Run it on a heartbeat and the record grows an
20
+ anchored history: a later claim whose root matches a checkpoint is provably
21
+ over the complete set as of that checkpoint. Skips when the record is
22
+ unchanged; writes a local `.protect-mcp-anchors.jsonl` history; only the
23
+ root, count, and time range leave the machine.
24
+ - **Pinned identity in the anchor loop.** `claim --anchor`, `anchor-record`,
25
+ and `verify-claim` now resolve the anchoring key against the public ScopeBlind
26
+ key directory: an enrolled key shows "anchored as <Org> (key pinned)", a
27
+ revoked key fails verification, an anonymous key points at enrollment. The
28
+ free anchor stays anonymous; the named identity is the paid upgrade.
29
+
3
30
  ## 0.9.3: the skeptic's tools sharpen
4
31
 
5
32
  Two verification upgrades: the anchor check moves into the verifier, and the
package/README.md CHANGED
@@ -373,7 +373,8 @@ To report a vulnerability, see [SECURITY.md](./SECURITY.md).
373
373
  | `recommend` | Draft a reviewable JSON policy from observed local calls. Dry-run by default; use `--write` to create `protect-mcp.recommended.json`. |
374
374
  | `registry` | Create an org identity, anchor receipt digests, and write a static verifier page. Hosted mode uploads digests only. |
375
375
  | `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. |
376
+ | `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. |
377
+ | `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
378
  | `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
379
  | `killer-demo` | Generate a complete shadow-mode to policy to approval to signed-receipt demo pack. |
379
380
  | `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,
package/dist/cli.js CHANGED
@@ -5146,6 +5146,33 @@ function deriveResource(input) {
5146
5146
  }
5147
5147
  return void 0;
5148
5148
  }
5149
+ function findField(input, names, depth = 0) {
5150
+ if (depth > 4 || input === null || typeof input !== "object") return void 0;
5151
+ const o = input;
5152
+ const keys = Object.keys(o).sort();
5153
+ for (const k of keys) {
5154
+ if (names.indexOf(k.toLowerCase()) >= 0 && o[k] !== void 0 && o[k] !== null) return o[k];
5155
+ }
5156
+ for (const k of keys) {
5157
+ const v = findField(o[k], names, depth + 1);
5158
+ if (v !== void 0) return v;
5159
+ }
5160
+ return void 0;
5161
+ }
5162
+ function derivePayment(tool, input) {
5163
+ if (deriveCapabilities(tool, input).indexOf("payment") < 0) return void 0;
5164
+ const p = { amount: null, asset: null, recipient_digest: null };
5165
+ const amt = findField(input, ["amount"]);
5166
+ if (typeof amt === "number" && Number.isFinite(amt) && amt >= 0) p.amount = amt;
5167
+ else if (typeof amt === "string" && /^\d{1,15}(\.\d{1,18})?$/.test(amt.trim()) && amt.indexOf(".") >= 0) p.amount = parseFloat(amt);
5168
+ const asset = findField(input, ["asset", "currency", "token"]);
5169
+ if (typeof asset === "string" && asset.trim()) p.asset = asset.trim().slice(0, 64);
5170
+ const to = findField(input, ["payto", "pay_to", "recipient", "destination", "to"]);
5171
+ if (typeof to === "string" && to.trim()) p.recipient_digest = sha256Hex2(to.trim().toLowerCase());
5172
+ const scheme = findField(input, ["scheme"]);
5173
+ if (typeof scheme === "string" && scheme.trim()) p.scheme = scheme.trim().slice(0, 32);
5174
+ return p;
5175
+ }
5149
5176
  function buildEnrichment(tool, input) {
5150
5177
  const e = {
5151
5178
  v: ENRICHMENT_VERSION,
@@ -5154,6 +5181,8 @@ function buildEnrichment(tool, input) {
5154
5181
  };
5155
5182
  const resource = deriveResource(input);
5156
5183
  if (resource) e.resource = resource;
5184
+ const payment = derivePayment(tool, input);
5185
+ if (payment) e.payment = payment;
5157
5186
  return e;
5158
5187
  }
5159
5188
  var ENRICHMENT_VERSION, RULES;
@@ -5174,7 +5203,11 @@ var init_receipt_enrichment = __esm({
5174
5203
  { 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/ },
5175
5204
  { 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/ },
5176
5205
  { cap: "financial", text: /\b(order|trade|buy|sell|transfer|wire|payment|withdraw|deposit|swap|invoice|charge|refund|settle)\b/ },
5177
- { cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ }
5206
+ { cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ },
5207
+ // Agent payments (x402 / value transfer). Deliberately BROAD: a false positive
5208
+ // only makes a `claim --no payment` harder to assert (conservative); a false
5209
+ // negative would let a real payment escape the record's payment claims.
5210
+ { 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/ }
5178
5211
  ];
5179
5212
  }
5180
5213
  });
@@ -5183,15 +5216,19 @@ var init_receipt_enrichment = __esm({
5183
5216
  var claim_exports = {};
5184
5217
  __export(claim_exports, {
5185
5218
  ANCHOR_SCHEMA: () => ANCHOR_SCHEMA,
5219
+ CHECKPOINT_SCHEMA: () => CHECKPOINT_SCHEMA,
5186
5220
  CLAIM_TYPE: () => CLAIM_TYPE,
5187
5221
  DEFAULT_LOG: () => DEFAULT_LOG,
5188
5222
  anchorClaim: () => anchorClaim,
5223
+ anchorRecordCheckpoint: () => anchorRecordCheckpoint,
5189
5224
  buildAnchorEnvelope: () => buildAnchorEnvelope,
5190
5225
  buildClaim: () => buildClaim,
5226
+ buildRecordCheckpoint: () => buildRecordCheckpoint,
5191
5227
  checkClaimAnchor: () => checkClaimAnchor,
5192
5228
  claimDigest: () => claimDigest,
5193
5229
  evaluate: () => evaluate,
5194
5230
  leafHash: () => leafHash,
5231
+ lookupPinnedIdentity: () => lookupPinnedIdentity,
5195
5232
  merkleRoot: () => merkleRoot,
5196
5233
  receiptToLeaf: () => receiptToLeaf,
5197
5234
  verifyAnchorEnvelope: () => verifyAnchorEnvelope,
@@ -5211,7 +5248,13 @@ function receiptToLeaf(e) {
5211
5248
  const ms = typeof tsRaw === "number" ? tsRaw : typeof tsRaw === "string" ? Date.parse(tsRaw) : NaN;
5212
5249
  const t = isFinite(ms) ? new Date(ms).toISOString() : "";
5213
5250
  const d = sha256Hex3(canonicalJson(e));
5214
- return { d, v, c, t };
5251
+ const leaf = { d, v, c, t };
5252
+ const pay = enr && enr.payment;
5253
+ if (pay && typeof pay === "object") {
5254
+ const amt = pay.amount;
5255
+ leaf.p = typeof amt === "number" && Number.isFinite(amt) ? amt : null;
5256
+ }
5257
+ return leaf;
5215
5258
  }
5216
5259
  function leafHash(leaf) {
5217
5260
  return sha256Hex3(canonicalJson(leaf));
@@ -5244,6 +5287,10 @@ function evaluate(pred, leaves) {
5244
5287
  const matched2 = leaves.filter((l) => l.v === pred.verdict).length;
5245
5288
  return { statement: `No action was ${pred.verdict}`, holds: matched2 === 0, matched: matched2 };
5246
5289
  }
5290
+ if (pred.kind === "payment_under") {
5291
+ const matched2 = leaves.filter((l) => "p" in l && (l.p === null || l.p >= pred.cap)).length;
5292
+ return { statement: `Every payment stayed under ${pred.cap} (unknown amounts count as over)`, holds: matched2 === 0, matched: matched2 };
5293
+ }
5247
5294
  const matched = leaves.filter((l) => l.v === pred.verdict).length;
5248
5295
  return { statement: `${matched} action${matched === 1 ? " was" : "s were"} ${pred.verdict}`, holds: true, matched };
5249
5296
  }
@@ -5408,11 +5455,9 @@ async function checkClaimAnchor(pack, sidecar, opts) {
5408
5455
  return out;
5409
5456
  }
5410
5457
  }
5411
- async function anchorClaim(pack, key, opts) {
5412
- const envelope = buildAnchorEnvelope(pack, key, opts.issuedAt);
5413
- const base = (opts.log || DEFAULT_LOG).replace(/\/+$/, "");
5414
- const doFetch = opts.fetchImpl || globalThis.fetch;
5415
- if (!doFetch) return { ok: false, claim_digest: envelope.claim_digest, error: "fetch_unavailable", envelope };
5458
+ async function submitEnvelope(envelope, base, fetchImpl) {
5459
+ const doFetch = fetchImpl || globalThis.fetch;
5460
+ if (!doFetch) return { ok: false, error: "fetch_unavailable" };
5416
5461
  const encoded = toBase64(new TextEncoder().encode(JSON.stringify(envelope)));
5417
5462
  try {
5418
5463
  const resp = await doFetch(`${base}/fn/log/anchor-pack`, {
@@ -5422,22 +5467,86 @@ async function anchorClaim(pack, key, opts) {
5422
5467
  });
5423
5468
  const data = await resp.json().catch(() => null);
5424
5469
  if (!resp.ok || !data || !data.ok || typeof data.seq !== "number") {
5425
- return { ok: false, claim_digest: envelope.claim_digest, error: data && data.error || `http_${resp.status}`, envelope };
5470
+ return { ok: false, error: data && data.error || `http_${resp.status}` };
5426
5471
  }
5472
+ return { ok: true, seq: data.seq, anchored_at: data.anchored_at, already_anchored: !!data.already_anchored };
5473
+ } catch {
5474
+ return { ok: false, error: "network_error" };
5475
+ }
5476
+ }
5477
+ async function anchorClaim(pack, key, opts) {
5478
+ const envelope = buildAnchorEnvelope(pack, key, opts.issuedAt);
5479
+ const base = (opts.log || DEFAULT_LOG).replace(/\/+$/, "");
5480
+ const out = await submitEnvelope(envelope, base, opts.fetchImpl);
5481
+ if (!out.ok) return { ok: false, claim_digest: envelope.claim_digest, error: out.error, envelope };
5482
+ return {
5483
+ ok: true,
5484
+ claim_digest: envelope.claim_digest,
5485
+ seq: out.seq,
5486
+ entry_url: `${base}/fn/log/${out.seq}`,
5487
+ anchored_at: out.anchored_at,
5488
+ already_anchored: out.already_anchored,
5489
+ envelope
5490
+ };
5491
+ }
5492
+ function buildRecordCheckpoint(receipts, key, issuedAt) {
5493
+ const leaves = receipts.map(receiptToLeaf);
5494
+ const times = leaves.map((l) => l.t).filter(Boolean).sort();
5495
+ const signed = {
5496
+ type: "evidence_pack",
5497
+ schema: CHECKPOINT_SCHEMA,
5498
+ anchors: "protect-mcp-record",
5499
+ record_root: merkleRoot(leaves.map(leafHash)),
5500
+ total: leaves.length,
5501
+ from: times[0] || "",
5502
+ to: times[times.length - 1] || "",
5503
+ issued_at: issuedAt,
5504
+ verification_key: key.publicKey,
5505
+ disclosure: "internal"
5506
+ };
5507
+ const hash = sha2562(new TextEncoder().encode(JSON.stringify(anchorDeepSort(signed))));
5508
+ const digest = bytesToHex(hash);
5509
+ const signature = bytesToHex(ed25519.sign(hash, hexToBytes(key.privateKey)));
5510
+ return { ...signed, signature, digest };
5511
+ }
5512
+ async function anchorRecordCheckpoint(receipts, key, opts) {
5513
+ const checkpoint = buildRecordCheckpoint(receipts, key, opts.issuedAt);
5514
+ const base = (opts.log || DEFAULT_LOG).replace(/\/+$/, "");
5515
+ const out = await submitEnvelope(checkpoint, base, opts.fetchImpl);
5516
+ if (!out.ok) return { ok: false, record_root: checkpoint.record_root, total: checkpoint.total, checkpoint, error: out.error };
5517
+ return {
5518
+ ok: true,
5519
+ record_root: checkpoint.record_root,
5520
+ total: checkpoint.total,
5521
+ seq: out.seq,
5522
+ entry_url: `${base}/fn/log/${out.seq}`,
5523
+ anchored_at: out.anchored_at,
5524
+ already_anchored: out.already_anchored,
5525
+ checkpoint
5526
+ };
5527
+ }
5528
+ async function lookupPinnedIdentity(publicKey, opts) {
5529
+ const base = (opts && opts.log || DEFAULT_LOG).replace(/\/+$/, "");
5530
+ const doFetch = opts && opts.fetchImpl || globalThis.fetch;
5531
+ if (!doFetch || !/^[0-9a-f]{64}$/i.test(publicKey)) return null;
5532
+ try {
5533
+ const resp = await doFetch(`${base}/fn/log/keys/lookup/${publicKey.toLowerCase()}`, { headers: { accept: "application/json" } });
5534
+ const data = await resp.json().catch(() => null);
5535
+ if (!data || data.ok !== true) return null;
5536
+ if (!data.found) return { found: false };
5427
5537
  return {
5428
- ok: true,
5429
- claim_digest: envelope.claim_digest,
5430
- seq: data.seq,
5431
- entry_url: `${base}/fn/log/${data.seq}`,
5432
- anchored_at: data.anchored_at,
5433
- already_anchored: !!data.already_anchored,
5434
- envelope
5538
+ found: true,
5539
+ name: data.name,
5540
+ slug: data.slug,
5541
+ kid: data.kid,
5542
+ enrolled_at: data.enrolled_at,
5543
+ revoked: !!(data.revoked || data.revoked_at)
5435
5544
  };
5436
5545
  } catch {
5437
- return { ok: false, claim_digest: envelope.claim_digest, error: "network_error", envelope };
5546
+ return null;
5438
5547
  }
5439
5548
  }
5440
- var CLAIM_TYPE, ANCHOR_SCHEMA, DEFAULT_LOG;
5549
+ var CLAIM_TYPE, ANCHOR_SCHEMA, DEFAULT_LOG, CHECKPOINT_SCHEMA;
5441
5550
  var init_claim = __esm({
5442
5551
  "src/claim.ts"() {
5443
5552
  "use strict";
@@ -5448,6 +5557,7 @@ var init_claim = __esm({
5448
5557
  CLAIM_TYPE = "scopeblind.claim.v1";
5449
5558
  ANCHOR_SCHEMA = "scopeblind.protect-mcp.anchor.v1";
5450
5559
  DEFAULT_LOG = "https://scopeblind.com";
5560
+ CHECKPOINT_SCHEMA = "scopeblind.protect-mcp.record-checkpoint.v1";
5451
5561
  }
5452
5562
  });
5453
5563
 
@@ -8711,8 +8821,12 @@ Commands:
8711
8821
  digest Generate a human-readable summary of agent activity
8712
8822
  receipts Show recent persisted signed receipts
8713
8823
  record Open a local, searchable view of your record in the browser
8714
- claim Attest a signed, position-blind claim over your record (e.g. no egress)
8715
- verify-claim Verify a claim attestation offline (signature + predicate + commitment)
8824
+ claim Attest a signed, position-blind claim over your record (e.g. no egress,
8825
+ no payment, every payment under a cap)
8826
+ verify-claim Verify a claim attestation offline (signature + predicate + commitment
8827
+ + the anchor sidecar and issuer identity when present)
8828
+ anchor-record Checkpoint the record's Merkle root + count into the public log
8829
+ (heartbeat-friendly: skips when unchanged; only hashes leave)
8716
8830
  bundle Export an offline-verifiable audit bundle
8717
8831
 
8718
8832
  Examples:
@@ -10889,20 +11003,23 @@ async function handleClaim(argv) {
10889
11003
  const di = argv.indexOf("--dir");
10890
11004
  if (di !== -1 && argv[di + 1]) dir = argv[di + 1];
10891
11005
  let predicate = null;
10892
- const noIdx = argv.indexOf("--no"), onlyIdx = argv.indexOf("--only"), nvIdx = argv.indexOf("--no-verdict"), cvIdx = argv.indexOf("--count");
11006
+ const noIdx = argv.indexOf("--no"), onlyIdx = argv.indexOf("--only"), nvIdx = argv.indexOf("--no-verdict"), cvIdx = argv.indexOf("--count"), puIdx = argv.indexOf("--payment-under");
10893
11007
  if (noIdx !== -1 && argv[noIdx + 1]) predicate = { kind: "no_capability", capability: argv[noIdx + 1] };
10894
11008
  else if (onlyIdx !== -1 && argv[onlyIdx + 1]) predicate = { kind: "only_capabilities", capabilities: argv[onlyIdx + 1].split(",").map((s) => s.trim()).filter(Boolean) };
10895
11009
  else if (nvIdx !== -1 && argv[nvIdx + 1]) predicate = { kind: "no_verdict", verdict: argv[nvIdx + 1] };
10896
11010
  else if (cvIdx !== -1 && argv[cvIdx + 1]) predicate = { kind: "count_verdict", verdict: argv[cvIdx + 1] };
11011
+ else if (puIdx !== -1 && argv[puIdx + 1] && isFinite(parseFloat(argv[puIdx + 1]))) predicate = { kind: "payment_under", cap: parseFloat(argv[puIdx + 1]) };
10897
11012
  if (!predicate) {
10898
11013
  process.stderr.write(`
10899
11014
  ${bold("protect-mcp claim")}
10900
11015
 
10901
11016
  Attest a signed, position-blind claim over your record:
10902
- --no <capability> no action used it, e.g. ${dim("--no net.egress")}
11017
+ --no <capability> no action used it, e.g. ${dim("--no net.egress")} or ${dim("--no payment")}
10903
11018
  --only <c1,c2,...> all actions confined to these capabilities
10904
11019
  --no-verdict <verdict> e.g. ${dim("--no-verdict blocked")}
10905
11020
  --count <verdict> how many, e.g. ${dim("--count blocked")}
11021
+ --payment-under <cap> every agent payment stayed under the cap (amounts the
11022
+ gate could not read count as OVER, so this cannot lie)
10906
11023
  --anchor also record the claim digest in the public append-only
10907
11024
  log so a counterparty can trust it is complete (only the
10908
11025
  hash is sent; your record stays local)
@@ -11005,8 +11122,18 @@ ${bold("\u{1F6E1}\uFE0F Signed claim")}
11005
11122
  `);
11006
11123
  process.stdout.write(` ${dim("Anchor record written to " + sidecar + ". Only the digest was sent; your record stayed local.")}
11007
11124
  `);
11008
- process.stdout.write(` ${dim("To anchor as your enrolled org identity (a key a counterparty can pin), see")} ${bold("scopeblind.com/enroll")}
11125
+ const { lookupPinnedIdentity: lookupPinnedIdentity2 } = await Promise.resolve().then(() => (init_claim(), claim_exports));
11126
+ const who = await lookupPinnedIdentity2(key.publicKey, { log: logBase });
11127
+ if (who && who.found && !who.revoked) {
11128
+ process.stdout.write(` ${green("Identity:")} anchored as ${bold(who.name || who.slug || "enrolled org")} ${dim("(key pinned in the ScopeBlind directory" + (who.enrolled_at ? ", enrolled " + who.enrolled_at.slice(0, 10) : "") + ")")}
11129
+ `);
11130
+ } else if (who && who.found && who.revoked) {
11131
+ process.stdout.write(` ${red("Identity: this key is REVOKED in the ScopeBlind directory.")}
11132
+ `);
11133
+ } else {
11134
+ process.stdout.write(` ${dim("Identity: anonymous (key not enrolled). To anchor as a named org a counterparty can pin, see")} ${bold("scopeblind.com/enroll")}
11009
11135
  `);
11136
+ }
11010
11137
  } else {
11011
11138
  process.stdout.write(` ${yellow("Anchor skipped")} ${dim("(" + (res.error || "unavailable") + "). The claim above is complete and verifiable offline without it.")}
11012
11139
  `);
@@ -11016,6 +11143,132 @@ ${bold("\u{1F6E1}\uFE0F Signed claim")}
11016
11143
  `);
11017
11144
  process.exit(0);
11018
11145
  }
11146
+ async function handleAnchorRecord(argv) {
11147
+ const { readFileSync: readFileSync11, existsSync: existsSync9, appendFileSync: appendFileSync3 } = await import("fs");
11148
+ const { join: join8 } = await import("path");
11149
+ const { anchorRecordCheckpoint: anchorRecordCheckpoint2, buildRecordCheckpoint: buildRecordCheckpoint2, lookupPinnedIdentity: lookupPinnedIdentity2 } = await Promise.resolve().then(() => (init_claim(), claim_exports));
11150
+ let dir = process.cwd();
11151
+ const di = argv.indexOf("--dir");
11152
+ if (di !== -1 && argv[di + 1]) dir = argv[di + 1];
11153
+ const li = argv.indexOf("--log");
11154
+ const logBase = li !== -1 && argv[li + 1] ? argv[li + 1] : void 0;
11155
+ const keyPath = join8(dir, "keys", "gateway.json");
11156
+ if (!existsSync9(keyPath)) {
11157
+ process.stderr.write(`
11158
+ ${bold("protect-mcp anchor-record")}
11159
+
11160
+ No signing key at ${keyPath}. A checkpoint must be signed. Run ${bold("npx protect-mcp init")} first.
11161
+
11162
+ `);
11163
+ process.exit(1);
11164
+ return;
11165
+ }
11166
+ let key;
11167
+ try {
11168
+ key = JSON.parse(readFileSync11(keyPath, "utf-8"));
11169
+ } catch {
11170
+ process.stderr.write(`
11171
+ protect-mcp anchor-record: ${keyPath} is not valid JSON.
11172
+
11173
+ `);
11174
+ process.exit(1);
11175
+ return;
11176
+ }
11177
+ if (!key.privateKey || !key.publicKey) {
11178
+ process.stderr.write(`
11179
+ protect-mcp anchor-record: ${keyPath} is missing privateKey/publicKey.
11180
+
11181
+ `);
11182
+ process.exit(1);
11183
+ return;
11184
+ }
11185
+ const recPath = join8(dir, ".protect-mcp-receipts.jsonl");
11186
+ if (!existsSync9(recPath)) {
11187
+ process.stderr.write(`
11188
+ ${bold("protect-mcp anchor-record")}
11189
+
11190
+ No signed receipts in ${dir}. Run the gate with signing on, then try again.
11191
+
11192
+ `);
11193
+ process.exit(0);
11194
+ return;
11195
+ }
11196
+ const receipts = readFileSync11(recPath, "utf-8").split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => {
11197
+ try {
11198
+ return JSON.parse(l);
11199
+ } catch {
11200
+ return null;
11201
+ }
11202
+ }).filter((x) => x !== null);
11203
+ if (!receipts.length) {
11204
+ process.stderr.write(`
11205
+ protect-mcp anchor-record: no readable receipts in ${recPath}.
11206
+
11207
+ `);
11208
+ process.exit(0);
11209
+ return;
11210
+ }
11211
+ const claimKey = { privateKey: key.privateKey, publicKey: key.publicKey, kid: key.kid || "gateway", issuer: "protect-mcp" };
11212
+ const historyPath = join8(dir, ".protect-mcp-anchors.jsonl");
11213
+ const preview = buildRecordCheckpoint2(receipts, claimKey, "preview");
11214
+ if (!argv.includes("--force") && existsSync9(historyPath)) {
11215
+ const lines = readFileSync11(historyPath, "utf-8").split(/\r?\n/).filter(Boolean);
11216
+ const last = lines.length ? (() => {
11217
+ try {
11218
+ return JSON.parse(lines[lines.length - 1]);
11219
+ } catch {
11220
+ return null;
11221
+ }
11222
+ })() : null;
11223
+ if (last && last.record_root === preview.record_root && last.total === preview.total) {
11224
+ process.stdout.write(`
11225
+ ${bold("\u{1F6E1}\uFE0F Record checkpoint")}
11226
+ `);
11227
+ process.stdout.write(` Unchanged since entry ${bold("#" + last.seq)} ${dim("(" + last.total + " receipts, anchored " + (last.anchored_at || "") + ")")}. Nothing new to anchor.
11228
+ `);
11229
+ process.stdout.write(` ${dim("Use --force to re-anchor anyway.")}
11230
+
11231
+ `);
11232
+ process.exit(0);
11233
+ return;
11234
+ }
11235
+ }
11236
+ const res = await anchorRecordCheckpoint2(receipts, claimKey, { log: logBase, issuedAt: (/* @__PURE__ */ new Date()).toISOString() });
11237
+ process.stdout.write(`
11238
+ ${bold("\u{1F6E1}\uFE0F Record checkpoint")}
11239
+ `);
11240
+ process.stdout.write(` ${res.total} receipts ${dim("\xB7")} root ${dim(res.record_root.slice(0, 16) + "\u2026")} ${dim("(" + res.checkpoint.from.slice(0, 10) + " \u2192 " + res.checkpoint.to.slice(0, 10) + ")")}
11241
+ `);
11242
+ if (!res.ok) {
11243
+ process.stdout.write(` ${yellow("Anchor failed")} ${dim("(" + (res.error || "unavailable") + "). Nothing was recorded; try again.")}
11244
+
11245
+ `);
11246
+ process.exit(1);
11247
+ return;
11248
+ }
11249
+ appendFileSync3(historyPath, JSON.stringify({ schema: res.checkpoint.schema, seq: res.seq, anchored_at: res.anchored_at, total: res.total, record_root: res.record_root, entry_url: res.entry_url, digest: res.checkpoint.digest }) + "\n");
11250
+ process.stdout.write(` ${green("Anchored")} as log entry ${bold("#" + res.seq)} ${dim(res.entry_url || "")}
11251
+ `);
11252
+ process.stdout.write(` ${dim("Only the root, count, and time range were sent. History: " + historyPath)}
11253
+ `);
11254
+ const who = await lookupPinnedIdentity2(claimKey.publicKey, { log: logBase });
11255
+ if (who && who.found && !who.revoked) {
11256
+ process.stdout.write(` ${green("Identity:")} anchored as ${bold(who.name || who.slug || "enrolled org")} ${dim("(key pinned in the ScopeBlind directory)")}
11257
+ `);
11258
+ } else if (who && who.found && who.revoked) {
11259
+ process.stdout.write(` ${red("Identity: this key is REVOKED in the ScopeBlind directory.")}
11260
+ `);
11261
+ } else {
11262
+ process.stdout.write(` ${dim("Identity: anonymous (key not enrolled). Named identity: scopeblind.com/enroll")}
11263
+ `);
11264
+ }
11265
+ process.stdout.write(` ${dim("A claim whose commitment matches this root is provably over the complete record as of")}
11266
+ `);
11267
+ process.stdout.write(` ${dim("this checkpoint. Run this on a heartbeat (e.g. cron) to keep the anchored history growing.")}
11268
+
11269
+ `);
11270
+ process.exit(0);
11271
+ }
11019
11272
  async function handleVerifyClaim(argv) {
11020
11273
  const { readFileSync: readFileSync11, existsSync: existsSync9 } = await import("fs");
11021
11274
  const { verifyClaim: verifyClaim2 } = await Promise.resolve().then(() => (init_claim(), claim_exports));
@@ -11102,6 +11355,21 @@ ${bold("protect-mcp verify-claim")}
11102
11355
  process.stdout.write(` ${yellow("~")} log not checked ${dim(argv.includes("--offline") ? "(--offline)" : "(unreachable; local binding checks stand)")}
11103
11356
  `);
11104
11357
  }
11358
+ if (!argv.includes("--offline") && sidecar.envelope) {
11359
+ const { lookupPinnedIdentity: lookupPinnedIdentity2 } = await Promise.resolve().then(() => (init_claim(), claim_exports));
11360
+ const who = await lookupPinnedIdentity2(sidecar.envelope.verification_key, {});
11361
+ if (who && who.found && !who.revoked) {
11362
+ process.stdout.write(` ${green("\u2713")} issuer key pinned to ${bold(who.name || who.slug || "an enrolled org")} ${dim("(ScopeBlind key directory)")}
11363
+ `);
11364
+ } else if (who && who.found && who.revoked) {
11365
+ anchorOk = false;
11366
+ process.stdout.write(` ${red("\u2717")} issuer key is REVOKED in the ScopeBlind key directory
11367
+ `);
11368
+ } else if (who && !who.found) {
11369
+ process.stdout.write(` ${dim("issuer key not enrolled (anonymous issuer); named identities pin via scopeblind.com/enroll")}
11370
+ `);
11371
+ }
11372
+ }
11105
11373
  }
11106
11374
  } else if (requireAnchor) {
11107
11375
  anchorOk = false;
@@ -12629,6 +12897,10 @@ async function main() {
12629
12897
  await handleVerifyClaim(args.slice(1));
12630
12898
  return;
12631
12899
  }
12900
+ if (args[0] === "anchor-record") {
12901
+ await handleAnchorRecord(args.slice(1));
12902
+ return;
12903
+ }
12632
12904
  if (args[0] === "init-hooks") {
12633
12905
  await handleInitHooks(args.slice(1));
12634
12906
  process.exit(0);
package/dist/cli.mjs CHANGED
@@ -100,8 +100,12 @@ Commands:
100
100
  digest Generate a human-readable summary of agent activity
101
101
  receipts Show recent persisted signed receipts
102
102
  record Open a local, searchable view of your record in the browser
103
- claim Attest a signed, position-blind claim over your record (e.g. no egress)
104
- verify-claim Verify a claim attestation offline (signature + predicate + commitment)
103
+ claim Attest a signed, position-blind claim over your record (e.g. no egress,
104
+ no payment, every payment under a cap)
105
+ verify-claim Verify a claim attestation offline (signature + predicate + commitment
106
+ + the anchor sidecar and issuer identity when present)
107
+ anchor-record Checkpoint the record's Merkle root + count into the public log
108
+ (heartbeat-friendly: skips when unchanged; only hashes leave)
105
109
  bundle Export an offline-verifiable audit bundle
106
110
 
107
111
  Examples:
@@ -2273,25 +2277,28 @@ if(META.live){var poll=function(){fetch('/data',{cache:'no-store'}).then(functio
2273
2277
  async function handleClaim(argv) {
2274
2278
  const { readFileSync, existsSync, writeFileSync } = await import("fs");
2275
2279
  const { join } = await import("path");
2276
- const { buildClaim } = await import("./claim-3EBRPP5U.mjs");
2280
+ const { buildClaim } = await import("./claim-RIXFOQM7.mjs");
2277
2281
  let dir = process.cwd();
2278
2282
  const di = argv.indexOf("--dir");
2279
2283
  if (di !== -1 && argv[di + 1]) dir = argv[di + 1];
2280
2284
  let predicate = null;
2281
- const noIdx = argv.indexOf("--no"), onlyIdx = argv.indexOf("--only"), nvIdx = argv.indexOf("--no-verdict"), cvIdx = argv.indexOf("--count");
2285
+ const noIdx = argv.indexOf("--no"), onlyIdx = argv.indexOf("--only"), nvIdx = argv.indexOf("--no-verdict"), cvIdx = argv.indexOf("--count"), puIdx = argv.indexOf("--payment-under");
2282
2286
  if (noIdx !== -1 && argv[noIdx + 1]) predicate = { kind: "no_capability", capability: argv[noIdx + 1] };
2283
2287
  else if (onlyIdx !== -1 && argv[onlyIdx + 1]) predicate = { kind: "only_capabilities", capabilities: argv[onlyIdx + 1].split(",").map((s) => s.trim()).filter(Boolean) };
2284
2288
  else if (nvIdx !== -1 && argv[nvIdx + 1]) predicate = { kind: "no_verdict", verdict: argv[nvIdx + 1] };
2285
2289
  else if (cvIdx !== -1 && argv[cvIdx + 1]) predicate = { kind: "count_verdict", verdict: argv[cvIdx + 1] };
2290
+ else if (puIdx !== -1 && argv[puIdx + 1] && isFinite(parseFloat(argv[puIdx + 1]))) predicate = { kind: "payment_under", cap: parseFloat(argv[puIdx + 1]) };
2286
2291
  if (!predicate) {
2287
2292
  process.stderr.write(`
2288
2293
  ${bold("protect-mcp claim")}
2289
2294
 
2290
2295
  Attest a signed, position-blind claim over your record:
2291
- --no <capability> no action used it, e.g. ${dim("--no net.egress")}
2296
+ --no <capability> no action used it, e.g. ${dim("--no net.egress")} or ${dim("--no payment")}
2292
2297
  --only <c1,c2,...> all actions confined to these capabilities
2293
2298
  --no-verdict <verdict> e.g. ${dim("--no-verdict blocked")}
2294
2299
  --count <verdict> how many, e.g. ${dim("--count blocked")}
2300
+ --payment-under <cap> every agent payment stayed under the cap (amounts the
2301
+ gate could not read count as OVER, so this cannot lie)
2295
2302
  --anchor also record the claim digest in the public append-only
2296
2303
  log so a counterparty can trust it is complete (only the
2297
2304
  hash is sent; your record stays local)
@@ -2374,7 +2381,7 @@ ${bold("\u{1F6E1}\uFE0F Signed claim")}
2374
2381
  process.stdout.write(` Hand it to anyone. They verify offline: ${bold("npx protect-mcp verify-claim " + out)}
2375
2382
  `);
2376
2383
  if (argv.indexOf("--anchor") !== -1) {
2377
- const { anchorClaim } = await import("./claim-3EBRPP5U.mjs");
2384
+ const { anchorClaim } = await import("./claim-RIXFOQM7.mjs");
2378
2385
  const li = argv.indexOf("--log");
2379
2386
  const logBase = li !== -1 && argv[li + 1] ? argv[li + 1] : void 0;
2380
2387
  process.stdout.write(`
@@ -2394,8 +2401,18 @@ ${bold("\u{1F6E1}\uFE0F Signed claim")}
2394
2401
  `);
2395
2402
  process.stdout.write(` ${dim("Anchor record written to " + sidecar + ". Only the digest was sent; your record stayed local.")}
2396
2403
  `);
2397
- process.stdout.write(` ${dim("To anchor as your enrolled org identity (a key a counterparty can pin), see")} ${bold("scopeblind.com/enroll")}
2404
+ const { lookupPinnedIdentity } = await import("./claim-RIXFOQM7.mjs");
2405
+ const who = await lookupPinnedIdentity(key.publicKey, { log: logBase });
2406
+ if (who && who.found && !who.revoked) {
2407
+ process.stdout.write(` ${green("Identity:")} anchored as ${bold(who.name || who.slug || "enrolled org")} ${dim("(key pinned in the ScopeBlind directory" + (who.enrolled_at ? ", enrolled " + who.enrolled_at.slice(0, 10) : "") + ")")}
2398
2408
  `);
2409
+ } else if (who && who.found && who.revoked) {
2410
+ process.stdout.write(` ${red("Identity: this key is REVOKED in the ScopeBlind directory.")}
2411
+ `);
2412
+ } else {
2413
+ process.stdout.write(` ${dim("Identity: anonymous (key not enrolled). To anchor as a named org a counterparty can pin, see")} ${bold("scopeblind.com/enroll")}
2414
+ `);
2415
+ }
2399
2416
  } else {
2400
2417
  process.stdout.write(` ${yellow("Anchor skipped")} ${dim("(" + (res.error || "unavailable") + "). The claim above is complete and verifiable offline without it.")}
2401
2418
  `);
@@ -2405,9 +2422,135 @@ ${bold("\u{1F6E1}\uFE0F Signed claim")}
2405
2422
  `);
2406
2423
  process.exit(0);
2407
2424
  }
2425
+ async function handleAnchorRecord(argv) {
2426
+ const { readFileSync, existsSync, appendFileSync } = await import("fs");
2427
+ const { join } = await import("path");
2428
+ const { anchorRecordCheckpoint, buildRecordCheckpoint, lookupPinnedIdentity } = await import("./claim-RIXFOQM7.mjs");
2429
+ let dir = process.cwd();
2430
+ const di = argv.indexOf("--dir");
2431
+ if (di !== -1 && argv[di + 1]) dir = argv[di + 1];
2432
+ const li = argv.indexOf("--log");
2433
+ const logBase = li !== -1 && argv[li + 1] ? argv[li + 1] : void 0;
2434
+ const keyPath = join(dir, "keys", "gateway.json");
2435
+ if (!existsSync(keyPath)) {
2436
+ process.stderr.write(`
2437
+ ${bold("protect-mcp anchor-record")}
2438
+
2439
+ No signing key at ${keyPath}. A checkpoint must be signed. Run ${bold("npx protect-mcp init")} first.
2440
+
2441
+ `);
2442
+ process.exit(1);
2443
+ return;
2444
+ }
2445
+ let key;
2446
+ try {
2447
+ key = JSON.parse(readFileSync(keyPath, "utf-8"));
2448
+ } catch {
2449
+ process.stderr.write(`
2450
+ protect-mcp anchor-record: ${keyPath} is not valid JSON.
2451
+
2452
+ `);
2453
+ process.exit(1);
2454
+ return;
2455
+ }
2456
+ if (!key.privateKey || !key.publicKey) {
2457
+ process.stderr.write(`
2458
+ protect-mcp anchor-record: ${keyPath} is missing privateKey/publicKey.
2459
+
2460
+ `);
2461
+ process.exit(1);
2462
+ return;
2463
+ }
2464
+ const recPath = join(dir, ".protect-mcp-receipts.jsonl");
2465
+ if (!existsSync(recPath)) {
2466
+ process.stderr.write(`
2467
+ ${bold("protect-mcp anchor-record")}
2468
+
2469
+ No signed receipts in ${dir}. Run the gate with signing on, then try again.
2470
+
2471
+ `);
2472
+ process.exit(0);
2473
+ return;
2474
+ }
2475
+ const receipts = readFileSync(recPath, "utf-8").split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => {
2476
+ try {
2477
+ return JSON.parse(l);
2478
+ } catch {
2479
+ return null;
2480
+ }
2481
+ }).filter((x) => x !== null);
2482
+ if (!receipts.length) {
2483
+ process.stderr.write(`
2484
+ protect-mcp anchor-record: no readable receipts in ${recPath}.
2485
+
2486
+ `);
2487
+ process.exit(0);
2488
+ return;
2489
+ }
2490
+ const claimKey = { privateKey: key.privateKey, publicKey: key.publicKey, kid: key.kid || "gateway", issuer: "protect-mcp" };
2491
+ const historyPath = join(dir, ".protect-mcp-anchors.jsonl");
2492
+ const preview = buildRecordCheckpoint(receipts, claimKey, "preview");
2493
+ if (!argv.includes("--force") && existsSync(historyPath)) {
2494
+ const lines = readFileSync(historyPath, "utf-8").split(/\r?\n/).filter(Boolean);
2495
+ const last = lines.length ? (() => {
2496
+ try {
2497
+ return JSON.parse(lines[lines.length - 1]);
2498
+ } catch {
2499
+ return null;
2500
+ }
2501
+ })() : null;
2502
+ if (last && last.record_root === preview.record_root && last.total === preview.total) {
2503
+ process.stdout.write(`
2504
+ ${bold("\u{1F6E1}\uFE0F Record checkpoint")}
2505
+ `);
2506
+ process.stdout.write(` Unchanged since entry ${bold("#" + last.seq)} ${dim("(" + last.total + " receipts, anchored " + (last.anchored_at || "") + ")")}. Nothing new to anchor.
2507
+ `);
2508
+ process.stdout.write(` ${dim("Use --force to re-anchor anyway.")}
2509
+
2510
+ `);
2511
+ process.exit(0);
2512
+ return;
2513
+ }
2514
+ }
2515
+ const res = await anchorRecordCheckpoint(receipts, claimKey, { log: logBase, issuedAt: (/* @__PURE__ */ new Date()).toISOString() });
2516
+ process.stdout.write(`
2517
+ ${bold("\u{1F6E1}\uFE0F Record checkpoint")}
2518
+ `);
2519
+ process.stdout.write(` ${res.total} receipts ${dim("\xB7")} root ${dim(res.record_root.slice(0, 16) + "\u2026")} ${dim("(" + res.checkpoint.from.slice(0, 10) + " \u2192 " + res.checkpoint.to.slice(0, 10) + ")")}
2520
+ `);
2521
+ if (!res.ok) {
2522
+ process.stdout.write(` ${yellow("Anchor failed")} ${dim("(" + (res.error || "unavailable") + "). Nothing was recorded; try again.")}
2523
+
2524
+ `);
2525
+ process.exit(1);
2526
+ return;
2527
+ }
2528
+ appendFileSync(historyPath, JSON.stringify({ schema: res.checkpoint.schema, seq: res.seq, anchored_at: res.anchored_at, total: res.total, record_root: res.record_root, entry_url: res.entry_url, digest: res.checkpoint.digest }) + "\n");
2529
+ process.stdout.write(` ${green("Anchored")} as log entry ${bold("#" + res.seq)} ${dim(res.entry_url || "")}
2530
+ `);
2531
+ process.stdout.write(` ${dim("Only the root, count, and time range were sent. History: " + historyPath)}
2532
+ `);
2533
+ const who = await lookupPinnedIdentity(claimKey.publicKey, { log: logBase });
2534
+ if (who && who.found && !who.revoked) {
2535
+ process.stdout.write(` ${green("Identity:")} anchored as ${bold(who.name || who.slug || "enrolled org")} ${dim("(key pinned in the ScopeBlind directory)")}
2536
+ `);
2537
+ } else if (who && who.found && who.revoked) {
2538
+ process.stdout.write(` ${red("Identity: this key is REVOKED in the ScopeBlind directory.")}
2539
+ `);
2540
+ } else {
2541
+ process.stdout.write(` ${dim("Identity: anonymous (key not enrolled). Named identity: scopeblind.com/enroll")}
2542
+ `);
2543
+ }
2544
+ process.stdout.write(` ${dim("A claim whose commitment matches this root is provably over the complete record as of")}
2545
+ `);
2546
+ process.stdout.write(` ${dim("this checkpoint. Run this on a heartbeat (e.g. cron) to keep the anchored history growing.")}
2547
+
2548
+ `);
2549
+ process.exit(0);
2550
+ }
2408
2551
  async function handleVerifyClaim(argv) {
2409
2552
  const { readFileSync, existsSync } = await import("fs");
2410
- const { verifyClaim } = await import("./claim-3EBRPP5U.mjs");
2553
+ const { verifyClaim } = await import("./claim-RIXFOQM7.mjs");
2411
2554
  const file = argv.find((a) => !a.startsWith("--"));
2412
2555
  if (!file || !existsSync(file)) {
2413
2556
  process.stderr.write(`
@@ -2459,7 +2602,7 @@ ${bold("protect-mcp verify-claim")}
2459
2602
  const requireAnchor = argv.includes("--check-anchor");
2460
2603
  let anchorOk = true;
2461
2604
  if (existsSync(sidecarPath)) {
2462
- const { checkClaimAnchor } = await import("./claim-3EBRPP5U.mjs");
2605
+ const { checkClaimAnchor } = await import("./claim-RIXFOQM7.mjs");
2463
2606
  let sidecar = null;
2464
2607
  try {
2465
2608
  sidecar = JSON.parse(readFileSync(sidecarPath, "utf-8"));
@@ -2491,6 +2634,21 @@ ${bold("protect-mcp verify-claim")}
2491
2634
  process.stdout.write(` ${yellow("~")} log not checked ${dim(argv.includes("--offline") ? "(--offline)" : "(unreachable; local binding checks stand)")}
2492
2635
  `);
2493
2636
  }
2637
+ if (!argv.includes("--offline") && sidecar.envelope) {
2638
+ const { lookupPinnedIdentity } = await import("./claim-RIXFOQM7.mjs");
2639
+ const who = await lookupPinnedIdentity(sidecar.envelope.verification_key, {});
2640
+ if (who && who.found && !who.revoked) {
2641
+ process.stdout.write(` ${green("\u2713")} issuer key pinned to ${bold(who.name || who.slug || "an enrolled org")} ${dim("(ScopeBlind key directory)")}
2642
+ `);
2643
+ } else if (who && who.found && who.revoked) {
2644
+ anchorOk = false;
2645
+ process.stdout.write(` ${red("\u2717")} issuer key is REVOKED in the ScopeBlind key directory
2646
+ `);
2647
+ } else if (who && !who.found) {
2648
+ process.stdout.write(` ${dim("issuer key not enrolled (anonymous issuer); named identities pin via scopeblind.com/enroll")}
2649
+ `);
2650
+ }
2651
+ }
2494
2652
  }
2495
2653
  } else if (requireAnchor) {
2496
2654
  anchorOk = false;
@@ -4018,6 +4176,10 @@ async function main() {
4018
4176
  await handleVerifyClaim(args.slice(1));
4019
4177
  return;
4020
4178
  }
4179
+ if (args[0] === "anchor-record") {
4180
+ await handleAnchorRecord(args.slice(1));
4181
+ return;
4182
+ }
4021
4183
  if (args[0] === "init-hooks") {
4022
4184
  await handleInitHooks(args.slice(1));
4023
4185
  process.exit(0);
@@ -1082,7 +1082,11 @@ var RULES = [
1082
1082
  { 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/ },
1083
1083
  { 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/ },
1084
1084
  { cap: "financial", text: /\b(order|trade|buy|sell|transfer|wire|payment|withdraw|deposit|swap|invoice|charge|refund|settle)\b/ },
1085
- { cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ }
1085
+ { cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ },
1086
+ // Agent payments (x402 / value transfer). Deliberately BROAD: a false positive
1087
+ // only makes a `claim --no payment` harder to assert (conservative); a false
1088
+ // negative would let a real payment escape the record's payment claims.
1089
+ { 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/ }
1086
1090
  ];
1087
1091
  function deriveCapabilities(tool, input) {
1088
1092
  const t = String(tool || "").toLowerCase();
@@ -1116,6 +1120,33 @@ function deriveResource(input) {
1116
1120
  }
1117
1121
  return void 0;
1118
1122
  }
1123
+ function findField(input, names, depth = 0) {
1124
+ if (depth > 4 || input === null || typeof input !== "object") return void 0;
1125
+ const o = input;
1126
+ const keys = Object.keys(o).sort();
1127
+ for (const k of keys) {
1128
+ if (names.indexOf(k.toLowerCase()) >= 0 && o[k] !== void 0 && o[k] !== null) return o[k];
1129
+ }
1130
+ for (const k of keys) {
1131
+ const v = findField(o[k], names, depth + 1);
1132
+ if (v !== void 0) return v;
1133
+ }
1134
+ return void 0;
1135
+ }
1136
+ function derivePayment(tool, input) {
1137
+ if (deriveCapabilities(tool, input).indexOf("payment") < 0) return void 0;
1138
+ const p = { amount: null, asset: null, recipient_digest: null };
1139
+ const amt = findField(input, ["amount"]);
1140
+ if (typeof amt === "number" && Number.isFinite(amt) && amt >= 0) p.amount = amt;
1141
+ else if (typeof amt === "string" && /^\d{1,15}(\.\d{1,18})?$/.test(amt.trim()) && amt.indexOf(".") >= 0) p.amount = parseFloat(amt);
1142
+ const asset = findField(input, ["asset", "currency", "token"]);
1143
+ if (typeof asset === "string" && asset.trim()) p.asset = asset.trim().slice(0, 64);
1144
+ const to = findField(input, ["payto", "pay_to", "recipient", "destination", "to"]);
1145
+ if (typeof to === "string" && to.trim()) p.recipient_digest = sha256Hex(to.trim().toLowerCase());
1146
+ const scheme = findField(input, ["scheme"]);
1147
+ if (typeof scheme === "string" && scheme.trim()) p.scheme = scheme.trim().slice(0, 32);
1148
+ return p;
1149
+ }
1119
1150
  function buildEnrichment(tool, input) {
1120
1151
  const e = {
1121
1152
  v: ENRICHMENT_VERSION,
@@ -1124,6 +1155,8 @@ function buildEnrichment(tool, input) {
1124
1155
  };
1125
1156
  const resource = deriveResource(input);
1126
1157
  if (resource) e.resource = resource;
1158
+ const payment = derivePayment(tool, input);
1159
+ if (payment) e.payment = payment;
1127
1160
  return e;
1128
1161
  }
1129
1162
 
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  startHookServer
3
- } from "./chunk-DX7QOA3E.mjs";
4
- import "./chunk-HPKHMVZX.mjs";
3
+ } from "./chunk-H332ZNJ6.mjs";
4
+ import "./chunk-WWPQNIVF.mjs";
5
5
  import "./chunk-AYNQIEN7.mjs";
6
6
  import "./chunk-XLJUZ4WO.mjs";
7
7
  import "./chunk-JIDDQUSQ.mjs";
package/dist/index.js CHANGED
@@ -40405,7 +40405,11 @@ var RULES = [
40405
40405
  { 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/ },
40406
40406
  { 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/ },
40407
40407
  { cap: "financial", text: /\b(order|trade|buy|sell|transfer|wire|payment|withdraw|deposit|swap|invoice|charge|refund|settle)\b/ },
40408
- { cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ }
40408
+ { cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ },
40409
+ // Agent payments (x402 / value transfer). Deliberately BROAD: a false positive
40410
+ // only makes a `claim --no payment` harder to assert (conservative); a false
40411
+ // negative would let a real payment escape the record's payment claims.
40412
+ { 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/ }
40409
40413
  ];
40410
40414
  function deriveCapabilities(tool, input) {
40411
40415
  const t = String(tool || "").toLowerCase();
@@ -40439,6 +40443,33 @@ function deriveResource(input) {
40439
40443
  }
40440
40444
  return void 0;
40441
40445
  }
40446
+ function findField(input, names, depth = 0) {
40447
+ if (depth > 4 || input === null || typeof input !== "object") return void 0;
40448
+ const o = input;
40449
+ const keys = Object.keys(o).sort();
40450
+ for (const k of keys) {
40451
+ if (names.indexOf(k.toLowerCase()) >= 0 && o[k] !== void 0 && o[k] !== null) return o[k];
40452
+ }
40453
+ for (const k of keys) {
40454
+ const v = findField(o[k], names, depth + 1);
40455
+ if (v !== void 0) return v;
40456
+ }
40457
+ return void 0;
40458
+ }
40459
+ function derivePayment(tool, input) {
40460
+ if (deriveCapabilities(tool, input).indexOf("payment") < 0) return void 0;
40461
+ const p = { amount: null, asset: null, recipient_digest: null };
40462
+ const amt = findField(input, ["amount"]);
40463
+ if (typeof amt === "number" && Number.isFinite(amt) && amt >= 0) p.amount = amt;
40464
+ else if (typeof amt === "string" && /^\d{1,15}(\.\d{1,18})?$/.test(amt.trim()) && amt.indexOf(".") >= 0) p.amount = parseFloat(amt);
40465
+ const asset = findField(input, ["asset", "currency", "token"]);
40466
+ if (typeof asset === "string" && asset.trim()) p.asset = asset.trim().slice(0, 64);
40467
+ const to = findField(input, ["payto", "pay_to", "recipient", "destination", "to"]);
40468
+ if (typeof to === "string" && to.trim()) p.recipient_digest = sha256Hex(to.trim().toLowerCase());
40469
+ const scheme = findField(input, ["scheme"]);
40470
+ if (typeof scheme === "string" && scheme.trim()) p.scheme = scheme.trim().slice(0, 32);
40471
+ return p;
40472
+ }
40442
40473
  function buildEnrichment(tool, input) {
40443
40474
  const e = {
40444
40475
  v: ENRICHMENT_VERSION,
@@ -40447,6 +40478,8 @@ function buildEnrichment(tool, input) {
40447
40478
  };
40448
40479
  const resource = deriveResource(input);
40449
40480
  if (resource) e.resource = resource;
40481
+ const payment = derivePayment(tool, input);
40482
+ if (payment) e.payment = payment;
40450
40483
  return e;
40451
40484
  }
40452
40485
 
package/dist/index.mjs CHANGED
@@ -54,8 +54,8 @@ import {
54
54
  forwardReceipt,
55
55
  getScopeBlindBridge,
56
56
  startHookServer
57
- } from "./chunk-DX7QOA3E.mjs";
58
- import "./chunk-HPKHMVZX.mjs";
57
+ } from "./chunk-H332ZNJ6.mjs";
58
+ import "./chunk-WWPQNIVF.mjs";
59
59
  import {
60
60
  sha256 as sha2562
61
61
  } from "./chunk-AYNQIEN7.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protect-mcp",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "mcpName": "com.scopeblind/protect-mcp",
5
5
  "description": "Fail-closed Cedar policy gate + Ed25519 signed receipts for AI agent tool calls. Denies on any policy error, proves the gate is live with a startup self-test, and turns every decision into a local searchable record you own. The open gate behind Legate by ScopeBlind. scopeblind.com",
6
6
  "main": "dist/index.js",